From 0e2e1265bbff798a010f084cb7a849b6b429fa88 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:52:03 +0000 Subject: [PATCH 01/22] fix(cli): push oauth server enabled and path together (CLI-2691) (#7367) ## TL;DR fixes `config push` failing when the oauth server path changes or the server is turned on or off ## whats improved/fixed? the api only accepts `oauth_server_enabled` and `oauth_server_authorization_path` together, but `config push` sent just the changed one ## how? send the missing one too, using the linked project's current value (so enabling keeps a stored custom path), else the config value or default ## ref: - closes: https://github.com/supabase/cli/issues/7087 --- .../src/commands/config/push/SIDE_EFFECTS.md | 3 +- .../src/commands/config/push/push.encoders.ts | 56 +++-- .../config/push/push.encoders.unit.test.ts | 40 ++++ .../src/commands/config/push/push.handler.ts | 1 + .../config/push/push.integration.test.ts | 191 +++++++++++++++++- 5 files changed, 272 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index f88703be93..6c690a64c5 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -78,7 +78,8 @@ for a handful of groups the target endpoint requires together even when only one member changed — the network-restriction CIDR pair, each storage feature container (`image_transformation`, `s3_protocol`, `analytics`, `vector`), the SMTP key set (or just `smtp_host: ""` to disable it), captcha, each auth -hook, each OAuth provider, and the currently-active SMS provider. An +hook, each OAuth provider, the OAuth server's `enabled` and +`authorization_url_path` pair, and the currently-active SMS provider. An undeclared member of one of these groups is sent with the project's CURRENT value — read from the same row-1 response, so it does not change — never a value your file doesn't declare. Only when that response did not report a diff --git a/apps/cli/src/commands/config/push/push.encoders.ts b/apps/cli/src/commands/config/push/push.encoders.ts index ac2158ea44..a1e8dfc273 100644 --- a/apps/cli/src/commands/config/push/push.encoders.ts +++ b/apps/cli/src/commands/config/push/push.encoders.ts @@ -9,8 +9,8 @@ * always prefers the project's current (`remote`) value over a schema-materialized local * default, so an undeclared companion never gets clobbered with a default the user never asked * for. Only when `remote` doesn't report a companion at all does resolution fall through to - * `local` (and, for the storage feature containers only, the decoded `config`) — and that - * fallback is disclosed via `forced`, never applied silently. + * `local` (and, for the storage feature containers and the OAuth server pair only, the decoded + * `config`) — and that fallback is disclosed via `forced`, never applied silently. */ import type { CliConfig, ConfigChange, ProjectConfig } from "@supabase/config"; @@ -53,7 +53,8 @@ export interface PushEncoderInput { /** * The storage encoder's own input: adds the decoded `config` as the last resort for the * `storage.analytics`/`storage.vector` containers' required inner keys, which the local - * projection prunes entirely once disabled. No other encoder needs this fourth tier. + * projection prunes entirely once disabled. Only the auth encoder's OAuth server pair also + * needs this fourth tier. */ export interface StorageEncoderInput extends PushEncoderInput { readonly config: CliConfig; @@ -125,8 +126,11 @@ export interface AuthEncoderInput extends PushEncoderInput { readonly secrets: ReadonlyArray; /** HTML bodies loaded from `content_path`, for the unmapped mailer `*_content` keys. */ readonly emailContent: AuthEmailContent; - /** Raw `data.attributes.auth` — for comparing the 13 unmapped mailer `*_content` keys. */ + /** Raw `data.attributes.auth` — for comparing the 13 unmapped mailer `*_content` keys, and + * for the OAuth server's stored path that `remote` prunes while disabled. */ readonly remoteAuthAttributes: Readonly>; + /** Decoded config — the OAuth server pair's last fallback when the projection pruned it. */ + readonly config: CliConfig; /** Clock value for `sms_test_otp_valid_until` (+10 calendar years). */ readonly now: DateTime.Utc; } @@ -201,12 +205,11 @@ function resolveLeaf( } /** - * {@link resolveLeaf}, with a fourth fallback tier for the storage feature - * containers only: the decoded `config`'s schema-materialized value, for the - * `max_*` keys the local projection prunes entirely once a feature is - * disabled. + * {@link resolveLeaf}, with a fourth fallback tier: the decoded `config`'s + * schema-materialized value, for keys the local projection prunes entirely + * once a feature is disabled. */ -function resolveStorageFeatureLeaf( +function resolvePrunedLeaf( changes: ReadonlyArray, path: ReadonlyArray, remote: ProjectConfig, @@ -642,28 +645,28 @@ function encodeStorageBodyImpl(input: StorageEncoderInput): PushEncoded 0) { const analyticsPaths = analyticsChanges.map((change) => change.path); - const enabledR = resolveStorageFeatureLeaf( + const enabledR = resolvePrunedLeaf( changes, ["storage", "analytics", "enabled"], remote, local, config, ); - const maxNamespacesR = resolveStorageFeatureLeaf( + const maxNamespacesR = resolvePrunedLeaf( changes, ["storage", "analytics", "max_namespaces"], remote, local, config, ); - const maxTablesR = resolveStorageFeatureLeaf( + const maxTablesR = resolvePrunedLeaf( changes, ["storage", "analytics", "max_tables"], remote, local, config, ); - const maxCatalogsR = resolveStorageFeatureLeaf( + const maxCatalogsR = resolvePrunedLeaf( changes, ["storage", "analytics", "max_catalogs"], remote, @@ -697,21 +700,21 @@ function encodeStorageBodyImpl(input: StorageEncoderInput): PushEncoded 0) { const vectorPaths = vectorChanges.map((change) => change.path); - const enabledR = resolveStorageFeatureLeaf( + const enabledR = resolvePrunedLeaf( changes, ["storage", "vector", "enabled"], remote, local, config, ); - const maxBucketsR = resolveStorageFeatureLeaf( + const maxBucketsR = resolvePrunedLeaf( changes, ["storage", "vector", "max_buckets"], remote, local, config, ); - const maxIndexesR = resolveStorageFeatureLeaf( + const maxIndexesR = resolvePrunedLeaf( changes, ["storage", "vector", "max_indexes"], remote, @@ -1373,7 +1376,8 @@ export const PUSH_AUTH_LEAF_MAP: ReadonlyArray = [ function encodeAuthBodyImpl( input: AuthEncoderInput, ): PushEncoded>> { - const { changes, local, remote, secrets, emailContent, remoteAuthAttributes, now } = input; + const { changes, local, remote, secrets, emailContent, remoteAuthAttributes, config, now } = + input; const body: Record = {}; const encoded: Array> = []; const unencodable: Array<{ path: ReadonlyArray; reason: string }> = []; @@ -1388,6 +1392,24 @@ function encodeAuthBodyImpl( for (const spec of PUSH_AUTH_LEAF_MAP) { leaf(spec.configPath, spec.apiKey, spec.transform, spec.reason); } + /* The API takes `oauth_server_enabled` and `oauth_server_authorization_path` only together, so + when one of them ships the other rides along with its resolved value. */ + const oauthPair = PUSH_AUTH_LEAF_MAP.filter( + (spec) => + spec.apiKey === "oauth_server_enabled" || spec.apiKey === "oauth_server_authorization_path", + ); + const oauthSent = oauthPair.find((spec) => spec.apiKey in body); + const oauthUnsent = oauthPair.find((spec) => !(spec.apiKey in body)); + if (oauthSent !== undefined && oauthUnsent !== undefined) { + /* `remote` prunes a disabled server's stored path, so the raw attribute is read first. */ + const stored = oauthUnsent.transform(remoteAuthAttributes[oauthUnsent.apiKey]); + const resolved: LeafResolution = + stored === undefined + ? resolvePrunedLeaf(changes, oauthUnsent.configPath, remote, local, config) + : { value: stored, source: "remote" }; + body[oauthUnsent.apiKey] = resolved.value; + pushForced(forced, oauthUnsent.configPath, resolved); + } // smtp (container) const smtpChanges = changesUnderPrefix(changes, ["auth", "email", "smtp"]); diff --git a/apps/cli/src/commands/config/push/push.encoders.unit.test.ts b/apps/cli/src/commands/config/push/push.encoders.unit.test.ts index 75d775c235..a74fdd3ff1 100644 --- a/apps/cli/src/commands/config/push/push.encoders.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.encoders.unit.test.ts @@ -60,6 +60,7 @@ function authInput(overrides: Partial = {}): AuthEncoderInput secrets: [], emailContent: EMPTY_EMAIL_CONTENT, remoteAuthAttributes: {}, + config: getDefaultCliConfig(), now: DateTime.makeUnsafe("2030-01-01T00:00:00.000Z"), ...overrides, }; @@ -939,6 +940,45 @@ describe("encodeAuthBody", () => { }); }); + describe("oauth server enabled + authorization path pair", () => { + it("a path-only change against a project that doesn't report enabled sends the local flag and discloses it", () => { + const result = encodeAuthBody( + authInput({ + changes: [change(["auth", "oauth_server", "authorization_url_path"], "/new/consent")], + remote: { auth: { oauth_server: { authorization_url_path: "/oauth/consent" } } }, + local: { + auth: { oauth_server: { enabled: true, authorization_url_path: "/new/consent" } }, + }, + }), + ); + expect(result.body).toEqual({ + oauth_server_enabled: true, + oauth_server_authorization_path: "/new/consent", + }); + expect(result.forced).toEqual([{ path: ["auth", "oauth_server", "enabled"], value: true }]); + }); + + it("a disable on a project with no stored path sends the config's path and discloses it", () => { + const result = encodeAuthBody( + authInput({ + changes: [change(["auth", "oauth_server", "enabled"], false, true)], + remote: { auth: { oauth_server: { enabled: true } } }, + remoteAuthAttributes: { + oauth_server_enabled: true, + oauth_server_authorization_path: null, + }, + }), + ); + expect(result.body).toEqual({ + oauth_server_enabled: false, + oauth_server_authorization_path: "/oauth/consent", + }); + expect(result.forced).toEqual([ + { path: ["auth", "oauth_server", "authorization_url_path"], value: "/oauth/consent" }, + ]); + }); + }); + describe("external provider containers", () => { it("disabled → only external__enabled: false", () => { const result = encodeAuthBody( diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index f957c19be2..d4ff183d8c 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -619,6 +619,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF secrets, emailContent: authEmailContent, remoteAuthAttributes, + config, now, }); const result = yield* applyResource("auth", changes, encoded, secrets, (body) => diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index 43e036e71c..573d91fc7b 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -1359,7 +1359,10 @@ otp_expiry = 120 (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); expect(update).toBeDefined(); - expect(update?.body).toEqual({ oauth_server_enabled: true }); + expect(update?.body).toEqual({ + oauth_server_enabled: true, + oauth_server_authorization_path: "/oauth/consent", + }); expect(out.stderrText).not.toContain("has no Management API field"); const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); @@ -1369,6 +1372,192 @@ otp_expiry = 120 }, ); + it.effect( + "disabling auth.oauth_server also sends the project's current authorization path the API requires with it", + () => { + const { layer, api } = setup({ + toml: `project_id = "test"\n[auth.oauth_server]\nenabled = false\n`, + format: "json", + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + auth: { + ...asJsonRecord(a["auth"]), + oauth_server_enabled: true, + oauth_server_authorization_path: "/custom/consent", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const update = api.requests.find( + (r) => r.method === "PATCH" && r.url.includes("/config/auth"), + ); + expect(update?.body).toEqual({ + oauth_server_enabled: false, + oauth_server_authorization_path: "/custom/consent", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "disabling auth.oauth_server on a project with no stored path sends the declared path and discloses it", + () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n[auth.oauth_server]\nenabled = false\nauthorization_url_path = "/my/consent"\n`, + format: "json", + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + auth: { + ...asJsonRecord(a["auth"]), + oauth_server_enabled: true, + oauth_server_authorization_path: null, + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const update = api.requests.find( + (r) => r.method === "PATCH" && r.url.includes("/config/auth"), + ); + expect(update?.body).toEqual({ + oauth_server_enabled: false, + oauth_server_authorization_path: "/my/consent", + }); + const success = out.messages.find((m) => m.type === "success"); + const data = yield* decodeJsonRecord(success?.data); + expect(data["forced"]).toEqual([ + { path: ["auth", "oauth_server", "authorization_url_path"], value: "/my/consent" }, + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "enabling auth.oauth_server keeps the custom authorization path the disabled project still stores", + () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n[auth.oauth_server]\nenabled = true\n`, + format: "json", + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + auth: { + ...asJsonRecord(a["auth"]), + oauth_server_enabled: false, + oauth_server_authorization_path: "/custom/consent", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const update = api.requests.find( + (r) => r.method === "PATCH" && r.url.includes("/config/auth"), + ); + expect(update?.body).toEqual({ + oauth_server_enabled: true, + oauth_server_authorization_path: "/custom/consent", + }); + const success = out.messages.find((m) => m.type === "success"); + const data = yield* decodeJsonRecord(success?.data); + expect(data["forced"]).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "enabling auth.oauth_server on a project with no authorization path sends the default path and discloses it", + () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n[auth.oauth_server]\nenabled = true\n`, + format: "json", + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + auth: { + ...asJsonRecord(a["auth"]), + oauth_server_enabled: false, + oauth_server_authorization_path: null, + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const update = api.requests.find( + (r) => r.method === "PATCH" && r.url.includes("/config/auth"), + ); + expect(update?.body).toEqual({ + oauth_server_enabled: true, + oauth_server_authorization_path: "/oauth/consent", + }); + const success = out.messages.find((m) => m.type === "success"); + const data = yield* decodeJsonRecord(success?.data); + expect(data["forced"]).toEqual([ + { path: ["auth", "oauth_server", "authorization_url_path"], value: "/oauth/consent" }, + ]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "changing only auth.oauth_server.authorization_url_path also sends the enabled flag the API requires with it", + () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n[auth.oauth_server]\nenabled = true\nauthorization_url_path = "/new/consent"\n`, + format: "json", + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + auth: { + ...asJsonRecord(a["auth"]), + oauth_server_enabled: true, + oauth_server_authorization_path: "/oauth/consent", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const update = api.requests.find( + (r) => r.method === "PATCH" && r.url.includes("/config/auth"), + ); + expect(update?.body).toEqual({ + oauth_server_enabled: true, + oauth_server_authorization_path: "/new/consent", + }); + const success = out.messages.find((m) => m.type === "success"); + const data = yield* decodeJsonRecord(success?.data); + expect(data["forced"]).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("reports the remote-only count when nothing pushable exists anywhere", () => { const { layer, out, api } = setup({ toml: `project_id = "test"\n`, From c72507449430ab55cb612295941b49e3eca95a14 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:40:26 +0000 Subject: [PATCH 02/22] chore(stack): bump storage to v1.84.34-r0 (#7373) This bumps storage from v1.84.33-r0 to v1.84.34-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.84.34-r0 Planned after supabase/slim-services published v1.84.34-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index e16164a315..98f1352f50 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.149.1 AS realtime -FROM supabase/storage-api:v1.84.33 AS storage +FROM supabase/storage-api:v1.84.34 AS storage FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index e16164a315..98f1352f50 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.149.1 AS realtime -FROM supabase/storage-api:v1.84.33 AS storage +FROM supabase/storage-api:v1.84.34 AS storage FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 7bd75f56ae..622e78124e 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -343,23 +343,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.84.33", + upstreamVersion: "v1.84.34", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.84.33-r0@sha256:7f43434eca2449d01fb18c20588c443ecac5b091ba321043c849593ab5e70915", - upstreamImage: "supabase/storage-api:v1.84.33", + "ghcr.io/supabase/cli/storage:v1.84.34-r0@sha256:56ead3e65081a0584c934553777e091964e27b7e3fef192c522f08b969bd2588", + upstreamImage: "supabase/storage-api:v1.84.34", natives: { "darwin-arm64": { - archive: "98e5846d8b98b97161f2af4fca060964b7250b5d0d7b71b560ea08ea8a730309", - manifest: "55625a992aa413be1e30d2bf8825913c812e2edf333b83e860c800f879f8b7b2", + archive: "65e35bc7c64297aed0bed8210cbe472cf608395ea72df3fd4096543aa5d0c701", + manifest: "68864b9db3a487ed7ce521d4d9c52c68bd7c3ebc997ade6e3c7a642e39b3c98e", }, "linux-amd64": { - archive: "b3c41fd8fc12fd8680ca76aa082f3539ba9cdfc2ccbbaa4651a3a4ef3e2e6af7", - manifest: "51e91a6db77fbf7f9f2c22de8d8c0a35bfb0462de06d131e0d143d04fad88ea3", + archive: "22180ed8faffdc3f68608d2d6df445ff243f7a0a5dcdab1d68cc60b7bc724594", + manifest: "58071c14b6a92e180d73a5a986b05ccc4d2199d115a66095f857250ec22cc9a4", }, "linux-arm64": { - archive: "34143d6934f6371b09d41b8d1c6974959afc71aaeeb782075c3054dfbd2f860f", - manifest: "8cc3e2ef7498db11b40954e6f69ea0b0ac62eebcfc7dbe1fea75af916684c5fe", + archive: "d1ef41a826a7d5b1241e4c323ca270b60986455619e8ac45e7c834ba6cf13973", + manifest: "56e7d7e84446de91554700097b57394b29ff57cd7401736e2482a0ed1d0ab6df", }, }, }, From 7916c9831617f86a863716bc43bd33516ece1ddb Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:48:20 +0000 Subject: [PATCH 03/22] chore(stack): bump postgres to 17.11.0.008-r0 (#7375) This bumps postgres from 17.11.0.007-r0 to 17.11.0.008-r0. https://github.com/supabase/slim-services/releases/tag/postgres-17.11.0.008-r0 Planned after supabase/slim-services published 17.11.0.008-orioledb-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 98f1352f50..21375cc964 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -5,7 +5,7 @@ # a catalog update. Everything else in this file (this comment, kong, pg14, the one-shot job # images) is hand- or Dependabot-managed. Keep a line between image lines: git conflicts on edits to # adjacent lines, and catalog PRs for neighbouring aliases are often open at the same time. -FROM supabase/postgres:17.11.0.007 AS pg +FROM supabase/postgres:17.11.0.008 AS pg FROM supabase/postgres:15.19.0.008 AS pg15 # Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 98f1352f50..21375cc964 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -5,7 +5,7 @@ # a catalog update. Everything else in this file (this comment, kong, pg14, the one-shot job # images) is hand- or Dependabot-managed. Keep a line between image lines: git conflicts on edits to # adjacent lines, and catalog PRs for neighbouring aliases are often open at the same time. -FROM supabase/postgres:17.11.0.007 AS pg +FROM supabase/postgres:17.11.0.008 AS pg FROM supabase/postgres:15.19.0.008 AS pg15 # Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 622e78124e..8030663ca5 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -127,23 +127,23 @@ const definitions: Readonly> = { database: definition( "postgres", { - upstreamVersion: "17.11.0.007", + upstreamVersion: "17.11.0.008", revision: 0, image: - "ghcr.io/supabase/cli/postgres:17.11.0.007-r0@sha256:320b6fad223dfb8ff40a1893936232d34cd47c0f4f807e79741017ac3c1974a2", - upstreamImage: "supabase/postgres:17.11.0.007", + "ghcr.io/supabase/cli/postgres:17.11.0.008-r0@sha256:c392725424414f5c3487b44042d97adceed262b200fb89c5ce011b7a533556a2", + upstreamImage: "supabase/postgres:17.11.0.008", natives: { "darwin-arm64": { - archive: "ad0f293a750ea2e2d0c4f2c342a703d923fa1d3da3b1e1427722f760f2520cbc", - manifest: "e3998a69465ccc774d5a938b48d35b4e720f51557cab69358228502c4e1093fc", + archive: "de01b042eb3da72a3bce4b00b8f07e4b22545d9a2aad83d5b312918eadb39f44", + manifest: "39dea156823ccc497518ebef8954018c7b6c0956b313980c4d20aeab0aae55ed", }, "linux-amd64": { - archive: "0496408934df764c3a90a83aa49599cb374eab638f85cc8a5d45f07e965d0812", - manifest: "789d555736087fb7699c75c9047272e41021f9813ecc47d453c0ab59d4bc0ce3", + archive: "e61c16149f9c24cabbd2fe5b1318b1b92351cc4c175a2d27207fc18e4c4d7cac", + manifest: "5d8d54557d949272817d50314f26cd03ab0a2c6e4c8f88b2a9d5314a3148af6b", }, "linux-arm64": { - archive: "b16c21eb7b86aee84ceadc0834f4639de851a0b56244984057a1d299fb54583c", - manifest: "6e91bb3eba59bf5e9c31b6a5ce66d66a1cbaa87f3bbfc41542e0c540c2d6303d", + archive: "abd73beb155fbebba4fe7c6cb0ef927236de144f21740a157af1d57d34dce221", + manifest: "b9ffb160dccce6c207d722c498fc4dde174ea7e678b3ba156fe9c2607036d07f", }, }, }, From 5d5f5265375a1a051b52ee9f9b76a95ef5f45468 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:49:47 +0000 Subject: [PATCH 04/22] chore(stack): add postgres 17.11.0.008-orioledb-r0 (#7376) This adds postgres 17.11.0.008-orioledb-r0 as a new release line. https://github.com/supabase/slim-services/releases/tag/postgres-17.11.0.008-orioledb-r0 Planned after supabase/slim-services published 17.11.0.008-orioledb-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- packages/stack/src/Artifacts.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 8030663ca5..b94aae474d 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -262,6 +262,27 @@ const definitions: Readonly> = { }, }, }, + "17.11.0.008-orioledb": { + upstreamVersion: "17.11.0.008-orioledb", + revision: 0, + image: + "ghcr.io/supabase/cli/postgres:17.11.0.008-orioledb-r0@sha256:99d6197f871836ca0db2780d6394a3e906581a9afacc135a4aed64ad2440e3d3", + upstreamImage: "supabase/postgres:17.11.0.008-orioledb", + natives: { + "darwin-arm64": { + archive: "2d226fa1638651e93f2b093d1db29385b9994370e3600441202fe0916cf0f83a", + manifest: "27bf40f038982607558233465e8c582d58620d2d7c22cf2efa32de086a34aa84", + }, + "linux-amd64": { + archive: "807f8e70001ce2c7d770e2e93187cd5b88d00351d2f0e69aecc7ed994ebfb662", + manifest: "f1fa6e01e1230b66bad4e3b4d097b0ad9802a93e97b102a6114f1c04e0f7e024", + }, + "linux-arm64": { + archive: "5dae0486dc909c0831b73b29edd8bfe84904781796d5cfd9ca5d4b0946e6f741", + manifest: "21287142ea7a0df316ae83acf796e76704e53d0db254dad8a43cab0897346d02", + }, + }, + }, }, ), rest: definition( From 61c822dc2d279a9eae9ee9bfb67f2370fd82328d Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:36:47 +0000 Subject: [PATCH 05/22] chore(stack): bump storage to v1.84.35-r0 (#7381) This bumps storage from v1.84.34-r0 to v1.84.35-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.84.35-r0 Planned after supabase/slim-services published v1.84.35-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 21375cc964..25f4c990e3 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.149.1 AS realtime -FROM supabase/storage-api:v1.84.34 AS storage +FROM supabase/storage-api:v1.84.35 AS storage FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 21375cc964..25f4c990e3 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.149.1 AS realtime -FROM supabase/storage-api:v1.84.34 AS storage +FROM supabase/storage-api:v1.84.35 AS storage FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index b94aae474d..4f0f6a1230 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -364,23 +364,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.84.34", + upstreamVersion: "v1.84.35", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.84.34-r0@sha256:56ead3e65081a0584c934553777e091964e27b7e3fef192c522f08b969bd2588", - upstreamImage: "supabase/storage-api:v1.84.34", + "ghcr.io/supabase/cli/storage:v1.84.35-r0@sha256:118663bdc687f79b03faa6db839d73229e09c9b562ee34bfadfe1a7e817b4eb3", + upstreamImage: "supabase/storage-api:v1.84.35", natives: { "darwin-arm64": { - archive: "65e35bc7c64297aed0bed8210cbe472cf608395ea72df3fd4096543aa5d0c701", - manifest: "68864b9db3a487ed7ce521d4d9c52c68bd7c3ebc997ade6e3c7a642e39b3c98e", + archive: "d5d5638a8b8e9f3118a8d3c80322b71a9be6a3d05b466dc8d434fc92a7a4e3f0", + manifest: "2daaa90e911502dc660e308d1da164ba68fb0f913bcab0f04afe2f5b3f82f0e2", }, "linux-amd64": { - archive: "22180ed8faffdc3f68608d2d6df445ff243f7a0a5dcdab1d68cc60b7bc724594", - manifest: "58071c14b6a92e180d73a5a986b05ccc4d2199d115a66095f857250ec22cc9a4", + archive: "7de270b65d63c84bbc20123c3572bf4d175f9a6da981303960525dd252a79c3c", + manifest: "e8d43f55de7bb9f0ea391daa2e390d9634c73abf3ceda39bf76e99bdfcf96844", }, "linux-arm64": { - archive: "d1ef41a826a7d5b1241e4c323ca270b60986455619e8ac45e7c834ba6cf13973", - manifest: "56e7d7e84446de91554700097b57394b29ff57cd7401736e2482a0ed1d0ab6df", + archive: "3667e596befad84d72aa73fb054d95cd8a86c06b38423576c927c22f0e7f7581", + manifest: "590354cc95358f66fc4e71053348288eefdee82cee414678329cfc7ebac52369", }, }, }, From 0cc65ea654fecb8833151c71a06fe19566e70935 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:34:56 +0000 Subject: [PATCH 06/22] chore(stack): bump storage to v1.84.36-r0 (#7383) This bumps storage from v1.84.35-r0 to v1.84.36-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.84.36-r0 Planned after supabase/slim-services published v1.84.36-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 25f4c990e3..28ab9b51d4 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.149.1 AS realtime -FROM supabase/storage-api:v1.84.35 AS storage +FROM supabase/storage-api:v1.84.36 AS storage FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 25f4c990e3..28ab9b51d4 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.149.1 AS realtime -FROM supabase/storage-api:v1.84.35 AS storage +FROM supabase/storage-api:v1.84.36 AS storage FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 4f0f6a1230..1151e95503 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -364,23 +364,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.84.35", + upstreamVersion: "v1.84.36", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.84.35-r0@sha256:118663bdc687f79b03faa6db839d73229e09c9b562ee34bfadfe1a7e817b4eb3", - upstreamImage: "supabase/storage-api:v1.84.35", + "ghcr.io/supabase/cli/storage:v1.84.36-r0@sha256:9eb28fa8a52f99fbf6aaeec8d079daa31d9d067e68a7c539cfb8871860f9dddc", + upstreamImage: "supabase/storage-api:v1.84.36", natives: { "darwin-arm64": { - archive: "d5d5638a8b8e9f3118a8d3c80322b71a9be6a3d05b466dc8d434fc92a7a4e3f0", - manifest: "2daaa90e911502dc660e308d1da164ba68fb0f913bcab0f04afe2f5b3f82f0e2", + archive: "8ee898d729877351d92e687fc8f3864b0ab5ef4370f0b54b9de047c2b525175d", + manifest: "5fd3d7e4cf1158360f22d5fde8870d85edadf395e11ff8df290cd67382a81fba", }, "linux-amd64": { - archive: "7de270b65d63c84bbc20123c3572bf4d175f9a6da981303960525dd252a79c3c", - manifest: "e8d43f55de7bb9f0ea391daa2e390d9634c73abf3ceda39bf76e99bdfcf96844", + archive: "84445a8ef385b453fa1c7d0909b38fa9f93c3e2fb9096b3acaae04fd6e9a7657", + manifest: "4d90b1cc5355975d036cd47ca42bb3ac6cd7d467cf5706f56603bf9c97a3f9fd", }, "linux-arm64": { - archive: "3667e596befad84d72aa73fb054d95cd8a86c06b38423576c927c22f0e7f7581", - manifest: "590354cc95358f66fc4e71053348288eefdee82cee414678329cfc7ebac52369", + archive: "5239ee032f36191f6b5162b0924dd62e31aa972bdd0a9d6cdbcbced648d8ec74", + manifest: "dac73ba2310a97726e4ad9894f1ebb51fd2e5dbd05a061106c8209f2e1ecc963", }, }, }, From 480345c70ae106395ee69ada32d0d659ebb3ffab Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:07:39 +0000 Subject: [PATCH 07/22] chore(stack): bump realtime to v2.149.2-r0 (#7389) This bumps realtime from v2.149.1-r0 to v2.149.2-r0. https://github.com/supabase/slim-services/releases/tag/realtime-v2.149.2-r0 Planned after supabase/slim-services published v2.149.2-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 28ab9b51d4..4e24d87d41 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -33,7 +33,7 @@ FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.149.1 AS realtime +FROM supabase/realtime:v2.149.2 AS realtime FROM supabase/storage-api:v1.84.36 AS storage diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 28ab9b51d4..4e24d87d41 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -33,7 +33,7 @@ FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.149.1 AS realtime +FROM supabase/realtime:v2.149.2 AS realtime FROM supabase/storage-api:v1.84.36 AS storage diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 1151e95503..28fa26f4e6 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -338,23 +338,23 @@ const definitions: Readonly> = { realtime: definition( "realtime", { - upstreamVersion: "v2.149.1", + upstreamVersion: "v2.149.2", revision: 0, image: - "ghcr.io/supabase/cli/realtime:v2.149.1-r0@sha256:3a1218ee89626cd88487184c2d87f16268ac2b002e96282800aa0037a042324b", - upstreamImage: "supabase/realtime:v2.149.1", + "ghcr.io/supabase/cli/realtime:v2.149.2-r0@sha256:7338e6bdb3e4a91bb503ec4f54ab048805ee86b503cbe2a7bb982da4d46ba356", + upstreamImage: "supabase/realtime:v2.149.2", natives: { "darwin-arm64": { - archive: "245518dd7265a06eea5c99227f26180ec276b31b3294c7fa9dee12db0adb5f2a", - manifest: "42a3722ff12b77bfa07963e075e6bb09cacc699639c03b4010553c91627f02e2", + archive: "63d5d2297cffc86981900c96d51428cc7b95ce525d87636fb20a9140f5a467d0", + manifest: "736d13224220c1cc8ee02741eaadde8a1503b826c406ef3f20cacd36c4e3613c", }, "linux-amd64": { - archive: "d42c54a35a9e5b50f29e9a2d53b5fd21c2fe60eed4530dddaae363635bddf9ca", - manifest: "954ad956c5e4b06e5ab4f14ab7dfec0f76dc6aa0174f65a4c54a8eda35098d91", + archive: "8845ef50a4f20baa786d8bd4ce8dd58508354591102e8d65f49e576239774128", + manifest: "604b892726c1e159a90a7edf93cc9194eba98cdfb4bb9275c099e9a7c2ef723b", }, "linux-arm64": { - archive: "532466fb9a9ee72569f0a12edd23d9fe5d5d5b5e5fd274adc567a64884b6b802", - manifest: "8e86a1f6dc1da5927e5ea1ddc0a3a0938be7d5452686d59978225ac780402e6e", + archive: "923fd2735a69554dece6e287a52f66b91935a7dc725e36e580d6ba9add869d6f", + manifest: "59f2c400f2a797dac2d04d8c48a32f9bf14d4d0069f41fd07c8e3fc8d9dfb1e3", }, }, }, From bff634ca77bde75cd2123c6c0604f3390ec2e777 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:35:59 +0000 Subject: [PATCH 08/22] fix(cli): let `link` work without secret key access (CLI-2274, CLI-2699) (#7388) ## TL;DR `supabase link` now works with a project scoped token that can't read secret API keys, so CI can link and run `db push` with least privilege ## whats improved/fixed? link always asked for the secret keys with `reveal=true` so a token without API Key Secrets read got a 403 and link failed with `Authorization failed for the access token and project ref pair` those keys only feed the optional service version checks, so the whole command was blocked for nothing ## how? a 403 there now prints a warning naming the missing permission, skips those checks and still writes the link any other failure still fails, and `link --help` now lists `api_gateway_keys_secret_read` as optional
visual rep ```mermaid flowchart TD link["supabase link"] --> project["GET /v1/projects/{ref}"] project --> keys["GET api-keys?reveal=true"] keys -- "200" --> probes["service version checks"] probes --> saved["link saved"] keys -- "403 before" --> failed["link fails
db push --linked has no project"] keys -- "403 now" --> warn["warning names API Key Secrets read
version checks skipped"] warn --> saved keys -- "401, 404, other" --> error["link fails as before"] saved --> push["db push --linked
connects with the database password"] ```
## ref: - closes: https://github.com/supabase/cli/issues/6392 - closes: https://github.com/supabase/cli/issues/7165 --- .../command-internal/link-services-core.ts | 3 +- .../src/command-internal/tenant-versions.ts | 5 +++ apps/cli/src/commands/link/SIDE_EFFECTS.md | 9 ++--- apps/cli/src/commands/link/link.command.ts | 2 +- apps/cli/src/commands/link/link.handler.ts | 29 +++++++++++----- .../commands/link/link.integration.test.ts | 34 ++++++++++++++++++- .../link/link.lock.integration.test.ts | 27 ++++++++++++--- 7 files changed, 90 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/command-internal/link-services-core.ts b/apps/cli/src/command-internal/link-services-core.ts index c10aa43350..36164b4491 100644 --- a/apps/cli/src/command-internal/link-services-core.ts +++ b/apps/cli/src/command-internal/link-services-core.ts @@ -25,7 +25,7 @@ export interface LinkServicesInput { readonly ref: string; /** * Tenant API key used for the service version probes: `link` passes the - * service-role key, `bootstrap` passes the anon key. + * service-role key, `bootstrap` passes the anon key. Empty skips the probes. */ readonly serviceKey: string; readonly skipPooler: boolean; @@ -60,6 +60,7 @@ export const linkServicesCore = Effect.fn("LinkServices.write")(function* ( yield* linkStorageMigration(api, input.ref, state, workdir); yield* linkPooler({ api, ref: input.ref, skipPooler: input.skipPooler, state, workdir }); + if (input.serviceKey.length === 0) return; const tenantOpts = { ref: input.ref, diff --git a/apps/cli/src/command-internal/tenant-versions.ts b/apps/cli/src/command-internal/tenant-versions.ts index a7a5f73863..03eb1afe5b 100644 --- a/apps/cli/src/command-internal/tenant-versions.ts +++ b/apps/cli/src/command-internal/tenant-versions.ts @@ -199,10 +199,15 @@ interface TenantObservations { /** * Probes `rest`, `auth` and `storage` on the project's tenant host, concurrently. A service that * answers 404 or an empty version is `Unreported`; a transport error or any other status is `Failed`. + * Without a key nothing is probed and every service is `Failed`. */ export const probeTenantVersions = Effect.fn("TenantVersions.probe")(function* ( opts: TenantVersionOptions, ) { + if (opts.serviceKey.length === 0) { + const skipped: HostedObservation = { _tag: "Failed", cause: "no API key to probe with" }; + return { auth: skipped, rest: skipped, storage: skipped } satisfies TenantObservations; + } const [rest, auth, storage] = yield* Effect.all( [ probeService(opts, "postgrest", "/rest/v1/", (response) => response.json, postgrestRaw), diff --git a/apps/cli/src/commands/link/SIDE_EFFECTS.md b/apps/cli/src/commands/link/SIDE_EFFECTS.md index 462d3b2722..dcdebb6e3a 100644 --- a/apps/cli/src/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/link/SIDE_EFFECTS.md @@ -82,7 +82,7 @@ Management API (base `CommandSettings.apiUrl`, `Authorization: Bearer ` (prompt path); `WARNING: Project status is instead of Active Healthy. Some operations might fail.`; the dashboard unpause suggestion on a paused project. +- stderr: `Selected project: ` (prompt path); `WARNING: Project status is instead of Active Healthy. Some operations might fail.`; `WARNING: The access token cannot read secret API keys (API Key Secrets: read), so the service version checks were skipped.` when `reveal=true` answers 403; the dashboard unpause suggestion on a paused project. - stderr: `Resolved branch "" of project to project ref .` — via `output.raw(..., "stderr")` (NOT `output.info`, which clack renders on stdout with `│`/`◇` framing in text mode) — only when a non-ref-shaped `[ref-or-branch]`/`--project-ref` value @@ -198,7 +198,8 @@ stays busy fails the link with `LockResolutionError` reason `lease-busy` before - Probes the target (the `link.probe` span; `stack update` runs the same probe under `update.probe`): the database version from `getProject` (`database.version`), or from `getABranchConfig` (`postgres_version`) when the ref is a branch the project lookup does not return, and the `rest`, `auth` and `storage` versions from the target's - tenant host (`/rest/v1/`, `/auth/v1/health`, `/storage/v1/version`) with the service-role key. + tenant host (`/rest/v1/`, `/auth/v1/health`, `/storage/v1/version`) with the service-role key + (skipped and recorded as failed when `reveal=true` answers 403). The key is sent as `apikey`, and as a bearer token unless it is an `sb_` key; it is never logged or recorded, and a redirect is never followed (the probe counts it as failed). Services without a version endpoint are not probed. diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index 720235e62c..18bfd8c4da 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -84,7 +84,7 @@ export const linkCommand = Command.make("link", config).pipe( { operationId: "v1-get-project", kind: "required" }, { operationId: "v1-get-project-api-keys", - kind: "required", + kind: "best-effort", request: { query: { reveal: "true" } }, }, { diff --git a/apps/cli/src/commands/link/link.handler.ts b/apps/cli/src/commands/link/link.handler.ts index 05dee044ec..04da6b56e8 100644 --- a/apps/cli/src/commands/link/link.handler.ts +++ b/apps/cli/src/commands/link/link.handler.ts @@ -430,14 +430,27 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { } } - // 2. Resolve service keys (auth check). - const keys = yield* api.v1 - .getProjectApiKeys({ ref, reveal: true }) - .pipe(Effect.catch(mapApiKeysError)); - const { anon, serviceRole } = extractServiceKeys(keys); - if (anon.length === 0 && serviceRole.length === 0) { - return yield* new LinkMissingKeyError({ message: "Anon key not found." }); - } + // 2. Resolve service keys. A 403 means the token cannot read secret keys: link still links, + // with no key to probe. + const serviceRole = yield* api.v1.getProjectApiKeys({ ref, reveal: true }).pipe( + Effect.catch(mapApiKeysError), + Effect.flatMap((keys) => { + const { anon, serviceRole } = extractServiceKeys(keys); + return anon.length === 0 && serviceRole.length === 0 + ? Effect.fail(new LinkMissingKeyError({ message: "Anon key not found." })) + : Effect.succeed(serviceRole); + }), + Effect.catchTag("LinkAuthTokenError", (error) => + error.status === 403 + ? output + .raw( + "WARNING: The access token cannot read secret API keys (API Key Secrets: read), so the service version checks were skipped.\n", + "stderr", + ) + .pipe(Effect.as("")) + : Effect.fail(error), + ), + ); // 3. Link services — best-effort, using the service-role key for tenant probes. Stack mode // keeps only the pooler URL; versions come from the service lock. diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 4e3e4abdf3..9b27a58ecf 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1534,6 +1534,8 @@ describe("link permissions", () => { function setupRecording( opts: { readonly selectResponses?: ReadonlyArray; + /* A project-scoped token without API Key Secrets read: `reveal=true` is refused. */ + readonly revealRefused?: boolean; } = {}, ) { const out = mockOutput({ format: "text", promptSelectResponses: opts.selectResponses }); @@ -1559,7 +1561,14 @@ describe("link permissions", () => { : jsonResponse(request, 200, { ...HEALTHY_PROJECT, id: ref, ref }); } if (rest === "/branches") return jsonResponse(request, 200, [LINK_BRANCH]); - if (rest === "/api-keys") return jsonResponse(request, 200, SERVICE_KEYS); + if (rest === "/api-keys") { + return opts.revealRefused === true + ? jsonResponse(request, 403, { + message: + "Your account does not have the necessary privileges to access this endpoint.", + }) + : jsonResponse(request, 200, SERVICE_KEYS); + } if (rest === "/config/storage") { return jsonResponse(request, 200, { migrationVersion: "2026-01-01-000000", @@ -1613,6 +1622,29 @@ describe("link permissions", () => { }).pipe(Effect.provide(layer)); }); + it.effect("links with a token that cannot read secret keys, skipping the version checks", () => { + const { layer, api, out, workdir } = setupRecording({ revealRefused: true }); + return Effect.gen(function* () { + yield* link(flags()); + + expect(api.requests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`)).toEqual( + [ + `GET ${API}/v1/projects/${VALID_REF}`, + `GET ${API}/v1/projects/${VALID_REF}/api-keys?reveal=true`, + `GET ${API}/v1/projects/${VALID_REF}/config/storage`, + `GET ${API}/v1/projects/${VALID_REF}/config/database/pooler`, + ], + ); + expect(out.stderrText).toContain("API Key Secrets: read"); + expect(yield* readTemp(workdir, "project-ref")).toBe(VALID_REF); + assertPermissionDrift({ + command: "link", + activeFlags: { "project-ref": true, "skip-pooler": false }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("lists the projects for the picker when no ref is given on a TTY", () => { const { layer, api } = setupRecording({ selectResponses: [VALID_REF] }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/link/link.lock.integration.test.ts b/apps/cli/src/commands/link/link.lock.integration.test.ts index 51cc5b8b12..47255d6e0c 100644 --- a/apps/cli/src/commands/link/link.lock.integration.test.ts +++ b/apps/cli/src/commands/link/link.lock.integration.test.ts @@ -146,8 +146,8 @@ interface World { readonly host: ReleaseFixture; readonly tenant: Tenant; readonly workdir: string; - /** Switches the link target between the project and its feature branch, and sets the hosted database version. */ - readonly target: { onBranch: boolean; database: string }; + /** Switches the link target between the project and its feature branch, sets the hosted database version, and refuses `reveal=true`. */ + readonly target: { onBranch: boolean; database: string; revealRefused: boolean }; } const startWorld = Effect.gen(function* () { @@ -161,7 +161,7 @@ const startWorld = Effect.gen(function* () { host, tenant: hostedTenant(), workdir, - target: { onBranch: false, database: PROJECT.database.version }, + target: { onBranch: false, database: PROJECT.database.version, revealRefused: false }, } satisfies World; }); @@ -181,7 +181,10 @@ const layerFor = ( }), getABranchConfig: () => Effect.succeed({ postgres_version: "15.1.0.117", postgres_engine: "15" }), - getProjectApiKeys: () => Effect.succeed(SERVICE_KEYS), + getProjectApiKeys: () => + world.target.revealRefused + ? Effect.fail(statusCodeFailure(403)) + : Effect.succeed(SERVICE_KEYS), getPoolerConfig: () => Effect.succeed(POOLER), listAllBranches: () => Effect.succeed(world.target.onBranch ? [FEATURE_BRANCH] : []), }, @@ -334,6 +337,22 @@ describe("link records hosted versions in the stack lock", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("links without probing the tenant when the token cannot read secret keys", () => + Effect.gen(function* () { + const world: World = yield* startWorld; + world.target.revealRefused = true; + + const out = yield* runLink(world); + + const services = yield* readServices(world.workdir); + for (const kind of ["rest", "auth", "storage"] as const) { + expect(services[kind].resolution).toBe("unobserved"); + } + expect(world.tenant.requests).toEqual([]); + expect(warnings(out).join("\n")).toContain("no API key to probe with"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("takes the bundled pin as unobserved for a service the platform does not report", () => Effect.gen(function* () { const world = yield* startWorld; From b0f101664b751850fa69a8b335dd00018be8aa29 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 29 Oct 2026 10:53:55 +0000 Subject: [PATCH 09/22] feat(config): publish the config schemas as draft-07 under supabase.com/schemas (#7366) ## Summary The config, project-config and `supabase.lock` schemas declared draft 2020-12, and their `$id`s pointed at `supabase.com/docs/cli/...`, which 404s. Taplo (Even Better TOML) doesn't support anything newer than draft-07, and the schemas only use keywords draft-07 already has. So this moves all three to draft-07, with `$id`s at the URLs they'll be hosted at. - `toPublishedJsonSchemaDocument` and the lock schema now go through Effect's `JsonSchema.toDocumentDraft07`. That converter turns `$defs` into `definitions`, rewrites the refs, and throws on anything draft-07 can't represent rather than dropping it - The `$id`s move to `https://supabase.com/schemas/{config,project-config,supabase-lock}/v1.json`. Nothing is served there yet. CLI-2865 sets up the path in supabase/supabase and CLI-2864 publishes to it on stable releases - The committed copies move from `apps/docs/public/cli/*.schema.json` to `apps/docs/public/schemas//v1.json`. That's the same layout as the hosted URLs, so the publisher can copy the folder across as-is - `supabase.lock` keeps being written with its original `$schema` value, because older CLIs check that field as a literal and would reject anything else. Reads also accept the hosted URL (`HOSTED_LOCK_SCHEMA_URL`), so a later release can switch writes over once enough CLIs read it - New `apps/cli/scripts/published-schemas.unit.test.ts` runs in the existing unit job. It fails when a committed schema: - uses a keyword newer than draft-07 - has a ref to `#/$defs/` - has an `$id` that doesn't match its file path - has drifted from what the source renders - The lock schema committed on develop was already behind the `Lock` schema. Regenerating it also brings in the current shape (no `ArtifactPin`). The per-service `ServiceLock` schemas render identically, so the published schema collapses them into one definition ## Schema shape change `@supabase/config/schema.json` and `@supabase/config/project-schema.json` now declare draft-07 (`http://json-schema.org/draft-07/schema#`) and the new `$id`s. `CLI_CONFIG_SCHEMA_URL` and `PROJECT_CONFIG_SCHEMA_URL` change value to match. The schema bodies are otherwise unchanged. If you validate them with a 2020-12-only validator such as `Ajv2020`, switch to a draft-07 one. Code that uses those constants as a default `$schema` now writes the new URL. Existing `$schema` values in user config files aren't changed. ## Linked issue Closes CLI-2856 --- .gitattributes | 4 +- apps/cli/docs/stack-commands.md | 2 +- apps/cli/scripts/generate-docs.ts | 20 +- apps/cli/scripts/published-schemas.ts | 77 +++++++ .../scripts/published-schemas.unit.test.ts | 214 ++++++++++++++++++ .../experimental/stack/start/SIDE_EFFECTS.md | 2 +- .../config/v1.json} | 16 +- .../project-config/v1.json} | 42 ++-- .../supabase-lock/v1.json} | 170 ++++++++------ docs/self-documenting-cli.md | 9 +- packages/config/AGENTS.md | 4 +- packages/config/README.md | 12 +- .../scripts/build-artifacts.unit.test.ts | 2 +- packages/config/src/json-schema-document.ts | 23 +- .../src/json-schema-document.unit.test.ts | 25 +- .../src/project-config/project-schema.ts | 2 +- .../project-schema.unit.test.ts | 4 +- packages/config/src/schema-metadata.ts | 6 +- packages/stack/src/Lock.integration.test.ts | 38 ++++ packages/stack/src/Lock.ts | 12 +- packages/stack/src/effect.ts | 1 + turbo.json | 15 +- 22 files changed, 534 insertions(+), 166 deletions(-) create mode 100644 apps/cli/scripts/published-schemas.ts create mode 100644 apps/cli/scripts/published-schemas.unit.test.ts rename apps/docs/public/{cli/config.schema.json => schemas/config/v1.json} (99%) rename apps/docs/public/{cli/project-config.schema.json => schemas/project-config/v1.json} (99%) rename apps/docs/public/{cli/supabase-lock/v1.schema.json => schemas/supabase-lock/v1.json} (57%) diff --git a/.gitattributes b/.gitattributes index 5aa13a6aaa..12a665f563 100644 --- a/.gitattributes +++ b/.gitattributes @@ -28,9 +28,7 @@ packages/api/src/generated/* linguist-generated=true packages/api/scripts/openapi-source.json linguist-generated=true packages/stack/src/functions/generated/* text eol=lf linguist-generated=true apps/cli/src/shared/feedback/database.types.ts linguist-generated=true -apps/docs/public/cli/config.schema.json linguist-generated=true -apps/docs/public/cli/project-config.schema.json linguist-generated=true -apps/docs/public/cli/supabase-lock/v1.schema.json linguist-generated=true +apps/docs/public/schemas/** linguist-generated=true # Lockfiles. Collapsed rather than hidden -- a dependency change is still one # click away in review, which is the same treatment GitHub gives package-lock.json. diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 46fa0e8a17..8c8721c4bb 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -351,7 +351,7 @@ start --frozen` fails instead of resolving when the lock is missing or stale; it targets another project or branch than the lock observed (run `supabase stack update`), and when this CLI bundles a newer revision of a locked version. A lock that is malformed, does not match the format, or comes from a newer CLI always fails the start. The lock's JSON Schema is published at -`https://supabase.com/docs/cli/supabase-lock/v1.schema.json`. A database with initialized data keeps +`https://supabase.com/schemas/supabase-lock/v1.json`. A database with initialized data keeps its build: any change other than a new `-rN` revision of the same build (a different upstream build, release line or major) is refused. The refusal offers to keep the data by pinning the recorded build with `[local.services.database] version`, setting `[db] major_version` back when the major differs and removing any diff --git a/apps/cli/scripts/generate-docs.ts b/apps/cli/scripts/generate-docs.ts index 886f0866ce..be5456e7f0 100644 --- a/apps/cli/scripts/generate-docs.ts +++ b/apps/cli/scripts/generate-docs.ts @@ -1,11 +1,12 @@ import { BunFileSystem, BunPath } from "@effect/platform-bun"; import process from "node:process"; import { CLI_CONFIG_SCHEMA_URL, PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; -import { Lock, LOCK_SCHEMA_URL } from "@supabase/stack/effect"; +import { HOSTED_LOCK_SCHEMA_URL } from "@supabase/stack/effect"; import { Console, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { rootCommand } from "../src/cli/root.ts"; import { collectCommands, getHelpDoc } from "../src/shared/cli/command-docs.ts"; import { formatHelpDocAsMarkdown } from "../src/shared/cli/markdown-formatter.ts"; +import { lockSchemaDocument, publishedSchemaPath } from "./published-schemas.ts"; const BINARY_NAME = "supabase"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); @@ -92,8 +93,7 @@ const main = Effect.gen(function* () { schemaUrl: string, distFileName: string, ) { - const schemaPathname = new URL(schemaUrl).pathname.replace(/^\/docs/, ""); - const destPath = path.join(defaultDocsPublicDir, schemaPathname); + const destPath = path.join(defaultDocsPublicDir, publishedSchemaPath(schemaUrl)); const sourcePath = path.join(configPackageDistDir, distFileName); yield* fs.makeDirectory(path.dirname(destPath), { recursive: true }); @@ -106,20 +106,10 @@ const main = Effect.gen(function* () { /** Writes the `supabase.lock` v1 JSON Schema, generated from the stack's lock schema, to its docs-site public path. */ const writeLockSchemaAsset = Effect.fnUntraced(function* () { - const { schema, definitions } = Schema.toJsonSchemaDocument(Lock); - const document = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: LOCK_SCHEMA_URL, - title: "supabase.lock", - description: "The service versions that supabase start pins in supabase/supabase.lock.", - ...schema, - $defs: definitions, - }; - const schemaPathname = new URL(LOCK_SCHEMA_URL).pathname.replace(/^\/docs/, ""); - const destPath = path.join(defaultDocsPublicDir, schemaPathname); + const destPath = path.join(defaultDocsPublicDir, publishedSchemaPath(HOSTED_LOCK_SCHEMA_URL)); yield* fs.makeDirectory(path.dirname(destPath), { recursive: true }); - yield* fs.writeFileString(destPath, `${yield* encodeJson(document)}\n`); + yield* fs.writeFileString(destPath, `${yield* encodeJson(lockSchemaDocument())}\n`); yield* Console.log( `Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`, diff --git a/apps/cli/scripts/published-schemas.ts b/apps/cli/scripts/published-schemas.ts new file mode 100644 index 0000000000..31f2ca2073 --- /dev/null +++ b/apps/cli/scripts/published-schemas.ts @@ -0,0 +1,77 @@ +import { HOSTED_LOCK_SCHEMA_URL, Lock, LOCK_SCHEMA_URL } from "@supabase/stack/effect"; +import { JsonSchema, Predicate, Schema } from "effect"; + +const DEFINITION_REF_PREFIX = "#/definitions/"; + +const renameRefs = (node: unknown, renames: ReadonlyMap): unknown => + Array.isArray(node) + ? node.map((child) => renameRefs(child, renames)) + : Predicate.isObject(node) + ? renameRefsIn(node, renames) + : node; + +const renameRefsIn = ( + node: JsonSchema.JsonSchema, + renames: ReadonlyMap, +): JsonSchema.JsonSchema => + Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + key === "$ref" && typeof value === "string" + ? (renames.get(value) ?? value) + : renameRefs(value, renames), + ]), + ); + +/** Collapses identical definitions onto the first name, since `Lock` renders one per service. */ +const dedupeDefinitions = ({ schema, definitions }: JsonSchema.Document<"draft-07">) => { + const firstByBody = new Map(); + const renames = new Map(); + const kept: Array<[string, JsonSchema.JsonSchema]> = []; + for (const [name, definition] of Object.entries(definitions)) { + const body = JSON.stringify(definition); + const first = firstByBody.get(body); + if (first === undefined) { + firstByBody.set(body, name); + kept.push([name, definition]); + } else { + renames.set(`${DEFINITION_REF_PREFIX}${name}`, `${DEFINITION_REF_PREFIX}${first}`); + } + } + return { + schema: renameRefsIn(schema, renames), + definitions: renameRefsIn(Object.fromEntries(kept), renames), + }; +}; + +/** `readLock` accepts the hosted `$schema` URL alongside the legacy one that writes still use. */ +const acceptHostedSchemaUrl = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { + const properties = schema["properties"]; + return Predicate.isObject(properties) + ? { + ...schema, + properties: { + ...properties, + $schema: { type: "string", enum: [LOCK_SCHEMA_URL, HOSTED_LOCK_SCHEMA_URL] }, + }, + } + : schema; +}; + +/** The draft-07 JSON Schema of `supabase/supabase.lock`, rendered from the stack's `Lock` schema. */ +export const lockSchemaDocument = () => { + const { schema, definitions } = dedupeDefinitions( + JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(Lock)), + ); + return { + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, + $id: HOSTED_LOCK_SCHEMA_URL, + title: "supabase.lock", + description: "The service versions that supabase start pins in supabase/supabase.lock.", + ...acceptHostedSchemaUrl(schema), + definitions, + }; +}; + +/** The path under `apps/docs/public` that serves the schema published at `url`. */ +export const publishedSchemaPath = (url: string): string => new URL(url).pathname; diff --git a/apps/cli/scripts/published-schemas.unit.test.ts b/apps/cli/scripts/published-schemas.unit.test.ts new file mode 100644 index 0000000000..ee0e98bdab --- /dev/null +++ b/apps/cli/scripts/published-schemas.unit.test.ts @@ -0,0 +1,214 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { + CLI_CONFIG_SCHEMA_URL, + PROJECT_CONFIG_SCHEMA_URL, + toCliConfigJsonSchema, + toProjectConfigJsonSchema, +} from "@supabase/config"; +import { HOSTED_LOCK_SCHEMA_URL, LOCK_SCHEMA_URL } from "@supabase/stack/effect"; +import { Effect, FileSystem, JsonSchema, Path, Schema } from "effect"; +import { lockSchemaDocument, publishedSchemaPath } from "./published-schemas.ts"; + +type Document = Readonly>; + +const REGENERATE = + "run `pnpm --filter @supabase/config build && bun apps/cli/scripts/generate-docs.ts`"; + +const POST_DRAFT_07_KEYWORDS = new Set([ + "$defs", + "prefixItems", + "unevaluatedProperties", + "unevaluatedItems", + "dependentRequired", + "dependentSchemas", + "$dynamicRef", + "$dynamicAnchor", + "$recursiveRef", + "$recursiveAnchor", + "$anchor", + "$vocabulary", + "minContains", + "maxContains", +]); +const DATA_KEYWORDS = new Set(["default", "examples", "enum", "const"]); +const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties", "definitions"]); + +const isNode = (value: unknown): value is Document => + typeof value === "object" && value !== null && !Array.isArray(value); + +const findPostDraft07Usage = (node: unknown, path: ReadonlyArray = []): Array => { + if (Array.isArray(node)) { + return node.flatMap((child, index) => findPostDraft07Usage(child, [...path, String(index)])); + } + if (!isNode(node)) return []; + return Object.entries(node).flatMap(([keyword, value]) => { + const here = [...path, keyword]; + if (POST_DRAFT_07_KEYWORDS.has(keyword)) return [here.join("/")]; + if (keyword === "$ref" && typeof value === "string" && value.startsWith("#/$defs/")) { + return [here.join("/")]; + } + if (DATA_KEYWORDS.has(keyword)) return []; + if (SCHEMA_MAP_KEYWORDS.has(keyword) && isNode(value)) { + return Object.entries(value).flatMap(([name, child]) => + findPostDraft07Usage(child, [...here, name]), + ); + } + return findPostDraft07Usage(value, here); + }); +}; + +const collectRefs = (node: unknown): Array => { + if (Array.isArray(node)) return node.flatMap(collectRefs); + if (!isNode(node)) return []; + return Object.entries(node).flatMap(([keyword, value]) => + keyword === "$ref" && typeof value === "string" ? [value] : collectRefs(value), + ); +}; + +const withoutMetadata = ({ + $id: _id, + title: _title, + description: _description, + ...rest +}: Document): Document => rest; + +const published = [ + { + name: "config", + file: "schemas/config/v1.json", + url: CLI_CONFIG_SCHEMA_URL, + build: (): Document => toCliConfigJsonSchema(), + comparable: withoutMetadata, + }, + { + name: "project-config", + file: "schemas/project-config/v1.json", + url: PROJECT_CONFIG_SCHEMA_URL, + build: (): Document => toProjectConfigJsonSchema(), + comparable: withoutMetadata, + }, + { + name: "supabase-lock", + file: "schemas/supabase-lock/v1.json", + url: HOSTED_LOCK_SCHEMA_URL, + build: (): Document => lockSchemaDocument(), + comparable: (document: Document): Document => document, + }, +] as const; + +const decodeDocument = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const readCommitted = (file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const publicDir = path.resolve(import.meta.dirname, "../../docs/public"); + return yield* decodeDocument(yield* fs.readFileString(path.join(publicDir, file))); + }).pipe(Effect.provide(BunServices.layer)); + +describe("findPostDraft07Usage", () => { + it("flags post-draft-07 keywords and $defs refs in schema positions", () => { + const document = { + type: "object", + $defs: { Name: { type: "string" } }, + properties: { + tuple: { type: "array", prefixItems: [{ type: "string" }] }, + name: { $ref: "#/$defs/Name" }, + }, + }; + + expect(findPostDraft07Usage(document)).toEqual([ + "$defs", + "properties/tuple/prefixItems", + "properties/name/$ref", + ]); + }); + + it("treats property names and data keywords as data", () => { + const document = { + type: "object", + properties: { + prefixItems: { type: "string", default: "x" }, + $defs: { type: "object", default: { $defs: {} }, examples: [{ prefixItems: [] }] }, + mode: { enum: ["$defs", "prefixItems"], const: "$defs" }, + }, + definitions: { unevaluatedProperties: { type: "string" } }, + }; + + expect(findPostDraft07Usage(document)).toEqual([]); + }); +}); + +describe.each(published)("published $name schema", (entry) => { + it.effect("declares draft-07 and an $id that serves from its own path", () => + Effect.gen(function* () { + const document = yield* readCommitted(entry.file); + + expect(document["$schema"]).toBe(JsonSchema.META_SCHEMA_URI_DRAFT_07); + expect(document["$id"]).toBe(entry.url); + expect(publishedSchemaPath(entry.url)).toBe(`/${entry.file}`); + }), + ); + + it.effect("uses no keyword newer than draft-07", () => + Effect.gen(function* () { + expect(findPostDraft07Usage(yield* readCommitted(entry.file))).toEqual([]); + }), + ); + + it.effect("matches what the build renders", () => + Effect.gen(function* () { + const document = yield* readCommitted(entry.file); + + expect( + entry.comparable(document), + `apps/docs/public/${entry.file} is stale: ${REGENERATE}`, + ).toEqual(entry.comparable(entry.build())); + }), + ); +}); + +describe("published supabase-lock schema definitions", () => { + const readLockDefinitions = Effect.gen(function* () { + const document = yield* readCommitted("schemas/supabase-lock/v1.json"); + const definitions = document["definitions"]; + return { document, definitions: isNode(definitions) ? definitions : {} }; + }); + + it.effect("are pairwise distinct", () => + Effect.gen(function* () { + const { definitions } = yield* readLockDefinitions; + const bodies = Object.values(definitions).map((definition) => JSON.stringify(definition)); + + expect(bodies.length).toBeGreaterThan(1); + expect(new Set(bodies).size).toBe(bodies.length); + }), + ); + + it.effect("cover every $ref in the document", () => + Effect.gen(function* () { + const { document, definitions } = yield* readLockDefinitions; + const refs = collectRefs(document); + + expect(refs).not.toEqual([]); + expect( + refs.filter((ref) => !Object.keys(definitions).includes(ref.replace("#/definitions/", ""))), + ).toEqual([]); + }), + ); + + it.effect("accept every $schema value readLock accepts", () => + Effect.gen(function* () { + const document = yield* readCommitted("schemas/supabase-lock/v1.json"); + const properties = document["properties"]; + const schemaProperty = isNode(properties) ? properties["$schema"] : undefined; + + expect(isNode(schemaProperty) ? schemaProperty["enum"] : undefined).toEqual( + expect.arrayContaining([LOCK_SCHEMA_URL, HOSTED_LOCK_SCHEMA_URL]), + ); + }), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 646341ec16..fd6a4c84fa 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -133,7 +133,7 @@ When the project has a `supabase/config.toml`, the versions a new or stopped sta twelve services it records the specifier requested under `[local.services]` (`.version`, else `default.version`, else `linked`), how it resolved, and the exact pin: the release revision, each target's archive and manifest digests, and the image digest. Its -[JSON Schema](https://supabase.com/docs/cli/supabase-lock/v1.schema.json) is published with the +[JSON Schema](https://supabase.com/schemas/supabase-lock/v1.json) is published with the docs. A project without `supabase/config.toml` runs the versions bundled with the CLI and has no lock, unless `--frozen` is set, which fails the start. diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/schemas/config/v1.json similarity index 99% rename from apps/docs/public/cli/config.schema.json rename to apps/docs/public/schemas/config/v1.json index e64d6aeb24..1da196bf31 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/schemas/config/v1.json @@ -1,6 +1,6 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://supabase.com/docs/cli/config.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://supabase.com/schemas/config/v1.json", "title": "Supabase CLI config (CliConfig)", "description": "The Supabase CLI's local project config document (supabase/config.toml or supabase/config.json).", "type": "object", @@ -4062,6 +4062,8 @@ "properties": { "rules": { "type": "array", + "description": "Inspection rules.", + "default": [], "items": { "type": "object", "properties": { @@ -4083,9 +4085,7 @@ } }, "additionalProperties": false - }, - "description": "Inspection rules.", - "default": [] + } } }, "additionalProperties": false @@ -8330,6 +8330,8 @@ "properties": { "rules": { "type": "array", + "description": "Inspection rules.", + "default": [], "items": { "type": "object", "properties": { @@ -8351,9 +8353,7 @@ } }, "additionalProperties": false - }, - "description": "Inspection rules.", - "default": [] + } } }, "additionalProperties": false diff --git a/apps/docs/public/cli/project-config.schema.json b/apps/docs/public/schemas/project-config/v1.json similarity index 99% rename from apps/docs/public/cli/project-config.schema.json rename to apps/docs/public/schemas/project-config/v1.json index 7cff1018b1..022c95f0af 100644 --- a/apps/docs/public/cli/project-config.schema.json +++ b/apps/docs/public/schemas/project-config/v1.json @@ -1,6 +1,6 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://supabase.com/docs/cli/project-config.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://supabase.com/schemas/project-config/v1.json", "title": "Supabase hosted project config (ProjectConfig)", "description": "The sparse, hosted-project subset of CliConfig that a Supabase project manages.", "type": "object", @@ -15,19 +15,19 @@ }, "schemas": { "type": "array", + "default": ["public", "graphql_public"], "items": { "type": "string", "description": "Schemas to expose in your API. Tables, views and stored procedures in this schema will get API endpoints." - }, - "default": ["public", "graphql_public"] + } }, "extra_search_path": { "type": "array", + "default": ["public", "extensions"], "items": { "type": "string", "description": "Extra schemas to add to the search_path of every request." - }, - "default": ["public", "extensions"] + } }, "max_rows": { "type": "number", @@ -56,12 +56,12 @@ }, "additional_redirect_urls": { "type": "array", + "description": "A list of exact URLs that auth providers are permitted to redirect to post authentication.", + "default": ["https://127.0.0.1:3000"], "items": { "type": "string", "description": "A URL that auth providers are permitted to redirect to." - }, - "description": "A list of exact URLs that auth providers are permitted to redirect to post authentication.", - "default": ["https://127.0.0.1:3000"] + } }, "jwt_expiry": { "type": "number", @@ -1599,19 +1599,19 @@ }, "allowed_cidrs": { "type": "array", + "description": "Allowed IPv4 CIDR blocks.", + "default": ["0.0.0.0/0"], "items": { "type": "string" - }, - "description": "Allowed IPv4 CIDR blocks.", - "default": ["0.0.0.0/0"] + } }, "allowed_cidrs_v6": { "type": "array", + "description": "Allowed IPv6 CIDR blocks.", + "default": ["::/0"], "items": { "type": "string" - }, - "description": "Allowed IPv6 CIDR blocks.", - "default": ["::/0"] + } } }, "additionalProperties": true @@ -1673,12 +1673,12 @@ }, "allowed_mime_types": { "type": "array", + "description": "The list of allowed MIME types for the bucket.", + "default": [], "items": { "type": "string", "description": "A MIME type allowed for the bucket." - }, - "description": "The list of allowed MIME types for the bucket.", - "default": [] + } }, "objects_path": { "type": "string", @@ -1815,11 +1815,11 @@ }, "exclude": { "type": "array", + "description": "Patterns for paths to leave out of the uploaded build context, read the way\n`.gitignore` reads them: a pattern without `/` matches that name at any depth,\none with `/` is anchored at the compute's source directory, a trailing `/`\nmatches directories only, and `**` spans directories. Excluding a directory\nexcludes everything under it; re-inclusion (`!`) is not supported. Only the\npatterns recorded here are excluded, so a compute with no list uploads its source\ndirectory whole.", + "examples": [["node_modules", ".env"]], "items": { "type": "string" - }, - "description": "Patterns for paths to leave out of the uploaded build context, read the way\n`.gitignore` reads them: a pattern without `/` matches that name at any depth,\none with `/` is anchored at the compute's source directory, a trailing `/`\nmatches directories only, and `**` spans directories. Excluding a directory\nexcludes everything under it; re-inclusion (`!`) is not supported. Only the\npatterns recorded here are excluded, so a compute with no list uploads its source\ndirectory whole.", - "examples": [["node_modules", ".env"]] + } } }, "additionalProperties": true diff --git a/apps/docs/public/cli/supabase-lock/v1.schema.json b/apps/docs/public/schemas/supabase-lock/v1.json similarity index 57% rename from apps/docs/public/cli/supabase-lock/v1.schema.json rename to apps/docs/public/schemas/supabase-lock/v1.json index 705b48f7d5..184b797bf2 100644 --- a/apps/docs/public/cli/supabase-lock/v1.schema.json +++ b/apps/docs/public/schemas/supabase-lock/v1.json @@ -1,6 +1,6 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://supabase.com/docs/cli/supabase-lock/v1.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://supabase.com/schemas/supabase-lock/v1.json", "title": "supabase.lock", "description": "The service versions that supabase start pins in supabase/supabase.lock.", "type": "object", @@ -8,7 +8,8 @@ "$schema": { "type": "string", "enum": [ - "https://supabase.com/docs/cli/supabase-lock/v1.schema.json" + "https://supabase.com/docs/cli/supabase-lock/v1.schema.json", + "https://supabase.com/schemas/supabase-lock/v1.json" ] }, "lockfileVersion": { @@ -54,40 +55,40 @@ "type": "object", "properties": { "database": { - "$ref": "#/$defs/DatabaseLock" + "$ref": "#/definitions/DatabaseLock" }, "rest": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "auth": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "realtime": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "storage": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "imgproxy": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "functions": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "studio": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "pgmeta": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "mail": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "analytics": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" }, "pooler": { - "$ref": "#/$defs/ServiceLock" + "$ref": "#/definitions/ServiceLock" } }, "required": [ @@ -113,7 +114,7 @@ "services" ], "additionalProperties": true, - "$defs": { + "definitions": { "LinkedVersion": { "type": "object", "properties": { @@ -148,51 +149,6 @@ ], "additionalProperties": true }, - "ArtifactPin": { - "type": "object", - "properties": { - "upstreamVersion": { - "type": "string" - }, - "revision": { - "type": "integer" - }, - "image": { - "type": "string" - }, - "upstreamImage": { - "type": "string" - }, - "natives": { - "type": "object", - "properties": { - "darwin-arm64": { - "$ref": "#/$defs/NativePin" - }, - "linux-amd64": { - "$ref": "#/$defs/NativePin" - }, - "linux-arm64": { - "$ref": "#/$defs/NativePin" - } - }, - "required": [ - "darwin-arm64", - "linux-amd64", - "linux-arm64" - ], - "additionalProperties": true - } - }, - "required": [ - "upstreamVersion", - "revision", - "image", - "upstreamImage", - "natives" - ], - "additionalProperties": true - }, "DatabaseLock": { "type": "object", "properties": { @@ -200,7 +156,7 @@ "type": "string" }, "linked": { - "$ref": "#/$defs/LinkedVersion" + "$ref": "#/definitions/LinkedVersion" }, "resolution": { "type": "string", @@ -212,7 +168,49 @@ ] }, "resolved": { - "$ref": "#/$defs/ArtifactPin" + "type": "object", + "properties": { + "upstreamVersion": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "image": { + "type": "string" + }, + "upstreamImage": { + "type": "string" + }, + "natives": { + "type": "object", + "properties": { + "darwin-arm64": { + "$ref": "#/definitions/NativePin" + }, + "linux-amd64": { + "$ref": "#/definitions/NativePin" + }, + "linux-arm64": { + "$ref": "#/definitions/NativePin" + } + }, + "required": [ + "darwin-arm64", + "linux-amd64", + "linux-arm64" + ], + "additionalProperties": true + } + }, + "required": [ + "upstreamVersion", + "revision", + "image", + "upstreamImage", + "natives" + ], + "additionalProperties": true }, "majorVersion": { "type": "integer" @@ -241,7 +239,7 @@ "type": "string" }, "linked": { - "$ref": "#/$defs/LinkedVersion" + "$ref": "#/definitions/LinkedVersion" }, "resolution": { "type": "string", @@ -253,7 +251,49 @@ ] }, "resolved": { - "$ref": "#/$defs/ArtifactPin" + "type": "object", + "properties": { + "upstreamVersion": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "image": { + "type": "string" + }, + "upstreamImage": { + "type": "string" + }, + "natives": { + "type": "object", + "properties": { + "darwin-arm64": { + "$ref": "#/definitions/NativePin" + }, + "linux-amd64": { + "$ref": "#/definitions/NativePin" + }, + "linux-arm64": { + "$ref": "#/definitions/NativePin" + } + }, + "required": [ + "darwin-arm64", + "linux-amd64", + "linux-arm64" + ], + "additionalProperties": true + } + }, + "required": [ + "upstreamVersion", + "revision", + "image", + "upstreamImage", + "natives" + ], + "additionalProperties": true } }, "required": [ diff --git a/docs/self-documenting-cli.md b/docs/self-documenting-cli.md index 3f7988c392..adaeda653f 100644 --- a/docs/self-documenting-cli.md +++ b/docs/self-documenting-cli.md @@ -24,9 +24,10 @@ development. The generator: frontmatter. 4. Writes `content/docs/commands/index.mdx`, a table linking to every command page, and `content/docs/commands/meta.json`, which controls sidebar order. -5. Copies the built `@supabase/config` JSON schemas into `public/cli/` so the configuration - reference links resolve. This requires `@supabase/config#build` to have run first, which - `turbo.json` wires up. +5. Writes the config, project-config and `supabase.lock` JSON Schemas to + `public/schemas//v1.json`, mirroring their `https://supabase.com/schemas/` URLs. The two + config schemas are copied from the `@supabase/config` build, which requires + `@supabase/config#build` to have run first; `turbo.json` wires that up. The generated `content/docs/commands/` directory is git-ignored; only the hand-authored pages are committed. @@ -49,7 +50,7 @@ apps/docs/ │ ├── getting-started.mdx ← Quickstart guide (hand-authored) │ ├── meta.json ← Top-level page order │ └── commands/ ← Auto-generated command reference (git-ignored) -├── public/cli/ ← Generated config schema assets +├── public/schemas/ ← Generated config, project-config and supabase.lock JSON Schemas └── lib/ └── source.ts ← Fumadocs content source loader ``` diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index 46ae61ee30..7add8c00d7 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -49,9 +49,9 @@ artifacts (`./schema.json`, `./project-schema.json`). `CliConfigWriteError` — kept off `./effect`'s public surface deliberately (no consumer outside `apps/cli` needs them, and internal-only keeps the published semver surface unchanged). Anything here can change or vanish in any release. -- `@supabase/config/schema.json` — generated JSON Schema (draft 2020-12) for `CliConfig` (a +- `@supabase/config/schema.json` — generated JSON Schema (draft-07) for `CliConfig` (a `dist/` build output); number, integer, boolean, enum and string-list fields also accept `env(VAR)` strings. -- `@supabase/config/project-schema.json` (CLI-2234) — generated JSON Schema (draft 2020-12) for +- `@supabase/config/project-schema.json` (CLI-2234) — generated JSON Schema (draft-07) for `ProjectConfig`, derived from `ProjectConfigSchema` (`src/project-config/project-schema.ts`); a `dist/` build output alongside `schema.json`. diff --git a/packages/config/README.md b/packages/config/README.md index b118d12e3e..a5934fe57d 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -79,8 +79,8 @@ for the full vocabulary. | `./io` | A Promise-based facade over the same file-IO/Effect programs as `./effect` | Resolved automatically via package.json export conditions (`bun`/`node`/`browser`/`default`). Requires one of the optional platform peers — `@effect/platform-bun` under Bun, `@effect/platform-node` under Node — installed at runtime; see "Installing" below for the failure mode when it's missing | | `./effect` | Effect-native superset of `.`: `CliConfigStore`/`cliConfigStoreLayer`, config loading/saving, project-environment resolution, functions-manifest inference | Requires `effect`; requires a platform peer only for the file-IO programs, exactly like `./io` | | `./internal` | `ENV_CAPTURE_REGEX`, `AUTH_HOOK_NAMES`, `unmappedSecretApiPaths`, `projectConfigMappingRows`, the `ProjectConfigMappingRow`/`ProjectConfigApiAttributes`/`InternalLoadCliConfigOptions` types, plus `loadCliConfig`/`resolveCliConfigValue`/`resolveCliConfigSubtree` re-typed to additionally accept the internal-only `goViperCompat` option — the SAME runtime functions `./effect` exports, not independent implementations | **Not covered by semver, and only `apps/cli` may import it** (enforced by `src/monorepo-import-contract.unit.test.ts`). Exists solely for the Supabase CLI's own use and its contract-guard tests — any export here (its existence, shape, or behavior) can change or vanish in any release without notice | -| `./schema.json` | Generated JSON Schema for `CliConfig` | Draft 2020-12 — a language-agnostic contract for non-TypeScript consumers | -| `./project-schema.json` | Generated JSON Schema for `ProjectConfig` | Draft 2020-12 — a language-agnostic contract for non-TypeScript consumers | +| `./schema.json` | Generated JSON Schema for `CliConfig` | Draft-07 — a language-agnostic contract for non-TypeScript consumers | +| `./project-schema.json` | Generated JSON Schema for `ProjectConfig` | Draft-07 — a language-agnostic contract for non-TypeScript consumers | A few things worth calling out beyond the table: @@ -175,10 +175,10 @@ Every runtime and type export of the pure `.` entrypoint, grouped by category: **JSON Schema generators + URLs** -| Export | What it is | -| ----------------------------------------------------- | --------------------------------------------------------- | -| `toCliConfigJsonSchema` / `toProjectConfigJsonSchema` | Render each shape's JSON Schema (draft 2020-12) document. | -| `CLI_CONFIG_SCHEMA_URL` / `PROJECT_CONFIG_SCHEMA_URL` | The `$id`/`$schema` URL for each generated document. | +| Export | What it is | +| ----------------------------------------------------- | ---------------------------------------------------- | +| `toCliConfigJsonSchema` / `toProjectConfigJsonSchema` | Render each shape's JSON Schema (draft-07) document. | +| `CLI_CONFIG_SCHEMA_URL` / `PROJECT_CONFIG_SCHEMA_URL` | The `$id`/`$schema` URL for each generated document. | **Errors** diff --git a/packages/config/scripts/build-artifacts.unit.test.ts b/packages/config/scripts/build-artifacts.unit.test.ts index 3a7a50f404..569a48a4fc 100644 --- a/packages/config/scripts/build-artifacts.unit.test.ts +++ b/packages/config/scripts/build-artifacts.unit.test.ts @@ -93,7 +93,7 @@ describe("published JSON Schema artifacts", () => { "Supabase hosted project config (ProjectConfig)", ], ])("%s carries $schema, $id, and title", (_name, document, id, title) => { - expect(document["$schema"]).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(document["$schema"]).toBe("http://json-schema.org/draft-07/schema#"); expect(document["$id"]).toBe(id); expect(document["title"]).toBe(title); }); diff --git a/packages/config/src/json-schema-document.ts b/packages/config/src/json-schema-document.ts index 9fe55f4f53..09498b0423 100644 --- a/packages/config/src/json-schema-document.ts +++ b/packages/config/src/json-schema-document.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { JsonSchema, Schema } from "effect"; import { ENV_PATTERN } from "./lib/env.ts"; type Node = Record; @@ -9,8 +9,8 @@ const DATA_KEYWORDS = new Set(["default", "examples", "enum", "const"]); const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties"]); /** - * Renders a published JSON Schema document, carrying the `x-secret` marker through and keeping - * pattern-keyed records open: the generator writes a boolean `additionalProperties` on them + * Renders a published draft-07 JSON Schema document, carrying the `x-secret` marker through and + * keeping pattern-keyed records open: the generator writes a boolean `additionalProperties` on them * (effect 4.0) with no per-schema setting, while the published documents never carried one there. * With `acceptEnvReferences`, number, integer, boolean, string-enum and string-list leaves also * accept the `env(VAR)` references the loader resolves. @@ -28,11 +28,16 @@ export function toPublishedJsonSchemaDocument( }); const root = openPatternKeyedRecords(document.schema); const definitions = openPatternKeyedRecords(document.definitions); + const published = JsonSchema.toDocumentDraft07({ + dialect: "draft-2020-12", + schema: options.acceptEnvReferences ? acceptEnvReferences(root) : root, + definitions: options.acceptEnvReferences ? mapSchemas(definitions) : definitions, + }); return { - $schema: "https://json-schema.org/draft/2020-12/schema", - ...(options.acceptEnvReferences ? acceptEnvReferences(root) : root), - ...(Object.keys(definitions).length > 0 - ? { $defs: options.acceptEnvReferences ? mapSchemas(definitions) : definitions } + $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, + ...published.schema, + ...(Object.keys(published.definitions).length > 0 + ? { definitions: published.definitions } : {}), }; } @@ -67,11 +72,11 @@ function acceptEnvReferences(node: T): T { return out as T; } -function mapSchemas(schemas: unknown): unknown { +function mapSchemas(schemas: T): T { if (!isNode(schemas)) return schemas; return Object.fromEntries( Object.entries(schemas).map(([name, schema]) => [name, acceptEnvReferences(schema)]), - ); + ) as T; } function acceptEnvReferencesInUnion(members: ReadonlyArray): Array { diff --git a/packages/config/src/json-schema-document.unit.test.ts b/packages/config/src/json-schema-document.unit.test.ts index 1e0cb4c19c..bde8768f18 100644 --- a/packages/config/src/json-schema-document.unit.test.ts +++ b/packages/config/src/json-schema-document.unit.test.ts @@ -1,5 +1,4 @@ -import type { ErrorObject, ValidateFunction } from "ajv"; -import Ajv2020 from "ajv/dist/2020"; +import Ajv, { type ErrorObject, type ValidateFunction } from "ajv"; import { Result, Schema, SchemaIssue } from "effect"; import { describe, expect, test } from "vitest"; import { CliConfigSchema, toCliConfigJsonSchema } from "./base.ts"; @@ -25,7 +24,7 @@ const isNode = (value: unknown): value is Node => typeof value === "object" && value !== null && !Array.isArray(value); const DATA_KEYWORDS = new Set(["default", "examples", "enum", "const"]); -const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties", "$defs"]); +const SCHEMA_MAP_KEYWORDS = new Set(["properties", "patternProperties", "definitions"]); type Path = ReadonlyArray; @@ -77,11 +76,7 @@ const patternOf = (node: Node): string => { return node.pattern; }; -// $schema is dropped so the tests do not depend on which draft the header names. -const compile = (document: Node): ValidateFunction => { - const schema = Object.fromEntries(Object.entries(document).filter(([key]) => key !== "$schema")); - return new Ajv2020({ strict: false }).compile(schema); -}; +const compile = (document: Node): ValidateFunction => new Ajv({ strict: false }).compile(document); const errorsFor = (validate: ValidateFunction, value: unknown): ReadonlyArray => validate(value) ? [] : [...(validate.errors ?? [])]; @@ -633,13 +628,23 @@ describe("toPublishedJsonSchemaDocument acceptEnvReferences", () => { const Port = Schema.Finite.annotate({ identifier: "Port" }); const Flag = Schema.Boolean.annotate({ identifier: "Flag" }); const document = publish(Schema.Struct({ a: Port, b: Port, c: Flag }), true); - expect(at(document, "$defs", "Port").anyOf).toEqual([{ type: "number" }, envBranch]); - expect(at(document, "$defs", "Flag").anyOf).toEqual([{ type: "boolean" }, envBranch]); + expect(at(document, "definitions", "Port").anyOf).toEqual([{ type: "number" }, envBranch]); + expect(at(document, "definitions", "Flag").anyOf).toEqual([{ type: "boolean" }, envBranch]); const validate = compile(document); expect(errorsFor(validate, { a: "env(A)", b: 2, c: "env(C)" })).toEqual([]); expect(failingPaths(validate, { a: "x", b: 2, c: true })).toContain("/a"); }); + test("renders shared definitions under draft-07 `definitions` with matching refs", () => { + const Port = Schema.Finite.annotate({ identifier: "Port" }); + const document = publish(Schema.Struct({ a: Port, b: Port }), true); + expect(document.$schema).toBe("http://json-schema.org/draft-07/schema#"); + expect(document).not.toHaveProperty("$defs"); + expect(Object.keys(at(document, "definitions"))).toEqual(["Port"]); + expect(at(document, "properties", "a")).toEqual({ $ref: "#/definitions/Port" }); + expect(JSON.stringify(document)).not.toContain("#/$defs/"); + }); + test("the project config schema carries no env-reference branch", () => { const project = toProjectConfigJsonSchema(); const maxRows = at(project, "properties", "api", "properties", "max_rows"); diff --git a/packages/config/src/project-config/project-schema.ts b/packages/config/src/project-config/project-schema.ts index 029291db74..2588b924e3 100644 --- a/packages/config/src/project-config/project-schema.ts +++ b/packages/config/src/project-config/project-schema.ts @@ -137,7 +137,7 @@ export const ProjectConfigSchema: StandardSchemaV1< Schema.make>(projectConfigAst), ); -/** JSON Schema (draft 2020-12) rendering of {@link ProjectConfigSchema}. */ +/** JSON Schema (draft-07) rendering of {@link ProjectConfigSchema}. */ export function toProjectConfigJsonSchema() { return toPublishedJsonSchemaDocument(ProjectConfigSchema, { onExcessProperty: "ignore" }); } diff --git a/packages/config/src/project-config/project-schema.unit.test.ts b/packages/config/src/project-config/project-schema.unit.test.ts index 445295a748..bbc72ed1ba 100644 --- a/packages/config/src/project-config/project-schema.unit.test.ts +++ b/packages/config/src/project-config/project-schema.unit.test.ts @@ -489,8 +489,8 @@ describe("toProjectConfigJsonSchema", () => { // cast. const document = JSON.parse(JSON.stringify(typedDocument)); - test("declares the draft 2020-12 dialect", () => { - expect(typedDocument.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + test("declares the draft-07 dialect", () => { + expect(typedDocument.$schema).toBe("http://json-schema.org/draft-07/schema#"); }); test("top-level properties are the seven hosted sections minus the fully-local realtime section", () => { diff --git a/packages/config/src/schema-metadata.ts b/packages/config/src/schema-metadata.ts index 01dca5072c..09fc1bcc5a 100644 --- a/packages/config/src/schema-metadata.ts +++ b/packages/config/src/schema-metadata.ts @@ -1,3 +1,3 @@ -export const CLI_CONFIG_SCHEMA_URL = "https://supabase.com/docs/cli/config.schema.json"; -/** Sibling of {@link CLI_CONFIG_SCHEMA_URL} for `ProjectConfigSchema`'s generated JSON Schema document (`toProjectConfigJsonSchema`) — same `/docs/cli/` path, same `apps/cli/scripts/generate-docs.ts` pathname-derivation convention. */ -export const PROJECT_CONFIG_SCHEMA_URL = "https://supabase.com/docs/cli/project-config.schema.json"; +export const CLI_CONFIG_SCHEMA_URL = "https://supabase.com/schemas/config/v1.json"; +/** Sibling of {@link CLI_CONFIG_SCHEMA_URL} for `ProjectConfigSchema`'s generated JSON Schema document. */ +export const PROJECT_CONFIG_SCHEMA_URL = "https://supabase.com/schemas/project-config/v1.json"; diff --git a/packages/stack/src/Lock.integration.test.ts b/packages/stack/src/Lock.integration.test.ts index a2b679efe0..71236241a5 100644 --- a/packages/stack/src/Lock.integration.test.ts +++ b/packages/stack/src/Lock.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { database15, selection } from "../tests/pins.ts"; import { + HOSTED_LOCK_SCHEMA_URL, Lock, LOCK_SCHEMA_URL, lockSelection, @@ -12,6 +13,8 @@ import { writeLock, } from "./Lock.ts"; +const V1_LOCK_SCHEMA_URL = "https://supabase.com/docs/cli/supabase-lock/v1.schema.json"; + const bundled = (pin: Lock["services"]["rest"]["resolved"]) => ({ specifier: "bundled", resolution: "resolved", resolved: pin }) as const; @@ -101,6 +104,41 @@ describe("lock", () => { ), ); + it.live("a fresh lock is written with the schema URL older CLIs require", () => + inProject((projectRoot, lockFile) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + yield* writeLock(projectRoot, lockOf(selection)); + + expect(parse(yield* fs.readFileString(lockFile))).toMatchObject({ + $schema: V1_LOCK_SCHEMA_URL, + }); + }), + ), + ); + + it.live("a lock naming the hosted schema URL reads and rewrites with the written one", () => + inProject((projectRoot, lockFile) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${projectRoot}/supabase`, { recursive: true }); + yield* fs.writeFileString( + lockFile, + stringify({ ...lockOf(selection), $schema: HOSTED_LOCK_SCHEMA_URL }), + ); + + const read = yield* readLock(projectRoot); + yield* writeLock(projectRoot, Option.getOrThrow(read)); + + expect(Option.getOrThrow(read).$schema).toBe(V1_LOCK_SCHEMA_URL); + expect(parse(yield* fs.readFileString(lockFile))).toMatchObject({ + $schema: V1_LOCK_SCHEMA_URL, + }); + }), + ), + ); + it.live("a lock with a newer lockfileVersion fails with an upgrade error", () => inProject((projectRoot, lockFile) => Effect.gen(function* () { diff --git a/packages/stack/src/Lock.ts b/packages/stack/src/Lock.ts index f4d1c31a15..9046f1b313 100644 --- a/packages/stack/src/Lock.ts +++ b/packages/stack/src/Lock.ts @@ -8,9 +8,12 @@ import { import { SLIM_IMAGE_GHCR_REGISTRY } from "./preparation/SlimEndpoints.ts"; import { publish } from "./StackNamespace.ts"; -/** The `$schema` URL of lock format v1; it never changes once published. */ +/** The `$schema` value every v1 lock is written with, because older CLIs require it. */ export const LOCK_SCHEMA_URL = "https://supabase.com/docs/cli/supabase-lock/v1.schema.json"; +/** Where the v1 lock schema is hosted; reads accept it as `$schema` so a later release can write it. */ +export const HOSTED_LOCK_SCHEMA_URL = "https://supabase.com/schemas/supabase-lock/v1.json"; + const LOCK_VERSION = 1; /** `supabase.lock` inside the project's `supabase` directory. */ @@ -129,6 +132,11 @@ const decodeVersion = Schema.decodeUnknownOption( ); const decodeLock = Schema.decodeUnknownEffect(Lock); +const withWrittenSchemaUrl = (document: unknown): unknown => + Predicate.isObject(document) && document["$schema"] === HOSTED_LOCK_SCHEMA_URL + ? { ...document, $schema: LOCK_SCHEMA_URL } + : document; + const sortedKeysReplacer = (_key: string, value: unknown): unknown => Predicate.isObject(value) && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) @@ -179,7 +187,7 @@ export const readLock = Effect.fn("Lock.read")(function* (projectRoot: string) { message: `${file} has lockfileVersion ${newer.value.lockfileVersion}, but this CLI reads version ${LOCK_VERSION}; upgrade the CLI.`, }); } - const lock = yield* decodeLock(document).pipe( + const lock = yield* decodeLock(withWrittenSchemaUrl(document)).pipe( Effect.mapError( (cause) => new LockInvalidError({ diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index a8159dcd47..2ac54a3964 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -87,6 +87,7 @@ export { holdLock } from "./StackNamespace.ts"; /** Whether a `holdLock` failure means another holder kept the lock for the whole budget. */ export { isLockBusy } from "./StackNamespace.ts"; export { + HOSTED_LOCK_SCHEMA_URL, Lock, LOCK_SCHEMA_URL, LockInvalidError, diff --git a/turbo.json b/turbo.json index 21923aea38..4dae097155 100644 --- a/turbo.json +++ b/turbo.json @@ -93,17 +93,10 @@ "inputs": [ "$TURBO_DEFAULT$", "!content/docs/commands/**", - "!public/cli/config.schema.json", - "!public/cli/project-config.schema.json", - "!public/cli/supabase-lock/v1.schema.json", + "!public/schemas/**", "$TURBO_ROOT$/mise.lock" ], - "outputs": [ - "content/docs/commands/**", - "public/cli/config.schema.json", - "public/cli/project-config.schema.json", - "public/cli/supabase-lock/v1.schema.json" - ] + "outputs": ["content/docs/commands/**", "public/schemas/**"] }, "@supabase/docs#build": { "cache": true, @@ -111,9 +104,7 @@ "inputs": [ "$TURBO_DEFAULT$", "!content/docs/commands/**", - "!public/cli/config.schema.json", - "!public/cli/project-config.schema.json", - "!public/cli/supabase-lock/v1.schema.json", + "!public/schemas/**", "$TURBO_ROOT$/mise.lock" ], "outputs": [".next/**"] From 097b7f2bd330bb99b4b8ea49efd1f5953b5edb02 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:34:50 +0000 Subject: [PATCH 10/22] feat(stack): add the remaining gateway routes and forwarded headers (CLI-2576) (#7382) ## TL;DR adds the gateway routes and forwarded headers the stack still lacked next to the legacy backend, so Studio's GraphiQL works on the stack ## whats introduced? the stack's api port now serves the routes the legacy backend had - `/graphql/v1`, adding `Content-Profile: graphql_public` only when the client sends none - `/rest-admin/v1` and `/.well-known/oauth-authorization-server` - `/pg/`, `/analytics/v1/` and `/pooler/v2/`, each while its service runs `/auth/v1/verify`, `/callback` and `/authorize` skip the api key rewrite, like the legacy backend gateway routes send `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Path` and `X-Forwarded-Prefix` with the legacy values and replace any client sent ones, while each service's own port stays unchanged ## ref - extends: https://github.com/supabase/cli/pull/7345 --- .../stack/src/HttpProxy.integration.test.ts | 138 ++++++++++++ packages/stack/src/HttpProxy.ts | 29 +++ .../stack/src/Network.integration.test.ts | 202 +++++++++++++++--- packages/stack/src/Network.ts | 23 +- packages/stack/src/Owner.ts | 22 +- packages/stack/src/host/Endpoints.ts | 62 +++++- 6 files changed, 416 insertions(+), 60 deletions(-) diff --git a/packages/stack/src/HttpProxy.integration.test.ts b/packages/stack/src/HttpProxy.integration.test.ts index f049201169..93846f5e49 100644 --- a/packages/stack/src/HttpProxy.integration.test.ts +++ b/packages/stack/src/HttpProxy.integration.test.ts @@ -608,6 +608,144 @@ it.live("tells upstreams the client address", () => const headerOf = (value: string | ReadonlyArray | undefined) => value === undefined || typeof value === "string" ? value : value.join(", "); +it.live("tells upstreams the scheme, host, port, path and stripped prefix the client reached", () => + Effect.scoped( + Effect.gen(function* () { + const forwarded = ( + headers: Readonly | undefined>>, + ) => ({ + proto: headerOf(headers["x-forwarded-proto"]), + host: headerOf(headers["x-forwarded-host"]), + port: headerOf(headers["x-forwarded-port"]), + path: headerOf(headers["x-forwarded-path"]), + prefix: headerOf(headers["x-forwarded-prefix"]), + }); + const backend = createServer((incoming, response) => { + response.end(JSON.stringify(forwarded(incoming.headers))); + }); + const sockets = new WebSocketServer({ server: backend }); + const upgraded = yield* Deferred.make, HttpProxyTestError>(); + sockets.on("connection", (socket, incoming) => { + Deferred.doneUnsafe(upgraded, Effect.succeed(forwarded(incoming.headers))); + socket.close(); + }); + const backendAddress = yield* listen(backend, { beforeClose: () => sockets.close() }); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + const target = Effect.succeed(backendAddress); + yield* proxy.setRoutes([ + { id: "functions", prefix: "/functions/v1", upstreamPrefix: "/", gateway: true, target }, + { + id: "graphql", + prefix: "/graphql/v1", + upstreamPrefix: "/rpc/graphql", + forwardedPrefix: "/graphql/v1", + gateway: true, + target, + }, + { id: "studio", prefix: "/", target }, + ]); + const spoofed = { + "x-forwarded-proto": "https", + "x-forwarded-host": "spoofed.example", + "x-forwarded-port": "443", + "x-forwarded-path": "/spoofed", + "x-forwarded-prefix": "/spoofed", + }; + const send = (path: string, headers: Readonly>) => + request(proxy.port, path, new Uint8Array(), headers, "GET").pipe( + Effect.provide(NodeHttpClient.layerNodeHttp), + Effect.map(({ body }) => JSON.parse(new TextDecoder().decode(body))), + ); + const port = String(proxy.port); + + expect(yield* send("/functions/v1/hello?name=x", spoofed)).toEqual({ + proto: "http", + host: "127.0.0.1", + port, + path: "/functions/v1/hello", + prefix: "/functions/v1/", + }); + expect(yield* send("/functions/v1/hello", { host: "LOCALHOST.:54321" })).toEqual( + expect.objectContaining({ host: "localhost" }), + ); + expect(yield* send("/graphql/v1", {})).toEqual( + expect.objectContaining({ path: "/graphql/v1", prefix: "/graphql/v1" }), + ); + /* A dedicated listener's route is not Kong's, so it adds none of them. */ + expect(yield* send("/project/default", { host: "Studio.Example.test:9999" })).toEqual({ + proto: undefined, + host: undefined, + port: undefined, + path: undefined, + prefix: undefined, + }); + + const client = new WebSocket(`ws://127.0.0.1:${proxy.port}/functions/v1/socket`, { + headers: spoofed, + }); + client.on("error", (cause) => + Deferred.doneUnsafe( + upgraded, + Effect.fail(new HttpProxyTestError({ message: cause.message, cause })), + ), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => client.terminate())); + expect(yield* Deferred.await(upgraded).pipe(Effect.timeout("10 seconds"))).toEqual({ + proto: "http", + host: "127.0.0.1", + port, + path: "/functions/v1/socket", + prefix: "/functions/v1/", + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("adds a route's default headers only when the client sent none", () => + Effect.scoped( + Effect.gen(function* () { + const backend = createServer((incoming, response) => { + response.end( + JSON.stringify({ url: incoming.url, profile: incoming.headers["content-profile"] }), + ); + }); + const backendAddress = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([ + { + id: "graphql", + prefix: "/graphql/v1", + upstreamPrefix: "/rpc/graphql", + addHeaders: { "content-profile": "graphql_public" }, + target: Effect.succeed(backendAddress), + }, + { + id: "mixed", + prefix: "/mixed", + upstreamPrefix: "/rpc/graphql", + addHeaders: { "Content-Profile": "graphql_public" }, + target: Effect.succeed(backendAddress), + }, + ]); + const send = (headers: Readonly>, path = "/graphql/v1") => + request(proxy.port, path, new Uint8Array(), headers).pipe( + Effect.provide(NodeHttpClient.layerNodeHttp), + Effect.map(({ body }) => JSON.parse(new TextDecoder().decode(body))), + ); + + expect(yield* send({})).toEqual({ url: "/rpc/graphql", profile: "graphql_public" }); + expect(yield* send({ "Content-Profile": "private" })).toEqual({ + url: "/rpc/graphql", + profile: "private", + }); + expect(yield* send({ "content-profile": "private" }, "/mixed")).toEqual({ + url: "/rpc/graphql", + profile: "private", + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + it.live("returns a gateway error naming the route and cause when a target cannot wake", () => { const logs: Array = []; return Effect.scoped( diff --git a/packages/stack/src/HttpProxy.ts b/packages/stack/src/HttpProxy.ts index a1394e4358..60232c13c1 100644 --- a/packages/stack/src/HttpProxy.ts +++ b/packages/stack/src/HttpProxy.ts @@ -28,6 +28,12 @@ export interface HttpRoute { readonly target: Effect.Effect; readonly upstreamPrefix?: string; readonly upstreamHost?: string; + /** Headers sent only when the client sent none, like Kong's request-transformer `add`. */ + readonly addHeaders?: Readonly>; + /** X-Forwarded-Prefix of a stripped route; defaults to `prefix/`, legacy Kong's path form. */ + readonly forwardedPrefix?: string; + /** A shared API listener route, which sends legacy Kong's X-Forwarded-Proto/Host/Port/Path/Prefix. */ + readonly gateway?: boolean; readonly keyRewrite?: HttpRouteKeyRewrite; /** Sends each write over a fresh upstream connection; reads still share the pool and its retry. */ readonly freshWrites?: boolean; @@ -144,6 +150,29 @@ const upstreamHeadersFor = (request: IncomingMessage, route: HttpRoute) => { result["x-forwarded-for"] = forwardedFor === undefined ? client : `${forwardedFor}, ${client}`; result["x-real-ip"] = client; } + /* Like Kong without trusted IPs: client-sent X-Forwarded-* values are replaced, the host is + nginx's `$host` (lowercase, no port or trailing dot: ngx_http_validate_host, release-1.19.10 + src/http/ngx_http_request.c#L2026-L2028), and only a stripped route sends its prefix. */ + if (route.gateway === true) { + const host = headerValue(headers.host) + ?.toLowerCase() + .replace(/\.?(?::\d*)?$/u, ""); + const port = request.socket.localPort; + const prefix = + route.upstreamPrefix === undefined || route.prefix === "/" + ? undefined + : (route.forwardedPrefix ?? `${route.prefix}/`); + result["x-forwarded-proto"] = "http"; + if (host === undefined) delete result["x-forwarded-host"]; + else result["x-forwarded-host"] = host; + if (port === undefined) delete result["x-forwarded-port"]; + else result["x-forwarded-port"] = String(port); + result["x-forwarded-path"] = pathnameFor(request.url ?? "/"); + if (prefix === undefined) delete result["x-forwarded-prefix"]; + else result["x-forwarded-prefix"] = prefix; + } + for (const [name, value] of Object.entries(route.addHeaders ?? {})) + result[name.toLowerCase()] ??= value; const keyRewrite = route.keyRewrite; if (keyRewrite?.policy === "bearer") { const value = bearerValueFor(headers, keyRewrite.keys); diff --git a/packages/stack/src/Network.integration.test.ts b/packages/stack/src/Network.integration.test.ts index 71ea4f4d3a..e4a5a93ee4 100644 --- a/packages/stack/src/Network.integration.test.ts +++ b/packages/stack/src/Network.integration.test.ts @@ -3,12 +3,12 @@ import { expect, it } from "@effect/vitest"; import { Context, Data, Deferred, Effect, Fiber, FileSystem, Hash, Layer, Path, Ref } from "effect"; import { randomUUID } from "node:crypto"; import * as Net from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- fixture retains an idle HTTP connection. -import { createServer } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- real socket fixture. +import { createServer, type RequestListener } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- real socket fixture. import { HttpBody, HttpClient } from "effect/http"; import * as Network from "./Network.ts"; import * as PortReservations from "./namespace/PortReservations.ts"; import { DOCKER_HOST_ALIAS } from "./runtime/Container.ts"; -import { freshWrites } from "./host/Endpoints.ts"; +import { freshWrites, joinRoutes, sharedRoutes } from "./host/Endpoints.ts"; import * as StackNamespace from "./StackNamespace.ts"; const makeTestState = (root: string) => @@ -45,34 +45,37 @@ const reservedPort = (root: string, stackId: string, key: string) => class FixtureError extends Data.TaggedError("FixtureError")<{ readonly message: string }> {} -const backend = Effect.acquireRelease( - Effect.callback< - { host: string; port: number; server: ReturnType }, - FixtureError - >((resume) => { - const server = createServer((request, response) => { - response.end(`backend:${request.url ?? "/"}`); - }); - const onError = (cause: Error) => - resume(Effect.fail(new FixtureError({ message: cause.message }))); - server.once("error", onError); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (address === null || typeof address === "string") - resume(Effect.fail(new FixtureError({ message: "no address" }))); - else resume(Effect.succeed({ host: "127.0.0.1", port: address.port, server })); - }); - return Effect.sync(() => { - server.off("error", onError); - server.close(); - }); - }), - ({ server }) => - Effect.callback((resume) => { - server.close(() => resume(Effect.void)); - return Effect.void; +const backendWith = (handler: RequestListener) => + Effect.acquireRelease( + Effect.callback< + { host: string; port: number; server: ReturnType }, + FixtureError + >((resume) => { + const server = createServer(handler); + const onError = (cause: Error) => + resume(Effect.fail(new FixtureError({ message: cause.message }))); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") + resume(Effect.fail(new FixtureError({ message: "no address" }))); + else resume(Effect.succeed({ host: "127.0.0.1", port: address.port, server })); + }); + return Effect.sync(() => { + server.off("error", onError); + server.close(); + }); }), -); + ({ server }) => + Effect.callback((resume) => { + server.close(() => resume(Effect.void)); + return Effect.void; + }), + ); + +const backend = backendWith((request, response) => { + response.end(`backend:${request.url ?? "/"}`); +}); const request = (host: string, port: number, path: string) => Effect.gen(function* () { @@ -574,6 +577,147 @@ it.live("wakes a sleeping backend when a request reaches its joined route", () = ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); +/** Answers with its own name and what a gateway route forwarded to it. */ +const echo = + (name: string): RequestListener => + (incoming, response) => { + response.end( + JSON.stringify({ + backend: name, + url: incoming.url, + authorization: incoming.headers.authorization, + profile: incoming.headers["content-profile"], + prefix: incoming.headers["x-forwarded-prefix"], + }), + ); + }; + +it.live("serves legacy Kong's gateway routes while each service runs", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-gateway-routes-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack")); + const api = yield* backendWith(echo("api")); + const admin = yield* backendWith(echo("admin")); + const keys = { + publishableKey: "sb_publishable_example", + secretKey: "sb_secret_example", + anonKey: "anon.jwt.value", + serviceRoleKey: "service.role.jwt", + }; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const register = (service: "rest" | "auth" | "pgmeta" | "analytics" | "pooler") => + network + .register({ + id: service, + endpoints: { + http: { + ...endpoint(api, Effect.succeed(true)), + shared: sharedRoutes({ service }, "http", keys, (name) => + Effect.succeed(name === "admin" ? admin : api), + ), + join: joinRoutes({ service }, "http"), + }, + }, + }) + .pipe(Effect.tap((namespace) => namespace.bind)); + const rest = yield* register("rest"); + for (const service of ["auth", "pgmeta", "analytics"] as const) yield* register(service); + const address = yield* rest.address("http", "host"); + const send = (path: string, headers: Readonly> = {}) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get(`http://${address.host}:${address.port}${path}`, { + headers, + }); + return response.status === 404 ? 404 : yield* response.json; + }).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)); + const publishable = { apikey: keys.publishableKey }; + + expect(yield* send("/graphql/v1", publishable)).toEqual({ + backend: "api", + url: "/rpc/graphql", + authorization: "Bearer anon.jwt.value", + profile: "graphql_public", + prefix: "/graphql/v1", + }); + expect(yield* send("/graphql/v1", { ...publishable, "content-profile": "app" })).toEqual( + expect.objectContaining({ profile: "app" }), + ); + expect(yield* send("/rest-admin/v1/ready")).toEqual({ + backend: "admin", + url: "/ready", + prefix: "/rest-admin/v1/", + }); + for (const path of [ + "/auth/v1/verify?token=t&type=magiclink", + "/auth/v1/callback?code=c", + "/auth/v1/authorize?provider=github", + ]) + expect(yield* send(path, publishable)).toEqual({ + backend: "api", + url: path.slice("/auth/v1".length), + prefix: path.split("?", 1)[0], + }); + expect(yield* send("/auth/v1/user", publishable)).toEqual( + expect.objectContaining({ url: "/user", authorization: "Bearer anon.jwt.value" }), + ); + expect(yield* send("/.well-known/oauth-authorization-server")).toEqual({ + backend: "api", + url: "/.well-known/oauth-authorization-server", + prefix: "/.well-known/oauth-authorization-server", + }); + expect(yield* send("/pg/tables")).toEqual({ backend: "api", url: "/tables", prefix: "/pg/" }); + expect(yield* send("/analytics/v1/health")).toEqual( + expect.objectContaining({ url: "/health", prefix: "/analytics/v1/" }), + ); + expect(yield* send("/pooler/v2/tenants")).toBe(404); + yield* register("pooler"); + expect(yield* send("/pooler/v2/tenants")).toEqual( + expect.objectContaining({ url: "/v2/tenants", prefix: "/pooler/v2/" }), + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("keeps a dedicated listener's client X-Forwarded headers without Kong's gateway set", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-dedicated-headers-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack")); + const target = yield* backendWith((incoming, response) => { + response.end(JSON.stringify(incoming.headers)); + }); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const studio = yield* network.register({ + id: "studio", + endpoints: { http: endpoint(target, Effect.succeed(true)) }, + }); + yield* studio.bind; + const address = yield* studio.address("http", "host"); + const client = yield* HttpClient.HttpClient; + const response = yield* client.get(`http://${address.host}:${address.port}/`, { + headers: { "x-forwarded-proto": "https", "x-forwarded-host": "spoofed.example" }, + }); + const headers = yield* response.json; + expect(headers).toEqual( + expect.objectContaining({ + "x-forwarded-proto": "https", + "x-forwarded-host": "spoofed.example", + "x-forwarded-for": "127.0.0.1", + "x-real-ip": "127.0.0.1", + }), + ); + for (const name of ["x-forwarded-port", "x-forwarded-path", "x-forwarded-prefix"]) + expect(headers).not.toHaveProperty(name); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + it.live("addresses docker runtime endpoints through the stack host alias", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/stack/src/Network.ts b/packages/stack/src/Network.ts index 33397a07a3..36fc8cf230 100644 --- a/packages/stack/src/Network.ts +++ b/packages/stack/src/Network.ts @@ -8,10 +8,12 @@ import { makeHttpProxy, type HttpAccessSink, type HttpProxy, type HttpRoute } fr export type NetworkRuntime = "native" | "docker" | "podman"; +/* `target` serves a route from another backend than its endpoint's, like PostgREST's admin port. */ type RouteContribution = Pick< HttpRoute, - "prefix" | "upstreamPrefix" | "upstreamHost" | "keyRewrite" ->; + "prefix" | "upstreamPrefix" | "upstreamHost" | "addHeaders" | "forwardedPrefix" | "keyRewrite" +> & + Partial>; export interface NetworkEndpoint { readonly protocol: "tcp" | "http"; @@ -122,20 +124,19 @@ const makeNetwork = (options: { const routeKey = (route: Pick) => `${route.id}:${route.prefix}`; - /** Maps one endpoint's route contributions to `HttpRoute`s on a dedicated, shared or joined listener. */ + /** Maps one endpoint's route contributions to `HttpRoute`s; shared and joined ones are `gateway` routes. */ const toHttpRoutes = ( id: string, contributions: ReadonlyArray, endpoint: Pick, + gateway: boolean, ): ReadonlyArray => contributions.map((route) => ({ + ...route, id, - prefix: route.prefix, - upstreamPrefix: route.upstreamPrefix, - upstreamHost: route.upstreamHost, - ...(route.keyRewrite === undefined ? {} : { keyRewrite: route.keyRewrite }), + ...(gateway ? { gateway: true } : {}), ...(endpoint.freshWrites === true ? { freshWrites: true } : {}), - target: endpoint.backend, + target: route.target ?? endpoint.backend, })); /** Installs a namespace's joined routes onto the shared listener, or queues them if it does not exist yet. */ @@ -144,7 +145,7 @@ const makeNetwork = (options: { join: NonNullable, endpoint: NetworkEndpoint, ) { - const routes = toHttpRoutes(routeId, join, endpoint); + const routes = toHttpRoutes(routeId, join, endpoint, true); const ownKeys = new Set(routes.map(routeKey)); const current = yield* Ref.get(shared); if (current === undefined) { @@ -209,7 +210,7 @@ const makeNetwork = (options: { yield* probe(key, host, port); const proxy = yield* makeHttpProxy({ host, port }); yield* proxy.setRoutes( - toHttpRoutes(id, [{ prefix: "/" }], endpoint), + toHttpRoutes(id, [{ prefix: "/" }], endpoint, false), ); return { proxy }; } @@ -248,7 +249,7 @@ const makeNetwork = (options: { else if (seeded.length > 0) yield* Ref.set(pendingJoins, []); const routes = [ ...current.routes, - ...toHttpRoutes(id, endpoint.shared, endpoint), + ...toHttpRoutes(id, endpoint.shared, endpoint, true), ]; yield* current.proxy.setRoutes(routes); yield* Ref.set(shared, { ...current, routes }); diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index 4467d43b89..397a70ce06 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -579,24 +579,22 @@ const makeOwner = Effect.fn("Owner.make")(function* ( Effect.flatMap((next) => mergeInputs(next, inputs)), Effect.flatMap(requireInputs), ); + const backendFor = (name: string): NetworkEndpoint["backend"] => + orchestrator.acquire(id, name !== "inspector", `traffic on endpoint ${name}`).pipe( + Effect.andThen(recipe.endpoint(name)), + Effect.map(backendAddress), + Effect.mapError((cause) => + cause instanceof ProxyError ? cause : new ProxyError({ message: cause.message, cause }), + ), + ); const endpoints = Object.fromEntries( endpointNames(initial).map((name) => { - const shared = sharedRoutes(initial, name, routeKeys); + const shared = sharedRoutes(initial, name, routeKeys, backendFor); const join = joinRoutes(initial, name); const endpoint: NetworkEndpoint = { protocol: name === "http" ? "http" : "tcp", port: endpointPort(initial, name), - backend: orchestrator - .acquire(id, name !== "inspector", `traffic on endpoint ${name}`) - .pipe( - Effect.andThen(recipe.endpoint(name)), - Effect.map(backendAddress), - Effect.mapError((cause) => - cause instanceof ProxyError - ? cause - : new ProxyError({ message: cause.message, cause }), - ), - ), + backend: backendFor(name), enabled, ...(freshWrites(initial, name) ? { freshWrites: true } : {}), ...(shared === undefined ? {} : { shared }), diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index 2a45395b0d..535ca9ba5d 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -57,11 +57,15 @@ export const sharesApiEndpoint = (creation: EndpointIntents): boolean => type RouteKeys = NonNullable[number]["keyRewrite"]>["keys"]; -/** Routes a service's HTTP endpoint on the shared API listener, or `undefined` for a dedicated one. */ +/** + * Routes a service's HTTP endpoint on the shared API listener, or `undefined` for a dedicated one. + * `backend` reaches another of the service's endpoints, like PostgREST's admin port. + */ export const sharedRoutes = ( - creation: ServiceCreation, + creation: Pick, name: string, keys: RouteKeys, + backend: (endpoint: string) => NetworkEndpoint["backend"], ): NetworkEndpoint["shared"] => { const route = name === "http" ? apiRoute(creation.service) : undefined; if (route === undefined) return undefined; @@ -72,6 +76,7 @@ export const sharedRoutes = ( prefix: "/realtime/v1/api", upstreamPrefix: "/api", upstreamHost: "realtime-dev", + forwardedPrefix: "/realtime/v1/api", keyRewrite: { policy: "bearer", keys }, }, { @@ -87,8 +92,32 @@ export const sharedRoutes = ( { prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }, ]; case "rest": + return [ + { prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }, + { + prefix: "/graphql/v1", + upstreamPrefix: "/rpc/graphql", + forwardedPrefix: "/graphql/v1", + addHeaders: { "content-profile": "graphql_public" }, + keyRewrite: { policy: "bearer", keys }, + }, + { prefix: "/rest-admin/v1", upstreamPrefix: "/", target: backend("admin") }, + ]; case "auth": - return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }]; + return [ + /* Like legacy Kong, email links, OAuth redirects and discovery keep the client's headers. */ + ...["verify", "callback", "authorize"].map((path) => ({ + prefix: `${route}/${path}`, + upstreamPrefix: `/${path}`, + forwardedPrefix: `${route}/${path}`, + })), + { + prefix: "/.well-known/oauth-authorization-server", + upstreamPrefix: "/.well-known/oauth-authorization-server", + forwardedPrefix: "/.well-known/oauth-authorization-server", + }, + { prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }, + ]; case "functions": return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "sb-api-key", keys } }]; default: @@ -96,11 +125,28 @@ export const sharedRoutes = ( } }; -/** Shared-listener routes a dedicated HTTP endpoint also serves: Studio's MCP server at `/mcp`. */ -export const joinRoutes = (creation: ServiceCreation, name: string): NetworkEndpoint["join"] => - creation.service === "studio" && name === "http" - ? [{ prefix: "/mcp", upstreamPrefix: "/api/mcp" }] - : undefined; +/** + * Shared-listener routes a dedicated HTTP endpoint also serves while its service runs, like + * legacy Kong's: Studio's MCP server, postgres-meta, Logflare and Supavisor's API. + */ +export const joinRoutes = ( + creation: Pick, + name: string, +): NetworkEndpoint["join"] => { + if (name !== "http") return undefined; + switch (creation.service) { + case "studio": + return [{ prefix: "/mcp", upstreamPrefix: "/api/mcp", forwardedPrefix: "/mcp" }]; + case "pgmeta": + return [{ prefix: "/pg", upstreamPrefix: "/" }]; + case "analytics": + return [{ prefix: "/analytics/v1", upstreamPrefix: "/" }]; + case "pooler": + return [{ prefix: "/pooler/v2", upstreamPrefix: "/v2" }]; + default: + return undefined; + } +}; /** * Whether an endpoint's writes each take a fresh upstream connection instead of the pool: only From 96781a40268a5da929f22f5637b892966e9419e6 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 29 Oct 2026 11:56:34 +0000 Subject: [PATCH 11/22] ci(cli): authenticate mirror detect job to ECR Public (#7390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Detect unmirrored images` job in `mirror-template-images.yml` checks `public.ecr.aws` without logging in. Anonymous ECR Public pulls share a per-IP data quota with other GitHub-hosted runner users. That quota is often already used up, so most develop pushes fail with `429 toomanyrequests: Data limit exceeded` on every ECR target, and retrying doesn't help. This gives the detect job the same auth as the mirror job (`cli-go-mirror-image.yml`): `id-token: write`, assume `PROD_AWS_ROLE` in `us-east-1`, and log in to `public.ecr.aws`. The presence check itself is unchanged. Fixes CLI-2900. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .github/workflows/mirror-template-images.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index 1b45c64493..0e63be1f2a 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -41,6 +41,7 @@ jobs: permissions: contents: read packages: read + id-token: write outputs: missing: ${{ steps.detect.outputs.missing }} steps: @@ -54,6 +55,19 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + # Anonymous public.ecr.aws pulls share a per-IP data quota with every + # other GitHub-hosted runner user, which is often exhausted already. + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ secrets.PROD_AWS_ROLE }} + aws-region: us-east-1 + + - name: Log in to public.ecr.aws + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: public.ecr.aws + - name: Log in to ghcr.io uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: From fd2e017d5fe01ea2effc40be4fbf55c7620d81c0 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 29 Oct 2026 12:31:12 +0000 Subject: [PATCH 12/22] fix(cli): recover start and db start from a stopped database container (#7369) ## Summary If a `supabase start` dies after Docker creates the database container but before it starts it, the container is left in Docker's `created` state. That can happen if the process is killed, a port is already taken, or a second `start` rolls back the first one's containers. Every `supabase start` after that printed `supabase start is already running.` and then failed with `container is not running: created`. The way out was to remove the container by hand, and nothing pointed you there. Looking into it turned up three more problems along the same path: - The leftover volume never gets set up. `start` decided whether to run the first-run setup (schema, roles, migrations, seed) purely on whether the database volume already existed. A leftover volume that Postgres never initialised still exists, so after `supabase stop` and `supabase start` the setup was skipped and the stack came up without `supabase_migrations` or any of the project's migrations. - Two starts on one project break each other. When one fails, its rollback prunes the project's containers and network by label, including the ones the other start is still bringing up. Usually both fail, and they can leave behind exactly the `created` container above. - `db start` only checked that the database container existed. For a stopped or never-started one it printed `Postgres database is already running.` and exited 0 with Postgres down. The local database auto-start in `db schema declarative generate` and `sync` used the same check, skipped the start and then failed to connect. This fixes all of them. ### What changes - `start` recovers a `created` database container the same way it already recovers an `exited` one. It removes the project's containers and network, keeps the volumes, and brings the stack up again. - A database container that isn't running and can't be recovered (an unknown state, or Bitbucket Pipelines) now fails with ` container is not running: ` and suggests `supabase stop`. It no longer prints `supabase start is already running.` first. - `start` and `db start` check whether a reused volume was ever initialised. Between `docker create` and `docker start` they run `docker cp :/var/lib/postgresql/data/PG_VERSION -`, which reads through the volume mount while the container is still stopped. The Postgres image runs `initdb` when `PG_VERSION` is missing, so a missing file means the volume is uninitialised. In that case the first-run setup runs and bucket seeding happens as on a fresh volume, and a failed bring-up prunes the volume like it does for a fresh one. Only Docker's or Podman's own message for a missing `PG_VERSION` counts as uninitialised. Any other outcome, including a connection error, is treated as initialised, so an ambiguous probe never runs setup over real data. - `start` and `db start` take a per-project lock at `$SUPABASE_HOME/locks/start-.lock`. It's held through bring-up and rollback. A second start on the same project prints `Waiting for another supabase start for this project to finish...` and waits for the first, up to 10 minutes, before failing with `LocalStackStartBusyError`. If the lock file can't be created, the start carries on without it rather than failing. `stop` doesn't take the lock. - `db start` and the declarative auto-start now read the container's state as well as whether it exists. A database container that isn't running is removed on its own with `docker container rm -f`, keeping the volume, and brought up again. The `PG_VERSION` check above decides whether setup runs. The project's other containers are left alone, since `db start` only manages the database. In Bitbucket Pipelines they fail with the same not-running error as `start` instead. `db reset` and `db diff` still only check that the container exists, since `db reset` recreates it anyway. - If `db start` fails, it now rolls back only what it creates: the database container, the database volume when this run created it or found it uninitialised, and any project network nothing else is using. It used to remove every container in the project and, on a fresh volume, prune every unused project volume, so a failed recovery could take down the rest of a running stack. A stopped database with `--from-backup` is refused before anything is removed. - Under `--debug`, a missing profile file now logs `No profile file at ; using the default profile.` instead of the raw file-not-found error. That line was the only thing in the original report that looked like an error, and it wasn't one. ### Not covered - If `start` is killed after Postgres has started running `initdb` but before the CLI's setup finishes, the volume has `PG_VERSION`, so the next start treats it as initialised and skips setup. That's the same as before this change. Catching it would need a marker written once setup completes. I'd rather not guess at that from schema contents, since a project with no migrations looks the same as an unfinished one. ## Linked issue Closes #7344 (CLI-2876) --- .../db-bootstrap/container-lifecycle.ts | 44 +- .../container-lifecycle.unit.test.ts | 117 +++++ .../local-db-running.integration.test.ts | 191 ++++++- .../db-bootstrap/local-db-running.ts | 135 +++-- .../db-bootstrap/pgdata-snapshot.ts | 25 +- .../db-bootstrap/postgres.service.ts | 5 +- .../command-internal/db-bootstrap/rollback.ts | 61 ++- .../db-bootstrap/rollback.unit.test.ts | 152 +++++- .../shadow-cache.integration.test.ts | 6 +- .../db-bootstrap/start-database.ts | 57 ++- .../db-bootstrap/start-local-database.ts | 84 ++- .../src/command-internal/docker-remove-all.ts | 52 +- .../local-stack-start-lock.ts | 89 ++++ .../cli/src/command-internal/status-errors.ts | 11 +- .../db/reset/reset.integration.test.ts | 2 +- .../declarative/generate/SIDE_EFFECTS.md | 18 +- .../schema/declarative/sync/SIDE_EFFECTS.md | 10 +- .../shared/pgdelta.seam.integration.test.ts | 72 ++- .../commands/db/shared/pgdelta.seam.layer.ts | 6 +- .../cli/src/commands/db/start/SIDE_EFFECTS.md | 120 +++-- .../db/start/start.integration.test.ts | 483 +++++++++++++++++- .../cli/src/commands/db/start/start.layers.ts | 1 + apps/cli/src/commands/start/SIDE_EFFECTS.md | 51 +- apps/cli/src/commands/start/start.command.ts | 1 + apps/cli/src/commands/start/start.handler.ts | 56 +- .../commands/start/start.integration.test.ts | 430 +++++++++++++++- apps/cli/src/config/command-settings.layer.ts | 8 +- .../command-settings.layer.unit.test.ts | 47 ++ .../telemetry/__fixtures__/error-tags.txt | 1 + apps/cli/tests/helpers/command-mocks.ts | 12 +- apps/cli/tests/helpers/start-lock.ts | 29 ++ 31 files changed, 2063 insertions(+), 313 deletions(-) create mode 100644 apps/cli/src/command-internal/local-stack-start-lock.ts create mode 100644 apps/cli/tests/helpers/start-lock.ts diff --git a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts index ee66e005ba..7b56d105cd 100644 --- a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts @@ -2,7 +2,8 @@ * Given a fully-resolved {@link StartContainerSpec}, sets the two project-identity labels, * provisions the container's own named volumes, builds the `docker create` argv, spawns `docker * create`, copies any `secretFiles` in via `docker cp` (see - * {@link copyStartSecretFilesIntoContainer}), then spawns `docker start`. + * {@link copyStartSecretFilesIntoContainer}), runs the caller's optional `beforeStart` hook, then + * spawns `docker start`. * * Network creation is not part of this per-container function; see {@link ensureNetwork} for why * it runs once instead of once per container. @@ -36,6 +37,7 @@ import { isDockerClientEnvKey, type StartContainerSpec, } from "./docker-create-args.ts"; +import { POSTGRES_DATA_DIR } from "./postgres.service.ts"; type StartSecretFileSpec = NonNullable[number]; @@ -334,7 +336,7 @@ export class VolumeInspectError extends Data.TaggedError("VolumeInspectError")<{ } /** Docker's/Podman's "no such volume" stderr shape for `volume inspect`. */ -function isVolumeNotFoundMessage(message: string): boolean { +export function isVolumeNotFoundMessage(message: string): boolean { return /no such volume/iu.test(message); } @@ -645,10 +647,42 @@ function extractPreStartArchiveIntoContainer( ); } +/** Docker's, Podman's and older Docker clients' `cp` diagnostics for a missing `PG_VERSION`. */ +const PG_VERSION_MISSING_PATTERN = + /could not find the file \S*\/PG_VERSION in container|"\S*\/PG_VERSION" could not be found on container|no such container:path: \S+:\S*\/PG_VERSION/iu; + +/** + * Whether the Postgres data directory of a created, not-yet-started container already holds a + * cluster (`PG_VERSION`, written by `initdb`). Only a diagnostic reporting `PG_VERSION` itself + * as missing resolves `false`; every other outcome resolves `true`, so an ambiguous probe never + * runs setup over real data. + */ +export const postgresDataInitialized = Effect.fn("ContainerLifecycle.postgresDataInitialized")( + function* (spawner: Spawner, containerId: string) { + const initialized = yield* Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli( + spawner, + ["cp", `${containerId}:${POSTGRES_DATA_DIR}/PG_VERSION`, "-"], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ); + return exitCode === 0 || !PG_VERSION_MISSING_PATTERN.test(stderr); + }), + ).pipe(Effect.orElseSucceed(() => true)); + yield* Effect.annotateCurrentSpan({ "db.data_initialized": initialized }); + return initialized; + }, +); + /** * Provisions this container's named volumes (skipped under Bitbucket Pipelines), creates it, * copies any `secretFiles` and `preStartArchives` in via `docker cp` while it's created but not - * yet running (so its entrypoint never races the copy), then starts it. + * yet running (so its entrypoint never races the copy), runs `beforeStart` with the container id + * while it is still stopped, then starts it. * * Resolves to the created container's id/name on success. */ @@ -656,6 +690,7 @@ export function createContainer( spawner: Spawner, spec: StartContainerSpec, opts: ContainerOpts, + beforeStart?: (containerId: string) => Effect.Effect, ): Effect.Effect { return Effect.gen(function* () { const labels: Record = { @@ -711,6 +746,9 @@ export function createContainer( }).pipe(Effect.orElseSucceed(() => 0)), ), ); + if (beforeStart !== undefined) { + yield* beforeStart(containerId); + } yield* dockerStartContainer(spawner, containerId, finalSpec); return containerId; }).pipe( 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 255ae0d3b9..49429d5fe5 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 @@ -15,6 +15,7 @@ import { removeContainer, removeVolume, createContainer, + postgresDataInitialized, volumeExists, } from "./container-lifecycle.ts"; import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; @@ -524,6 +525,40 @@ describe("createContainer secretFiles", () => { }); }); +describe("createContainer beforeStart", () => { + it.live( + "runs the hook with the created container id after the secret copy and before start", + () => { + const mock = mockSpawner((args) => { + if (args[0] === "create") return { exitCode: 0, stdout: "container-id-hook\n" }; + return { exitCode: 0 }; + }); + const spec: StartContainerSpec = { + ...baseSpec, + binds: [], + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "super-secret-content" }], + }; + + return createContainer( + mock.spawner, + spec, + { projectId: "proj", isBitbucketPipeline: false, workdir: workdir.current, extraHosts: [] }, + (containerId) => postgresDataInitialized(mock.spawner, containerId).pipe(Effect.asVoid), + ).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("container-id-hook"); + expect(mock.spawned).toEqual([ + expect.arrayContaining(["create"]), + ["cp", "-", "container-id-hook:/"], + ["cp", "container-id-hook:/var/lib/postgresql/data/PG_VERSION", "-"], + ["start", "container-id-hook"], + ]); + }), + ); + }, + ); +}); + describe("ensureNetwork", () => { it.live("creates the network with labels when it does not exist yet", () => { const mock = mockSpawner((args) => @@ -728,6 +763,88 @@ describe("volumeExists", () => { }); }); +describe("postgresDataInitialized", () => { + const pgVersionProbe = ["cp", "container-id-pg:/var/lib/postgresql/data/PG_VERSION", "-"]; + + it.live("resolves true when PG_VERSION can be copied out (cp exits 0)", () => { + const mock = mockSpawner(() => ({ exitCode: 0 })); + return postgresDataInitialized(mock.spawner, "container-id-pg").pipe( + Effect.map((initialized) => { + expect(initialized).toBe(true); + expect(mock.spawned).toEqual([pgVersionProbe]); + }), + ); + }); + + const missingFileStderr = { + Docker: + "Error response from daemon: Could not find the file /var/lib/postgresql/data/PG_VERSION in container container-id-pg\n", + Podman: + 'Error: "/var/lib/postgresql/data/PG_VERSION" could not be found on container container-id-pg: no such file or directory\n', + "an older Docker client": + "Error: No such container:path: container-id-pg:/var/lib/postgresql/data/PG_VERSION\n", + "Docker after a config warning": + "WARNING: Error loading config file: open /home/me/.docker/config.json: no such file or directory\nError response from daemon: Could not find the file /var/lib/postgresql/data/PG_VERSION in container container-id-pg\n", + }; + for (const [runtime, stderr] of Object.entries(missingFileStderr)) { + it.live(`resolves false when ${runtime} reports the PG_VERSION file missing`, () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr })); + return postgresDataInitialized(mock.spawner, "container-id-pg").pipe( + Effect.map((initialized) => { + expect(initialized).toBe(false); + }), + ); + }); + } + + const otherFailureStderr = { + "a missing container": "Error response from daemon: No such container: container-id-pg\n", + "a permission error": "Error response from daemon: permission denied\n", + "an unreachable daemon": "Cannot connect to the Docker daemon at unix:///var/run/docker.sock\n", + "an unreadable Docker CLI config": + "open /home/me/.docker/config.json: no such file or directory\n", + "a Docker socket that does not exist": + 'error during connect: Head "http://%2Fvar%2Frun%2Fdocker.sock/_ping": dial unix /var/run/docker.sock: connect: no such file or directory\n', + "a Docker socket that does not exist when requesting the PG_VERSION archive": + 'error during connect: Head "http://%2Fvar%2Frun%2Fdocker.sock/v1.47/containers/container-id-pg/archive?path=%2Fvar%2Flib%2Fpostgresql%2Fdata%2FPG_VERSION": dial unix /var/run/docker.sock: connect: no such file or directory\n', + "a Podman socket that does not exist": + 'Error: unable to connect to Podman socket: Get "http://d/v5.2.0/libpod/_ping": dial unix /run/user/1000/podman/podman.sock: connect: no such file or directory\n', + "a Podman socket that does not exist when requesting the PG_VERSION archive": + 'Error: unable to connect to Podman socket: Get "http://d/v5.2.0/libpod/containers/container-id-pg/archive?path=%2Fvar%2Flib%2Fpostgresql%2Fdata%2FPG_VERSION": dial unix /run/user/1000/podman/podman.sock: connect: no such file or directory\n', + "a Docker config warning beside an unrelated PG_VERSION error": + "WARNING: Error loading config file: open /home/me/.docker/config.json: no such file or directory\nError response from daemon: permission denied reading /var/lib/postgresql/data/PG_VERSION\n", + "empty stderr": "", + }; + for (const [cause, stderr] of Object.entries(otherFailureStderr)) { + it.live(`resolves true (never re-seeds) on a non-zero exit with ${cause}`, () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr })); + return postgresDataInitialized(mock.spawner, "container-id-pg").pipe( + Effect.map((initialized) => { + expect(initialized).toBe(true); + }), + ); + }); + } + + it.live("resolves true when no container runtime can be spawned", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn ENOENT", + }), + ), + ); + return postgresDataInitialized(spawner, "container-id-pg").pipe( + Effect.map((initialized) => { + expect(initialized).toBe(true); + }), + ); + }); +}); + describe("removeContainer", () => { it.live("spawns `docker container rm -f ` and succeeds on exit 0", () => { const mock = mockSpawner(() => ({ exitCode: 0 })); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts index 7bd9526ee1..5da28f5bc9 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts @@ -15,6 +15,7 @@ import { LocalDockerEngine, dockerEndpointSocketPath, isLocalDbRunning, + localDbState, localDockerEngineLayer, } from "./local-db-running.ts"; @@ -109,10 +110,10 @@ const localDockerEngineTestLayer = localDockerEngineLayer.pipe( Layer.provide(runtimeInfoLayer), ); -const engineContainerExists = (containerId: string) => +const engineInspectContainer = (containerId: string) => Effect.gen(function* () { const engine = yield* LocalDockerEngine; - return yield* engine.containerExists(containerId); + return yield* engine.inspectContainer(containerId); }).pipe(Effect.provide(localDockerEngineTestLayer)); describe("LocalDockerEngine (direct Engine-API transport)", () => { @@ -122,18 +123,47 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { Effect.gen(function* () { const engine = yield* makeEngineServer((_req, res) => { res.writeHead(200, { "content-type": "application/json", ...ENGINE_IDENTITY_HEADERS }); - res.end(JSON.stringify({ Id: "abc123", State: { Status: "created" } })); + res.end(JSON.stringify({ Id: "abc123", State: { Running: true, Status: "running" } })); }); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); - expect(answer).toEqual(Option.some(true)); + expect(answer).toEqual(Option.some({ exists: true, running: true, status: "running" })); expect(engine.requestedUrls).toEqual(["/containers/supabase_db_engine-probe/json"]); }), ), ); + it.live.each([ + ["a stopped container", { Running: false, Status: "exited" }, false, "exited"], + ["a created container", { Running: false, Status: "created" }, false, "created"], + ["a running container", { Running: true, Status: "running" }, true, "running"], + ["a missing State", undefined, true, ""], + ["a non-object State", "exited", true, ""], + ] as const)( + "reports %s from the Engine inspect payload's State", + ([, state, running, status]) => + Effect.scoped( + Effect.gen(function* () { + const engine = yield* makeEngineServer((_req, res) => { + res.writeHead(200, { + "content-type": "application/json", + ...ENGINE_IDENTITY_HEADERS, + }); + res.end( + JSON.stringify({ Id: "abc123", ...(state === undefined ? {} : { State: state }) }), + ); + }); + const answer = yield* withDockerHost( + `unix://${engine.socketPath}`, + engineInspectContainer("supabase_db_engine-probe"), + ); + expect(answer).toEqual(Option.some({ exists: true, running, status })); + }), + ), + ); + it.live("answers absent for an Engine 404", () => Effect.scoped( Effect.gen(function* () { @@ -143,9 +173,9 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); - expect(answer).toEqual(Option.some(false)); + expect(answer).toEqual(Option.some({ exists: false })); }), ), ); @@ -169,19 +199,19 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const present = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("present"), + engineInspectContainer("present"), ); const absent = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("absent"), + engineInspectContainer("absent"), ); const viaServerHeader = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("server-header"), + engineInspectContainer("server-header"), ); expect(present).toEqual(Option.none()); expect(absent).toEqual(Option.none()); - expect(viaServerHeader).toEqual(Option.some(false)); + expect(viaServerHeader).toEqual(Option.some({ exists: false })); }), ), ); @@ -197,11 +227,11 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const empty = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("empty"), + engineInspectContainer("empty"), ); const malformed = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("malformed"), + engineInspectContainer("malformed"), ); expect(empty).toEqual(Option.none()); expect(malformed).toEqual(Option.none()); @@ -223,7 +253,7 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), @@ -238,7 +268,7 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { const engine = yield* makeEngineServer(() => {}); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), @@ -255,7 +285,7 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), @@ -276,7 +306,7 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), @@ -297,7 +327,7 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const traced = Effect.gen(function* () { const engine = yield* LocalDockerEngine; - return yield* engine.containerExists("supabase_db_engine-probe"); + return yield* engine.inspectContainer("supabase_db_engine-probe"); }).pipe(Effect.provide(localDockerEngineTestLayer.pipe(Layer.provide(recorder)))); return Effect.scoped( Effect.gen(function* () { @@ -360,7 +390,7 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { }); const answer = yield* withDockerHost( `unix://${engine.socketPath}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ); expect(answer).toEqual(Option.none()); }), @@ -373,7 +403,7 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { const missingDir = mkdtempSync(join(tmpdir(), "ldbgone-")); return withDockerHost( `unix://${join(missingDir, "never-created.sock")}`, - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ).pipe( Effect.ensuring(Effect.sync(() => rmSync(missingDir, { recursive: true, force: true }))), Effect.map((answer) => { @@ -385,7 +415,7 @@ describe("LocalDockerEngine (direct Engine-API transport)", () => { it.live("gives no answer for an endpoint the transport cannot address (ssh)", () => withDockerHost( "ssh://user@remote-host", - engineContainerExists("supabase_db_engine-probe"), + engineInspectContainer("supabase_db_engine-probe"), ).pipe( Effect.map((answer) => { expect(answer).toEqual(Option.none()); @@ -411,10 +441,10 @@ describe("isLocalDbRunning", () => { const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); return probe(mock.layer).pipe( Effect.provideService(LocalDockerEngine, { - containerExists: (containerId) => + inspectContainer: (containerId) => Effect.sync(() => { asked.push(containerId); - }).pipe(Effect.as(Option.some(true))), + }).pipe(Effect.as(Option.some({ exists: true, running: true, status: "running" }))), }), Effect.map((running) => { expect(running).toBe(true); @@ -424,11 +454,39 @@ describe("isLocalDbRunning", () => { ); }); + it.live("still reports a stopped container as present, from the Engine answer", () => { + const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); + return probe(mock.layer).pipe( + Effect.provideService(LocalDockerEngine, { + inspectContainer: () => + Effect.succeed(Option.some({ exists: true, running: false, status: "exited" })), + }), + Effect.map((exists) => { + expect(exists).toBe(true); + expect(mock.spawned).toEqual([]); + }), + ); + }); + + it.live("still reports a stopped container as present, from the container CLI stdout", () => { + const mock = mockContainerCliSpawner(() => ({ + stdout: [JSON.stringify([{ State: { Running: false, Status: "exited" } }])], + })); + return probe(mock.layer).pipe( + Effect.provideService(LocalDockerEngine, { + inspectContainer: () => Effect.succeed(Option.none()), + }), + Effect.map((exists) => { + expect(exists).toBe(true); + }), + ); + }); + it.live("trusts a definitive Engine 404 without spawning the container CLI", () => { const mock = mockContainerCliSpawner(() => ({ exitCode: 0 })); return probe(mock.layer).pipe( Effect.provideService(LocalDockerEngine, { - containerExists: () => Effect.succeed(Option.some(false)), + inspectContainer: () => Effect.succeed(Option.some({ exists: false })), }), Effect.map((running) => { expect(running).toBe(false); @@ -444,7 +502,7 @@ describe("isLocalDbRunning", () => { })); return probe(mock.layer).pipe( Effect.provideService(LocalDockerEngine, { - containerExists: () => Effect.succeed(Option.none()), + inspectContainer: () => Effect.succeed(Option.none()), }), Effect.map((running) => { expect(running).toBe(false); @@ -478,3 +536,86 @@ describe("isLocalDbRunning", () => { }, ); }); + +describe("localDbState", () => { + const stateOf = (spawnerLayer: Layer.Layer) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = mkdtempSync(join(tmpdir(), "ldbstate-")); + return yield* localDbState(spawner, fs, path, workdir, "engine-probe").pipe( + Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))), + ); + }).pipe( + Effect.provide(spawnerLayer), + Effect.provide(BunServices.layer), + Effect.provideService(LocalDockerEngine, { + inspectContainer: () => Effect.succeed(Option.none()), + }), + ); + + it.live.each([ + ["a stopped container", { Running: false, Status: "exited" }, false, "exited"], + ["a created container", { Running: false, Status: "created" }, false, "created"], + ["a running container", { Running: true, Status: "running" }, true, "running"], + ] as const)( + "reads %s from the container CLI inspect stdout's State", + ([, state, running, status]) => { + const mock = mockContainerCliSpawner(() => ({ + stdout: [JSON.stringify([{ Id: "abc123", State: state }])], + })); + return stateOf(mock.layer).pipe( + Effect.map((answer) => { + expect(answer).toEqual({ exists: true, running, status }); + expect(mock.spawned.map((s) => s.args)).toEqual([ + ["container", "inspect", "supabase_db_engine-probe"], + ]); + }), + ); + }, + ); + + it.live.each([ + ["empty", ""], + ["not JSON", "not json"], + ["a JSON object rather than an array", '{"State":{"Running":false,"Status":"exited"}}'], + ["an empty array", "[]"], + ["an entry without State", '[{"Id":"abc123"}]'], + ] as const)( + "counts a container whose inspect stdout is %s as running when the exit code is 0", + ([, stdout]) => { + const mock = mockContainerCliSpawner(() => ({ stdout: stdout === "" ? [] : [stdout] })); + return stateOf(mock.layer).pipe( + Effect.map((answer) => { + expect(answer).toEqual({ exists: true, running: true, status: "" }); + }), + ); + }, + ); + + it.live("answers absent when the container CLI reports no such container", () => { + const mock = mockContainerCliSpawner(() => ({ + exitCode: 1, + stderr: ["Error response from daemon: No such container: supabase_db_engine-probe"], + })); + return stateOf(mock.layer).pipe( + Effect.map((answer) => { + expect(answer).toEqual({ exists: false }); + }), + ); + }); + + it.live("fails with the daemon marker when the container CLI cannot reach the daemon", () => { + const mock = mockContainerCliSpawner(() => ({ + exitCode: 1, + stderr: ["Cannot connect to the Docker daemon at unix:///var/run/docker.sock."], + })); + return stateOf(mock.layer).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toMatchObject({ _tag: "LocalDbRunningError", daemonDown: true }); + }), + ); + }); +}); diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts index 0f8e9a547b..e846829d11 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts @@ -1,6 +1,6 @@ import http from "node:http"; -import { Context, Crypto, Data, Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import { Context, Crypto, Data, Effect, FileSystem, Layer, Option, Path } from "effect"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import { @@ -9,7 +9,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; -import { isContainerNotFoundMessage, spawnContainerCli } from "../container-cli.ts"; +import { collectText, isContainerNotFoundMessage, spawnContainerCli } from "../container-cli.ts"; import { readDbToml } from "../db-config.toml-read.ts"; import { resolveLocalProjectId, localDbContainerId } from "../docker-ids.ts"; import { SUGGEST_DOCKER_INSTALL, isDockerDaemonUnreachable } from "../docker-suggest.ts"; @@ -37,22 +37,25 @@ export class LocalDbRunningError extends Data.TaggedError("LocalDbRunningError") } } +/** Whether the local db container exists and, when it does, its Docker `State`. */ +export type LocalDbState = + | { readonly exists: false } + | { readonly exists: true; readonly running: boolean; readonly status: string }; + /** - * Direct Engine-API access to the locally addressable Docker daemon, so - * {@link isLocalDbRunning} does not depend on a spawnable, responsive - * `docker` CLI binary (issue #6110). `containerExists`: `Option.some(true)` — - * an Engine-identified 200 with a valid inspect payload; `Option.some(false)` - * — an Engine-identified 404; `Option.none()` — anything else (non-addressable - * endpoint, transport failure, silent socket, the 5s wall-clock bound - * expiring, non-Engine responder, abnormal - * status, malformed body), telling the caller to fall back to the container - * CLI, which preserves the established wording, daemon-down classification, - * and Podman fallback. + * Direct Engine-API access to the locally addressable Docker daemon, so {@link localDbState} does + * not depend on a spawnable, responsive `docker` CLI binary (issue #6110). `inspectContainer`: + * `Option.some({ exists: false })` — an Engine-identified 404; `Option.some({ exists: true, … })` — + * an Engine-identified 200 with a valid inspect payload; `Option.none()` — anything else + * (non-addressable endpoint, transport failure, silent socket, the 5s wall-clock bound expiring, + * non-Engine responder, abnormal status, malformed body), telling the caller to fall back to the + * container CLI, which preserves the established wording, daemon-down classification, and Podman + * fallback. */ export class LocalDockerEngine extends Context.Service< LocalDockerEngine, { - readonly containerExists: (containerId: string) => Effect.Effect>; + readonly inspectContainer: (containerId: string) => Effect.Effect>; } >()("supabase/cli/LocalDockerEngine") {} @@ -129,6 +132,30 @@ const isEngineResponse = (response: http.IncomingMessage): boolean => response.headers["api-version"] !== undefined || String(response.headers["server"] ?? "").startsWith("Docker/"); +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** A missing or garbled `State` counts as running, so only an explicit `Running: false` differs. */ +const existingDbState = (inspect: Readonly>): LocalDbState => { + const state = isRecord(inspect["State"]) ? inspect["State"] : {}; + return { + exists: true, + running: state["Running"] !== false, + status: typeof state["Status"] === "string" ? state["Status"] : "", + }; +}; + +const parseInspectStdout = (stdout: string): LocalDbState => { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + parsed = undefined; + } + const first: unknown = Array.isArray(parsed) ? parsed[0] : undefined; + return existingDbState(isRecord(first) ? first : {}); +}; + /** * `GET /containers//json` over a local socket / named pipe. Total: every * terminal state settles exactly once, and anything that is not an @@ -139,10 +166,10 @@ const isEngineResponse = (response: http.IncomingMessage): boolean => const inspectContainerOverSocket = ( socketPath: string, containerId: string, -): Effect.Effect> => +): Effect.Effect> => Effect.callback((resume, signal) => { let settled = false; - const settle = (result: Option.Option) => { + const settle = (result: Option.Option) => { if (settled) return; settled = true; resume(Effect.succeed(result)); @@ -187,7 +214,7 @@ const inspectContainerOverSocket = ( } const status = response.statusCode ?? 0; if (status === 404) { - settle(Option.some(false)); + settle(Option.some({ exists: false })); return; } if (status !== 200) { @@ -201,11 +228,11 @@ const inspectContainerOverSocket = ( } catch { payload = undefined; } - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + if (!isRecord(payload)) { settleNone(); return; } - settle(Option.some(true)); + settle(Option.some(existingDbState(payload))); }); }, ); @@ -263,7 +290,7 @@ export const localDockerEngineLayer: Layer.Layer< const httpLine = (url: string) => Option.isSome(debugLogger) ? debugLogger.value.http("GET", redactHttpUrl(url)) : Effect.void; return LocalDockerEngine.of({ - containerExists: (containerId) => + inspectContainer: (containerId) => Effect.gen(function* () { const endpoint = yield* resolveEndpoint; const endpointValue = Option.getOrUndefined(endpoint); @@ -272,13 +299,13 @@ export const localDockerEngineLayer: Layer.Layer< if (socketPath === undefined) { return yield* debug( `local db engine probe: endpoint not directly addressable (${describeEndpoint(endpointValue)}) — using the container CLI`, - ).pipe(Effect.as(Option.none())); + ).pipe(Effect.as(Option.none())); } return yield* httpLine(`${endpointValue}/containers/${containerId}/json`).pipe( Effect.andThen(inspectContainerOverSocket(socketPath, containerId)), Effect.timeoutOrElse({ duration: ENGINE_PROBE_DEADLINE_MS, - orElse: () => Effect.succeed(Option.none()), + orElse: () => Effect.succeed(Option.none()), }), Effect.tap((answer) => Option.isNone(answer) @@ -293,20 +320,9 @@ export const localDockerEngineLayer: Layer.Layer< }), ); -const decodeChunks = (chunks: ReadonlyArray): string => { - const total = chunks.reduce((size, chunk) => size + chunk.length, 0); - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - return new TextDecoder().decode(bytes); -}; - /** - * Answers whether the local Postgres container exists. Resolves `true` when it exists, `false` - * when it definitively does not, and fails with {@link LocalDbRunningError} on any other inspect + * Resolves the local Postgres container's {@link LocalDbState}: `exists: false` when it + * definitively does not exist, and fails with {@link LocalDbRunningError} on any other inspect * failure (e.g. an unreachable daemon) rather than treating it as "not running". * * Asks the Engine API first ({@link LocalDockerEngine}) so a stalled `docker` binary can't block @@ -315,13 +331,13 @@ const decodeChunks = (chunks: ReadonlyArray): string => { * best-effort read: only `projectId` is needed, and an unreadable `.env` falls back to the * workdir basename. */ -export function isLocalDbRunning( +export function localDbState( spawner: Spawner, fs: FileSystem.FileSystem, path: Path.Path, workdir: string, configuredProjectId: string | undefined, -): Effect.Effect { +): Effect.Effect { return Effect.scoped( Effect.gen(function* () { // Config was already validated (and any unresolved-env WARN already printed) by the @@ -342,17 +358,15 @@ export function isLocalDbRunning( const containerId = localDbContainerId(projectId); // Engine probe first; `Option.none()` falls through to the CLI spawn below. const engine = yield* LocalDockerEngine; - const engineAnswer = yield* engine.containerExists(containerId); + const engineAnswer = yield* engine.inspectContainer(containerId); yield* Effect.annotateCurrentSpan( "probe.method", Option.isSome(engineAnswer) ? "engine" : "cli", ); if (Option.isSome(engineAnswer)) return engineAnswer.value; - // Discard stdout (the inspect JSON) so the unconsumed pipe can never - // deadlock; only the exit code + stderr matter. const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { stdin: "ignore", - stdout: "ignore", + stdout: "pipe", stderr: "pipe", extendEnv: true, }).pipe( @@ -364,11 +378,10 @@ export function isLocalDbRunning( }), ), ); - const stderrChunks: Array = []; - yield* Stream.runForEach(child.stderr, (chunk) => - Effect.sync(() => { - stderrChunks.push(chunk); - }), + // Drained concurrently: a full, unread pipe would stall the child. + const [stdout, stderrText] = yield* Effect.all( + [collectText(child.stdout), collectText(child.stderr)], + { concurrency: "unbounded" }, ).pipe( Effect.mapError(() => new LocalDbRunningError({ message: "failed to inspect service" })), ); @@ -376,9 +389,9 @@ export function isLocalDbRunning( Effect.map(Number), Effect.mapError(() => new LocalDbRunningError({ message: "failed to inspect service" })), ); - if (inspectExit === 0) return true; + if (inspectExit === 0) return parseInspectStdout(stdout); - const stderr = decodeChunks(stderrChunks).trim(); + const stderr = stderrText.trim(); if (!isContainerNotFoundMessage(stderr)) { // Surface the Docker install hint instead of raw stderr when the daemon is unreachable. const daemonDown = isDockerDaemonUnreachable(stderr); @@ -393,7 +406,31 @@ export function isLocalDbRunning( }), ); } - return false; + return { exists: false } satisfies LocalDbState; }), - ).pipe(Effect.withSpan("LocalDb.isRunning")); + ).pipe( + Effect.tap((state) => + Effect.annotateCurrentSpan({ + "db.exists": state.exists, + ...(state.exists && { "db.running": state.running, "db.status": state.status }), + }), + ), + Effect.withSpan("LocalDb.state"), + ); +} + +/** + * Answers whether the local Postgres container exists, whatever state it is in. Callers that + * need to know it is running use {@link localDbState}. + */ +export function isLocalDbRunning( + spawner: Spawner, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + configuredProjectId: string | undefined, +): Effect.Effect { + return localDbState(spawner, fs, path, workdir, configuredProjectId).pipe( + Effect.map((state) => state.exists), + ); } diff --git a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts index afa0317e8d..72a680111e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts @@ -18,15 +18,10 @@ import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import { collectText, describeContainerCliFailure, spawnContainerCli } from "../container-cli.ts"; import { dockerCopyArchiveIntoContainer } from "./container-lifecycle.ts"; import type { StartContainerSpec } from "./docker-create-args.ts"; +import { POSTGRES_DATA_DIR } from "./postgres.service.ts"; type Spawner = ChildProcessSpawner["Service"]; -/** - * PGDATA path inside every `supabase/postgres` image. Hardcoded rather than read from the - * container's own `PGDATA` env, since the tar layout depends on this exact value. - */ -export const PGDATA_PATH = "/var/lib/postgresql/data"; - /** * `docker cp` unpacks an archive's members relative to `dest`, and the export tar has `data/` as * its own top-level member, so the restore target must be PGDATA's parent. A POSIX constant, not @@ -38,7 +33,7 @@ export const PGDATA_PARENT_PATH = "/var/lib/postgresql"; * PGDATA's own directory name — `docker cp : -` names its members after the source * basename, so `data/` is the export tar's top-level entry. */ -const PGDATA_DIR_NAME = PGDATA_PATH.slice(PGDATA_PARENT_PATH.length + 1); +const PGDATA_DIR_NAME = POSTGRES_DATA_DIR.slice(PGDATA_PARENT_PATH.length + 1); /** * Proves an archive carries an exported cluster: every Postgres data directory has a @@ -128,13 +123,17 @@ export const stampPgDataBaselineMarker = ( ): Effect.Effect => Effect.gen(function* () { const tar = yield* pgDataBaselineMarkerTar(key); - yield* dockerCopyArchiveIntoContainer(spawner, tar, `${containerId}:${PGDATA_PATH}`, (detail) => - pgDataSnapshotUnavailable(`failed to stamp ${PGDATA_BASELINE_MARKER_ENTRY}: ${detail}`), + yield* dockerCopyArchiveIntoContainer( + spawner, + tar, + `${containerId}:${POSTGRES_DATA_DIR}`, + (detail) => + pgDataSnapshotUnavailable(`failed to stamp ${PGDATA_BASELINE_MARKER_ENTRY}: ${detail}`), ); }); /** - * Streams `docker cp :${PGDATA_PATH} -` to a temp file next to `tarPath` and + * Streams `docker cp :${POSTGRES_DATA_DIR} -` to a temp file next to `tarPath` and * `rename`s it into place, so a partially written tar is never visible under the final name; * any failure removes the temp file. The container must already be stopped (callers own the * stop/start), and the temp name is scoped by pid alone, so concurrent exports to the same @@ -155,12 +154,12 @@ export const exportPgDataTar = ( Effect.gen(function* () { const child = yield* spawnContainerCli( spawner, - ["cp", `${containerId}:${PGDATA_PATH}`, "-"], + ["cp", `${containerId}:${POSTGRES_DATA_DIR}`, "-"], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, ).pipe( Effect.mapError((cause) => pgDataSnapshotUnavailable( - `failed to export ${PGDATA_PATH}: ${describeContainerCliFailure(cause)}`, + `failed to export ${POSTGRES_DATA_DIR}: ${describeContainerCliFailure(cause)}`, ), ), ); @@ -181,7 +180,7 @@ export const exportPgDataTar = ( ).pipe( Effect.mapError((cause) => pgDataSnapshotUnavailable( - `failed to export ${PGDATA_PATH}: ${describeContainerCliFailure(cause)}`, + `failed to export ${POSTGRES_DATA_DIR}: ${describeContainerCliFailure(cause)}`, ), ), ); 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..10589ddf36 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts @@ -25,6 +25,9 @@ import { START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; */ const POSTGRES_PASSWORD = "postgres"; +/** `PGDATA` in every `supabase/postgres` image; the pgdata snapshot tar layout depends on it. */ +export const POSTGRES_DATA_DIR = "/var/lib/postgresql/data"; + /** * In-container path for the pgsodium root key, delivered via * {@link buildPostgresStartContainerSpec}'s `secretFiles` docker cp rather than a heredoc. @@ -309,7 +312,7 @@ export function buildPostgresStartContainerSpec( secretFiles: [{ containerPath: POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }], }), binds: [ - `${containerName}:/var/lib/postgresql/data`, + `${containerName}:${POSTGRES_DATA_DIR}`, // The backup file bind is appended only on the `fromBackup` branch. ...(input.fromBackup === undefined ? [] diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.ts index 9ee5e8af4e..7c8529846e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.ts @@ -1,9 +1,11 @@ import { Effect, FileSystem, Path } from "effect"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; +import { isContainerNotFoundMessage } from "../container-cli.ts"; import type { ContainerIdName } from "../docker-lifecycle.ts"; -import { dockerRemoveAll } from "../docker-remove-all.ts"; +import { dockerRemoveAll, pruneProjectNetworks } from "../docker-remove-all.ts"; import { cleanupStartSecrets } from "../start-secrets-cleanup.ts"; +import { isVolumeNotFoundMessage, removeContainer, removeVolume } from "./container-lifecycle.ts"; import { HealthCheckTimeoutError } from "./health-check.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -59,3 +61,60 @@ export const rollbackStart = ( attributes: { "rollback.delete_volumes": deleteVolumes }, }), ); + +const writeStderrLine = (message: string) => + Effect.sync(() => { + globalThis.process.stderr.write(`${message}\n`); + }); + +/** Resolves whether the resource is gone; a missing resource counts, any other failure is logged. */ +const removeLoggingFailure = ( + remove: Effect.Effect, + isMissing: (message: string) => boolean, +) => + remove.pipe( + Effect.catchIf( + (error) => isMissing(error.message), + () => Effect.void, + ), + Effect.as(true), + Effect.catch((error) => writeStderrLine(error.message).pipe(Effect.as(false))), + ); + +/** + * `db start`'s rollback: removes only the db container, its volume when `deleteVolume`, and the + * project's unused networks, so the project's other services and volumes survive a failed run. + * A rollback failure is only logged to stderr, never propagated. + */ +export const rollbackDatabaseStart = ( + spawner: Spawner, + filterValue: string, + dbContainerId: string, + deleteVolume: boolean, + debug: boolean, +): Effect.Effect => + Effect.gen(function* () { + yield* writeStderrLine("Stopping containers..."); + + const containerRemoved = yield* removeLoggingFailure( + removeContainer(spawner, dbContainerId), + isContainerNotFoundMessage, + ); + yield* Effect.annotateCurrentSpan("container.removed", containerRemoved); + + if (deleteVolume) { + const volumeRemoved = yield* removeLoggingFailure( + removeVolume(spawner, dbContainerId), + isVolumeNotFoundMessage, + ); + yield* Effect.annotateCurrentSpan("volume.removed", volumeRemoved); + } + + yield* pruneProjectNetworks(spawner, filterValue, debug).pipe( + Effect.catch((error) => writeStderrLine(error.message)), + ); + }).pipe( + Effect.withSpan("DbBootstrap.rollbackDatabaseStart", { + attributes: { "rollback.delete_volumes": deleteVolume }, + }), + ); diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts index 7aecc2c49f..ff072f9066 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.unit.test.ts @@ -1,10 +1,10 @@ import { BunServices } from "@effect/platform-bun"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; -import { Data, Deferred, Effect, Sink, Stream } from "effect"; +import { Data, Deferred, Effect, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { HealthCheckTimeoutError } from "./health-check.ts"; -import { isUnhealthyStartError, rollbackStart } from "./rollback.ts"; +import { isUnhealthyStartError, rollbackDatabaseStart, rollbackStart } from "./rollback.ts"; function captureStderr() { return vi.spyOn(process.stderr, "write").mockImplementation(() => true); @@ -14,16 +14,19 @@ afterEach(() => { vi.restoreAllMocks(); }); +interface SpawnResult { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; +} + /** * Every spawned command answers with the same fixed `exitCode`/`stdout`/`stderr`, enough to * drive `dockerRemoveAll` through either its success path or its first failure branch. + * `respond` answers per command instead. */ function mockSpawner( - opts: { - readonly exitCode?: number; - readonly stdout?: string; - readonly stderr?: string; - } = {}, + opts: SpawnResult & { readonly respond?: (args: ReadonlyArray) => SpawnResult } = {}, ) { const encoder = new TextEncoder(); const spawned: Array> = []; @@ -32,14 +35,19 @@ function mockSpawner( Effect.gen(function* () { const args = command._tag === "StandardCommand" ? command.args : []; spawned.push(args); + const result = opts.respond?.(args) ?? opts; const exitDeferred = yield* Deferred.make(); - yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(result.exitCode ?? 0)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - stdout: Stream.fromIterable(opts.stdout !== undefined ? [encoder.encode(opts.stdout)] : []), - stderr: Stream.fromIterable(opts.stderr !== undefined ? [encoder.encode(opts.stderr)] : []), + stdout: Stream.fromIterable( + result.stdout !== undefined ? [encoder.encode(result.stdout)] : [], + ), + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), all: Stream.empty, exitCode: Deferred.await(exitDeferred), isRunning: Effect.succeed(false), @@ -133,6 +141,130 @@ describe("rollbackStart", () => { }); }); +describe("rollbackDatabaseStart", () => { + const FILTER = "com.supabase.cli.project=my-app"; + const DB = "supabase_db_my-app"; + const removeDb = ["container", "rm", "-f", DB]; + const removeDbVolume = ["volume", "rm", "-f", DB]; + const pruneNetworks = ["network", "prune", "--force", "--filter", `label=${FILTER}`]; + + it.live("removes only the db container, then prunes the project's networks", () => { + const mock = mockSpawner(); + const stderr = captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(mock.spawner, FILTER, DB, false, false); + expect(mock.spawned).toEqual([removeDb, pruneNetworks]); + expect(stderr.mock.calls).toEqual([["Stopping containers...\n"]]); + }); + }); + + it.live("also removes the db volume, after the container, when the volume is fresh", () => { + const mock = mockSpawner(); + captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(mock.spawner, FILTER, DB, true, false); + expect(mock.spawned).toEqual([removeDb, removeDbVolume, pruneNetworks]); + }); + }); + + it.live("reports the pruned networks to stderr in debug mode", () => { + const mock = mockSpawner({ + respond: (args) => + args[0] === "network" ? { stdout: "Deleted Networks:\nsupabase_network_my-app\n" } : {}, + }); + const stderr = captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(mock.spawner, FILTER, DB, false, true); + expect(stderr.mock.calls).toEqual([ + ["Stopping containers...\n"], + ["Pruned network: [supabase_network_my-app]\n"], + ]); + }); + }); + + const removalOutcomes = [ + { + scenario: "a missing container", + failing: "container rm", + stderr: `Error response from daemon: No such container: ${DB}`, + logged: undefined, + }, + { + scenario: "a missing container reported by Podman", + failing: "container rm", + stderr: `Error: no container with name or ID "${DB}" found: no such container`, + logged: undefined, + }, + { + scenario: "a missing volume", + failing: "volume rm", + stderr: `Error response from daemon: No such volume: ${DB}`, + logged: undefined, + }, + { + scenario: "a container that cannot be removed", + failing: "container rm", + stderr: "permission denied", + logged: "failed to remove container: permission denied", + }, + { + scenario: "a volume that cannot be removed", + failing: "volume rm", + stderr: `Error response from daemon: remove ${DB}: volume is in use`, + logged: `failed to remove volume: Error response from daemon: remove ${DB}: volume is in use`, + }, + { + scenario: "networks that cannot be pruned", + failing: "network prune", + stderr: "permission denied", + logged: "failed to prune networks: permission denied", + }, + ] as const; + for (const { scenario, failing, stderr: failureStderr, logged } of removalOutcomes) { + it.live( + `${logged === undefined ? "silently continues" : "logs the failure and continues"} past ${scenario}`, + () => { + const mock = mockSpawner({ + respond: (args) => + args.slice(0, 2).join(" ") === failing ? { exitCode: 1, stderr: failureStderr } : {}, + }); + const stderr = captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(mock.spawner, FILTER, DB, true, false); + expect(mock.spawned).toEqual([removeDb, removeDbVolume, pruneNetworks]); + expect(stderr.mock.calls).toEqual([ + ["Stopping containers...\n"], + ...(logged === undefined ? [] : [[`${logged}\n`]]), + ]); + }); + }, + ); + } + + it.live("never fails when no container runtime can be spawned", () => { + const spawner = ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn ENOENT", + }), + ), + ); + const stderr = captureStderr(); + return Effect.gen(function* () { + yield* rollbackDatabaseStart(spawner, FILTER, DB, true, false); + expect(stderr.mock.calls.map(([line]) => line)).toEqual([ + "Stopping containers...\n", + expect.stringContaining("failed to remove container"), + expect.stringContaining("failed to remove volume"), + expect.stringContaining("failed to prune networks"), + ]); + }); + }); +}); + class OtherTaggedError extends Data.TaggedError("OtherTaggedError")<{ readonly message: string; }> {} diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts index 67e575fa52..7e03595ed9 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts @@ -42,9 +42,9 @@ import { PGDATA_BASELINE_MARKER_ENTRY, PGDATA_BASELINE_MARKER_NAME, PGDATA_PARENT_PATH, - PGDATA_PATH, pgDataBaselineMarkerContent, } from "./pgdata-snapshot.ts"; +import { POSTGRES_DATA_DIR } from "./postgres.service.ts"; import { SHADOW_BASELINE_KEEP, SHADOW_CACHE_ENV, @@ -487,7 +487,7 @@ describe("acquireShadowDatabase", () => { expect(docker.stepCalls("cp-stamp")[0]).toEqual([ "cp", "-", - `${handle.containerId}:${PGDATA_PATH}`, + `${handle.containerId}:${POSTGRES_DATA_DIR}`, ]); // The stamp really carries the marker file, delivered as a tar so `docker cp` unpacks it // relative to PGDATA rather than rewriting the directory's ownership — and its content is @@ -497,7 +497,7 @@ describe("acquireShadowDatabase", () => { expect(stamp).toContain(pgDataBaselineMarkerContent(handle.snapshotKey ?? "")); expect(docker.stepCalls("cp-out")[0]).toEqual([ "cp", - `${handle.containerId}:${PGDATA_PATH}`, + `${handle.containerId}:${POSTGRES_DATA_DIR}`, "-", ]); expect(docker.containers.get(handle.containerId)?.running).toBe(true); diff --git a/apps/cli/src/command-internal/db-bootstrap/start-database.ts b/apps/cli/src/command-internal/db-bootstrap/start-database.ts index 383617d528..87ed38fd06 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -1,8 +1,9 @@ /** * The single bring-up sequence for the local Postgres container, shared by `supabase start` and - * `db start`: volume probe, image resolve and network ensure, create and start, health wait - * (swallowed only for `fromBackup`), fresh-volume setup (skipped for `fromBackup`), then - * `initCurrentBranch`. `--ignore-health-check` and rollback are the caller's concern. + * `db start`: volume probe, image resolve and network ensure, create (probing a reused volume's + * data directory before start) and start, health wait (swallowed only for `fromBackup`), setup + * when the volume is new or its data directory was never initialised (skipped for `fromBackup`), + * then `initCurrentBranch`. `--ignore-health-check` and rollback are the caller's concern. * * `resolvePostgresImage` and `setup.jwks` are caller-supplied `Effect`s because `db start` * resolves them lazily while `supabase start` threads values it already resolved. @@ -27,6 +28,7 @@ import type { DockerRun } from "../docker-run.service.ts"; import { ensureNetwork, createContainer, + postgresDataInitialized, volumeExists, COMPOSE_PROJECT_LABEL, type ContainerCreateError, @@ -72,6 +74,13 @@ class StartBackupVolumeExistsError extends Data.TaggedError("StartBackupVolumeEx } } +/** The refusal for `--from-backup` against an existing data volume. */ +export const backupVolumeExistsError = () => + new StartBackupVolumeExistsError({ + message: "backup volume already exists", + suggestion: `Run ${aqua("supabase stop --no-backup")} to remove existing docker volumes.`, + }); + /** Every failure {@link startDatabase} itself can produce, independent of the caller's own `E`. */ export type StartDatabaseError = | NetworkCreateError @@ -109,9 +118,10 @@ export interface StartDatabaseInput { readonly webhooksEnabled: boolean; readonly setup: FreshDbSetupInput; /** - * Reports whether the volume was freshly created, for the caller's own rollback logic. Fired - * once after the pre-create refuse guards pass, so rollback never treats a leftover sibling - * volume as this run's fresh data. + * Reports whether the volume holds no initialised data, for the caller's own rollback logic. + * Fires `false` for an existing volume once the pre-create refuse guards pass, so rollback never + * treats a leftover sibling volume as this run's fresh data, then `true` if the container's data + * directory turns out to be uninitialised. */ readonly onFreshVolumeResolved: (isFreshVolume: boolean) => void; } @@ -141,32 +151,29 @@ export const startDatabase = ( // `docker network create` are idempotent, so creating either first would make "did this // volume already exist" unobservable and could leave a network behind for a request the // guard below is about to reject. - const isFreshVolume = !(yield* volumeExists(spawner, input.dbContainerId)); + const volumeExisted = yield* volumeExists(spawner, input.dbContainerId); const fromBackup = input.postgresSpec.fromBackup; yield* Effect.annotateCurrentSpan({ - "db.fresh_volume": isFreshVolume, + "db.volume_existed": volumeExisted, "db.from_backup": fromBackup !== undefined, }); - if (!isFreshVolume && fromBackup !== undefined) { + if (volumeExisted && fromBackup !== undefined) { // Refused before any container or network is created, and before freshness is published, // so rollback cannot prune it. - return yield* new StartBackupVolumeExistsError({ - message: "backup volume already exists", - suggestion: `Run ${aqua("supabase stop --no-backup")} to remove existing docker volumes.`, - }); + return yield* backupVolumeExistsError(); } // Print this before image resolve so a flag-off cold/failed pull still // follows the established progress order. yield* output.raw( - isFreshVolume ? START_STARTING_DATABASE_MESSAGE : START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, + volumeExisted ? START_STARTING_DATABASE_FROM_BACKUP_MESSAGE : START_STARTING_DATABASE_MESSAGE, "stderr", ); const resolvedPostgresImage = yield* input.resolvePostgresImage; - input.onFreshVolumeResolved(isFreshVolume); + input.onFreshVolumeResolved(!volumeExisted); // Runs once per `start` run rather than once per container — see `ensureNetwork`'s own doc // comment — but kept after the volume probe/guard above, never before it. @@ -179,7 +186,25 @@ export const startDatabase = ( ...input.postgresSpec, image: resolvedPostgresImage, }); - yield* createContainer(spawner, postgresSpec, input.containerOpts); + // A reused volume with no `PG_VERSION` was never initialised, so it still needs the + // fresh-volume setup. Published before `docker start` so a failed start rolls the volume back. + let dataInitialized = true; + yield* createContainer( + spawner, + postgresSpec, + input.containerOpts, + volumeExisted + ? (containerId) => + postgresDataInitialized(spawner, containerId).pipe( + Effect.map((initialized) => { + dataInitialized = initialized; + if (!initialized) input.onFreshVolumeResolved(true); + }), + ) + : undefined, + ); + const isFreshVolume = !volumeExisted || !dataInitialized; + yield* Effect.annotateCurrentSpan({ "db.fresh_volume": isFreshVolume }); // A host connect, not Docker's HEALTHCHECK: that needs `docker exec`, which nested or // sandboxed Docker cannot do. 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 5d042f9392..dde96e4be1 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 @@ -8,7 +8,7 @@ * {@link StartLocalDatabaseResult} discriminator instead of printing the terminal line itself. */ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { ChildProcessSpawner } from "effect/process"; import { Output } from "../../shared/output/output.service.ts"; @@ -49,12 +49,16 @@ import { parseGoDuration, resolveHealthTimeoutSeconds } from "../go-duration.ts" import { ramInBytes } from "../size-units.ts"; import { goUrlParse } from "../storage-url.ts"; import { loadLocalProjectContext } from "../local-project-context.ts"; -import { cliProjectFilterValue } from "../docker-ids.ts"; +import { cliProjectFilterValue, localDbContainerId } from "../docker-ids.ts"; +import { isBitbucketPipeline } from "../bitbucket-pipeline.ts"; +import { holdLocalStackStartLock } from "../local-stack-start-lock.ts"; +import { dbNotRunningError } from "../status-errors.ts"; import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; +import { removeContainer } from "./container-lifecycle.ts"; import { awaitDockerHealthSettled } from "./health-check.ts"; -import { isLocalDbRunning } from "./local-db-running.ts"; -import { rollbackStart } from "./rollback.ts"; -import { startDatabase } from "./start-database.ts"; +import { localDbState } from "./local-db-running.ts"; +import { rollbackDatabaseStart } from "./rollback.ts"; +import { backupVolumeExistsError, startDatabase } from "./start-database.ts"; function asRecord(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -85,15 +89,7 @@ interface StartLocalDatabaseResult { readonly status: StartLocalDatabaseStatus; } -/** - * Starts the local Postgres database, or no-ops when it is already running. See this module's - * own header for the full design rationale. - * - * `fromBackupFlag` is `db start`'s own `--from-backup` value, resolved against the caller's cwd - * before being passed in; the declarative seam's `ensureLocalDatabaseStarted` always calls with - * no argument. - */ -export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(function* ( +const startLocalDatabaseInScope = Effect.fn("DbBootstrap.startLocalDatabase")(function* ( fromBackupFlag?: string, ) { const output = yield* Output; @@ -110,7 +106,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // `isLocalDbRunning`'s best-effort read, which swallows config errors. const dbTomlValues = yield* checkDbToml(fs, path, cliSettings.workdir); - // Threaded into `rollbackStart`'s own teardown (gates its `Pruned …:` stderr reports) and into + // Threaded into `rollbackDatabaseStart`'s own teardown (gates its `Pruned …:` stderr reports) and into // `buildLocalDbContainerInputs`'s own `setup.debug`, so a failed fresh-volume migrate job tees // its own stderr. Resolved with the `SUPABASE_DEBUG` shell/project-`.env` fallback, not the // bare flag. @@ -635,19 +631,32 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu ), ); + // Held through bring-up and rollback so a concurrent start cannot prune this run's containers. + yield* holdLocalStackStartLock(context.projectId); + // If the db container is already up, tell the caller and stop here. Runs after the config // load/validation above. - const running = yield* isLocalDbRunning( - spawner, - fs, - path, - cliSettings.workdir, - Option.getOrUndefined(cliSettings.projectId), - ); - yield* Effect.annotateCurrentSpan("db.already_running", running); + // Probed by the same project id bring-up uses, so a stopped container is removed by the name + // that was inspected. + const dbState = yield* localDbState(spawner, fs, path, cliSettings.workdir, context.projectId); + const running = dbState.exists && dbState.running; + const recoverStopped = dbState.exists && !dbState.running; + yield* Effect.annotateCurrentSpan({ + "db.already_running": running, + "db.recover_stopped": recoverStopped, + }); if (running) { return { status: "already-running" } satisfies StartLocalDatabaseResult; } + // Bitbucket has no named volumes, so removing the container would destroy its data. + if (dbState.exists) { + const inBitbucketPipeline = yield* isBitbucketPipeline(projectEnvValues).pipe( + Effect.mapError((error) => new DbConfigLoadError({ message: error.message })), + ); + if (inBitbucketPipeline) { + return yield* dbNotRunningError(localDbContainerId(context.projectId), dbState.status); + } + } // Resolve a relative `--from-backup` against the caller's cwd, captured before any workdir // change. An empty `--from-backup ""` is a normal no-backup start, so treat it as absent @@ -659,6 +668,12 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu ? fromBackupFlag : path.join(runtimeInfo.cwd, fromBackupFlag); + // A stopped container's named volume always exists, so a restore is refused before the + // container is removed. + if (recoverStopped && fromBackup !== undefined) { + return yield* backupVolumeExistsError(); + } + // Not running → bring up the container natively. `context` (loaded eagerly above) is threaded // through as `preloadedContext` so this call reuses it instead of calling // `loadLocalProjectContext` a second time, which would double-print deprecated-config-section @@ -687,6 +702,13 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu const filterValue = cliProjectFilterValue(projectId); + // Only the db container: `db start` leaves the project's other services alone. + if (recoverStopped) { + yield* removeContainer(spawner, dbContainerId).pipe( + Effect.withSpan("DbBootstrap.recoverStoppedDatabase"), + ); + } + // Assigned by `startDatabase`'s own pre-create volume-existence check; defaults to // `false` so a rollback triggered by an earlier failure (e.g. network creation) never // deletes a volume this run never confirmed was fresh. @@ -694,8 +716,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu // Runs the exact start-database sequence (network -> volume probe -> container create+start // -> health wait -> fresh-volume setup -> `_current_branch`) — shared with `supabase start`, - // see `startDatabase`'s own header. Any failure rolls back via the same `Effect.onError` - // wrapper `supabase start` uses. + // see `startDatabase`'s own header. Any failure rolls back only what `db start` creates. yield* startDatabase(spawner, { fs, path, @@ -719,7 +740,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu }, }).pipe( Effect.onError(() => - rollbackStart(spawner, filterValue, isFreshVolume, cliSettings.workdir, debug), + rollbackDatabaseStart(spawner, filterValue, dbContainerId, isFreshVolume, debug), ), ); @@ -729,3 +750,14 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu return { status: "started" } satisfies StartLocalDatabaseResult; }); + +/** + * Starts the local Postgres database, or no-ops when it is already running. See this module's + * own header for the full design rationale. + * + * `fromBackupFlag` is `db start`'s own `--from-backup` value, resolved against the caller's cwd + * before being passed in; the declarative seam's `ensureLocalDatabaseStarted` always calls with + * no argument. + */ +export const startLocalDatabase = (fromBackupFlag?: string) => + Effect.scoped(startLocalDatabaseInScope(fromBackupFlag)); diff --git a/apps/cli/src/command-internal/docker-remove-all.ts b/apps/cli/src/command-internal/docker-remove-all.ts index a37893b1fd..e43a0b69b6 100644 --- a/apps/cli/src/command-internal/docker-remove-all.ts +++ b/apps/cli/src/command-internal/docker-remove-all.ts @@ -128,6 +128,36 @@ const pruneFailureMessage = (kind: string, stderr: string) => { return detail.length > 0 ? `failed to prune ${kind}: ${detail}` : `failed to prune ${kind}`; }; +/** Prunes unused networks carrying `filterValue`; networks with attached containers are kept. */ +export const pruneProjectNetworks = ( + spawner: Spawner, + filterValue: string, + debug: boolean, +): Effect.Effect => + Effect.gen(function* () { + const networkPrune = yield* pruneWaitingForConcurrentPrune(spawner, [ + "network", + "prune", + "--force", + "--filter", + `label=${filterValue}`, + ]).pipe( + Effect.mapError( + (cause) => + new DockerRemoveAllNetworkPruneError({ + message: `failed to prune networks: ${describeContainerCliFailure(cause)}`, + }), + ), + ); + if (networkPrune.exitCode !== 0) { + return yield* new DockerRemoveAllNetworkPruneError({ + message: pruneFailureMessage("networks", networkPrune.stderr), + }); + } + // Established output text: singular "network", unlike the container/volume reports. + yield* reportPruned(debug, "Pruned network:", networkPrune.stdout); + }); + /** Every failure {@link dockerRemoveAll} can produce. */ export type DockerRemoveAllError = | DockerRemoveAllListError @@ -246,25 +276,5 @@ export const dockerRemoveAll = ( yield* reportPruned(debug, "Pruned volumes:", volumePrune.stdout); } - const networkPrune = yield* pruneWaitingForConcurrentPrune(spawner, [ - "network", - "prune", - "--force", - "--filter", - `label=${filterValue}`, - ]).pipe( - Effect.mapError( - (cause) => - new DockerRemoveAllNetworkPruneError({ - message: `failed to prune networks: ${describeContainerCliFailure(cause)}`, - }), - ), - ); - if (networkPrune.exitCode !== 0) { - return yield* new DockerRemoveAllNetworkPruneError({ - message: pruneFailureMessage("networks", networkPrune.stderr), - }); - } - // Established output text: singular "network", unlike the container/volume reports. - yield* reportPruned(debug, "Pruned network:", networkPrune.stdout); + yield* pruneProjectNetworks(spawner, filterValue, debug); }); diff --git a/apps/cli/src/command-internal/local-stack-start-lock.ts b/apps/cli/src/command-internal/local-stack-start-lock.ts new file mode 100644 index 0000000000..d8af7a33a6 --- /dev/null +++ b/apps/cli/src/command-internal/local-stack-start-lock.ts @@ -0,0 +1,89 @@ +import { holdLock, isLockBusy } from "@supabase/stack/effect"; +import { Clock, Data, Effect, FileSystem, Option, Path } from "effect"; + +import { CommandSettings } from "../config/command-settings.service.ts"; +import { Output } from "../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; +import { aqua } from "./colors.ts"; +import { DebugLogger } from "./debug-logger.service.ts"; + +const SUBJECT = "the start lock"; +const NO_WAIT = "0 millis"; +const WAIT_BUDGET = "10 minutes"; +const WAITING_MESSAGE = "Waiting for another supabase start for this project to finish...\n"; + +/** Another `supabase start` for this project held the start lock for the whole wait budget. */ +export class LocalStackStartBusyError extends Data.TaggedError("LocalStackStartBusyError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.startStack, fingerprint_suffix: "lease_held" }; + } +} + +/** + * Serialises `supabase start` and `supabase db start` per project for the enclosing scope. An + * unwritable lock location never blocks a start; it continues unlocked. + */ +export const holdLocalStackStartLock = Effect.fn("LocalStack.startLock")(function* ( + projectId: string, +) { + if (projectId.length === 0) return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = yield* Output; + const { supabaseHome } = yield* CommandSettings; + const debugLogger = yield* Effect.serviceOption(DebugLogger); + const locksDir = path.join(supabaseHome, "locks"); + const lockFile = path.join(locksDir, `start-${projectId}.lock`); + const started = yield* Clock.currentTimeMillis; + + const record = (outcome: "acquired" | "busy" | "unavailable") => + Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => + Effect.annotateCurrentSpan({ "lock.outcome": outcome, "lock.wait_ms": now - started }), + ), + ); + const continueUnlocked = (reason: string) => + (Option.isSome(debugLogger) + ? debugLogger.value.debug(`Continuing without the start lock: ${reason}`) + : Effect.void + ).pipe(Effect.andThen(record("unavailable"))); + const waitForHolder = Effect.gen(function* () { + if (output.format === "text") { + yield* output.raw(WAITING_MESSAGE, "stderr"); + } + yield* holdLock(lockFile, WAIT_BUDGET, SUBJECT); + }); + + yield* fs.makeDirectory(locksDir, { recursive: true }).pipe( + Effect.matchEffect({ + onFailure: (error) => continueUnlocked(error.message), + onSuccess: () => + holdLock(lockFile, NO_WAIT, SUBJECT).pipe( + Effect.catchIf(isLockBusy, () => waitForHolder), + Effect.matchEffect({ + onSuccess: () => record("acquired"), + onFailure: (error) => + isLockBusy(error) + ? record("busy").pipe( + Effect.andThen( + Effect.fail( + new LocalStackStartBusyError({ + message: "another supabase start is still running for this project", + suggestion: `Wait for it to finish or cancel it, then run ${aqua("supabase start")} again.`, + }), + ), + ), + ) + : continueUnlocked(error.message), + }), + ), + }), + ); +}); diff --git a/apps/cli/src/command-internal/status-errors.ts b/apps/cli/src/command-internal/status-errors.ts index 3da9741a59..313c7fb05e 100644 --- a/apps/cli/src/command-internal/status-errors.ts +++ b/apps/cli/src/command-internal/status-errors.ts @@ -4,6 +4,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; +import { aqua } from "./colors.ts"; /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a directory. Checked @@ -51,12 +52,20 @@ export class StatusDbInspectError extends Data.TaggedError("StatusDbInspectError /** The db container is absent or present but not in the `running` state. */ export class StatusDbNotRunningError extends Data.TaggedError("StatusDbNotRunningError")<{ readonly message: string; + readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.startStack; + return this.suggestion === undefined ? actionability.startStack : actionability.stopStack; } } +/** The db container exists but is not running; `supabase stop` clears the stopped project. */ +export const dbNotRunningError = (containerId: string, status: string) => + new StatusDbNotRunningError({ + message: `${containerId} container is not running: ${status || "unknown"}`, + suggestion: `Run ${aqua("supabase stop")} to remove this project's stopped containers, then run ${aqua("supabase start")} again.`, + }); + /** The db container is running but its Docker health check is not `healthy`. */ export class StatusDbNotReadyError extends Data.TaggedError("StatusDbNotReadyError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 453dc96a51..9f2d9e2362 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -1296,7 +1296,7 @@ describe("db reset", () => { Layer.merge( layer, Layer.succeed(LocalDockerEngine, { - containerExists: () => Effect.succeed(Option.some(false)), + inspectContainer: () => Effect.succeed(Option.some({ exists: false })), }), ), ), diff --git a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 5087e45520..7f9bea7def 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -28,17 +28,19 @@ formatting without disabling safe compaction. ## Files Written -| Path | Format | When | -| ----------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------ | -| `/supabase/schemas/**/*.sql` (default declarative dir, or invocation-local `--output`) | SQL | selected destination is wiped + rewritten after confirmation | -| `/.pgdelta-export.json` | JSON | export metadata | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | with `PGDELTA_DEBUG` | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/schemas/**/*.sql` (default declarative dir, or invocation-local `--output`) | SQL | selected destination is wiped + rewritten after confirmation | +| `/.pgdelta-export.json` | JSON | export metadata | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | with `PGDELTA_DEBUG` | +| `$SUPABASE_HOME/locks/start-.lock` | SQLite | when the local database is started — the per-project start lock shared with `supabase start`; a concurrent start prints `Waiting for another supabase start for this project to finish...` on stderr (see `db start`) | ## Subprocesses / Containers -| What | When | -| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | +| What | When | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | +| Local database auto-start via `startLocalDatabase` — absent `db` container: started; present but not running: removed with `docker container rm -f` (data volume kept) and started, as `db start` does; in Bitbucket Pipelines it fails with a `supabase stop` suggestion instead | the local `db` container is not running | ## Environment Variables diff --git a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 024dbccea6..2b0fdb0105 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -43,15 +43,17 @@ disabling safe compaction. | `/supabase/migrations/_[_].sql` | SQL | changes; bundled engine may emit ordered segments | | `/supabase/schemas/extension.sql` | SQL | accepted legacy-extension repair | | `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `$SUPABASE_HOME/locks/start-.lock` | SQLite | when the local database is started — the per-project start lock shared with `supabase start`; a concurrent start prints `Waiting for another supabase start for this project to finish...` on stderr (see `db start`) | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot — migrations/declarative shadows (`--no-cache` bypasses the snapshot cache entirely — neither read nor written); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | ## Subprocesses / Containers -| What | When | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| Two natively-provisioned shadows (migrated source + declarative target) via `acquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | always | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Two natively-provisioned shadows (migrated source + declarative target) via `acquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | always | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| Local database auto-start via `startLocalDatabase` — absent `db` container: started; present but not running: removed with `docker container rm -f` (data volume kept) and started, as `db start` does; in Bitbucket Pipelines it fails with a `supabase stop` suggestion instead | the local `db` container is not running | ## Environment Variables diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index b581e9df22..07d6490596 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { afterEach, beforeEach, vi } from "vitest"; @@ -11,6 +11,7 @@ import { mockShadowContainerCliSpawner, shadowCacheDisabledLayer, useTempWorkdir, + withEmptyConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; @@ -27,6 +28,7 @@ import { } from "../../../command-internal/db-connection.service.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; +import { LocalDockerEngine } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { SUGGEST_DOCKER_INSTALL } from "../../../command-internal/docker-suggest.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; @@ -128,6 +130,8 @@ function setup( readonly dbInspectFailsWith?: string; readonly dbInspectImage?: string; readonly stackBackend?: boolean; + /** The Engine API's answer for an existing local db container. */ + readonly dbContainer?: { readonly running: boolean; readonly status: string }; } = {}, ) { const out = mockOutput(); @@ -146,6 +150,20 @@ function setup( // `Layer.succeed`s below are ALSO listed as `layer`'s own top-level members (so the test // body itself can resolve `Output`/etc.), but that doesn't satisfy `seam`'s OWN identical // requirements as a sibling entry in the same merge. + const { dbContainer } = opts; + const engine = + dbContainer === undefined + ? mockLocalDockerEngineUnavailableLayer + : Layer.succeed(LocalDockerEngine, { + inspectContainer: () => + Effect.succeed( + Option.some({ + exists: true as const, + running: dbContainer.running, + status: dbContainer.status, + }), + ), + }); const seam = declarativeSeamLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnection.layer), @@ -162,7 +180,7 @@ function setup( // `seam` itself resolves — `Layer.provide` fully resolves each requirement it can // satisfy as it's applied, so `BunServices.layer` only ever fills in `FileSystem`/`Path`. Layer.provide(shadowSpawner.layer), - Layer.provide(mockLocalDockerEngineUnavailableLayer), + Layer.provide(engine), Layer.provide(unusedStackServices), Layer.provide(BunServices.layer), ); @@ -214,6 +232,56 @@ describe("declarativeSeamLayer.ensureLocalDatabaseStarted", () => { ); }); +describe("declarativeSeamLayer.ensureLocalDatabaseStarted with an existing db container", () => { + beforeEach(() => { + vi.stubEnv("BITBUCKET_CLONE_DIR", undefined); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const writeProjectId = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + 'project_id = "seam-existing"\n', + ); + }); + + it.effect.each(["exited", "created"] as const)( + "replaces a db container left %s with a fresh start instead of treating it as running", + (status) => { + const { layer, shadowSpawned } = setup(tmp.current, { + dbContainer: { running: false, status }, + }); + return Effect.gen(function* () { + yield* writeProjectId; + const seam = yield* DeclarativeSeam; + yield* seam.ensureLocalDatabaseStarted; + expect( + shadowSpawned.filter((s) => s.args[0] === "container" && s.args[1] === "rm"), + ).toEqual([{ args: ["container", "rm", "-f", "supabase_db_seam-existing"] }]); + const create = shadowSpawned.find((s) => s.args[0] === "create"); + expect(create?.args[create.args.indexOf("--name") + 1]).toBe("supabase_db_seam-existing"); + }).pipe(Effect.provide(layer), withEmptyConfigEnv); + }, + ); + + it.effect("leaves a running db container untouched", () => { + const { layer, shadowSpawned } = setup(tmp.current, { + dbContainer: { running: true, status: "running" }, + }); + return Effect.gen(function* () { + yield* writeProjectId; + const seam = yield* DeclarativeSeam; + yield* seam.ensureLocalDatabaseStarted; + expect(shadowSpawned).toEqual([]); + }).pipe(Effect.provide(layer)); + }); +}); + describe("declarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { beforeEach(() => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 04b0f18d5a..b2d2d07a92 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -11,7 +11,7 @@ import { getRegistryImageUrl } from "../../../command-internal/docker-registry.t import { isDockerDaemonUnreachable } from "../../../command-internal/docker-suggest.ts"; import { imageDigest, imageTag, isSlimImageRef } from "../../../shared/services/slim-images.ts"; import { upstreamVersionFromTag } from "../../../shared/services/services.shared.ts"; -import { isLocalDbRunning } from "../../../command-internal/db-bootstrap/local-db-running.ts"; +import { localDbState } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start-local-database.ts"; import { resolveLocalProjectId, localDbContainerId } from "../../../command-internal/docker-ids.ts"; import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; @@ -104,7 +104,7 @@ export const declarativeSeamLayer = Layer.effect( ), ); } - const running = yield* isLocalDbRunning( + const dbState = yield* localDbState( spawner, fs, path, @@ -124,7 +124,7 @@ export const declarativeSeamLayer = Layer.effect( }), ), ); - if (running) return; // already running — the seam never prints anything here. + if (dbState.exists && dbState.running) return; // the seam never prints anything here. yield* startLocalDatabase().pipe( Effect.provideContext(context), Effect.asVoid, diff --git a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md index b67252d03e..4e56da2c87 100644 --- a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md @@ -6,16 +6,28 @@ Fully native. CLI-1954 removed the last Go delegation — the hidden `db __db-bo **not** the top-level `supabase start`: no status table, no `cli_stack_started` event, no `Finished` line, no `--exclude`, no `--ignore-health-check`. -The handler validates config, checks whether the local Postgres container is already -running (`isLocalDbRunning` in `command-internal/db-bootstrap/local-db-running.ts`, -shared with `db reset --local`'s own running-check — a direct Docker Engine API -`GET /containers//json` over the active context's unix socket / named pipe, falling -back to a spawned `docker container inspect` whenever the Engine gives no definitive, -Engine-identified answer, so a stalled `docker` CLI binary can no longer block the -already-running check itself — issue #6110's silent pre-output hang. The bring-up that -follows a definitive "absent" answer still shells out, starting with the volume-freshness -probe, so a stalled `docker` CLI still blocks an actual bring-up), and otherwise natively -brings up the container itself, reusing +The handler validates config, takes the per-project start lock shared with `supabase start` +(`$SUPABASE_HOME/locks/start-.lock`, held until the command ends: a second start for +the same project prints `Waiting for another supabase start for this project to finish...` to +stderr in text format, waits up to 10 minutes, then fails with `LocalStackStartBusyError`; a lock +location that cannot be opened never blocks the command), inspects the local Postgres +container (`localDbState` in `command-internal/db-bootstrap/local-db-running.ts` — a direct +Docker Engine API `GET /containers//json` over the active context's unix socket / named +pipe, falling back to a spawned `docker container inspect` whenever the Engine gives no +definitive, Engine-identified answer, so a stalled `docker` CLI binary can no longer block the +state check itself — issue #6110's silent pre-output hang; the fallback reads the inspect +stdout's `State` as well as the exit code. `db reset --local` and `db diff` use its +existence-only wrapper `isLocalDbRunning`). A running container short-circuits to "already +running". A container that exists but is not running (`created`, `exited`, …) is removed with +`docker container rm -f supabase_db_` — only the db container, with the named data +volume kept and the project's other services left alone — and then brought up like an absent one; +the reused-volume data-directory probe decides whether setup runs. `--from-backup` against such a +container fails with `backup volume already exists` (step 3) before the container is removed. In Bitbucket Pipelines +(`BITBUCKET_CLONE_DIR` set) removal is skipped, since the lack of named volumes would make it +destructive: the command fails with ` container is not running: ` and a +`supabase stop` suggestion instead. The bring-up that follows an "absent" answer (or that +removal) still shells out, starting with the volume-freshness probe, so a stalled `docker` CLI +still blocks an actual bring-up. It natively brings up the container itself, reusing `command-internal/db-bootstrap/`'s container-bootstrap primitives (the same ones `supabase start` uses for its own Postgres bring-up, and `db reset --local`'s own recreate composition reuses too — see that command's `SIDE_EFFECTS.md`): @@ -53,7 +65,10 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): to stderr, and the `exec format error` recovery advice when the logs show it, still happen either way. 7. On a fresh volume with `--from-backup` unset: run the `SetupLocalDatabase`-equivalent - pipeline (`command-internal/db-bootstrap/db-setup.ts`) — initial schema (PG<=14: SQL over a + pipeline. A volume counts as fresh when it did not exist before step 2 OR it exists but was + never initialised: between create and start, `docker cp :/var/lib/postgresql/data/PG_VERSION -` + fails with a diagnostic reporting `PG_VERSION` missing from the container (any other probe outcome counts as + initialised). The pipeline (`command-internal/db-bootstrap/db-setup.ts`) — initial schema (PG<=14: SQL over a direct `DbConnection`; PG>=15: up to three one-shot `docker run --rm` migrate jobs for realtime/storage/auth, each gated on its own `enabled` flag; the realtime one-shot runs so user migrations see the tenant), API-privilege @@ -71,9 +86,13 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): leaves `starting`, the container stops running, or Docker cannot launch its health check. Never fails the command, whatever the outcome, and is not covered by the rollback below. -Any failure from step 1 onward rolls back via `rollbackStart` (stop + prune every -container/network this project's label matches; volumes are pruned too, but ONLY when the -volume was confirmed fresh this run). +Any failure from step 1 onward prints `Stopping containers...` to stderr and rolls back via +`rollbackDatabaseStart`, which only touches what this command creates: the db container +(`docker container rm -f`), the db volume (`docker volume rm -f supabase_db_`, ONLY when +the volume was confirmed fresh this run, including a reused volume the probe found uninitialised, +even when `docker start` then fails), and unused project-labelled networks +(`docker network prune`). The project's other containers and volumes are left alone. A rollback +failure is logged to stderr and never replaces the original error. ## Files Read @@ -90,46 +109,49 @@ volume was confirmed fresh this run). | `/supabase/migrations/*.sql`, `supabase/seed.sql` | SQL | on a fresh volume with no `--from-backup`, via the standard migration-apply + seed pipeline | | `/supabase/` (files/directories/globs) | SQL | on a fresh volume with no `--from-backup`, INSTEAD of `migrations/*.sql`, when `--experimental`/`SUPABASE_EXPERIMENTAL` is set and `[experimental.pgdelta] enabled` is false | | `/supabase/.branches/_current_branch` | text | always, existence check before writing (see "Files Written") | -| `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the already-running probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth | +| `~/.docker/config.json` + Docker context store (`contexts/meta//meta.json`) | JSON | resolving the daemon endpoint for the db state probe (in-process); also read by the `docker`/`podman` CLI itself for registry auth | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------- | -| `/supabase/.branches/_current_branch` | text | only if absent — writes `"main"` (see the step-by-step sequence above for exactly when) | -| local Docker volume `supabase_db_` | — | the Postgres data volume, created on first start (or first `--from-backup` restore) | -| local Docker network `supabase_network_` (or `--network-id`) | — | created if it doesn't already exist | -| `$SUPABASE_HOME/stacks//` | JSON | the stack definition, service identities, selected runtime, and service state on the stack backend | -| `~/.supabase/telemetry.json` | JSON | always — telemetry flush (`Effect.ensuring(telemetryState.flush)`), success and failure | +| Path | Format | When | +| --------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | only if absent — writes `"main"` (see the step-by-step sequence above for exactly when) | +| local Docker volume `supabase_db_` | — | the Postgres data volume, created on first start (or first `--from-backup` restore) | +| local Docker network `supabase_network_` (or `--network-id`) | — | created if it doesn't already exist | +| `$SUPABASE_HOME/locks/start-.lock` | SQLite | the per-project start lock shared with `supabase start`; never deleted, released when the command ends (by the OS if the process dies) | +| `$SUPABASE_HOME/stacks//` | JSON | the stack definition, service identities, selected runtime, and service state on the stack backend | +| `~/.supabase/telemetry.json` | JSON | always — telemetry flush (`Effect.ensuring(telemetryState.flush)`), success and failure | ## Subprocesses Every step below shells out to `docker` (falling back to `podman`), matching every other native container command in this codebase — never `supabase-go` — except the -already-running probe, which prefers a direct Engine-API request (see "API Routes") and +db state probe, which prefers a direct Engine-API request (see "API Routes") and only spawns on fallback. -| Command | When | -| -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `docker container inspect supabase_db_` | the already-running probe — only when the direct Engine-API request (active local socket/named pipe) gives no definitive answer | -| `docker network create --label ... ` | when not already running, unless `--network-id` names a built-in network | -| `docker volume inspect supabase_db_` | when not already running — the pre-create fresh-volume probe | -| `docker image inspect` / `docker pull` (registry-fallback resolve) | when not already running — resolves the Postgres image | -| `docker volume create --label ...` | when not already running, unless a `--from-backup` restore onto an existing volume (fails first) | -| `docker create` + optional `docker cp - :/` + `docker start` | when not already running — `cp` streams one in-memory tar archive containing the pgsodium root key at its exact path when the selected entrypoint needs it | -| `docker container inspect` (repeated) | readiness polling every 500ms up to `db.health_timeout`, then health-settle polling every 1s for up to 15s | -| `docker logs ` | on a health-check timeout (either path — swallowed or not) | -| `docker run --rm ...` | fresh volume, no `--from-backup`, `db.major_version >= 15`: up to 3 one-shot migrate jobs (realtime/storage/auth) | -| `docker ps` / `docker stop` / `docker container prune` / `docker volume prune` (fresh-volume runs only) / `docker network prune` | on ANY failure from network-ensure through `_current_branch` — the rollback | +| Command | When | +| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | the db state probe — only when the direct Engine-API request (active local socket/named pipe) gives no definitive answer | +| `docker container rm -f supabase_db_` | the db container exists but is not running, outside Bitbucket Pipelines — the data volume is kept | +| `docker network create --label ... ` | when not already running, unless `--network-id` names a built-in network | +| `docker volume inspect supabase_db_` | when not already running — the pre-create fresh-volume probe | +| `docker image inspect` / `docker pull` (registry-fallback resolve) | when not already running — resolves the Postgres image | +| `docker volume create --label ...` | when not already running, unless a `--from-backup` restore onto an existing volume (fails first) | +| `docker create` + optional `docker cp - :/` + `docker start` | when not already running — `cp` streams one in-memory tar archive containing the pgsodium root key at its exact path when the selected entrypoint needs it | +| `docker cp :/var/lib/postgresql/data/PG_VERSION -` | when the volume already existed, between `docker create` and `docker start` — the data-directory probe; its stdout is discarded | +| `docker container inspect` (repeated) | readiness polling every 500ms up to `db.health_timeout`, then health-settle polling every 1s for up to 15s | +| `docker logs ` | on a health-check timeout (either path — swallowed or not) | +| `docker run --rm ...` | fresh volume, no `--from-backup`, `db.major_version >= 15`: up to 3 one-shot migrate jobs (realtime/storage/auth) | +| `docker container rm -f` / `docker volume rm -f` (fresh-volume runs only) / `docker network prune` | on ANY failure from network-ensure through `_current_branch` — the rollback | ## API Routes -No platform (Management API) routes. The already-running probe issues one Docker Engine +No platform (Management API) routes. The db state probe issues one Docker Engine API request over the active context's local unix socket / named pipe: -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| GET | `/containers/supabase_db_/json` | — | — | `Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object (identity-gated 200 → running, 404 → absent; anything else → the spawned-CLI fallback) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/containers/supabase_db_/json` | — | — | `Api-Version`/`Server` identity headers + status; a 200 body must parse as a JSON object (identity-gated 200 → exists, running unless `State.Running` is `false`, 404 → absent; anything else → the spawned-CLI fallback) | ## Environment Variables @@ -164,15 +186,17 @@ code is surfaced on failure. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------- | -| `0` | success — database started, or already running | -| `0` | `--from-backup` set and the health-check timed out (swallowed) | -| `1` | malformed `supabase/config.toml` | -| `1` | Docker daemon unreachable / inspect failure | -| `1` | `backup volume already exists` (`--from-backup` against a non-fresh volume) | -| `1` | a health-check timeout with `--from-backup` unset | -| `1` | any other container-bootstrap failure (network/volume/create/start/setup) | +| Code | Condition | +| ---- | ----------------------------------------------------------------------------------------------------------- | +| `0` | success — database started, or already running | +| `0` | `--from-backup` set and the health-check timed out (swallowed) | +| `1` | malformed `supabase/config.toml` | +| `1` | Docker daemon unreachable / inspect failure | +| `1` | db container exists but is not running, in Bitbucket Pipelines (`supabase stop` suggestion) | +| `1` | `backup volume already exists` (`--from-backup` against a non-fresh volume) | +| `1` | a health-check timeout with `--from-backup` unset | +| `1` | another `supabase start`/`db start` still held the start lock after 10 minutes (`LocalStackStartBusyError`) | +| `1` | any other container-bootstrap failure (network/volume/create/start/setup) | ## Output diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index e94c1a53d7..77243fba00 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -2,6 +2,7 @@ import { BunServices } from "@effect/platform-bun"; import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; import { Cause, + Clock, Deferred, Effect, Exit, @@ -27,6 +28,7 @@ import { mockRuntimeInfo, } from "../../../../tests/helpers/mocks.ts"; import { EXEC_FAILED_STATE } from "../../../../tests/helpers/docker-health.ts"; +import { observeMakeDirectory, startLockState } from "../../../../tests/helpers/start-lock.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts"; import { @@ -39,6 +41,7 @@ import { withEmptyConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; import { DebugFlag, ExperimentalFlag, @@ -46,9 +49,12 @@ import { } from "../../../command-internal/global-flags.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { LocalDockerEngine } from "../../../command-internal/db-bootstrap/local-db-running.ts"; +import { ContainerStartError } from "../../../command-internal/db-bootstrap/container-lifecycle.ts"; +import { HealthCheckTimeoutError } from "../../../command-internal/db-bootstrap/health-check.ts"; import { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; import { DbConnection, type DbSession } from "../../../command-internal/db-connection.service.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; +import { LocalStackStartBusyError } from "../../../command-internal/local-stack-start-lock.ts"; import { dbStart } from "./start.handler.ts"; import type { DbStartFlags } from "./start.command.ts"; import { stackLocalDatabaseConn } from "../../../command-internal/stack-local-database.ts"; @@ -61,6 +67,7 @@ import { } from "../../../command-internal/stack-catalog-setup.ts"; import { StackError, + holdLock, type DatabaseInstance, type ServiceCreation, type ServiceCreationInput, @@ -171,12 +178,24 @@ function dbSetupJobCalls(spawned: ReadonlyArray): ReadonlyArray s.args[0] === "run" && s.args[1] === "--rm"); } +/** The rollback's closing step; nothing else in `db start` prunes networks. */ function rollbackWasAttempted(spawned: ReadonlyArray): boolean { - return spawned.some((s) => s.args[0] === "container" && s.args[1] === "prune"); + return spawned.some((s) => s.args[0] === "network" && s.args[1] === "prune"); } -function volumePruneWasAttempted(spawned: ReadonlyArray): boolean { - return spawned.some((s) => s.args[0] === "volume" && s.args[1] === "prune"); +const TEARDOWN_VERBS: ReadonlySet = new Set(["rm", "stop", "kill", "prune"]); + +/** Every command that stops, removes, or prunes something, in spawn order. */ +function teardownCommands(spawned: ReadonlyArray): ReadonlyArray { + return spawned + .filter((s) => TEARDOWN_VERBS.has(s.args[0]) || TEARDOWN_VERBS.has(s.args[1])) + .map((s) => s.args.join(" ")); +} + +function volumeRemovals(spawned: ReadonlyArray): ReadonlyArray> { + return spawned + .filter((s) => s.args[0] === "volume" && (s.args[1] === "rm" || s.args[1] === "prune")) + .map((s) => s.args); } /** Stateful default route: only created containers inspect successfully, matching Docker across initial state detection and post-create health waits. Existing volume by default (a restart). */ @@ -220,6 +239,43 @@ function freshVolumeRoute( }; } +/** Docker's answer when the created Postgres container's data directory holds no `PG_VERSION`, i.e. `initdb` never ran. */ +function uninitialisedDataDirRoute( + base: (args: ReadonlyArray) => RouteResult, +): (args: ReadonlyArray) => RouteResult { + return (args) => { + const source = args[1]; + if ( + args[0] === "cp" && + source?.endsWith(":/var/lib/postgresql/data/PG_VERSION") === true && + args[2] === "-" + ) { + return { + exitCode: 1, + stderr: [ + `Error response from daemon: Could not find the file /var/lib/postgresql/data/PG_VERSION in container ${source.split(":")[0]}`, + ], + }; + } + return base(args); + }; +} + +/** `docker start` of the Postgres container exits non-zero, as when the runtime cannot launch it. */ +function dbStartFailsRoute( + base: (args: ReadonlyArray) => RouteResult, +): (args: ReadonlyArray) => RouteResult { + return (args) => + args[0] === "start" && args[1] === fakeContainerId(DB_CONTAINER) + ? { + exitCode: 1, + stderr: [ + "Error response from daemon: failed to create task for container: OCI runtime create failed", + ], + } + : base(args); +} + /** Docker's HEALTHCHECK can never launch on this host, so every created container reports exec-failed health. */ function execFailedHealthRoute( base: (args: ReadonlyArray) => RouteResult, @@ -246,6 +302,79 @@ function alreadyRunningRoute( }; } +const DB_PROJECT_ID = Option.some("test"); +const DB_CONTAINER = "supabase_db_test"; +const BITBUCKET_ENV = "BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n"; + +interface DbContainerState { + readonly Running: boolean; + readonly Status: string; +} + +/** + * A db container left behind in `state`: `container inspect` reports it as a real `docker + * container inspect` array payload until `container rm` removes it, and a same-name `create` + * conflicts until then, as Docker's name uniqueness would. + */ +function leftBehindDbRoute( + state: DbContainerState, + base: (args: ReadonlyArray) => RouteResult, +): (args: ReadonlyArray) => RouteResult { + let removed = false; + return (args) => { + if (args[0] === "container" && args[1] === "rm" && args.includes(DB_CONTAINER)) { + removed = true; + return { exitCode: 0 }; + } + if (!removed && args[0] === "container" && args[1] === "inspect" && args[2] === DB_CONTAINER) { + return { stdout: [JSON.stringify([{ State: state }])] }; + } + if (!removed && args[0] === "create" && containerNameFromCreateArgs(args) === DB_CONTAINER) { + return { + exitCode: 125, + stderr: [`Conflict. The container name "/${DB_CONTAINER}" is already in use`], + }; + } + return base(args); + }; +} + +/** The project's other services are up: `docker ps` lists them by the project label. */ +function otherServicesRunningRoute( + base: (args: ReadonlyArray) => RouteResult, +): (args: ReadonlyArray) => RouteResult { + return (args) => + args[0] === "ps" + ? { stdout: ["a1b2c3\tsupabase_auth_test\t", "d4e5f6\tsupabase_rest_test\t"] } + : base(args); +} + +function containerRemovals( + spawned: ReadonlyArray, +): ReadonlyArray> { + return spawned.filter((s) => s.args[0] === "container" && s.args[1] === "rm").map((s) => s.args); +} + +type DbProbePath = "Engine API" | "container CLI"; + +/** + * Makes the chosen probe path the only one that can see the left-behind container: the Engine + * row leaves the container CLI answering "no such container", so only the Engine answer can + * trigger the stopped-container handling. + */ +function leftBehindDbProbe(probe: DbProbePath, status: string) { + const state = { Running: false, Status: status }; + return probe === "Engine API" + ? { + route: defaultRoute(), + engine: Layer.succeed(LocalDockerEngine, { + inspectContainer: () => + Effect.succeed(Option.some({ exists: true, running: false, status })), + }), + } + : { route: leftBehindDbRoute(state, defaultRoute()), engine: undefined }; +} + /** * Makes `isLocalDbRunning`'s pre-bring-up `container inspect` fail for a reason other than * "no such container" — simulates an unreachable Docker daemon during the running-check, which @@ -319,6 +448,8 @@ interface SetupOpts { readonly projectEnvContents?: string; readonly skipConfig?: boolean; readonly workdir?: string; + /** The `SUPABASE_PROJECT_ID` the db state probe resolves; defaults to `mockCommandSettings`' own. */ + readonly projectId?: Option.Option; readonly cwd?: string; readonly platform?: NodeJS.Platform; readonly networkId?: string; @@ -357,7 +488,7 @@ function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const http = alwaysReadyHttpClient(); const telemetry = mockTelemetryStateTracked(); - const cliSettings = mockCommandSettings({ workdir }); + const cliSettings = mockCommandSettings({ workdir, projectId: opts.projectId }); const baseRoute = opts.route ?? defaultRoute(); const route = opts.running === true @@ -488,7 +619,8 @@ describe("db start", () => { Layer.mergeAll( layer, Layer.succeed(LocalDockerEngine, { - containerExists: () => Effect.succeed(Option.some(true)), + inspectContainer: () => + Effect.succeed(Option.some({ exists: true, running: true, status: "running" })), }), ), ), @@ -499,6 +631,178 @@ describe("db start", () => { }, ); + it.effect( + "leaves a running db container alone when docker inspect reports State.Running true", + () => { + const { layer, out, child } = setup({ + route: leftBehindDbRoute({ Running: true, Status: "running" }, defaultRoute()), + projectId: DB_PROJECT_ID, + }); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Postgres database is already running."); + expect(containerRemovals(child.spawned)).toEqual([]); + expect(createArgs(child.spawned)).toBeUndefined(); + }); + }, + ); + + describe("a db container that exists but is not running", () => { + beforeEach(() => { + vi.stubEnv("BITBUCKET_CLONE_DIR", undefined); + }); + + it.effect.each([ + ["Engine API", "exited"], + ["container CLI", "exited"], + ["Engine API", "created"], + ["container CLI", "created"], + ] as const)( + "removes only the %s-reported %s db container and brings it back up", + ([probe, status]) => { + const { route, engine } = leftBehindDbProbe(probe, status); + const { layer, out, child } = setup({ route, projectId: DB_PROJECT_ID }); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide(engine === undefined ? layer : Layer.mergeAll(layer, engine)), + ); + expect(out.stderrText).not.toContain("already running"); + expect(out.stderrText).toContain("Starting database"); + expect(containerRemovals(child.spawned)).toEqual([ + ["container", "rm", "-f", DB_CONTAINER], + ]); + expect(volumeRemovals(child.spawned)).toEqual([]); + expect(rollbackWasAttempted(child.spawned)).toBe(false); + expect(containerNameFromCreateArgs(createArgs(child.spawned) ?? [])).toBe(DB_CONTAINER); + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + expect(yield* readCurrentBranch(tempRoot.current)).toBe("main"); + }).pipe(withEmptyConfigEnv); + }, + ); + + it.effect.each(["Engine API", "container CLI"] as const)( + "inside Bitbucket Pipelines the %s-reported stopped container fails with a supabase stop suggestion and is left in place", + (probe) => { + const { route, engine } = leftBehindDbProbe(probe, "exited"); + const { layer, out, child } = setup({ + route, + projectId: DB_PROJECT_ID, + projectEnvContents: BITBUCKET_ENV, + }); + return Effect.gen(function* () { + const error = yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide(engine === undefined ? layer : Layer.mergeAll(layer, engine)), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "StatusDbNotRunningError", + message: `${DB_CONTAINER} container is not running: exited`, + suggestion: expect.stringContaining("supabase stop"), + }); + expect(out.stderrText).not.toContain("already running"); + expect(containerRemovals(child.spawned)).toEqual([]); + expect(volumeRemovals(child.spawned)).toEqual([]); + expect(createArgs(child.spawned)).toBeUndefined(); + expect(yield* currentBranchExists(tempRoot.current)).toBe(false); + }); + }, + ); + + it.effect.each(["Engine API", "container CLI"] as const)( + "--from-backup against the %s-reported stopped container fails before removing anything", + (probe) => { + const { route, engine } = leftBehindDbProbe(probe, "exited"); + const { layer, child } = setup({ route, projectId: DB_PROJECT_ID }); + return Effect.gen(function* () { + const error = yield* dbStart(flags("/abs/host/backup.sql")).pipe( + Effect.provide(engine === undefined ? layer : Layer.mergeAll(layer, engine)), + Effect.flip, + ); + expect(error).toMatchObject({ + _tag: "StartBackupVolumeExistsError", + message: "backup volume already exists", + suggestion: expect.stringContaining("supabase stop --no-backup"), + }); + expect(teardownCommands(child.spawned)).toEqual([]); + expect(createArgs(child.spawned)).toBeUndefined(); + expect(yield* currentBranchExists(tempRoot.current)).toBe(false); + }).pipe(withEmptyConfigEnv); + }, + ); + + for (const [probe, route, outcome, volumeRemoval] of [ + [ + "initialised", + (base: ReturnType) => base, + "removes only the db container and unused networks", + [], + ], + [ + "uninitialised", + uninitialisedDataDirRoute, + "removes only the db container, its volume and unused networks", + [`volume rm -f ${DB_CONTAINER}`], + ], + ] as const) { + it.effect( + `a failed bring-up over a stopped db container on an ${probe} data directory ${outcome}, leaving the project's running services alone`, + () => { + const { layer, child } = setup({ + route: route( + dbStartFailsRoute( + otherServicesRunningRoute( + leftBehindDbRoute({ Running: false, Status: "exited" }, defaultRoute()), + ), + ), + ), + projectId: DB_PROJECT_ID, + }); + return Effect.gen(function* () { + const error = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toBeInstanceOf(ContainerStartError); + // The first removal recovers the stopped container; the second is the rollback of + // the replacement that failed to start. + expect(teardownCommands(child.spawned)).toEqual([ + `container rm -f ${DB_CONTAINER}`, + `container rm -f ${DB_CONTAINER}`, + ...volumeRemoval, + "network prune --force --filter label=com.supabase.cli.project=test", + ]); + expect(yield* currentBranchExists(tempRoot.current)).toBe(false); + }).pipe(withEmptyConfigEnv); + }, + ); + } + + it.effect("an empty BITBUCKET_CLONE_DIR does not count as Bitbucket Pipelines", () => { + const { route } = leftBehindDbProbe("container CLI", "exited"); + const { layer, child } = setup({ + route, + projectId: DB_PROJECT_ID, + projectEnvContents: "BITBUCKET_CLONE_DIR=\n", + }); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(containerRemovals(child.spawned)).toEqual([["container", "rm", "-f", DB_CONTAINER]]); + expect(createArgs(child.spawned)).not.toBeUndefined(); + }).pipe(withEmptyConfigEnv); + }); + + it.effect("starts normally inside Bitbucket Pipelines when no db container exists", () => { + const { layer, out, child } = setup({ + route: freshVolumeRoute(defaultRoute()), + projectEnvContents: BITBUCKET_ENV, + }); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Starting database"); + expect(containerRemovals(child.spawned)).toEqual([]); + expect(createArgs(child.spawned)).not.toBeUndefined(); + expect(yield* readCurrentBranch(tempRoot.current)).toBe("main"); + }); + }); + }); + it.effect( "starts the database on a fresh volume: creates the container, runs the SetupLocalDatabase-equivalent pipeline, and writes _current_branch", () => { @@ -614,7 +918,7 @@ describe("db start", () => { expect(Cause.pretty(exit.cause)).toContain("DbConfigLoadError"); } // The container is already created/healthy by the time JWKS resolution runs, so the - // rollback still tears it down. + // rollback still removes it. expect(rollbackWasAttempted(child.spawned)).toBe(true); }).pipe( Effect.ensuring( @@ -700,10 +1004,9 @@ describe("db start", () => { suggestion: expect.stringContaining("supabase stop --no-backup"), }); expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); - // The volume this guard detected must never be pruned, even though the rollback still - // removes everything else on failure. + // The rollback must never remove the volume this guard detected. expect(rollbackWasAttempted(child.spawned)).toBe(true); - expect(volumePruneWasAttempted(child.spawned)).toBe(false); + expect(volumeRemovals(child.spawned)).toEqual([]); }); }, ); @@ -799,15 +1102,15 @@ describe("db start", () => { const exit = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(rollbackWasAttempted(child.spawned)).toBe(true); - // This run's fresh volume means the rollback prunes it too — the "backup volume already - // exists" test above covers the non-pruning case. - expect(volumePruneWasAttempted(child.spawned)).toBe(true); + // This run's fresh volume means the rollback removes it too — the "backup volume already + // exists" test above covers the non-removing case. + expect(volumeRemovals(child.spawned)).toEqual([["volume", "rm", "-f", DB_CONTAINER]]); expect(yield* currentBranchExists(tempRoot.current)).toBe(false); }); }); it.live( - "honors a SUPABASE_DEBUG set only in the project .env: the rollback's Pruned reports fire without --debug", + "honors a SUPABASE_DEBUG set only in the project .env: the rollback's Pruned network report fires without --debug", () => { // A shell SUPABASE_DEBUG (even "false") would suppress the project .env value, so clear // it first. @@ -829,7 +1132,7 @@ describe("db start", () => { const exit = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(rollbackWasAttempted(child.spawned)).toBe(true); - expect(writes.some((chunk) => chunk.includes("Pruned containers:"))).toBe(true); + expect(writes.some((chunk) => chunk.includes("Pruned network:"))).toBe(true); }).pipe( withEmptyConfigEnv, Effect.ensuring( @@ -859,6 +1162,156 @@ describe("db start", () => { }, ); + describe("an existing volume whose Postgres data directory was never initialised", () => { + it.effect("runs the schema setup pipeline as it does for a brand-new volume", () => { + const { layer, out, child } = setup({ route: uninitialisedDataDirRoute(defaultRoute()) }); + return Effect.gen(function* () { + yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Initialising schema..."); + expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); + expect(volumeRemovals(child.spawned)).toEqual([]); + expect(yield* readCurrentBranch(tempRoot.current)).toBe("main"); + }); + }); + + for (const [probe, route, volumeRemoved] of [ + ["uninitialised", uninitialisedDataDirRoute, true], + ["initialised", (base: ReturnType) => base, false], + ] as const) { + const rollbackOutcome = volumeRemoved ? "and removes" : "without removing"; + const expectedVolumeRemovals = volumeRemoved ? [["volume", "rm", "-f", DB_CONTAINER]] : []; + + it.live( + `rolls back ${rollbackOutcome} the volume when Postgres never becomes healthy on an ${probe} data directory`, + () => { + const { layer, child } = setup({ + configContents: 'project_id = "test"\n[db]\nhealth_timeout = "0s"\n', + route: route(defaultRoute()), + connectFailures: Number.POSITIVE_INFINITY, + }); + return Effect.gen(function* () { + const error = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toBeInstanceOf(HealthCheckTimeoutError); + expect(rollbackWasAttempted(child.spawned)).toBe(true); + expect(volumeRemovals(child.spawned)).toEqual(expectedVolumeRemovals); + }); + }, + ); + + it.effect( + `rolls back ${rollbackOutcome} the volume when docker start of Postgres fails on an ${probe} data directory`, + () => { + const { layer, child } = setup({ route: dbStartFailsRoute(route(defaultRoute())) }); + return Effect.gen(function* () { + const error = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toBeInstanceOf(ContainerStartError); + expect(error).toMatchObject({ + message: expect.stringContaining( + `failed to start docker container "${DB_CONTAINER}"`, + ), + }); + expect(rollbackWasAttempted(child.spawned)).toBe(true); + expect(volumeRemovals(child.spawned)).toEqual(expectedVolumeRemovals); + }); + }, + ); + } + }); + + describe("per-project start lock", () => { + const WAITING_LINE = "Waiting for another supabase start for this project to finish...\n"; + + const startLockFile = Effect.fnUntraced(function* (workdir: string) { + const path = yield* Path.Path; + return path.join(workdir, ".supabase", "locks", "start-test.lock"); + }); + + it.effect( + "waits the 10 minute budget for a concurrent start of the same project, then fails without touching Docker", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lockFile = yield* startLockFile(tempRoot.current); + yield* fs.makeDirectory(path.dirname(lockFile), { recursive: true }); + yield* holdLock(lockFile, "0 millis", "concurrent start"); + const lockDirReady = yield* Deferred.make(); + const { layer, out, child } = setup(); + + const forkedAt = yield* Clock.currentTimeMillis; + const fiber = yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide( + Layer.mergeAll( + layer, + observeMakeDirectory((made) => + made === path.dirname(lockFile) + ? Deferred.succeed(lockDirReady, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.raceFirst(Deferred.await(lockDirReady), Fiber.await(fiber)); + for (let minute = 0; minute < 60 && fiber.pollUnsafe() === undefined; minute++) { + yield* TestClock.adjust("1 minute"); + } + const error = yield* Fiber.join(fiber); + const waitedMillis = (yield* Clock.currentTimeMillis) - forkedAt; + + expect(error).toBeInstanceOf(LocalStackStartBusyError); + expect(error).toMatchObject({ + _tag: "LocalStackStartBusyError", + message: "another supabase start is still running for this project", + suggestion: expect.stringContaining("supabase start"), + }); + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_fingerprint: "tag:LocalStackStartBusyError:lease_held", + }); + expect(out.stderrText).toContain(WAITING_LINE); + expect(waitedMillis).toBeGreaterThanOrEqual(10 * 60_000); + expect(waitedMillis).toBeLessThanOrEqual(11 * 60_000); + expect(child.spawned).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + 60_000, + ); + + for (const outcome of ["succeeds", "fails"] as const) { + it.effect( + `holds the lock while the database comes up and releases it when the start ${outcome}`, + () => { + const { layer, dbSession } = setup({ route: freshVolumeRoute(defaultRoute()) }); + return Effect.gen(function* () { + const lockFile = yield* startLockFile(tempRoot.current); + const stateAtConnect: Array<"free" | "busy" | "error"> = []; + const probingConnection = Layer.succeed(DbConnection, { + connect: () => + startLockState(lockFile).pipe( + Effect.flatMap((state) => { + stateAtConnect.push(state); + return outcome === "fails" && stateAtConnect.length > 1 + ? Effect.fail(new DbConnectError({ message: "connection refused" })) + : Effect.succeed(dbSession.session); + }), + ), + }); + + const exit = yield* dbStart(DEFAULT_FLAGS).pipe( + Effect.provide(Layer.mergeAll(layer, probingConnection)), + Effect.exit, + ); + + expect(Exit.isSuccess(exit)).toBe(outcome === "succeeds"); + expect(stateAtConnect.length).toBeGreaterThan(1); + expect(stateAtConnect.every((state) => state === "busy")).toBe(true); + expect(yield* startLockState(lockFile)).toBe("free"); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + } + }); + it.effect("proceeds with no config file (missing config is tolerated)", () => { const { layer, child } = setup({ skipConfig: true, route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { @@ -1676,7 +2129,7 @@ describe("db start", () => { const exit = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(rollbackWasAttempted(child.spawned)).toBe(true); - expect(volumePruneWasAttempted(child.spawned)).toBe(true); + expect(volumeRemovals(child.spawned)).toEqual([["volume", "rm", "-f", DB_CONTAINER]]); }); }); diff --git a/apps/cli/src/commands/db/start/start.layers.ts b/apps/cli/src/commands/db/start/start.layers.ts index bb56504df5..3c2e5cf923 100644 --- a/apps/cli/src/commands/db/start/start.layers.ts +++ b/apps/cli/src/commands/db/start/start.layers.ts @@ -23,6 +23,7 @@ const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); export const dbStartRuntimeLayer = Layer.mergeAll( cliSettings, + debugLoggerLayer, telemetryStateLayer, // Backs `isLocalDbRunning`'s direct Engine-API probe (+ its `--debug` trace). localDockerEngineLayer.pipe(Layer.provide(debugLoggerLayer)), diff --git a/apps/cli/src/commands/start/SIDE_EFFECTS.md b/apps/cli/src/commands/start/SIDE_EFFECTS.md index 6674f3fe3b..db599c583c 100644 --- a/apps/cli/src/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/start/SIDE_EFFECTS.md @@ -37,9 +37,20 @@ One piece of the old Go CLI's `start` remains explicitly **out of scope**: ### Fresh-volume DB setup (`startSetupLocalDatabase`) -Runs the initial schema/migrations/seed pipeline. Gated on -`isFreshVolume` (`volumeExists` on the Postgres volume, checked BEFORE the -volume is created) — this same check also selects which of +Runs the initial schema/migrations/seed pipeline. Gated on `isFreshVolume`, true when the +Postgres volume did not exist before this run created it (`volumeExists`, checked BEFORE the +volume is created) OR when it exists but was never initialised: after Postgres's container is +created and before it starts, `docker cp :/var/lib/postgresql/data/PG_VERSION -` exits +non-zero with a diagnostic reporting `PG_VERSION` missing from the container (Docker's `Could not +find the file /PG_VERSION in container`, older Docker clients' `No such container:path: +:/PG_VERSION`, Podman's `"/PG_VERSION" could not be found on container`) because +`initdb` has not run, for example after an earlier `start` died between `docker create` and +`docker start`. Any other probe outcome — exit 0, an unspawnable runtime, an unrecognised error, or +a generic missing-file error that is not one of those diagnostics, such as a Docker socket dial +failure — counts as initialised, so an existing database is never re-seeded on an ambiguous +probe. The freshness is published as soon as the probe resolves, so a `docker start` failure +afterwards still rolls the volume back. A volume whose `initdb` started but whose setup never +finished has `PG_VERSION` and also counts as initialised. Only the volume-existence check selects which of `Starting database...`/`Starting database from backup...` prints to stderr immediately before Postgres's container is created. Runs immediately after Postgres's own health check passes, before "Starting containers..." prints and @@ -184,6 +195,7 @@ command. | Path | Format | When | | ---------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | +| `$SUPABASE_HOME/locks/start-.lock` | SQLite | on every start with a non-empty project id — the per-project start lock (see "Concurrent starts"); never deleted, released when the command ends (by the OS if the process dies) | | `/supabase/snippets/` | — | when Studio is enabled, only if absent — created before any container and kept on rollback; `podman create -v`, unlike Docker, rejects a missing bind source | | `/supabase/.temp/start-secrets//{env,multiline-env}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts — see below | | auth email `content_path` files (SELinux security label only, no content change) | xattr | when the container runtime applies SELinux labels (enforcing or permissive): the `rw,z` Kong bind relabels each mounted template to the shared container context; persists after `supabase stop` | @@ -291,6 +303,8 @@ code is surfaced on failure. | `1` | malformed CSV in an `--exclude`/`-x` value — fails during flag parsing, before the handler and telemetry, with the exact diagnostic text on stderr; the shorthand frames it with both spellings (e.g. `invalid argument "a\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field`; a blank-only value fails with `EOF`) — CLI-2005 | | `1` | malformed `config.toml` / `Config.Validate` failure, including an `auth.email.*.content_path` that resolves outside the project root, or that resolves in-root but is missing/unreadable (checked eagerly, before any Docker work, regardless of `auth.enabled` — see Notes) | | `1` | stopped Postgres detected but the project id sanitizes to empty — aborts before recovery removes any containers | +| `1` | Postgres container exists but is not running and is not recovered (Bitbucket Pipelines, or Docker reported no state) — ` container is not running: ` (`unknown` for an empty status) with a `supabase stop` then `supabase start` suggestion; the "already running" banner is never printed | +| `1` | another `supabase start`/`supabase db start` for this project still held the start lock after the 10-minute wait (`LocalStackStartBusyError`) — fails before any container work, with a suggestion to wait for or cancel the other start | | `1` | `docker`/`podman` not spawnable, or the daemon is unreachable | | `1` | stopped-stack recovery cannot list, stop, or prune current-project containers, or prune matching networks — aborts before startup; named volumes are preserved | | `1` | image pull exhausted across every registry candidate, or the Docker daemon becomes unreachable during the pre-pull — even with `--ignore-health-check` (intentional divergence from the old Go CLI's exit-0 swallow quirk; see the CLI-1987 note under "Notes") | @@ -305,9 +319,9 @@ code is surfaced on failure. Rollback (`rollbackStart`) tears down everything created so far by Docker label, and never masks the original failure — a rollback error is logged to stderr and swallowed. `deleteVolumes` is -`true` only when this run's Postgres volume was freshly created (so a failed -first-ever `start` prunes its own empty volume too), `false` otherwise (never touches a -pre-existing user's data on a failed restart). Rollback also reclaims this run's own +`true` only when this run's Postgres volume was freshly created or reused while still +uninitialised (so a failed first-ever `start` prunes its own empty volume too), `false` +otherwise (never touches a pre-existing user's data on a failed restart). Rollback also reclaims this run's own `/supabase/.temp/start-secrets/` directories (via `cleanupStartSecrets`, `command-internal/start-secrets-cleanup.ts`) once teardown is CONFIRMED complete — the matching containers come from @@ -434,12 +448,16 @@ prose, not structured data. "unhealthy service", but a user reading only `--help` may still expect exit 0 here. - `--preview` is a hidden, parsed-but-inert flag, inherited from the old Go CLI (never read by its own `start.Run`). -- The already-running check uses `docker container inspect` on the Postgres container and, - unless `--ignore-health-check` is set, requires its Docker health to be `healthy` or absent; +- The already-running check uses `docker container inspect` on the Postgres container. A + container that is not running never prints the "already running" banner: it fails with + ` container is not running: ` (`unknown` for an empty status) and a + suggestion to run `supabase stop` then `supabase start`. For a running container, unless + `--ignore-health-check` is set, it requires its Docker health to be `healthy` or absent; otherwise it fails with ` container is not ready: ...` (reason format in "Readiness gating"). When Docker could not launch the health check, it accepts the container and prints the same `WARNING:` naming `supabase_db_` as unverified. For a verified - stopped container outside Bitbucket Pipelines, `start` removes all current-project + stopped container (any non-empty Docker status, including `created`) outside Bitbucket + Pipelines, `start` removes all current-project containers — including running siblings — and unused networks, preserves named volumes, and continues normal startup. After container removal succeeds, it deletes `/supabase/.temp/start-secrets/` only for containers @@ -452,9 +470,18 @@ prose, not structured data. unaffected by that change. - Existing local values declared under a Function import map's `scopes` are mounted read-only into Edge Runtime, and into the Studio container that shares the same resolved Function bind mounts, even when they resolve outside the nearest Git root. The Edge Runtime container additionally prunes its aggregated bind list before `docker create` — a bind already supplied verbatim by a same-mode parent bind is dropped so the bootstrap `docker cp` never sees a file bind nested inside a read-only parent (supabase/supabase#50088) — while Studio keeps the full unpruned set (its bring-up performs no `docker cp`). The mounted target itself is bound as declared; imports reached from inside an out-of-root target are not additionally bound. Edge Runtime bring-up prints a `WARN` naming each distinct out-of-root host path once; Studio's bind resolution stays silent, so with Edge Runtime excluded (`-x edge-runtime`) the mounts still reach Studio and no warning is printed. Missing targets retain Edge Runtime startup's existing skip behavior. This is NOT the same permissiveness as email template `content_path` below — a `scopes` value is an explicit opt-in mechanism, mounted `:ro`, with containment enforced on the upload side elsewhere (`functions deploy`), whereas `content_path` has no opt-in at all and is enforced at resolution time, mounted `:rw,z`. - **Auth email `content_path` project-root containment AND readability apply here regardless of `auth.enabled`.** Kong's mount set (`resolveKongEmailTemplateMounts`, `start.handler.ts`) covers every configured `[auth.email.template.*]` entry, and every `enabled = true` `[auth.email.notification.*]` entry, unconditionally — Kong is the stack's mandatory gateway, independent of whether GoTrue itself is started. `start.handler.ts` resolves, confines (`resolveEmailTemplateContentPath`; symlinks dereferenced using `fs.realPath` before the check), AND read-verifies (a discarded `FileSystem.readFileString`) that same set in one eager pass before any Docker work, in addition to the `auth.enabled`-gated read `resolveLocalConfigValues`'s own validation performs; the resulting resolved path is threaded straight into Kong's bind mount (`buildKongEmailTemplateBind` in `kong.service.ts`, which no longer re-resolves anything) rather than re-derived later, right before Kong's own `docker create`. Each template is bound `rw,z`: when the container runtime applies SELinux labels (enforcing or permissive mode) it relabels the mounted host file in place with the shared container context so Kong can read it under enforcement — a persistent change to the file's security label that survives `supabase stop` — and the option is a no-op where the runtime has no SELinux support. A path that resolves outside the project root aborts the run with `Invalid config for auth.email.
..content_path: resolves outside the project root ()`; a resolved, in-root path that cannot be read as a regular file aborts with `Invalid config for auth.email.
..content_path: ` — both before a single container is created, and both regardless of `auth.enabled` (closing the gap where a missing `content_path` would otherwise reach an unconditional Kong bind mount and the root-privileged Docker daemon would silently create a directory there). -- Docker status `created` is not considered a recoverable stopped stack: the container and - named volume are preserved because the volume may not have completed its first database - initialization, and `start` reports the existing not-running status instead. +- A Postgres container in Docker status `created` (an earlier `start` died between `docker +create` and `docker start`) is recovered like any other stopped stack. The named volume is + preserved, and the data-directory probe (see "Fresh-volume DB setup") decides whether the + next bring-up still runs the setup pipeline and bucket seeding. +- **Concurrent starts:** `start` and `db start` take a per-project + lock at `$SUPABASE_HOME/locks/start-.lock` (a SQLite `BEGIN IMMEDIATE` lock the OS + releases when the process dies) once the project id is resolved and before inspecting any + container, and hold it until the command ends. A second run prints `Waiting for another supabase +start for this project to finish...` to stderr (text format only) and waits up to 10 + minutes, then fails with `LocalStackStartBusyError`. A lock location that cannot be created or + opened never blocks a start: it continues unlocked and logs the reason under `--debug`. `stop` + does not take the lock, so it still runs while another start is in progress. - **Intentional divergence from Go — spec-strict import-map key matching (CLI-2179, ruled 2026-08-12):** Edge Runtime bind mounts are computed by the same functions import scanner as `functions deploy`/`functions serve` (`walkImportPaths`/`substituteImportMapValue`, diff --git a/apps/cli/src/commands/start/start.command.ts b/apps/cli/src/commands/start/start.command.ts index cc154fc557..1bd998d684 100644 --- a/apps/cli/src/commands/start/start.command.ts +++ b/apps/cli/src/commands/start/start.command.ts @@ -71,6 +71,7 @@ const platformApiFactory = commandPlatformApiFactoryLayer.pipe( const startRuntimeLayer = Layer.mergeAll( cliSettings, + debugLoggerLayer, telemetryStateLayer, commandRuntimeLayer(["start"]), dockerRunLayer, diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index eb7e120996..f053448156 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -106,11 +106,12 @@ import { type LocalProjectContext, } from "../../command-internal/local-project-context.ts"; import { seedBucketsRun } from "../../command-internal/seed-buckets.ts"; +import { holdLocalStackStartLock } from "../../command-internal/local-stack-start-lock.ts"; import { cleanupStartSecrets } from "../../command-internal/start-secrets-cleanup.ts"; import { + dbNotRunningError, StatusDbInspectError, StatusDbNotReadyError, - StatusDbNotRunningError, StatusInvalidConfigError, StatusListError, } from "../../command-internal/status-errors.ts"; @@ -598,6 +599,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const dbContainerId = localDbContainerId(projectId); const filterValue = cliProjectFilterValue(projectId); + const dbNotRunning = (status: string) => dbNotRunningError(dbContainerId, status); // Shared by the already-running branch (full status pipeline, re-health-checked) and the // success path (a direct pretty-print, no re-health-check). @@ -634,6 +636,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { Effect.mapError((error) => new StartConfigLoadError({ message: error.message })), ); + // A concurrent start's rollback would otherwise prune this run's containers and network. + yield* holdLocalStackStartLock(projectId); + // 3. A missing container proceeds to startup; other inspect failures propagate. Stopped // stacks are recovered unless Bitbucket's lack of named volumes makes removal destructive. const inspectDbState = inspectContainerState(spawner, dbContainerId).pipe( @@ -653,41 +658,40 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const isRecoverableStoppedState = ( state: { readonly running: boolean; readonly status: string } | void, ) => - // `created` may own a just-provisioned volume that Postgres never initialized. - state?.running === false && state.status.length > 0 && state.status !== "created"; + // Bring-up's data-directory probe decides whether a reused volume still needs setup. + state?.running === false && state.status.length > 0; const shouldRecoverStoppedStack = isRecoverableStoppedState(dbState) && !inBitbucketPipeline; yield* Effect.annotateCurrentSpan({ - "start.already_running": dbState !== undefined && !shouldRecoverStoppedStack, + "start.already_running": dbState?.running === true, "start.recover_stopped_stack": shouldRecoverStoppedStack, }); const reportAlreadyRunningStatus = Effect.fn("start.reportAlreadyRunning")(function* () { + // The full status pipeline for this branch: health-check plus "stopped services" diffing, + // distinct from the success path's direct pretty-print call below. + const state = flags.ignoreHealthCheck + ? undefined + : yield* inspectContainerState(spawner, dbContainerId).pipe( + Effect.mapError((cause) => new StatusDbInspectError({ message: cause.message })), + ); + if (state !== undefined && !state.running) { + return yield* dbNotRunning(state.status); + } + // Gated on text mode for consistency with every other supplementary stderr line this // handler prints — json/stream-json callers get a clean structured payload with no noise. if (output.format === "text") { yield* output.raw(startAlreadyRunningMessage(), "stderr"); } - // The full status pipeline for this branch: health-check plus "stopped services" diffing, - // distinct from the success path's direct pretty-print call below. - if (!flags.ignoreHealthCheck) { - const state = yield* inspectContainerState(spawner, dbContainerId).pipe( - Effect.mapError((cause) => new StatusDbInspectError({ message: cause.message })), - ); - if (!state.running) { - return yield* new StatusDbNotRunningError({ - message: `${dbContainerId} container is not running: ${state.status}`, + if (state?.health !== undefined && state.health !== "healthy") { + if (!healthCheckCannotExec(state)) { + return yield* new StatusDbNotReadyError({ + message: `${dbContainerId} ${formatNotReadyReason(state)}`, }); } - if (state.health !== undefined && state.health !== "healthy") { - if (!healthCheckCannotExec(state)) { - return yield* new StatusDbNotReadyError({ - message: `${dbContainerId} ${formatNotReadyReason(state)}`, - }); - } - yield* Effect.annotateCurrentSpan("db.health_exec_unavailable", true); - yield* output.raw(healthCheckExecUnavailableWarning([dbContainerId]), "stderr"); - } + yield* Effect.annotateCurrentSpan("db.health_exec_unavailable", true); + yield* output.raw(healthCheckExecUnavailableWarning([dbContainerId]), "stderr"); } const runningNames = yield* listContainersByLabel(spawner, { @@ -717,6 +721,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { }); if (dbState !== undefined && !shouldRecoverStoppedStack) { + if (!dbState.running) { + return yield* dbNotRunning(dbState.status); + } return yield* reportAlreadyRunningStatus(); } @@ -1734,6 +1741,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // Recheck after preflight in case Docker or another process restarted the stack. const recheckedState = yield* inspectDbState; if (recheckedState !== undefined && !isRecoverableStoppedState(recheckedState)) { + if (!recheckedState.running) { + return yield* dbNotRunning(recheckedState.status); + } return yield* reportAlreadyRunningStatus(); } // cliProjectFilterValue("") targets every CLI-managed project; never use it here. @@ -1958,5 +1968,5 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const { values: statusValues } = yield* buildStatusValues(statusExcluded, values); yield* output.success("", statusValues); } - }).pipe(Effect.ensuring(telemetryState.flush)); + }).pipe(Effect.scoped, Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 12e48c4bb7..e8510cb4f6 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -2,6 +2,7 @@ import { generateKeyPairSync } from "node:crypto"; import { BunServices } from "@effect/platform-bun"; import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { holdLock } from "@supabase/stack/effect"; import { Cause, Clock, @@ -49,9 +50,13 @@ import { inspectedState, } from "../../../tests/helpers/docker-health.ts"; import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; +import { observeMakeDirectory, startLockState } from "../../../tests/helpers/start-lock.ts"; import { unusedStackServices } from "../../../tests/helpers/unused-stack.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; -import { classifyCliCauseActionability } from "../../shared/telemetry/error-actionability.ts"; +import { + classifyCliCauseActionability, + classifyCliErrorActionability, +} from "../../shared/telemetry/error-actionability.ts"; import { DebugFlag, ExperimentalFlag, @@ -61,10 +66,12 @@ import { import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; import { serviceContainerIds, serviceContainerName } from "../../command-internal/docker-ids.ts"; import { bindMountSpecSource } from "../../command-internal/docker-bind-classify.ts"; +import { ContainerStartError } from "../../command-internal/db-bootstrap/container-lifecycle.ts"; import { HealthCheckTimeoutSeconds } from "../../command-internal/db-bootstrap/health-check.ts"; import { DbConnectError } from "../../command-internal/db-connection.errors.ts"; import { DbConnection, type DbSession } from "../../command-internal/db-connection.service.ts"; import { dockerRunLayer } from "../../command-internal/docker-run.layer.ts"; +import { LocalStackStartBusyError } from "../../command-internal/local-stack-start-lock.ts"; import { START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import type { StartFlags } from "./start.command.ts"; import { start } from "./start.handler.ts"; @@ -398,6 +405,72 @@ function freshVolumeRoute( }; } +/** Docker's answer when the created Postgres container's data directory holds no `PG_VERSION`, i.e. `initdb` never ran. */ +function uninitialisedDataDirRoute( + base: ( + args: ReadonlyArray, + ) => RouteResult | Effect.Effect, +): ( + args: ReadonlyArray, +) => RouteResult | Effect.Effect { + return (args) => { + if ( + args[0] === "cp" && + args[1]?.endsWith(":/var/lib/postgresql/data/PG_VERSION") === true && + args[2] === "-" + ) { + return { + exitCode: 1, + stderr: [ + `Error response from daemon: Could not find the file /var/lib/postgresql/data/PG_VERSION in container ${args[1].split(":")[0]}`, + ], + }; + } + return base(args); + }; +} + +/** `docker start` of the Postgres container exits non-zero, as when the runtime cannot launch it. */ +function dbStartFailsRoute( + base: ( + args: ReadonlyArray, + ) => RouteResult | Effect.Effect, +) { + return (args: ReadonlyArray) => + args[0] === "start" && args[1] === fakeContainerId("supabase_db_demo") + ? { + exitCode: 1, + stderr: [ + "Error response from daemon: failed to create task for container: OCI runtime create failed", + ], + } + : base(args); +} + +/** The project's database container reports `state` until recovery lists the project's containers, then behaves like a fresh bring-up. */ +function recoveringStackRoute( + state: string, + workdir: string, + base: (args: ReadonlyArray) => RouteResult = defaultRoute(), +) { + let recovering = false; + return (args: ReadonlyArray): RouteResult => { + if ( + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_db_demo" && + !recovering + ) { + return { stdout: [state] }; + } + if (args[0] === "ps" && args.includes("--all")) { + recovering = true; + return { stdout: [`db-id\tsupabase_db_demo\t${workdir}`] }; + } + return base(args); + }; +} + /** * Vector-capable variant: empty bucket list, a fixed set of existing vector buckets, and a * raw-body recorder for `DeleteVectorBucket` calls; every other request answers a bare 200. @@ -572,6 +645,8 @@ interface SetupOpts { readonly experimental?: boolean; /** Replaces the `DbConnection.connect` behind the Postgres readiness probe. Defaults to succeeding. */ readonly dbConnect?: (typeof DbConnection)["Service"]["connect"]; + /** Overrides `$SUPABASE_HOME`, which the start lock lives under. Defaults to `/.supabase`. */ + readonly supabaseHome?: string; } const setup = Effect.fnUntraced(function* (opts: SetupOpts = {}) { @@ -585,7 +660,7 @@ const setup = Effect.fnUntraced(function* (opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const telemetry = mockTelemetryStateTracked(); const analytics = mockAnalytics(); - const cliSettings = mockCommandSettings({ workdir }); + const cliSettings = mockCommandSettings({ workdir, supabaseHome: opts.supabaseHome }); const child = mockStartContainerCliSpawner(opts.route ?? defaultRoute(), { failSpawn: opts.failSpawn, onSecretCopy: opts.onSecretCopy, @@ -1055,7 +1130,7 @@ describe("start integration", () => { "BITBUCKET_CLONE_DIR", "/opt/atlassian/pipelines/agent/build", Effect.gen(function* () { - const { layer, child } = yield* setup({ + const { layer, out, child } = yield* setup({ route: (args) => { if (args[0] === "container" && args[1] === "inspect") { return { stdout: [STOPPED_STATE] }; @@ -1067,8 +1142,11 @@ describe("start integration", () => { const exit = yield* Effect.exit(start(flags()).pipe(Effect.provide(layer))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("StatusDbNotRunningError"); + const serialized = Cause.pretty(exit.cause); + expect(serialized).toContain("StatusDbNotRunningError"); + expect(serialized).toContain("supabase_db_demo container is not running: exited"); } + expect(out.stderrText).not.toContain("is already running"); expect( child.spawned.some( (spawn) => @@ -1085,7 +1163,7 @@ describe("start integration", () => { it.effect("does not remove containers when re-inspect returns an unknown state", () => Effect.gen(function* () { let dbInspects = 0; - const { layer, child } = yield* setup({ + const { layer, out, child } = yield* setup({ route: (args) => { if (args[0] === "container" && args[1] === "inspect") { dbInspects += 1; @@ -1097,6 +1175,12 @@ describe("start integration", () => { const exit = yield* Effect.exit(start(flags()).pipe(Effect.provide(layer))); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain( + "supabase_db_demo container is not running: unknown", + ); + } + expect(out.stderrText).not.toContain("is already running"); expect( child.spawned.some( (spawn) => @@ -1106,36 +1190,105 @@ describe("start integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("does not recover a created database container with unknown volume state", () => + it.effect("recovers a created database container without pruning the database volume", () => Effect.gen(function* () { - const { layer, child } = yield* setup({ + const workdir = tempRoot.current; + const route = defaultRoute(); + let recovering = false; + const { layer, out, child } = yield* setup({ route: (args) => { - if (args[0] === "container" && args[1] === "inspect") { + if ( + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_db_demo" && + !recovering + ) { return { stdout: [CREATED_STATE] }; } - return { exitCode: 0 }; + if (args[0] === "ps" && args.includes("--all")) { + recovering = true; + return { stdout: [`db-id\tsupabase_db_demo\t${workdir}`] }; + } + return route(args); }, }); - const exit = yield* Effect.exit(start(flags()).pipe(Effect.provide(layer))); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const serialized = Cause.pretty(exit.cause); - expect(serialized).toContain("StatusDbNotRunningError"); - expect(serialized).toContain("container is not running: created"); - } + yield* start(flags()).pipe(Effect.provide(layer)); + + expect(out.stderrText).not.toContain("is already running"); + expect(out.stderrText).not.toContain("Initialising schema..."); + expect(createdContainerNames(child.spawned)).toContain("supabase_db_demo"); expect( child.spawned.some( (spawn) => - (spawn.args[0] === "ps" && spawn.args.includes("--all")) || - spawn.args[0] === "stop" || - spawn.args[1] === "prune" || - spawn.args[0] === "create", + spawn.args[0] === "container" && + spawn.args[1] === "prune" && + spawn.args.includes("label=com.supabase.cli.project=demo"), ), + ).toBe(true); + expect( + child.spawned.some((spawn) => spawn.args[0] === "volume" && spawn.args[1] === "prune"), ).toBe(false); }).pipe(Effect.provide(BunServices.layer)), ); + it.effect( + "fails with the unknown status and a supabase stop suggestion when Docker reports no database state", + () => + Effect.gen(function* () { + const { layer, out, child } = yield* setup({ + route: (args) => { + if (args[0] === "container" && args[1] === "inspect") return { stdout: [""] }; + return { exitCode: 0 }; + }, + }); + + const error = yield* start(flags()).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "StatusDbNotRunningError", + message: "supabase_db_demo container is not running: unknown", + suggestion: expect.stringContaining("supabase stop"), + }); + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + suggested_command: "supabase stop", + }); + expect(out.stderrText).not.toContain("is already running"); + expect(createdContainerNames(child.spawned)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "fails with the container status and no already-running banner when the database stops between the two inspects", + () => + Effect.gen(function* () { + let dbInspects = 0; + const { layer, out } = yield* setup({ + route: (args) => { + if ( + args[0] === "container" && + args[1] === "inspect" && + args[2] === "supabase_db_demo" + ) { + dbInspects += 1; + return { stdout: [dbInspects === 1 ? HEALTHY_STATE : STOPPED_STATE] }; + } + return { exitCode: 0 }; + }, + }); + + const error = yield* start(flags()).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ + _tag: "StatusDbNotRunningError", + message: "supabase_db_demo container is not running: exited", + suggestion: expect.stringContaining("supabase stop"), + }); + expect(out.stderrText).not.toContain("is already running"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("validates custom TLS files before removing a stopped stack", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -1393,6 +1546,148 @@ describe("start integration", () => { ); }); + describe("per-project start lock", () => { + const WAITING_LINE = "Waiting for another supabase start for this project to finish...\n"; + + const startLockFile = Effect.fnUntraced(function* (workdir: string) { + const path = yield* Path.Path; + return path.join(workdir, ".supabase", "locks", "start-demo.lock"); + }); + + const startAgainstHeldLock = (format: "text" | "json" | "stream-json") => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lockFile = yield* startLockFile(tempRoot.current); + yield* fs.makeDirectory(path.dirname(lockFile), { recursive: true }); + yield* holdLock(lockFile, "0 millis", "concurrent start"); + const lockDirReady = yield* Deferred.make(); + const { layer, out, child } = yield* setup({ format }); + + const forkedAt = yield* Clock.currentTimeMillis; + const fiber = yield* start(flags()).pipe( + Effect.provide( + Layer.mergeAll( + layer, + observeMakeDirectory((made) => + made === path.dirname(lockFile) + ? Deferred.succeed(lockDirReady, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.raceFirst(Deferred.await(lockDirReady), Fiber.await(fiber)); + for (let minute = 0; minute < 60 && fiber.pollUnsafe() === undefined; minute++) { + yield* TestClock.adjust("1 minute"); + } + const error = yield* Fiber.join(fiber); + const waitedMillis = (yield* Clock.currentTimeMillis) - forkedAt; + return { error, out, child, waitedMillis }; + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); + + it.effect( + "waits the 10 minute budget for a concurrent start of the same project, then fails without touching Docker", + () => + Effect.gen(function* () { + const { error, out, child, waitedMillis } = yield* startAgainstHeldLock("text"); + + expect(error).toBeInstanceOf(LocalStackStartBusyError); + expect(error).toMatchObject({ + _tag: "LocalStackStartBusyError", + message: "another supabase start is still running for this project", + suggestion: expect.stringContaining("supabase start"), + }); + expect(classifyCliErrorActionability(error)).toMatchObject({ + error_kind: "user_actionable", + error_fingerprint: "tag:LocalStackStartBusyError:lease_held", + }); + expect(out.stderrText).toContain(WAITING_LINE); + expect(waitedMillis).toBeGreaterThanOrEqual(10 * 60_000); + expect(waitedMillis).toBeLessThanOrEqual(11 * 60_000); + expect(child.spawned).toEqual([]); + }), + 60_000, + ); + + for (const format of ["json", "stream-json"] as const) { + it.effect( + `keeps the waiting line out of ${format} output while waiting for a concurrent start`, + () => + Effect.gen(function* () { + const { error, out } = yield* startAgainstHeldLock(format); + + expect(error).toMatchObject({ _tag: "LocalStackStartBusyError" }); + expect(out.rawChunks).toEqual([]); + }), + 60_000, + ); + } + + it.effect("holds the lock from before the first Docker inspect through bring-up", () => + Effect.scoped( + Effect.gen(function* () { + const lockFile = yield* startLockFile(tempRoot.current); + const base = defaultRoute(); + const stateAtInspect: Array<"free" | "busy" | "error"> = []; + const { layer } = yield* setup({ + route: (args) => + args[0] === "container" && args[1] === "inspect" && args[2] === "supabase_db_demo" + ? startLockState(lockFile).pipe( + Effect.map((state) => { + stateAtInspect.push(state); + return base(args); + }), + ) + : base(args), + }); + + yield* start(flags()).pipe(Effect.provide(layer)); + + expect(stateAtInspect.length).toBeGreaterThan(1); + expect(stateAtInspect.every((state) => state === "busy")).toBe(true); + expect(yield* startLockState(lockFile)).toBe("free"); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("releases the lock when start fails", () => + Effect.scoped( + Effect.gen(function* () { + const lockFile = yield* startLockFile(tempRoot.current); + const { layer } = yield* setup({ + route: (args) => + args[0] === "container" && args[1] === "inspect" ? { stdout: [""] } : { exitCode: 0 }, + }); + + const error = yield* start(flags()).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toMatchObject({ _tag: "StatusDbNotRunningError" }); + expect(yield* startLockState(lockFile)).toBe("free"); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("starts without a lock when the lock location cannot be created", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const blocker = path.join(tempRoot.current, "not-a-directory"); + yield* fs.writeFileString(blocker, ""); + const { layer, out, child } = yield* setup({ + supabaseHome: path.join(blocker, "home"), + }); + + yield* start(flags()).pipe(Effect.provide(layer)); + + expect(createdContainerNames(child.spawned)).toContain("supabase_db_demo"); + expect(out.stderrText).not.toContain(WAITING_LINE); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); + describe("config load / validation failures", () => { it.effect("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => Effect.gen(function* () { @@ -2713,6 +3008,101 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe(Effect.provide(BunServices.layer)), ); + describe("a reused volume whose Postgres data directory was never initialised", () => { + for (const [status, state] of [ + ["created", CREATED_STATE], + ["exited", STOPPED_STATE], + ] as const) { + it.effect( + `runs the database setup when the ${status} container's volume has no PG_VERSION`, + () => + Effect.gen(function* () { + const { layer, out, child } = yield* setup({ + route: uninitialisedDataDirRoute(recoveringStackRoute(state, tempRoot.current)), + }); + + yield* start(flags({ exclude: ["edge-runtime"] })).pipe(Effect.provide(layer)); + + expect(out.stderrText).not.toContain("is already running"); + expect(out.stderrText).toContain("Initialising schema..."); + expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); + expect( + child.spawned.some((s) => s.args[0] === "volume" && s.args[1] === "prune"), + ).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + } + + it.effect("seeds configured buckets, as it does for a brand-new volume", () => + Effect.gen(function* () { + const http = mockStorageBucketHttpClient(); + const { layer } = yield* setup({ + configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', + route: uninitialisedDataDirRoute(recoveringStackRoute(CREATED_STATE, tempRoot.current)), + httpClientLayer: http.layer, + }); + + yield* start(flags({ exclude: ["edge-runtime"] })).pipe(Effect.provide(layer)); + + expect(http.createdBucketRequests).toHaveLength(1); + }).pipe(Effect.provide(BunServices.layer)), + ); + + for (const [probe, route, volumePruned] of [ + ["uninitialised", uninitialisedDataDirRoute, true], + ["initialised", (base: ReturnType) => base, false], + ] as const) { + it.live( + `rolls back ${volumePruned ? "and prunes" : "without pruning"} the volume when Postgres never becomes healthy on an ${probe} data directory`, + () => + Effect.gen(function* () { + const { layer, child } = yield* setup({ + configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "0s"\n', + dbConnect: refusedDbConnect, + route: route(recoveringStackRoute(CREATED_STATE, tempRoot.current)), + }); + + const exit = yield* Effect.exit(start(flags()).pipe(Effect.provide(layer))); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("HealthCheckTimeoutError"); + } + expect(rollbackWasAttempted(child.spawned)).toBe(true); + expect( + child.spawned.some((s) => s.args[0] === "volume" && s.args[1] === "prune"), + ).toBe(volumePruned); + }).pipe(Effect.provide(BunServices.layer)), + 10_000, + ); + + it.effect( + `rolls back ${volumePruned ? "and prunes" : "without pruning"} the volume when docker start of Postgres fails on an ${probe} data directory`, + () => + Effect.gen(function* () { + const { layer, child } = yield* setup({ + route: dbStartFailsRoute( + route(recoveringStackRoute(CREATED_STATE, tempRoot.current)), + ), + }); + + const error = yield* start(flags()).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(ContainerStartError); + expect(error).toMatchObject({ + message: expect.stringContaining( + 'failed to start docker container "supabase_db_demo"', + ), + }); + expect(rollbackWasAttempted(child.spawned)).toBe(true); + expect( + child.spawned.some((s) => s.args[0] === "volume" && s.args[1] === "prune"), + ).toBe(volumePruned); + }).pipe(Effect.provide(BunServices.layer)), + ); + } + }); + it.effect("seeds a configured bucket on a fresh volume with storage enabled", () => Effect.gen(function* () { const http = mockStorageBucketHttpClient(); diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index 27dc344cf2..eb2f89c952 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -47,7 +47,13 @@ function resolveProfile( Effect.tap(() => debugLogger.debug(`Loading profile from file: ${filePath}`)), Effect.map(Option.some), Effect.catch((error) => - debugLogger.debug(unknownMessage(error)).pipe(Effect.as(Option.none())), + debugLogger + .debug( + error.reason._tag === "NotFound" + ? `No profile file at ${filePath}; using the default profile.` + : unknownMessage(error), + ) + .pipe(Effect.as(Option.none())), ), ); token = Option.match(content, { diff --git a/apps/cli/src/config/command-settings.layer.unit.test.ts b/apps/cli/src/config/command-settings.layer.unit.test.ts index c548a92f91..71bba65867 100644 --- a/apps/cli/src/config/command-settings.layer.unit.test.ts +++ b/apps/cli/src/config/command-settings.layer.unit.test.ts @@ -263,6 +263,53 @@ describe("commandSettingsLayer", () => { ); }); + it.effect("debug logs a missing persisted profile file as the default profile", () => { + const home = path.join(tempRoot.current, "home"); + const profilePath = path.join(home, ".supabase", "profile"); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const rawError = yield* fs.readFileString(profilePath).pipe(Effect.flip); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ home, cwd: tempRoot.current, debug: true }), + ); + const logged = stderr.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(config.profile).toBe("supabase"); + expect(logged).toContain(`No profile file at ${profilePath}; using the default profile.\n`); + expect(logged).not.toContain(rawError.message); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(BunServices.layer), + ); + }); + + it.effect( + "debug logs the read error, not the missing-file line, for an unreadable profile", + () => { + const home = path.join(tempRoot.current, "home"); + const profilePath = path.join(home, ".supabase", "profile"); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(profilePath, { recursive: true }); + const rawError = yield* fs.readFileString(profilePath).pipe(Effect.flip); + const config = yield* Effect.provide( + Effect.service(CommandSettings), + makeLayer({ home, cwd: tempRoot.current, debug: true }), + ); + const logged = stderr.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(rawError.reason._tag).not.toBe("NotFound"); + expect(config.profile).toBe("supabase"); + expect(logged).toContain(`${rawError.message}\n`); + expect(logged).not.toContain("No profile file at"); + }).pipe( + Effect.ensuring(Effect.sync(() => stderr.mockRestore())), + Effect.provide(BunServices.layer), + ); + }, + ); + it.effect("flag and env take precedence over the persisted profile file", () => { const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 4cba21d391..bc951e7570 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -338,6 +338,7 @@ LinkProjectStatusError LinkProjectStatusNetworkError LinkRefArgConflictError LocalDbRunningError +LocalStackStartBusyError LockResolutionError LoginCryptoError LoginDecryptError diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 4181c6f7c6..098d80c5ab 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -56,10 +56,8 @@ import { LoginVerificationError, } from "../../src/commands/login/login.errors.ts"; import { CommandSettings } from "../../src/config/command-settings.service.ts"; -import { - PGDATA_BASELINE_MARKER_NAME, - PGDATA_PATH, -} from "../../src/command-internal/db-bootstrap/pgdata-snapshot.ts"; +import { PGDATA_BASELINE_MARKER_NAME } from "../../src/command-internal/db-bootstrap/pgdata-snapshot.ts"; +import { POSTGRES_DATA_DIR } from "../../src/command-internal/db-bootstrap/postgres.service.ts"; import { remoteTargetLayer } from "../../src/config/remote-target.layer.ts"; import { LocalDockerEngine } from "../../src/command-internal/db-bootstrap/local-db-running.ts"; import { LinkedProjectCache } from "../../src/telemetry/linked-project-cache.service.ts"; @@ -106,7 +104,7 @@ export const mockLinkedProjectCacheLayer = Layer.succeed(LinkedProjectCache, { * socket. Direct-path tests provide their own definitive `LocalDockerEngine`. */ export const mockLocalDockerEngineUnavailableLayer = Layer.succeed(LocalDockerEngine, { - containerExists: () => Effect.succeed(Option.none()), + inspectContainer: () => Effect.succeed(Option.none()), }); export const mockTelemetryStateLayer = Layer.succeed(TelemetryState, { @@ -1306,7 +1304,7 @@ export function mockDockerDaemonCliSpawner( const container = containers.get(id); const received = yield* readStdin(command); const isSecret = containerPath === "" || containerPath === "/"; - const isStamp = containerPath === PGDATA_PATH; + const isStamp = containerPath === POSTGRES_DATA_DIR; const failed = (isStamp && opts.failStamp === true) || (!isSecret && !isStamp && opts.failCopyIn === true); @@ -1379,7 +1377,7 @@ export function mockDockerDaemonCliSpawner( const dest = args[2] ?? ""; const containerPath = dest.slice(dest.indexOf(":") + 1); if (containerPath === "" || containerPath === "/") return "cp-secret"; - return containerPath === PGDATA_PATH ? "cp-stamp" : "cp-in"; + return containerPath === POSTGRES_DATA_DIR ? "cp-stamp" : "cp-in"; } if (args[2] === "-") return "cp-out"; return "cp-secret"; diff --git a/apps/cli/tests/helpers/start-lock.ts b/apps/cli/tests/helpers/start-lock.ts new file mode 100644 index 0000000000..ffff322750 --- /dev/null +++ b/apps/cli/tests/helpers/start-lock.ts @@ -0,0 +1,29 @@ +import { BunServices } from "@effect/platform-bun"; +import { holdLock, isLockBusy } from "@supabase/stack/effect"; +import { Effect, FileSystem, Layer } from "effect"; +import * as TestClock from "effect/testing/TestClock"; + +/** Probes a start lock on the real clock, so a held lock reads `busy` without waiting on the TestClock. */ +export const startLockState = (lockFile: string) => + TestClock.withLive( + holdLock(lockFile, "0 millis", "probe").pipe( + Effect.scoped, + Effect.match({ + onSuccess: () => "free" as const, + onFailure: (error) => (isLockBusy(error) ? ("busy" as const) : ("error" as const)), + }), + ), + ); + +/** A `FileSystem` that reports each successful `makeDirectory`, the last real I/O before the first lock attempt. */ +export const observeMakeDirectory = (onMade: (path: string) => Effect.Effect) => + Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (real) => + FileSystem.FileSystem.of({ + ...real, + makeDirectory: (path, options) => + real.makeDirectory(path, options).pipe(Effect.tap(() => onMade(path))), + }), + ), + ).pipe(Layer.provide(BunServices.layer)); From f8bcf8925674da53bb8b35e90370e052ef4e2283 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 29 Oct 2026 13:41:21 +0000 Subject: [PATCH 13/22] ci(cli): add a report-only CLI benchmark for pull requests (#7363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a non-blocking job that compares the CLI build of each ready pull request into `develop` with a build of the `develop` commit it branches from, without Docker, a database, or the network. It writes a job summary that leads with the answer ("no changes found" or "N things to check"), then one plain-words line per flag (including when a command that made no Management API calls starts making them), a before/after timing table per command, API call counts, binary size, and a collapsed section with timing ranges, per-command API calls, and the slowest phases for commands that moved. The first push to a pull request from this repository benchmarks and posts one comment with the same report, tagged with a hidden marker. Later pushes skip the benchmark while that comment reports a result, and the job summary says so. Pull requests from forks have a read-only token, so they get the job summary only and benchmark every push. A maintainer can comment `/bench` to run the benchmark again on the pull request's current head commit. The command re-runs that commit's workflow run (the same as "Re-run all jobs" in the Actions UI), and the re-run updates the comment in place. An eyes reaction on the `/bench` comment means GitHub accepted the re-run; the result arrives as the updated benchmark comment, whether it succeeded or failed. If nothing can be re-run, the pull request gets a reply saying why: a benchmark is already running, the run is older than 30 days, GitHub refused the re-run, or there is no run for the commit. The comment keeps a collapsed "Previous runs" list (commit, time, headline, link; up to 20) in a hidden marker. Only the repository owner and people with write access can run it, and only on open, non-draft pull requests into `develop` from this repository. The whole comment must be exactly `/bench`; any other comment is ignored. Comment-triggered workflows run from the default branch, so `/bench` starts working only after this merges to `develop`. A pull request that changes none of the benchmarked paths, or more than the 300 files GitHub's path filter inspects, gets no run, so `/bench` replies that there is none. - `apps/cli/scripts/bench-cli.ts` gains `--workloads ` and `--api-url`. Every launch gets a profile file pointing at a local replay server (the profile is the CLI's only API redirect), and the harness resets the server's request log and scenario before each launch. API calls are counted from HTTP client spans in the trace and cross-checked against the server's request log. - `apps/cli/scripts/bench-report.ts` holds the report schemas and pure analysis; `bench-summary.ts` renders the Markdown for the job summary and the comment; `replay-control.ts` is the client for the replay server's control API. - `apps/cli/bench/workloads.pr.json` lists the workloads. They were chosen from command usage data, ranked and filtered to commands that need no Docker, database, network, or prompt; the method, query, and caveats are in `apps/cli/docs/tracing-monitoring.md`. - `apps/cli-e2e/scripts/replay-serve.ts` starts the existing replay server standalone. - `.github/workflows/cli-bench.yml` and `.github/scripts/cli-bench/` are new. No existing workflow or action is edited. The `/bench` job never checks out or runs pull request code: it runs default branch code and only asks GitHub to re-run the existing `pull_request` run, so benchmarks always execute in the pull request's own run and cache scope, like any other pull request job. Permissions are split by job: the job that decides whether to benchmark is read-only, the `/bench` job is the only one with `actions: write`, the job that builds and runs pull request code has read-only permissions, and a separate job, the only `pull_request` job with comment-write permission, downloads the rendered markdown, sanitizes it, and posts it; it never runs pull request code. Whether the workflow token may call the re-run endpoint is verified on the first `/bench` after merge; a refusal is reported in the reply. Pull request runs now measure the pull request head commit instead of the test merge commit, so the commit shown in the report is the one that was benchmarked. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .github/scripts/cli-bench/github.ts | 69 +++ .github/scripts/cli-bench/history.test.ts | 114 ++++ .github/scripts/cli-bench/history.ts | 106 ++++ .../scripts/cli-bench/post-comment.test.ts | 143 +++++ .github/scripts/cli-bench/post-comment.ts | 153 +++++ .github/scripts/cli-bench/rerun.test.ts | 184 ++++++ .github/scripts/cli-bench/rerun.ts | 231 ++++++++ .github/scripts/cli-bench/resolve.test.ts | 47 ++ .github/scripts/cli-bench/resolve.ts | 68 +++ .github/workflows/cli-bench.yml | 301 ++++++++++ apps/cli-e2e/package.json | 1 + apps/cli-e2e/scripts/replay-serve.ts | 36 ++ apps/cli-e2e/src/server/replay-server.ts | 3 + apps/cli/bench/workloads.pr.json | 26 + apps/cli/docs/tracing-monitoring.md | 148 ++++- .../scripts/__fixtures__/api-call-trace.jsonl | 2 + .../cli/scripts/bench-cli.integration.test.ts | 109 ++++ apps/cli/scripts/bench-cli.ts | 548 ++++++++---------- apps/cli/scripts/bench-cli.unit.test.ts | 190 +++--- apps/cli/scripts/bench-report.ts | 523 +++++++++++++++++ apps/cli/scripts/bench-report.unit.test.ts | 474 +++++++++++++++ .../scripts/bench-summary.integration.test.ts | 25 + apps/cli/scripts/bench-summary.ts | 450 ++++++++++++++ apps/cli/scripts/bench-summary.unit.test.ts | 224 +++++++ apps/cli/scripts/bench-workloads.unit.test.ts | 39 ++ .../replay-control.integration.test.ts | 60 ++ apps/cli/scripts/replay-control.ts | 80 +++ apps/cli/scripts/trace-report.ts | 38 +- apps/cli/scripts/trace-report.unit.test.ts | 68 +++ 29 files changed, 4051 insertions(+), 409 deletions(-) create mode 100644 .github/scripts/cli-bench/github.ts create mode 100644 .github/scripts/cli-bench/history.test.ts create mode 100644 .github/scripts/cli-bench/history.ts create mode 100644 .github/scripts/cli-bench/post-comment.test.ts create mode 100644 .github/scripts/cli-bench/post-comment.ts create mode 100644 .github/scripts/cli-bench/rerun.test.ts create mode 100644 .github/scripts/cli-bench/rerun.ts create mode 100644 .github/scripts/cli-bench/resolve.test.ts create mode 100644 .github/scripts/cli-bench/resolve.ts create mode 100644 .github/workflows/cli-bench.yml create mode 100644 apps/cli-e2e/scripts/replay-serve.ts create mode 100644 apps/cli/bench/workloads.pr.json create mode 100644 apps/cli/scripts/__fixtures__/api-call-trace.jsonl create mode 100644 apps/cli/scripts/bench-cli.integration.test.ts create mode 100644 apps/cli/scripts/bench-report.ts create mode 100644 apps/cli/scripts/bench-report.unit.test.ts create mode 100644 apps/cli/scripts/bench-summary.integration.test.ts create mode 100644 apps/cli/scripts/bench-summary.ts create mode 100644 apps/cli/scripts/bench-summary.unit.test.ts create mode 100644 apps/cli/scripts/bench-workloads.unit.test.ts create mode 100644 apps/cli/scripts/replay-control.integration.test.ts create mode 100644 apps/cli/scripts/replay-control.ts diff --git a/.github/scripts/cli-bench/github.ts b/.github/scripts/cli-bench/github.ts new file mode 100644 index 0000000000..4859009a68 --- /dev/null +++ b/.github/scripts/cli-bench/github.ts @@ -0,0 +1,69 @@ +/** Small GitHub REST helpers shared by the CLI benchmark scripts. */ + +export function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} + +/** A GitHub request that did not succeed, with the HTTP status and GitHub's own message. */ +export class GithubRequestError extends Error { + constructor( + readonly status: number, + readonly detail: string, + url: string, + ) { + super(`GitHub request failed (${status}) for ${url}`); + this.name = "GithubRequestError"; + } +} + +export async function githubFetch( + url: string, + token: string, + init: RequestInit = {}, +): Promise { + const response = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { message?: unknown }; + const detail = typeof payload.message === "string" ? payload.message : response.statusText; + throw new GithubRequestError(response.status, detail, url); + } + return response; +} + +export interface IssueComment { + id: number; + body: string; + authorLogin: string; +} + +/** Every comment on an issue or pull request, oldest first. */ +export async function listIssueComments( + token: string, + base: string, + issue: number, +): Promise> { + const comments: Array = []; + for (let page = 1; ; page++) { + const batch = (await ( + await githubFetch(`${base}/issues/${issue}/comments?per_page=100&page=${page}`, token) + ).json()) as Array<{ id: number; body: string | null; user: { login: string } | null }>; + comments.push( + ...batch.map((entry) => ({ + id: entry.id, + body: entry.body ?? "", + authorLogin: entry.user?.login ?? "", + })), + ); + if (batch.length < 100) return comments; + } +} diff --git a/.github/scripts/cli-bench/history.test.ts b/.github/scripts/cli-bench/history.test.ts new file mode 100644 index 0000000000..0d096bce41 --- /dev/null +++ b/.github/scripts/cli-bench/history.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { + decodeHistory, + headlineOf, + makeEntry, + MAX_PREVIOUS_RUNS, + mergeHistory, + renderHistory, + type RunEntry, +} from "./history.ts"; + +const entry = (n: number, overrides: Partial = {}): RunEntry => ({ + sha: n.toString(16).padStart(7, "0"), + at: "2026-10-28T09:05Z", + headline: "no changes found", + url: `https://github.com/o/r/actions/runs/${n}`, + ...overrides, +}); + +describe("headlineOf", () => { + test("reads the title of the rendered report", () => { + expect(headlineOf("\n\n## CLI benchmark: 2 things to check\n\nbody")).toBe( + "2 things to check", + ); + }); + + test("falls back when the report has no title", () => { + expect(headlineOf("nothing here")).toBe("unknown"); + }); +}); + +describe("makeEntry", () => { + test("shortens the commit and rounds the time down to the minute in UTC", () => { + const made = makeEntry({ + sha: "0123456789abcdef0123456789abcdef01234567", + now: new Date("2026-10-28T09:05:59Z"), + headline: "no changes found", + url: "https://github.com/o/r/actions/runs/9/attempts/2", + }); + + expect(made).toMatchObject({ sha: "0123456", at: "2026-10-28T09:05Z" }); + }); + + test("refuses a run link that is not a workflow run", () => { + const made = makeEntry({ + sha: "abcdef1", + now: new Date(), + headline: "h", + url: "https://evil.test/x", + }); + + expect(made).toBeUndefined(); + }); +}); + +describe("mergeHistory", () => { + test("puts the new run first", () => { + expect(mergeHistory([entry(1)], entry(2)).map((e) => e.sha)).toEqual([ + entry(2).sha, + entry(1).sha, + ]); + }); + + test("keeps the current run plus at most 20 earlier ones, dropping the oldest", () => { + const previous = Array.from({ length: 30 }, (_, i) => entry(i + 1)); + + const merged = mergeHistory(previous, entry(100)); + + expect(merged).toHaveLength(MAX_PREVIOUS_RUNS + 1); + expect(merged[0]).toEqual(entry(100)); + expect(merged.at(-1)).toEqual(entry(MAX_PREVIOUS_RUNS)); + }); +}); + +describe("renderHistory and decodeHistory", () => { + test("round-trips the entries through the hidden marker", () => { + const entries = [entry(3), entry(2), entry(1)]; + + expect(decodeHistory(`body\n${renderHistory(entries, (t) => t)}\n`)).toEqual(entries); + }); + + test("lists earlier runs newest first and leaves the current run out of the list", () => { + const rendered = renderHistory( + [entry(3), entry(2, { headline: "1 thing to check" })], + (t) => t, + ); + + expect(rendered).toContain("Previous runs"); + expect(rendered).toContain( + "- `0000002` · 2026-10-28 09:05 UTC · 1 thing to check · [run](https://github.com/o/r/actions/runs/2)", + ); + expect(rendered).not.toContain("0000003` ·"); + }); + + test("shows no list for a first run", () => { + expect(renderHistory([entry(1)], (t) => t)).not.toContain("Previous runs"); + }); + + test.each([ + ["no marker", "plain comment"], + ["not base64 json", ""], + ["wrong shape", ``], + [ + "a forged run link", + ``, + ], + [ + "too many entries", + ``, + ], + ])("treats %s as empty history", (_name, body) => { + expect(decodeHistory(body)).toEqual([]); + }); +}); diff --git a/.github/scripts/cli-bench/history.ts b/.github/scripts/cli-bench/history.ts new file mode 100644 index 0000000000..9602383119 --- /dev/null +++ b/.github/scripts/cli-bench/history.ts @@ -0,0 +1,106 @@ +/** + * Run history kept inside the benchmark comment. The newest run is the comment itself; earlier + * runs sit in a collapsed list, backed by a hidden marker so the next update can carry them on. + * The marker is read back only from the workflow bot's own comment, and anything that does not + * decode cleanly counts as no history. + */ + +/** Earlier runs kept below the current one. */ +export const MAX_PREVIOUS_RUNS = 20; +const HISTORY_MARKER = //u; +const MAX_ENCODED_CHARS = 30_000; +const MAX_HEADLINE_CHARS = 100; +const SHA = /^[0-9a-f]{7,40}$/u; +const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/u; +const RUN_URL = /^https:\/\/[^\s)<>]+\/actions\/runs\/\d+(?:\/attempts\/\d+)?$/u; + +export interface RunEntry { + /** Short commit SHA. */ + sha: string; + /** UTC time to the minute, `YYYY-MM-DDTHH:MMZ`. */ + at: string; + headline: string; + url: string; +} + +const isEntry = (value: unknown): value is RunEntry => { + if (typeof value !== "object" || value === null) return false; + const { sha, at, headline, url } = value as Record; + return ( + typeof sha === "string" && + SHA.test(sha) && + typeof at === "string" && + AT.test(at) && + typeof headline === "string" && + headline.length <= MAX_HEADLINE_CHARS && + !/[\r\n]/u.test(headline) && + typeof url === "string" && + RUN_URL.test(url) + ); +}; + +/** The title the renderer puts after `## CLI benchmark:`, flattened to one bounded line. */ +export function headlineOf(markdown: string): string { + const title = /^## CLI benchmark: (.+)$/mu.exec(markdown)?.[1] ?? "unknown"; + return title.replaceAll(/\s+/gu, " ").trim().slice(0, MAX_HEADLINE_CHARS); +} + +/** An entry for a run, or undefined when the commit or run link is not in the expected shape. */ +export function makeEntry(input: { + sha: string; + now: Date; + headline: string; + url: string; +}): RunEntry | undefined { + const entry = { + sha: input.sha.slice(0, 7), + at: `${input.now.toISOString().slice(0, 16)}Z`, + headline: input.headline, + url: input.url, + }; + return isEntry(entry) ? entry : undefined; +} + +/** The runs recorded in a comment body, newest first; empty when the marker is absent or invalid. */ +export function decodeHistory(body: string): Array { + const encoded = HISTORY_MARKER.exec(body)?.[1]; + if (encoded === undefined || encoded.length > MAX_ENCODED_CHARS) return []; + try { + const value: unknown = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + return Array.isArray(value) && value.length <= MAX_PREVIOUS_RUNS + 1 && value.every(isEntry) + ? value + : []; + } catch { + return []; + } +} + +/** The new run first, then the earlier ones, with at most {@link MAX_PREVIOUS_RUNS} behind it. */ +export function mergeHistory( + previous: ReadonlyArray, + current: RunEntry, +): Array { + return [current, ...previous].slice(0, MAX_PREVIOUS_RUNS + 1); +} + +const day = (at: string) => `${at.slice(0, 10)} ${at.slice(11, 16)} UTC`; + +/** + * The hidden marker for `entries` (current run first), preceded by a collapsed list of the + * earlier ones when there are any. `clean` makes a headline safe to render. + */ +export function renderHistory( + entries: ReadonlyArray, + clean: (text: string) => string, +): string { + const marker = ``; + const earlier = entries.slice(1); + if (earlier.length === 0) return marker; + const lines = earlier.map( + (entry) => + `- \`${entry.sha}\` · ${day(entry.at)} · ${clean(entry.headline)} · [run](${entry.url})`, + ); + return ["
Previous runs", "", ...lines, "", "
", marker].join( + "\n", + ); +} diff --git a/.github/scripts/cli-bench/post-comment.test.ts b/.github/scripts/cli-bench/post-comment.test.ts new file mode 100644 index 0000000000..fb890a8952 --- /dev/null +++ b/.github/scripts/cli-bench/post-comment.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { + BENCH_MARKER, + FAILURE_TITLE, + MAX_BODY_CHARS, + planComment, + prepareBody, + sanitize, +} from "./post-comment.ts"; +import { decodeHistory, type RunEntry } from "./history.ts"; + +const BOT = "github-actions[bot]"; + +describe("planComment", () => { + test("creates the comment when none carries the marker", () => { + expect(planComment("create-if-missing", [{ id: 1, body: "hi", authorLogin: BOT }])).toEqual({ + kind: "create", + }); + }); + + test("leaves an existing marker comment alone on a later push", () => { + const existing = { id: 5, body: `${BENCH_MARKER}\nold`, authorLogin: BOT }; + + expect(planComment("create-if-missing", [existing])).toEqual({ kind: "skip" }); + }); + + test("replaces a first-run failure notice on a later push", () => { + const failed = { + id: 5, + body: `${BENCH_MARKER}\n${FAILURE_TITLE}\nbuild failed`, + authorLogin: BOT, + }; + + expect(planComment("create-if-missing", [failed])).toEqual({ + kind: "update", + id: 5, + previousBody: failed.body, + }); + }); + + test("updates the marker comment in place for a re-run", () => { + const existing = { id: 5, body: `${BENCH_MARKER}\nold`, authorLogin: BOT }; + + expect(planComment("upsert", [existing])).toEqual({ + kind: "update", + id: 5, + previousBody: existing.body, + }); + }); + + test("ignores a marker pasted by someone else", () => { + const forged = { id: 6, body: BENCH_MARKER, authorLogin: "mallory" }; + + expect(planComment("create-if-missing", [forged])).toEqual({ kind: "create" }); + }); +}); + +describe("prepareBody", () => { + test("puts the marker first exactly once", () => { + const body = prepareBody(`text ${BENCH_MARKER} more`); + + expect(body.startsWith(`${BENCH_MARKER}\n`)).toBe(true); + expect(body.split(BENCH_MARKER)).toHaveLength(2); + }); + + test("cuts an oversized report below GitHub's limit and says so", () => { + const body = prepareBody("x".repeat(200_000)); + + expect(body.length).toBeLessThanOrEqual(MAX_BODY_CHARS); + expect(body).toContain("truncated"); + }); +}); + +describe("prepareBody history", () => { + const run = (n: number): RunEntry => ({ + sha: n.toString(16).padStart(7, "0"), + at: "2026-10-28T09:05Z", + headline: "no changes found", + url: `https://github.com/o/r/actions/runs/${n}`, + }); + + test("lists earlier runs after the run link and carries them in the marker", () => { + const body = prepareBody("report", "https://github.com/o/r/actions/runs/3", [run(3), run(2)]); + + expect(body.indexOf("[Workflow run]")).toBeLessThan(body.indexOf("Previous runs")); + expect(decodeHistory(body)).toEqual([run(3), run(2)]); + }); + + test("drops the oldest runs before cutting the report when the body is too long", () => { + const history = Array.from({ length: 21 }, (_, i) => run(21 - i)); + const report = "x".repeat(MAX_BODY_CHARS - 4_000); + + const body = prepareBody(report, "https://github.com/o/r/actions/runs/21", history); + + expect(body.length).toBeLessThanOrEqual(MAX_BODY_CHARS); + expect(body).not.toContain("truncated"); + const kept = decodeHistory(body); + expect(kept.length).toBeGreaterThan(0); + expect(kept.length).toBeLessThan(history.length); + expect(kept[0]).toEqual(history[0]); + }); + + test("renders an earlier run's headline through the sanitizer", () => { + const hostile = { ...run(1), headline: "@team " }; + + const body = prepareBody("report", undefined, [run(2), hostile]); + + expect(body).not.toContain(" { + test("neutralizes mentions, links, images and unknown HTML but keeps the report tags", () => { + const hostile = + "@org/team [x](https://evil.test) ![i](https://evil.test/a.png) @u [a][r] www.evil.test
"; + + const clean = sanitize(hostile); + + expect(clean).not.toMatch(/@(?!\u200b)/u); + expect(clean).not.toContain("@"); + expect(clean).not.toMatch(/(?"); + }); + + test("pins the failure title and marker literals the renderer emits", () => { + expect(FAILURE_TITLE).toBe("## CLI benchmark: could not run"); + expect(BENCH_MARKER).toBe(""); + }); +}); + +describe("prepareBody run link", () => { + test("appends the trusted run link after the report", () => { + const body = prepareBody("report", "https://github.com/o/r/actions/runs/1"); + + expect(body.trimEnd().endsWith("[Workflow run](https://github.com/o/r/actions/runs/1)")).toBe( + true, + ); + }); +}); diff --git a/.github/scripts/cli-bench/post-comment.ts b/.github/scripts/cli-bench/post-comment.ts new file mode 100644 index 0000000000..d46572ea3c --- /dev/null +++ b/.github/scripts/cli-bench/post-comment.ts @@ -0,0 +1,153 @@ +/** + * Posts the CLI benchmark summary as the pull request's single marker comment. + * + * Runs in the one job that can write comments and treats the rendered markdown as untrusted data + * (pull request code produced it): it reads a bounded regular file, neutralizes mentions, links and + * HTML, adds the marker and the run link itself, truncates to GitHub's body limit, and creates or + * updates the comment. Run in CI as: `bun .github/scripts/cli-bench/post-comment.ts`. + * + * Modes: `create-if-missing` leaves an existing marker comment alone unless it reports a failed + * run (first run on a pull request); `upsert` updates it in place or creates it (a re-run). + * Every post also records the run in the comment's history of earlier runs. + */ +import { lstatSync, readFileSync } from "node:fs"; + +import { githubFetch, listIssueComments, requireEnv, type IssueComment } from "./github.ts"; +import { decodeHistory, headlineOf, makeEntry, mergeHistory, renderHistory } from "./history.ts"; +import type { RunEntry } from "./history.ts"; + +/** Hidden tag identifying the benchmark comment; the renderer in `bench-summary.ts` emits the same text. */ +export const BENCH_MARKER = ""; +/** Login of comments posted with the workflow's `GITHUB_TOKEN`. */ +const WORKFLOW_BOT_LOGIN = "github-actions[bot]"; +/** GitHub rejects comment bodies longer than 65536 characters. */ +export const MAX_BODY_CHARS = 65_000; +const TRUNCATION_NOTICE = + "\n\n
\n\n_The report was truncated; see the workflow run for the rest._\n"; + +export type Mode = "create-if-missing" | "upsert"; + +export type ExistingComment = IssueComment; + +export type CommentPlan = + | { kind: "skip" } + | { kind: "create" } + | { kind: "update"; id: number; previousBody: string }; + +const ALLOWED_TAGS = new Set(["
", "
", "", "", "
"]); +const ZERO_WIDTH_SPACE = "\u200b"; +/** Title the renderer gives a run that produced no comparison. */ +export const FAILURE_TITLE = "## CLI benchmark: could not run"; +export const MAX_FILE_BYTES = 1_000_000; + +/** Keeps mentions, links, images and raw HTML in untrusted markdown from rendering. */ +export function sanitize(markdown: string): string { + return markdown + .replaceAll("&", "&") + .replaceAll(/<[^>\n]*>?/gu, (tag) => + ALLOWED_TAGS.has(tag) ? tag : tag.replaceAll("<", "<"), + ) + .replaceAll("@", `@${ZERO_WIDTH_SPACE}`) + .replaceAll("://", `:${ZERO_WIDTH_SPACE}//`) + .replaceAll(/\b(www)\./giu, `$1${ZERO_WIDTH_SPACE}.`) + .replaceAll("[", "\\[") + .replaceAll("]", "\\]"); +} + +/** + * The comment body: the marker first, the sanitized report, the trusted run link, then the run + * history. `history` lists the current run first; the oldest runs are dropped until the body fits. + */ +export function prepareBody( + markdown: string, + runUrl?: string, + history: ReadonlyArray = [], +): string { + const link = runUrl === undefined || runUrl === "" ? "" : `\n\n[Workflow run](${runUrl})\n`; + const content = sanitize(markdown.replaceAll(BENCH_MARKER, "")).trim(); + const body = `${BENCH_MARKER}\n${content}\n`; + for (let kept = history.length; kept >= 1; kept--) { + const tail = `${link}\n${renderHistory(history.slice(0, kept), sanitize)}\n`; + if (body.length + tail.length <= MAX_BODY_CHARS) return body + tail; + } + const tail = `${link}\n${history.length === 0 ? "" : `${renderHistory(history.slice(0, 1), sanitize)}\n`}`; + const limit = MAX_BODY_CHARS - tail.length; + const fitted = + body.length <= limit + ? body + : body.slice(0, limit - TRUNCATION_NOTICE.length) + TRUNCATION_NOTICE; + return fitted + tail; +} + +/** The workflow bot's benchmark comment, if any. Only its marker counts. */ +export function findBenchComment( + comments: ReadonlyArray, +): ExistingComment | undefined { + return comments.find( + (comment) => comment.authorLogin === WORKFLOW_BOT_LOGIN && comment.body.includes(BENCH_MARKER), + ); +} + +/** What to do with the pull request's benchmark comment. */ +export function planComment(mode: Mode, comments: ReadonlyArray): CommentPlan { + const existing = findBenchComment(comments); + if (existing === undefined) return { kind: "create" }; + // A first run that could not compare anything must not block the next push's real result. + const replaceable = mode === "upsert" || existing.body.includes(FAILURE_TITLE); + return replaceable + ? { kind: "update", id: existing.id, previousBody: existing.body } + : { kind: "skip" }; +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const base = `https://api.github.com/repos/${requireEnv("GITHUB_REPOSITORY")}`; + const pr = Number(requireEnv("PR_NUMBER")); + const mode = requireEnv("MODE"); + if (mode !== "create-if-missing" && mode !== "upsert") { + throw new Error(`Unsupported MODE "${mode}".`); + } + const file = requireEnv("BODY_FILE"); + const stat = lstatSync(file); + if (!stat.isFile() || stat.size > MAX_FILE_BYTES) { + throw new Error("The benchmark comment file is not a regular file under the size limit."); + } + const markdown = readFileSync(file, "utf8"); + const runUrl = process.env.RUN_URL; + const entry = makeEntry({ + sha: requireEnv("HEAD_SHA"), + now: new Date(), + headline: headlineOf(markdown), + url: runUrl ?? "", + }); + + const plan = planComment(mode, await listIssueComments(token, base, pr)); + const earlier = plan.kind === "update" ? decodeHistory(plan.previousBody) : []; + const body = prepareBody( + markdown, + runUrl, + entry === undefined ? [] : mergeHistory(earlier, entry), + ); + if (plan.kind === "skip") { + console.log(`PR #${pr} already has a benchmark comment; leaving it untouched.`); + } else if (plan.kind === "update") { + await githubFetch(`${base}/issues/comments/${plan.id}`, token, { + method: "PATCH", + body: JSON.stringify({ body }), + }); + console.log(`Updated benchmark comment ${plan.id} on PR #${pr}.`); + } else { + await githubFetch(`${base}/issues/${pr}/comments`, token, { + method: "POST", + body: JSON.stringify({ body }), + }); + console.log(`Created benchmark comment on PR #${pr}.`); + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/scripts/cli-bench/rerun.test.ts b/.github/scripts/cli-bench/rerun.test.ts new file mode 100644 index 0000000000..fa37cad648 --- /dev/null +++ b/.github/scripts/cli-bench/rerun.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; +import { GithubRequestError } from "./github.ts"; +import { + type BenchPr, + type CommandIo, + handleBenchCommand, + RERUN_WINDOW_MS, + type WorkflowRun, +} from "./rerun.ts"; + +const REPO = "supabase/cli"; +const HEAD = "a".repeat(40); +const NOW = new Date("2026-10-28T12:00:00Z"); +const pr = (overrides: Partial = {}): BenchPr => ({ + state: "open", + draft: false, + headSha: HEAD, + headRepoFullName: REPO, + baseRepoFullName: REPO, + baseRef: "develop", + ...overrides, +}); +const run = (overrides: Partial = {}): WorkflowRun => ({ + id: 11, + status: "completed", + createdAt: "2026-10-27T12:00:00Z", + ...overrides, +}); + +function setup( + options: { + permissions?: Record; + pr?: BenchPr; + runs?: ReadonlyArray; + rerunError?: Error; + } = {}, +) { + const calls = { + reactions: [] as Array, + replies: [] as Array, + reruns: [] as Array, + }; + const io: CommandIo = { + fetchPr: async () => options.pr ?? pr(), + fetchPermission: async (login) => (options.permissions ?? { maintainer: "write" })[login], + listRuns: async () => options.runs ?? [run()], + rerun: async (id) => { + if (options.rerunError !== undefined) throw options.rerunError; + calls.reruns.push(id); + }, + reactToComment: async (id) => { + calls.reactions.push(id); + }, + replyToPr: async (_n, body) => { + calls.replies.push(body); + }, + }; + return { io, calls }; +} + +const command = (body: string, association = "COLLABORATOR", login = "maintainer") => ({ + prNumber: 7, + comment: { id: 99, authorLogin: login, authorAssociation: association, body }, + now: NOW, +}); + +describe("handleBenchCommand", () => { + test("re-runs the latest run for the head commit and reacts only after GitHub accepts it", async () => { + const { io, calls } = setup({ + runs: [run({ id: 1, createdAt: "2026-10-20T00:00:00Z" }), run({ id: 2 })], + }); + + const outcome = await handleBenchCommand(command("/bench\n"), io); + + expect(outcome).toEqual({ kind: "rerun", runId: 2 }); + expect(calls.reruns).toEqual([2]); + expect(calls.reactions).toEqual([99]); + expect(calls.replies).toEqual([]); + }); + + test.each(["/bench full", "/benchmark", "please /bench", "/Bench"])( + "ignores %p without a reply", + async (body) => { + const { io, calls } = setup(); + + const outcome = await handleBenchCommand(command(body), io); + + expect(outcome.kind).toBe("ignored"); + expect(calls).toEqual({ reactions: [], replies: [], reruns: [] }); + }, + ); + + test("rejects a commenter without write permission even as a MEMBER", async () => { + const { io, calls } = setup({ permissions: { maintainer: "read" } }); + + const outcome = await handleBenchCommand(command("/bench", "MEMBER"), io); + + expect(outcome).toMatchObject({ kind: "ignored" }); + expect(calls.reruns).toEqual([]); + }); + + test("lets the repository owner run it without a permission lookup result", async () => { + const { io } = setup({ permissions: {} }); + + expect((await handleBenchCommand(command("/bench", "OWNER"), io)).kind).toBe("rerun"); + }); + + test.each([ + ["a closed pull request", pr({ state: "closed" })], + ["a fork", pr({ headRepoFullName: "someone/cli" })], + ["a deleted head repository", pr({ headRepoFullName: "" })], + ["a pull request into another branch", pr({ baseRef: "feature/stack-base" })], + ])("ignores /bench on %s", async (_name, head) => { + const { io, calls } = setup({ pr: head }); + + const outcome = await handleBenchCommand(command("/bench"), io); + + expect(outcome.kind).toBe("ignored"); + expect(calls).toEqual({ reactions: [], replies: [], reruns: [] }); + }); + + test("tells the pull request there is no run when none exists for the head commit", async () => { + const { io, calls } = setup({ runs: [] }); + + const outcome = await handleBenchCommand(command("/bench"), io); + + expect(outcome.kind).toBe("replied"); + expect(calls.replies).toHaveLength(1); + expect(calls.replies[0]).toContain("no benchmark run for commit `aaaaaaa`"); + expect(calls.reruns).toEqual([]); + expect(calls.reactions).toEqual([]); + }); + + test("says a benchmark is already running instead of re-running it", async () => { + const { io, calls } = setup({ runs: [run({ status: "in_progress" })] }); + + await handleBenchCommand(command("/bench"), io); + + expect(calls.replies[0]).toContain("already running"); + expect(calls.reruns).toEqual([]); + expect(calls.reactions).toEqual([]); + }); + + test("says the re-run could not start when the run is older than 30 days", async () => { + const old = new Date(NOW.getTime() - RERUN_WINDOW_MS - 1000).toISOString(); + const { io, calls } = setup({ runs: [run({ createdAt: old })] }); + + await handleBenchCommand(command("/bench"), io); + + expect(calls.replies[0]).toContain("older than 30 days"); + expect(calls.reruns).toEqual([]); + }); + + test("surfaces GitHub's refusal in a reply and does not react", async () => { + const { io, calls } = setup({ + rerunError: new GithubRequestError(403, "Resource not accessible by integration", "u"), + }); + + const outcome = await handleBenchCommand(command("/bench"), io); + + expect(outcome.kind).toBe("replied"); + expect(calls.replies[0]).toContain("403"); + expect(calls.replies[0]).toContain("Resource not accessible by integration"); + expect(calls.reactions).toEqual([]); + }); + + test("keeps an accepted re-run when the reaction fails", async () => { + const { io } = setup(); + io.reactToComment = async () => { + throw new Error("boom"); + }; + + expect(await handleBenchCommand(command("/bench"), io)).toEqual({ kind: "rerun", runId: 11 }); + }); + + test("explains that a draft pull request is not benchmarked", async () => { + const { io, calls } = setup({ pr: pr({ draft: true }) }); + + await handleBenchCommand(command("/bench"), io); + + expect(calls.replies[0]).toContain("draft"); + expect(calls.reruns).toEqual([]); + }); +}); diff --git a/.github/scripts/cli-bench/rerun.ts b/.github/scripts/cli-bench/rerun.ts new file mode 100644 index 0000000000..b0020185b8 --- /dev/null +++ b/.github/scripts/cli-bench/rerun.ts @@ -0,0 +1,231 @@ +/** + * `/bench` handler: re-runs the benchmark workflow run of a pull request's current head commit. + * + * Runs default branch code and never checks out or runs pull request code. The commenter must be + * the repository owner or hold write access, and the pull request must be an open, ready one from + * this repository into `develop`. The command re-runs the existing `pull_request` run, which + * benchmarks and updates the comment; the pull request gets a reply when no run can be re-run. + * Run in CI as: `bun .github/scripts/cli-bench/rerun.ts`. + */ +import { appendFileSync } from "node:fs"; + +import { fetchAuthorPermission, WRITE_PERMISSIONS } from "../contribution-gate.ts"; +import { githubFetch, GithubRequestError, requireEnv } from "./github.ts"; + +export const BENCH_COMMAND = "/bench"; +/** Base branches the benchmark runs for; keep in sync with the workflow's `branches:` filter. */ +export const BENCH_BASE_BRANCHES: ReadonlyArray = ["develop"]; +/** The workflow file whose runs `/bench` re-runs. */ +export const WORKFLOW_FILE = "cli-bench.yml"; +/** GitHub refuses to re-run a workflow run older than this. */ +export const RERUN_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +export interface BenchPr { + state: "open" | "closed"; + draft: boolean; + headSha: string; + /** `owner/name` of the head repository, empty when it was deleted. */ + headRepoFullName: string; + baseRepoFullName: string; + baseRef: string; +} + +export interface WorkflowRun { + id: number; + status: string; + createdAt: string; +} + +export interface CommandInput { + prNumber: number; + comment: { id: number; authorLogin: string; authorAssociation: string; body: string }; + now: Date; +} + +export interface CommandIo { + fetchPr: (prNumber: number) => Promise; + fetchPermission: (login: string) => Promise; + /** Runs of the benchmark workflow triggered by `pull_request` for this commit, any order. */ + listRuns: (headSha: string) => Promise>; + rerun: (runId: number) => Promise; + reactToComment: (commentId: number) => Promise; + replyToPr: (prNumber: number, body: string) => Promise; +} + +export type CommandOutcome = + | { kind: "ignored"; reason: string } + | { kind: "replied"; reason: string } + | { kind: "rerun"; runId: number }; + +const short = (sha: string) => sha.slice(0, 7); + +/** A GitHub error message as inline code: one short line with no backticks. */ +const quoted = (detail: string) => + `\`${detail.replaceAll("`", "'").replaceAll(/\s+/gu, " ").trim().slice(0, 200)}\``; + +export const replies = { + noRun: (sha: string) => + `There is no benchmark run for commit \`${short(sha)}\`. Runs start on a push that changes the ` + + "CLI, its packages, or the benchmark workflow, and GitHub's path filter looks at only the " + + "first 300 changed files, so this pull request may not have one.", + running: (sha: string) => + `A benchmark is already running for commit \`${short(sha)}\`. The comment updates when it finishes.`, + draft: () => `This pull request is a draft. The benchmark runs once it is ready for review.`, + expired: (sha: string) => + `The benchmark could not be re-run for commit \`${short(sha)}\`: its run is older than 30 days. ` + + "Push a new commit to benchmark again.", + refused: (sha: string, status: number, detail: string) => + `The benchmark could not be re-run for commit \`${short(sha)}\`: GitHub refused the request ` + + `(${status} ${quoted(detail)}).`, +}; + +/** Handles one `/bench` comment; a rejected command is ignored without a reply. */ +export async function handleBenchCommand( + input: CommandInput, + io: CommandIo, +): Promise { + const { comment, prNumber } = input; + // The workflow's `if:` only pre-filters on `startsWith`, so `/bench full` must be rejected here. + if (comment.body.trim() !== BENCH_COMMAND) { + return { kind: "ignored", reason: `Comment is not exactly ${BENCH_COMMAND}.` }; + } + // Only the repository OWNER skips the lookup: MEMBER and COLLABORATOR do not imply push access. + const permission = await io.fetchPermission(comment.authorLogin); + const authorized = + comment.authorAssociation === "OWNER" || + (permission !== undefined && WRITE_PERMISSIONS.has(permission)); + if (!authorized) { + return { + kind: "ignored", + reason: + `Commenter @${comment.authorLogin} is not authorized to run ${BENCH_COMMAND} ` + + `(author_association=${comment.authorAssociation}, permission=${permission ?? "n/a"}).`, + }; + } + const pr = await io.fetchPr(prNumber); + if (pr.state === "closed") return { kind: "ignored", reason: `PR #${prNumber} is closed.` }; + if (pr.headRepoFullName !== pr.baseRepoFullName) { + return { + kind: "ignored", + reason: `${BENCH_COMMAND} runs only on pull requests from this repository.`, + }; + } + if (!BENCH_BASE_BRANCHES.includes(pr.baseRef)) { + return { + kind: "ignored", + reason: `${BENCH_COMMAND} runs only on pull requests into ${BENCH_BASE_BRANCHES.join(" or ")}.`, + }; + } + + const reply = async (text: string): Promise => { + await io.replyToPr(prNumber, text); + return { kind: "replied", reason: text }; + }; + if (pr.draft) return reply(replies.draft()); + + const latest = [...(await io.listRuns(pr.headSha))].sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + )[0]; + if (latest === undefined) return reply(replies.noRun(pr.headSha)); + if (latest.status !== "completed") return reply(replies.running(pr.headSha)); + if (input.now.getTime() - Date.parse(latest.createdAt) > RERUN_WINDOW_MS) { + return reply(replies.expired(pr.headSha)); + } + + try { + await io.rerun(latest.id); + } catch (error) { + if (error instanceof GithubRequestError) { + return reply(replies.refused(pr.headSha, error.status, error.detail)); + } + throw error; + } + // Cosmetic: a failed reaction must never hide an accepted re-run. + try { + await io.reactToComment(comment.id); + } catch (error) { + console.warn(`Could not react to comment ${comment.id}: ${String(error)}`); + } + return { kind: "rerun", runId: latest.id }; +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const [owner = "", repo = ""] = requireEnv("GITHUB_REPOSITORY").split("/"); + const base = `https://api.github.com/repos/${owner}/${repo}`; + const prNumber = Number(requireEnv("PR_NUMBER")); + + const io: CommandIo = { + fetchPr: async (n) => { + const value = (await (await githubFetch(`${base}/pulls/${n}`, token)).json()) as { + state: "open" | "closed"; + draft: boolean; + head: { sha: string; repo: { full_name: string } | null }; + base: { ref: string; repo: { full_name: string } }; + }; + return { + state: value.state, + draft: value.draft, + headSha: value.head.sha, + headRepoFullName: value.head.repo?.full_name ?? "", + baseRepoFullName: value.base.repo.full_name, + baseRef: value.base.ref, + }; + }, + fetchPermission: (login) => fetchAuthorPermission(token, owner, repo, login), + listRuns: async (headSha) => { + const url = + `${base}/actions/workflows/${WORKFLOW_FILE}/runs` + + `?event=pull_request&head_sha=${headSha}&per_page=100`; + const payload = (await (await githubFetch(url, token)).json()) as { + workflow_runs: Array<{ id: number; status: string; created_at: string; head_sha: string }>; + }; + return payload.workflow_runs + .filter((run) => run.head_sha === headSha) + .map((run) => ({ id: run.id, status: run.status, createdAt: run.created_at })); + }, + rerun: async (runId) => { + await githubFetch(`${base}/actions/runs/${runId}/rerun`, token, { method: "POST" }); + }, + reactToComment: async (commentId) => { + await githubFetch(`${base}/issues/comments/${commentId}/reactions`, token, { + method: "POST", + body: JSON.stringify({ content: "eyes" }), + }); + }, + replyToPr: async (n, body) => { + await githubFetch(`${base}/issues/${n}/comments`, token, { + method: "POST", + body: JSON.stringify({ body }), + }); + }, + }; + + const outcome = await handleBenchCommand( + { + prNumber, + comment: { + id: Number(requireEnv("COMMENT_ID")), + authorLogin: requireEnv("COMMENT_AUTHOR_LOGIN"), + authorAssociation: requireEnv("COMMENT_AUTHOR_ASSOCIATION"), + body: requireEnv("COMMENT_BODY"), + }, + now: new Date(), + }, + io, + ); + const message = + outcome.kind === "rerun" + ? `Re-running benchmark run ${outcome.runId} for PR #${prNumber}.` + : `${outcome.kind === "replied" ? "Replied on" : "Ignored command on"} PR #${prNumber}: ${outcome.reason}`; + console.log(message); + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (summaryFile) appendFileSync(summaryFile, `${message}\n`); +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/scripts/cli-bench/resolve.test.ts b/.github/scripts/cli-bench/resolve.test.ts new file mode 100644 index 0000000000..7ac55287b2 --- /dev/null +++ b/.github/scripts/cli-bench/resolve.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { BENCH_MARKER, FAILURE_TITLE } from "./post-comment.ts"; +import { resolveBench, SKIP_REASON } from "./resolve.ts"; + +const BOT = "github-actions[bot]"; +const reported = { + id: 5, + body: `${BENCH_MARKER}\n## CLI benchmark: no changes found`, + authorLogin: BOT, +}; + +describe("resolveBench", () => { + test("benchmarks the first push of a pull request that has no comment yet", () => { + expect(resolveBench({ runAttempt: 1, isFork: false, comments: [] }).shouldRun).toBe(true); + }); + + test("skips a later push while the comment reports a result, and says how to refresh it", () => { + const result = resolveBench({ runAttempt: 1, isFork: false, comments: [reported] }); + + expect(result).toEqual({ shouldRun: false, skipReason: SKIP_REASON }); + expect(SKIP_REASON).toContain("`/bench`"); + }); + + test("benchmarks again when the comment reports a failed run", () => { + const failed = { ...reported, body: `${BENCH_MARKER}\n${FAILURE_TITLE}` }; + + expect(resolveBench({ runAttempt: 1, isFork: false, comments: [failed] }).shouldRun).toBe(true); + }); + + test("benchmarks every re-run even when the comment reports a result", () => { + expect(resolveBench({ runAttempt: 2, isFork: false, comments: [reported] }).shouldRun).toBe( + true, + ); + }); + + test("ignores a marker pasted by someone else", () => { + const forged = { ...reported, authorLogin: "mallory" }; + + expect(resolveBench({ runAttempt: 1, isFork: false, comments: [forged] }).shouldRun).toBe(true); + }); + + test("benchmarks every push of a fork pull request, which never has a comment", () => { + expect(resolveBench({ runAttempt: 1, isFork: true, comments: [reported] }).shouldRun).toBe( + true, + ); + }); +}); diff --git a/.github/scripts/cli-bench/resolve.ts b/.github/scripts/cli-bench/resolve.ts new file mode 100644 index 0000000000..03882d2450 --- /dev/null +++ b/.github/scripts/cli-bench/resolve.ts @@ -0,0 +1,68 @@ +/** + * CLI benchmark resolver for `pull_request` runs: decides whether the benchmark job runs. + * + * The first run on a pull request benchmarks and comments. Later pushes skip while the comment + * exists and reports a result; a re-run (`run_attempt` above 1), a missing comment, a failed first + * run, or a fork pull request, which never has a comment, benchmarks. Needs read access only. + * Run in CI as: `bun .github/scripts/cli-bench/resolve.ts`. + */ +import { appendFileSync } from "node:fs"; + +import { listIssueComments, requireEnv, type IssueComment } from "./github.ts"; +import { FAILURE_TITLE, findBenchComment } from "./post-comment.ts"; + +export interface ResolveInput { + runAttempt: number; + isFork: boolean; + comments: ReadonlyArray; +} + +export interface ResolveResult { + shouldRun: boolean; + /** Shown in the job summary when the benchmark is skipped. */ + skipReason?: string; +} + +export const SKIP_REASON = + "A benchmark comment already exists on this pull request. Comment `/bench` to refresh it."; + +/** Skips only a first attempt on a pull request whose benchmark comment reports a result. */ +export function resolveBench(input: ResolveInput): ResolveResult { + if (input.isFork || input.runAttempt > 1) return { shouldRun: true }; + const existing = findBenchComment(input.comments); + if (existing === undefined || existing.body.includes(FAILURE_TITLE)) return { shouldRun: true }; + return { shouldRun: false, skipReason: SKIP_REASON }; +} + +async function main(): Promise { + const token = requireEnv("GITHUB_TOKEN"); + const base = `https://api.github.com/repos/${requireEnv("GITHUB_REPOSITORY")}`; + const prNumber = Number(requireEnv("PR_NUMBER")); + const runAttempt = Number(requireEnv("RUN_ATTEMPT")); + const isFork = requireEnv("HEAD_REPO") !== requireEnv("GITHUB_REPOSITORY"); + + let comments: ReadonlyArray = []; + if (!isFork && runAttempt === 1) { + // A comment that cannot be listed must not hide a benchmark: run it instead. + comments = await listIssueComments(token, base, prNumber).catch((error: unknown) => { + console.warn(`Could not list comments on PR #${prNumber}: ${String(error)}`); + return []; + }); + } + + const result = resolveBench({ runAttempt, isFork, comments }); + console.log( + `CLI benchmark resolve for PR #${prNumber}: should_run=${result.shouldRun} ` + + `attempt=${runAttempt} fork=${isFork}`, + ); + appendFileSync(requireEnv("GITHUB_OUTPUT"), `should_run=${result.shouldRun}\n`); + const summaryFile = process.env.GITHUB_STEP_SUMMARY; + if (result.skipReason && summaryFile) appendFileSync(summaryFile, `${result.skipReason}\n`); +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/.github/workflows/cli-bench.yml b/.github/workflows/cli-bench.yml new file mode 100644 index 0000000000..73f76a6ac5 --- /dev/null +++ b/.github/workflows/cli-bench.yml @@ -0,0 +1,301 @@ +name: CLI benchmark +run-name: "CLI benchmark #${{ github.event.pull_request.number || github.event.issue.number }}" + +# Report-only comparison of a pull request's CLI build with a build of its merge base. Every +# workload runs against the replay server, so no Docker, database, or network is involved. The +# job never blocks a pull request and is not a required check. +# +# The first push to a pull request from this repository benchmarks and posts one comment tagged +# ``; later pushes skip while that comment reports a result. A maintainer +# commenting exactly `/bench` re-runs the run for the pull request's current head commit, as does +# "Re-run all jobs" in the Actions UI: a re-run benchmarks again and updates the comment, keeping +# earlier runs in a collapsed list. Pull requests from forks benchmark every push and write a job +# summary only. A pull request that changes no benchmarked path, or more than the 300 files +# GitHub's path filter inspects, has no run to re-run, and `/bench` says so. +# See apps/cli/docs/tracing-monitoring.md. +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + branches: + - develop + paths: + - "apps/cli/**" + - "apps/cli-e2e/**" + - "packages/**" + - "pnpm-lock.yaml" + - "turbo.json" + - ".github/workflows/cli-bench.yml" + - ".github/scripts/cli-bench/**" + # `/bench` runs default branch code only: it never checks out or runs pull request code. + # jactionlint ignore=dangerous-triggers + issue_comment: + types: + - created + +permissions: {} + +# Comment runs get a group of their own, so a comment can never cancel a pull request's benchmark +# nor be cancelled by one. +concurrency: + group: >- + cli-bench-${{ github.event.pull_request.number || github.event.issue.number }}${{ + github.event_name == 'issue_comment' && format('-comment-{0}', github.run_id) || '' }} + cancel-in-progress: true + +jobs: + # Decides whether this push benchmarks. Read-only: it only lists the pull request's comments. + resolve: + name: Resolve + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + should_run: ${{ steps.resolve.outputs.should_run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: ".bun-version" + no-cache: true + - name: Resolve + id: resolve + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: bun .github/scripts/cli-bench/resolve.ts + + # `/bench`: re-runs this workflow's run for the pull request's current head commit. The only job + # with `actions: write`; it runs default branch code and no pull request code. + rerun: + name: Re-run benchmark + # Cheap, non-authoritative pre-filter; rerun.ts does the exact command match and the + # effective-permission lookup. + if: > + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + startsWith(github.event.comment.body, '/bench') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: ".bun-version" + no-cache: true + - name: Re-run + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_AUTHOR_LOGIN: ${{ github.event.comment.user.login }} + COMMENT_AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} + COMMENT_BODY: ${{ github.event.comment.body }} + run: bun .github/scripts/cli-bench/rerun.ts + + bench: + name: Benchmark CLI against merge base + needs: resolve + if: needs.resolve.outputs.should_run == 'true' + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout pull request head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Prepare benchmark directory + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/bench" + echo "BENCH_DIR=$RUNNER_TEMP/bench" >> "$GITHUB_ENV" + + - name: Build branch binary + id: branch_build + continue-on-error: true + shell: bash + run: | + pnpm --filter supabase run build:binary + mkdir -p "$BENCH_DIR/branch" + cp apps/cli/dist/supabase "$BENCH_DIR/branch/supabase" + + - name: Build merge-base binary + id: base_build + continue-on-error: true + if: steps.branch_build.outcome == 'success' + env: + HUSKY: "0" + BASE_REF: ${{ github.event.pull_request.base.ref }} + shell: bash + run: | + merge_base="$(git merge-base HEAD "origin/$BASE_REF")" + echo "Merge base: $merge_base" + echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT" + git worktree add "$RUNNER_TEMP/base-src" "$merge_base" + cd "$RUNNER_TEMP/base-src" + # The setup action writes this file only when the Dependency Firewall token is available. + if [ -f "$RUNNER_TEMP/dependency-firewall.npmrc" ]; then + export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/dependency-firewall.npmrc" + fi + pnpm install --frozen-lockfile + pnpm --filter supabase run build:binary + mkdir -p "$BENCH_DIR/base" + cp apps/cli/dist/supabase "$BENCH_DIR/base/supabase" + + - name: Start replay server + id: replay + continue-on-error: true + if: steps.branch_build.outcome == 'success' && steps.base_build.outcome == 'success' + shell: bash + run: | + bun apps/cli-e2e/scripts/replay-serve.ts > "$BENCH_DIR/replay.log" 2>&1 & + echo "REPLAY_PID=$!" >> "$GITHUB_ENV" + for _ in $(seq 1 60); do + url="$(grep -o 'http://[^"]*' "$BENCH_DIR/replay.log" | head -n 1 || true)" + if [ -n "$url" ]; then + echo "REPLAY_URL=$url" >> "$GITHUB_ENV" + exit 0 + fi + sleep 0.5 + done + echo "replay server did not report a URL" >&2 + cat "$BENCH_DIR/replay.log" >&2 + exit 1 + + - name: Run benchmark + id: bench + continue-on-error: true + if: steps.replay.outcome == 'success' + shell: bash + run: | + bun apps/cli/scripts/bench-cli.ts \ + --base "$BENCH_DIR/base/supabase" \ + --branch "$BENCH_DIR/branch/supabase" \ + --workloads apps/cli/bench/workloads.pr.json \ + --api-url "$REPLAY_URL" \ + --trace \ + --out "$BENCH_DIR/report.json" | tee "$BENCH_DIR/report.txt" + + - name: Stop replay server + if: always() && env.REPLAY_PID != '' + shell: bash + run: kill "$REPLAY_PID" || true + + - name: Write job summary + if: always() + env: + BRANCH_BUILD: ${{ steps.branch_build.outcome == 'success' && 'ok' || 'failed' }} + BASE_BUILD: ${{ steps.base_build.outcome == 'success' && 'ok' || 'failed' }} + REPLAY: ${{ steps.replay.outcome == 'success' && 'ok' || 'failed' }} + MERGE_BASE: ${{ steps.base_build.outputs.merge_base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + shell: bash + run: | + bun apps/cli/scripts/bench-summary.ts \ + --report "$BENCH_DIR/report.json" \ + --branch-build "$BRANCH_BUILD" \ + --base-build "$BASE_BUILD" \ + --replay "$REPLAY" \ + --head-sha "$HEAD_SHA" \ + --base-ref "$BASE_REF" \ + --merge-base "$MERGE_BASE" \ + --comment-out "$BENCH_DIR/comment.md" + + - name: Upload report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-bench-report + path: | + ${{ runner.temp }}/bench/report.json + ${{ runner.temp }}/bench/report.txt + retention-days: 14 + if-no-files-found: ignore + + - name: Upload comment + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-bench-comment + path: ${{ runner.temp }}/bench/comment.md + retention-days: 1 + if-no-files-found: ignore + + comment: + name: Post pull request comment + needs: + - resolve + - bench + # A pull_request run from a fork has a read-only token, so it posts no comment. Failing to + # post never fails the workflow. + if: >- + ${{ !cancelled() && needs.resolve.outputs.should_run == 'true' && needs.bench.result == 'success' && + github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + # The only pull_request job that can write to the pull request. It checks out the merge commit, + # which its author can already run with this token by editing the workflow. It never builds or + # runs the benchmarked binaries and reads the rendered markdown as data. + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: ".bun-version" + no-cache: true + - name: Download comment + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-bench-comment + path: ${{ runner.temp }}/bench-comment + - name: Post comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MODE: ${{ github.run_attempt > 1 && 'upsert' || 'create-if-missing' }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BODY_FILE: ${{ runner.temp }}/bench-comment/comment.md + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} + run: | + if [ ! -f "$BODY_FILE" ]; then + echo "The benchmark job produced no comment." + exit 0 + fi + bun .github/scripts/cli-bench/post-comment.ts diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json index 28e474eaa3..d5caeef214 100644 --- a/apps/cli-e2e/package.json +++ b/apps/cli-e2e/package.json @@ -7,6 +7,7 @@ "test": "pnpm run test:e2e", "test:e2e": "pnpm exec turbo run supabase#build && pnpm exec turbo run @supabase/cli-e2e#test:e2e:run --only --", "test:e2e:run": "bun --bun vitest run", + "replay:serve": "bun scripts/replay-serve.ts", "record": "RECORD=true pnpm run test:e2e:run", "types:check": "tsc --noEmit" }, diff --git a/apps/cli-e2e/scripts/replay-serve.ts b/apps/cli-e2e/scripts/replay-serve.ts new file mode 100644 index 0000000000..f1ea4a6e88 --- /dev/null +++ b/apps/cli-e2e/scripts/replay-serve.ts @@ -0,0 +1,36 @@ +import { startReplayServer } from "../src/server/replay-server.ts"; + +const FIXTURES_DIR = new URL("../fixtures", import.meta.url).pathname; + +function parsePort(argv: readonly string[]): number { + const index = argv.indexOf("--port"); + if (index === -1) return 0; + const port = Number(argv[index + 1]); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`--port expects an integer between 0 and 65535, got "${argv[index + 1]}"`); + } + return port; +} + +// Recording mode wipes the committed fixtures, so a standalone server never runs in it. +if (process.env["RECORD"] === "true") { + throw new Error("replay-serve does not support RECORD; unset it to serve the fixtures"); +} + +const server = await startReplayServer({ + fixturesDir: FIXTURES_DIR, + hostname: "127.0.0.1", + port: parsePort(process.argv.slice(2)), +}); + +let stopping = false; +const shutdown = async () => { + if (stopping) return; + stopping = true; + await server.stop(); + process.exit(0); +}; +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); + +console.log(JSON.stringify({ url: server.url })); diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 8706bc8736..4d85198cac 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -153,6 +153,8 @@ interface ReplayServerOptions { fixturesDir: string; /** Port to listen on (0 = random). */ port?: number; + /** Interface to bind; Bun binds all interfaces when omitted. */ + hostname?: string; /** Optional Postgres mock server to control via /_ctrl/pg-* endpoints. */ pgMock?: PgMockHandle; } @@ -193,6 +195,7 @@ export async function startReplayServer(options: ReplayServerOptions): Promise` to also write the raw per-run samples, and `--cpu-prof` to capture a Bun CPU profile of one untimed @@ -84,6 +84,152 @@ rm -f /tmp/cli-base/mise.toml /tmp/cli-base/mise.lock # if mise refuses the untr (cd /tmp/cli-base && pnpm install && pnpm exec turbo run supabase#build) ``` +### Pull request benchmark + +`.github/workflows/cli-bench.yml` runs on every ready pull request into `develop` that touches the CLI, its packages, or +the e2e replay fixtures. It builds the pull request and the base tip it merges into with `pnpm --filter supabase run build:binary`, runs both +through `bench-cli.ts` against a local replay server, and writes a job summary with `bench-summary.ts`. +The job is report only: it never blocks a pull request, and a benchmark problem is reported in the +summary instead of failing the job: a failed branch or base build or a replay server that does not +start replaces the comparison with a notice naming the step. The workloads use no Docker, database, or network, and no +prompts. + +The first push to a pull request from this repository benchmarks and posts one comment, tagged with +the hidden marker ``. Later pushes skip the benchmark, and the job summary says +so, while that comment reports a result; a comment that reports a failed run is benchmarked again +on the next push. Pull requests from forks have a read-only token, so they get the job summary only +and benchmark every push. + +A maintainer can comment exactly `/bench` to run the benchmark again on the pull request's current +head commit. The command re-runs that commit's `pull_request` workflow run, as "Re-run all jobs" in +the Actions UI does, and the re-run updates the comment in place, or creates it if it is missing. +The comment keeps a collapsed "Previous runs" list below the report with the commit, time (UTC), +headline, and link of each earlier run, newest first and up to 20, stored in a hidden marker in the +comment. Only the repository owner and people with write access can run it, and only on open, +non-draft pull requests into `develop` from this repository; the command must be the whole comment, +so `/bench full` and anything else is ignored. An eyes reaction on the comment means GitHub accepted +the re-run and a benchmark is running; the result arrives as the updated benchmark comment, whether +it succeeded or failed. When no re-run can start, the pull request gets a reply saying why: a +benchmark is already running for the commit, the run is older than 30 days, GitHub refused the +re-run (the reply includes the status and message), or there is no run for the commit. + +Known gap: a pull request whose changes match none of the workflow's path filters, or that changes +more files than GitHub's path filter inspects (300), gets no run, so there is nothing for `/bench` +to re-run and it replies that there is none. + +Workflows triggered by comments run from the default branch, so `/bench` works only once the +workflow is on `develop`. The `/bench` job runs default branch code only: it never checks out or +runs pull request code, because a comment-triggered run shares the default branch's cache scope. +Benchmarks always run in the `pull_request` run. `resolve` (read-only) decides whether a push +benchmarks; `rerun` is the only job with `actions: write`. `bench` has read-only permissions, and +`comment` is the only `pull_request` job that can write to the pull request. It never builds or +runs the benchmarked binaries: it downloads the rendered markdown, sanitizes it, and posts it. + +To run it locally with two binaries (for example builds from two commits): + +```sh +pnpm --filter @supabase/cli-e2e run replay:serve # prints {"url":"http://127.0.0.1:"} +bun apps/cli/scripts/bench-cli.ts --base --branch \ + --workloads apps/cli/bench/workloads.pr.json --api-url --trace --out /tmp/report.json +bun apps/cli/scripts/bench-summary.ts --report /tmp/report.json +``` + +`--workloads` replaces repeated `--command` flags. `apps/cli/bench/workloads.pr.json` lists entries +with an `id`, an `args` array (so quoting works), a `cwdSetup` of `none` or `init`, and optionally +a replay `scenario`. `init` runs `supabase init` in the launch's project directory first. Setup runs +outside the timed region. + +`--api-url` (which requires `--trace`) points every launch at the replay server by writing a profile +file into the launch's fresh `SUPABASE_HOME` and setting `SUPABASE_PROFILE`, plus a dummy access +token. The profile is the CLI's only API redirect: `SUPABASE_API_URL` does not change where the +Management API client sends requests, so it is never set. Every launch gets the profile, including +`--version` and `init`, so an unexpected new API call reaches the server and is counted. Before each +launch the harness clears the server's request log and scenario, then selects the workload's +scenario, so scenarios that are used up as they are served start fresh every time. A failing control +call aborts the benchmark. A launch that runs longer than 60 seconds is killed and counted as a +failed launch (exit code 124), so that workload is reported as not compared and the rest finish. + +API calls are counted from the traced launch's HTTP client spans: spans to the `--api-url` host and +port are API calls, and spans to any other host are `externalCalls`. The count is cross-checked +against the replay server's request log for the same launch. The `--out` file stores raw per-run +data; the summary derives medians, flags, and verdicts from it, so thresholds can change without +re-running. + +The report (the comment and the job summary share one renderer) leads with the answer, "no changes +found" or "N things to check", then one plain-words line for each flag, a table of the median time +per command before and after the change, API calls, and the binary size. A collapsed section holds +the timing ranges, API calls per command, and the slowest phases. It reports: + +- Flags: an API call count that goes from 0 to more than 0 (for example `migration new` starting to + call the API) or from more than 0 to 0, calls to hosts other than the replay server, counts that + differ between runs of one build, spans that disagree with the request log, 4xx/5xx replay + responses (the build did not follow the recorded scenario), non-zero or differing exit codes, + missing traces, and too few runs for a timing verdict. Other count changes, such as 1 to 2 calls, appear in the API calls table with the + changed endpoints but are not flagged. +- Wall time: base ("before") and branch ("after") medians and ranges. A verdict of slower or faster needs the branch + median outside the base min-max range, a change of at least 5 percent and 5 ms, and at least 5 + measured runs; otherwise it reads "within noise", or "not comparable" when a build failed. + Thresholds are named constants in `bench-report.ts`. +- API calls per workload, base and branch. +- Phase deltas: the three largest span deltas for each workload with a slower or faster verdict. +- Binary size of `dist/supabase`. + +The baseline API calls per workload on `develop` (a project-scoped command +also refreshes the linked-project cache, so it makes two calls): + +| Workload | API calls | +| ------------------------------------------ | ---------------------------------------------------------------------- | +| `version`, `help`, `init`, `migration-new` | 0 | +| `projects-list` | 1: `GET /v1/projects` | +| `gen-types` | 2: `GET /v1/projects/{ref}/types/typescript`, `GET /v1/projects/{ref}` | +| `functions-list` | 2: `GET /v1/projects/{ref}/functions`, `GET /v1/projects/{ref}` | + +`SUPABASE_NO_KEYRING=1` disables the keyring (`CommandCredentials.load` reports +`keyring.enabled=false`); the value `true` is not recognized. Counts come from the untimed traced +launch, so a launch that behaves differently when timed shows up only as an exit-code difference. + +#### How the workloads were chosen + +The list comes from `cli_command_executed` usage in PostHog over the last 30 days, for stable CLI +versions only (prereleases excluded). Commands are ranked by distinct `device_id`, then by run +count; the p50 and p90 duration of successful runs, the CI share, and the agent share are context. +Only commands that run with no Docker, no database, no real network, and no prompts are kept, and +every Go-proxied command is dropped. One command is taken from each class (startup, local files, +API read without a ref, API calls with a ref, API read of a large response) rather than the top N +overall, capped to fit the time budget. Refresh the list each quarter, or after the +v3 release with the version regex updated: + +```sql +SELECT properties.command cmd, uniq(properties.device_id) users, count() runs, + round(100*countIf(properties.exit_code != 0)/count(),1) err_pct, + round(quantileIf(0.5)(properties.duration_ms, properties.exit_code = 0)) p50_ms, + round(quantileIf(0.9)(properties.duration_ms, properties.exit_code = 0)) p90_ms, + round(100*countIf(properties.is_ci = true)/count(),1) ci_pct, + round(100*countIf(properties.is_agent = true)/count(),1) agent_pct +FROM events WHERE event='cli_command_executed' AND timestamp >= now() - INTERVAL 30 DAY + AND match(properties.cli_version,'^2\\.(119|120|121|122)\\.[0-9]+$') +GROUP BY cmd ORDER BY users DESC LIMIT 40 +``` + +Caveats: + +- Telemetry records the command path and the flags set on the command line, with values redacted + unless the flag is boolean, a choice, or listed in the command's `safeFlags` (see + [analytics.md](analytics.md)); positional arguments and free-form values are not recorded, so + `bench` and the TypeScript target only approximate real use. +- `device_id` counts machines, not people, short-lived CI runners inflate it, and a large share of + runs come from agents. Telemetry durations include network, Docker, and wait time, so they guided + the choice but are not what the benchmark measures. +- Excluded high-usage commands: `start`, `status`, `stop`, `db start`, `db reset`, and `test db` + need Docker; `db push`, `db lint`, `db dump`, `db diff`, `migration list`, `migration up`, + `migration repair`, and `db schema declarative sync` need a database (a nightly tier); + `link` probes `https://.`, which never reaches the replay server; + `functions deploy` uses Docker by default and its API path has no success scenario; `login` is + interactive unless a token is passed; `functions serve` needs Docker. +- The first launch of a process is slower (credential loading); the warm-up runs absorb it. +- Launches write a profile file, so numbers from runs with `--api-url` are not comparable with runs + without it. + ## Grafana, Tempo, or Jaeger Run a local OpenTelemetry stack, for example Grafana's all-in-one image: diff --git a/apps/cli/scripts/__fixtures__/api-call-trace.jsonl b/apps/cli/scripts/__fixtures__/api-call-trace.jsonl new file mode 100644 index 0000000000..da0943e55b --- /dev/null +++ b/apps/cli/scripts/__fixtures__/api-call-trace.jsonl @@ -0,0 +1,2 @@ +{"resourceSpans":[{"scopeSpans":[{"scope":{"name":"effect","version":"4.0.2"},"spans":[{"traceId":"8ced2df9b0dcb856be8a39ecde5572f1","spanId":"b4b54720ec88d11a","parentSpanId":"894f97211df62fba","name":"GET","kind":3,"startTimeUnixNano":"1793178856745192708","endTimeUnixNano":"1793178856748612583","attributes":[{"key":"http.request.method","value":{"stringValue":"GET"}},{"key":"url.full","value":{"stringValue":"http://127.0.0.1:62824/v1/projects"}},{"key":"url.path","value":{"stringValue":"/v1/projects"}},{"key":"url.scheme","value":{"stringValue":"http"}},{"key":"server.address","value":{"stringValue":"127.0.0.1"}},{"key":"server.port","value":{"intValue":62824}},{"key":"http.response.status_code","value":{"intValue":200}}],"droppedAttributesCount":0,"events":[],"droppedEventsCount":0,"status":{"code":1},"links":[],"droppedLinksCount":0},{"traceId":"8ced2df9b0dcb856be8a39ecde5572f1","spanId":"894664602ca4b246","name":"cli.run","kind":1,"startTimeUnixNano":"1793178856730765750","endTimeUnixNano":"1793178856751636708","attributes":[{"key":"process.boot_ms","value":{"intValue":100}},{"key":"process.exit_code","value":{"intValue":0}}],"droppedAttributesCount":0,"events":[],"droppedEventsCount":0,"status":{"code":1},"links":[],"droppedLinksCount":0}]}]}]} +{"resourceSpans":[{"scopeSpans":[{"scope":{"name":"effect","version":"4.0.2"},"spans":[{"traceId":"ab4e0d434b5b64d740d8c6b20839d5bc","spanId":"ba8b1abcbb3ace23","parentSpanId":"22c8e0027cbedc65","name":"GET","kind":3,"startTimeUnixNano":"1793178857188826250","endTimeUnixNano":"1793178857192480042","attributes":[{"key":"http.request.method","value":{"stringValue":"GET"}},{"key":"url.full","value":{"stringValue":"http://127.0.0.1:62824/v1/projects"}},{"key":"url.path","value":{"stringValue":"/v1/projects"}},{"key":"url.scheme","value":{"stringValue":"http"}},{"key":"server.address","value":{"stringValue":"127.0.0.1"}},{"key":"server.port","value":{"intValue":62824}},{"key":"http.response.status_code","value":{"intValue":200}}],"droppedAttributesCount":0,"events":[],"droppedEventsCount":0,"status":{"code":1},"links":[],"droppedLinksCount":0},{"traceId":"ab4e0d434b5b64d740d8c6b20839d5bc","spanId":"8330e018c95efc64","name":"cli.run","kind":1,"startTimeUnixNano":"1793178857174426458","endTimeUnixNano":"1793178857195383917","attributes":[{"key":"process.boot_ms","value":{"intValue":118}},{"key":"process.exit_code","value":{"intValue":0}}],"droppedAttributesCount":0,"events":[],"droppedEventsCount":0,"status":{"code":1},"links":[],"droppedLinksCount":0}]}]}]} diff --git a/apps/cli/scripts/bench-cli.integration.test.ts b/apps/cli/scripts/bench-cli.integration.test.ts new file mode 100644 index 0000000000..6caeed1fcb --- /dev/null +++ b/apps/cli/scripts/bench-cli.integration.test.ts @@ -0,0 +1,109 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Duration, Effect, FileSystem, Layer, Ref } from "effect"; +import { FetchHttpClient } from "effect/http"; +import { startReplayServer } from "../../cli-e2e/src/server/replay-server.ts"; +import { + LAUNCH_TIMEOUT_EXIT_CODE, + runLaunch, + runWorkloadBench, + type BenchOptions, +} from "./bench-cli.ts"; + +const fixturesDir = new URL("../../cli-e2e/fixtures", import.meta.url).pathname; + +// Stands in for a CLI build: every launch makes one request to the profile's API and, when asked +// to trace, records it as an HTTP client span. +const FAKE_CLI = `#!/bin/sh +URL=$(sed -n 's/^api_url: "\\(.*\\)"$/\\1/p' "$SUPABASE_PROFILE") +curl -s -o /dev/null "$URL/v1/projects" +if [ -n "$SUPABASE_TRACE_FILE" ]; then + PORT=\${URL##*:} + printf '{"resourceSpans":[{"scopeSpans":[{"spans":[{"traceId":"t","spanId":"s","name":"GET","startTimeUnixNano":"0","endTimeUnixNano":"1000000","status":{"code":1},"attributes":[{"key":"http.request.method","value":{"stringValue":"GET"}},{"key":"url.path","value":{"stringValue":"/v1/projects"}},{"key":"server.address","value":{"stringValue":"127.0.0.1"}},{"key":"server.port","value":{"intValue":%s}},{"key":"http.response.status_code","value":{"intValue":200}}]}]}]}]}\\n' "$PORT" >> "$SUPABASE_TRACE_FILE" +fi +`; + +describe("runWorkloadBench", () => { + it.effect("counts only the measured launch's API calls, not setup or earlier launches", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const server = yield* Effect.acquireRelease( + Effect.promise(() => startReplayServer({ fixturesDir })), + (handle) => Effect.promise(() => handle.stop()), + ); + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "bench-fake-cli-" }); + const binary = `${dir}/supabase`; + yield* fs.writeFileString(binary, FAKE_CLI); + yield* fs.chmod(binary, 0o755); + const options: BenchOptions = { + base: binary, + branch: binary, + runs: 2, + warmup: 1, + commands: [], + workloadsFile: undefined, + cwdSetupInit: false, + apiUrl: server.url, + trace: true, + json: false, + out: undefined, + cpuProf: false, + updateCheck: false, + }; + + const { report } = yield* runWorkloadBench( + options, + { + id: "w", + args: ["projects", "list"], + cwdSetup: "init", + scenario: "projects-list-renders-project-list", + }, + yield* Ref.make(false), + ); + + const samples = [...report.base, ...report.branch]; + expect(samples).toHaveLength(4); + for (const sample of samples) { + expect(sample.exitCode).toBe(0); + expect(sample.trace?.api?.apiCalls).toBe(1); + expect(sample.trace?.api?.requestLogCount).toBe(1); + expect(sample.trace?.api?.externalCalls).toBe(0); + } + }), + ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer))), + ); +}); + +describe("runLaunch", () => { + it.live("kills a launch that outlives the timeout and records a failing exit code", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "bench-hung-cli-" }); + const pidFile = `${dir}/pid`; + const binary = `${dir}/supabase`; + yield* fs.writeFileString( + binary, + `#!/bin/sh\necho $$ > "${pidFile}"\nexec /bin/sleep 60\n`, + ); + yield* fs.chmod(binary, 0o755); + + const result = yield* runLaunch({ + binary, + args: [], + cwd: dir, + env: {}, + timeout: Duration.millis(500), + }); + + expect(result.exitCode).toBe(LAUNCH_TIMEOUT_EXIT_CODE); + expect(result.wallMs).toBeLessThan(30_000); + const pid = Number((yield* fs.readFileString(pidFile)).trim()); + expect(Number.isInteger(pid)).toBe(true); + expect(() => process.kill(pid, 0)).toThrow(); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/scripts/bench-cli.ts b/apps/cli/scripts/bench-cli.ts index 6e1df06c16..5699c7aba8 100644 --- a/apps/cli/scripts/bench-cli.ts +++ b/apps/cli/scripts/bench-cli.ts @@ -1,32 +1,65 @@ /** * A/B wall-time benchmark for two CLI binaries. Alternates launches between `--base` and * `--branch` so neither build runs consistently warmer, isolates each launch's environment, and - * optionally compares per-span trace totals when `SUPABASE_TRACE_FILE` tracing is available. Run - * as `bun scripts/bench-cli.ts --base --branch [options]`. + * optionally records per-span trace totals and Management API call counts from + * `SUPABASE_TRACE_FILE` tracing. Run as + * `bun scripts/bench-cli.ts --base --branch [options]`. */ import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import process from "node:process"; import { parseArgs } from "node:util"; import { BunPath, BunRuntime, BunServices } from "@effect/platform-bun"; -import { Clock, Console, Effect, FileSystem, Path, Ref, Schema, Stdio } from "effect"; +import { + Clock, + Console, + Duration, + Effect, + FileSystem, + Layer, + Option, + Path, + Ref, + Schema, + Stdio, +} from "effect"; +import { FetchHttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; -import { readSpans, type ReportSpan } from "./trace-report.ts"; +import { + analyzeWorkload, + BenchReport, + classifyHttpSpans, + encodeBenchReport, + collectFlags, + parseWorkloadManifest, + summarizeSpanRun, + validateWorkloads, + type Build, + type RunSample, + type SpanComparison, + type Workload, + type WorkloadAnalysis, + type WorkloadReport, +} from "./bench-report.ts"; +import { readRequestLogCount, resetReplay } from "./replay-control.ts"; +import { readSpans } from "./trace-report.ts"; const USAGE = - 'usage: bun scripts/bench-cli.ts --base --branch [--runs N] [--warmup N] [--command ""]... [--cwd-setup init] [--trace] [--json] [--out ] [--cpu-prof] [--update-check]\n' + - " --command splits on whitespace; it does not support shell quoting.\n" + + 'usage: bun scripts/bench-cli.ts --base --branch [--runs N] [--warmup N] [--command ""]... [--workloads ] [--api-url ] [--cwd-setup init] [--trace] [--json] [--out ] [--cpu-prof] [--update-check]\n' + + " --command splits on whitespace; it does not support shell quoting. Use --workloads for quoted arguments.\n" + + " --workloads reads a manifest of workloads and cannot be combined with --command or --cwd-setup.\n" + + " --api-url points every launch at a replay server through a profile file, and counts Management API calls from the traced launches; it requires --trace.\n" + " --update-check keeps the GitHub release check, which otherwise adds network time to every launch."; -export type Build = "base" | "branch"; - export interface BenchOptions { readonly base: string; readonly branch: string; readonly runs: number; readonly warmup: number; readonly commands: ReadonlyArray>; + readonly workloadsFile: string | undefined; readonly cwdSetupInit: boolean; + readonly apiUrl: string | undefined; readonly trace: boolean; readonly json: boolean; readonly out: string | undefined; @@ -34,10 +67,11 @@ export interface BenchOptions { readonly updateCheck: boolean; } -const ReportJson = Schema.fromJsonString(Schema.Unknown, { space: 2 }); - const DEFAULT_COMMANDS: ReadonlyArray> = [["--version"], ["--help"]]; +/** Dummy token for launches pointed at a replay server; it never reaches a real API. */ +const BENCH_ACCESS_TOKEN = `sbp_${"0".repeat(40)}`; + /** Whether `--help`/`-h` is passed as a flag rather than as a `--command` value. */ export function wantsHelp(argv: ReadonlyArray): boolean { return argv.some( @@ -45,7 +79,7 @@ export function wantsHelp(argv: ReadonlyArray): boolean { ); } -/** Parses argv into {@link BenchOptions}; throws on missing `--base`/`--branch` or bad numbers. */ +/** Parses argv into {@link BenchOptions}; throws on missing `--base`/`--branch` or bad values. */ export function parseBenchArgs(argv: ReadonlyArray): BenchOptions { // `parseArgs` rejects a separate value that starts with `-`, but CLI commands usually do. const args: Array = []; @@ -67,6 +101,8 @@ export function parseBenchArgs(argv: ReadonlyArray): BenchOptions { runs: { type: "string", default: "10" }, warmup: { type: "string", default: "2" }, command: { type: "string", multiple: true }, + workloads: { type: "string" }, + "api-url": { type: "string" }, "cwd-setup": { type: "string" }, trace: { type: "boolean", default: false }, json: { type: "boolean", default: false }, @@ -90,6 +126,15 @@ export function parseBenchArgs(argv: ReadonlyArray): BenchOptions { if (values["cwd-setup"] !== undefined && values["cwd-setup"] !== "init") { throw new Error('--cwd-setup only supports "init"'); } + const hasCommands = values.command !== undefined && values.command.length > 0; + if (values.workloads !== undefined && (hasCommands || values["cwd-setup"] !== undefined)) { + throw new Error("--workloads cannot be combined with --command or --cwd-setup"); + } + if (values["api-url"] !== undefined) { + if (!URL.canParse(values["api-url"])) throw new Error("--api-url must be a valid URL"); + if (!values.trace) + throw new Error("--api-url requires --trace, which supplies the call counts"); + } /* The host `Path` service (`node:path` itself), read synchronously for this sync parser. */ const hostPath = Effect.runSync( @@ -102,11 +147,12 @@ export function parseBenchArgs(argv: ReadonlyArray): BenchOptions { branch: hostPath.resolve(values.branch), runs, warmup, - commands: - values.command === undefined || values.command.length === 0 - ? DEFAULT_COMMANDS - : values.command.map((raw) => raw.split(/\s+/u).filter((part) => part.length > 0)), + commands: hasCommands + ? values.command!.map((raw) => raw.split(/\s+/u).filter((part) => part.length > 0)) + : DEFAULT_COMMANDS, + workloadsFile: values.workloads, cwdSetupInit: values["cwd-setup"] === "init", + apiUrl: values["api-url"], trace: values.trace ?? false, json: values.json ?? false, out: values.out, @@ -139,185 +185,68 @@ export interface PlannedRun { readonly iteration: number; } -/** Alternates base/branch launches per iteration; the first `warmup` iterations are discarded. */ +/** Launches base and branch once per iteration, swapping which goes first on every other measured iteration. */ export function buildRunPlan(runs: number, warmup: number): ReadonlyArray { const plan: Array = []; for (let iteration = 0; iteration < runs + warmup; iteration++) { const isWarmup = iteration < warmup; - plan.push({ build: "base", warmup: isWarmup, iteration }); - plan.push({ build: "branch", warmup: isWarmup, iteration }); + const order: ReadonlyArray = + Math.abs(iteration - warmup) % 2 === 0 ? ["base", "branch"] : ["branch", "base"]; + for (const build of order) plan.push({ build, warmup: isWarmup, iteration }); } return plan; } -const round = (value: number): number => Math.round(value * 100) / 100; - -export function median(values: ReadonlyArray): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!; -} - -export function percentile(values: ReadonlyArray, p: number): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const rank = Math.min(Math.max(Math.ceil((p / 100) * sorted.length) - 1, 0), sorted.length - 1); - return sorted[rank]!; -} - -export interface TimingSummary { - readonly median: number; - readonly min: number; - readonly max: number; - readonly p90: number; -} - -export function summarizeTimings(values: ReadonlyArray): TimingSummary { - const sorted = [...values].sort((a, b) => a - b); - return { - median: round(median(sorted)), - min: round(sorted[0] ?? 0), - max: round(sorted[sorted.length - 1] ?? 0), - p90: round(percentile(sorted, 90)), - }; -} - -export interface Delta { - readonly ms: number; - readonly pct: number; -} - -/** `branch - base`; `pct` is `0` when `baseMedian` is `0` rather than `Infinity`. */ -export function deltaOf(baseMedian: number, branchMedian: number): Delta { - const ms = branchMedian - baseMedian; - return { ms: round(ms), pct: baseMedian === 0 ? 0 : round((ms / baseMedian) * 100) }; -} - -export interface SpanRunSummary { - readonly totalMs: number; - readonly count: number; -} - -/** Sums duration and counts occurrences of each span name within one run's decoded spans. */ -export function summarizeSpanRun(spans: ReadonlyArray): Record { - const result: Record = {}; - for (const span of spans) { - const entry = result[span.name] ?? { totalMs: 0, count: 0 }; - result[span.name] = { - totalMs: entry.totalMs + (span.endMs - span.startMs), - count: entry.count + 1, - }; +export class WorkloadValidationError extends Schema.TaggedError()( + "WorkloadValidationError", + { problems: Schema.Array(Schema.String) }, +) { + override get message(): string { + return this.problems.join("\n"); } - return result; -} - -export interface SpanAggregate { - readonly medianMs: number; - readonly medianCount: number; -} - -/** Medians each span name's per-run total duration and count across runs; missing runs count as 0. */ -export function aggregateSpanRuns( - runs: ReadonlyArray>, -): Record { - const names = new Set(); - for (const run of runs) for (const name of Object.keys(run)) names.add(name); - const result: Record = {}; - for (const name of names) { - result[name] = { - medianMs: round(median(runs.map((run) => run[name]?.totalMs ?? 0))), - medianCount: round(median(runs.map((run) => run[name]?.count ?? 0))), - }; - } - return result; -} - -export interface SpanComparison { - readonly name: string; - readonly base: SpanAggregate | undefined; - readonly branch: SpanAggregate | undefined; - readonly deltaMs: number | undefined; - readonly onlyIn: Build | undefined; -} - -function spanSortKey(entry: SpanComparison): number { - return entry.deltaMs !== undefined - ? Math.abs(entry.deltaMs) - : (entry.branch?.medianMs ?? entry.base?.medianMs ?? 0); -} - -/** Pairs base/branch span aggregates by name, sorted by `|delta|` descending; flags one-sided spans. */ -export function compareSpans( - base: Record, - branch: Record, -): ReadonlyArray { - const names = new Set([...Object.keys(base), ...Object.keys(branch)]); - const comparisons = [...names].map((name): SpanComparison => { - const baseEntry = base[name]; - const branchEntry = branch[name]; - return { - name, - base: baseEntry, - branch: branchEntry, - deltaMs: - baseEntry !== undefined && branchEntry !== undefined - ? round(branchEntry.medianMs - baseEntry.medianMs) - : undefined, - onlyIn: baseEntry === undefined ? "branch" : branchEntry === undefined ? "base" : undefined, - }; - }); - return comparisons.sort((a, b) => spanSortKey(b) - spanSortKey(a)); -} - -/** Whether the builds exited differently, which makes their timings measure different work. */ -export function exitCodesDiffer( - base: ReadonlyArray, - branch: ReadonlyArray, -): boolean { - const codes = (values: ReadonlyArray) => - [...new Set(values)].sort((a, b) => a - b).join(","); - return codes(base) !== codes(branch); -} - -interface BuildTimingSummary extends TimingSummary { - readonly exitCodes: ReadonlyArray; -} - -export interface CommandReport { - readonly command: ReadonlyArray; - readonly base: BuildTimingSummary; - readonly branch: BuildTimingSummary; - readonly deltaMs: number; - readonly deltaPct: number; - readonly spans: ReadonlyArray | undefined; - readonly traceEmptyFor: ReadonlyArray; - readonly exitCodesDiffer: boolean; } -interface RawSample { - readonly command: ReadonlyArray; - readonly build: Build; - readonly wallMs: number; - readonly exitCode: number; - readonly spans: Record | undefined; -} +/** The workloads for a run: the manifest entries, or one per `--command` with `--cwd-setup`. */ +export const resolveWorkloads = Effect.fn("BenchCli.resolveWorkloads")(function* ( + options: BenchOptions, +) { + const workloads: ReadonlyArray = + options.workloadsFile === undefined + ? options.commands.map((args) => ({ + id: args.join(" ") || "(no args)", + args, + cwdSetup: options.cwdSetupInit ? ("init" as const) : ("none" as const), + })) + : yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* parseWorkloadManifest(yield* fs.readFileString(options.workloadsFile!)); + }); + const problems = validateWorkloads(workloads, { apiUrl: options.apiUrl }); + if (problems.length > 0) return yield* new WorkloadValidationError({ problems }); + return workloads; +}); interface LaunchResult { readonly wallMs: number; readonly exitCode: number; } -const runLaunch = (params: { +/** Longest a single launch may run before it is killed and counted as a failed launch. */ +export const LAUNCH_TIMEOUT = Duration.seconds(60); +/** Exit code recorded for a launch that hit {@link LAUNCH_TIMEOUT}. */ +export const LAUNCH_TIMEOUT_EXIT_CODE = 124; + +export const runLaunch = Effect.fn("BenchCli.runLaunch")(function* (params: { readonly binary: string; readonly args: ReadonlyArray; readonly cwd: string; readonly env: Record; -}) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const start = yield* Clock.currentTimeNanos; - const exitCode = yield* spawner.exitCode( + readonly timeout?: Duration.Duration; +}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const start = yield* Clock.currentTimeNanos; + const exit = yield* spawner + .exitCode( ChildProcess.make(params.binary, params.args, { cwd: params.cwd, env: params.env, @@ -326,33 +255,72 @@ const runLaunch = (params: { stdout: "ignore", stderr: "ignore", }), + ) + .pipe(Effect.timeoutOption(params.timeout ?? LAUNCH_TIMEOUT)); + const end = yield* Clock.currentTimeNanos; + if (Option.isNone(exit)) { + yield* Console.error( + `warning: ${params.binary} ${params.args.join(" ")} did not exit within ${Duration.format(params.timeout ?? LAUNCH_TIMEOUT)}; it was killed`, ); - const end = yield* Clock.currentTimeNanos; - return { - wallMs: Number(end - start) / 1_000_000, - exitCode: Number(exitCode), - } satisfies LaunchResult; - }); + } + return { + wallMs: Number(end - start) / 1_000_000, + exitCode: Option.isSome(exit) ? Number(exit.value) : LAUNCH_TIMEOUT_EXIT_CODE, + } satisfies LaunchResult; +}); + +const PROFILE_FILE = "bench-profile.yaml"; + +/** A profile naming `apiUrl` as the Management API; the profile is the CLI's only API redirect. */ +export function profileYaml(apiUrl: string): string { + return [ + "name: bench", + `api_url: "${apiUrl}"`, + `dashboard_url: "${apiUrl}"`, + "project_host: localhost", + "", + ].join("\n"); +} -const baseLaunchEnv = (supabaseHome: string, updateCheck: boolean): Record => ({ +const baseLaunchEnv = ( + options: BenchOptions, + supabaseHome: string, + profilePath: string, +): Record => ({ SUPABASE_HOME: supabaseHome, DO_NOT_TRACK: "1", SUPABASE_NO_KEYRING: "1", - ...(updateCheck ? {} : { SUPABASE_NO_UPDATE_NOTIFIER: "1" }), + ...(options.updateCheck ? {} : { SUPABASE_NO_UPDATE_NOTIFIER: "1" }), + ...(options.apiUrl === undefined + ? {} + : { + SUPABASE_PROFILE: profilePath, + SUPABASE_ACCESS_TOKEN: BENCH_ACCESS_TOKEN, + SUPABASE_TELEMETRY_DISABLED: "1", + }), }); -/** A fresh project directory and `SUPABASE_HOME`, initialized when `--cwd-setup init` is set. */ -const prepareLaunch = (options: BenchOptions, binary: string) => +/** + * A fresh project directory and `SUPABASE_HOME`, with the replay profile written and the + * workload's `cwdSetup` applied. + */ +const prepareLaunch = (options: BenchOptions, workload: Workload, binary: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const projectDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-bench-cwd-" }); const supabaseHome = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-bench-home-" }); - if (options.cwdSetupInit) { + const profilePath = path.join(supabaseHome, PROFILE_FILE); + if (options.apiUrl !== undefined) { + yield* fs.writeFileString(profilePath, profileYaml(options.apiUrl)); + } + const env = baseLaunchEnv(options, supabaseHome, profilePath); + if (workload.cwdSetup !== "none") { const init = yield* runLaunch({ binary, args: ["init"], cwd: projectDir, - env: isolatedEnv(process.env, baseLaunchEnv(supabaseHome, options.updateCheck)), + env: isolatedEnv(process.env, env), }); if (init.exitCode !== 0) { yield* Console.error( @@ -360,12 +328,12 @@ const prepareLaunch = (options: BenchOptions, binary: string) => ); } } - return { projectDir, supabaseHome }; + return { projectDir, env }; }); -const runCommandBench = ( +export const runWorkloadBench = ( options: BenchOptions, - command: ReadonlyArray, + workload: Workload, profiled: Ref.Ref, ) => Effect.gen(function* () { @@ -373,30 +341,30 @@ const runCommandBench = ( const path = yield* Path.Path; const plan = buildRunPlan(options.runs, options.warmup); - const samples: Record> = { base: [], branch: [] }; - const exitCodes: Record> = { base: [], branch: [] }; - const spanRuns: Record>> = { base: [], branch: [] }; - const raw: Array = []; + const samples: Record> = { base: [], branch: [] }; let cpuProfilePath: string | undefined; + // Resets the replay server after setup, so setup's own requests and a previous launch's + // consumed scenario never reach the launch that follows. + const resetServer = + options.apiUrl === undefined ? Effect.void : resetReplay(options.apiUrl, workload.scenario); + for (const planned of plan) { const binary = planned.build === "base" ? options.base : options.branch; yield* Effect.scoped( Effect.gen(function* () { - const { projectDir, supabaseHome } = yield* prepareLaunch(options, binary); + const prepared = yield* prepareLaunch(options, workload, binary); + yield* resetServer; const result = yield* runLaunch({ binary, - args: command, - cwd: projectDir, - env: isolatedEnv(process.env, baseLaunchEnv(supabaseHome, options.updateCheck)), + args: workload.args, + cwd: prepared.projectDir, + env: isolatedEnv(process.env, prepared.env), }); if (planned.warmup) return; - samples[planned.build].push(result.wallMs); - exitCodes[planned.build].push(result.exitCode); - // Tracing and profiling slow the launch they observe, so they run in an extra, untimed // launch in fresh directories. Both builds get traced launches; only one branch launch // is profiled. @@ -413,81 +381,66 @@ const runCommandBench = ( bunOptions = `--cpu-prof --cpu-prof-dir=${profileDir} --cpu-prof-name=${profileName}`; } } + + let trace: RunSample["trace"]; + let tracedExitCode: number | undefined; if (traceFile !== undefined || bunOptions !== undefined) { - const observed = yield* prepareLaunch(options, binary); - yield* runLaunch({ + const observed = yield* prepareLaunch(options, workload, binary); + yield* resetServer; + const traced = yield* runLaunch({ binary, - args: command, + args: workload.args, cwd: observed.projectDir, env: isolatedEnv(process.env, { - ...baseLaunchEnv(observed.supabaseHome, options.updateCheck), + ...observed.env, ...(traceFile === undefined ? {} : { SUPABASE_TRACE_FILE: traceFile }), ...(bunOptions === undefined ? {} : { BUN_OPTIONS: bunOptions }), }), }); + tracedExitCode = traced.exitCode; + if (traceFile !== undefined) { + const requestLogCount = + options.apiUrl === undefined + ? undefined + : yield* readRequestLogCount(options.apiUrl); + // Trace data is optional; a missing or unreadable file must not discard timings. + const decoded = yield* readSpans(traceFile).pipe(Effect.option); + if (Option.isSome(decoded) && decoded.value.spans.length > 0) { + const { spans, skippedLines } = decoded.value; + const http = + options.apiUrl === undefined + ? undefined + : classifyHttpSpans(spans, options.apiUrl); + trace = { + skippedLines, + spans: summarizeSpanRun(spans), + ...(http === undefined || requestLogCount === undefined + ? {} + : { api: { ...http, requestLogCount } }), + }; + } + } } - const spans = - traceFile === undefined - ? undefined - : summarizeSpanRun( - // Trace data is optional; a missing or unreadable file must not discard timings. - (yield* readSpans(traceFile).pipe( - Effect.orElseSucceed(() => ({ spans: [], skippedLines: 0 })), - )).spans, - ); - if (spans !== undefined) spanRuns[planned.build].push(spans); - - raw.push({ - command, - build: planned.build, + samples[planned.build].push({ wallMs: result.wallMs, exitCode: result.exitCode, - spans, + ...(tracedExitCode === undefined ? {} : { tracedExitCode }), + ...(trace === undefined ? {} : { trace }), }); }), ); } - const base = summarizeTimings(samples.base); - const branch = summarizeTimings(samples.branch); - const delta = deltaOf(base.median, branch.median); - - const spans = options.trace - ? compareSpans(aggregateSpanRuns(spanRuns.base), aggregateSpanRuns(spanRuns.branch)) - : undefined; - const traceEmptyFor: ReadonlyArray = options.trace - ? (["base", "branch"] as const).filter( - (build) => - spanRuns[build].length > 0 && - spanRuns[build].every((run) => Object.keys(run).length === 0), - ) - : []; - - const report: CommandReport = { - command, - base: { ...base, exitCodes: exitCodes.base }, - branch: { ...branch, exitCodes: exitCodes.branch }, - deltaMs: delta.ms, - deltaPct: delta.pct, - spans, - traceEmptyFor, - exitCodesDiffer: exitCodesDiffer(exitCodes.base, exitCodes.branch), + const report: WorkloadReport = { + id: workload.id, + args: workload.args, + base: samples.base, + branch: samples.branch, }; - return { report, raw, cpuProfilePath }; + return { report, cpuProfilePath }; }); -interface Report { - readonly base: string; - readonly branch: string; - readonly runs: number; - readonly warmup: number; - readonly trace: boolean; - readonly cwdSetup: "init" | undefined; - readonly commands: ReadonlyArray; - readonly cpuProfilePath: string | undefined; -} - function formatSpanLine(span: SpanComparison): string { if (span.onlyIn !== undefined) { const only = span.onlyIn === "base" ? span.base : span.branch; @@ -500,52 +453,57 @@ function formatSpanLine(span: SpanComparison): string { ); } -function formatCommandReport(commandReport: CommandReport): ReadonlyArray { +function formatWorkload(analysis: WorkloadAnalysis): ReadonlyArray { const sign = (value: number) => (value >= 0 ? "+" : ""); + const line = (label: string, build: WorkloadAnalysis["base"]) => + ` ${label} median ${build.timing.median}ms min ${build.timing.min}ms max ${build.timing.max}ms p90 ${build.timing.p90}ms exits [${build.exitCodes.join(",")}]`; const lines = [ - `command: ${commandReport.command.join(" ") || "(no args)"}`, - ` base: median ${commandReport.base.median}ms min ${commandReport.base.min}ms max ${commandReport.base.max}ms p90 ${commandReport.base.p90}ms exits [${commandReport.base.exitCodes.join(",")}]`, - ` branch: median ${commandReport.branch.median}ms min ${commandReport.branch.min}ms max ${commandReport.branch.max}ms p90 ${commandReport.branch.p90}ms exits [${commandReport.branch.exitCodes.join(",")}]`, - ` delta: ${sign(commandReport.deltaMs)}${commandReport.deltaMs}ms (${sign(commandReport.deltaPct)}${commandReport.deltaPct}%)`, + `command: ${analysis.args.join(" ") || "(no args)"} [${analysis.id}]`, + line("base: ", analysis.base), + line("branch:", analysis.branch), + ` delta: ${sign(analysis.deltaMs)}${analysis.deltaMs}ms (${sign(analysis.deltaPct)}${analysis.deltaPct}%) ${analysis.verdict}`, ]; - if (commandReport.exitCodesDiffer) { - lines.push(" warning: exit codes differ between builds, so the timings are not comparable"); - } - for (const build of ["base", "branch"] as const) { - const codes = commandReport[build].exitCodes; - const failed = codes.filter((code) => code !== 0).length; - if (failed > 0) { - lines.push( - ` warning: ${build} exited non-zero in ${failed}/${codes.length} runs, so its timings measure a failing invocation`, - ); + if (analysis.base.api !== undefined || analysis.branch.api !== undefined) { + lines.push( + ` api calls: base ${analysis.base.api?.medianCalls ?? "n/a"} branch ${analysis.branch.api?.medianCalls ?? "n/a"}`, + ); + for (const change of analysis.endpointChanges) { + lines.push(` ${change.endpoint} base ${change.base} branch ${change.branch}`); } } - if (commandReport.traceEmptyFor.length > 0) { - lines.push(` trace: no spans captured for ${commandReport.traceEmptyFor.join(", ")}`); - } - if (commandReport.spans !== undefined && commandReport.spans.length > 0) { + for (const flag of collectFlags(analysis)) lines.push(` warning: ${flag.detail}`); + if (analysis.spans !== undefined && analysis.spans.length > 0) { lines.push(" spans (delta ms, or one-sided median ms x count):"); - for (const span of commandReport.spans) lines.push(formatSpanLine(span)); + for (const span of analysis.spans) lines.push(formatSpanLine(span)); } lines.push(""); return lines; } -function formatReport(report: Report): string { +function formatReport(report: BenchReport): string { const lines = [ `base: ${report.base}`, `branch: ${report.branch}`, `runs: ${report.runs} warmup: ${report.warmup}` + - (report.cwdSetup === undefined ? "" : ` cwd-setup: ${report.cwdSetup}`), + (report.apiUrl === undefined ? "" : ` api-url: ${report.apiUrl}`), "", ]; - for (const commandReport of report.commands) lines.push(...formatCommandReport(commandReport)); + for (const workload of report.workloads) { + lines.push(...formatWorkload(analyzeWorkload(workload, report.trace))); + } if (report.cpuProfilePath !== undefined) { lines.push(`cpu profile (one untimed branch launch): ${report.cpuProfilePath}`); } return lines.join("\n"); } +const fileSize = (binary: string) => + FileSystem.FileSystem.use((fs) => fs.stat(binary)).pipe( + Effect.map((info) => Number(info.size)), + Effect.option, + Effect.map(Option.getOrUndefined), + ); + const main = Effect.gen(function* () { const stdio = yield* Stdio.Stdio; const argv = yield* stdio.args; @@ -570,42 +528,42 @@ const main = Effect.gen(function* () { ); if (options === undefined) return; + const workloads = yield* resolveWorkloads(options); const profiled = yield* Ref.make(false); - const commands: Array = []; - const samples: Array = []; + const reports: Array = []; let cpuProfilePath: string | undefined; - for (const command of options.commands) { - const result = yield* runCommandBench(options, command, profiled); - commands.push(result.report); - samples.push(...result.raw); + for (const workload of workloads) { + const result = yield* runWorkloadBench(options, workload, profiled); + reports.push(result.report); if (result.cpuProfilePath !== undefined) cpuProfilePath = result.cpuProfilePath; } - const report: Report = { + const baseSizeBytes = yield* fileSize(options.base); + const branchSizeBytes = yield* fileSize(options.branch); + const report: BenchReport = { base: options.base, branch: options.branch, + ...(baseSizeBytes === undefined ? {} : { baseSizeBytes }), + ...(branchSizeBytes === undefined ? {} : { branchSizeBytes }), runs: options.runs, warmup: options.warmup, trace: options.trace, - cwdSetup: options.cwdSetupInit ? "init" : undefined, - commands, - cpuProfilePath, + ...(options.apiUrl === undefined ? {} : { apiUrl: options.apiUrl }), + workloads: reports, + ...(cpuProfilePath === undefined ? {} : { cpuProfilePath }), }; if (options.out !== undefined) { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString( - options.out, - yield* Schema.encodeEffect(ReportJson)({ report, samples }), - ); + yield* fs.writeFileString(options.out, yield* encodeBenchReport(report)); } - yield* Console.log( - options.json ? yield* Schema.encodeEffect(ReportJson)(report) : formatReport(report), - ); + yield* Console.log(options.json ? yield* encodeBenchReport(report) : formatReport(report)); }); if (import.meta.main) { - BunRuntime.runMain(main.pipe(Effect.provide(BunServices.layer))); + BunRuntime.runMain( + main.pipe(Effect.provide(Layer.mergeAll(FetchHttpClient.layer, BunServices.layer))), + ); } diff --git a/apps/cli/scripts/bench-cli.unit.test.ts b/apps/cli/scripts/bench-cli.unit.test.ts index d991afdc32..8906f3a380 100644 --- a/apps/cli/scripts/bench-cli.unit.test.ts +++ b/apps/cli/scripts/bench-cli.unit.test.ts @@ -1,37 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; import { - aggregateSpanRuns, buildRunPlan, - compareSpans, - deltaOf, - exitCodesDiffer, isolatedEnv, parseBenchArgs, + profileYaml, + resolveWorkloads, wantsHelp, - summarizeSpanRun, - summarizeTimings, } from "./bench-cli.ts"; -import type { SpanRunSummary } from "./bench-cli.ts"; -import type { ReportSpan } from "./trace-report.ts"; - -const span = (name: string, startMs: number, endMs: number): ReportSpan => ({ - traceId: "t", - spanId: `${name}-${startMs}`, - parentSpanId: undefined, - name, - startMs, - endMs, - failed: false, - errorType: undefined, -}); + +const required = ["--base", "/bin/a", "--branch", "/bin/b"]; describe("parseBenchArgs", () => { it("accepts commands that start with a dash and keeps update checks off by default", () => { const options = parseBenchArgs([ - "--base", - "/bin/a", - "--branch", - "/bin/b", + ...required, "--command", "--version", "--command", @@ -41,115 +25,96 @@ describe("parseBenchArgs", () => { expect(options.commands).toEqual([["--version"], ["db", "diff", "--local"]]); expect(options.updateCheck).toBe(false); }); + + it("rejects a manifest combined with --command or --cwd-setup", () => { + expect(() => + parseBenchArgs([...required, "--workloads", "w.json", "--command", "init"]), + ).toThrow("--workloads cannot be combined"); + expect(() => + parseBenchArgs([...required, "--workloads", "w.json", "--cwd-setup", "init"]), + ).toThrow("--workloads cannot be combined"); + }); + + it("requires --trace with --api-url because call counts come from the trace", () => { + expect(() => parseBenchArgs([...required, "--api-url", "http://127.0.0.1:1"])).toThrow( + "--api-url requires --trace", + ); + expect(parseBenchArgs([...required, "--api-url", "http://127.0.0.1:1", "--trace"]).apiUrl).toBe( + "http://127.0.0.1:1", + ); + }); +}); + +describe("resolveWorkloads", () => { + it.effect.each([ + { cwdSetup: ["--cwd-setup", "init"], expected: "init" }, + { cwdSetup: [], expected: "none" }, + ])("turns --command entries into workloads with cwdSetup $expected", ({ cwdSetup, expected }) => + Effect.gen(function* () { + const options = parseBenchArgs([ + ...required, + "--command", + "migration new bench", + ...cwdSetup, + ]); + + const workloads = yield* resolveWorkloads(options); + + expect(workloads).toEqual([ + { id: "migration new bench", args: ["migration", "new", "bench"], cwdSetup: expected }, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); + +describe("resolveWorkloads validation", () => { + it.effect("fails with a WorkloadValidationError carrying the problems", () => + Effect.gen(function* () { + const options = parseBenchArgs([ + ...required, + "--command", + "migration new bench", + "--command", + "migration new bench", + ]); + + const error = yield* resolveWorkloads(options).pipe(Effect.flip); + + const problems = error._tag === "WorkloadValidationError" ? error.problems : undefined; + expect(problems).toEqual(['duplicate workload id "migration new bench"']); + }).pipe(Effect.provide(BunServices.layer)), + ); }); describe("wantsHelp", () => { it("treats --help as a flag only when it is not a --command value", () => { expect(wantsHelp(["--help"])).toBe(true); - expect(wantsHelp(["--base", "/a", "--branch", "/b", "--command", "--help"])).toBe(false); - }); -}); - -describe("summarizeTimings", () => { - it("computes median, min, max, and p90", () => { - expect(summarizeTimings([10, 20, 30, 40, 50])).toEqual({ - median: 30, - min: 10, - max: 50, - p90: 50, - }); + expect(wantsHelp([...required, "--command", "--help"])).toBe(false); }); }); describe("buildRunPlan", () => { - it("alternates base and branch and discards the first warmup iterations", () => { + it("swaps the launch order every other iteration and discards the first warmup iterations", () => { const plan = buildRunPlan(2, 1); expect(plan.map((run) => [run.build, run.warmup])).toEqual([ - ["base", true], ["branch", true], + ["base", true], ["base", false], ["branch", false], - ["base", false], ["branch", false], + ["base", false], ]); }); }); -describe("exitCodesDiffer", () => { - it("flags builds that exit differently but not shared failures", () => { - expect(exitCodesDiffer([1, 1], [1, 1])).toBe(false); - expect(exitCodesDiffer([0, 0], [1, 0])).toBe(true); - }); -}); - -describe("deltaOf", () => { - it("reports branch minus base in ms and percent", () => { - expect(deltaOf(100, 110)).toEqual({ ms: 10, pct: 10 }); - }); - - it("reports a zero percent delta instead of Infinity when base is zero", () => { - expect(deltaOf(0, 5)).toEqual({ ms: 5, pct: 0 }); - }); -}); - -describe("span aggregation", () => { - it("sums duration and counts per span name within one run", () => { - const run = [span("Config.load", 0, 10), span("Db.query", 10, 20), span("Db.query", 20, 50)]; - - expect(summarizeSpanRun(run)).toEqual({ - "Config.load": { totalMs: 10, count: 1 }, - "Db.query": { totalMs: 40, count: 2 }, - }); - }); +describe("profileYaml", () => { + it("points the API and dashboard at the replay server and keeps project hosts local", () => { + const yaml = profileYaml("http://127.0.0.1:9"); - it("medians per-span totals and counts across runs, treating an absent span as zero", () => { - const runs: ReadonlyArray> = [ - { "Db.query": { totalMs: 40, count: 2 } }, - { "Db.query": { totalMs: 60, count: 2 } }, - {}, - ]; - - expect(aggregateSpanRuns(runs)).toEqual({ - "Db.query": { medianMs: 40, medianCount: 2 }, - }); - }); -}); - -describe("compareSpans", () => { - it("flags spans found in only one build and sorts by absolute delta", () => { - const base = { - "Config.load": { medianMs: 10, medianCount: 1 }, - "Credentials.load": { medianMs: 5, medianCount: 1 }, - }; - const branch = { - "Config.load": { medianMs: 40, medianCount: 1 }, - "Otlp.flush": { medianMs: 3, medianCount: 1 }, - }; - - expect(compareSpans(base, branch)).toEqual([ - { - name: "Config.load", - base: { medianMs: 10, medianCount: 1 }, - branch: { medianMs: 40, medianCount: 1 }, - deltaMs: 30, - onlyIn: undefined, - }, - { - name: "Credentials.load", - base: { medianMs: 5, medianCount: 1 }, - branch: undefined, - deltaMs: undefined, - onlyIn: "base", - }, - { - name: "Otlp.flush", - base: undefined, - branch: { medianMs: 3, medianCount: 1 }, - deltaMs: undefined, - onlyIn: "branch", - }, - ]); + expect(yaml).toContain('api_url: "http://127.0.0.1:9"'); + expect(yaml).toContain('dashboard_url: "http://127.0.0.1:9"'); + expect(yaml).toContain("project_host: localhost"); }); }); @@ -159,6 +124,7 @@ describe("isolatedEnv", () => { HOME: "/home/person", PATH: "/usr/bin", SUPABASE_ACCESS_TOKEN: "secret", + SUPABASE_API_URL: "https://elsewhere", OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector", TRACEPARENT: "00-...-01", }; diff --git a/apps/cli/scripts/bench-report.ts b/apps/cli/scripts/bench-report.ts new file mode 100644 index 0000000000..8e48662b76 --- /dev/null +++ b/apps/cli/scripts/bench-report.ts @@ -0,0 +1,523 @@ +/** + * Report schemas and the pure analysis shared by `bench-cli.ts` (which records raw per-run data) + * and `bench-summary.ts` (which derives flags and verdicts), so thresholds can change without + * re-running a benchmark. + */ +import { Effect, Schema } from "effect"; +import type { ReportSpan } from "./trace-report.ts"; + +export type Build = "base" | "branch"; + +const round = (value: number): number => Math.round(value * 100) / 100; + +export function median(values: ReadonlyArray): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1]! + sorted[mid]!) / 2 : sorted[mid]!; +} + +export function percentile(values: ReadonlyArray, p: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const rank = Math.min(Math.max(Math.ceil((p / 100) * sorted.length) - 1, 0), sorted.length - 1); + return sorted[rank]!; +} + +export interface TimingSummary { + readonly median: number; + readonly min: number; + readonly max: number; + readonly p90: number; +} + +export function summarizeTimings(values: ReadonlyArray): TimingSummary { + const sorted = [...values].sort((a, b) => a - b); + return { + median: round(median(sorted)), + min: round(sorted[0] ?? 0), + max: round(sorted[sorted.length - 1] ?? 0), + p90: round(percentile(sorted, 90)), + }; +} + +export interface Delta { + readonly ms: number; + readonly pct: number; +} + +/** `branch - base`; `pct` is `0` when `baseMedian` is `0` rather than `Infinity`. */ +export function deltaOf(baseMedian: number, branchMedian: number): Delta { + const ms = branchMedian - baseMedian; + return { ms: round(ms), pct: baseMedian === 0 ? 0 : round((ms / baseMedian) * 100) }; +} + +const SpanRunSummary = Schema.Struct({ totalMs: Schema.Finite, count: Schema.Finite }); +export type SpanRunSummary = typeof SpanRunSummary.Type; + +/** + * Sums duration and counts occurrences of each span name within one run's decoded spans. HTTP + * client spans are left out: the API-call section reports them per endpoint. + */ +export function summarizeSpanRun(spans: ReadonlyArray): Record { + const result: Record = {}; + for (const span of spans) { + if (span.http !== undefined) continue; + const entry = result[span.name] ?? { totalMs: 0, count: 0 }; + result[span.name] = { + totalMs: entry.totalMs + (span.endMs - span.startMs), + count: entry.count + 1, + }; + } + return result; +} + +export interface SpanAggregate { + readonly medianMs: number; + readonly medianCount: number; +} + +/** Medians each span name's per-run total duration and count across runs; missing runs count as 0. */ +export function aggregateSpanRuns( + runs: ReadonlyArray>, +): Record { + const names = new Set(); + for (const run of runs) for (const name of Object.keys(run)) names.add(name); + const result: Record = {}; + for (const name of names) { + result[name] = { + medianMs: round(median(runs.map((run) => run[name]?.totalMs ?? 0))), + medianCount: round(median(runs.map((run) => run[name]?.count ?? 0))), + }; + } + return result; +} + +export interface SpanComparison { + readonly name: string; + readonly base: SpanAggregate | undefined; + readonly branch: SpanAggregate | undefined; + readonly deltaMs: number | undefined; + readonly onlyIn: Build | undefined; +} + +function spanSortKey(entry: SpanComparison): number { + return entry.deltaMs !== undefined + ? Math.abs(entry.deltaMs) + : (entry.branch?.medianMs ?? entry.base?.medianMs ?? 0); +} + +/** Pairs base/branch span aggregates by name, sorted by `|delta|` descending; flags one-sided spans. */ +export function compareSpans( + base: Record, + branch: Record, +): ReadonlyArray { + const names = new Set([...Object.keys(base), ...Object.keys(branch)]); + const comparisons = [...names].map((name): SpanComparison => { + const baseEntry = base[name]; + const branchEntry = branch[name]; + return { + name, + base: baseEntry, + branch: branchEntry, + deltaMs: + baseEntry !== undefined && branchEntry !== undefined + ? round(branchEntry.medianMs - baseEntry.medianMs) + : undefined, + onlyIn: baseEntry === undefined ? "branch" : branchEntry === undefined ? "base" : undefined, + }; + }); + return comparisons.sort((a, b) => spanSortKey(b) - spanSortKey(a)); +} + +/** Whether the builds exited differently, which makes their timings measure different work. */ +export function exitCodesDiffer( + base: ReadonlyArray, + branch: ReadonlyArray, +): boolean { + const codes = (values: ReadonlyArray) => + [...new Set(values)].sort((a, b) => a - b).join(","); + return codes(base) !== codes(branch); +} + +/* Workload manifest */ + +const Workload = Schema.Struct({ + id: Schema.NonEmptyString, + args: Schema.Array(Schema.String), + cwdSetup: Schema.Literals(["none", "init"]), + scenario: Schema.optionalKey(Schema.String), +}); +export type Workload = typeof Workload.Type; + +const WorkloadManifest = Schema.Struct({ workloads: Schema.NonEmptyArray(Workload) }); + +const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(WorkloadManifest)); + +/** Rules the schema cannot express; returns one message per violation. */ +export function validateWorkloads( + workloads: ReadonlyArray, + context: { readonly apiUrl: string | undefined }, +): ReadonlyArray { + const problems: Array = []; + const seen = new Set(); + for (const workload of workloads) { + if (seen.has(workload.id)) problems.push(`duplicate workload id "${workload.id}"`); + seen.add(workload.id); + if (workload.scenario !== undefined && context.apiUrl === undefined) { + problems.push(`workload "${workload.id}" names a scenario and needs --api-url`); + } + } + return problems; +} + +/** Decodes the text of a workload manifest file. */ +export const parseWorkloadManifest = (text: string) => + decodeManifest(text).pipe(Effect.map((manifest) => manifest.workloads)); + +/* HTTP call classification */ + +export interface HttpCallSummary { + readonly apiCalls: number; + readonly externalCalls: number; + readonly errorStatuses: number; + readonly endpoints: Record; +} + +/** + * Splits a run's HTTP client spans into calls to `apiUrl` (same hostname and port) and calls to + * any other host. `errorStatuses` counts API calls that got a 4xx or 5xx response. + */ +export function classifyHttpSpans( + spans: ReadonlyArray, + apiUrl: string, +): HttpCallSummary { + const url = new URL(apiUrl); + const apiHost = url.hostname.replace(/^\[|\]$/gu, ""); + const apiPort = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port); + let apiCalls = 0; + let externalCalls = 0; + let errorStatuses = 0; + const endpoints: Record = {}; + for (const span of spans) { + const http = span.http; + if (http === undefined) continue; + if (http.address !== apiHost || (http.port !== undefined && http.port !== apiPort)) { + externalCalls += 1; + continue; + } + apiCalls += 1; + if (http.status !== undefined && http.status >= 400) errorStatuses += 1; + const endpoint = `${http.method} ${http.path ?? "(unknown path)"}`; + endpoints[endpoint] = (endpoints[endpoint] ?? 0) + 1; + } + return { apiCalls, externalCalls, errorStatuses, endpoints }; +} + +/* Report (raw, as written by `bench-cli.ts --out`) */ + +const ApiSample = Schema.Struct({ + apiCalls: Schema.Finite, + externalCalls: Schema.Finite, + errorStatuses: Schema.Finite, + endpoints: Schema.Record(Schema.String, Schema.Finite), + requestLogCount: Schema.Finite, +}); + +const TraceSample = Schema.Struct({ + skippedLines: Schema.Finite, + spans: Schema.Record(Schema.String, SpanRunSummary), + api: Schema.optionalKey(ApiSample), +}); + +const RunSample = Schema.Struct({ + wallMs: Schema.Finite, + exitCode: Schema.Finite, + /** Exit code of the traced launch, recorded even when no spans could be decoded. */ + tracedExitCode: Schema.optionalKey(Schema.Finite), + trace: Schema.optionalKey(TraceSample), +}); +export type RunSample = typeof RunSample.Type; + +const WorkloadReport = Schema.Struct({ + id: Schema.String, + args: Schema.Array(Schema.String), + base: Schema.Array(RunSample), + branch: Schema.Array(RunSample), +}); +export type WorkloadReport = typeof WorkloadReport.Type; + +export const BenchReport = Schema.Struct({ + base: Schema.String, + branch: Schema.String, + baseSizeBytes: Schema.optionalKey(Schema.Finite), + branchSizeBytes: Schema.optionalKey(Schema.Finite), + runs: Schema.Finite, + warmup: Schema.Finite, + trace: Schema.Boolean, + apiUrl: Schema.optionalKey(Schema.String), + workloads: Schema.Array(WorkloadReport), + cpuProfilePath: Schema.optionalKey(Schema.String), +}); +export type BenchReport = typeof BenchReport.Type; + +const BenchReportJson = Schema.fromJsonString(BenchReport, { space: 2 }); +export const decodeBenchReport = Schema.decodeUnknownEffect(BenchReportJson); +export const encodeBenchReport = Schema.encodeEffect(BenchReportJson); + +/* Analysis */ + +export const VERDICT_MIN_PCT = 5; +export const VERDICT_MIN_MS = 5; +export const VERDICT_MIN_SAMPLES = 5; +export const PHASE_ROWS = 3; + +export type TimingVerdict = "slower" | "faster" | "within noise" | "not comparable"; + +/** + * "slower" or "faster" only when the branch median is outside the base min-max range and the + * change is at least {@link VERDICT_MIN_PCT} percent and {@link VERDICT_MIN_MS} ms. + */ +export function timingVerdict( + base: TimingSummary, + branch: TimingSummary, + context: { readonly comparable: boolean; readonly samples: number }, +): TimingVerdict { + if (!context.comparable || context.samples < VERDICT_MIN_SAMPLES) return "not comparable"; + const delta = deltaOf(base.median, branch.median); + if (Math.abs(delta.pct) < VERDICT_MIN_PCT || Math.abs(delta.ms) < VERDICT_MIN_MS) { + return "within noise"; + } + if (branch.median > base.max) return "slower"; + if (branch.median < base.min) return "faster"; + return "within noise"; +} + +export interface ApiSummary { + readonly medianCalls: number; + readonly runCalls: ReadonlyArray; + readonly nonDeterministic: boolean; + /** Median per-run count for each `METHOD path`; a run without the endpoint counts as 0. */ + readonly endpoints: Record; + readonly externalCalls: number; + /** Runs whose span-derived API call count differs from the replay server's request log. */ + readonly logMismatchRuns: number; + readonly errorStatuses: number; +} + +export interface BuildAnalysis { + readonly timing: TimingSummary; + readonly samples: number; + readonly exitCodes: ReadonlyArray; + readonly tracedExitCodes: ReadonlyArray; + readonly spans: Record | undefined; + readonly api: ApiSummary | undefined; + /** Measured runs without a usable trace when tracing was requested. */ + readonly traceMissingRuns: number; + readonly skippedTraceLines: number; +} + +function summarizeApi(samples: ReadonlyArray): ApiSummary | undefined { + const apis = samples.flatMap((sample) => + sample.trace?.api === undefined ? [] : [sample.trace.api], + ); + if (apis.length === 0) return undefined; + const runCalls = apis.map((api) => api.apiCalls); + const keys = new Set(apis.flatMap((api) => Object.keys(api.endpoints))); + const endpoints: Record = {}; + for (const key of keys) endpoints[key] = median(apis.map((api) => api.endpoints[key] ?? 0)); + const shape = (api: (typeof apis)[number]) => + JSON.stringify(Object.entries(api.endpoints).sort(([a], [b]) => a.localeCompare(b))); + return { + medianCalls: median(runCalls), + runCalls, + nonDeterministic: new Set(apis.map(shape)).size > 1, + endpoints, + externalCalls: Math.max(...apis.map((api) => api.externalCalls)), + logMismatchRuns: apis.filter((api) => api.apiCalls !== api.requestLogCount).length, + errorStatuses: Math.max(...apis.map((api) => api.errorStatuses)), + }; +} + +function summarizeBuild(samples: ReadonlyArray, traced: boolean): BuildAnalysis { + const withTrace = samples.flatMap((sample) => (sample.trace === undefined ? [] : [sample.trace])); + return { + timing: summarizeTimings(samples.map((sample) => sample.wallMs)), + samples: samples.length, + exitCodes: samples.map((sample) => sample.exitCode), + tracedExitCodes: samples.flatMap((sample) => + sample.tracedExitCode === undefined ? [] : [sample.tracedExitCode], + ), + spans: traced ? aggregateSpanRuns(withTrace.map((trace) => trace.spans)) : undefined, + api: summarizeApi(samples), + traceMissingRuns: traced ? samples.length - withTrace.length : 0, + skippedTraceLines: withTrace.reduce((sum, trace) => sum + trace.skippedLines, 0), + }; +} + +export interface EndpointChange { + readonly endpoint: string; + readonly base: number; + readonly branch: number; +} + +export interface WorkloadAnalysis { + readonly id: string; + readonly args: ReadonlyArray; + readonly base: BuildAnalysis; + readonly branch: BuildAnalysis; + readonly deltaMs: number; + readonly deltaPct: number; + readonly verdict: TimingVerdict; + readonly exitCodesDiffer: boolean; + readonly spans: ReadonlyArray | undefined; + /** Top phase deltas; empty unless the verdict is "slower" or "faster". */ + readonly phaseRows: ReadonlyArray; + readonly endpointChanges: ReadonlyArray; +} + +/** Whether every launch of the workload exited 0 and every API call got a non-error response. */ +function runsCleanly(build: BuildAnalysis): boolean { + return ( + [...build.exitCodes, ...build.tracedExitCodes].every((code) => code === 0) && + (build.api?.errorStatuses ?? 0) === 0 + ); +} + +function endpointChanges( + base: BuildAnalysis, + branch: BuildAnalysis, +): ReadonlyArray { + if (base.api === undefined || branch.api === undefined) return []; + const baseEndpoints = base.api.endpoints; + const branchEndpoints = branch.api.endpoints; + const keys = new Set([...Object.keys(baseEndpoints), ...Object.keys(branchEndpoints)]); + return [...keys] + .sort() + .map((endpoint) => ({ + endpoint, + base: baseEndpoints[endpoint] ?? 0, + branch: branchEndpoints[endpoint] ?? 0, + })) + .filter((change) => change.base !== change.branch); +} + +/** Derives medians, comparisons and the timing verdict for one workload from its raw samples. */ +export function analyzeWorkload(workload: WorkloadReport, traced: boolean): WorkloadAnalysis { + const base = summarizeBuild(workload.base, traced); + const branch = summarizeBuild(workload.branch, traced); + const delta = deltaOf(base.timing.median, branch.timing.median); + const differ = exitCodesDiffer(base.exitCodes, branch.exitCodes); + const verdict = timingVerdict(base.timing, branch.timing, { + comparable: !differ && runsCleanly(base) && runsCleanly(branch), + samples: Math.min(base.samples, branch.samples), + }); + const spans = + base.spans === undefined || branch.spans === undefined + ? undefined + : compareSpans(base.spans, branch.spans); + return { + id: workload.id, + args: workload.args, + base, + branch, + deltaMs: delta.ms, + deltaPct: delta.pct, + verdict, + exitCodesDiffer: differ, + spans, + phaseRows: + verdict === "slower" || verdict === "faster" ? (spans ?? []).slice(0, PHASE_ROWS) : [], + endpointChanges: endpointChanges(base, branch), + }; +} + +export type FlagKind = + | "new-api-calls" + | "api-calls-dropped" + | "external-calls" + | "non-deterministic" + | "request-log-mismatch" + | "error-status" + | "trace-incomplete" + | "non-zero-exit" + | "exit-codes-differ" + | "traced-exit-differs" + | "insufficient-samples"; + +export interface Flag { + readonly workload: string; + readonly kind: FlagKind; + readonly detail: string; + /** The build the flag is about, absent when it concerns the comparison. */ + readonly build?: Build; +} + +/** Everything about a workload that makes its comparison suspect or notable beyond timing. */ +export function collectFlags(analysis: WorkloadAnalysis): ReadonlyArray { + const flags: Array = []; + const add = (kind: FlagKind, detail: string, build?: Build) => + flags.push({ workload: analysis.id, kind, detail, ...(build === undefined ? {} : { build }) }); + const baseCalls = analysis.base.api?.medianCalls; + const branchCalls = analysis.branch.api?.medianCalls; + if (baseCalls === 0 && branchCalls !== undefined && branchCalls > 0) { + add("new-api-calls", `base makes 0 API calls, branch makes ${branchCalls}`); + } + if (baseCalls !== undefined && baseCalls > 0 && branchCalls === 0) { + add("api-calls-dropped", `base makes ${baseCalls} API calls, branch makes 0`); + } + if (Math.min(analysis.base.samples, analysis.branch.samples) < VERDICT_MIN_SAMPLES) { + add( + "insufficient-samples", + `fewer than ${VERDICT_MIN_SAMPLES} measured runs, so no timing verdict`, + ); + } + if (analysis.exitCodesDiffer) { + add("exit-codes-differ", "exit codes differ between builds, so timings are not comparable"); + } + for (const build of ["base", "branch"] as const) { + const summary = analysis[build]; + const failed = summary.exitCodes.filter((code) => code !== 0).length; + if (failed > 0) { + add("non-zero-exit", `${build} exited non-zero in ${failed}/${summary.samples} runs`, build); + } + const timed = new Set(summary.exitCodes); + if (summary.tracedExitCodes.some((code) => !timed.has(code))) { + add( + "traced-exit-differs", + `${build} traced launch exited differently from its timed launches`, + build, + ); + } + if (summary.traceMissingRuns > 0 || summary.skippedTraceLines > 0) { + add( + "trace-incomplete", + `${build} has ${summary.traceMissingRuns} runs without a trace and ${summary.skippedTraceLines} undecodable trace lines`, + build, + ); + } + const api = summary.api; + if (api === undefined) continue; + if (api.externalCalls > 0) { + add("external-calls", `${build} made calls to hosts other than the replay server`, build); + } + if (api.nonDeterministic) { + add("non-deterministic", `${build} API calls differ between runs`, build); + } + if (api.logMismatchRuns > 0) { + add( + "request-log-mismatch", + `${build} spans disagree with the replay request log in ${api.logMismatchRuns} runs`, + build, + ); + } + if (api.errorStatuses > 0) { + add( + "error-status", + `${build} got 4xx/5xx replay responses, so it did not follow the recorded scenario`, + build, + ); + } + } + return flags; +} diff --git a/apps/cli/scripts/bench-report.unit.test.ts b/apps/cli/scripts/bench-report.unit.test.ts new file mode 100644 index 0000000000..9f41a41d98 --- /dev/null +++ b/apps/cli/scripts/bench-report.unit.test.ts @@ -0,0 +1,474 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { + aggregateSpanRuns, + analyzeWorkload, + classifyHttpSpans, + collectFlags, + decodeBenchReport, + encodeBenchReport, + compareSpans, + deltaOf, + exitCodesDiffer, + parseWorkloadManifest, + summarizeSpanRun, + summarizeTimings, + timingVerdict, + validateWorkloads, + type BenchReport, + type RunSample, + type SpanRunSummary, + type WorkloadReport, +} from "./bench-report.ts"; +import type { ReportSpan } from "./trace-report.ts"; + +const span = (name: string, startMs: number, endMs: number): ReportSpan => ({ + traceId: "t", + spanId: `${name}-${startMs}`, + parentSpanId: undefined, + name, + startMs, + endMs, + failed: false, + errorType: undefined, +}); + +const httpSpan = ( + method: string, + path: string, + port: number, + status = 200, + address = "127.0.0.1", +): ReportSpan => ({ + ...span(method, 0, 5), + http: { method, path, address, port, status }, +}); + +describe("summarizeTimings", () => { + it("computes median, min, max, and p90", () => { + expect(summarizeTimings([10, 20, 30, 40, 50])).toEqual({ + median: 30, + min: 10, + max: 50, + p90: 50, + }); + }); +}); + +describe("exitCodesDiffer", () => { + it("flags builds that exit differently but not shared failures", () => { + expect(exitCodesDiffer([1, 1], [1, 1])).toBe(false); + expect(exitCodesDiffer([0, 0], [1, 0])).toBe(true); + }); +}); + +describe("deltaOf", () => { + it("reports branch minus base in ms and percent", () => { + expect(deltaOf(100, 110)).toEqual({ ms: 10, pct: 10 }); + }); + + it("reports a zero percent delta instead of Infinity when base is zero", () => { + expect(deltaOf(0, 5)).toEqual({ ms: 5, pct: 0 }); + }); +}); + +describe("span aggregation", () => { + it("sums duration and counts per span name within one run, leaving HTTP spans to the API section", () => { + const run = [ + span("Config.load", 0, 10), + span("Db.query", 10, 20), + span("Db.query", 20, 50), + httpSpan("GET", "/v1/projects", 1), + ]; + + expect(summarizeSpanRun(run)).toEqual({ + "Config.load": { totalMs: 10, count: 1 }, + "Db.query": { totalMs: 40, count: 2 }, + }); + }); + + it("medians per-span totals and counts across runs, treating an absent span as zero", () => { + const runs: ReadonlyArray> = [ + { "Db.query": { totalMs: 40, count: 2 } }, + { "Db.query": { totalMs: 60, count: 2 } }, + {}, + ]; + + expect(aggregateSpanRuns(runs)).toEqual({ "Db.query": { medianMs: 40, medianCount: 2 } }); + }); +}); + +describe("compareSpans", () => { + it("flags spans found in only one build and sorts by absolute delta", () => { + const base = { + "Config.load": { medianMs: 10, medianCount: 1 }, + "Credentials.load": { medianMs: 5, medianCount: 1 }, + }; + const branch = { + "Config.load": { medianMs: 40, medianCount: 1 }, + "Otlp.flush": { medianMs: 3, medianCount: 1 }, + }; + + expect( + compareSpans(base, branch).map((entry) => [entry.name, entry.deltaMs, entry.onlyIn]), + ).toEqual([ + ["Config.load", 30, undefined], + ["Credentials.load", undefined, "base"], + ["Otlp.flush", undefined, "branch"], + ]); + }); +}); + +describe("workload manifest", () => { + const entry = { id: "w", args: ["init"], cwdSetup: "none" } as const; + + it.effect("decodes entries and rejects an unknown cwdSetup", () => + Effect.gen(function* () { + const parsed = yield* parseWorkloadManifest( + '{"workloads":[{"id":"w","args":["init"],"cwdSetup":"none"}]}', + ); + expect(parsed).toEqual([entry]); + + const exit = yield* Effect.exit( + parseWorkloadManifest('{"workloads":[{"id":"w","args":["init"],"cwdSetup":"bogus"}]}'), + ); + expect(exit._tag).toBe("Failure"); + }), + ); + + it("requires --api-url for scenarios and unique ids", () => { + const problems = validateWorkloads( + [ + { id: "b", args: [], cwdSetup: "none", scenario: "s" }, + { id: "b", args: [], cwdSetup: "none" }, + ], + { apiUrl: undefined }, + ); + + expect(problems).toEqual([ + 'workload "b" names a scenario and needs --api-url', + 'duplicate workload id "b"', + ]); + expect( + validateWorkloads([{ ...entry, scenario: "s" }], { apiUrl: "http://127.0.0.1:1" }), + ).toEqual([]); + }); +}); + +describe("classifyHttpSpans", () => { + const apiUrl = "http://127.0.0.1:4010"; + + it("counts calls to the API host and port per endpoint and separates other hosts and ports", () => { + const summary = classifyHttpSpans( + [ + httpSpan("GET", "/v1/projects", 4010), + httpSpan("GET", "/v1/projects", 4010), + httpSpan("POST", "/v1/x", 4010, 502), + httpSpan("GET", "/v1/projects", 4011), + httpSpan("GET", "/", 443, 200, "api.supabase.com"), + span("Config.load", 0, 1), + ], + apiUrl, + ); + + expect(summary).toEqual({ + apiCalls: 3, + externalCalls: 2, + errorStatuses: 1, + endpoints: { "GET /v1/projects": 2, "POST /v1/x": 1 }, + }); + }); +}); + +describe("timingVerdict", () => { + const base = { median: 100, min: 95, max: 108, p90: 107 }; + const context = { comparable: true, samples: 10 }; + + it.each([ + { branch: { median: 120, min: 115, max: 125, p90: 124 }, expected: "slower" }, + { branch: { median: 80, min: 78, max: 90, p90: 88 }, expected: "faster" }, + { branch: { median: 103, min: 99, max: 110, p90: 109 }, expected: "within noise" }, + { branch: { median: 107, min: 99, max: 118, p90: 117 }, expected: "within noise" }, + ])("calls a branch median of $branch.median $expected", ({ branch, expected }) => { + expect(timingVerdict(base, branch, context)).toBe(expected); + }); + + it("ignores a large percentage change that is under the absolute floor", () => { + expect( + timingVerdict( + { median: 10, min: 9, max: 11, p90: 11 }, + { median: 14, min: 13, max: 15, p90: 15 }, + context, + ), + ).toBe("within noise"); + }); + + it("declines to compare when runs are not comparable or too few", () => { + const slow = { median: 200, min: 190, max: 210, p90: 209 }; + expect(timingVerdict(base, slow, { comparable: false, samples: 10 })).toBe("not comparable"); + expect(timingVerdict(base, slow, { comparable: true, samples: 3 })).toBe("not comparable"); + }); +}); + +const sample = ( + wallMs: number, + api?: { + calls: number; + endpoints?: Record; + log?: number; + external?: number; + errors?: number; + }, + exitCode = 0, +): RunSample => ({ + wallMs, + exitCode, + tracedExitCode: exitCode, + trace: { + skippedLines: 0, + spans: { "cli.run": { totalMs: wallMs / 2, count: 1 } }, + ...(api === undefined + ? {} + : { + api: { + apiCalls: api.calls, + externalCalls: api.external ?? 0, + errorStatuses: api.errors ?? 0, + endpoints: api.endpoints ?? {}, + requestLogCount: api.log ?? api.calls, + }, + }), + }, +}); + +const workload = ( + base: ReadonlyArray, + branch: ReadonlyArray, +): WorkloadReport => ({ + id: "w", + args: ["migration", "new"], + base, + branch, +}); + +const times = (n: number, make: (i: number) => RunSample) => + Array.from({ length: n }, (_, i) => make(i)); + +describe("analyzeWorkload and collectFlags", () => { + it("flags a workload whose base makes no API calls and whose branch makes one", () => { + const analysis = analyzeWorkload( + workload( + times(5, () => sample(50, { calls: 0 })), + times(5, () => sample(51, { calls: 1, endpoints: { "GET /v1/projects/abc": 1 } })), + ), + true, + ); + + expect(collectFlags(analysis).map((flag) => flag.kind)).toEqual(["new-api-calls"]); + expect(analysis.endpointChanges).toEqual([ + { endpoint: "GET /v1/projects/abc", base: 0, branch: 1 }, + ]); + }); + + it("lists a count change from one call to two without a zero flag", () => { + const analysis = analyzeWorkload( + workload( + times(5, () => sample(50, { calls: 1, endpoints: { "GET /v1/projects": 1 } })), + times(5, () => sample(50, { calls: 2, endpoints: { "GET /v1/projects": 2 } })), + ), + true, + ); + + expect(collectFlags(analysis)).toEqual([]); + expect(analysis.branch.api?.medianCalls).toBe(2); + expect(analysis.endpointChanges).toEqual([ + { endpoint: "GET /v1/projects", base: 1, branch: 2 }, + ]); + }); + + it("flags external calls, non-determinism, and spans that disagree with the request log", () => { + const noisy = [ + sample(50, { calls: 1, endpoints: { "GET /a": 1 }, external: 1 }), + sample(50, { calls: 2, endpoints: { "GET /a": 2 }, log: 1 }), + sample(50, { calls: 1, endpoints: { "GET /a": 1 } }), + sample(50, { calls: 1, endpoints: { "GET /a": 1 } }), + sample(50, { calls: 1, endpoints: { "GET /a": 1 } }), + ]; + const steady = times(5, () => sample(50, { calls: 1, endpoints: { "GET /a": 1 } })); + + const kinds = collectFlags(analyzeWorkload(workload(steady, noisy), true)).map( + (flag) => flag.kind, + ); + + expect(kinds).toEqual(["external-calls", "non-deterministic", "request-log-mismatch"]); + }); + + it("flags a branch that lost all API calls and treats a missing trace as incomplete", () => { + const analysis = analyzeWorkload( + workload( + times(5, () => sample(50, { calls: 1 })), + [...times(4, () => sample(50, { calls: 0 })), { wallMs: 50, exitCode: 0 }], + ), + true, + ); + + expect(collectFlags(analysis).map((flag) => flag.kind)).toEqual([ + "api-calls-dropped", + "trace-incomplete", + ]); + }); + + it("withholds the verdict and flags failures when a build exits non-zero or gets error responses", () => { + const failing = times(5, () => sample(200, { calls: 1, errors: 1 }, 1)); + const analysis = analyzeWorkload( + workload( + times(5, () => sample(50, { calls: 1 })), + failing, + ), + true, + ); + + expect(analysis.verdict).toBe("not comparable"); + expect(collectFlags(analysis).map((flag) => flag.kind)).toEqual([ + "exit-codes-differ", + "non-zero-exit", + "error-status", + ]); + }); + + it("reports the heaviest phase deltas only when the timing verdict is slower or faster", () => { + const slow = analyzeWorkload( + workload( + times(5, (i) => sample(100 + i)), + times(5, (i) => sample(200 + i)), + ), + true, + ); + const same = analyzeWorkload( + workload( + times(5, (i) => sample(100 + i)), + times(5, (i) => sample(101 + i)), + ), + true, + ); + + expect(slow.verdict).toBe("slower"); + expect(slow.phaseRows.map((row) => row.name)).toEqual(["cli.run"]); + expect(same.verdict).toBe("within noise"); + expect(same.phaseRows).toEqual([]); + }); +}); + +describe("classifyHttpSpans host matching", () => { + it("matches a bracketed IPv6 API URL against the unbracketed span address", () => { + const summary = classifyHttpSpans( + [httpSpan("GET", "/v1/x", 4010, 200, "::1")], + "http://[::1]:4010", + ); + + expect(summary.apiCalls).toBe(1); + expect(summary.externalCalls).toBe(0); + }); + + it("counts a call to another host as external even when its port is unknown", () => { + const other: ReportSpan = { + ...span("GET", 0, 1), + http: { method: "GET", path: "/", address: "api.example.com", port: undefined, status: 200 }, + }; + + expect(classifyHttpSpans([other], "http://127.0.0.1:4010").externalCalls).toBe(1); + }); +}); + +describe("more flags", () => { + it("explains a missing verdict when there are too few runs", () => { + const few = times(3, () => sample(50, { calls: 1 })); + + const flags = collectFlags(analyzeWorkload(workload(few, few), true)); + + expect(flags.map((flag) => flag.kind)).toEqual(["insufficient-samples"]); + }); + + it("flags a traced launch that exits differently from the timed launches", () => { + const traced = sample(50, { calls: 1 }); + const odd: RunSample = { ...traced, tracedExitCode: 1 }; + + const flags = collectFlags( + analyzeWorkload( + workload( + times(5, () => sample(50, { calls: 1 })), + times(5, () => odd), + ), + true, + ), + ); + + expect(flags.map((flag) => flag.kind)).toContain("traced-exit-differs"); + }); +}); + +describe("traced launch failures", () => { + it("keeps a failed traced launch visible when no spans were decoded", () => { + const failedWithoutSpans: RunSample = { wallMs: 50, exitCode: 0, tracedExitCode: 1 }; + + const analysis = analyzeWorkload( + workload( + times(5, () => sample(50)), + times(5, () => failedWithoutSpans), + ), + true, + ); + + expect(analysis.verdict).toBe("not comparable"); + expect(collectFlags(analysis).map((flag) => flag.kind)).toEqual( + expect.arrayContaining(["traced-exit-differs", "trace-incomplete"]), + ); + }); +}); + +describe("endpoint changes without an API measurement", () => { + it("reports none when one build has no API measurement", () => { + const analysis = analyzeWorkload( + workload( + times(5, () => ({ wallMs: 50, exitCode: 0 })), + times(5, () => sample(50, { calls: 1, endpoints: { "GET /v1/projects": 1 } })), + ), + true, + ); + + expect(analysis.endpointChanges).toEqual([]); + }); +}); + +describe("report JSON", () => { + const minimal: BenchReport = { + base: "/b", + branch: "/c", + runs: 5, + warmup: 1, + trace: false, + workloads: [{ id: "w", args: ["x"], base: [{ wallMs: 1, exitCode: 0 }], branch: [] }], + }; + const full: BenchReport = { + ...minimal, + baseSizeBytes: 10, + branchSizeBytes: 11, + trace: true, + apiUrl: "http://127.0.0.1:1", + cpuProfilePath: "/p", + workloads: [ + { ...minimal.workloads[0]!, base: [sample(1, { calls: 1, endpoints: { "GET /a": 1 } })] }, + ], + }; + + it.effect.each([ + { name: "omitted optional fields", report: minimal }, + { name: "every field", report: full }, + ])("round-trips a report with $name", ({ report }) => + Effect.gen(function* () { + const decoded = yield* decodeBenchReport(yield* encodeBenchReport(report)); + + expect(decoded).toEqual(report); + }), + ); +}); diff --git a/apps/cli/scripts/bench-summary.integration.test.ts b/apps/cli/scripts/bench-summary.integration.test.ts new file mode 100644 index 0000000000..98d45acc1a --- /dev/null +++ b/apps/cli/scripts/bench-summary.integration.test.ts @@ -0,0 +1,25 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; +import { loadReport } from "./bench-summary.ts"; + +describe("loadReport", () => { + it.effect("tells an absent report from an unreadable one", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "bench-summary-" }); + const corrupt = `${dir}/report.json`; + yield* fs.writeFileString(corrupt, "{ not a report"); + + const absent = yield* loadReport(fs, `${dir}/missing.json`); + const unreadable = yield* loadReport(fs, corrupt); + + expect(absent.report).toBeUndefined(); + expect(absent.notice).toContain("did not produce a report"); + expect(unreadable.report).toBeUndefined(); + expect(unreadable.notice).toContain("could not be read"); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/scripts/bench-summary.ts b/apps/cli/scripts/bench-summary.ts new file mode 100644 index 0000000000..21b94399ee --- /dev/null +++ b/apps/cli/scripts/bench-summary.ts @@ -0,0 +1,450 @@ +/** + * Renders a `bench-cli.ts --out` report as Markdown: the answer first, then a timing table, API + * calls, binary size, and collapsed details. Run as `bun scripts/bench-summary.ts --report ` + * with optional `--branch-build|--base-build|--replay ok|failed` step outcomes, `--head-sha`, + * `--base-ref`, `--merge-base`, and `--comment-out ` for the pull request + * comment variant; the job summary goes to `GITHUB_STEP_SUMMARY` when set, otherwise to stdout. + */ +import process from "node:process"; +import { parseArgs } from "node:util"; +import { BunRuntime, BunServices } from "@effect/platform-bun"; +import { Config, Console, Effect, FileSystem, Option, Stdio } from "effect"; +import { + analyzeWorkload, + collectFlags, + decodeBenchReport, + VERDICT_MIN_PCT, + type BenchReport, + type Flag, + type WorkloadAnalysis, +} from "./bench-report.ts"; + +const USAGE = + "usage: bun scripts/bench-summary.ts --report [--branch-build ok|failed] [--base-build ok|failed] [--replay ok|failed] [--head-sha ] [--base-ref ] [--merge-base ] [--comment-out ]"; + +type Outcome = "ok" | "failed"; + +export interface SummaryOptions { + readonly report: string | undefined; + readonly branchBuild: Outcome; + readonly baseBuild: Outcome; + readonly replay: Outcome; + readonly headSha: string | undefined; + readonly baseRef: string | undefined; + readonly mergeBase: string | undefined; + readonly commentOut: string | undefined; +} + +const OUTCOME_FLAGS = ["branch-build", "base-build", "replay"] as const; + +/** Parses argv; throws on an unknown outcome or a missing report when every step succeeded. */ +export function parseSummaryArgs(argv: ReadonlyArray): SummaryOptions { + const { values } = parseArgs({ + args: [...argv], + options: { + report: { type: "string" }, + "branch-build": { type: "string", default: "ok" }, + "base-build": { type: "string", default: "ok" }, + replay: { type: "string", default: "ok" }, + "head-sha": { type: "string" }, + "base-ref": { type: "string" }, + "merge-base": { type: "string" }, + "comment-out": { type: "string" }, + }, + }); + for (const flag of OUTCOME_FLAGS) { + if (values[flag] !== "ok" && values[flag] !== "failed") { + throw new Error(`--${flag} must be "ok" or "failed"`); + } + } + const options: SummaryOptions = { + report: values.report, + branchBuild: values["branch-build"] === "failed" ? "failed" : "ok", + baseBuild: values["base-build"] === "failed" ? "failed" : "ok", + replay: values.replay === "failed" ? "failed" : "ok", + headSha: values["head-sha"], + baseRef: values["base-ref"], + mergeBase: values["merge-base"], + commentOut: values["comment-out"], + }; + if (values.report === undefined && summaryNotice(options) === undefined) throw new Error(USAGE); + return options; +} + +const NO_REPORT = + "The benchmark did not produce a report. See the Run benchmark step in the workflow run."; +const UNREADABLE_REPORT = + "The benchmark report could not be read. See the Write job summary step in the workflow run."; + +/** The reason there is nothing to compare, in the order the steps run, or undefined when all ran. */ +export function summaryNotice( + options: Pick, +): string | undefined { + if (options.branchBuild === "failed") { + return "The build of this commit failed, so there is nothing to compare."; + } + if (options.baseBuild === "failed") { + return "The build of the base branch failed, so there is nothing to compare."; + } + if (options.replay === "failed") { + return "The local test server failed to start, so the benchmark did not run. See the Start replay server step in the workflow run."; + } + return undefined; +} + +const sign = (value: number) => (value >= 0 ? "+" : ""); +const ms = (value: number) => `${Math.round(value)} ms`; +const range = (build: WorkloadAnalysis["base"]) => + `${Math.round(build.timing.min)}-${Math.round(build.timing.max)} ms`; +const signedMs = (value: number) => `${sign(value)}${Math.round(value)} ms`; +const signedPct = (value: number) => `${sign(value)}${Math.round(value)}%`; + +const cell = (value: string) => value.replaceAll("|", "\\|"); +/** Inline code for text that comes from the benchmarked build or its workload file. */ +const code = (value: string) => `\`${value.replaceAll("`", "'").replaceAll(/\s+/gu, " ")}\``; +const command = (analysis: Pick) => + code(["supabase", ...analysis.args].join(" ")); +const plural = (count: number, one: string, many: string) => (count === 1 ? one : many); +const callCount = (count: number | undefined) => + `${count ?? "some"} ${count === 1 ? "call" : "calls"}`; +const MIB = 1024 * 1024; +const mb = (bytes: number) => `${(bytes / MIB).toFixed(1)} MB`; + +function table(header: ReadonlyArray, rows: ReadonlyArray>): string { + return [ + `| ${header.join(" | ")} |`, + `| ${header.map(() => "---").join(" | ")} |`, + ...rows.map((row) => `| ${row.map(cell).join(" | ")} |`), + ].join("\n"); +} + +const when = (build: Flag["build"]) => (build === "base" ? "before" : "after"); + +/** One plain-words sentence for a flag. */ +function describeFlag(flag: Flag, analysis: WorkloadAnalysis): string { + const name = command(analysis); + const at = when(flag.build); + switch (flag.kind) { + case "new-api-calls": { + const added = analysis.endpointChanges + .filter((change) => change.branch > change.base) + .slice(0, 3) + .map((change) => code(change.endpoint)); + return ( + `${name} now calls the Management API. It made no calls before and now makes ` + + `${analysis.branch.api?.medianCalls ?? "some"}${added.length === 0 ? "" : ` (${added.join(", ")})`}.` + ); + } + case "api-calls-dropped": + return `${name} no longer calls the Management API. It made ${callCount(analysis.base.api?.medianCalls)} before and now makes none.`; + case "external-calls": + return `${name} made calls to hosts other than the local test server ${at}, so its timing may be skewed.`; + case "non-deterministic": + return `${name} makes a different number of API calls from run to run ${at}, so its results are less reliable.`; + case "request-log-mismatch": + return `The API calls counted for ${name} ${at} do not match what the local test server saw, so the counts may be wrong.`; + case "error-status": + return `${name} got error responses from the local test server ${at}, so it did not follow the recorded scenario.`; + case "trace-incomplete": + return `Some runs of ${name} ${at} produced no usable trace, so call counts and phase timings may be incomplete.`; + case "non-zero-exit": + return `${name} exited with an error ${at}; its timing is not compared.`; + case "exit-codes-differ": + return `${name} exits with different codes before and after, so its timing is not compared.`; + case "traced-exit-differs": + return `${name} exited differently when traced ${at}, so its timing is not compared.`; + case "insufficient-samples": + return `${name} had too few timed runs for a timing verdict.`; + } +} + +function changeText(analysis: WorkloadAnalysis): string { + switch (analysis.verdict) { + case "not comparable": + return "not compared"; + case "slower": + case "faster": { + const delta = Math.abs(Math.round(analysis.deltaMs)); + return `${analysis.verdict} by ${delta} ms (${signedPct(analysis.deltaPct)})`; + } + case "within noise": + return Math.abs(analysis.deltaPct) >= VERDICT_MIN_PCT + ? `no real change (${signedPct(analysis.deltaPct)}, within normal variation)` + : "no real change"; + } +} + +function timingTable(analyses: ReadonlyArray): string { + return table( + ["Command", "Before", "After", "Change"], + analyses.map((analysis) => [ + command(analysis), + ms(analysis.base.timing.median), + ms(analysis.branch.timing.median), + changeText(analysis), + ]), + ); +} + +interface ApiOverview { + readonly line: string; + /** Ids of the commands whose API calls changed. */ + readonly changedIds: ReadonlyArray; +} + +function apiOverview(analyses: ReadonlyArray): ApiOverview { + const measured = analyses.filter( + (analysis) => analysis.base.api !== undefined && analysis.branch.api !== undefined, + ); + if (measured.length === 0) return { line: "API calls were not counted.", changedIds: [] }; + const changed = measured.filter( + (analysis) => + analysis.base.api?.medianCalls !== analysis.branch.api?.medianCalls || + analysis.endpointChanges.length > 0, + ); + const missing = analyses.length - measured.length; + const suffix = missing === 0 ? "" : ` (not measured for ${missing})`; + if (changed.length === 0) { + return { + line: `API calls: unchanged for ${measured.length === 1 ? "the command" : `all ${measured.length} commands`}${suffix}.`, + changedIds: [], + }; + } + const lines = changed.map((analysis) => { + const endpoints = analysis.endpointChanges + .map((change) => `${code(change.endpoint)} ${change.base} -> ${change.branch}`) + .join(", "); + return `- ${command(analysis)}: ${callCount(analysis.base.api?.medianCalls)} before, ${analysis.branch.api?.medianCalls} now${endpoints === "" ? "" : ` (${endpoints})`}`; + }); + return { + line: [ + `API calls changed for ${changed.length} ${plural(changed.length, "command", "commands")}${suffix}:`, + ...lines, + ].join("\n"), + changedIds: changed.map((analysis) => analysis.id), + }; +} + +function sizeLine(report: BenchReport): string { + const { baseSizeBytes: base, branchSizeBytes: branch } = report; + if (base === undefined || branch === undefined) return "Binary size: not recorded."; + const delta = branch - base; + if (delta === 0) return `Binary size: ${mb(branch)}, unchanged.`; + const abs = Math.abs(delta); + const size = abs < 1024 ? `${abs} bytes` : abs < MIB ? `${(abs / 1024).toFixed(1)} KB` : mb(abs); + return `Binary size: ${mb(branch)}, ${delta > 0 ? "up" : "down"} ${size} from ${mb(base)}.`; +} + +function apiDetailTable(analyses: ReadonlyArray): string { + const rows = analyses + .filter((analysis) => analysis.base.api !== undefined || analysis.branch.api !== undefined) + .map((analysis) => [ + command(analysis), + String(analysis.base.api?.medianCalls ?? "n/a"), + String(analysis.branch.api?.medianCalls ?? "n/a"), + analysis.endpointChanges + .map((change) => `${code(change.endpoint)} ${change.base} -> ${change.branch}`) + .join("
"), + ]); + if (rows.length === 0) return "API calls were not counted."; + return table(["Command", "Before", "After", "Endpoint changes"], rows); +} + +function rangeTable(analyses: ReadonlyArray): string { + return table( + ["Command", "Before range", "After range", "Change", "Verdict"], + analyses.map((analysis) => [ + command(analysis), + range(analysis.base), + range(analysis.branch), + `${signedMs(analysis.deltaMs)} (${signedPct(analysis.deltaPct)})`, + analysis.verdict, + ]), + ); +} + +function phaseDetail(analyses: ReadonlyArray): string { + const rows = analyses.flatMap((analysis) => + analysis.phaseRows.map((row) => { + const only = row.onlyIn === undefined ? undefined : row[row.onlyIn]; + return [ + command(analysis), + code(row.name), + row.deltaMs === undefined + ? `${row.onlyIn === "base" ? "before" : "after"} only, ${Math.round(only?.medianMs ?? 0)} ms` + : signedMs(row.deltaMs), + `${row.base === undefined ? "-" : Math.round(row.base.medianMs)} -> ${row.branch === undefined ? "-" : Math.round(row.branch.medianMs)} ms`, + ]; + }), + ); + if (rows.length === 0) { + return analyses.some((analysis) => analysis.verdict === "not comparable") + ? "No phase deltas are available; some commands were not comparable." + : "No command moved beyond noise, so there are no phase deltas."; + } + return table(["Command", "Span", "Change", "Before -> after"], rows); +} + +export const BENCH_MARKER = ""; + +export interface RenderContext { + /** `comment` adds the marker and the re-run footer; `summary` is the job summary. */ + readonly target: "summary" | "comment"; + readonly headSha?: string | undefined; + readonly baseRef?: string | undefined; + readonly mergeBase?: string | undefined; +} + +const short = (sha: string) => sha.slice(0, 7); + +function contextLine(report: BenchReport | undefined, context: RenderContext): string { + const parts: Array = []; + if (context.headSha !== undefined && context.headSha !== "") { + const against = + context.baseRef === undefined || context.baseRef === "" + ? "" + : ` against ${code(context.baseRef)}${ + context.mergeBase === undefined || context.mergeBase === "" + ? "" + : ` at ${code(short(context.mergeBase))}` + }`; + parts.push(`Commit ${code(short(context.headSha))}${against}`); + } + if (report !== undefined) parts.push(`${report.runs} ${plural(report.runs, "run", "runs")} each`); + parts.push("report only, never blocks merging"); + return parts.join(" · "); +} + +function frame( + title: string, + body: ReadonlyArray, + context: RenderContext, + report?: BenchReport, +): string { + const footer = + context.target === "comment" ? ["Comment `/bench` to run it again on the latest commit."] : []; + return ( + [ + ...(context.target === "comment" ? [BENCH_MARKER] : []), + `## CLI benchmark: ${title}`, + contextLine(report, context), + ...body, + ...footer, + ].join("\n\n") + "\n" + ); +} + +/** The Markdown report, or `notice` in plain words when there is no report to compare. */ +export function renderSummary( + report: BenchReport | undefined, + notice: string, + context: RenderContext = { target: "summary" }, +): string { + if (report === undefined) return frame("could not run", [notice], context); + const analyses = report.workloads.map((workload) => analyzeWorkload(workload, report.trace)); + const flags = analyses.flatMap((analysis) => + collectFlags(analysis).map((flag) => ({ flag, analysis })), + ); + if (analyses.length === 0) return frame("no commands were benchmarked", [], context, report); + const api = apiOverview(analyses); + const moved = analyses + .filter((analysis) => analysis.verdict === "slower" || analysis.verdict === "faster") + .map((analysis) => analysis.id); + const things = new Set([...flags.map(({ analysis }) => analysis.id), ...moved, ...api.changedIds]) + .size; + const title = + things === 0 ? "no changes found" : `${things} ${plural(things, "thing", "things")} to check`; + return frame( + title, + [ + ...flags.map(({ flag, analysis }) => `⚠️ ${describeFlag(flag, analysis)}`), + timingTable(analyses), + api.line, + sizeLine(report), + [ + "
Details: timing ranges, API calls per command, slowest phases", + "#### Timing ranges", + rangeTable(analyses), + "#### API calls per command", + apiDetailTable(analyses), + "#### Slowest phases", + phaseDetail(analyses), + "
", + ].join("\n\n"), + ], + context, + report, + ); +} + +export const loadReport = Effect.fn("BenchSummary.loadReport")(function* ( + fs: FileSystem.FileSystem, + file: string, +) { + if (!(yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false)))) { + return { report: undefined, notice: NO_REPORT }; + } + return yield* fs.readFileString(file).pipe( + Effect.flatMap(decodeBenchReport), + Effect.map((report) => ({ report, notice: NO_REPORT })), + Effect.catch((cause) => + Console.error(`could not read benchmark report ${file}: ${String(cause)}`).pipe( + Effect.as({ + report: undefined, + notice: UNREADABLE_REPORT, + }), + ), + ), + ); +}); + +const main = Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const argv = yield* stdio.args; + const options = yield* Effect.try({ + try: () => parseSummaryArgs(argv), + catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), + }).pipe( + Effect.catch((message) => + Console.error(message).pipe( + Effect.andThen( + Effect.sync(() => { + process.exitCode = 2; + }), + ), + Effect.as(undefined), + ), + ), + ); + if (options === undefined) return; + + const fs = yield* FileSystem.FileSystem; + const outcomeNotice = summaryNotice(options); + const loaded = + outcomeNotice === undefined && options.report !== undefined + ? yield* loadReport(fs, options.report) + : { report: undefined, notice: outcomeNotice ?? NO_REPORT }; + const context = { + headSha: options.headSha, + baseRef: options.baseRef, + mergeBase: options.mergeBase, + }; + const markdown = renderSummary(loaded.report, loaded.notice, { target: "summary", ...context }); + const summaryFile = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY")); + if (Option.isSome(summaryFile) && summaryFile.value !== "") { + yield* fs.writeFileString(summaryFile.value, markdown, { flag: "a" }); + } else { + yield* Console.log(markdown); + } + + if (options.commentOut !== undefined) { + yield* fs.writeFileString( + options.commentOut, + renderSummary(loaded.report, loaded.notice, { target: "comment", ...context }), + ); + } +}); + +if (import.meta.main) { + BunRuntime.runMain(main.pipe(Effect.provide(BunServices.layer))); +} diff --git a/apps/cli/scripts/bench-summary.unit.test.ts b/apps/cli/scripts/bench-summary.unit.test.ts new file mode 100644 index 0000000000..3d5e369c6a --- /dev/null +++ b/apps/cli/scripts/bench-summary.unit.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; +import type { BenchReport, RunSample } from "./bench-report.ts"; +import { BENCH_MARKER, parseSummaryArgs, renderSummary, summaryNotice } from "./bench-summary.ts"; + +const run = (wallMs: number, calls: number, endpoints: Record): RunSample => ({ + wallMs, + exitCode: 0, + tracedExitCode: 0, + trace: { + skippedLines: 0, + spans: { "cli.run": { totalMs: wallMs / 2, count: 1 } }, + api: { apiCalls: calls, externalCalls: 0, errorStatuses: 0, endpoints, requestLogCount: calls }, + }, +}); + +const runs = (n: number, make: (i: number) => RunSample) => + Array.from({ length: n }, (_, i) => make(i)); + +const report = (workloads: BenchReport["workloads"]): BenchReport => ({ + base: "/base/supabase", + branch: "/branch/supabase", + baseSizeBytes: 1000, + branchSizeBytes: 1500, + runs: 5, + warmup: 1, + trace: true, + apiUrl: "http://127.0.0.1:1", + workloads, +}); + +const steady = (id: string, args: ReadonlyArray) => ({ + id, + args, + base: runs(5, (i) => run(30 + i, 0, {})), + branch: runs(5, (i) => run(30 + i, 0, {})), +}); + +const migrationNew = { + id: "migration-new", + args: ["migration", "new", "bench"], + base: runs(5, (i) => run(50 + i, 0, {})), + branch: runs(5, (i) => run(120 + i, 1, { "GET /v1/projects/abc": 1 })), +}; + +describe("renderSummary", () => { + it("leads with no changes found and shows commands, not workload ids", () => { + const sized = { + ...report([steady("version", ["--version"])]), + baseSizeBytes: 140269744, + branchSizeBytes: 140269744, + }; + const markdown = renderSummary(sized, "unused", { + target: "summary", + headSha: "abcdef1234567890", + baseRef: "develop", + mergeBase: "1234567890abcdef", + }); + + expect(markdown).toContain("## CLI benchmark: no changes found"); + expect(markdown).toContain( + "Commit `abcdef1` against `develop` at `1234567` · 5 runs each · report only, never blocks merging", + ); + expect(markdown).toContain("| `supabase --version` | 32 ms | 32 ms | no real change |"); + expect(markdown).toContain("API calls: unchanged for the command."); + expect(markdown).toContain("Binary size: 133.8 MB, unchanged."); + expect(markdown).not.toContain("⚠️"); + }); + + it("shows a binary size change in MB, or KB when it is small", () => { + const grown = { + ...report([steady("version", ["--version"])]), + baseSizeBytes: 140269744, + branchSizeBytes: 140269744 + 3 * 1048576, + }; + const nudged = { ...grown, branchSizeBytes: 140269744 + 2048 }; + + expect(renderSummary(grown, "unused")).toContain( + "Binary size: 136.8 MB, up 3.0 MB from 133.8 MB.", + ); + expect(renderSummary(nudged, "unused")).toContain("up 2.0 KB from 133.8 MB"); + }); + + it("explains a new API call in plain words and counts each command once", () => { + const markdown = renderSummary(report([migrationNew]), "unused"); + + expect(markdown).toContain("## CLI benchmark: 1 thing to check"); + expect(markdown).toContain( + "⚠️ `supabase migration new bench` now calls the Management API. It made no calls before and now makes 1 (`GET /v1/projects/abc`).", + ); + expect(markdown).toContain("slower by 70 ms (+135%)"); + }); + + it("keeps command text from the benchmarked build inside code spans", () => { + const markdown = renderSummary(report([steady("x", ["a`@someone`", "b|c"])]), "unused"); + + expect(markdown).toContain("`supabase a'@someone' b\\|c`"); + }); + + it("says nothing was benchmarked instead of a clean pass when there are no commands", () => { + const markdown = renderSummary(report([]), "unused"); + + expect(markdown).toContain("## CLI benchmark: no commands were benchmarked"); + expect(markdown).not.toContain("no changes found"); + }); + + it("describes a dropped API call with a singular count", () => { + const dropped = { + id: "list", + args: ["projects", "list"], + base: runs(5, (i) => run(30 + i, 1, { "GET /v1/projects": 1 })), + branch: runs(5, (i) => run(30 + i, 0, {})), + }; + + const markdown = renderSummary(report([dropped]), "unused"); + + expect(markdown).toContain( + "⚠️ `supabase projects list` no longer calls the Management API. It made 1 call before and now makes none.", + ); + }); + + it("names the failed step and stays a notice when there is no report", () => { + const markdown = renderSummary(undefined, "The build of this commit failed.", { + target: "summary", + }); + + expect(markdown).toContain("## CLI benchmark: could not run"); + expect(markdown).toContain("The build of this commit failed."); + expect(markdown).not.toContain("/bench"); + }); + + it("adds the marker, re-run hint only to the comment", () => { + const input = report([steady("version", ["--version"])]); + const context = { headSha: "abcdef1234" } as const; + + const comment = renderSummary(input, "unused", { target: "comment", ...context }); + const summary = renderSummary(input, "unused", { target: "summary", ...context }); + + expect(BENCH_MARKER).toBe(""); + expect(comment.startsWith(`${BENCH_MARKER}\n`)).toBe(true); + expect(comment).toContain("Comment `/bench` to run it again on the latest commit."); + expect(summary).not.toContain(BENCH_MARKER); + expect(summary).not.toContain("/bench"); + }); + + it("does not claim a missing timing verdict when commands were not comparable", () => { + const failing = (wallMs: number): RunSample => ({ ...run(wallMs, 0, {}), exitCode: 1 }); + + const markdown = renderSummary( + report([ + { + id: "broken", + args: ["x"], + base: runs(5, (i) => run(30 + i, 0, {})), + branch: runs(5, (i) => failing(30 + i)), + }, + ]), + "unused", + ); + + expect(markdown).toContain("No phase deltas are available; some commands were not comparable."); + expect(markdown).toContain("not compared"); + }); + + it("says API calls were not counted when no call data exists", () => { + const noTrace = (wallMs: number): RunSample => ({ wallMs, exitCode: 0 }); + const markdown = renderSummary( + { + ...report([ + { + id: "a", + args: ["x"], + base: runs(5, (i) => noTrace(30 + i)), + branch: runs(5, (i) => noTrace(30 + i)), + }, + ]), + trace: false, + }, + "unused", + ); + + expect(markdown).toContain("API calls were not counted."); + }); +}); + +describe("summaryNotice", () => { + const ok = { branchBuild: "ok", baseBuild: "ok", replay: "ok" } as const; + + it("is undefined when every step succeeded", () => { + expect(summaryNotice(ok)).toBeUndefined(); + }); + + it.each([ + { failed: { branchBuild: "failed" }, expected: "build of this commit failed" }, + { failed: { baseBuild: "failed" }, expected: "build of the base branch failed" }, + { failed: { replay: "failed" }, expected: "test server failed to start" }, + ] as const)("names the failed step: $expected", ({ failed, expected }) => { + expect(summaryNotice({ ...ok, ...failed })).toContain(expected); + }); + + it("reports the earliest failed step when later ones were skipped", () => { + expect( + summaryNotice({ branchBuild: "failed", baseBuild: "failed", replay: "failed" }), + ).toContain("build of this commit failed"); + }); +}); + +describe("parseSummaryArgs", () => { + it("rejects an unknown step outcome", () => { + expect(() => parseSummaryArgs(["--report", "r.json", "--replay", "maybe"])).toThrow("--replay"); + }); + + it("needs a report unless a step failed", () => { + expect(() => parseSummaryArgs([])).toThrow(); + expect(parseSummaryArgs(["--replay", "failed"])).toMatchObject({ + report: undefined, + branchBuild: "ok", + baseBuild: "ok", + replay: "failed", + }); + expect( + parseSummaryArgs(["--report", "r.json", "--head-sha", "abc", "--comment-out", "c.md"]), + ).toMatchObject({ report: "r.json", replay: "ok", headSha: "abc", commentOut: "c.md" }); + }); +}); diff --git a/apps/cli/scripts/bench-workloads.unit.test.ts b/apps/cli/scripts/bench-workloads.unit.test.ts new file mode 100644 index 0000000000..403ce9664f --- /dev/null +++ b/apps/cli/scripts/bench-workloads.unit.test.ts @@ -0,0 +1,39 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; +import { parseWorkloadManifest, validateWorkloads } from "./bench-report.ts"; + +const manifestPath = new URL("../bench/workloads.pr.json", import.meta.url).pathname; +const scenariosDir = new URL("../../cli-e2e/fixtures/scenarios/", import.meta.url).pathname; + +const loadManifest = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* parseWorkloadManifest(yield* fs.readFileString(manifestPath)); +}); + +describe("PR workload manifest", () => { + it.effect("decodes and passes the structural rules when a replay server is configured", () => + Effect.gen(function* () { + const workloads = yield* loadManifest; + + expect(workloads.length).toBeGreaterThan(0); + expect(validateWorkloads(workloads, { apiUrl: "http://127.0.0.1:1" })).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("names only replay scenarios that exist and serve the project ref it uses", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workloads = yield* loadManifest; + + for (const workload of workloads) { + if (workload.scenario === undefined) continue; + const fixture = yield* fs.readFileString( + `${scenariosDir}${workload.scenario}/interactions.json`, + ); + const ref = workload.args.find((arg) => arg.startsWith("replay")); + if (ref !== undefined) expect(fixture).toContain(`/v1/projects/${ref}`); + } + }).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/apps/cli/scripts/replay-control.integration.test.ts b/apps/cli/scripts/replay-control.integration.test.ts new file mode 100644 index 0000000000..ee393ead1e --- /dev/null +++ b/apps/cli/scripts/replay-control.integration.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { FetchHttpClient } from "effect/http"; +import * as HttpClient from "effect/http/HttpClient"; +import { startReplayServer } from "../../cli-e2e/src/server/replay-server.ts"; +import { readRequestLogCount, resetReplay } from "./replay-control.ts"; + +const fixturesDir = new URL("../../cli-e2e/fixtures", import.meta.url).pathname; +const SCENARIO = "projects-list-renders-project-list"; + +const replayServer = Effect.acquireRelease( + Effect.promise(() => startReplayServer({ fixturesDir })), + (server) => Effect.promise(() => server.stop()), +); + +const callApi = (url: string, path: string) => + HttpClient.HttpClient.use((client) => client.get(new URL(path, url).toString())); + +describe("replay control against the replay server", () => { + it.effect("serves a consumed scenario again and starts each launch with an empty log", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* replayServer; + + yield* resetReplay(server.url, SCENARIO); + const first = yield* callApi(server.url, "/v1/projects"); + const logged = yield* readRequestLogCount(server.url); + + yield* resetReplay(server.url, SCENARIO); + const cleared = yield* readRequestLogCount(server.url); + const second = yield* callApi(server.url, "/v1/projects"); + + expect([first.status, second.status]).toEqual([200, 200]); + expect(logged).toBe(1); + expect(cleared).toBe(0); + }), + ).pipe(Effect.provide(FetchHttpClient.layer)), + ); + + it.effect("fails when the scenario does not exist instead of keeping the previous one", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* replayServer; + + const failure = yield* resetReplay(server.url, "no-such-scenario").pipe(Effect.flip); + + expect(failure._tag).toBe("ReplayControlError"); + expect(failure.step).toBe("POST /_ctrl/scenario"); + }), + ).pipe(Effect.provide(FetchHttpClient.layer)), + ); + + it.effect("fails when the server is unreachable", () => + Effect.gen(function* () { + const failure = yield* resetReplay("http://127.0.0.1:1", undefined).pipe(Effect.flip); + + expect(failure._tag).toBe("ReplayControlError"); + }).pipe(Effect.provide(FetchHttpClient.layer)), + ); +}); diff --git a/apps/cli/scripts/replay-control.ts b/apps/cli/scripts/replay-control.ts new file mode 100644 index 0000000000..b8b72e052b --- /dev/null +++ b/apps/cli/scripts/replay-control.ts @@ -0,0 +1,80 @@ +/** + * Client for the replay server's `/_ctrl/*` HTTP API, used by `bench-cli.ts` to start each launch + * from a known server state. Any failed control call aborts the benchmark, because a launch that + * runs against stale or missing scenario state measures the wrong work. + */ +import { Effect, Schema } from "effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +export class ReplayControlError extends Schema.TaggedError()( + "ReplayControlError", + { step: Schema.String, message: Schema.String }, +) {} + +export interface ControlCall { + readonly method: "DELETE" | "POST"; + readonly path: string; + readonly body?: { readonly name: string }; +} + +/** The calls that clear the request log and select `scenario`, or no scenario when it is absent. */ +export function controlPlan(scenario: string | undefined): ReadonlyArray { + return [ + { method: "DELETE", path: "/_ctrl/requests" }, + { method: "DELETE", path: "/_ctrl/scenario" }, + ...(scenario === undefined + ? [] + : [{ method: "POST", path: "/_ctrl/scenario", body: { name: scenario } } as const]), + ]; +} + +const controlUrl = (apiUrl: string, path: string) => new URL(path, apiUrl).toString(); + +const failure = (step: string) => (cause: unknown) => + new ReplayControlError({ + step, + message: `${step} failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }); + +const send = Effect.fn("ReplayControl.send")(function* ( + apiUrl: string, + method: "GET" | "DELETE" | "POST", + path: string, + body?: { readonly name: string }, +) { + const step = `${method} ${path}`; + const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk); + const base = + method === "GET" + ? HttpClientRequest.get(controlUrl(apiUrl, path)) + : method === "DELETE" + ? HttpClientRequest.delete(controlUrl(apiUrl, path)) + : HttpClientRequest.post(controlUrl(apiUrl, path)); + const request = body === undefined ? base : HttpClientRequest.bodyJsonUnsafe(base, body); + return yield* client.execute(request).pipe(Effect.mapError(failure(step))); +}); + +/** Resets the request log and scenario so the next launch sees a freshly loaded scenario. */ +export const resetReplay = Effect.fn("ReplayControl.reset")(function* ( + apiUrl: string, + scenario: string | undefined, +) { + for (const call of controlPlan(scenario)) { + yield* send(apiUrl, call.method, call.path, call.body); + } +}); + +const RequestLog = Schema.Array(Schema.Struct({ method: Schema.String, pathname: Schema.String })); + +/** Number of API requests the server has logged since the last reset. */ +export const readRequestLogCount = Effect.fn("ReplayControl.readRequestLogCount")(function* ( + apiUrl: string, +) { + const response = yield* send(apiUrl, "GET", "/_ctrl/requests"); + const json = yield* HttpClientResponse.schemaBodyJson(RequestLog)(response).pipe( + Effect.mapError(failure("GET /_ctrl/requests body")), + ); + return json.length; +}); diff --git a/apps/cli/scripts/trace-report.ts b/apps/cli/scripts/trace-report.ts index a16430839c..41cc789ece 100644 --- a/apps/cli/scripts/trace-report.ts +++ b/apps/cli/scripts/trace-report.ts @@ -8,13 +8,23 @@ import { Console, Effect, FileSystem, Schema, Stdio } from "effect"; const KeyValues = Schema.Array( Schema.Struct({ key: Schema.String, - value: Schema.Struct({ stringValue: Schema.optional(Schema.String) }), + value: Schema.Struct({ + stringValue: Schema.optional(Schema.String), + // OTLP/JSON encodes 64-bit integers as strings; this exporter writes numbers. + intValue: Schema.optional(Schema.Union([Schema.Finite, Schema.String])), + }), }), ); const stringAttribute = (attributes: typeof KeyValues.Type | undefined, key: string) => attributes?.find((attribute) => attribute.key === key)?.value.stringValue; +const intAttribute = (attributes: typeof KeyValues.Type | undefined, key: string) => { + const value = attributes?.find((attribute) => attribute.key === key)?.value.intValue; + const number = value === undefined ? Number.NaN : Number(value); + return Number.isInteger(number) ? number : undefined; +}; + /** Labels container CLI spans with their verb so `pull` and `run` report separately. */ export function reportSpanName(name: string, subcommand: string | undefined): string { return subcommand === undefined ? name : `${name} (${subcommand})`; @@ -47,6 +57,15 @@ const TraceBatch = Schema.fromJsonString( const ReportJson = Schema.fromJsonString(Schema.Unknown, { space: 2 }); +/** Attributes of an HTTP client span; `path` has no query string. */ +export interface HttpSpanAttributes { + readonly method: string; + readonly path: string | undefined; + readonly address: string | undefined; + readonly port: number | undefined; + readonly status: number | undefined; +} + export interface ReportSpan { readonly traceId: string; readonly spanId: string; @@ -56,6 +75,8 @@ export interface ReportSpan { readonly endMs: number; readonly failed: boolean; readonly errorType: string | undefined; + /** Present only on HTTP client spans. */ + readonly http?: HttpSpanAttributes; } export interface TraceReport { @@ -74,6 +95,19 @@ export interface TraceReport { } const STATUS_ERROR = 2; + +function httpAttributes(attributes: typeof KeyValues.Type | undefined) { + const method = stringAttribute(attributes, "http.request.method"); + if (method === undefined) return undefined; + return { + method, + path: stringAttribute(attributes, "url.path")?.split("?")[0], + address: stringAttribute(attributes, "server.address"), + port: intAttribute(attributes, "server.port"), + status: intAttribute(attributes, "http.response.status_code"), + } satisfies HttpSpanAttributes; +} + const nanosToMs = (value: string) => Number(BigInt(value) / 1_000n) / 1_000; const round = (value: number) => Math.round(value * 100) / 100; @@ -192,6 +226,7 @@ export const readSpans = Effect.fnUntraced(function* (file: string) { for (const resource of batch.resourceSpans) { for (const scope of resource.scopeSpans) { for (const span of scope.spans) { + const http = httpAttributes(span.attributes); spans.push({ traceId: span.traceId, spanId: span.spanId, @@ -204,6 +239,7 @@ export const readSpans = Effect.fnUntraced(function* (file: string) { span.events?.find((event) => event.name === "exception")?.attributes, "exception.type", ), + ...(http === undefined ? {} : { http }), }); } } diff --git a/apps/cli/scripts/trace-report.unit.test.ts b/apps/cli/scripts/trace-report.unit.test.ts index c2fff6938e..b9195f141f 100644 --- a/apps/cli/scripts/trace-report.unit.test.ts +++ b/apps/cli/scripts/trace-report.unit.test.ts @@ -149,6 +149,74 @@ describe("readSpans", () => { }); }); +describe("readSpans HTTP attributes", () => { + const fixture = new URL("./__fixtures__/api-call-trace.jsonl", import.meta.url).pathname; + + it.effect("reads method, path, host, port and status from traces of both CLI generations", () => + Effect.gen(function* () { + const { spans, skippedLines } = yield* readSpans(fixture); + const http = spans.flatMap((span) => (span.http === undefined ? [] : [span.http])); + + expect(skippedLines).toBe(0); + expect(http).toHaveLength(2); + for (const call of http) { + expect(call).toEqual({ + method: "GET", + path: "/v1/projects", + address: "127.0.0.1", + port: 62824, + status: 200, + }); + } + expect(spans.find((span) => span.name === "cli.run")?.http).toBeUndefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("accepts OTLP string-encoded integers and drops the query string from the path", () => { + const batch = JSON.stringify({ + resourceSpans: [ + { + scopeSpans: [ + { + spans: [ + { + traceId: "t", + spanId: "s", + name: "POST", + startTimeUnixNano: "0", + endTimeUnixNano: "1000000", + status: { code: 1 }, + attributes: [ + { key: "http.request.method", value: { stringValue: "POST" } }, + { key: "url.path", value: { stringValue: "/v1/x?limit=1" } }, + { key: "server.port", value: { intValue: "8443" } }, + { key: "http.response.status_code", value: { intValue: "503" } }, + ], + }, + ], + }, + ], + }, + ], + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = yield* fs.makeTempFileScoped({ suffix: ".jsonl" }); + yield* fs.writeFileString(file, `${batch}\n`); + const { spans } = yield* readSpans(file); + + expect(spans[0]?.http).toEqual({ + method: "POST", + path: "/v1/x", + address: undefined, + port: 8443, + status: 503, + }); + }).pipe(Effect.provide(BunServices.layer)); + }); +}); + describe("selectTrace", () => { const twoRuns = [ { ...span("a1", undefined, "cli.run", 0, 100), traceId: "first" }, From 4834c89de9d518d20131e4deb1cd7d36af96f5f6 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:49:53 +0000 Subject: [PATCH 14/22] chore(stack): bump postgres to 15.19.0.011-r0 (#7392) This bumps postgres from 15.19.0.008-r0 to 15.19.0.011-r0. https://github.com/supabase/slim-services/releases/tag/postgres-15.19.0.011-r0 Planned after supabase/slim-services published 15.19.0.011-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 4e24d87d41..9cee83d38c 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -7,7 +7,7 @@ # adjacent lines, and catalog PRs for neighbouring aliases are often open at the same time. FROM supabase/postgres:17.11.0.008 AS pg -FROM supabase/postgres:15.19.0.008 AS pg15 +FROM supabase/postgres:15.19.0.011 AS pg15 # Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). FROM supabase/postgres:14.1.0.89 AS pg14 # New always-on service alias: extend SERVICE_IMAGE_ALIASES in diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 4e24d87d41..9cee83d38c 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -7,7 +7,7 @@ # adjacent lines, and catalog PRs for neighbouring aliases are often open at the same time. FROM supabase/postgres:17.11.0.008 AS pg -FROM supabase/postgres:15.19.0.008 AS pg15 +FROM supabase/postgres:15.19.0.011 AS pg15 # Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). FROM supabase/postgres:14.1.0.89 AS pg14 # New always-on service alias: extend SERVICE_IMAGE_ALIASES in diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 28fa26f4e6..dc961d06f6 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -157,24 +157,24 @@ const definitions: Readonly> = { "bin/psql", ], { - "15.19.0.008": { - upstreamVersion: "15.19.0.008", + "15.19.0.011": { + upstreamVersion: "15.19.0.011", revision: 0, image: - "ghcr.io/supabase/cli/postgres:15.19.0.008-r0@sha256:a3d0932e880417c1c729c9344febd787d6e5b810e53a563cb8752b6467cf8fdf", - upstreamImage: "supabase/postgres:15.19.0.008", + "ghcr.io/supabase/cli/postgres:15.19.0.011-r0@sha256:867906556504e326ae1ea67efb61d817aa5c071c6a0b366f7c8da20b5a269eab", + upstreamImage: "supabase/postgres:15.19.0.011", natives: { "darwin-arm64": { - archive: "32bb35483a40d11797fab1d17fd454d01bbeaf1c235d18547890766f73003a1b", - manifest: "4c4d840eda2beaaf5ae0f0607ae34243b949762a0f32bd1a32aef3db16acac73", + archive: "0a6cdf221771ee7e110d5d07433a38151134391634c986a19f7b8688eaa80f0c", + manifest: "89f598712ea2038c8a232842ea54909c729b44c22d429c5afdfc79b6734513c3", }, "linux-amd64": { - archive: "1b116cf465d4331b982e814c78e3c871bcd8de834540bdf1be263280e1721402", - manifest: "226724e98aff3f0ceffc9af0b0906f030612244cd07da41ba71c46dd2295f87c", + archive: "18c110a3977c155393bdcbf8e3bd11d65b8f3525890289cc75ac6136bb8b1af1", + manifest: "2454007798b77b086709ab4c1ef802ab545feb5150d6b2c37a2eb5c3dd720873", }, "linux-arm64": { - archive: "dc10516c0969357f8c877b981063696c493f701df26a32ac12dbce921ec3da7f", - manifest: "2cc1196fc4c31b79fe2ca2529ba6b42794e7440c30f9213aa9b31129a02afb7c", + archive: "74ee731a86e350ee4a48e18b7bb7cdd39133f0a1f1910ac5588287b0436ed350", + manifest: "37afcfc17233af73ae2a4fb491c58d121280b4341af4554ab4d568adc3132442", }, }, }, From 6bcada753a64a6ca0618e9e1eec66eb572dfbc48 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:49:55 +0000 Subject: [PATCH 15/22] chore(stack): bump postgres to 17.11.0.011-r0 (#7391) This bumps postgres from 17.11.0.008-r0 to 17.11.0.011-r0. https://github.com/supabase/slim-services/releases/tag/postgres-17.11.0.011-r0 Planned after supabase/slim-services published 15.19.0.011-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 16 ++++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 9cee83d38c..40285169d4 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -5,7 +5,7 @@ # a catalog update. Everything else in this file (this comment, kong, pg14, the one-shot job # images) is hand- or Dependabot-managed. Keep a line between image lines: git conflicts on edits to # adjacent lines, and catalog PRs for neighbouring aliases are often open at the same time. -FROM supabase/postgres:17.11.0.008 AS pg +FROM supabase/postgres:17.11.0.011 AS pg FROM supabase/postgres:15.19.0.011 AS pg15 # Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 9cee83d38c..40285169d4 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -5,7 +5,7 @@ # a catalog update. Everything else in this file (this comment, kong, pg14, the one-shot job # images) is hand- or Dependabot-managed. Keep a line between image lines: git conflicts on edits to # adjacent lines, and catalog PRs for neighbouring aliases are often open at the same time. -FROM supabase/postgres:17.11.0.008 AS pg +FROM supabase/postgres:17.11.0.011 AS pg FROM supabase/postgres:15.19.0.011 AS pg15 # Postgres 14 has no slim build; bumped by hand (Dependabot ignores supabase/postgres). diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index dc961d06f6..2b7944505e 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -127,23 +127,23 @@ const definitions: Readonly> = { database: definition( "postgres", { - upstreamVersion: "17.11.0.008", + upstreamVersion: "17.11.0.011", revision: 0, image: - "ghcr.io/supabase/cli/postgres:17.11.0.008-r0@sha256:c392725424414f5c3487b44042d97adceed262b200fb89c5ce011b7a533556a2", - upstreamImage: "supabase/postgres:17.11.0.008", + "ghcr.io/supabase/cli/postgres:17.11.0.011-r0@sha256:9a0eae54eb663be4b8a021382e9d290eb1960398d3b9825856b81d6944c48834", + upstreamImage: "supabase/postgres:17.11.0.011", natives: { "darwin-arm64": { archive: "de01b042eb3da72a3bce4b00b8f07e4b22545d9a2aad83d5b312918eadb39f44", - manifest: "39dea156823ccc497518ebef8954018c7b6c0956b313980c4d20aeab0aae55ed", + manifest: "8298f20dac793bb997a3d0110bca2f7296ed038d18ded274a43317360955cbaf", }, "linux-amd64": { - archive: "e61c16149f9c24cabbd2fe5b1318b1b92351cc4c175a2d27207fc18e4c4d7cac", - manifest: "5d8d54557d949272817d50314f26cd03ab0a2c6e4c8f88b2a9d5314a3148af6b", + archive: "986389e7cdfc81e30c5b2f8e3e15bca98377374c1124b80d1740d0035e657128", + manifest: "10ba0c16e8c5160be1dc7cf4e3698e661c8bd3bf7353b6f78e7022d9cf6a5591", }, "linux-arm64": { - archive: "abd73beb155fbebba4fe7c6cb0ef927236de144f21740a157af1d57d34dce221", - manifest: "b9ffb160dccce6c207d722c498fc4dde174ea7e678b3ba156fe9c2607036d07f", + archive: "0baab2d07d1437c640aeed7cf60a851c1f81cb85426c8fe5ebfb7ac6ee06dbd0", + manifest: "ae8de4c02229ac55f5db9edd54cb01a674a3d06a858187245af97353707f4556", }, }, }, From 3a9e1fb8f0bbb7bb24794403e1e1d9645d8e9240 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:50:14 +0000 Subject: [PATCH 16/22] chore(stack): add postgres 17.11.0.011-orioledb-r0 (#7393) This adds postgres 17.11.0.011-orioledb-r0 as a new release line. https://github.com/supabase/slim-services/releases/tag/postgres-17.11.0.011-orioledb-r0 Planned after supabase/slim-services published 15.19.0.011-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- packages/stack/src/Artifacts.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 2b7944505e..728c96f0dd 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -283,6 +283,27 @@ const definitions: Readonly> = { }, }, }, + "17.11.0.011-orioledb": { + upstreamVersion: "17.11.0.011-orioledb", + revision: 0, + image: + "ghcr.io/supabase/cli/postgres:17.11.0.011-orioledb-r0@sha256:4fd10c449c500bac93c5b481e5fd1243a88cd5e73dccff114c07502ca6efc903", + upstreamImage: "supabase/postgres:17.11.0.011-orioledb", + natives: { + "darwin-arm64": { + archive: "9bfcbf53f022134a7cf59118614d76f58f99601024c7da1dcc02c469e20e0dce", + manifest: "051a0903da4a114c506ccb5aa3882226fccc696399b9bdc0fa319b7673d430c0", + }, + "linux-amd64": { + archive: "dc1b6ed700c84b487348885a0a30ebaf9d1a51d273ca3fa61f52ab7c5020a7d8", + manifest: "fe7de179c5c0557239fcef08afbcc2d881ba440e0241db211d7de39af010f908", + }, + "linux-arm64": { + archive: "42140929c1fb386fbbb80af2d1fba005990c85f5e25434949ae0d5011ea7ecf6", + manifest: "d6bc300af6ded866b47c945cb65d861f99c607b9d7c703710e75e32b9fd9c9ab", + }, + }, + }, }, ), rest: definition( From b73be06e522f3d99668ae6a3972dfbd1f16fe2c7 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 29 Oct 2026 15:03:11 +0000 Subject: [PATCH 17/22] fix(cli): lint config-exposed schemas in local db advisors (CLI-2824) (#7379) ## TL;DR `supabase db advisors --local` / `--db-url` now lints the schemas that `[api]` in `config.toml` exposes, instead of whatever `pgrst.db_schemas` value the connection happens to carry. ## What's fixed? Splinter decides which schemas to lint by reading `pgrst.db_schemas`. The CLI never set it, so: - When it was unset, splinter fell back to `public`, and tables without RLS in other exposed schemas were never reported. - After any transaction on a backend had set it locally, the backend read `''` from then on. A pooled connection could then make `db advisors` report "No issues found" even with unprotected `public` tables. ## How? - `readDbToml` resolves `exposedSchemas` from `[api].schemas` and `[api].enabled` (empty when the Data API is off), with the `SUPABASE_API_SCHEMAS` / `SUPABASE_API_ENABLED` overrides and `[remotes.*]` precedence that `supabase start` uses. `DbConfigResolver` passes it on the `--local` and `--db-url` paths. - `db advisors` runs `select set_config('pgrst.db_schemas', $1, true)` inside its existing rollback transaction, before the lint setup. A Data API that is off sets `''`, so nothing is reported as exposed. - The `db query` agent-mode RLS advisory binds the same schema list as `$1::text[]` instead of reading the setting, and is skipped when the Data API is off. - `--linked` is unchanged; the platform sets the value for hosted projects. Behavior change: a malformed `SUPABASE_API_ENABLED` now fails config loading for commands that resolve the local database config, matching how `SUPABASE_AUTH_ENABLED` and `supabase start` already treat it. ## ref: - CLI-2824 --- .../db-config.integration.test.ts | 22 ++++ .../src/command-internal/db-config.layer.ts | 8 +- .../command-internal/db-config.toml-read.ts | 15 +++ .../db-config.toml-read.unit.test.ts | 59 ++++++++++ .../src/command-internal/db-config.types.ts | 2 + .../src/commands/db/advisors/SIDE_EFFECTS.md | 24 ++-- .../commands/db/advisors/advisors.handler.ts | 22 +++- .../db/advisors/advisors.integration.test.ts | 84 ++++++++++++- .../db/advisors/advisors.live.test.ts | 110 +++++++++++++++++- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 16 +-- .../src/commands/db/query/query.advisory.ts | 10 +- .../src/commands/db/query/query.handler.ts | 21 ++-- .../db/query/query.integration.test.ts | 59 +++++++--- ...eclarative.orchestrate.integration.test.ts | 1 + ...elta-engine.next.layer.integration.test.ts | 1 + ...elta-next-shadow.stack.integration.test.ts | 1 + 16 files changed, 404 insertions(+), 51 deletions(-) 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 338634959e..794e14c1f7 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -223,6 +223,17 @@ const assertDbConfigDrift = ( }); describe("dbConfigResolver (local + db-url)", () => { + it.effect.each([ + { name: "local", flags: localFlags }, + { name: "db-url", flags: dbUrlFlags("postgres://alice:password@example.com:6543/appdb") }, + ])("resolves Data API exposure for $name", ({ flags }) => + Effect.gen(function* () { + const dir = yield* withWorkdir('[api]\nschemas = ["app", "public"]'); + const result = yield* resolve(dir, flags); + expect(result.exposedSchemas).toEqual(["app", "public"]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("local mode: uses 127.0.0.1 with config.toml db.port/password and is local", () => Effect.gen(function* () { const dir = yield* withWorkdir( @@ -560,6 +571,17 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { stackApi: Layer.Layer = projectStackApi(dir, "running"), ) => resolve(dir, dbUrlFlags(url), { stackApi }).pipe(Effect.provide(stackBackendLayer("stack"))); + it.effect("resolves Data API exposure for the stack-backed local database", () => + Effect.gen(function* () { + const dir = yield* withWorkdir('[api]\nschemas = ["app", "public"]'); + const resolved = yield* resolve(dir, localFlags, { + projectStateMode: "stack", + stackApi: projectStackApi(dir, "running"), + }).pipe(Effect.provide(stackBackendLayer("stack"))); + expect(resolved.exposedSchemas).toEqual(["app", "public"]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("treats the url printed by `stack status --env` as the local database", () => Effect.gen(function* () { const resolved = yield* resolveOnStack(yield* withWorkdir(), stackUrl(STACK_SQL_PORT)); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index ee59d1bdc8..457007e4c9 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -705,6 +705,7 @@ export const dbConfigResolverLayer = Layer.effect( conn: isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, isLocal, + exposedSchemas: tomlValues.exposedSchemas, }; } @@ -778,7 +779,11 @@ export const dbConfigResolverLayer = Layer.effect( }); const backend = yield* currentStackBackend; if (backend.kind === "stack") { - return { conn: yield* stackDatabaseConn, isLocal: true }; + return { + conn: yield* stackDatabaseConn, + isLocal: true, + exposedSchemas: tomlValues.exposedSchemas, + }; } return { conn: { @@ -789,6 +794,7 @@ export const dbConfigResolverLayer = Layer.effect( database: "postgres", }, isLocal: true, + exposedSchemas: tomlValues.exposedSchemas, }; }); 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 bb1436db72..23049614da 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -54,6 +54,8 @@ export interface DbTomlValues { */ readonly envLookup: (name: EnvOverrideName) => string | undefined; readonly apiSchemas: ReadonlyArray; + /** Schemas the Data API serves; empty when `[api].enabled` is false. */ + readonly exposedSchemas: ReadonlyArray; /** `[db] port`, default 54322 (`packages/config/src/db.ts`). */ readonly port: number; /** `[db] shadow_port`, default 54320. */ @@ -1066,6 +1068,7 @@ const ENV_OVERRIDE_NAMES = [ "SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", "SUPABASE_API_AUTO_EXPOSE_NEW_TABLES", "SUPABASE_API_SCHEMAS", + "SUPABASE_API_ENABLED", "SUPABASE_EXPERIMENTAL_PG_DELTA", ] as const; @@ -2187,10 +2190,22 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }); } + const apiEnabled = yield* resolveBoolOrFail( + "api.enabled", + apiRaw?.["enabled"], + true, + lookup, + remoteWins("api.enabled") ? undefined : envOverride("SUPABASE_API_ENABLED"), + ); + const exposedSchemas = apiEnabled + ? [...new Set(apiSchemas.map((schema) => schema.trim()).filter((schema) => schema.length > 0))] + : []; + const values: DbTomlValues = { projectEnv, envLookup: envOverride, apiSchemas, + exposedSchemas, port, shadowPort, password: passwordRaw !== undefined ? expandEnv(passwordRaw, lookup) : DEFAULT_PASSWORD, 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 7c15a71d5c..81bb108452 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 @@ -2990,3 +2990,62 @@ describe("readDbToml OrioleDB telemetry", () => { ); }); }); + +describe("readDbToml Data API exposure", () => { + it.effect.each([ + { + name: "disabled in config", + config: '[api]\nenabled = false\nschemas = ["app"]', + env: { SUPABASE_API_ENABLED: "" }, + expected: [], + }, + { + name: "disabled by environment", + config: "[api]\nenabled = true", + env: { SUPABASE_API_ENABLED: "false" }, + expected: [], + }, + { + name: "trimmed and deduplicated", + config: '[api]\nschemas = ["app", " public", "app"]', + env: { SUPABASE_API_ENABLED: "" }, + expected: ["app", "public"], + }, + ])("resolves exposed schemas: $name", ({ config, env, expected }) => + withConfig(config).pipe( + Effect.flatMap(read), + Effect.tap((values) => Effect.sync(() => expect(values.exposedSchemas).toEqual(expected))), + withEnv(env), + ), + ); + + it.effect("an explicit remote api.enabled beats SUPABASE_API_ENABLED", () => { + const ref = "abcdefghijklmnopqrst"; + return withConfig( + ["[remotes.prod]", `project_id = "${ref}"`, "[remotes.prod.api]", "enabled = false"].join( + "\n", + ), + ).pipe( + Effect.flatMap((dir) => readRef(dir, ref)), + Effect.tap((values) => Effect.sync(() => expect(values.exposedSchemas).toEqual([]))), + withEnv({ SUPABASE_API_ENABLED: "true" }), + ); + }); + + it.effect("rejects malformed SUPABASE_API_ENABLED", () => + withConfig("[api]\nenabled = true").pipe( + Effect.flatMap(read), + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("DbConfigLoadError"); + expect(Cause.pretty(exit.cause)).toContain("invalid api.enabled"); + } + }), + ), + withEnv({ SUPABASE_API_ENABLED: "perhaps" }), + ), + ); +}); diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index 508507dbc8..147066dad2 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -62,6 +62,8 @@ export interface DbConfigFlags { export interface ResolvedDbConfig { readonly conn: PgConnInput; readonly isLocal: boolean; + /** Schemas the local config exposes through the Data API; set on the `--local` and `--db-url` paths. */ + readonly exposedSchemas?: ReadonlyArray; /** * The resolved linked project ref (`--linked` path only; `None` for `--local` / `--db-url`). * Lets the caller re-read config with the ref applied so a matching `[remotes.]` block can diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index a82411d588..dd6a55675b 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -8,7 +8,7 @@ Management API. | Path | Format | When | | -------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ | -| `/supabase/config.toml` | TOML | local / `--db-url` — to resolve the DB connection config | +| `/supabase/config.toml` | TOML | local / `--db-url` — to resolve the DB connection and `[api].enabled` / `[api].schemas` | | `~/.supabase/access-token` | plain text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset (keyring → file) | | `/supabase/.temp/project-ref` | plain text | `--linked` only — to resolve the project ref; skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | | `PGSSL*` PEM, `pg_service.conf` | PEM, libpq | linked: `pg_service.conf` if `PGSERVICE` is set; PEM files only on the pooler temp-role probe; see `db push` | @@ -36,19 +36,21 @@ Issued through the typed client; the generated schema keeps advisor ## Database (`--local` / `--db-url`) -One connection. Within one transaction: `BEGIN` → `set local search_path = ''` +One connection. Within one transaction: `BEGIN` → `set_config('pgrst.db_schemas', , true)` → `set local search_path = ''` (setup half of `templates/lints.sql`) → the multi-CTE lints query → `ROLLBACK`. `--type` filters the resulting rows by category (`SECURITY` / `PERFORMANCE`). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------------------- | ----------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no (keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | linked project ref override (superseded by `--project-ref` when set) | no | -| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | -| `PGHOST` / `PGPORT` / … | connection overrides (local / `--db-url`) | no | -| `PG*` libpq | linked: validated; used only by the temp-role probe; see `db push` | no | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `SUPABASE_API_SCHEMAS` | overrides `[api].schemas` for local / `--db-url` | no | +| `SUPABASE_API_ENABLED` | overrides `[api].enabled`; malformed values fail config loading on every target unless a matching `[remotes.*]` block sets `api.enabled` | no | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no (keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | linked project ref override (superseded by `--project-ref` when set) | no | +| `SUPABASE_PROFILE` | API profile (built-in name or YAML path) | no | +| `PGHOST` / `PGPORT` / … | connection overrides (local / `--db-url`) | no | +| `PG*` libpq | linked: validated; used only by the temp-role probe; see `db push` | no | The API base URL is derived from `SUPABASE_PROFILE`; `SUPABASE_API_URL` is **not** honored (see `command-settings.layer.unit.test.ts`). @@ -101,3 +103,7 @@ the process exits non-zero (no error envelope is written over the payload). equivalent env var simply leaves unused on a non-linked target). - Not-logged-in suggestion: `Run supabase login first.` - Telemetry: only the standard `cli_command_executed` event (no custom events). +- Exposure reflects config on disk, which may differ from a stack started with older config. +- `--db-url` uses the local `[api]` exposure even when the URL points at a remote database, because a raw URL does not identify a project whose Data API settings could be read; align `[api]` (or `SUPABASE_API_SCHEMAS` / `SUPABASE_API_ENABLED`) with the target, or use `--linked`. +- The exposure value reverts at rollback, but the setting stays defined and later reads `''` on that backend. +- `--linked` exposure is set by the platform. diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index 8bf8ddde8e..e52354ff3b 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -61,6 +61,21 @@ const queryLints = Effect.fn("DbAdvisors.queryLints")(function* (session: DbSess return rows.map(scanAdvisorLintRow); }); +const setExposedSchemas = Effect.fn("DbAdvisors.setExposedSchemas")(function* ( + session: DbSession, + schemas: ReadonlyArray, +) { + yield* Effect.annotateCurrentSpan("advisor.exposed_schema_count", schemas.length); + yield* session + .query("select set_config('pgrst.db_schemas', $1, true)", [schemas.join(",")]) + .pipe( + Effect.mapError( + (cause) => + new DbAdvisorsSetupError({ message: `failed to prepare lint session: ${cause.message}` }), + ), + ); +}); + /** Gathers lints from a local (or `--db-url`) database connection. */ const runLocal = Effect.fn("db.advisors.runLocal")(function* ( flags: DbAdvisorsFlags, @@ -94,7 +109,12 @@ const runLocal = Effect.fn("db.advisors.runLocal")(function* ( }), ), ); - return yield* queryLints(session).pipe( + return yield* Effect.gen(function* () { + if (cfg.exposedSchemas !== undefined) { + yield* setExposedSchemas(session, cfg.exposedSchemas); + } + return yield* queryLints(session); + }).pipe( Effect.ensuring( session .exec("rollback") diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 66b3d868bd..45ac0ca27f 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -116,9 +116,11 @@ function mockResolver(opts: { ipv6Error?: boolean } = {}) { function mockConnection(opts: { rows?: ReadonlyArray>; setupFails?: boolean; + exposureFails?: boolean; queryFails?: boolean; }) { const execs: Array = []; + const calls: Array<{ kind: "exec" | "query"; sql: string; params?: ReadonlyArray }> = []; const layer = Layer.succeed(DbConnection, { connect: () => { const session: DbSession = { @@ -128,13 +130,18 @@ function mockConnection(opts: { exec: (sql: string) => Effect.suspend(() => { execs.push(sql); + calls.push({ kind: "exec", sql }); if (sql === SETUP_SQL && opts.setupFails === true) { return Effect.fail(new DbExecError({ message: "syntax error at set" })); } return Effect.void; }), - query: (sql: string) => + query: (sql: string, params?: ReadonlyArray) => Effect.suspend(() => { + calls.push({ kind: "query", sql, params }); + if (sql.includes("pgrst.db_schemas") && opts.exposureFails === true) { + return Effect.fail(new DbExecError({ message: "permission denied" })); + } if (sql === QUERY_SQL && opts.queryFails === true) { return Effect.fail(new DbExecError({ message: "syntax error" })); } @@ -149,6 +156,7 @@ function mockConnection(opts: { }); return { layer, + calls, get execs() { return execs; }, @@ -212,6 +220,7 @@ interface SetupOpts { format?: "text" | "json" | "stream-json"; rows?: ReadonlyArray>; setupFails?: boolean; + exposureFails?: boolean; queryFails?: boolean; loggedIn?: boolean; invalidToken?: boolean; @@ -223,7 +232,7 @@ interface SetupOpts { /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ args?: ReadonlyArray; /** Runs the real `DbConfigResolver` and linked-project cache against the temp `workdir`. */ - realLayers?: { readonly workdir: string }; + realLayers?: { readonly workdir: string; readonly env?: Readonly> }; } function setup(opts: SetupOpts = {}) { @@ -232,6 +241,7 @@ function setup(opts: SetupOpts = {}) { const connection = mockConnection({ rows: opts.rows, setupFails: opts.setupFails, + exposureFails: opts.exposureFails, queryFails: opts.queryFails, }); const telemetry = mockTelemetryStateTracked(); @@ -285,7 +295,7 @@ function setup(opts: SetupOpts = {}) { : realDbConfigResolver({ workdir: opts.realLayers.workdir, dbConnection: connection.layer, - env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN, ...opts.realLayers.env }, }), connection.layer, telemetry.layer, @@ -324,6 +334,55 @@ const flags = (over: Partial = {}): DbAdvisorsFlags => ({ }); describe("db advisors — local", () => { + const tempRoot = useTempWorkdir("supabase-advisors-exposure-"); + it.effect.each([ + { + name: "configured schemas", + config: '[api]\nschemas = ["app", "public"]', + env: { SUPABASE_API_SCHEMAS: "" }, + expected: "app,public", + }, + { + name: "Data API disabled", + config: "[api]\nenabled = false", + env: { SUPABASE_API_SCHEMAS: "" }, + expected: "", + }, + { + name: "environment schemas", + config: '[api]\nschemas = ["public"]', + env: { SUPABASE_API_SCHEMAS: "app,public" }, + expected: "app,public", + }, + { + name: "default schemas", + config: undefined, + env: { SUPABASE_API_SCHEMAS: "" }, + expected: "public,graphql_public", + }, + ])("sets exposure before lint setup: $name", ({ config, env, expected }) => { + const { layer, connection } = setup({ realLayers: { workdir: tempRoot.current, env } }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (config !== undefined) { + yield* fs.makeDirectory(`${tempRoot.current}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${tempRoot.current}/supabase/config.toml`, config); + } + yield* dbAdvisors(flags()); + expect(connection.calls).toEqual([ + { kind: "exec", sql: "begin" }, + { + kind: "query", + sql: "select set_config('pgrst.db_schemas', $1, true)", + params: [expected], + }, + { kind: "exec", sql: SETUP_SQL }, + { kind: "query", sql: QUERY_SQL, params: undefined }, + { kind: "exec", sql: "rollback" }, + ]); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunServices.layer)), provideEnv(env)); + }); + it.effect("queries the local database and prints the Go pretty JSON array", () => { const { layer, out, connection, api } = setup({ rows: [lintRow()] }); return Effect.gen(function* () { @@ -375,6 +434,25 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "rolls back and fails with 'failed to prepare lint session' when exposure fails", + () => { + const env = { SUPABASE_API_SCHEMAS: "" }; + const { layer, connection } = setup({ + exposureFails: true, + realLayers: { workdir: tempRoot.current, env }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(dbAdvisors(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("failed to prepare lint session"); + } + expect(connection.execs).toEqual(["begin", "rollback"]); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunServices.layer)), provideEnv(env)); + }, + ); + it.effect("fails with 'failed to query lints' on a query error", () => { const { layer } = setup({ queryFails: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/advisors/advisors.live.test.ts b/apps/cli/src/commands/db/advisors/advisors.live.test.ts index 45774ad04e..9d6efea7dd 100644 --- a/apps/cli/src/commands/db/advisors/advisors.live.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.live.test.ts @@ -1,6 +1,7 @@ +import { BunServices } from "@effect/platform-bun"; import { randomUUID } from "node:crypto"; -import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Predicate, Schema } from "effect"; import { expect } from "vitest"; import { queryLiveDb, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; @@ -43,3 +44,110 @@ test("reads advisor findings over the database connection", ({ cliEffect, projec }; }), ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); + +test("reports public tables when the backend defaults exposed schemas to empty", ({ + cliEffect, + project, + signal, +}) => + Effect.runPromise( + Effect.gen(function* () { + const suffix = randomUUID().replaceAll("-", ""); + const role = `e2e_adv_${suffix}`; + const table = `e2e_advisors_${suffix}`; + const password = randomUUID(); + const dbUrl = new URL(project.dbUrl); + dbUrl.username = `${role}.${project.ref}`; + dbUrl.password = password; + const target = Effect.gen(function* () { + yield* Effect.promise(() => + queryLiveDb(project.dbUrl, `create role ${role} login password '${password}'`), + ); + yield* Effect.promise(() => + queryLiveDb(project.dbUrl, `alter role ${role} set pgrst.db_schemas = ''`), + ); + yield* Effect.promise(() => + queryLiveDb(project.dbUrl, `create table public.${table} (id int)`), + ); + const exposure = yield* Effect.promise(() => + queryLiveDb(dbUrl.toString(), "select current_setting('pgrst.db_schemas', true) as v"), + ); + expect(exposure).toEqual([{ v: "" }]); + const result = yield* cliEffect(["db", "advisors", "--db-url", dbUrl.toString()]); + expect(result.exitCode, result.stderr).toBe(0); + const findings = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Array(Schema.Unknown)), + )(result.stdout); + expect(findings, result.stdout).toContainEqual( + expect.objectContaining({ + name: "rls_disabled_in_public", + metadata: expect.objectContaining({ schema: "public", name: table }), + }), + ); + }).pipe(Effect.withSpan("AdvisorsLive.emptyBackend")); + const targetExit = yield* Effect.exit(target); + const cleanupExits = [ + yield* Effect.exit( + Effect.promise(() => queryLiveDb(project.dbUrl, `drop table if exists public.${table}`)), + ), + yield* Effect.exit( + Effect.promise(() => queryLiveDb(project.dbUrl, `drop role if exists ${role}`)), + ), + ]; + return { + targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, + cleanupErrors: cleanupExits.filter(Exit.isFailure).map((exit) => Cause.squash(exit.cause)), + }; + }), + { signal }, + ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); + +test("reports tables in a configured non-public exposed schema", ({ + cliEffect, + project, + workspace, + signal, +}) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const schema = `e2e_adv_${randomUUID().replaceAll("-", "")}`; + const table = "unprotected"; + const target = Effect.gen(function* () { + yield* fs.writeFileString( + `${workspace.path}/supabase/config.toml`, + `[api]\nschemas = ["public", "${schema}"]\n`, + ); + yield* Effect.promise(() => queryLiveDb(project.dbUrl, `create schema ${schema}`)); + yield* Effect.promise(() => + queryLiveDb(project.dbUrl, `create table ${schema}.${table} (id int)`), + ); + yield* Effect.promise(() => + queryLiveDb(project.dbUrl, `grant usage on schema ${schema} to anon`), + ); + yield* Effect.promise(() => + queryLiveDb(project.dbUrl, `grant select on table ${schema}.${table} to anon`), + ); + const result = yield* cliEffect(["db", "advisors", "--db-url", project.dbUrl]); + expect(result.exitCode, result.stderr).toBe(0); + const findings = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Array(Schema.Unknown)), + )(result.stdout); + expect(findings, result.stdout).toContainEqual( + expect.objectContaining({ + name: "rls_disabled_in_public", + metadata: expect.objectContaining({ schema, name: table }), + }), + ); + }).pipe(Effect.withSpan("AdvisorsLive.nonPublicSchema")); + const targetExit = yield* Effect.exit(target); + const cleanupExit = yield* Effect.exit( + Effect.promise(() => queryLiveDb(project.dbUrl, `drop schema if exists ${schema} cascade`)), + ); + return { + targetError: Exit.isFailure(targetExit) ? Cause.squash(targetExit.cause) : undefined, + cleanupErrors: Exit.isFailure(cleanupExit) ? [Cause.squash(cleanupExit.cause)] : [], + }; + }).pipe(Effect.provide(BunServices.layer)), + { signal }, + ).then(({ targetError, cleanupErrors }) => throwWithCleanup(targetError, cleanupErrors))); diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 4d2ca3a78f..07e419d53f 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -10,7 +10,7 @@ the result as a table or JSON. | ------------------------------------ | ---------- | ------------------------------------------------------------------------------------------ | | `` (from `--file`) | SQL | when `--file` / `-f` is set (takes precedence over arg/stdin) | | stdin | SQL | when piped (not a TTY) and no `--file`/positional SQL | -| `supabase/config.toml` | TOML | local / `--db-url` connection resolution | +| `supabase/config.toml` | TOML | local / `--db-url` connection resolution and `[api].enabled` / `[api].schemas` | | `~/.supabase/access-token` | plain text | `--linked` when `SUPABASE_ACCESS_TOKEN` unset | | `supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | | `supabase/.temp/linked-project.json` | JSON | `--linked` existence check before the cache write (see below) | @@ -31,11 +31,13 @@ the result as a table or JSON. ## Environment Variables -| Variable | Purpose | -| ----------------------- | ---------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | `--linked` auth | -| agent-detection signals | `--agent=auto` (e.g. `CURSOR_*`, `CLAUDECODE`, …) via `@vercel/detect-agent` | -| `PG*` libpq | linked-project TLS, timeout, service; see `db push` | +| Variable | Purpose | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | `--linked` auth | +| `SUPABASE_API_SCHEMAS` | overrides `[api].schemas` for the local / `--db-url` agent-mode advisory | +| `SUPABASE_API_ENABLED` | overrides `[api].enabled`; malformed values fail config load on every target unless a matching `[remotes.*]` block sets `api.enabled` | +| agent-detection signals | `--agent=auto` (e.g. `CURSOR_*`, `CLAUDECODE`, …) via `@vercel/detect-agent` | +| `PG*` libpq | linked-project TLS, timeout, service; see `db push` | ## Exit Codes @@ -57,7 +59,7 @@ payload). Diagnostics (`Connecting to {local|remote} database...`) go to envelope `{advisory?, boundary, rows, warning}` with a random 16-byte hex boundary (`Random`), HTML-escaped, map keys sorted. Agent mode additionally runs a best-effort RLS advisory check (local - path only). + path only), checking `[api]`-exposed schemas and skipping the check when the Data API is off. ### Agent mode diff --git a/apps/cli/src/commands/db/query/query.advisory.ts b/apps/cli/src/commands/db/query/query.advisory.ts index 5ec4bd65b5..64914cd9b1 100644 --- a/apps/cli/src/commands/db/query/query.advisory.ts +++ b/apps/cli/src/commands/db/query/query.advisory.ts @@ -6,18 +6,14 @@ import type { Advisory } from "./query.format.ts"; * with Row Level Security disabled, surfaced inside the JSON envelope. */ -/** `rlsCheckSQL` — user-schema tables with RLS disabled (mirrors `lints.sql`). */ +/** User-schema tables with RLS disabled in the schemas bound to `$1`. */ export const RLS_CHECK_SQL = ` -SELECT format('%I.%I', n.nspname, c.relname) +SELECT format('%I.%I', n.nspname, c.relname) AS table_name FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE c.relkind = 'r' AND NOT c.relrowsecurity - AND n.nspname = any(array( - SELECT trim(unnest(string_to_array( - coalesce(nullif(current_setting('pgrst.db_schemas', 't'), ''), 'public'), - ','))) - )) + AND n.nspname = any($1::text[]) AND n.nspname NOT IN ( '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 2e572c08bd..f1f3aefca3 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -14,7 +14,8 @@ import { sanitizeErrorBody, unexpectedStatusMessage, } from "../../../command-internal/http-errors.ts"; -import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import type { ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { AgentFlag, DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { Random } from "../../../shared/runtime/random.service.ts"; @@ -122,7 +123,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { }); const runLocal = ( - target: { readonly conn: PgConnInput; readonly isLocal: boolean }, + target: Pick, sql: string, format: ResolvedFormat, agentMode: boolean, @@ -147,12 +148,16 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { } // Agent mode runs a best-effort RLS advisory check (only rendered in JSON). - const advisory = agentMode - ? yield* session.queryRaw(RLS_CHECK_SQL).pipe( - Effect.map((rls) => buildRlsAdvisory(rls.rows.map((row) => String(row[0] ?? "")))), - Effect.orElseSucceed(() => Option.none()), - ) - : Option.none(); + const schemas = target.exposedSchemas; + const advisory = + agentMode && schemas !== undefined && schemas.length > 0 + ? yield* session.query(RLS_CHECK_SQL, [schemas]).pipe( + Effect.map((rows) => + buildRlsAdvisory(rows.map((row) => String(row["table_name"] ?? ""))), + ), + Effect.orElseSucceed(() => Option.none()), + ) + : Option.none(); yield* emit( format, diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index d807d9db43..a41569aa13 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -77,7 +77,11 @@ const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const failMessage = (exit: Exit.Exit): string | undefined => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error.message : undefined; -function mockResolver(isLocal = true, resolveFails = false) { +function mockResolver( + isLocal = true, + resolveFails = false, + exposedSchemas: ReadonlyArray | undefined = ["public"], +) { return Layer.succeed(DbConfigResolver, { resolve: () => resolveFails @@ -86,7 +90,7 @@ function mockResolver(isLocal = true, resolveFails = false) { message: "failed to parse connection string: invalid dsn", }), ) - : Effect.succeed({ conn: LOCAL_CONN, isLocal }), + : Effect.succeed({ conn: LOCAL_CONN, isLocal, exposedSchemas }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); } @@ -94,6 +98,7 @@ function mockResolver(isLocal = true, resolveFails = false) { function mockDbConnection(opts: { result?: QueryResult; rlsTables?: ReadonlyArray; + rlsParams?: Array | undefined>; rlsFails?: boolean; queryFails?: boolean; }) { @@ -102,19 +107,16 @@ function mockDbConnection(opts: { Effect.succeed({ exec: () => Effect.void, execBatch: () => Effect.void, - query: () => Effect.succeed([]), + query: (sql, params) => { + if (sql !== RLS_CHECK_SQL) return Effect.succeed([]); + opts.rlsParams?.push(params); + return opts.rlsFails === true + ? Effect.fail(new DbExecError({ message: "advisory failed" })) + : Effect.succeed((opts.rlsTables ?? []).map((table) => ({ table_name: table }))); + }, extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: (sql: string) => { - if (sql === RLS_CHECK_SQL) { - return opts.rlsFails === true - ? Effect.fail(new DbExecError({ message: "advisory failed" })) - : Effect.succeed({ - fields: ["format"], - rows: (opts.rlsTables ?? []).map((table) => [table]), - commandTag: `SELECT ${(opts.rlsTables ?? []).length}`, - }); - } + queryRaw: () => { return opts.queryFails === true ? Effect.fail(new DbExecError({ message: "failed to execute query: boom" })) : Effect.succeed(opts.result ?? { fields: [], rows: [], commandTag: "CREATE TABLE" }); @@ -190,6 +192,7 @@ function mockStdin(opts: { isTTY?: boolean; piped?: string }) { interface SetupOpts { format?: "text" | "json" | "stream-json"; isLocal?: boolean; + exposedSchemas?: ReadonlyArray; agent?: "auto" | "yes" | "no"; goOutput?: "env" | "json" | "pretty" | "toml" | "yaml" | "table" | "csv"; aiTool?: string; @@ -197,6 +200,7 @@ interface SetupOpts { piped?: string; result?: QueryResult; rlsTables?: ReadonlyArray; + rlsParams?: Array | undefined>; rlsFails?: boolean; queryFails?: boolean; linkedStatus?: number; @@ -267,7 +271,7 @@ function setup(opts: SetupOpts = {}) { dbConnection: mockDbConnection(opts), env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, }) - : mockResolver(opts.isLocal, opts.resolveFails), + : mockResolver(opts.isLocal, opts.resolveFails, opts.exposedSchemas), mockDbConnection(opts), mockProjectRef(opts.unlinked, opts.refReadFails), mockStdin({ isTTY: opts.stdinTTY, piped: opts.piped }), @@ -600,6 +604,33 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("checks the configured exposed schemas for the RLS advisory", () => { + const rlsParams: Array | undefined> = []; + const { layer } = setup({ + result: SELECT_RESULT, + agent: "yes", + exposedSchemas: ["app", "public"], + rlsParams, + }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + expect(rlsParams).toEqual([[["app", "public"]]]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("omits the advisory when the Data API is disabled", () => { + const { layer, out } = setup({ + result: SELECT_RESULT, + agent: "yes", + exposedSchemas: [], + rlsTables: ["public.users"], + }); + return Effect.gen(function* () { + yield* dbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); + expect(yield* decodeJson(out.stdoutText)).not.toHaveProperty("advisory"); + }).pipe(Effect.provide(layer)); + }); + it.effect("omits the advisory when the RLS check fails", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", rlsFails: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index a5a5c04bfa..e7dc802b33 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -38,6 +38,7 @@ const toml: DbTomlValues = { projectEnv: {}, envLookup: () => undefined, apiSchemas: ["public", "graphql_public"], + exposedSchemas: ["public", "graphql_public"], port: 54322, shadowPort: 54320, password: "postgres", diff --git a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts index 8295649669..23034db73a 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.integration.test.ts @@ -29,6 +29,7 @@ const toml: DbTomlValues = { projectEnv: {}, envLookup: () => undefined, apiSchemas: ["public", "graphql_public"], + exposedSchemas: ["public", "graphql_public"], port: 54322, shadowPort: 54320, password: "postgres", diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts index 216cd484fe..4f8c8c320b 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.stack.integration.test.ts @@ -27,6 +27,7 @@ const toml = { projectEnv: {}, envLookup: () => undefined, apiSchemas: ["public", "graphql_public"], + exposedSchemas: ["public", "graphql_public"], port: 54321, shadowPort: 54320, password: "postgres", From 74d4774766936b6e6141a58d061c3396a01c85c9 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:21:17 +0000 Subject: [PATCH 18/22] chore: sync API types from infrastructure (#7400) This PR was automatically created to sync API types from the infrastructure repository. Changes were detected after refreshing `apps/cli-go/api/v1-openapi.yaml` from the latest spec from infrastructure, and `apps/cli-go/pkg/api` is regenerated from it. The snapshot diff is the upstream API change and is worth reading: it is marked `linguist-generated`, so GitHub collapses it behind a "Load diff" click rather than hiding it. Co-authored-by: supabase-cli-releaser[bot] <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> --- apps/cli-go/api/v1-openapi.yaml | 36 ++++++++++++++++++++++++++++++++ apps/cli-go/pkg/api/types.gen.go | 24 ++++++++++++++++----- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/cli-go/api/v1-openapi.yaml b/apps/cli-go/api/v1-openapi.yaml index bdde6d92ef..faf1b4fe31 100644 --- a/apps/cli-go/api/v1-openapi.yaml +++ b/apps/cli-go/api/v1-openapi.yaml @@ -10464,6 +10464,18 @@ components: maximum: 9007199254740991 description: If `null`, the value is automatically configured to 10. nullable: true + db_pool_max_lifetime: + type: integer + minimum: 0 + maximum: 3600 + description: If `null`, the value is automatically configured to 1800. + nullable: true + db_pool_max_idletime: + type: integer + minimum: 0 + maximum: 60 + description: If `null`, the value is automatically configured to 30. + nullable: true jwt_secret: type: string required: @@ -10472,6 +10484,8 @@ components: - db_extra_search_path - db_pool - db_pool_acquisition_timeout + - db_pool_max_lifetime + - db_pool_max_idletime V1UpdatePostgrestConfigBody: type: object properties: @@ -10491,6 +10505,14 @@ components: type: integer minimum: 0 maximum: 60 + db_pool_max_lifetime: + type: integer + minimum: 0 + maximum: 3600 + db_pool_max_idletime: + type: integer + minimum: 0 + maximum: 60 example: db_schema: public,storage db_pool: 20 @@ -10520,12 +10542,26 @@ components: maximum: 9007199254740991 description: If `null`, the value is automatically configured to 10. nullable: true + db_pool_max_lifetime: + type: integer + minimum: 0 + maximum: 3600 + description: If `null`, the value is automatically configured to 1800. + nullable: true + db_pool_max_idletime: + type: integer + minimum: 0 + maximum: 60 + description: If `null`, the value is automatically configured to 30. + nullable: true required: - db_schema - max_rows - db_extra_search_path - db_pool - db_pool_acquisition_timeout + - db_pool_max_lifetime + - db_pool_max_idletime V1ProjectRefResponse_Output: type: object properties: diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index 6dc3052bc4..54523885fa 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -7282,9 +7282,15 @@ type PostgrestConfigWithJWTSecretResponseOutput struct { // DbPoolAcquisitionTimeout If `null`, the value is automatically configured to 10. DbPoolAcquisitionTimeout nullable.Nullable[int] `json:"db_pool_acquisition_timeout"` - DbSchema string `json:"db_schema"` - JwtSecret *string `json:"jwt_secret,omitempty"` - MaxRows int `json:"max_rows"` + + // DbPoolMaxIdletime If `null`, the value is automatically configured to 30. + DbPoolMaxIdletime nullable.Nullable[int] `json:"db_pool_max_idletime"` + + // DbPoolMaxLifetime If `null`, the value is automatically configured to 1800. + DbPoolMaxLifetime nullable.Nullable[int] `json:"db_pool_max_lifetime"` + DbSchema string `json:"db_schema"` + JwtSecret *string `json:"jwt_secret,omitempty"` + MaxRows int `json:"max_rows"` } // ProjectClaimTokenResponseOutput defines model for ProjectClaimTokenResponse_Output. @@ -8749,8 +8755,14 @@ type V1PostgrestConfigResponseOutput struct { // DbPoolAcquisitionTimeout If `null`, the value is automatically configured to 10. DbPoolAcquisitionTimeout nullable.Nullable[int] `json:"db_pool_acquisition_timeout"` - DbSchema string `json:"db_schema"` - MaxRows int `json:"max_rows"` + + // DbPoolMaxIdletime If `null`, the value is automatically configured to 30. + DbPoolMaxIdletime nullable.Nullable[int] `json:"db_pool_max_idletime"` + + // DbPoolMaxLifetime If `null`, the value is automatically configured to 1800. + DbPoolMaxLifetime nullable.Nullable[int] `json:"db_pool_max_lifetime"` + DbSchema string `json:"db_schema"` + MaxRows int `json:"max_rows"` } // V1ProfileResponseOutput defines model for V1ProfileResponse_Output. @@ -9038,6 +9050,8 @@ type V1UpdatePostgrestConfigBody struct { DbExtraSearchPath *string `json:"db_extra_search_path,omitempty"` DbPool *int `json:"db_pool,omitempty"` DbPoolAcquisitionTimeout *int `json:"db_pool_acquisition_timeout,omitempty"` + DbPoolMaxIdletime *int `json:"db_pool_max_idletime,omitempty"` + DbPoolMaxLifetime *int `json:"db_pool_max_lifetime,omitempty"` DbSchema *string `json:"db_schema,omitempty"` MaxRows *int `json:"max_rows,omitempty"` } From 60bb7343ec519daa4f180951fd74506960382a62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:51:33 +0000 Subject: [PATCH 19/22] chore(repo): fix the develop-into-next merge --- apps/cli/src/command-internal/db-bootstrap/rollback.ts | 2 +- apps/cli/src/command-internal/local-stack-start-lock.ts | 2 +- .../testdata/config-precedence/db-toml/a-base-only.json | 4 ++++ .../testdata/config-precedence/db-toml/b-matched-remote.json | 4 ++++ .../db-toml/b2-remote-block-without-ref.json | 4 ++++ .../testdata/config-precedence/db-toml/c-shell-env.json | 4 ++++ .../testdata/config-precedence/db-toml/d-project-dotenv.json | 4 ++++ .../config-precedence/db-toml/d2-shell-beats-dotenv.json | 4 ++++ .../config-precedence/db-toml/e-remote-beats-shell.json | 4 ++++ .../config-precedence/db-toml/e2-remote-beats-dotenv.json | 4 ++++ .../db-toml/f-remote-matched-by-env-project-id.json | 4 ++++ .../f2-remote-env-match-project-id-is-block-literal.json | 4 ++++ .../db-toml/g-captcha-secret-from-env-only.json | 4 ++++ .../db-toml/h-storage-realtime-env-disable.json | 4 ++++ .../db-toml/i-project-id-env-with-matched-remote.json | 4 ++++ .../db-toml/i2-project-id-env-without-remote.json | 4 ++++ 16 files changed, 58 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.ts index f09c160828..ab1b4a7b11 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.ts @@ -64,7 +64,7 @@ export const rollbackStart = ( const writeStderrLine = (message: string) => Effect.sync(() => { - globalThis.process.stderr.write(`${message}\n`); + process.stderr.write(`${message}\n`); }); /** Resolves whether the resource is gone; a missing resource counts, any other failure is logged. */ diff --git a/apps/cli/src/command-internal/local-stack-start-lock.ts b/apps/cli/src/command-internal/local-stack-start-lock.ts index d8af7a33a6..b6dd7a785b 100644 --- a/apps/cli/src/command-internal/local-stack-start-lock.ts +++ b/apps/cli/src/command-internal/local-stack-start-lock.ts @@ -8,8 +8,8 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; +import { DebugLogger } from "../shared/output/debug-logger.service.ts"; import { aqua } from "./colors.ts"; -import { DebugLogger } from "./debug-logger.service.ts"; const SUBJECT = "the start lock"; const NO_WAIT = "0 millis"; diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json index 3972c220f2..69487a0395 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/a-base-only.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": true, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json index 6fccaca7c5..b3411f8000 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b-matched-remote.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": false, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json index 3972c220f2..69487a0395 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/b2-remote-block-without-ref.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": true, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json index 8bdfd08325..42ed444410 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/c-shell-env.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "shell_api" + ], "majorVersion": 17, "migrationsEnabled": false, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json index 35a5fa5f17..a8c7202210 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d-project-dotenv.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "dotenv_api" + ], "majorVersion": 17, "migrationsEnabled": false, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json index 8bdfd08325..42ed444410 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/d2-shell-beats-dotenv.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "shell_api" + ], "majorVersion": 17, "migrationsEnabled": false, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json index f15845d793..5dbaa54e7d 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e-remote-beats-shell.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "shell_api" + ], "majorVersion": 17, "migrationsEnabled": true, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json index b7a808c7b7..3e04de121e 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/e2-remote-beats-dotenv.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "dotenv_api" + ], "majorVersion": 17, "migrationsEnabled": false, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json index 04064981ef..2f7cf1d7e3 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f-remote-matched-by-env-project-id.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": false, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json index 04064981ef..2f7cf1d7e3 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/f2-remote-env-match-project-id-is-block-literal.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": false, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json index 3972c220f2..69487a0395 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/g-captcha-secret-from-env-only.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": true, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json index 3458b23c6b..6450d106a5 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/h-storage-realtime-env-disable.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": true, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json index edcceaa606..62551b56f7 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i-project-id-env-with-matched-remote.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": false, "orioledbVersion": null, diff --git a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json index dc1a4610b5..73b419dc35 100644 --- a/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json +++ b/apps/cli/src/command-internal/testdata/config-precedence/db-toml/i2-project-id-env-without-remote.json @@ -13,6 +13,10 @@ "vaultNames": [] }, "denoVersion": 2, + "exposedSchemas": [ + "public", + "base_api" + ], "majorVersion": 17, "migrationsEnabled": true, "orioledbVersion": null, From c7e287b73e9bc8d99d8be75f466fceaf486f1fc4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:32:43 +0000 Subject: [PATCH 20/22] chore(repo): repair the develop-into-next sync --- .../matched-remote-with-project-id-env.json | 6 +++--- .../matched-remote-with-shell-env.json | 6 +++--- .../local-container-inputs/matched-remote.json | 6 +++--- .../local-container-inputs/no-overrides.json | 6 +++--- .../local-container-inputs/project-dotenv.json | 6 +++--- .../local-container-inputs/shell-env.json | 6 +++--- .../command-internal/db-config.integration.test.ts | 11 +++++------ .../src/commands/config/push/push.integration.test.ts | 10 +++++----- .../src/commands/db/query/query.integration.test.ts | 2 +- apps/cli/src/commands/link/link.integration.test.ts | 2 +- 10 files changed, 30 insertions(+), 31 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json index b0130c633d..dce71ff5c6 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-project-id-env.json @@ -5,8 +5,8 @@ "dbHealthTimeoutSeconds": 120, "majorVersion": 17, "orioledbVersion": null, - "postgresConfigImage": "supabase/postgres:17.11.0.007", - "postgresImage": "supabase/postgres:17.11.0.007", + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", "realtimeEnabledForSetup": true, "realtimeIpVersion": "IPv4", "realtimeMaxHeaderLength": 4096, @@ -32,7 +32,7 @@ "experimental": false, "networkId": "supabase_network_shell-project-id", "postgresSpecBase": { - "configImage": "supabase/postgres:17.11.0.007", + "configImage": "supabase/postgres:17.11.0.011", "db": { "health_timeout": "2m", "major_version": 17, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json index 86dda247be..4272d72177 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote-with-shell-env.json @@ -5,8 +5,8 @@ "dbHealthTimeoutSeconds": 120, "majorVersion": 17, "orioledbVersion": null, - "postgresConfigImage": "supabase/postgres:17.11.0.007", - "postgresImage": "supabase/postgres:17.11.0.007", + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", "realtimeEnabledForSetup": false, "realtimeIpVersion": "IPv4", "realtimeMaxHeaderLength": 4096, @@ -32,7 +32,7 @@ "experimental": false, "networkId": "supabase_network_abcdefghijklmnopqrst", "postgresSpecBase": { - "configImage": "supabase/postgres:17.11.0.007", + "configImage": "supabase/postgres:17.11.0.011", "db": { "health_timeout": "2m", "major_version": 17, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json index e2ed4087c9..980df2596f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/matched-remote.json @@ -5,8 +5,8 @@ "dbHealthTimeoutSeconds": 120, "majorVersion": 17, "orioledbVersion": null, - "postgresConfigImage": "supabase/postgres:17.11.0.007", - "postgresImage": "supabase/postgres:17.11.0.007", + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", "realtimeEnabledForSetup": true, "realtimeIpVersion": "IPv4", "realtimeMaxHeaderLength": 4096, @@ -32,7 +32,7 @@ "experimental": false, "networkId": "supabase_network_abcdefghijklmnopqrst", "postgresSpecBase": { - "configImage": "supabase/postgres:17.11.0.007", + "configImage": "supabase/postgres:17.11.0.011", "db": { "health_timeout": "2m", "major_version": 17, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json index 477dbd3742..2f86ba9c79 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/no-overrides.json @@ -5,8 +5,8 @@ "dbHealthTimeoutSeconds": 120, "majorVersion": 17, "orioledbVersion": null, - "postgresConfigImage": "supabase/postgres:17.11.0.007", - "postgresImage": "supabase/postgres:17.11.0.007", + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", "realtimeEnabledForSetup": true, "realtimeIpVersion": "IPv4", "realtimeMaxHeaderLength": 4096, @@ -32,7 +32,7 @@ "experimental": false, "networkId": "supabase_network_golden-shadow", "postgresSpecBase": { - "configImage": "supabase/postgres:17.11.0.007", + "configImage": "supabase/postgres:17.11.0.011", "db": { "health_timeout": "2m", "major_version": 17, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json index 1e497b7bc3..3d85d0db5a 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/project-dotenv.json @@ -5,8 +5,8 @@ "dbHealthTimeoutSeconds": 120, "majorVersion": 17, "orioledbVersion": null, - "postgresConfigImage": "supabase/postgres:17.11.0.007", - "postgresImage": "supabase/postgres:17.11.0.007", + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", "realtimeEnabledForSetup": false, "realtimeIpVersion": "IPv4", "realtimeMaxHeaderLength": 4096, @@ -32,7 +32,7 @@ "experimental": false, "networkId": "supabase_network_golden-shadow", "postgresSpecBase": { - "configImage": "supabase/postgres:17.11.0.007", + "configImage": "supabase/postgres:17.11.0.011", "db": { "health_timeout": "2m", "major_version": 17, diff --git a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json index d2bd58688f..a49d200a86 100644 --- a/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json +++ b/apps/cli/src/command-internal/db-bootstrap/testdata/config-precedence/local-container-inputs/shell-env.json @@ -5,8 +5,8 @@ "dbHealthTimeoutSeconds": 120, "majorVersion": 17, "orioledbVersion": null, - "postgresConfigImage": "supabase/postgres:17.11.0.007", - "postgresImage": "supabase/postgres:17.11.0.007", + "postgresConfigImage": "supabase/postgres:17.11.0.011", + "postgresImage": "supabase/postgres:17.11.0.011", "realtimeEnabledForSetup": false, "realtimeIpVersion": "IPv4", "realtimeMaxHeaderLength": 4096, @@ -32,7 +32,7 @@ "experimental": false, "networkId": "supabase_network_golden-shadow", "postgresSpecBase": { - "configImage": "supabase/postgres:17.11.0.007", + "configImage": "supabase/postgres:17.11.0.011", "db": { "health_timeout": "2m", "major_version": 17, 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 388ba06c13..192a7440c6 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -272,12 +272,12 @@ const assertDbConfigDrift = ( describe("dbConfigResolver (local + db-url)", () => { it.effect.each([ - { name: "local", flags: localFlags }, - { name: "db-url", flags: dbUrlFlags("postgres://alice:password@example.com:6543/appdb") }, - ])("resolves Data API exposure for $name", ({ flags }) => + { name: "local", target: localTarget }, + { name: "db-url", target: dbUrlTarget("postgres://alice:password@example.com:6543/appdb") }, + ])("resolves Data API exposure for $name", ({ target }) => Effect.gen(function* () { const dir = yield* withWorkdir('[api]\nschemas = ["app", "public"]'); - const result = yield* resolve(dir, flags); + const result = yield* resolveConnection(dir, inputFor(target)); expect(result.exposedSchemas).toEqual(["app", "public"]); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -737,8 +737,7 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { it.effect("resolves Data API exposure for the stack-backed local database", () => Effect.gen(function* () { const dir = yield* withWorkdir('[api]\nschemas = ["app", "public"]'); - const resolved = yield* resolve(dir, localFlags, { - projectStateMode: "stack", + const resolved = yield* resolveConnection(dir, inputFor(localTarget), { stackApi: projectStackApi(dir, "running"), }).pipe(Effect.provide(stackBackendLayer("stack"))); expect(resolved.exposedSchemas).toEqual(["app", "public"]); diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index dec0586f21..730b41c77f 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -1495,7 +1495,7 @@ otp_expiry = 120 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); @@ -1529,7 +1529,7 @@ otp_expiry = 120 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); @@ -1568,7 +1568,7 @@ otp_expiry = 120 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); @@ -1605,7 +1605,7 @@ otp_expiry = 120 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); @@ -1644,7 +1644,7 @@ otp_expiry = 120 }, }); return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); + yield* configPush({ remote: Option.none(), projectRef: Option.none() }); const update = api.requests.find( (r) => r.method === "PATCH" && r.url.includes("/config/auth"), ); diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index 838e0274b3..3b63d0a022 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -151,7 +151,7 @@ function mockDbConnection(opts: { Effect.succeed({ exec: () => Effect.void, execBatch: () => Effect.void, - query: (sql, params) => { + query: (sql: string, params?: ReadonlyArray) => { if (sql !== RLS_CHECK_SQL) return Effect.succeed([]); opts.rlsParams?.push(params); return opts.rlsFails === true diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 05033f7ce3..551d96c872 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1568,7 +1568,7 @@ describe("link permissions", () => { expect(yield* readTemp(workdir, "project-ref")).toBe(VALID_REF); assertPermissionDrift({ command: "link", - activeFlags: { "project-ref": true, "skip-pooler": false }, + activeFlags: { "project-ref": VALID_REF, "skip-pooler": false }, requests: api.requests, }); }).pipe(Effect.provide(layer)); From bcd3ec94393958ea8f1e29ef2582a1bf693a2193 Mon Sep 17 00:00:00 2001 From: Vaibhav <117663341+7ttp@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:56:37 +0000 Subject: [PATCH 21/22] fix(cli): stop `db reset` failing at random (CLI-2312, CLI-2563) (#7380) ## TL;DR stops `supabase db reset` randomly failing at `Initialising schema...` ## whats hurting the user(s)? `supabase db reset` fails at random with `error running container: exit 1`, mostly in CI it recreated the db while realtime kept running, so docker restarted realtime mid reset and its migrations collided with the reset's own on `schema_migrations_pkey` ## now fixed by: storage, auth, realtime and the pooler are stopped before the db is removed and started once at the end, even when the reset fails... ```mermaid sequenceDiagram participant R as db reset participant S as storage, auth, realtime, pooler participant D as docker participant DB as db participant J as init jobs Note over R,J: before R->>DB: remove and recreate DB--xS: connection lost D->>S: restart (unless-stopped) par S->>DB: realtime migrations and J->>DB: realtime migrations end DB-->>J: schema_migrations_pkey J-->>R: exit 1, reset fails Note over R,J: now R->>S: stop R->>DB: remove and recreate J->>DB: init migrations R->>DB: your migrations + seed R->>S: start once, reload kong Note over R,S: same restart on failure or ctrl-c ``` ## ref: - closes: https://github.com/supabase/cli/issues/6915 - closes: https://github.com/supabase/cli/issues/6445 --- .../db-bootstrap/recreate-local-database.ts | 41 ++- .../db-bootstrap/restart-services.ts | 44 ++- .../cli/src/commands/db/reset/SIDE_EFFECTS.md | 10 +- .../db/reset/reset.integration.test.ts | 320 +++++++++++++++++- .../declarative/generate/SIDE_EFFECTS.md | 2 +- .../schema/declarative/sync/SIDE_EFFECTS.md | 2 +- 6 files changed, 393 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts index 0e13168419..9076897396 100644 --- a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.ts @@ -6,10 +6,12 @@ * `startSetupLocalDatabase`) with a distinct sequence per major version: PG >= 15 recreates the * container/volume/network and re-runs setup; PG <= 14 recreates the two databases in place and * reinitializes the schema. Neither path probes for an existing volume, supports - * `--from-backup`, or rolls back on failure. + * `--from-backup`, or rolls back the database on failure; PG15 stops storage/auth/realtime/pooler + * first and restarts them (+ Kong reload) once at the end, or after a failure unless Docker + * reports the db container missing or stopped. */ -import { Data, Effect, Result, Schedule, type FileSystem, type Path } from "effect"; +import { Data, Effect, Exit, Result, Schedule, type FileSystem, type Path } from "effect"; import type * as HttpClient from "effect/http/HttpClient"; import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; @@ -21,10 +23,12 @@ import { ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; import { isSqlState } from "../connect-errors.ts"; +import { isContainerNotFoundMessage } from "../container-cli.ts"; import { checkDbToml } from "../db-config.toml-read.ts"; import { DbConnection, type DbSession } from "../db-connection.service.ts"; import { DbExecError, type DbConnectError } from "../db-connection.errors.ts"; import { CLI_PROJECT_LABEL } from "../docker-ids.ts"; +import { inspectContainerState } from "../docker-lifecycle.ts"; import type { DockerRun } from "../docker-run.service.ts"; import { migrateAndSeed } from "../migrate-and-seed.ts"; import { formatExecBatchError, type MigrationApplyError } from "../migration-apply.ts"; @@ -67,6 +71,7 @@ import { import { restartContainer, restartServicesAndReloadKong, + stopSatelliteServices, type ContainerRestartError, type KongReloadError, type RestartServicesError, @@ -252,6 +257,8 @@ const resetRecreateDatabases = Effect.fn("DbBootstrap.recreateDatabases")(functi } }); +const STACK_RECOVERY_HINT = "Run supabase stop && supabase start to bring the local stack back."; + /** PG >= 15 recreate path — see this module's own header for the full sequence. */ const recreateLocalDatabase15 = ( spawner: Spawner, @@ -270,6 +277,7 @@ const recreateLocalDatabase15 = ( Effect.gen(function* () { const output = yield* Output; + yield* stopSatelliteServices(spawner, input.projectId); yield* removeContainer(spawner, input.dbContainerId); yield* removeVolume(spawner, input.dbContainerId); @@ -317,8 +325,33 @@ const recreateLocalDatabase15 = ( }); yield* output.raw("Restarting containers...\n", "stderr"); - yield* restartServicesAndReloadKong(spawner, input.projectId); - }); + }).pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? restartServicesAndReloadKong(spawner, input.projectId) + : Effect.gen(function* () { + const output = yield* Output; + const inspected = yield* inspectContainerState(spawner, input.dbContainerId).pipe( + Effect.result, + ); + const dbGone = Result.isFailure(inspected) + ? isContainerNotFoundMessage(inspected.failure.message) + : !inspected.success.running; + if (dbGone) { + return yield* output.warn( + `The local database is not running, so storage, auth, realtime and the pooler were left stopped.\n${STACK_RECOVERY_HINT}`, + ); + } + yield* restartServicesAndReloadKong(spawner, input.projectId).pipe( + Effect.catchTags({ + RestartServicesError: (error) => + output.warn(`${error.message}\n${STACK_RECOVERY_HINT}`), + KongReloadError: (error) => output.warn(`${error.message}\n${error.suggestion}`), + }), + ); + }), + ), + ); /** * Loads `config.toml` once, ahead of `initDatabase` (needs `api.auto_expose_new_tables`) and the diff --git a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts index 376851bfbe..91d681f1b4 100644 --- a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts +++ b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts @@ -3,7 +3,8 @@ * `db reset` paths. Neither `db start` nor `supabase start` calls this: it exists purely to * bring the satellite containers (storage/auth/realtime/pooler) back in sync with a `db` * container that was just recreated or force-restarted out from under them, and to reload Kong's - * nginx so its cached upstream addresses (which may have changed) stop 502ing. + * nginx so its cached upstream addresses (which may have changed) stop 502ing. PG15's path also + * stops those satellites before it removes the `db` container ({@link stopSatelliteServices}). */ import { Data, Effect, Option, Result } from "effect"; @@ -54,18 +55,19 @@ export function restartContainer( } /** - * One satellite service's restart, tolerant of "not found" — a service excluded from the stack - * has no container to restart, and that's not an error. Never fails the surrounding `Effect.all` + * One satellite service's stop or restart, tolerant of "not found" — a service excluded from the + * stack has no container, and that's not an error. Never fails the surrounding `Effect.all` * itself: resolves `Option.some(message)` on a genuine failure so the caller can join every * service's outcome, and `Option.none()` on success or a tolerated not-found. */ -const restartSatelliteService = ( +const runSatelliteService = ( spawner: Spawner, + verb: "stop" | "restart", containerId: string, ): Effect.Effect> => Effect.scoped( Effect.gen(function* () { - const child = yield* spawnContainerCli(spawner, ["restart", containerId], { + const child = yield* spawnContainerCli(spawner, [verb, containerId], { stdin: "ignore", stdout: "ignore", stderr: "pipe", @@ -78,18 +80,18 @@ const restartSatelliteService = ( const trimmed = stderr.trim(); if (isContainerNotFoundMessage(trimmed)) return Option.none(); return Option.some( - `failed to restart ${containerId}: ${trimmed.length > 0 ? trimmed : `exit ${exitCode}`}`, + `failed to ${verb} ${containerId}: ${trimmed.length > 0 ? trimmed : `exit ${exitCode}`}`, ); }), ).pipe( Effect.catch((cause) => Effect.succeed( - Option.some(`failed to restart ${containerId}: ${describeContainerCliFailure(cause)}`), + Option.some(`failed to ${verb} ${containerId}: ${describeContainerCliFailure(cause)}`), ), ), ); -/** One or more satellite-service restarts failed. Messages are newline-joined. */ +/** One or more satellite-service stops or restarts failed. Messages are newline-joined. */ export class RestartServicesError extends Data.TaggedError("RestartServicesError")<{ readonly message: string; }> { @@ -99,13 +101,14 @@ export class RestartServicesError extends Data.TaggedError("RestartServicesError } /** - * Restarts storage/auth/realtime/pooler concurrently — not PostgREST, which reconnects and - * listens for schema changes on its own — without waiting for them to become healthy - * afterward, since some may be excluded from the stack. Every per-service failure (excluding a + * Stops or restarts storage/auth/realtime/pooler concurrently — not PostgREST, which reconnects and + * listens for schema changes on its own — and never waits for them to become healthy, since + * some may be excluded from the stack. Every per-service failure (excluding a * tolerated not-found) is joined into one newline-separated message. */ -function restartSatelliteServices( +function runSatelliteServices( spawner: Spawner, + verb: "stop" | "restart", projectId: string, ): Effect.Effect { const containerIds = [ @@ -116,14 +119,25 @@ function restartSatelliteServices( ]; return Effect.gen(function* () { const results = yield* Effect.all( - containerIds.map((containerId) => restartSatelliteService(spawner, containerId)), + containerIds.map((containerId) => runSatelliteService(spawner, verb, containerId)), { concurrency: "unbounded" }, ); const failures = results.filter(Option.isSome).map((result) => result.value); if (failures.length > 0) { return yield* new RestartServicesError({ message: failures.join("\n") }); } - }).pipe(Effect.withSpan("RestartServices.restartSatellites")); + }).pipe(Effect.withSpan(`RestartServices.${verb}Satellites`)); +} + +/** + * Stops storage/auth/realtime/pooler before `db reset` removes the db container: left running, + * `unless-stopped` restarts them and their own migrations race the one-shot init jobs (#6915). + */ +export function stopSatelliteServices( + spawner: Spawner, + projectId: string, +): Effect.Effect { + return runSatelliteServices(spawner, "stop", projectId); } /** Gateway-recovery hint, rendered as a `Suggestion:` line by `Output.fail`. */ @@ -225,7 +239,7 @@ export function restartServicesAndReloadKong( projectId: string, ): Effect.Effect { return Effect.gen(function* () { - yield* restartSatelliteServices(spawner, projectId); + yield* runSatelliteServices(spawner, "restart", projectId); yield* reloadKong(spawner, projectId); }).pipe(Effect.withSpan("RestartServices.restartAndReloadKong")); } diff --git a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md index f45d637b5e..1cb693d635 100644 --- a/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/reset/SIDE_EFFECTS.md @@ -95,6 +95,7 @@ equivalent, PG15) or `InitSchema14`/`ApplyApiPrivileges` (PG14). | Command | When | Purpose | | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) — spawned only when the direct Engine-API request over the active local socket/named pipe gives no definitive answer | +| `docker stop ` | local path, PG15 | stop the satellites before removing the db container so their `unless-stopped` restarts cannot race the one-shot init jobs (issue #6915), not-found tolerated per service | | `docker container rm -f supabase_db_` / `docker volume rm -f ` | local path, PG15 | remove the existing container/volume before recreating (Podman fallback) | | `docker network create` / `docker volume create` / `docker create` / `docker start` | local path, PG15 | recreate the Postgres container (same primitives `db start` uses) | | `docker run --rm ` | local path, PG15, per enabled service | the one-shot `initSchema15` migrate jobs (`startSetupLocalDatabase`) | @@ -132,7 +133,8 @@ child) is fully native as of CLI-1958. ### Local path (native, in TS) -**PG15+:** the container/volume are removed and recreated (see "Subprocesses"), then +**PG15+:** the storage/auth/realtime/pooler containers are stopped, then the +container/volume are removed and recreated (see "Subprocesses"), then the reused `startSetupLocalDatabase` pipeline runs the initial schema (as one-shot Docker jobs, not SQL over a session), `ApplyApiPrivileges`, a vault upsert, a `roles.sql` seed, and `MigrateAndSeed` (migrations `≤ --version`, seed unless @@ -161,7 +163,10 @@ Bucket objects are then seeded over the Storage gateway (reusing the `seed bucke local path), gated on a native storage-health check: absent (any inspect error, not just "not found") skips buckets without failing; present-but-unhealthy waits up to a **hardcoded 30 seconds** (independent of `db.health_timeout`) and, on timeout, **fails -the whole reset** (not just "skip buckets"). +the whole reset** (not just "skip buckets"). On PG15, a failure or interrupt before the final +restart inspects `supabase_db_` and, unless Docker reports it missing or stopped, runs the +same restart + Kong reload once before the original error surfaces (a restore failure only warns); +otherwise the satellites stay stopped with a warning to run `supabase stop && supabase start`. ## API Routes @@ -217,6 +222,7 @@ echoed, because those may genuinely have reached the server. | `1` | drop / migrate / seed / vault apply failure, or connection error | | `1` | no `[db.migrations].schema_paths` pattern matched anything on the `--experimental` branch, either target | | `1` | local: container/volume remove, network/volume/container create, health-check timeout, PG14 SQL, satellite-restart, or Kong-reload failure | +| `1` | local, PG15: satellite stop failure | | `1` | `--project-ref` set with a resolved target other than linked (see Notes) | There is no remaining Go child on either target (CLI-1955 removed it for local, diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index 9f2d9e2362..2951ec131c 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -2,8 +2,10 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, + Deferred, Effect, Exit, + Fiber, FileSystem, Layer, Option, @@ -299,6 +301,8 @@ type RouteResult = { readonly exitCode?: number; readonly stdout?: ReadonlyArray; readonly stderr?: ReadonlyArray; + /* The exit code resolves only once this completes, so a test can hold a spawn in flight. */ + readonly exitAfter?: Effect.Effect; }; function mockContainerCliSpawner(route: (args: ReadonlyArray) => RouteResult) { @@ -329,7 +333,9 @@ function mockContainerCliSpawner(route: (args: ReadonlyArray) => RouteRe stdout: Stream.fromIterable(stdoutBytes), stderr: Stream.fromIterable(stderrBytes), all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), + exitCode: (result.exitAfter ?? Effect.void).pipe( + Effect.as(ChildProcessSpawner.ExitCode(result.exitCode ?? 0)), + ), isRunning: Effect.succeed(false), stdin: Sink.drain, kill: () => Effect.void, @@ -2146,11 +2152,316 @@ describe("db reset", () => { expect(telemetry.flushed).toBe(true); }); }); + + it.effect("stops the satellites before removing the database container", () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const dbRemoval = child.spawned.findIndex( + (s) => s.args[0] === "container" && s.args[1] === "rm", + ); + expect(dbRemoval).toBeGreaterThan(-1); + const stoppedBeforeRemoval = child.spawned + .slice(0, dbRemoval) + .filter((s) => s.args[0] === "stop") + .map((s) => s.args[1]); + expect(stoppedBeforeRemoval).toHaveLength(4); + const lastSetupJob = child.spawned.findLastIndex((s) => s.args[0] === "run"); + expect(lastSetupJob).toBeGreaterThan(dbRemoval); + expect(child.spawned.findIndex((s) => s.args[0] === "restart")).toBeGreaterThan( + lastSetupJob, + ); + expect(stoppedBeforeRemoval).toEqual( + expect.arrayContaining([ + "supabase_storage_test", + "supabase_auth_test", + "supabase_realtime_test", + "supabase_pooler_test", + ]), + ); + }); + }); + + it.effect("restarts the stopped satellites when the reset fails after stopping them", () => { + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => (args[0] === "run" ? { exitCode: 1 } : defaultLocalResetRoute()(args)), + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("error running container: exit 1"); + } + expect(out.stderrText).not.toContain("Restarting containers..."); + expect(child.spawned.filter((s) => s.args[0] === "stop")).toHaveLength(4); + expect(restartedContainers(child.spawned)).toHaveLength(4); + expect(restartedContainers(child.spawned)).toEqual( + expect.arrayContaining([ + "supabase_storage_test", + "supabase_auth_test", + "supabase_realtime_test", + "supabase_pooler_test", + ]), + ); + }); + }); + + it.effect("restores the satellites without touching the database when a stop fails", () => { + const { layer, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => + args[0] === "stop" && args[1] === "supabase_auth_test" + ? { exitCode: 1, stderr: ["Error: cannot stop"] } + : defaultLocalResetRoute()(args), + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("failed to stop supabase_auth_test"); + } + expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( + false, + ); + expect(restartedContainers(child.spawned)).toHaveLength(4); + }); + }); + + it.effect("warns and keeps the original failure when restoring the satellites fails", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => + args[0] === "run" + ? { exitCode: 1 } + : defaultLocalResetRoute({ restartFails: ["supabase_storage_test"] })(args), + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("error running container: exit 1"); + } + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("failed to restart supabase_storage_test"), + }), + ); + }); + }); + + it.effect( + "leaves the satellites stopped when the reset is interrupted right after removing the database", + () => + Effect.gen(function* () { + const removingVolume = yield* Deferred.make(); + const base = defaultLocalResetRoute(); + let dbRemoved = false; + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => { + if (args[0] === "container" && args[1] === "rm") dbRemoved = true; + if (args[0] === "volume" && args[1] === "rm") { + return { + exitAfter: Deferred.succeed(removingVolume, undefined).pipe( + Effect.andThen(Effect.never), + ), + }; + } + if ( + dbRemoved && + args[0] === "container" && + args[1] === "inspect" && + args[2] === DB_ID + ) { + return { exitCode: 1, stderr: [`Error: No such container: ${DB_ID}`] }; + } + return base(args); + }, + }); + const fiber = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.forkChild); + yield* Deferred.await(removingVolume); + yield* Fiber.interrupt(fiber); + const exit = yield* Fiber.await(fiber); + + expect(Exit.hasInterrupts(exit)).toBe(true); + expect(restartedContainers(child.spawned)).toHaveLength(0); + expect(kongReloadCalls(child.spawned)).toHaveLength(0); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: + "The local database is not running, so storage, auth, realtime and the pooler were left stopped.\nRun supabase stop && supabase start to bring the local stack back.", + }), + ); + }), + ); + + it.effect( + "leaves the satellites stopped when the recreated database exits before it is ready", + () => { + const base = defaultLocalResetRoute(); + let dbCreated = false; + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => { + if (args[0] === "create") dbCreated = true; + if ( + dbCreated && + args[0] === "container" && + args[1] === "inspect" && + args[2] === DB_ID + ) { + return { stdout: [STOPPED_STATE] }; + } + return base(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("container is not running: exited"); + } + expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); + expect(restartedContainers(child.spawned)).toHaveLength(0); + expect(kongReloadCalls(child.spawned)).toHaveLength(0); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("The local database is not running"), + }), + ); + }); + }, + ); + + it.effect( + "restores the satellites when the database cannot be inspected after a failure", + () => { + const base = defaultLocalResetRoute(); + let setupStarted = false; + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => { + if (args[0] === "run") { + setupStarted = true; + return { exitCode: 1 }; + } + if ( + setupStarted && + args[0] === "container" && + args[1] === "inspect" && + args[2] === DB_ID + ) { + return { exitCode: 1, stderr: ["Error: permission denied"] }; + } + return base(args); + }, + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("error running container: exit 1"); + } + expect(restartedContainers(child.spawned)).toHaveLength(4); + expect(kongReloadCalls(child.spawned)).toHaveLength(1); + expect(out.messages).not.toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("not running"), + }), + ); + }); + }, + ); + + it.effect( + "keeps the gateway advice when the Kong reload fails while restoring after a failure", + () => { + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => + args[0] === "run" + ? { exitCode: 1 } + : defaultLocalResetRoute({ kongReloadFails: true })(args), + }); + return Effect.gen(function* () { + const exit = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("error running container: exit 1"); + } + expect(restartedContainers(child.spawned)).toHaveLength(4); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining( + "Local services restarted, but API routes may return 502 until the gateway reloads.", + ), + }), + ); + }); + }, + ); + + it.effect("resets the database when a satellite is excluded from the stack", () => { + const realtimeMissing = (args: ReadonlyArray) => + (args[0] === "stop" || args[0] === "restart") && args[1] === "supabase_realtime_test"; + const { layer, out, child } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + route: (args) => + realtimeMissing(args) + ? { exitCode: 1, stderr: [`Error: No such container: ${args[1] ?? ""}`] } + : defaultLocalResetRoute()(args), + }); + return Effect.gen(function* () { + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Finished supabase db reset.\n"); + expect(child.spawned.filter((s) => s.args[0] === "stop").map((s) => s.args[1])).toEqual( + expect.arrayContaining([ + "supabase_storage_test", + "supabase_auth_test", + "supabase_pooler_test", + ]), + ); + expect(restartedContainers(child.spawned)).toEqual( + expect.arrayContaining([ + "supabase_storage_test", + "supabase_auth_test", + "supabase_pooler_test", + ]), + ); + expect(kongReloadCalls(child.spawned)).toHaveLength(1); + }); + }); }); describe("local reset — Kong reload", () => { it.effect("fails the whole command with the exact suggestion when Kong reload fails", () => { - const { layer } = setup(tmp.current, { + const { layer, child } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], isLocal: true, @@ -2172,6 +2483,8 @@ describe("db reset", () => { suggestion: expect.stringContaining(`docker restart ${KONG_ID}`), }); } + expect(restartedContainers(child.spawned)).toHaveLength(4); + expect(kongReloadCalls(child.spawned)).toHaveLength(1); }); }); @@ -2204,7 +2517,7 @@ describe("db reset", () => { }); it.effect("fails the command when a satellite restart fails", () => { - const { layer } = setup(tmp.current, { + const { layer, child } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], isLocal: true, @@ -2216,6 +2529,7 @@ describe("db reset", () => { if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("failed to restart supabase_storage_test"); } + expect(restartedContainers(child.spawned)).toHaveLength(4); }); }); }); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 7f9bea7def..d31bdbabd4 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -39,7 +39,7 @@ formatting without disabling safe compaction. | What | When | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | +| `docker`/`podman` container recreate for the local `db` (+ satellite stop/restart, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | | Local database auto-start via `startLocalDatabase` — absent `db` container: started; present but not running: removed with `docker container rm -f` (data volume kept) and started, as `db start` does; in Bitbucket Pipelines it fails with a `supabase stop` suggestion instead | the local `db` container is not running | ## Environment Variables diff --git a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 2b0fdb0105..48a13c1b5c 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -52,7 +52,7 @@ disabling safe compaction. | What | When | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | | Two natively-provisioned shadows (migrated source + declarative target) via `acquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | always | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| `docker`/`podman` container recreate for the local `db` (+ satellite stop/restart, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | | Local database auto-start via `startLocalDatabase` — absent `db` container: started; present but not running: removed with `docker container rm -f` (data volume kept) and started, as `db start` does; in Bitbucket Pipelines it fails with a `supabase stop` suggestion instead | the local `db` container is not running | ## Environment Variables From db33a5757128dee98e155dd2e974f23ea9e0262c Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 29 Oct 2026 20:02:20 +0000 Subject: [PATCH 22/22] test(cli): confirm consent-gated commands in live tests Hosted mutations now refuse in a non-TTY run without --yes, so the live suite's remaining unconfirmed setup and cleanup calls failed with "Confirmation required". Pass --yes to db query (write statements), migration repair and postgres-config delete in the live tests and helpers. --- apps/cli/src/commands/db/pull/pull.live.test.ts | 1 + apps/cli/src/commands/migration/fetch/fetch.live.test.ts | 4 ++-- apps/cli/src/commands/migration/up/up.live.test.ts | 1 + apps/cli/tests/helpers/live.ts | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/db/pull/pull.live.test.ts b/apps/cli/src/commands/db/pull/pull.live.test.ts index e249031c85..493017f422 100644 --- a/apps/cli/src/commands/db/pull/pull.live.test.ts +++ b/apps/cli/src/commands/db/pull/pull.live.test.ts @@ -29,6 +29,7 @@ test("pulls the remote schema into an initial migration", ({ "db", "query", `create table if not exists ${marker} (id int)`, + "--yes", `--remote=${project.dbUrl}`, ]); requireLiveSuccess(seeded, "db query setup for db pull"); diff --git a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts index 1db89ff333..cf8d9ed253 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts @@ -46,7 +46,7 @@ test( "create table if not exists public.cli_live_roundtrip (id int);\n", ); const repairResult = yield* cliEffect( - ["migration", "repair", version, "--status", "applied", ...targetArgs], + ["migration", "repair", version, "--status", "applied", "--yes", ...targetArgs], { cwd: seedDir }, ); requireLiveSuccess(repairResult, "migration repair setup"); @@ -65,7 +65,7 @@ test( const revert = Effect.gen(function* () { const reverted = yield* cliEffect( - ["migration", "repair", version, "--status", "reverted", ...targetArgs], + ["migration", "repair", version, "--status", "reverted", "--yes", ...targetArgs], { cwd: seedDir }, ); if ( diff --git a/apps/cli/src/commands/migration/up/up.live.test.ts b/apps/cli/src/commands/migration/up/up.live.test.ts index 28fecae4f4..1bfd66fd6f 100644 --- a/apps/cli/src/commands/migration/up/up.live.test.ts +++ b/apps/cli/src/commands/migration/up/up.live.test.ts @@ -78,6 +78,7 @@ test("applies a test-written migration to the remote database", ({ "db", "query", `drop table if exists e2e_up_${version}`, + "--yes", `--remote=${project.dbUrl}`, ]); requireLiveSuccess(dropped, "db query cleanup after migration up"); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index eca7e09a6d..3bfcd04e58 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -286,6 +286,7 @@ export function removePostgresConfigLiveOverride( cli([ "postgres-config", "delete", + "--yes", "--config", key, ...experimentalProjectLiveFlags(project),