diff --git a/.agents/skills/test-audit/SKILL.md b/.agents/skills/test-audit/SKILL.md new file mode 100644 index 0000000000..abb6222771 --- /dev/null +++ b/.agents/skills/test-audit/SKILL.md @@ -0,0 +1,81 @@ +--- +name: test-audit +description: | + Load whenever a test is authored, changed, or reviewed. Gates new tests before they are added + and audits existing suites for low-value, duplicated, or implementation-coupled coverage across + the unit/integration/e2e/live tiers. +--- + +# Test Audit + +Two modes share one value bar. Authoring gates every new or changed test at write time. Audit +sweeps existing tests, read-only until each candidate has recorded evidence. Both apply the +junk patterns and retention bar below. See [Test quality](../../../AGENTS.md#test-quality) for +tiers, naming, and flake-resistance rules this skill assumes. + +## Authoring gate + +Before adding any test, answer all four questions. A missing answer means don't add it yet. Apply +the same questions to review suggestions that add tests or assertions, and push back when they +protect nothing: + +1. What behavior does it protect? +2. What credible regression makes it fail? +3. Why doesn't existing coverage already catch that failure? +4. Does it need a test-only production seam that only exposes internals (an export, flag, or + wrapper)? If so, move the test to the real boundary. A controlled clock, or a budget or config + value production also reads, is fine. + +## Level rule + +Each contract gets one primary test at the strongest boundary that can reach it: unit for pure +logic, integration for handlers and Effect layers, e2e for the subprocess boundary through +`runSupabase()`, live only when the command must reach the real platform or data plane. A second +level needs a distinct risk the first can't reach, not the same contract replayed. Command e2e +stays at one to three golden paths. Prefer a new row in an existing table-driven case over a +near-duplicate test. + +## Junk patterns + +Reject a new test, or flag an existing one, that matches any of these: + +- the same contract replayed at several tiers (unit, integration, e2e, live all asserting the + same branch) +- mocks or Effect test layers that implement the behavior being asserted, rather than standing in + for a real collaborator +- expected values produced by the helper under test instead of an independent source +- negative controls that pass for an unrelated reason, such as a different guard rejecting input + before the path under test ever runs +- test names that promise more than the test exercises +- real-time waits on production timeouts or backoff, such as an `it.live` case sleeping through a + retry schedule; use a controlled clock or an injected budget instead + +## Retention bar + +Always keep the test that gives a public CLI behavior, config, persistence, security, platform, +or published package contract its distinct proof. Being slow or static is never a reason to delete +it; a copy at another tier asserting the same thing can still be consolidated. + +## Regression tests + +A bug-fix test must be shown to fail on the pre-fix code for the intended reason, then pass after +the fix. A regression test that never demonstrably failed proves the mock, not the fix. + +## Audit mode + +Discovery stays read-only: no edits until every candidate has recorded evidence. Before touching +anything, record for each candidate: + +- what the test can actually detect +- the stronger proof that remains, or would remain, without it +- any test-only production seam its deletion would free + +Then mark it in a short ledger, one evidence line per mark: + +- `R` retain — names the contract and the regression it catches +- `F` fix — keep the contract, repair a weak or vacuous assertion +- `C` consolidate — names the owner absorbing it (a sibling table row, a stronger boundary suite) +- `D` delete — names the proof that remains, or that no contract exists + +Report the ledger in the audit response, or in the campaign's tracking issue when there is one, +never in a PR description. Prefer a few high-confidence candidates over a large speculative sweep. diff --git a/.claude/skills/effect b/.claude/skills/effect new file mode 120000 index 0000000000..dcea07a088 --- /dev/null +++ b/.claude/skills/effect @@ -0,0 +1 @@ +../../.agents/skills/effect \ No newline at end of file diff --git a/.claude/skills/test-audit b/.claude/skills/test-audit new file mode 120000 index 0000000000..7fb9bcd8b3 --- /dev/null +++ b/.claude/skills/test-audit @@ -0,0 +1 @@ +../../.agents/skills/test-audit \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8b94b670bc..adbacd4653 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,33 +1,29 @@ # Default all repository changes to the CLI team. * @supabase/cli -# Dependabot-only dependency surfaces. These ownerless rules override the -# catch-all above so CI-green Dependabot PRs can be auto-approved and merged. -/.github/workflows/*.yml -**/package.json -**/go.mod -**/go.sum -/apps/cli/src/shared/services/Dockerfile -/pnpm-lock.yaml -/pnpm-workspace.yaml +# Files that bots update. Either owner satisfies code-owner review; `supabase-oss` approves only +# bot-opened PRs, through bot-pr-auto-merge.yml. +/.github/workflows/*.yml @supabase/cli @supabase-oss +/package.json @supabase/cli @supabase-oss +/apps/*/package.json @supabase/cli @supabase-oss +/packages/*/package.json @supabase/cli @supabase-oss +/tools/*/package.json @supabase/cli @supabase-oss +/pnpm-lock.yaml @supabase/cli @supabase-oss +/pnpm-workspace.yaml @supabase/cli @supabase-oss +/apps/cli-go/go.mod @supabase/cli @supabase-oss +/apps/cli-go/go.sum @supabase/cli @supabase-oss +/apps/cli-go/pkg/go.mod @supabase/cli @supabase-oss +/apps/cli-go/pkg/go.sum @supabase/cli @supabase-oss +/apps/cli/src/shared/services/Dockerfile @supabase/cli @supabase-oss +/apps/cli-go/pkg/config/templates/Dockerfile @supabase/cli @supabase-oss +/packages/stack/src/Artifacts.ts @supabase/cli @supabase-oss +/apps/cli-go/api/v1-openapi.yaml @supabase/cli @supabase-oss +/apps/cli-go/pkg/api/*.gen.go @supabase/cli @supabase-oss +/packages/api/src/generated/ @supabase/cli @supabase-oss +/packages/api/scripts/openapi-source.json @supabase/cli @supabase-oss -# The AI review pipeline (workflow, supporting scripts, and prompts/schemas) -# executes trusted checkout code with API keys and can react to arbitrary -# comments/PRs; github-scripts-ci.yml tests and type-checks that same code — -# keep all of it under maintainer review rather than the ownerless Dependabot -# workflow-files rule above (which would otherwise un-own the two *.yml -# files here). Last matching pattern wins, so these restore/reassert -# ownership explicitly, even where the catch-all above already covers a path. +# These run trusted checkout code with secrets or deploy with admin credentials, so bot PRs +# touching them still need a maintainer. Last matching pattern wins. /.github/workflows/ai-review.yml @supabase/cli /.github/workflows/github-scripts-ci.yml @supabase/cli -/.github/scripts/ai-review/** @supabase/cli -/.github/ai-review/** @supabase/cli - -# The cli-artifacts infra workflow deploys with account-admin credentials from develop; -# keep it under maintainer review rather than the ownerless workflow-files rule above. /.github/workflows/cli-artifacts-infra.yml @supabase/cli - -# Generated code. These ownerless rules override the catch-all above so -# CI-green sync PRs (e.g. Management API OpenAPI spec) can be auto-merged. -/apps/cli-go/pkg/api/*.gen.go -/packages/api/src/generated/ diff --git a/.github/scripts/sync-artifacts-catalog.test.ts b/.github/scripts/sync-artifacts-catalog.test.ts index 1a266d12be..ebc4d8962e 100644 --- a/.github/scripts/sync-artifacts-catalog.test.ts +++ b/.github/scripts/sync-artifacts-catalog.test.ts @@ -51,6 +51,25 @@ function matchingS3( return async (url) => byUrl.get(url); } +/** A `fetchManifest` returning a derived service's `upstream_image`, for every native target. */ +const manifestWithUpstreamImage = + (image: string): RevisionIo["fetchManifest"] => + async () => + JSON.stringify({ upstream_image: image }); + +/** A `fetchProvenance` returning a mirrored service's `source`. */ +const provenanceWithSource = + (source: string): RevisionIo["fetchProvenance"] => + async () => + JSON.stringify({ source }); + +const unusedFetchManifest: RevisionIo["fetchManifest"] = async () => { + throw new Error("fetchManifest should not be called for this service"); +}; +const unusedFetchProvenance: RevisionIo["fetchProvenance"] = async () => { + throw new Error("fetchProvenance should not be called for this service"); +}; + /** * Runs the repo's pinned `oxfmt` binary over `source`, the way `slim-release-published.yml` * formats the catalog after every write, so parsing tests exercise real formatter output @@ -281,22 +300,13 @@ describe("planSlimUpdates", () => { ]); }); - test("both a hotfix and an upgrade can be planned in the same run", () => { - const { updates } = planSlimUpdates(plannerFixture, "postgrest", [ + test("an upgrade on a line suppresses that line's hotfix, warning about the skipped release", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ "postgrest-v16.2-r1", "postgrest-v16.4-r0", ]); expect(updates).toEqual([ - { - kind: "hotfix", - line: undefined, - branch: "slim-hotfix/postgrest", - title: "chore(stack): pin postgrest v16.2-r1", - fromRelease: "v16.2-r0", - toUpstream: "v16.2", - toRelease: "v16.2-r1", - }, { kind: "upgrade", line: undefined, @@ -307,6 +317,9 @@ describe("planSlimUpdates", () => { toRelease: "v16.4-r0", }, ]); + expect(warnings).toEqual([ + "::warning ::postgrest v16.2-r1 hotfix skipped because this line is upgrading to v16.4-r0; run --service postgrest --release v16.2-r1 to pin the hotfix alone.", + ]); }); test("an older committed upstream is a backlog republish: it plans nothing", () => { @@ -326,7 +339,7 @@ describe("planSlimUpdates", () => { ]); // The ignored major-18 tag is warned about, but doesn't block the two valid updates - // alongside it (P1: a warning must never corrupt or swallow the plan). + // alongside it: a warning never corrupts or swallows the plan. expect(warnings).toEqual([ "::warning ::postgres 18.0.0.001 is not on a release line packages/stack/src/Artifacts.ts carries for it; ignoring postgres-18.0.0.001-r0.", ]); @@ -570,7 +583,7 @@ describe("planUpdatesForService (the plan-updates transport's IO seam)", () => { }); }); -describe("waitForExpectedRelease (item A's eventual-consistency wait, call counts a subprocess can't assert)", () => { +describe("waitForExpectedRelease", () => { test("visible on the first listing: returns immediately without waiting", async () => { let calls = 0; const waits: number[] = []; @@ -645,7 +658,7 @@ describe("waitForExpectedRelease (item A's eventual-consistency wait, call count }); describe("runPlanUpdates (the actual plan-updates CLI mode, not just the pure planner)", () => { - test("an ignored tag, a valid upgrade, and the expected dispatched release: --output gets exactly the valid record, the warning reaches stdout, and the process exits 0", async () => { + test("an ignored tag, a valid upgrade, and the expected dispatched release: --output gets only the valid record, the warning reaches stdout, and the process exits 0", async () => { // Real catalog, real "postgrest" pin — a local fixture server stands in for the releases API // (`SLIM_SERVICES_RELEASES_API`), so this exercises the real subprocess: the CLI's argv // parsing, `--expect-release`'s visibility wait, the network lister, and the file/stdout @@ -794,7 +807,7 @@ describe("runPlanUpdates (the actual plan-updates CLI mode, not just the pure pl } }); - test("a newline-bearing --format is rejected, producing exactly one ::error :: line (not JSON.stringify'd; the boundary encoder is what protects it)", async () => { + test("a newline-bearing --format is rejected, producing a single ::error :: line (not JSON.stringify'd; the boundary encoder is what protects it)", async () => { const dir = await mkdtemp(join(tmpdir(), "plan-updates-bad-format-")); const outputPath = join(dir, "slim-updates.tsv"); try { @@ -863,6 +876,8 @@ describe("refreshCatalogPin", () => { fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r1", digests), imageDigest: async () => digest("h"), s3Sha256: matchingS3("analytics", "v1.50.9-r1", digests), + fetchManifest: manifestWithUpstreamImage("supabase/logflare:1.50.9"), + fetchProvenance: unusedFetchProvenance, }; const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); @@ -878,6 +893,7 @@ describe("refreshCatalogPin", () => { expect(result.source).toContain( `image: "ghcr.io/supabase/cli/analytics:v1.50.9-r1@${digest("h")}"`, ); + expect(result.source).toContain('upstreamImage: "supabase/logflare:1.50.9"'); }); test("a resolved pin survives real formatting and can be refreshed again", async () => { @@ -887,6 +903,8 @@ describe("refreshCatalogPin", () => { fetchChecksums: async () => checksumsFor("postgrest", "v16.2-r0", first), imageDigest: async () => digest("j"), s3Sha256: matchingS3("postgrest", "v16.2-r0", first), + fetchManifest: manifestWithUpstreamImage("postgrest/postgrest:v16.2"), + fetchProvenance: unusedFetchProvenance, }; const written = await refreshCatalogPin({ catalog: fixture, @@ -906,6 +924,8 @@ describe("refreshCatalogPin", () => { fetchChecksums: async () => checksumsFor("postgrest", "v16.2-r1", second), imageDigest: async () => digest("l"), s3Sha256: matchingS3("postgrest", "v16.2-r1", second), + fetchManifest: manifestWithUpstreamImage("postgrest/postgrest:v16.2"), + fetchProvenance: unusedFetchProvenance, }; const refreshed = await refreshCatalogPin({ catalog: formatted, @@ -922,6 +942,7 @@ describe("refreshCatalogPin", () => { }); expect(refreshed.source).toContain("revision: 1"); expect(refreshed.source).toContain(second["darwin-arm64"].archive); + expect(refreshed.source).toContain('upstreamImage: "postgrest/postgrest:v16.2"'); }); test("refreshes the Postgres 15 additional pin, including after formatting", async () => { @@ -931,6 +952,8 @@ describe("refreshCatalogPin", () => { fetchChecksums: async () => checksumsFor("postgres", "15.14.1.168-r0", digests), imageDigest: async () => digest("n"), s3Sha256: matchingS3("postgres", "15.14.1.168-r0", digests), + fetchManifest: manifestWithUpstreamImage("supabase/postgres:15.14.1.168"), + fetchProvenance: unusedFetchProvenance, }; const written = await refreshCatalogPin({ @@ -956,6 +979,8 @@ describe("refreshCatalogPin", () => { fetchChecksums: async () => checksumsFor("postgres", "15.14.1.168-r1", nextDigests), imageDigest: async () => digest("p"), s3Sha256: matchingS3("postgres", "15.14.1.168-r1", nextDigests), + fetchManifest: manifestWithUpstreamImage("supabase/postgres:15.14.1.168"), + fetchProvenance: unusedFetchProvenance, }; const refreshed = await refreshCatalogPin({ @@ -974,6 +999,7 @@ describe("refreshCatalogPin", () => { }); expect(refreshed.source).toContain(postgres17Image); expect(refreshed.source).toContain(nextDigests["linux-arm64"].manifest); + expect(refreshed.source).toContain('upstreamImage: "supabase/postgres:15.14.1.168"'); }); test("moves the Postgres 15 additional pin to a new upstream version, updating its key", async () => { @@ -983,6 +1009,8 @@ describe("refreshCatalogPin", () => { fetchChecksums: async () => checksumsFor("postgres", "15.19.0.002-r0", digests), imageDigest: async () => digest("v"), s3Sha256: matchingS3("postgres", "15.19.0.002-r0", digests), + fetchManifest: manifestWithUpstreamImage("supabase/postgres:15.19.0.002"), + fetchProvenance: unusedFetchProvenance, }; const written = await refreshCatalogPin({ @@ -1012,6 +1040,8 @@ describe("refreshCatalogPin", () => { fetchChecksums: async () => checksumsFor("postgres", "15.19.0.002-r1", nextDigests), imageDigest: async () => digest("x"), s3Sha256: matchingS3("postgres", "15.19.0.002-r1", nextDigests), + fetchManifest: manifestWithUpstreamImage("supabase/postgres:15.19.0.002"), + fetchProvenance: unusedFetchProvenance, }; const refreshed = await refreshCatalogPin({ catalog: formatted, @@ -1028,6 +1058,7 @@ describe("refreshCatalogPin", () => { target: "additional", }); expect(refreshed.source).toContain(nextDigests["linux-arm64"].manifest); + expect(refreshed.source).toContain('upstreamImage: "supabase/postgres:15.19.0.002"'); }); }); @@ -1054,6 +1085,8 @@ describe("resolveRevisionPin waits for the S3 mirror", () => { } return present(url); }, + fetchManifest: unusedFetchManifest, + fetchProvenance: unusedFetchProvenance, wait: async (ms) => { waits.push(ms); }, @@ -1075,6 +1108,8 @@ describe("resolveRevisionPin waits for the S3 mirror", () => { fetchChecksums: async () => checksumsFor("postgrest", releaseVersion, digests), imageDigest: async () => digest("s3-mismatch-digest"), s3Sha256: async () => hex("wrong-bytes"), + fetchManifest: unusedFetchManifest, + fetchProvenance: unusedFetchProvenance, wait: async (ms) => { waits.push(ms); }, @@ -1097,6 +1132,8 @@ describe("resolveRevisionPin waits for the S3 mirror", () => { fetchChecksums: async () => checksumsFor("postgrest", releaseVersion, digests), imageDigest: async () => digest("s3-never-digest"), s3Sha256: async () => undefined, + fetchManifest: unusedFetchManifest, + fetchProvenance: unusedFetchProvenance, wait: async (ms) => { waits.push(ms); }, @@ -1113,13 +1150,15 @@ describe("resolveRevisionPin waits for the S3 mirror", () => { }); describe("refreshCatalogPin --release", () => { - test("pins exactly the requested committed release, not the highest one", async () => { + test("pins the requested committed release, not the highest one", async () => { const digests = nativeDigests("release-0"); const io: RevisionIo = { listReleaseTags: async () => ["postgrest-v16.2-r0", "postgrest-v16.2-r1"], fetchChecksums: async () => checksumsFor("postgrest", "v16.2-r0", digests), imageDigest: async () => digest("release-digest-0"), s3Sha256: matchingS3("postgrest", "v16.2-r0", digests), + fetchManifest: manifestWithUpstreamImage("postgrest/postgrest:v16.2"), + fetchProvenance: unusedFetchProvenance, }; const result = await refreshCatalogPin({ @@ -1144,6 +1183,8 @@ describe("refreshCatalogPin --release", () => { fetchChecksums: async () => undefined, imageDigest: async () => undefined, s3Sha256: async () => undefined, + fetchManifest: unusedFetchManifest, + fetchProvenance: unusedFetchProvenance, }; await expect( @@ -1157,6 +1198,8 @@ describe("refreshCatalogPin --release", () => { fetchChecksums: async () => undefined, imageDigest: async () => undefined, s3Sha256: async () => undefined, + fetchManifest: unusedFetchManifest, + fetchProvenance: unusedFetchProvenance, }; await expect( @@ -1180,6 +1223,7 @@ describe("refreshCatalogPin backfills upstreamImage", () => { imageDigest: async () => digest("z"), s3Sha256: matchingS3("analytics", "v1.50.9-r1", digests), fetchManifest: async () => JSON.stringify({ upstream_image: "supabase/logflare:1.50.9" }), + fetchProvenance: unusedFetchProvenance, }; const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); @@ -1195,6 +1239,7 @@ describe("refreshCatalogPin backfills upstreamImage", () => { imageDigest: async () => digest("ab"), s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), fetchManifest: async () => JSON.stringify({ source_image: "supabase/logflare:1.50.9" }), + fetchProvenance: unusedFetchProvenance, }; const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); @@ -1211,6 +1256,7 @@ describe("refreshCatalogPin backfills upstreamImage", () => { s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), fetchManifest: async () => JSON.stringify({ upstream_image: "docker.io/supabase/logflare:1.50.9@sha256:deadbeef" }), + fetchProvenance: unusedFetchProvenance, }; const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); @@ -1230,6 +1276,7 @@ describe("refreshCatalogPin backfills upstreamImage", () => { call += 1; return JSON.stringify({ upstream_image: `supabase/logflare:1.50.${call}` }); }, + fetchProvenance: unusedFetchProvenance, }; await expect(refreshCatalogPin({ catalog: fixture, service: "analytics", io })).rejects.toThrow( @@ -1244,8 +1291,8 @@ describe("refreshCatalogPin backfills upstreamImage", () => { fetchChecksums: async () => checksumsFor("vector", "0.53.0-r0", digests), imageDigest: async () => digest("ah"), s3Sha256: matchingS3("vector", "0.53.0-r0", digests), - fetchProvenance: async () => - JSON.stringify({ source: "docker.io/timberio/vector:0.53.0-alpine" }), + fetchManifest: unusedFetchManifest, + fetchProvenance: provenanceWithSource("docker.io/timberio/vector:0.53.0-alpine"), }; const result = await refreshCatalogPin({ catalog: vectorFixture, service: "vector", io }); @@ -1253,20 +1300,6 @@ describe("refreshCatalogPin backfills upstreamImage", () => { expect(result.source).toContain('upstreamImage: "timberio/vector:0.53.0-alpine"'); }); - test("leaves upstreamImage absent when io has no manifest/provenance fetchers", async () => { - const digests = nativeDigests("ai"); - const io: RevisionIo = { - listReleaseTags: async () => ["analytics-v1.50.9-r0"], - fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), - imageDigest: async () => digest("aj"), - s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), - }; - - const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); - - expect(result.source).not.toContain("upstreamImage"); - }); - test("rejects a manifest upstream_image carrying a quote or template expression, writing nothing", async () => { const digests = nativeDigests("ak"); const io: RevisionIo = { @@ -1276,6 +1309,7 @@ describe("refreshCatalogPin backfills upstreamImage", () => { s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), fetchManifest: async () => JSON.stringify({ upstream_image: 'supabase/logflare:1.50.9"] }; import("evil"); //' }), + fetchProvenance: unusedFetchProvenance, }; await expect(refreshCatalogPin({ catalog: fixture, service: "analytics", io })).rejects.toThrow( @@ -1298,6 +1332,8 @@ describe("against the real catalog", () => { fetchChecksums: async () => checksumsFor("auth", `${pinnedVersion}-r0`, first), imageDigest: async () => digest("r"), s3Sha256: matchingS3("auth", `${pinnedVersion}-r0`, first), + fetchManifest: manifestWithUpstreamImage(`supabase/gotrue:${pinnedVersion}`), + fetchProvenance: unusedFetchProvenance, }; const written = await refreshCatalogPin({ catalog, service: "auth", io: firstIo }); expect(written.update?.revision).toBe(0); @@ -1311,6 +1347,8 @@ describe("against the real catalog", () => { fetchChecksums: async () => checksumsFor("auth", `${pinnedVersion}-r1`, second), imageDigest: async () => digest("t"), s3Sha256: matchingS3("auth", `${pinnedVersion}-r1`, second), + fetchManifest: manifestWithUpstreamImage(`supabase/gotrue:${pinnedVersion}`), + fetchProvenance: unusedFetchProvenance, }; const refreshed = await refreshCatalogPin({ catalog: formatted, @@ -1327,5 +1365,6 @@ describe("against the real catalog", () => { }); expect(refreshed.source).toContain("revision: 1"); expect(refreshed.source).toContain(second["darwin-arm64"].manifest); + expect(refreshed.source).toContain(`upstreamImage: "supabase/gotrue:${pinnedVersion}"`); }); }); diff --git a/.github/scripts/sync-artifacts-catalog.ts b/.github/scripts/sync-artifacts-catalog.ts index 0ec85a6b02..e568e07ab4 100644 --- a/.github/scripts/sync-artifacts-catalog.ts +++ b/.github/scripts/sync-artifacts-catalog.ts @@ -1,43 +1,6 @@ /** - * Pins `packages/stack/src/Artifacts.ts` to committed `supabase/slim-services` - * revisions (`-r`). Every entry is pinned by content: the GHCR - * image digest, plus an archive and manifest sha256 per native target. - * - * The catalog is the single version table (supabase/cli#6883): the Dockerfile's - * slim-capable lines are a generated view of it - * (`apps/cli/scripts/render-service-dockerfile.ts`), never the other way - * around. Updates land through two modes: - * - * Manual mode: refreshes one catalog entry, either to a specific committed - * release (`--release`) or to the highest committed revision of a given - * upstream version, or of its currently pinned upstream version when neither - * is given. `--upstream` and `--release` are mutually exclusive. - * - * bun .github/scripts/sync-artifacts-catalog.ts --service [--upstream | --release -r] - * - * Plan-updates mode (used by the `slim-release-published` dispatch workflow): - * lists a service's committed slim-services releases and computes, per - * release line, the hotfix and/or upgrade a workflow should apply. Pure - * planning: `planSlimUpdates` takes the release tag list as an argument, - * makes no network calls, and never logs — it returns `{ updates, warnings }`. - * Records go only to `--output ` (never stdout); warnings print to - * stdout as `::warning ::…` lines, so the two channels can't corrupt one - * another when a caller redirects stdout separately from the records file. - * - * bun .github/scripts/sync-artifacts-catalog.ts plan-updates --service \ - * --output [--format lines] [--expect-release -r] - * - * `--expect-release` handles the releases API lagging behind the dispatch that triggered this - * run: before planning, the listed committed tags must include `-`, or - * this mode re-lists a bounded number of times before giving up (`waitForExpectedRelease`). - * - * Validate-payload mode (used by the same workflow, before anything else): - * checks an untrusted `slim-release-published` dispatch payload against - * anchored charsets and prints it back as `key=value` lines, so a value that - * fails validation is never written to `$GITHUB_OUTPUT` in the first place. - * - * bun .github/scripts/sync-artifacts-catalog.ts validate-payload --service \ - * --upstream --revision --release + * Pins `packages/stack/src/Artifacts.ts` to committed `supabase/slim-services` revisions. See + * `docs/adr/0026-slim-artifact-mirrors.md` for the catalog model and this script's three modes. */ import { @@ -54,7 +17,7 @@ export const CATALOG_PATH = "packages/stack/src/Artifacts.ts"; const SLIM_IMAGE_PREFIX = `${SOURCE_REGISTRY}/`; -/** The three native targets the catalog pins per revision. Kept self-contained; see module docs. */ +/** Native targets the catalog pins per revision, in `Artifacts.ts`'s serialization order. */ const NATIVE_TARGETS = ["darwin-arm64", "linux-amd64", "linux-arm64"] as const; type NativeTargetName = (typeof NATIVE_TARGETS)[number]; @@ -67,7 +30,7 @@ const RELEASE_DOWNLOAD_BASE = "https://github.com/supabase/slim-services/release /** Bounded retry for `waitForExpectedRelease`: 6 attempts, 10s apart, by default. */ const EXPECT_RELEASE_ATTEMPTS = 6; const DEFAULT_EXPECT_RELEASE_INTERVAL_MS = 10_000; -/** Bounded retry for the S3-mirror wait in `resolveRevisionPin`: ~10 min, covering a native upload of all three targets (`mirror-slim-image.yml`'s `upload-natives-s3`). */ +/** ~10 min for `resolveRevisionPin`'s S3-mirror wait: `upload-natives-s3` for all three targets. */ const S3_WAIT_ATTEMPTS = 20; const DEFAULT_S3_WAIT_INTERVAL_MS = 30_000; const MAX_TIMER_DELAY_MS = 2 ** 31 - 1; // setTimeout's own ceiling. @@ -109,12 +72,7 @@ type BoundedWaitOutcome = | { readonly status: "timed-out" } | { readonly status: "failed"; readonly message: string }; -/** - * Generic bounded retry-with-wait: `waitForExpectedRelease`'s release-visibility wait and - * `resolveRevisionPin`'s S3-mirror wait both build on this. Calls `check` up to `attempts` - * times, `wait`-ing `intervalMs` between, until it reports `"done"` or an unrecoverable - * `"failed"` (never waited out). Exhausting every attempt on `"retry"` yields `"timed-out"`. - */ +/** Calls `check` up to `attempts` times, waiting between, until it reports `done` or `failed`. */ async function boundedWait( check: () => Promise>, wait: (ms: number) => Promise, @@ -196,21 +154,19 @@ export interface CatalogPinUpdate { readonly target: "default" | "additional"; } -/** Content pin for one resolved `-r` revision, ready to serialize into the catalog. */ +/** Content pin for one resolved `-r` revision, without its `upstreamImage`. */ interface ResolvedPin { readonly upstreamVersion: string; readonly revision: number; readonly image: string; - /** - * The pin's `ArtifactPin.upstreamImage`. Populated by manual mode (`refreshCatalogPin`) via - * `resolveUpstreamImage` below, when `io` carries `fetchManifest`/`fetchProvenance`. - */ - readonly upstreamImage?: string; readonly natives: Readonly< Record >; } +/** A `ResolvedPin` plus its `ArtifactPin.upstreamImage`, ready to serialize into the catalog. */ +type CatalogPin = ResolvedPin & { readonly upstreamImage: string }; + export type RevisionResolution = | { readonly status: "resolved"; readonly pin: ResolvedPin } | { @@ -234,20 +190,18 @@ export interface RevisionIo { /** * Raw contents of a native target's `.manifest.json` release asset, for a derived service's * `upstream_image` (or `source_image` on an image-derived build, e.g. postgrest's Linux - * targets). Optional: only manual mode's `upstreamImage` backfill (`resolveUpstreamImage`) - * calls this, so a test `io` that doesn't exercise that path can omit it. + * targets). */ - readonly fetchManifest?: ( + readonly fetchManifest: ( service: string, releaseVersion: string, target: NativeTargetName, ) => Promise; /** * Raw contents of a mirrored service's `-.oci-provenance.json` - * release asset, whose `source` field is the mirrored upstream image. Optional for the same - * reason as `fetchManifest`. + * release asset, whose `source` field is the mirrored upstream image. */ - readonly fetchProvenance?: ( + readonly fetchProvenance: ( service: string, releaseVersion: string, upstreamVersion: string, @@ -266,9 +220,8 @@ function desiredImage(service: string, releaseVersion: string, digest: string): * manifest digest, and every native target's archive and manifest sha256, cross-checked against * the S3 mirror copy. * - * With `requiredRevision` given, that exact revision must already be committed — this is what - * pins exactly a planned release (`--release`), never "highest at apply time". Without it, the - * highest committed revision of `upstream` is used (`--upstream`, and the hotfix/upgrade default). + * With `requiredRevision` given (`--release`), that revision must already be committed; it is + * never "highest at apply time". Without it, the highest committed revision of `upstream` is used. */ export async function resolveRevisionPin( service: string, @@ -449,7 +402,7 @@ function validateUpstreamImage(image: string, context: string): string { * manifest `upstream_image` (falling back to `source_image` for an image-derived build, e.g. * postgrest's Linux targets) — cross-checked across every native target, so a derived service * whose manifests disagree fails instead of silently picking one. Only manual mode - * (`refreshCatalogPin`) calls this; `io` must carry `fetchManifest`/`fetchProvenance`. + * (`refreshCatalogPin`) calls this. */ async function resolveUpstreamImage( service: string, @@ -458,11 +411,6 @@ async function resolveUpstreamImage( io: RevisionIo, ): Promise { if (MIRROR_MODE_SOURCE_SERVICES.has(service)) { - if (io.fetchProvenance === undefined) { - throw new InvalidPayloadError( - `${service} needs an io.fetchProvenance to resolve upstreamImage.`, - ); - } const provenance = await io.fetchProvenance(service, releaseVersion, upstreamVersion); if (provenance === undefined) { throw new InvalidPayloadError(`${service}-${releaseVersion} has no oci-provenance asset.`); @@ -476,9 +424,6 @@ async function resolveUpstreamImage( return validateUpstreamImage(normalizeUpstreamImage(source), `${service}-${releaseVersion}`); } - if (io.fetchManifest === undefined) { - throw new InvalidPayloadError(`${service} needs an io.fetchManifest to resolve upstreamImage.`); - } const values = new Set(); for (const target of NATIVE_TARGETS) { const manifest = await io.fetchManifest(service, releaseVersion, target); @@ -513,14 +458,12 @@ async function resolveUpstreamImage( * text is written straight into TypeScript source that later gets imported, so an unescaped * quote or template expression in any field (release metadata included) would inject code. */ -function serializePin(pin: ResolvedPin): string { +function serializePin(pin: CatalogPin): string { const natives = NATIVE_TARGETS.map((target) => { const native = pin.natives[target]; return `${JSON.stringify(target)}: { archive: ${JSON.stringify(native.archive)}, manifest: ${JSON.stringify(native.manifest)} }`; }).join(", "); - const upstreamImage = - pin.upstreamImage === undefined ? "" : ` upstreamImage: ${JSON.stringify(pin.upstreamImage)},`; - return `{ upstreamVersion: ${JSON.stringify(pin.upstreamVersion)}, revision: ${pin.revision}, image: ${JSON.stringify(pin.image)},${upstreamImage} natives: { ${natives} } }`; + return `{ upstreamVersion: ${JSON.stringify(pin.upstreamVersion)}, revision: ${pin.revision}, image: ${JSON.stringify(pin.image)}, upstreamImage: ${JSON.stringify(pin.upstreamImage)}, natives: { ${natives} } }`; } interface PinSpan { @@ -568,7 +511,7 @@ const PIN_UPSTREAM_VERSION = /upstreamVersion:\s*"([^"]+)"/; const PIN_REVISION = /revision:\s*(\d+)/; /** - * Matches a resolved `ArtifactPin` object literal starting exactly at `index`. The span is found + * Matches a resolved `ArtifactPin` object literal that starts at `index`. The span is found * by bracket balance, then the version is pulled out with a loose field search — so a formatter's * whitespace, line breaks, trailing commas, or property order never break matching, only the * literal shape itself would. @@ -729,7 +672,7 @@ const normalizeText = (text: string): string => text.replace(/\s+/g, " ").trim() function writePin( source: string, entry: Extract, - pin: ResolvedPin, + pin: CatalogPin, ): { readonly source: string; readonly changed: boolean } { const desired = serializePin(pin); const current = source.slice(entry.span.start, entry.span.end); @@ -821,24 +764,10 @@ export interface PlanSlimUpdatesResult { const SINGLE_LINE_KEY = "*"; /** - * The hotfix and/or upgrade a `slim-release-published` run should apply for `service`, computed - * against `catalog`'s current pins and `releaseTags` (every committed — published, non-draft — - * slim-services release tag; the caller filters drafts before calling this). Pure: no network, no - * file I/O, no logging — every diagnostic comes back in `warnings` instead, so a caller (the CLI, - * or a test) decides where it goes. This matters because `plan-updates` writes `updates` straight - * into a file the workflow parses as records; a `console.log`'d warning on the same stdout the - * workflow captures would corrupt that file instead of just being informational. - * - * Per release line (the default pin's line, plus one per additional pin — only postgres - * has more than one) a **hotfix** fires when the pinned upstream has a committed revision higher - * than the pinned one; an **upgrade** fires when the newest committed upstream on the line is - * newer than the pinned one (ties, e.g. two Studio builds dated the same day, are not newer). - * Both can fire in the same run. A service with no additional pins has exactly one line and - * accepts any comparable upstream on it — a Studio year rollover or a postgrest major bump is - * the same line moving forward, not a different one, so it is never filtered by `releaseLine`. - * Only a service that does carry additional pins (postgres) filters a release tag to the line its - * `releaseLine` names; a tag on no such line, or whose version isn't comparable, is warned about - * and ignored — it never causes a plan-updates run to fail. + * The upgrade or hotfix each of `service`'s release lines should get, planned against `catalog`'s + * pins and the committed (non-draft) `releaseTags`; the rules are in ADR 0026, "Hotfix and upgrade + * pickup". Never logs: `plan-updates` writes `updates` to a file the workflow parses as records, + * so diagnostics come back in `warnings`. */ export function planSlimUpdates( catalog: string, @@ -907,14 +836,8 @@ export function planSlimUpdates( const sameUpstreamRevisions = candidates .filter((candidate) => candidate.upstream === pinned.upstream) .map((candidate) => candidate.revision); - if (sameUpstreamRevisions.length > 0) { - const highest = Math.max(...sameUpstreamRevisions); - if (highest > pinned.revision) { - updates.push( - buildUpdate("hotfix", service, line, hasLines, pinned, pinned.upstream, highest), - ); - } - } + const hotfixRevision = + sameUpstreamRevisions.length > 0 ? Math.max(...sameUpstreamRevisions) : undefined; const highestRevisionByUpstream = new Map(); for (const candidate of candidates) { @@ -940,9 +863,23 @@ export function planSlimUpdates( bestUpstream = upstream; } } + if (bestUpstream !== undefined && compareVersions(bestUpstream, pinned.upstream) === 1) { const revision = highestRevisionByUpstream.get(bestUpstream) as number; updates.push(buildUpdate("upgrade", service, line, hasLines, pinned, bestUpstream, revision)); + if (hotfixRevision !== undefined && hotfixRevision > pinned.revision) { + const skippedRelease = `${pinned.upstream}-r${hotfixRevision}`; + warnings.push( + workflowCommand( + "warning", + `${service} ${skippedRelease} hotfix skipped because this line is upgrading to ${bestUpstream}-r${revision}; run --service ${service} --release ${skippedRelease} to pin the hotfix alone.`, + ), + ); + } + } else if (hotfixRevision !== undefined && hotfixRevision > pinned.revision) { + updates.push( + buildUpdate("hotfix", service, line, hasLines, pinned, pinned.upstream, hotfixRevision), + ); } } @@ -1009,7 +946,7 @@ export interface CatalogRefreshResult { } /** - * Refreshes one catalog entry: to exactly the committed release named by `release` (`-r`, + * Refreshes one catalog entry: to the committed release named by `release` (`-r`, * required to already be committed — never "highest at apply time"), or to the highest committed * revision of `upstream` (or, when both are omitted, of the entry's currently pinned upstream * version). `upstream` and `release` are mutually exclusive. Pure aside from `io`: callers own @@ -1059,20 +996,15 @@ export async function refreshCatalogPin(input: { if (resolution.status !== "resolved") { throw new InvalidPayloadError(resolution.message); } - // Manual mode also backfills `upstreamImage`, so a plain `io` (most tests) can still exercise - // revision resolution without stubbing the extra fetchers. - const pin = - input.io.fetchManifest === undefined && input.io.fetchProvenance === undefined - ? resolution.pin - : { - ...resolution.pin, - upstreamImage: await resolveUpstreamImage( - input.service, - `${resolution.pin.upstreamVersion}-r${resolution.pin.revision}`, - resolvedUpstream, - input.io, - ), - }; + const pin: CatalogPin = { + ...resolution.pin, + upstreamImage: await resolveUpstreamImage( + input.service, + `${resolution.pin.upstreamVersion}-r${resolution.pin.revision}`, + resolvedUpstream, + input.io, + ), + }; const written = writePin(input.catalog, entry, pin); if (!written.changed) return { source: input.catalog }; return { diff --git a/.github/workflows/api-package-sync.yml b/.github/workflows/api-package-sync.yml index 14cc9ed34b..d651f7b49d 100644 --- a/.github/workflows/api-package-sync.yml +++ b/.github/workflows/api-package-sync.yml @@ -54,7 +54,6 @@ jobs: - name: Create Pull Request if: steps.check.outputs.has_changes == 'true' - id: cpr uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ steps.app-token.outputs.token }} @@ -66,10 +65,3 @@ jobs: Changes were detected in the upstream OpenAPI documents exposed by `https://api.supabase.com/api/v1-json` and `https://api.supabase.com/api/v2-json`. branch: sync/api-package base: develop - - - name: Enable Pull Request Automerge - if: steps.check.outputs.has_changes == 'true' - run: gh pr merge --auto --squash --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml deleted file mode 100644 index ad1da2f19b..0000000000 --- a/.github/workflows/automerge.yml +++ /dev/null @@ -1,46 +0,0 @@ -# Adapted from https://blog.somewhatabstract.com/2021/10/11/setting-up-dependabot-with-github-actions-to-approve-and-merge/ -name: Dependabot auto-merge - -on: pull_request - -permissions: - contents: read - -jobs: - dependabot: - runs-on: ubuntu-latest - # Checking the actor will prevent your Action run failing on non-Dependabot - # PRs but also ensures that it only does work for Dependabot PRs. - if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name - steps: - # This first step will fail if there's no metadata and so the approval - # will not occur. - - name: Dependabot metadata - id: meta - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - - name: Generate token - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.GH_APP_CLIENT_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - permission-pull-requests: write - permission-contents: write - - # Here the PR gets approved. - - name: Approve a PR - run: gh pr review --approve "${GITHUB_EVENT_PULL_REQUEST_HTML_URL}" - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - GITHUB_EVENT_PULL_REQUEST_HTML_URL: ${{ github.event.pull_request.html_url }} - - # Finally, this sets the PR to allow auto-merging once all required - # checks pass. - - name: Enable auto-merge for Dependabot PRs - run: gh pr merge --auto --squash "${GITHUB_EVENT_PULL_REQUEST_HTML_URL}" - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - GITHUB_EVENT_PULL_REQUEST_HTML_URL: ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/bot-pr-auto-merge.yml b/.github/workflows/bot-pr-auto-merge.yml new file mode 100644 index 0000000000..362a12ae41 --- /dev/null +++ b/.github/workflows/bot-pr-auto-merge.yml @@ -0,0 +1,58 @@ +name: Bot PR auto-merge + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + branches: [develop] + +permissions: {} + +jobs: + approve-and-merge: + name: Approve and enable auto-merge + runs-on: ubuntu-latest + # Other apps (coding agents, CI vendors) also open PRs here and still need a maintainer. + # Release-notes PRs are published by a maintainer approval and must never merge. + if: >- + contains(fromJSON('["dependabot[bot]", "supabase-cli-releaser[bot]"]'), github.event.pull_request.user.login) && + contains(fromJSON('["dependabot[bot]", "supabase-cli-releaser[bot]"]'), github.event.sender.login) && + github.event.pull_request.head.repo.full_name == github.repository && + !github.event.pull_request.draft && + !startsWith(github.event.pull_request.head.ref, 'release-notes/') + # Holds `AUTO_APPROVE_PR_PAT`; the environment only deploys from the default branch. + environment: auto-approve + steps: + # pull_request_target is safe here because this job never checks out PR code. + - name: Generate token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.GH_APP_CLIENT_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-pull-requests: write + permission-contents: write + + - name: Assign supabase-oss + run: gh pr edit --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" --add-assignee supabase-oss + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + + # A rewritten PR keeps its approval; skipping it avoids stacking a review per push. + - name: Approve pull request + run: | + set -euo pipefail + decision="$(GH_TOKEN="$APP_TOKEN" gh pr view --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" --json reviewDecision --jq .reviewDecision)" + if [ "$decision" != "APPROVED" ]; then + gh pr review --approve --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" + fi + env: + GH_TOKEN: ${{ secrets.AUTO_APPROVE_PR_PAT }} + APP_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + + - name: Enable auto-merge + run: gh pr merge --auto --squash --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/cli-go-api-sync.yml b/.github/workflows/cli-go-api-sync.yml index 7593731443..0ca181493c 100644 --- a/.github/workflows/cli-go-api-sync.yml +++ b/.github/workflows/cli-go-api-sync.yml @@ -71,7 +71,6 @@ jobs: - name: Create Pull Request if: steps.check.outputs.has_changes == 'true' - id: cpr uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ steps.app-token.outputs.token }} @@ -83,20 +82,6 @@ jobs: 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. branch: sync/api-types base: develop - - - name: Approve Pull Request - if: steps.check.outputs.has_changes == 'true' - run: gh pr review --approve --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" - env: - GH_TOKEN: ${{ secrets.AUTO_APPROVE_PR_PAT }} - STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} - - - name: Enable Pull Request Automerge - if: steps.check.outputs.has_changes == 'true' - run: gh pr merge --auto --squash --repo "${{ github.repository }}" "${STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER}" - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - STEPS_CPR_OUTPUTS_PULL_REQUEST_NUMBER: ${{ steps.cpr.outputs.pull-request-number }} defaults: run: working-directory: apps/cli-go diff --git a/.github/workflows/live-e2e-notify.yml b/.github/workflows/live-e2e-notify.yml index 5307ebb2ec..66edf219b7 100644 --- a/.github/workflows/live-e2e-notify.yml +++ b/.github/workflows/live-e2e-notify.yml @@ -10,25 +10,24 @@ permissions: contents: read jobs: - notify: + classify: if: github.event.workflow_run.conclusion == 'failure' || github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest timeout-minutes: 5 continue-on-error: true + outputs: + suite: ${{ steps.classify.outputs.suite }} + startup_failure: ${{ steps.classify.outputs.startup_failure }} steps: - - name: Notify Slack + - name: Classify suite result + id: classify env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} RUN_ID: ${{ github.event.workflow_run.id }} - RUN_NUMBER: ${{ github.event.workflow_run.run_number }} RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} - WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }} WORKFLOW_NAME: ${{ github.event.workflow_run.name }} - SHA: ${{ github.event.workflow_run.head_sha }} - BRANCH: ${{ github.event.workflow_run.head_branch }} CONCLUSION: ${{ github.event.workflow_run.conclusion }} - SLACK_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} run: | set -euo pipefail @@ -50,74 +49,148 @@ jobs: elif [[ "$current_suite" == none || ("$current_suite" == failure && "$WORKFLOW_NAME" == "Release") ]]; then # Release's generic notification covers release failures and runs # that reused a staging result without creating a suite job. + echo "Current ${WORKFLOW_NAME} suite: ${current_suite}; nothing to notify" exit 0 fi + echo "Current ${WORKFLOW_NAME} suite: ${current_suite}" + { + echo "suite=${current_suite}" + echo "startup_failure=${startup_failure}" + } >> "$GITHUB_OUTPUT" + + notify: + needs: classify + if: needs.classify.outputs.suite != '' + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + # A group keeps one pending job and replaces it when another arrives, so + # only real suite results take the lock. + concurrency: + group: live-e2e-notify-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + steps: + - name: Notify Slack + id: notify + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + SHA: ${{ github.event.workflow_run.head_sha }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + SOURCE_CREATED_AT: ${{ github.event.workflow_run.created_at }} + NOTIFY_WORKFLOW_REF: ${{ github.workflow_ref }} + CURRENT_SUITE: ${{ needs.classify.outputs.suite }} + STARTUP_FAILURE: ${{ needs.classify.outputs.startup_failure }} + SLACK_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} + run: | + set -euo pipefail + + # The artifact holds the newest applied result and its source run's creation + # time, so late older results are ignored. Fork runs can upload the same + # artifact name, so only same-repository runs count. + branch_digest="$(printf '%s' "$BRANCH" | sha256sum | cut -c1-12)" + state_artifact="live-e2e-alert-state-${BRANCH//[^A-Za-z0-9._-]/-}-${branch_digest}" + state_dir="${RUNNER_TEMP}/live-e2e-alert-state" + mkdir -p "$state_dir" + + artifact_id="" + lookup_ok=true + for page in $(seq 1 20); do + if ! artifacts="$(gh api -X GET "/repos/${REPOSITORY}/actions/artifacts" -f "name=${state_artifact}" -F per_page=100 -F "page=${page}")"; then + lookup_ok=false + break + fi + artifact_id="$(jq -r '[.artifacts[]? | select(.expired != true and .workflow_run.repository_id != null and .workflow_run.head_repository_id == .workflow_run.repository_id)] | sort_by(.created_at) | last // empty | .id' <<< "$artifacts")" + if [[ -n "$artifact_id" ]] || (( $(jq '.artifacts | length' <<< "$artifacts") < 100 )); then + break + fi + done - prior_conclusion="" - if (( RUN_ATTEMPT > 1 )); then - if ! prior_jobs="$(gh api --paginate "/repos/${REPOSITORY}/actions/runs/${RUN_ID}/attempts/$((RUN_ATTEMPT - 1))/jobs?per_page=100")"; then - echo "::warning::Could not inspect the prior attempt for ${WORKFLOW_NAME} run ${RUN_ID}; suppressing transition notification." - exit 0 + recorded_state=unknown + recorded_at="" + if [[ "$lookup_ok" == true && -z "$artifact_id" ]]; then + recorded_state=none + elif [[ -n "$artifact_id" ]] \ + && gh api "/repos/${REPOSITORY}/actions/artifacts/${artifact_id}/zip" > "${state_dir}/previous.zip" \ + && stored="$(unzip -p "${state_dir}/previous.zip" state)"; then + stored_state="$(sed -n 1p <<< "$stored")" + if [[ "$stored_state" == failing || "$stored_state" == passing ]]; then + recorded_state="$stored_state" + recorded_at="$(sed -n 2p <<< "$stored")" fi - prior_conclusion="$(printf '%s\n' "$prior_jobs" | jq -s '{jobs: map(.jobs[])}' | suite_conclusion)" - [[ "$prior_conclusion" != none ]] || prior_conclusion="" fi + echo "Recorded state for ${BRANCH}: ${recorded_state} (source created ${recorded_at:-unknown}); this run's source created ${SOURCE_CREATED_AT}" - if ! history="$(gh api -X GET "/repos/${REPOSITORY}/actions/workflows/${WORKFLOW_ID}/runs" -f "branch=${BRANCH}" -F per_page=25)"; then - if [[ "$current_suite" == success && -z "$prior_conclusion" ]]; then - echo "::warning::Could not inspect recent ${WORKFLOW_NAME} history; suppressing recovery notification." - exit 0 + if [[ -n "$recorded_at" && "$SOURCE_CREATED_AT" < "$recorded_at" ]]; then + echo "Action: none (a newer suite result is already recorded)" + exit 0 + fi + + # A recorded failure is only saved after its alert was posted, so a + # recovery posts at most once per reported failure. + header="" + if [[ "$CURRENT_SUITE" == success ]]; then + new_state=passing + if [[ "$recorded_state" == failing ]]; then + header="✅ Live E2E recovered" + text="Live E2E recovered" + detail="Live E2E is passing again after the failure reported earlier." + else + echo "Action: recorded ${BRANCH} as passing" fi - echo "::warning::Could not inspect recent ${WORKFLOW_NAME} history; continuing with the confirmed current result." else - candidates="$(jq -r --argjson current_number "$RUN_NUMBER" --argjson current_attempt "$RUN_ATTEMPT" '(.workflow_runs // []) | map(select((.run_number != $current_number or (.run_attempt // 1) != $current_attempt) and .status == "completed" and (.conclusion == "failure" or .conclusion == "success"))) | sort_by(.run_number, (.run_attempt // 1)) | reverse | .[] | [.id, (.run_attempt // 1), .run_number, .conclusion] | @tsv' <<< "$history")" - while IFS=$'\t' read -r candidate_id candidate_attempt candidate_number candidate_conclusion; do - [[ -z "$candidate_id" ]] && continue - if (( candidate_number > RUN_NUMBER || (candidate_number == RUN_NUMBER && candidate_attempt > RUN_ATTEMPT) )); then - relation=newer - else - relation=older + new_state=failing + # The lock group replaces a pending notifier when another arrives, and the + # replaced run is cancelled; a pass dropped that way would have re-armed this alert. + dropped=false + if [[ "$recorded_state" == failing && -n "$recorded_at" ]]; then + notify_workflow="${NOTIFY_WORKFLOW_REF%@*}" + if ! cancelled="$(gh api -X GET "/repos/${REPOSITORY}/actions/workflows/${notify_workflow##*/}/runs" -f status=cancelled -f "created=>=${recorded_at}" -F per_page=1 --jq '.total_count')" \ + || [[ "$cancelled" != 0 ]]; then + dropped=true fi - if [[ "$relation" == newer || -z "$prior_conclusion" ]]; then - if ! candidate_jobs="$(gh api --paginate "/repos/${REPOSITORY}/actions/runs/${candidate_id}/attempts/${candidate_attempt}/jobs?per_page=100")"; then - echo "::warning::Could not inspect candidate run ${candidate_id}; suppressing notification because history is incomplete." - exit 0 - fi - candidate_suite="$(printf '%s\n' "$candidate_jobs" | jq -s '{jobs: map(.jobs[])}' | suite_conclusion)" - if [[ "$candidate_suite" == none && "$WORKFLOW_NAME" == "Live E2E" && "$candidate_conclusion" == failure ]]; then - candidate_suite=failure - fi - if [[ "$candidate_suite" == failure || "$candidate_suite" == success ]]; then - if [[ "$relation" == newer ]]; then - echo "A newer completed relevant suite exists; suppressing stale notification." - exit 0 - fi - prior_conclusion="$candidate_suite" - break - fi + fi + if [[ "$recorded_state" == failing && "$dropped" == false ]]; then + echo "Action: recorded ${BRANCH} as failing (failure already reported)" + else + if [[ "$recorded_state" == unknown ]]; then + echo "::warning::Could not read ${state_artifact}; posting the failure in case it is new." + elif [[ "$dropped" == true ]]; then + echo "::warning::A notifier run may have been dropped since the recorded failure; posting the failure in case the suite recovered in between." + fi + if [[ "$STARTUP_FAILURE" == true ]]; then + header="❌ Live E2E failed to start" + text="Live E2E failed to start" + detail="The Live E2E workflow failed before it produced a Live e2e job." + else + header="❌ Live E2E failed" + text="Live E2E failed" + detail="The ${WORKFLOW_NAME} live suite failed." fi - done <<< "$candidates" + fi fi - if [[ "$current_suite" == success ]]; then - [[ "$prior_conclusion" == failure ]] || exit 0 - header="✅ Live E2E recovered" - text="Live E2E recovered" - detail="The previous relevant ${WORKFLOW_NAME} live suite failed; this run passed." - else - [[ "$prior_conclusion" != failure ]] || exit 0 - if [[ "$startup_failure" == true ]]; then - header="❌ Live E2E failed to start" - text="Live E2E failed to start" - detail="The Live E2E workflow failed before it produced a Live e2e job." - else - header="❌ Live E2E failed" - text="Live E2E failed" - detail="The ${WORKFLOW_NAME} live suite failed." - fi + if [[ -n "$header" ]]; then + run_url="https://github.com/${REPOSITORY}/actions/runs/${RUN_ID}" + commit_url="https://github.com/${REPOSITORY}/commit/${SHA}" + payload="$(jq -n --arg text "$text" --arg header "$header" --arg detail "$detail" --arg run_url "$run_url" --arg commit_url "$commit_url" --arg branch "$BRANCH" --arg sha "${SHA:0:7}" '{text:$text,blocks:[{type:"header",text:{type:"plain_text",text:$header,emoji:true}},{type:"section",text:{type:"mrkdwn",text:($detail + "\n*Branch:* `" + $branch + "`\n*Commit:* <" + $commit_url + "|" + $sha + ">\n*Workflow run:* <" + $run_url + "|view logs and failed tests>")}}]}')" + curl -fsSL -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK" + echo "Action: posted ${text}" fi - run_url="https://github.com/${REPOSITORY}/actions/runs/${RUN_ID}" - commit_url="https://github.com/${REPOSITORY}/commit/${SHA}" - payload="$(jq -n --arg text "$text" --arg header "$header" --arg detail "$detail" --arg run_url "$run_url" --arg commit_url "$commit_url" --arg branch "$BRANCH" --arg sha "${SHA:0:7}" '{text:$text,blocks:[{type:"header",text:{type:"plain_text",text:$header,emoji:true}},{type:"section",text:{type:"mrkdwn",text:($detail + "\n*Branch:* `" + $branch + "`\n*Commit:* <" + $commit_url + "|" + $sha + ">\n*Workflow run:* <" + $run_url + "|view logs and failed tests>")}}]}')" - curl -fsSL -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_WEBHOOK" + printf '%s\n%s\n' "$new_state" "$SOURCE_CREATED_AT" > "${state_dir}/state" + { + echo "state=${new_state}" + echo "artifact=${state_artifact}" + } >> "$GITHUB_OUTPUT" + + - name: Record reported state + if: steps.notify.outputs.state != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ steps.notify.outputs.artifact }} + path: ${{ runner.temp }}/live-e2e-alert-state/state + retention-days: 90 + if-no-files-found: error diff --git a/.github/workflows/slim-release-published.yml b/.github/workflows/slim-release-published.yml index 9e90d9a362..f8520274f1 100644 --- a/.github/workflows/slim-release-published.yml +++ b/.github/workflows/slim-release-published.yml @@ -1,23 +1,7 @@ name: Slim Release Published -# supabase/slim-services sends this dispatch after it mints an immutable release -# `--r`. The stack catalog -# (packages/stack/src/Artifacts.ts) is the single version table for the service; this workflow -# treats the dispatch as a trigger and reconciles the catalog against every committed -# (published, non-draft) `-...-r` release, opening or updating one pull request per -# hotfix and/or upgrade it finds (`.github/scripts/sync-artifacts-catalog.ts`'s `planSlimUpdates`). -# -# Plan and apply run from the same checkout of the default branch, in a single job: a re-run -# always recomputes from the latest develop, so a stale plan can never be applied, and a -# superseded PR's branch is rewritten in place (force-pushed) instead of racing a fresh one. A -# backlog republish of an older upstream version naturally plans nothing. -# -# The payload arrives with whatever authority holds the dispatch token, so it is treated as -# untrusted: fields are pattern- and shape-checked before use -# (`validateSlimReleasePublishedPayload`), and never interpolated directly into a `run:` script -# (only passed through `env:`). Release tag names come from an external API too, so every value -# `planSlimUpdates` emits is re-validated against the same anchored patterns before it can reach a -# branch name or PR title. +# Reconciles the stack catalog against supabase/slim-services releases. See +# docs/adr/0026-slim-artifact-mirrors.md, "Hotfix and upgrade pickup". on: repository_dispatch: @@ -30,13 +14,15 @@ permissions: concurrency: group: slim-release-published-${{ github.event.client_payload.service }} cancel-in-progress: false + # The default `single` cancels a pending run when another is queued, losing its release-visibility wait. + queue: max jobs: pickup: runs-on: ubuntu-latest # Setup (~10 min) + release visibility (<1 min) + the S3-mirror wait (up to ~10 min, normally only - # for the first planned item) + pin, render and push per item. - timeout-minutes: 45 + # for the first planned item) + pin, render and push per item + the merge-queue wait (up to 30 min). + timeout-minutes: 75 permissions: contents: read steps: @@ -46,9 +32,7 @@ jobs: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # Installs the locked workspace dependencies the scripts need: `Artifacts.ts` imports - # `effect`, and `render-service-dockerfile.ts` imports `@supabase/stack/internal/artifacts`. - # Replaces the bare mise step the pre-single-table version of this workflow used. + # `Artifacts.ts` and `render-service-dockerfile.ts` import locked workspace dependencies. - name: Setup uses: ./.github/actions/setup with: @@ -58,13 +42,7 @@ jobs: id: base run: echo "sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - # Delegates to `validateSlimReleasePublishedPayload` (sync-artifacts-catalog.ts) so every - # field — including `upstream_version` and `release_version`, not just `service` and - # `revision` — is checked against an anchored charset before it is ever written to - # `$GITHUB_OUTPUT`, a branch name, or a PR title. A value that fails validation makes the - # script exit non-zero and print a single `::error ::…` line to stdout (the rejected value - # is JSON-escaped in that message, so it can never smuggle in a newline or workflow command), - # which this step re-echoes so it still surfaces as an annotation. + # Re-echoes a validate-payload failure so its `::error ::…` line surfaces as an annotation. - name: Validate payload id: validate env: @@ -81,17 +59,7 @@ jobs: fi echo "$output" >>"$GITHUB_OUTPUT" - # Reconciles the service against every committed slim-services release (`planSlimUpdates`), - # not just the release that triggered this dispatch — this run is idempotent and safe to - # receive out of order. Records go only to `--output` (never stdout): each line is - # field-separated with `\x1f` (see `updateLine`), which a later step reads directly. Any - # `::warning ::…` the planner emits (an ignored tag, say) prints to this step's own stdout - # instead, so it can never be mistaken for a malformed record. - # - # `--expect-release` guards against the releases API lagging behind this very dispatch: it - # requires `-` to be a listed tag before planning runs at all, - # re-listing a bounded number of times first (`waitForExpectedRelease`). Without it, a slow - # API would make this run plan without the release that triggered it, and pass quietly. + # `--expect-release` waits for the releases API to list the release behind this dispatch. - name: Plan updates id: plan env: @@ -134,25 +102,20 @@ jobs: permission-contents: write permission-pull-requests: write - # One branch per planned item, built fresh from the base commit recorded above (the default - # branch's head at the start of this run) — never from wherever a previous loop iteration - # left HEAD. Pins exactly the planned release (`--release`, never "highest at apply time"), - # so plan and pin agree by construction: a revision minted a second later arrives with its - # own dispatch, not by racing this one. - # - # The app token (contents + pull-requests write) never reaches third-party code or disk: - # `git push` takes it only as part of an explicit URL, computed once as `push_url` - # (overridable via `PUSH_REMOTE_URL`, the seam a dry run uses to target a local bare repo - # instead), and `gh` gets it inline per call. Every `bun`/`pnpm` command below — the sync - # script, the Dockerfile generator, the formatter — runs under `env -u APP_TOKEN` so a - # compromised transitive dependency of any of them (they import `effect`) can't read it. + # One branch per planned item, rebuilt from the recorded base commit, never from a previous + # iteration's HEAD; `--release` pins the planned release, never "highest at apply time". + # `bun`/`pnpm` run with the tokens unset: only `git push` and `gh` ever see them. + # A merge-queued branch rejects pushes; see ADR 0026 "Hotfix and upgrade pickup" for the wait and hand-off. - name: Apply planned updates if: steps.plan.outputs.count != '0' env: APP_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ github.token }} SERVICE: ${{ steps.validate.outputs.service }} + UPSTREAM_VERSION: ${{ steps.validate.outputs.upstream_version }} + REVISION: ${{ steps.validate.outputs.revision }} RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} + REPLAY: ${{ github.event.client_payload.replay }} BASE_SHA: ${{ steps.base.outputs.sha }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | @@ -162,9 +125,16 @@ jobs: push_url="${PUSH_REMOTE_URL:-https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git}" manual_prefix="bun .github/scripts/sync-artifacts-catalog.ts --service ${SERVICE} --release" - # Read the records on fd 3, not stdin: every command the loop body runs (`bun`, `gh`, - # `git`) otherwise shares stdin with the `read`, and any of them consuming a byte of it - # would swallow the rest of the file's records. + in_merge_queue() { + # shellcheck disable=SC2016 # `$owner`, `$name` and `$branch` are GraphQL variables. + GH_TOKEN="$APP_TOKEN" gh api graphql \ + -f query='query($owner: String!, $name: String!, $branch: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $branch, states: OPEN, first: 10) { nodes { isInMergeQueue isCrossRepository } } } }' \ + -f owner="${GITHUB_REPOSITORY%/*}" -f name="${GITHUB_REPOSITORY#*/}" -f branch="$1" \ + --jq '[.data.repository.pullRequests.nodes[] | select(.isCrossRepository | not) | .isInMergeQueue] | any' + } + + # Reads records on fd 3, not stdin, so commands the loop runs (`bun`, `gh`, `git`) never + # consume bytes meant for `read`. while IFS=$'\x1f' read -r -u 3 kind branch title release from; do if [ -z "$kind" ] || [ -z "$branch" ] || [ -z "$title" ] || [ -z "$release" ] || [ -z "$from" ]; then echo "::error ::Malformed plan-updates line: kind='${kind}' branch='${branch}' title='${title}' release='${release}' from='${from}'." @@ -190,20 +160,63 @@ jobs: git add -- "${generated_files[@]}" git commit -m "$title" - if ! git push --force "$push_url" "HEAD:refs/heads/${branch}"; then - echo "::error ::Failed to push ${branch}. Run manually: ${manual_prefix} ${release}, regenerate the Dockerfiles with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." - exit 1 + if ! push_output="$(git push --force "$push_url" "HEAD:refs/heads/${branch}" 2>&1)"; then + echo "$push_output" + if ! grep -q "queued for merging cannot be updated" <<<"$push_output"; then + echo "::error ::Failed to push ${branch}. Run manually: ${manual_prefix} ${release}, regenerate the Dockerfiles with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." + exit 1 + fi + manual_steps="run manually: ${manual_prefix} ${release}, regenerate the Dockerfiles with \`bun apps/cli/scripts/render-service-dockerfile.ts\`, then open a pull request by hand." + queued_steps="Once ${branch} leaves the merge queue, ${manual_steps}" + # The app token minted just before this step expires after one hour; capping the wait at + # 50 minutes into the step leaves room for the lookups and the dispatch that follow it. + deadline=$((SECONDS + 1800 < 3000 ? SECONDS + 1800 : 3000)) + while :; do + if ! queued="$(in_merge_queue "$branch")"; then + echo "::error ::Failed to read the merge-queue state of ${branch}. ${queued_steps}" + exit 1 + fi + case "$queued" in + false) break ;; + true) ;; + *) + echo "::error ::Unexpected merge-queue state '${queued}' for ${branch}. ${queued_steps}" + exit 1 + ;; + esac + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error ::${branch} is still in the merge queue. ${queued_steps}" + exit 1 + fi + sleep 30 + done + # `replay` counts consecutive re-sends; anything but 0-2 stops the chain. + if ! [[ "${REPLAY:-0}" =~ ^[0-2]$ ]]; then + echo "::error ::Stopped re-dispatching after repeated merge-queue rejections of ${branch}; ${manual_steps}" + exit 1 + fi + if ! GH_TOKEN="$APP_TOKEN" gh api "repos/${GITHUB_REPOSITORY}/dispatches" \ + -f event_type=slim-release-published \ + -f "client_payload[service]=${SERVICE}" \ + -f "client_payload[upstream_version]=${UPSTREAM_VERSION}" \ + -f "client_payload[revision]=${REVISION}" \ + -f "client_payload[release_version]=${RELEASE_VERSION}" \ + -f "client_payload[replay]=$((${REPLAY:-0} + 1))"; then + echo "::error ::Failed to re-dispatch slim-release-published for ${SERVICE} ${RELEASE_VERSION}. Re-run this job." + exit 1 + fi + echo "::warning ::${branch} was held by a merge-queued pull request; re-dispatched slim-release-published so a fresh run re-plans from ${DEFAULT_BRANCH}." + exit 0 fi + echo "$push_output" if [ "$kind" = "upgrade" ]; then action="bumps" else action="pins" fi - # Names the release this PR actually pins as the subject; the dispatch's triggering - # release (RELEASE_VERSION) can differ from it (a hotfix dispatch commonly also - # yields an unrelated upgrade on the same line), so it's only mentioned, not implied - # to be what changed. + # Names the release this PR actually pins, not necessarily RELEASE_VERSION: postgres + # can plan a hotfix on one line and an unrelated upgrade on another in one dispatch. body="$(printf 'This %s %s from %s to %s.\n\nhttps://github.com/supabase/slim-services/releases/tag/%s-%s\n\nPlanned after supabase/slim-services published %s. This branch is rewritten from %s whenever a newer relevant release arrives.\n' \ "$action" "$SERVICE" "$from" "$release" "$SERVICE" "$release" "$RELEASE_VERSION" "$DEFAULT_BRANCH")" @@ -218,7 +231,6 @@ jobs: elif ! GH_TOKEN="$APP_TOKEN" gh pr create \ --title "$title" \ --body "$body" \ - --assignee jgoux \ --head "$branch" \ --base "$DEFAULT_BRANCH"; then echo "::error ::Pushed ${branch} but failed to open a pull request. Run manually: ${manual_prefix} ${release}, then open a pull request from ${branch} against ${DEFAULT_BRANCH} by hand." diff --git a/.gitignore b/.gitignore index a1d49ff0cf..ff9c4a0605 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,8 @@ coverage/ .env .env.* !.env.example -.claude/ +.claude/* +!.claude/skills .agents/.repos/effect-v3 .repos/slim-services/ .worktrees/ diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 137f3fb227..d4ab0a187a 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -8,23 +8,10 @@ "**", "!packages/stack/**", "!apps/cli/src/commands/**", - "!apps/cli/src/shared/auth/**", - "!apps/cli/src/shared/compute/**", - "!apps/cli/src/shared/config/**", - "!apps/cli/src/shared/feedback/**", - "!apps/cli/src/shared/functions/functions-docker.ts", - "!apps/cli/src/shared/functions/functions-docker.unit.test.ts", - "!apps/cli/src/shared/functions/serve.ts", - "!apps/cli/src/shared/functions/serve.unit.test.ts", - "!apps/cli/src/shared/git/**", - "!apps/cli/src/shared/init/**", - "!apps/cli/src/shared/issue/**", - "!apps/cli/src/shared/output/**", - "!apps/cli/src/shared/runtime/**", - "!apps/cli/src/shared/services/**", - "!apps/cli/src/shared/telemetry/**", + "!apps/cli/src/shared/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. + "apps/cli/src/shared/cli/bin.ts", "apps/cli/src/shared/compute/stacks/**", "apps/cli/src/shared/feedback/database.types.ts", "!apps/cli/tests/helpers/branches-live.ts", diff --git a/AGENTS.md b/AGENTS.md index 7cdec80512..5511ea065b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,14 @@ The skill is authoritative for Effect coding practices when repository instructi Effect linting uses oxlint via `.oxlintrc.effect.json`; run `pnpm lint:effect:check` or `pnpm lint:effect:fix` from the repository root. +Instrument by default: when you add or change code that does I/O (HTTP, SQL, filesystem beyond a +small read, child processes, containers, waits) or millisecond-scale work, give it a named span +(`Effect.fn("Area.operation")`, or `Effect.withSpan(".")` for handler phases) and +annotate outcomes that help debugging, such as counts, cache hits, attempts, and exit codes. Keep +`Effect.fnUntraced` for pure helpers and per-item loop bodies. Follow the +[tracing conventions ADR](docs/adr/0027-cli-tracing-conventions.md) for naming, attribute rules, +and what never to record. + ## Commands, validation, and workflows Package scripts are the source of truth for leaf workspaces; root-owned Turbo coordinates build, @@ -137,7 +145,10 @@ limitations over speculative machinery while preserving required safeguards. - Assert behavior that matters to consumers, not implementation details. Prefer real parsers and observable outcomes over source-text or registry checks. Exercise shared contracts across supported implementations. - Make assertions meaningful: establish prerequisites, check specific failures, and choose matchers that express the intended contract. - Keep setup concise with small fixtures. Accept some duplication rather than introducing unnecessary test abstractions. -- Remove redundant coverage. Push back on review suggestions that add assertions without protecting meaningful behavior. + +Follow the [test-audit skill](.agents/skills/test-audit/SKILL.md) whenever a test is written, +changed, or reviewed: it gates new tests before they land and audits existing ones for redundant, +mock-driven, or implementation-coupled coverage. Name tests `*.unit.test.ts`, `*.integration.test.ts`, or `*.e2e.test.ts`; colocate them with source. Use `tests/` for shared helpers. For CLI commands, unit-test complex pure logic, integration-test diff --git a/apps/cli-go/api/v1-openapi.yaml b/apps/cli-go/api/v1-openapi.yaml index 5c3a40fa29..b65a666863 100644 --- a/apps/cli-go/api/v1-openapi.yaml +++ b/apps/cli-go/api/v1-openapi.yaml @@ -20,8 +20,7 @@ paths: example: abcdefghijklmnopqrst - type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ deprecated: true responses: '200': @@ -71,8 +70,7 @@ paths: example: abcdefghijklmnopqrst - type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ deprecated: true requestBody: required: true @@ -131,8 +129,7 @@ paths: example: abcdefghijklmnopqrst - type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ deprecated: true - name: force required: false @@ -192,8 +189,7 @@ paths: example: abcdefghijklmnopqrst - type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ deprecated: true requestBody: required: true @@ -250,8 +246,7 @@ paths: example: abcdefghijklmnopqrst - type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ deprecated: true requestBody: required: true @@ -308,8 +303,7 @@ paths: example: abcdefghijklmnopqrst - type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ deprecated: true requestBody: required: true @@ -366,8 +360,7 @@ paths: example: abcdefghijklmnopqrst - type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ deprecated: true responses: '201': @@ -421,8 +414,7 @@ paths: example: abcdefghijklmnopqrst - type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ deprecated: true - name: included_schemas required: false @@ -714,8 +706,7 @@ paths: in: query schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 66666666-6666-4666-8666-666666666666 type: string - name: response_type @@ -908,8 +899,7 @@ paths: in: query schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 66666666-6666-4666-8666-666666666666 type: string - name: response_type @@ -1054,8 +1044,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 44444444-4444-4444-8444-444444444444 type: string responses: @@ -1593,8 +1582,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 22222222-2222-4222-8222-222222222222 type: string - name: reveal @@ -1660,8 +1648,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 22222222-2222-4222-8222-222222222222 type: string - name: reveal @@ -1723,8 +1710,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 22222222-2222-4222-8222-222222222222 type: string - name: reveal @@ -2862,8 +2848,7 @@ paths: content: application/json: schema: - $ref: >- - #/components/schemas/PostgrestConfigWithJWTSecretResponse_Output + $ref: '#/components/schemas/PostgrestConfigWithJWTSecretResponse_Output' '401': description: Unauthorized '403': @@ -4269,8 +4254,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 33333333-3333-4333-8333-333333333333 type: string - name: ref @@ -4313,8 +4297,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 33333333-3333-4333-8333-333333333333 type: string - name: ref @@ -4363,8 +4346,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 33333333-3333-4333-8333-333333333333 type: string - name: ref @@ -4605,8 +4587,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 88888888-8888-4888-8888-888888888888 type: string responses: @@ -4653,8 +4634,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 88888888-8888-4888-8888-888888888888 type: string responses: @@ -4777,8 +4757,7 @@ paths: content: application/json: schema: - $ref: >- - #/components/schemas/GetProjectAvailableRestoreVersionsResponse_Output + $ref: '#/components/schemas/GetProjectAvailableRestoreVersionsResponse_Output' '401': description: Unauthorized '403': @@ -5327,8 +5306,7 @@ paths: in: query schema: format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ example: '2025-03-01T00:00:00Z' type: string - name: iso_timestamp_end @@ -5336,8 +5314,7 @@ paths: in: query schema: format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ example: '2025-03-01T23:59:59Z' type: string responses: @@ -6485,8 +6462,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 55555555-5555-4555-8555-555555555555 type: string responses: @@ -6529,8 +6505,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 55555555-5555-4555-8555-555555555555 type: string responses: @@ -7836,8 +7811,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 77777777-7777-4777-8777-777777777777 type: string responses: @@ -7888,8 +7862,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 77777777-7777-4777-8777-777777777777 type: string requestBody: @@ -7946,8 +7919,7 @@ paths: in: path schema: format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ example: 77777777-7777-4777-8777-777777777777 type: string responses: @@ -8833,8 +8805,7 @@ components: id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ name: type: string project_ref: @@ -8872,18 +8843,15 @@ components: created_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updated_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ review_requested_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ with_data: type: boolean notify_url: @@ -8892,8 +8860,7 @@ components: deletion_scheduled_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ preview_project_status: type: string enum: @@ -9457,8 +9424,7 @@ components: client_id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ client_secret: type: string code: @@ -9519,8 +9485,7 @@ components: client_id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ client_secret: type: string refresh_token: @@ -9956,14 +9921,12 @@ components: inserted_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ nullable: true updated_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ nullable: true required: - name @@ -10324,13 +10287,11 @@ components: updated_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ applied_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - entitlement - config @@ -10439,13 +10400,11 @@ components: updated_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ applied_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ status: type: string enum: @@ -11197,8 +11156,7 @@ components: id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ algorithm: type: string enum: @@ -11218,13 +11176,11 @@ components: created_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updated_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - algorithm @@ -11254,8 +11210,7 @@ components: kid: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ use: type: string enum: @@ -11315,8 +11270,7 @@ components: kid: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ use: type: string enum: @@ -11364,8 +11318,7 @@ components: kid: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ use: type: string enum: @@ -11410,8 +11363,7 @@ components: kid: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ use: type: string enum: @@ -11461,8 +11413,7 @@ components: id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ algorithm: type: string enum: @@ -11482,13 +11433,11 @@ components: created_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updated_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - id - algorithm @@ -12074,8 +12023,7 @@ components: enum: - abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - - >- - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\:"|<>?,./`~ + - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\:"|<>?,./`~ - '' - null nullable: true @@ -12213,8 +12161,7 @@ components: sms_test_otp_valid_until: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ nullable: true sms_textlocal_api_key: type: string @@ -12255,8 +12202,7 @@ components: smtp_admin_email: type: string format: email - pattern: >- - ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ nullable: true smtp_host: type: string @@ -12551,8 +12497,7 @@ components: smtp_admin_email: type: string format: email - pattern: >- - ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ nullable: true smtp_host: type: string @@ -12790,8 +12735,7 @@ components: enum: - abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789 - - >- - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\:"|<>?,./`~ + - abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\:"|<>?,./`~ - '' - null nullable: true @@ -12862,8 +12806,7 @@ components: sms_test_otp_valid_until: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ nullable: true sms_textlocal_api_key: type: string @@ -13340,8 +13283,7 @@ components: id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ type: type: string oidc_issuer_url: @@ -13669,8 +13611,7 @@ components: created_by: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ required: - token_alias - expires_at @@ -13690,8 +13631,7 @@ components: created_by: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ required: - token - token_alias @@ -13811,8 +13751,7 @@ components: observed_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ required: - name - title @@ -13879,8 +13818,7 @@ components: timestamp: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|))$ total_auth_requests: type: number total_realtime_requests: @@ -14167,8 +14105,7 @@ components: user_id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ user_roles: type: array items: @@ -14189,8 +14126,7 @@ components: cidr: type: string format: cidrv4 - pattern: >- - ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ required: - cidr allowed_cidrs_v6: @@ -14201,8 +14137,7 @@ components: cidr: type: string format: cidrv6 - pattern: >- - ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ + pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ required: - cidr branches_only: @@ -14221,12 +14156,10 @@ components: anyOf: - type: string format: ipv4 - pattern: >- - ^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$ + pattern: ^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$ - type: string format: ipv6 - pattern: >- - ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$ + pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$ required: - role - rhost @@ -14239,8 +14172,7 @@ components: user_id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ act: type: string user_role: @@ -14261,8 +14193,7 @@ components: cidr: type: string format: cidrv4 - pattern: >- - ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ required: - cidr allowed_cidrs_v6: @@ -14273,8 +14204,7 @@ components: cidr: type: string format: cidrv6 - pattern: >- - ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ + pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ required: - cidr branches_only: @@ -14295,8 +14225,7 @@ components: user_id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ primary_email: type: string nullable: true @@ -14324,8 +14253,7 @@ components: cidr: type: string format: cidrv4 - pattern: >- - ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ required: - cidr allowed_cidrs_v6: @@ -14336,8 +14264,7 @@ components: cidr: type: string format: cidrv6 - pattern: >- - ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ + pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ required: - cidr branches_only: @@ -14359,8 +14286,7 @@ components: invite_id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ expires_at: type: string user_roles: @@ -14383,8 +14309,7 @@ components: cidr: type: string format: cidrv4 - pattern: >- - ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ required: - cidr allowed_cidrs_v6: @@ -14395,8 +14320,7 @@ components: cidr: type: string format: cidrv6 - pattern: >- - ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ + pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ required: - cidr branches_only: @@ -14418,8 +14342,7 @@ components: type: string minLength: 1 format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ roles: type: array items: @@ -14441,8 +14364,7 @@ components: cidr: type: string format: cidrv4 - pattern: >- - ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ required: - cidr allowed_cidrs_v6: @@ -14453,8 +14375,7 @@ components: cidr: type: string format: cidrv6 - pattern: >- - ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ + pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ required: - cidr branches_only: @@ -14480,8 +14401,7 @@ components: type: string minLength: 1 format: email - pattern: >- - ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ roles: type: array items: @@ -14503,8 +14423,7 @@ components: cidr: type: string format: cidrv4 - pattern: >- - ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ required: - cidr allowed_cidrs_v6: @@ -14515,8 +14434,7 @@ components: cidr: type: string format: cidrv6 - pattern: >- - ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ + pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ required: - cidr branches_only: @@ -14541,13 +14459,11 @@ components: email: type: string format: email - pattern: >- - ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ invite_id: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ user_roles: type: array items: @@ -14568,8 +14484,7 @@ components: cidr: type: string format: cidrv4 - pattern: >- - ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ + pattern: ^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$ required: - cidr allowed_cidrs_v6: @@ -14580,8 +14495,7 @@ components: cidr: type: string format: cidrv6 - pattern: >- - ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ + pattern: ^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$ required: - cidr branches_only: @@ -14599,8 +14513,7 @@ components: type: string minLength: 1 format: email - pattern: >- - ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ token: type: string minLength: 1 @@ -15733,8 +15646,7 @@ components: admin_suspended_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: If set, the Realtime service has been suspended by an admin. nullable: true required: @@ -16350,8 +16262,7 @@ components: completed_on: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ nullable: true required: - name @@ -16379,8 +16290,7 @@ components: updated_at: type: string format: date-time - pattern: >- - ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ description: Timestamp of when the backup schedule was last updated. example: '2026-05-04T14:40:44+00:00' required: @@ -16702,8 +16612,7 @@ components: created_by: type: string format: uuid - pattern: >- - ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ + pattern: ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$ required: - project - preview diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index aee8c87f83..0f56babd32 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -3,29 +3,45 @@ # postgrest, pgmeta, studio, imgproxy, edgeruntime, vector, supavisor, gotrue, realtime, storage, # logflare. Do not hand-edit those tags; a CI drift check enforces this. Run the generator after # a catalog update. Everything else in this file (this comment, kong, pg14, the one-shot job -# images) is hand- or Dependabot-managed. -FROM supabase/postgres:17.11.0.002 AS pg -FROM supabase/postgres:15.19.0.002 AS pg15 +# 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.004 AS pg + +FROM supabase/postgres:15.19.0.004 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 # shared/services/services.shared.ts and DOCKERFILE_ALIAS_BY_SERVICE in # commands/start/start.gates.ts. FROM library/kong:2.8.1 AS kong + FROM axllent/mailpit:v1.31.3 AS mailpit + FROM postgrest/postgrest:v16.4 AS postgrest -FROM supabase/postgres-meta:v0.99.0 AS pgmeta -FROM supabase/studio:2026.09.28-sha-5e59b60 AS studio + +FROM supabase/postgres-meta:v0.100.0 AS pgmeta + +FROM supabase/studio:2026.10.05-sha-94b8b06 AS studio + FROM darthsim/imgproxy:v3.26.0 AS imgproxy -FROM supabase/edge-runtime:v1.77.1 AS edgeruntime + +FROM supabase/edge-runtime:v1.77.4 AS edgeruntime + FROM timberio/vector:0.58.0-alpine AS vector + FROM supabase/supavisor:2.9.13 AS supavisor + FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.140.3 AS realtime -FROM supabase/storage-api:v1.79.28 AS storage -FROM supabase/logflare:1.50.15 AS logflare + +FROM supabase/realtime:v2.140.10 AS realtime + +FROM supabase/storage-api:v1.79.36 AS storage + +FROM supabase/logflare:1.52.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias # is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ + FROM supabase/migra:3.0.1663481299 AS migra + FROM supabase/pg_prove:3.36 AS pgprove diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 58cac7c8ef..99426185fb 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -25,8 +25,8 @@ src/commands// SIDE_EFFECTS.md # required compatibility contract ``` -Register every command in `src/cli/root.ts`. Keep `.format.ts` and `.encoders.ts` pure. Tracing is -local observability and span names follow `.`. Read `src/shared/` and the +Register every command in `src/cli/root.ts`. Keep `.format.ts` and `.encoders.ts` pure. Handler +phase spans are named `.`; see the root tracing rule. Read `src/shared/` and the command-level infrastructure under `src/config/`, `src/auth/`, `src/telemetry/`, `src/output/`, and `src/command-internal/` before adding an equivalent helper. @@ -116,10 +116,10 @@ contracts remain unchanged. Update tests, generated schemas, and side-effect doc > [`src/shared/telemetry/error-tag-stability.unit.test.ts`](src/shared/telemetry/error-tag-stability.unit.test.ts) > compares every CLI and `@supabase/config` tag with the committed snapshot. -Native commands use `withCommandTelemetry` from `telemetry/command-telemetry.ts`, not shared -`withCommandInstrumentation`; pass the handler's `flags`, and pass its own `config` when it has -choice flags. Preserve the established property shape (`flags`, `is_agent`, and `env_signals`), -use canonical names and keys from the [event catalog](src/shared/telemetry/event-catalog.ts), and redact sensitive values. +Native commands use `withCommandTelemetry` from `telemetry/command-telemetry.ts`; pass the +handler's `flags`, and pass its own `config` when it has choice flags. Preserve the established +property shape (`flags`, `is_agent`, and `env_signals`), use canonical names and keys from the +[event catalog](src/shared/telemetry/event-catalog.ts), and redact sensitive values. `safeFlags` may include only values that carry no user data. Keep the established safe project, project-id, org-id, and version cases. When `--project-ref` also accepts branch names, whitelist diff --git a/apps/cli/README.md b/apps/cli/README.md index 25f8a27552..5baad3fd81 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -105,11 +105,11 @@ The CLI is built on `effect/unstable/cli`. Important areas: -- `src/shared/cli/` for shared runner logic, roots, and global flags +- `src/shared/cli/` for shared runner logic and roots - `src/commands/` for the command tree - `src/shared/output/` for text / JSON / NDJSON output policies - `src/shared/runtime/` for TTY, stdin, browser, and process-control services -- `src/shared/auth/` for login-related services +- `src/auth/` for credential and platform API services; `src/shared/auth/` keeps the JWKS and keyring helpers The local stack commands use `@supabase/stack` for lifecycle, status, logs, and runtime operations. Managed ownership uses stable loopback `GET /owner` and session-fenced `POST /stop`; same-version diff --git a/apps/cli/docs/analytics.md b/apps/cli/docs/analytics.md index 5bea4f988a..225713f0fc 100644 --- a/apps/cli/docs/analytics.md +++ b/apps/cli/docs/analytics.md @@ -34,18 +34,17 @@ The core pieces are: - `CurrentAnalyticsContext` - `withAnalyticsContext(...)` -- `withCommandInstrumentation(...)` +- `withCommandTelemetry(...)` -`CurrentAnalyticsContext` is a `ServiceMap.Reference` that carries the current analytics context +`CurrentAnalyticsContext` is a `Context.Reference` that carries the current analytics context for the running effect scope. -`withCommandInstrumentation(...)` wraps command handlers and installs per-invocation tracing plus -the per-invocation analytics context such as: +[`withCommandTelemetry(...)`](../src/telemetry/command-telemetry.ts) wraps command handlers and +installs the command span plus the per-invocation analytics context: - `command_run_id` - `command` -- `flags_used` -- `flag_values` +- `flags` That same context is then inherited by milestone events captured inside the command handler, which lets one CLI invocation share a single `command_run_id`. @@ -59,10 +58,10 @@ The primary analytics event is: It is emitted once per handled command invocation and includes: - `command` -- `flags_used` -- `flag_values` +- `flags` - `exit_code` - `duration_ms` +- `output_format` Failed invocations (`exit_code != 0`) handled by the TS shells also carry a sanitized error classification: @@ -87,17 +86,27 @@ they existed never carry them. KPI queries therefore scope to reports the classified share of failures so the covered fraction is explicit rather than assumed. -Flag capture is intentionally conservative: +Local-runtime commands also carry properties that split error rates by runtime: -- `flags_used` is always captured -- `flag_values` defaults to an empty object -- commands must opt specific flag values in explicitly later if needed +- `stack_backend` (`legacy` or `stack`) on commands routed by `experimental.stack`; see + [backend selection](stack-commands.md) +- `stack_runtime` (`docker`, `podman`, or `native`): the runtime of the stack a stack-backend + command looked up, which for `--db-url` may be the project stack even when the URL targets + another database +- `orioledb` (boolean) once the command resolves local database config, `true` when the project + selects an OrioleDB image (`db.orioledb_version` on a 15/17 project). It describes local config + only; for linked or `--db-url` targets it says nothing about the remote database + +Flag capture is intentionally conservative: `flags` records only flags changed on the command +line, and redacts each value unless it is boolean, a choice flag, or listed in the command's +`safeFlags`. Current milestone events include: - `cli_login_completed` - `cli_project_linked` - `cli_stack_started` +- `cli_upgrade_suggested` ## Shared Properties and Identity @@ -110,7 +119,8 @@ The analytics layer attaches a base set of properties to every PostHog event: - `is_first_run` - `is_tty` - `is_ci` -- `ai_tool` +- `is_agent` +- `env_signals` (allowlisted agent and terminal environment variables, when any are set) - `os` - `arch` - `cli_version` diff --git a/apps/cli/docs/code-structure.md b/apps/cli/docs/code-structure.md index 7c911ed16e..3e91abb16f 100644 --- a/apps/cli/docs/code-structure.md +++ b/apps/cli/docs/code-structure.md @@ -54,8 +54,8 @@ Shared concern files: ```text auth/ - credentials.service.ts - credentials.layer.ts + command-credentials.service.ts + command-credentials.layer.ts errors.ts ``` @@ -66,13 +66,13 @@ Rules: - Shared service files use `.service.ts`. - Shared layer files use `.layer.ts`. - Do not prefix leaf files with the slice name. - - `tracing.layer.ts`, not `telemetry.tracing.layer.ts` - - `credentials.service.ts`, not `auth.credentials.service.ts` + - `trace-export.layer.ts`, not `telemetry.trace-export.layer.ts` + - `command-credentials.service.ts`, not `auth.command-credentials.service.ts` ## Symbol Naming -- Service symbols are plain nouns: `Credentials`, `Tracing`, `Output`. -- Layer exports use concrete `*Layer` names: `credentialsLayer`, `tracingLayer`, `outputLayer`. +- Service symbols are plain nouns: `Tty`, `Analytics`, `Output`. +- Layer exports use concrete `*Layer` names: `ttyLayer`, `analyticsLayer`, `outputLayer`. - Do not use `.Default`. - Do not rely on `static layer` as the default pattern for shared concern slices. @@ -134,8 +134,8 @@ If a command needs private DI, prefer colocated `*.service.ts` and `*.layer.ts` Examples where comments are expected: -- `telemetry/tracing.layer.ts` +- `telemetry/trace-export.layer.ts` - `output/output.layer.ts` -- `auth/credentials.layer.ts` +- `auth/command-credentials.layer.ts` Consistency does not mean every service or layer file needs a header. The goal is high-signal comments on important boundaries. diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index dd8cbdd896..bbe4ab9e02 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -16,7 +16,7 @@ and password-reconciliation connection uses that administrative role, so a nativ | `supabase stack prepare` | Download artifacts without starting services. | | `supabase stack start` | Create or resume the project's stack. | | `supabase stack status` | Show identity, readiness, and drift, or export connection variables with `--env`. | -| `supabase stack logs` | Stream live stack logs. | +| `supabase stack logs` | Print retained stack logs; `-f` streams new lines. | | `supabase stack restart` | Restart an existing stack using its saved effective configuration. | | `supabase stack stop` | Stop a stack while retaining its data. | @@ -124,8 +124,9 @@ The stack backend rejects every explicit legacy `-o/--output` value: `env`, `pre project, branch, runtime, and owner availability. Registry entries that cannot be read or decoded are skipped with a warning on stderr identifying each stack; only a failure to read the stacks directory itself fails discovery. -The text table shortens readable IDs for scanning; use `--output-format json` or -`--output-format stream-json` for the complete structured inventory with full IDs. +The text table shortens readable IDs for scanning; every `--stack-id` accepts that short ID, or any +unique prefix of at least 4 characters. Use `--output-format json` or `--output-format stream-json` +for the complete structured inventory with full IDs. Listing is global and has no checkout filter. Owner availability is not service lifecycle or health; use `supabase stack status` for live state. Registry directories without a state file are ignored @@ -160,7 +161,7 @@ Other values are rejected. The override is applied before reading the project co `supabase services` follows the same backend selection. In stack mode it lists image versions and canonical `ghcr.io/supabase/cli/...` names from the installed CLI's artifact catalog, including -Mailpit and Vector. PostgreSQL uses the selected major version (15 or 17); invalid configuration +Mailpit. PostgreSQL uses the selected major version (15 or 17); invalid configuration or an unsupported PostgreSQL major warns with the cause and uses catalog defaults. This inventory describes the CLI catalog, not running containers, downloaded images, or service health. The Docker and native stack runtimes use the same catalog versions, though a running stack launched by another CLI @@ -237,18 +238,25 @@ lint transaction (always rolled back). It does not launch a client binary. ## Reading stack logs -`supabase stack logs` streams live stdout/stderr from composition members without -starting an owner or service. Select `--stack ` or `--stack-id `; -`--service ` can include standalone services too. The command -requires a reachable owner and streams until interrupted. Ctrl-C leaves services -running. There is no retained history, `--tail`, or `--follow` flag. - -Text uses ` //: ` and strips terminal -control sequences. For automation use `--output-format stream-json`: each -`log-entry` contains `timestamp`, `service`, `instance_id`, `stream`, `line`, and -`source: "live"`. Finite JSON output is not supported. Delivery is best effort; -stdout/stderr and different services may interleave. Missing stacks, unavailable -owners, and unmatched services fail with status 1; interruption exits 130. +`supabase stack logs` prints the retained stdout/stderr of composition members and +exits; `-f/--follow` then streams new lines until interrupted. Select `--stack ` +or `--stack-id `; the repeatable `--service ` can include +standalone services too. History is read from the persisted log files, so it works +while the stack is stopped; `--follow` requires a running owner and fails before +printing anything without one. Neither mode starts an owner or service, and Ctrl-C +leaves services running. + +`--tail N` (default 200) keeps the newest lines across the selected services and +`--since` takes a duration (`10m`, `1h30m`), an ISO-8601 time, or `start` for each +service's latest launch. Text uses ` | ` with aligned +labels, shows launches and lost output as dim separators, strips terminal control +sequences, and notes on stderr when the tail hides older lines. For automation use +`--output-format stream-json`: each `log-entry` contains `timestamp`, `service`, +`instance_id`, `stream`, `line`, and `source` (`history` or `live`), and markers are +`log-marker` events with `kind` and, for lost output, `count`. `--output-format json` +prints the history as one array and does not accept `--follow`. Missing stacks, +unmatched services, and `--follow` without an owner fail with status 1; interruption +exits 130. ## Data and configuration diff --git a/apps/cli/docs/supabase-home.md b/apps/cli/docs/supabase-home.md index b40e2c7217..4e5507407a 100644 --- a/apps/cli/docs/supabase-home.md +++ b/apps/cli/docs/supabase-home.md @@ -52,8 +52,8 @@ Managed stack documents and runtime artifacts are shared through the global CLI ```text / access-token + cli-latest telemetry.json - traces/ bin/ stacks/ / @@ -116,6 +116,16 @@ Auth, telemetry, traces, and downloaded binaries remain machine-global under `SU Legacy installer artifacts may coexist under that root, but they are not part of managed stack coordination. +## Release check cache + +After a successful command, the upgrade notice compares the running version with the latest +GitHub release. The release tag is cached for ten hours in `supabase/.temp/cli-latest` when the +project directory has a real `supabase/` directory, and in `/cli-latest` +otherwise. `--version` and `--help` check only the current directory, so from a project +subdirectory they use `/cli-latest`. A failed fetch writes an empty file, which +suppresses the notice and further fetches until it expires. The cache is skipped when the file, +`supabase/.temp`, or `SUPABASE_HOME` is a symlink. `SUPABASE_NO_UPDATE_NOTIFIER=1` disables the check. + ## Related docs - [CLI Code Structure](./code-structure.md) diff --git a/apps/cli/docs/tracing-monitoring.md b/apps/cli/docs/tracing-monitoring.md index 6c39ddcc07..a9cf43b439 100644 --- a/apps/cli/docs/tracing-monitoring.md +++ b/apps/cli/docs/tracing-monitoring.md @@ -1,110 +1,169 @@ # CLI Tracing and Monitoring -This document describes the CLI tracing path: spans, local trace export, and how this -observability path is intended to relate to Sentry. +Every CLI run can produce one span tree rooted at `cli.run`. Use it to find where time goes in a +command. Tracing is off unless you turn it on for a run; the conventions for adding spans live in +[ADR 0027](../../../docs/adr/0027-cli-tracing-conventions.md). For product analytics and command usage events, see [analytics.md](./analytics.md). -For where CLI-owned global state lives, including telemetry state under `SUPABASE_HOME`, see -[supabase-home.md](./supabase-home.md). +## Turning tracing on -## Purpose +Pick one destination per run: -Tracing answers observability questions such as: +| Variable | Effect | +| ------------------------ | ------------------------------------------------------------------------ | +| `SUPABASE_TRACE_FILE` | Appends one OTLP/JSON batch per line to this file (`0600` when allowed). | +| `SUPABASE_OTLP_ENDPOINT` | Posts OTLP/HTTP JSON to `/v1/traces`. | +| `SUPABASE_OTLP_HEADERS` | Extra collector headers as `key=value,key2=value2` (percent-encoded). | +| `TRACEPARENT` | W3C trace context adopted as the parent of `cli.run` when a sink is set. | -- which command ran -- how long major phases took -- whether the command succeeded or failed -- what happened inside a command span tree +Tracing never stops a command. When both `SUPABASE_TRACE_FILE` and `SUPABASE_OTLP_ENDPOINT` are +set, the endpoint or headers are malformed, or the sink cannot start (for example, the trace +file's directory is missing or the path is not a regular file, such as `/dev/null`), the CLI +prints one `Warning: tracing disabled: ` line on stderr and runs the command untraced with +the same exit code and stdout. A relative `SUPABASE_TRACE_FILE` resolves against the directory the +CLI started in. The generic `OTEL_EXPORTER_OTLP_*`, `OTEL_SERVICE_NAME`, and +`OTEL_RESOURCE_ATTRIBUTES` variables are ignored. A sink records the run even when `TRACEPARENT` +is marked unsampled. -This path is span-based. It is intentionally separate from PostHog analytics, which is event-based -and optimized for product questions rather than operational traces. +`SUPABASE_DEBUG=1` or `SUPABASE_TELEMETRY_DEBUG=1`, set in the environment or the project's +`supabase/.env`, also prints finished command spans to stderr: top-level spans, their children and +grandchildren, plus every failed span at any depth. Spans that end before CLI settings load, and +`cli.run` itself, are not printed. The `--debug` flag does not print spans. -The tracing implementation is currently owned by the `Tracing` service and -[`src/shared/telemetry/tracing.layer.ts`](../src/shared/telemetry/tracing.layer.ts). +Without a sink or debug variable, spans are no-ops and the CLI does no tracing I/O. The +implementation lives in +[`src/shared/telemetry/trace-export.layer.ts`](../src/shared/telemetry/trace-export.layer.ts). -## What Happens Today +## Local trace file -The CLI builds a custom Effect tracer and attaches a fixed set of global attributes to every span: - -- `schema_version` -- `device_id` -- `session_id` -- `is_first_run` -- `is_tty` -- `is_ci` -- `os` -- `arch` -- `cli_version` - -Commands then use normal Effect tracing primitives such as `Effect.withSpan(...)` and -`Effect.annotateCurrentSpan(...)` to create and enrich spans. - -Today, tracing is exported in two ways: +```sh +SUPABASE_TRACE_FILE=/tmp/supabase-trace.jsonl supabase db diff +bun apps/cli/scripts/trace-report.ts /tmp/supabase-trace.jsonl +``` -- NDJSON files under `SUPABASE_HOME/traces/` -- optional debug console output when telemetry debug is enabled +The report prints the heaviest path from `cli.run`, the top span names by self time, repeated span +names with counts and total time, and failed spans. Docker and Podman spans are labeled with their +verb, such as `ContainerCli.spawn (run)`. Pass `--top N` to change list lengths and +`--json` for machine-readable output. The file appends across runs; the report covers the run that +ended last, or the one passed with `--trace-id`. -The NDJSON exporter is the durable local trace sink. The debug console exporter is only for -interactive inspection while developing or debugging the CLI. +## Benchmarking -## Consent and State +`apps/cli/scripts/bench-cli.ts` compares wall time between two CLI binaries, for example a release +build against a branch build: -Tracing follows the shared telemetry consent model used by the CLI: +```sh +bun apps/cli/scripts/bench-cli.ts --base /tmp/cli-base/apps/cli/dist/supabase --branch apps/cli/dist/supabase \ + --runs 10 --warmup 2 --command "--version" --command "status" --cwd-setup init --trace +``` -- consent is user-level CLI state stored in `SUPABASE_HOME/telemetry.json` -- environment overrides can still disable telemetry in CI or sandboxed runs -- consent is not stored in `supabase/config.*` -- consent is not stored in repo-local `.supabase/` +It alternates launches (`base`, `branch`, `base`, `branch`, ...) so neither binary runs consistently +warmer, discards the first `--warmup` pairs, and gives each launch an isolated `SUPABASE_HOME` and +environment. For each command it reports the base and branch median, min, max, and p90 wall time in +milliseconds, plus the branch-minus-base delta in ms and percent. With `--trace`, each measured +launch is followed by an untimed launch with `SUPABASE_TRACE_FILE` set, so tracing never inflates +the wall times, and the report adds, per span name, the median total duration and count for +each build, sorted by the largest absolute delta; a span name present in only one build is flagged +rather than compared. `--command` splits on whitespace and does not support shell quoting; repeat the +flag for more than one command, and use `--cwd-setup init` when a command, such as `status`, needs to +run inside an initialized project. Pass `--json` for a machine-readable report, `--out ` to +also write the raw per-run samples, and `--cpu-prof` to capture a Bun CPU profile of one untimed +branch launch (`BUN_OPTIONS=--cpu-prof`), printing its `.cpuprofile` path. Launches set +`SUPABASE_NO_UPDATE_NOTIFIER=1`; pass `--update-check` to include the release check. Each launch's +fresh `SUPABASE_HOME` has no cached release tag, so the check calls GitHub on every launch, except +with `--cwd-setup init`, where the setup `init` caches the tag in the project before the measured +command runs. The extra traced launches add machine load, so take wall-time conclusions from a run +without `--trace`. + +To build a base binary from another commit without disturbing this worktree: + +```sh +mkdir /tmp/cli-base && git archive | tar -x -C /tmp/cli-base +rm -f /tmp/cli-base/mise.toml /tmp/cli-base/mise.lock # if mise refuses the untrusted config +(cd /tmp/cli-base && pnpm install && pnpm exec turbo run supabase#build) +``` -The consent read/write logic lives in [`src/shared/telemetry/consent.ts`](../src/shared/telemetry/consent.ts), -and the runtime view of telemetry state is built in -[`src/shared/telemetry/runtime.layer.ts`](../src/shared/telemetry/runtime.layer.ts). +## Grafana, Tempo, or Jaeger -When consent is not granted, the tracing layer does not initialize the NDJSON exporter. Debug -output is gated separately by the telemetry debug flags. +Run a local OpenTelemetry stack, for example Grafana's all-in-one image: -## Local Storage Layout +```sh +docker run --rm -p 3000:3000 -p 4318:4318 grafana/otel-lgtm +SUPABASE_OTLP_ENDPOINT=http://localhost:4318 supabase start +``` -The tracing path writes local files under: +Open Grafana at `http://localhost:3000`, choose the Tempo data source, and search with TraceQL: ```text -SUPABASE_HOME/ - telemetry.json - traces/ - .ndjson +{ resource.service.name = "supabase-cli" && name = "cli.run" } +{ resource.service.name = "supabase-cli" && duration > 1s } +{ resource.service.name = "supabase-cli" && status = error } ``` -`telemetry.json` stores telemetry state such as consent, device identity, and session identity. -`traces/` stores exported spans. - -This keeps monitoring data in machine-global CLI state rather than project config. - -## Relation to Sentry - -Sentry belongs on the tracing and monitoring side, not the PostHog analytics side. - -Current state: - -- the CLI already has a span-based tracing path -- that path exports locally to NDJSON -- it is not yet using Sentry as the live exporter - -Intended direction: - -- Sentry should consume the tracing path as an observability backend -- PostHog should continue to receive curated analytics events separately - -That separation matters because traces and analytics solve different problems and have different -volume, retention, and schema needs. - -## What Tracing Is Not - -Tracing is intentionally not used for: - -- product analytics funnels -- command adoption reporting -- organization or project group analytics -- user-facing milestone events such as `cli_login_completed` - -Those concerns belong to the analytics path described in [analytics.md](./analytics.md). +Any collector that accepts OTLP/HTTP JSON works; pass credentials with `SUPABASE_OTLP_HEADERS`. + +## What a trace contains + +- Resource: `service.name=supabase-cli`, `service.version`, `os`, `arch`, `is_ci`, + `service.instance.id` (a random id per run). +- `cli.run`: `process.boot_ms`, the time from process start to CLI entry, and + `process.exit_code`, the code the CLI exits with. A non-zero code ends `cli.run` with error + status and a `CliNonZeroExit` exception type. +- Failed spans: `error.type`, the failing error's tag, when it has one. +- Command span (`command.`): `command` and `command_run_id`. When telemetry consent is + granted, also `device_id`, `session_id`, and `is_first_run`. +- Layer spans such as `CliSettings.load`, `CliProjectContext.load`, and `ProjectLinkState.load`. +- `UpgradeNotice.check`, the post-command release check: `cache.location` (`project`, `user`, or + `disabled` when a symlink blocks the cache) and `cache.hit`. A cache miss adds an + `UpgradeNotice.fetch` child for the GitHub request. +- HTTP client spans with method, host, path, status, and allowlisted headers. +- Process spans with the executable basename, argument count, exit code, and for docker or + podman the verb (such as `container inspect`). Children receive a + `TRACEPARENT` pointing at their span when a sink is active. + +## What is removed before export + +Traces carry no free-form error or log text. Every batch and every console line is sanitized: + +- Exception events keep only `exception.type`, the error class or tag name such as `SqlError`; a + name that is not an identifier is exported as `Error`. Error messages and stack traces are + dropped. A failed span records the Postgres SQLSTATE, when the error carries one, as + `db.response.status_code`. +- Span status messages are dropped; the status code remains. +- Log events are renamed `log` and keep only `effect.logLevel`. +- `db.query.text` is replaced by `db.operation.name`, `db.query.hash`, and `db.query.length`. The + hash is keyed by a random salt drawn once per run, so repeated statements share a hash within a + run but hashes from different runs cannot be compared. +- `url.full` keeps only scheme, host, and path; `url.query` is dropped. +- Storage object paths in `url.full` and `url.path` keep the operation, such as `sign`, and replace + the bucket and object name with ``. Bucket routes (`/storage/v1/bucket/…` and + `/storage/v1/iceberg/bucket/…`) replace the bucket name the same way. +- Only `content-type`, `content-length`, `user-agent`, `x-request-id`, `cf-ray`, and `retry-after` + headers are kept. +- String values under keys that mention tokens, passwords, secrets, API keys, authorization, or + cookies are dropped; numeric and boolean values such as counts are kept. +- Remaining string attributes lose URL credentials (everything before the last `@`), bearer tokens, JWTs, Supabase keys and access + tokens, password pairs, single-quoted SQL literals, and constraint key values such as + `Key (email)=(…)`, and are capped at 2 KB. + +The terminal still shows the full error text. + +HTTP requests never carry `traceparent` or `b3` headers. + +## Lifecycle + +The trace scope closes before the process exits. The final flush is uninterruptible and capped at +2 seconds, so a slow collector cannot hold the CLI open. The file sink always acknowledges a +batch, so a local write failure never triggers retries or duplicate lines. + +## Old trace files + +Earlier versions wrote NDJSON files under `SUPABASE_HOME/traces/`. The CLI no longer writes or +cleans that directory; it is safe to delete. + +## What tracing is not + +Tracing is not used for product analytics funnels, command adoption reporting, organization or +project analytics, or milestone events such as `cli_login_completed`. Those belong to the +analytics path described in [analytics.md](./analytics.md). diff --git a/apps/cli/package.json b/apps/cli/package.json index c0251fc102..f5550975df 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -57,11 +57,11 @@ "@napi-rs/keyring": "^1.3.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", - "@supabase/pg-delta": "1.0.0-alpha.56", - "@supabase/pg-topo": "1.0.0-alpha.7", + "@supabase/pg-delta": "1.0.0-alpha.57", + "@supabase/pg-topo": "1.0.0-alpha.8", "@supabase/stack": "workspace:*", "@supabase/supabase-js": "catalog:", - "@supabase/typegen": "0.2.1", + "@supabase/typegen": "0.2.2", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/pg": "^8.23.1", diff --git a/apps/cli/scripts/bench-cli.ts b/apps/cli/scripts/bench-cli.ts new file mode 100644 index 0000000000..94e77f2da2 --- /dev/null +++ b/apps/cli/scripts/bench-cli.ts @@ -0,0 +1,601 @@ +/** + * 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]`. + */ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { resolve as resolvePath } from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; +import { BunRuntime, BunServices } from "@effect/platform-bun"; +import { Clock, Console, Effect, FileSystem, Path, Ref, Stdio } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { readSpans, type ReportSpan } 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" + + " --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 cwdSetupInit: boolean; + readonly trace: boolean; + readonly json: boolean; + readonly out: string | undefined; + readonly cpuProf: boolean; + readonly updateCheck: boolean; +} + +const DEFAULT_COMMANDS: ReadonlyArray> = [["--version"], ["--help"]]; + +/** Whether `--help`/`-h` is passed as a flag rather than as a `--command` value. */ +export function wantsHelp(argv: ReadonlyArray): boolean { + return argv.some( + (arg, index) => (arg === "--help" || arg === "-h") && argv[index - 1] !== "--command", + ); +} + +/** Parses argv into {@link BenchOptions}; throws on missing `--base`/`--branch` or bad numbers. */ +export function parseBenchArgs(argv: ReadonlyArray): BenchOptions { + // `parseArgs` rejects a separate value that starts with `-`, but CLI commands usually do. + const args: Array = []; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]!; + const value = argv[index + 1]; + if (arg === "--command" && value !== undefined) { + args.push(`--command=${value}`); + index++; + } else { + args.push(arg); + } + } + const { values } = parseArgs({ + args, + options: { + base: { type: "string" }, + branch: { type: "string" }, + runs: { type: "string", default: "10" }, + warmup: { type: "string", default: "2" }, + command: { type: "string", multiple: true }, + "cwd-setup": { type: "string" }, + trace: { type: "boolean", default: false }, + json: { type: "boolean", default: false }, + out: { type: "string" }, + "cpu-prof": { type: "boolean", default: false }, + "update-check": { type: "boolean", default: false }, + }, + }); + + if (values.base === undefined || values.branch === undefined) { + throw new Error(USAGE); + } + const runs = Number(values.runs); + const warmup = Number(values.warmup); + if (!Number.isInteger(runs) || runs < 1) { + throw new Error("--runs must be a positive integer"); + } + if (!Number.isInteger(warmup) || warmup < 0) { + throw new Error("--warmup must be a non-negative integer"); + } + if (values["cwd-setup"] !== undefined && values["cwd-setup"] !== "init") { + throw new Error('--cwd-setup only supports "init"'); + } + + return { + // Each launch spawns with a per-run `cwd`, which also resolves a relative command path; a + // path given relative to the invocation directory must become absolute before that happens. + base: resolvePath(values.base), + branch: resolvePath(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)), + cwdSetupInit: values["cwd-setup"] === "init", + trace: values.trace ?? false, + json: values.json ?? false, + out: values.out, + cpuProf: values["cpu-prof"] ?? false, + updateCheck: values["update-check"] ?? false, + }; +} + +const ENV_PREFIX = /^(?:SUPABASE_|OTEL_)/u; + +/** + * Starts from `ambient`, drops every `SUPABASE_*`/`OTEL_*` variable and `TRACEPARENT` so no + * consent, trace, or endpoint state leaks between launches, then applies `overrides`. + */ +export function isolatedEnv( + ambient: Readonly>, + overrides: Readonly>, +): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(ambient)) { + if (key === "TRACEPARENT" || ENV_PREFIX.test(key)) continue; + env[key] = value; + } + return { ...env, ...overrides }; +} + +export interface PlannedRun { + readonly build: Build; + readonly warmup: boolean; + readonly iteration: number; +} + +/** Alternates base/branch launches per iteration; the first `warmup` iterations are discarded. */ +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 }); + } + 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, + }; + } + 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; +} + +interface LaunchResult { + readonly wallMs: number; + readonly exitCode: number; +} + +const runLaunch = (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( + ChildProcess.make(params.binary, params.args, { + cwd: params.cwd, + env: params.env, + extendEnv: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ); + const end = yield* Clock.currentTimeNanos; + return { + wallMs: Number(end - start) / 1_000_000, + exitCode: Number(exitCode), + } satisfies LaunchResult; + }); + +const baseLaunchEnv = (supabaseHome: string, updateCheck: boolean): Record => ({ + SUPABASE_HOME: supabaseHome, + DO_NOT_TRACK: "1", + SUPABASE_NO_KEYRING: "1", + ...(updateCheck ? {} : { SUPABASE_NO_UPDATE_NOTIFIER: "1" }), +}); + +/** A fresh project directory and `SUPABASE_HOME`, initialized when `--cwd-setup init` is set. */ +const prepareLaunch = (options: BenchOptions, binary: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-bench-cwd-" }); + const supabaseHome = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-bench-home-" }); + if (options.cwdSetupInit) { + const init = yield* runLaunch({ + binary, + args: ["init"], + cwd: projectDir, + env: isolatedEnv(process.env, baseLaunchEnv(supabaseHome, options.updateCheck)), + }); + if (init.exitCode !== 0) { + yield* Console.error( + `warning: ${binary} init exited ${init.exitCode} in ${projectDir}; the run may not reflect a real project`, + ); + } + } + return { projectDir, supabaseHome }; + }); + +const runCommandBench = ( + options: BenchOptions, + command: ReadonlyArray, + profiled: Ref.Ref, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + 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 = []; + let cpuProfilePath: string | undefined; + + 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 result = yield* runLaunch({ + binary, + args: command, + cwd: projectDir, + env: isolatedEnv(process.env, baseLaunchEnv(supabaseHome, options.updateCheck)), + }); + + 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. + const traceFile = options.trace + ? yield* fs.makeTempFileScoped({ prefix: "supabase-bench-trace-", suffix: ".jsonl" }) + : undefined; + let bunOptions: string | undefined; + if (options.cpuProf && planned.build === "branch") { + const alreadyProfiled = yield* Ref.getAndSet(profiled, true); + if (!alreadyProfiled) { + const profileDir = tmpdir(); + const profileName = `supabase-bench-branch-${randomUUID()}.cpuprofile`; + cpuProfilePath = path.join(profileDir, profileName); + bunOptions = `--cpu-prof --cpu-prof-dir=${profileDir} --cpu-prof-name=${profileName}`; + } + } + if (traceFile !== undefined || bunOptions !== undefined) { + const observed = yield* prepareLaunch(options, binary); + yield* runLaunch({ + binary, + args: command, + cwd: observed.projectDir, + env: isolatedEnv(process.env, { + ...baseLaunchEnv(observed.supabaseHome, options.updateCheck), + ...(traceFile === undefined ? {} : { SUPABASE_TRACE_FILE: traceFile }), + ...(bunOptions === undefined ? {} : { BUN_OPTIONS: bunOptions }), + }), + }); + } + + 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, + wallMs: result.wallMs, + exitCode: result.exitCode, + spans, + }); + }), + ); + } + + 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), + }; + return { report, raw, 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; + return ` ${span.onlyIn} only ${span.name} ${only?.medianMs}ms x${only?.medianCount}`; + } + const sign = (span.deltaMs ?? 0) >= 0 ? "+" : ""; + return ( + ` ${sign}${span.deltaMs}ms ${span.name} ` + + `base ${span.base?.medianMs}ms x${span.base?.medianCount} branch ${span.branch?.medianMs}ms x${span.branch?.medianCount}` + ); +} + +function formatCommandReport(commandReport: CommandReport): ReadonlyArray { + const sign = (value: number) => (value >= 0 ? "+" : ""); + 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}%)`, + ]; + 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 (commandReport.traceEmptyFor.length > 0) { + lines.push(` trace: no spans captured for ${commandReport.traceEmptyFor.join(", ")}`); + } + if (commandReport.spans !== undefined && commandReport.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)); + } + lines.push(""); + return lines; +} + +function formatReport(report: Report): string { + const lines = [ + `base: ${report.base}`, + `branch: ${report.branch}`, + `runs: ${report.runs} warmup: ${report.warmup}` + + (report.cwdSetup === undefined ? "" : ` cwd-setup: ${report.cwdSetup}`), + "", + ]; + for (const commandReport of report.commands) lines.push(...formatCommandReport(commandReport)); + if (report.cpuProfilePath !== undefined) { + lines.push(`cpu profile (one untimed branch launch): ${report.cpuProfilePath}`); + } + return lines.join("\n"); +} + +const main = Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const argv = yield* stdio.args; + if (wantsHelp(argv)) { + yield* Console.log(USAGE); + return; + } + const options = yield* Effect.try({ + try: () => parseBenchArgs(argv), + catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), + }).pipe( + Effect.catch((message) => + Console.error(message === USAGE ? USAGE : `${message}\n${USAGE}`).pipe( + Effect.andThen( + Effect.sync(() => { + process.exitCode = 2; + }), + ), + Effect.as(undefined), + ), + ), + ); + if (options === undefined) return; + + const profiled = yield* Ref.make(false); + const commands: Array = []; + const samples: 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); + if (result.cpuProfilePath !== undefined) cpuProfilePath = result.cpuProfilePath; + } + + const report: Report = { + base: options.base, + branch: options.branch, + runs: options.runs, + warmup: options.warmup, + trace: options.trace, + cwdSetup: options.cwdSetupInit ? "init" : undefined, + commands, + cpuProfilePath, + }; + + if (options.out !== undefined) { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(options.out, JSON.stringify({ report, samples }, null, 2)); + } + + yield* Console.log(options.json ? JSON.stringify(report, null, 2) : formatReport(report)); +}); + +if (import.meta.main) { + BunRuntime.runMain(main.pipe(Effect.provide(BunServices.layer))); +} diff --git a/apps/cli/scripts/bench-cli.unit.test.ts b/apps/cli/scripts/bench-cli.unit.test.ts new file mode 100644 index 0000000000..d991afdc32 --- /dev/null +++ b/apps/cli/scripts/bench-cli.unit.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + aggregateSpanRuns, + buildRunPlan, + compareSpans, + deltaOf, + exitCodesDiffer, + isolatedEnv, + parseBenchArgs, + 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, +}); + +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", + "--command", + "--version", + "--command", + "db diff --local", + ]); + + expect(options.commands).toEqual([["--version"], ["db", "diff", "--local"]]); + expect(options.updateCheck).toBe(false); + }); +}); + +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, + }); + }); +}); + +describe("buildRunPlan", () => { + it("alternates base and branch 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", false], + ["branch", false], + ["base", false], + ["branch", 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 }, + }); + }); + + 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", + }, + ]); + }); +}); + +describe("isolatedEnv", () => { + it("strips SUPABASE_*, OTEL_*, and TRACEPARENT while keeping other ambient variables", () => { + const ambient = { + HOME: "/home/person", + PATH: "/usr/bin", + SUPABASE_ACCESS_TOKEN: "secret", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://collector", + TRACEPARENT: "00-...-01", + }; + + expect(isolatedEnv(ambient, { SUPABASE_HOME: "/tmp/home", DO_NOT_TRACK: "1" })).toEqual({ + HOME: "/home/person", + PATH: "/usr/bin", + SUPABASE_HOME: "/tmp/home", + DO_NOT_TRACK: "1", + }); + }); +}); diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index f2dcb02b00..28ed04fee9 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,3 +1,4 @@ +import { Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; @@ -23,7 +24,9 @@ const result = await Bun.build({ define: { SUPABASE_CLI_VERSION: JSON.stringify(packageJson.version), ...(await stackReleaseDefine()), - SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), + SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( + await Effect.runPromise(bundleServeMainTemplate()), + ), // Skips msgpackr's native addon probe at the build host's path, which can hang macOS startup. "process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": JSON.stringify("true"), }, diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 87131b408e..52ee98efff 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -4,6 +4,7 @@ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; @@ -89,7 +90,9 @@ const distDir = path.join(root, "dist"); const goSource = path.resolve(root, "apps/cli-go"); const buildDefines = { ...(await stackReleaseDefine()), - SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), + SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( + await Effect.runPromise(bundleServeMainTemplate()), + ), SUPABASE_CLI_POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_API_KEY ?? ""), SUPABASE_CLI_POSTHOG_HOST: JSON.stringify(process.env.POSTHOG_ENDPOINT ?? ""), // Skips msgpackr's startup probe for its native addon at the build host's store path, which diff --git a/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts index 73080b1628..f0e554551c 100644 --- a/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts +++ b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts @@ -3,9 +3,9 @@ import { describe, expect, test, vi } from "vitest"; import { renderDockerfile } from "./render-service-dockerfile.ts"; // A minimal fixture catalog covering every slim-capable alias, so `renderDockerfile`'s -// "every alias needs exactly one line" check is satisfied by default; each test only mutates -// what it's exercising. `vi.mock` factories are hoisted above every other top-level statement, -// so the fixture pins are built inline here rather than imported from an outer module. +// one-line-per-alias check is satisfied by default; each test only mutates what it's +// exercising. `vi.mock` factories are hoisted above every other top-level statement, so the +// fixture pins are built inline here rather than imported from an outer module. vi.mock("@supabase/stack/internal/artifacts", () => { const nativePin = { archive: "a".repeat(64), manifest: "b".repeat(64) }; const natives = { "darwin-arm64": nativePin, "linux-amd64": nativePin, "linux-arm64": nativePin }; diff --git a/apps/cli/scripts/render-service-dockerfile.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.unit.test.ts index 0c4f9036d9..029a235af8 100644 --- a/apps/cli/scripts/render-service-dockerfile.unit.test.ts +++ b/apps/cli/scripts/render-service-dockerfile.unit.test.ts @@ -21,8 +21,7 @@ describe("renderDockerfile against the real catalog and Dockerfile", () => { }); // The Go tree still `go:embed`s its own copy for a dependency that hasn't been removed yet; - // this is the single place that keeps the two copies in sync (folded from the former - // dockerfile-go-sync.unit.test.ts), until apps/cli-go is deleted. + // this is the single place that keeps the two copies in sync until apps/cli-go is deleted. test("the Go tree's embedded Dockerfile is a byte copy of the TS-owned one", () => { const goDockerfile = readFileSync(GO_DOCKERFILE_PATH, "utf8"); expect(goDockerfile).toBe(currentTsDockerfile); diff --git a/apps/cli/scripts/trace-report.ts b/apps/cli/scripts/trace-report.ts new file mode 100644 index 0000000000..29cb80c39d --- /dev/null +++ b/apps/cli/scripts/trace-report.ts @@ -0,0 +1,312 @@ +/** + * Summarizes a `SUPABASE_TRACE_FILE` trace: heaviest path, top self time, repeated span names, + * and failures. Run as `bun scripts/trace-report.ts [--top N] [--json]`. + */ +import { BunRuntime, BunServices } from "@effect/platform-bun"; +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) }), + }), +); + +const stringAttribute = (attributes: typeof KeyValues.Type | undefined, key: string) => + attributes?.find((attribute) => attribute.key === key)?.value.stringValue; + +/** 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})`; +} + +const OtlpSpan = Schema.Struct({ + traceId: Schema.String, + spanId: Schema.String, + parentSpanId: Schema.optional(Schema.String), + name: Schema.String, + startTimeUnixNano: Schema.String, + endTimeUnixNano: Schema.String, + status: Schema.Struct({ code: Schema.Number }), + attributes: Schema.optional(KeyValues), + events: Schema.optional( + Schema.Array(Schema.Struct({ name: Schema.String, attributes: KeyValues })), + ), +}); + +const TraceBatch = Schema.fromJsonString( + Schema.Struct({ + resourceSpans: Schema.Array( + Schema.Struct({ + scopeSpans: Schema.Array(Schema.Struct({ spans: Schema.Array(OtlpSpan) })), + }), + ), + }), +); + +export interface ReportSpan { + readonly traceId: string; + readonly spanId: string; + readonly parentSpanId: string | undefined; + readonly name: string; + readonly startMs: number; + readonly endMs: number; + readonly failed: boolean; + readonly errorType: string | undefined; +} + +export interface TraceReport { + readonly spanCount: number; + readonly criticalPath: ReadonlyArray<{ readonly name: string; readonly durationMs: number }>; + readonly topSelfTime: ReadonlyArray<{ readonly name: string; readonly selfMs: number }>; + readonly repeated: ReadonlyArray<{ + readonly name: string; + readonly count: number; + readonly totalMs: number; + }>; + readonly failures: ReadonlyArray<{ + readonly name: string; + readonly errorType: string | undefined; + }>; +} + +const STATUS_ERROR = 2; +const nanosToMs = (value: string) => Number(BigInt(value) / 1_000n) / 1_000; +const round = (value: number) => Math.round(value * 100) / 100; + +function coveredMs(intervals: ReadonlyArray): number { + const sorted = [...intervals].sort((a, b) => a[0] - b[0]); + let total = 0; + let current: [number, number] | undefined; + for (const [start, end] of sorted) { + if (current === undefined || start > current[1]) { + if (current !== undefined) total += current[1] - current[0]; + current = [start, end]; + } else { + current[1] = Math.max(current[1], end); + } + } + return current === undefined ? total : total + current[1] - current[0]; +} + +/** Builds the report from decoded spans. */ +export function analyzeTrace(spans: ReadonlyArray, top: number): TraceReport { + const ids = new Set(spans.map((span) => span.spanId)); + const children = new Map>(); + for (const span of spans) { + if (span.parentSpanId === undefined || !ids.has(span.parentSpanId)) continue; + const siblings = children.get(span.parentSpanId) ?? []; + siblings.push(span); + children.set(span.parentSpanId, siblings); + } + + const roots = spans + .filter((span) => span.parentSpanId === undefined || !ids.has(span.parentSpanId)) + .sort((a, b) => b.endMs - b.startMs - (a.endMs - a.startMs)); + const criticalPath: Array<{ name: string; durationMs: number }> = []; + let node = roots[0]; + while (node !== undefined) { + criticalPath.push({ name: node.name, durationMs: round(node.endMs - node.startMs) }); + const next = children.get(node.spanId); + node = next?.reduce((longest, child) => + child.endMs - child.startMs > longest.endMs - longest.startMs ? child : longest, + ); + } + + const selfByName = new Map(); + const repeatedByName = new Map(); + for (const span of spans) { + const duration = span.endMs - span.startMs; + // A child entirely outside its parent clips to an inverted interval that covers nothing. + const childIntervals = (children.get(span.spanId) ?? []) + .map( + (child) => + [Math.max(child.startMs, span.startMs), Math.min(child.endMs, span.endMs)] as const, + ) + .filter(([start, end]) => end > start); + const self = Math.max(0, duration - coveredMs(childIntervals)); + selfByName.set(span.name, (selfByName.get(span.name) ?? 0) + self); + const entry = repeatedByName.get(span.name) ?? { count: 0, totalMs: 0 }; + repeatedByName.set(span.name, { count: entry.count + 1, totalMs: entry.totalMs + duration }); + } + + return { + spanCount: spans.length, + criticalPath, + topSelfTime: [...selfByName] + .map(([name, selfMs]) => ({ name, selfMs: round(selfMs) })) + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, top), + repeated: [...repeatedByName] + .filter(([, entry]) => entry.count > 1) + .map(([name, entry]) => ({ name, count: entry.count, totalMs: round(entry.totalMs) })) + .sort((a, b) => b.count - a.count) + .slice(0, top), + failures: spans + .filter((span) => span.failed) + .map((span) => ({ name: span.name, errorType: span.errorType })), + }; +} + +function formatReport(report: TraceReport): string { + const lines = [`spans: ${report.spanCount}`, "", "critical path:"]; + report.criticalPath.forEach((step, depth) => + lines.push(` ${" ".repeat(depth)}${step.name} ${step.durationMs}ms`), + ); + lines.push("", "top self time:"); + for (const entry of report.topSelfTime) lines.push(` ${entry.selfMs}ms ${entry.name}`); + lines.push("", "repeated spans:"); + for (const entry of report.repeated) { + lines.push(` ${entry.count}x ${entry.totalMs}ms ${entry.name}`); + } + lines.push("", "failures:"); + if (report.failures.length === 0) lines.push(" none"); + for (const failure of report.failures) { + lines.push( + ` ${failure.name}${failure.errorType === undefined ? "" : `: ${failure.errorType}`}`, + ); + } + return lines.join("\n"); +} + +/** + * Decodes a `SUPABASE_TRACE_FILE`'s OTLP/JSON lines into flat spans, skipping lines that do not + * decode, such as a batch truncated by an interrupted run. + */ +export const readSpans = Effect.fnUntraced(function* (file: string) { + const fs = yield* FileSystem.FileSystem; + const text = yield* fs.readFileString(file); + const spans: Array = []; + let skippedLines = 0; + for (const line of text.split("\n")) { + if (line.trim().length === 0) continue; + const decoded = yield* Schema.decodeUnknownEffect(TraceBatch)(line).pipe(Effect.option); + if (decoded._tag === "None") { + skippedLines += 1; + continue; + } + const batch = decoded.value; + for (const resource of batch.resourceSpans) { + for (const scope of resource.scopeSpans) { + for (const span of scope.spans) { + spans.push({ + traceId: span.traceId, + spanId: span.spanId, + parentSpanId: span.parentSpanId, + name: reportSpanName(span.name, stringAttribute(span.attributes, "process.subcommand")), + startMs: nanosToMs(span.startTimeUnixNano), + endMs: nanosToMs(span.endTimeUnixNano), + failed: span.status.code === STATUS_ERROR, + errorType: stringAttribute( + span.events?.find((event) => event.name === "exception")?.attributes, + "exception.type", + ), + }); + } + } + } + } + return { spans, skippedLines }; +}); + +const USAGE = "usage: bun scripts/trace-report.ts [--top N] [--trace-id ID] [--json]"; + +/** One trace from a file that may hold several runs: `traceId`, or else the one that ended last. */ +export function selectTrace( + spans: ReadonlyArray, + traceId: string | undefined, +): + | { + readonly traceId: string; + readonly traceCount: number; + readonly spans: ReadonlyArray; + } + | undefined { + const byTrace = new Map>(); + for (const span of spans) { + const group = byTrace.get(span.traceId) ?? []; + group.push(span); + byTrace.set(span.traceId, group); + } + const lastEnd = (group: ReadonlyArray) => + Math.max(...group.map((span) => span.endMs)); + const chosen = + traceId ?? [...byTrace].sort(([, a], [, b]) => lastEnd(b) - lastEnd(a)).map(([id]) => id)[0]; + const group = chosen === undefined ? undefined : byTrace.get(chosen); + return chosen === undefined || group === undefined + ? undefined + : { traceId: chosen, traceCount: byTrace.size, spans: group }; +} + +export interface TraceReportOptions { + readonly file: string; + readonly top: number; + readonly traceId: string | undefined; + readonly json: boolean; +} + +/** Parses argv into {@link TraceReportOptions}; throws on a missing file or a bad `--top`. */ +export function parseTraceReportArgs(args: ReadonlyArray): TraceReportOptions { + const topIndex = args.indexOf("--top"); + const top = topIndex === -1 ? 15 : Number(args[topIndex + 1]); + if (!Number.isInteger(top) || top < 1) { + throw new Error("--top must be a positive integer"); + } + const traceIdIndex = args.indexOf("--trace-id"); + const traceId = traceIdIndex === -1 ? undefined : args[traceIdIndex + 1]; + if (traceIdIndex !== -1 && (traceId === undefined || traceId.startsWith("--"))) { + throw new Error("--trace-id needs a trace id"); + } + const valueIndexes = new Set([topIndex, traceIdIndex].filter((i) => i !== -1).map((i) => i + 1)); + const file = args.find((arg, index) => !arg.startsWith("--") && !valueIndexes.has(index)); + if (file === undefined) throw new Error(USAGE); + return { file, top, traceId, json: args.includes("--json") }; +} + +const main = Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const args = yield* stdio.args; + const options = yield* Effect.try({ + try: () => parseTraceReportArgs(args), + catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), + }).pipe( + Effect.catch((message) => + Console.error(message === USAGE ? USAGE : `${message}\n${USAGE}`).pipe( + Effect.andThen( + Effect.sync(() => { + process.exitCode = 2; + }), + ), + Effect.as(undefined), + ), + ), + ); + if (options === undefined) return; + const { spans, skippedLines } = yield* readSpans(options.file); + if (skippedLines > 0) { + yield* Console.error(`warning: skipped ${skippedLines} trace line(s) that did not decode`); + } + const trace = selectTrace(spans, options.traceId); + if (trace === undefined) { + yield* Console.error( + options.traceId === undefined + ? "no spans in the trace file" + : `trace ${options.traceId} is not in the trace file`, + ); + yield* Effect.sync(() => { + process.exitCode = 1; + }); + return; + } + const report = analyzeTrace(trace.spans, options.top); + const header = `trace: ${trace.traceId}${trace.traceCount > 1 ? ` (latest of ${trace.traceCount}; choose one with --trace-id)` : ""}`; + yield* Console.log( + options.json + ? JSON.stringify({ traceId: trace.traceId, traceCount: trace.traceCount, ...report }, null, 2) + : `${header}\n${formatReport(report)}`, + ); +}); + +if (import.meta.main) { + BunRuntime.runMain(main.pipe(Effect.provide(BunServices.layer))); +} diff --git a/apps/cli/scripts/trace-report.unit.test.ts b/apps/cli/scripts/trace-report.unit.test.ts new file mode 100644 index 0000000000..66e7bb5140 --- /dev/null +++ b/apps/cli/scripts/trace-report.unit.test.ts @@ -0,0 +1,179 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem } from "effect"; +import { describe, expect, it } from "vitest"; +import { + analyzeTrace, + parseTraceReportArgs, + readSpans, + reportSpanName, + selectTrace, + type ReportSpan, +} from "./trace-report.ts"; + +const span = ( + spanId: string, + parentSpanId: string | undefined, + name: string, + startMs: number, + endMs: number, + failed = false, +): ReportSpan => ({ + traceId: "t", + spanId, + parentSpanId, + name, + startMs, + endMs, + failed, + errorType: failed ? "SqlError" : undefined, +}); + +describe("analyzeTrace", () => { + const spans = [ + span("root", undefined, "cli.run", 0, 100), + span("a", "root", "Config.load", 0, 10), + span("b", "root", "Db.query", 10, 40), + span("c", "root", "Db.query", 40, 90, true), + span("d", "c", "Db.connect", 40, 80), + ]; + + it("follows the longest child from the root", () => { + expect(analyzeTrace(spans, 5).criticalPath.map((step) => step.name)).toEqual([ + "cli.run", + "Db.query", + "Db.connect", + ]); + }); + + it("subtracts child time from self time", () => { + const self = Object.fromEntries( + analyzeTrace(spans, 5).topSelfTime.map((entry) => [entry.name, entry.selfMs]), + ); + + expect(self).toEqual({ "cli.run": 10, "Config.load": 10, "Db.query": 40, "Db.connect": 40 }); + }); + + it("reports repeated names and failures", () => { + const report = analyzeTrace(spans, 5); + + expect(report.repeated).toEqual([{ name: "Db.query", count: 2, totalMs: 80 }]); + expect(report.failures).toEqual([{ name: "Db.query", errorType: "SqlError" }]); + }); + + it("ignores a child that ends outside its parent when computing self time", () => { + const report = analyzeTrace( + [ + span("root", undefined, "cli.run", 0, 100), + span("late", "root", "Analytics.flush", 150, 200), + span("inner", "root", "Db.query", 20, 50), + ], + 5, + ); + const self = Object.fromEntries(report.topSelfTime.map((entry) => [entry.name, entry.selfMs])); + + expect(self["cli.run"]).toBe(70); + }); +}); + +describe("parseTraceReportArgs", () => { + it("reads the trace file, --top, and --json", () => { + expect(parseTraceReportArgs(["trace.jsonl", "--top", "3", "--json"])).toEqual({ + file: "trace.jsonl", + top: 3, + json: true, + }); + }); + + it("defaults --top to 15", () => { + expect(parseTraceReportArgs(["trace.jsonl"]).top).toBe(15); + }); + + it.each([["0"], ["-2"], ["1.5"], ["abc"]])("rejects --top %s", (value) => { + expect(() => parseTraceReportArgs(["trace.jsonl", "--top", value])).toThrow( + "--top must be a positive integer", + ); + }); + + it("rejects --top without a value", () => { + expect(() => parseTraceReportArgs(["trace.jsonl", "--top"])).toThrow( + "--top must be a positive integer", + ); + }); + + it("requires a trace file", () => { + expect(() => parseTraceReportArgs(["--json"])).toThrow("usage:"); + }); +}); + +describe("reportSpanName", () => { + it("separates container CLI verbs and leaves other spans unchanged", () => { + expect(reportSpanName("ContainerCli.spawn", "image inspect")).toBe( + "ContainerCli.spawn (image inspect)", + ); + expect(reportSpanName("Db.query", undefined)).toBe("Db.query"); + }); +}); + +describe("readSpans", () => { + it("keeps decodable batches and counts lines that do not decode", async () => { + const batch = JSON.stringify({ + resourceSpans: [ + { + scopeSpans: [ + { + spans: [ + { + traceId: "t", + spanId: "s", + name: "cli.run", + startTimeUnixNano: "0", + endTimeUnixNano: "1000000", + status: { code: 1 }, + }, + ], + }, + ], + }, + ], + }); + + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = yield* fs.makeTempFileScoped({ suffix: ".jsonl" }); + yield* fs.writeFileString(file, `${batch}\n{"resourceSpans": [\n`); + return yield* readSpans(file); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); + + expect(result.spans.map((span) => span.name)).toEqual(["cli.run"]); + expect(result.skippedLines).toBe(1); + }); +}); + +describe("selectTrace", () => { + const twoRuns = [ + { ...span("a1", undefined, "cli.run", 0, 100), traceId: "first" }, + { ...span("b1", undefined, "cli.run", 200, 260), traceId: "second" }, + { ...span("b2", "b1", "Db.query", 210, 250), traceId: "second" }, + ]; + + it("reports the run that ended last unless a trace id is given", () => { + expect(selectTrace(twoRuns, undefined)).toMatchObject({ + traceId: "second", + traceCount: 2, + spans: [{ spanId: "b1" }, { spanId: "b2" }], + }); + expect(selectTrace(twoRuns, "first")?.spans.map((s) => s.spanId)).toEqual(["a1"]); + expect(selectTrace(twoRuns, "missing")).toBeUndefined(); + }); + + it("parses --trace-id without mistaking its value for the file", () => { + expect(parseTraceReportArgs(["--trace-id", "abc", "trace.jsonl"])).toMatchObject({ + file: "trace.jsonl", + traceId: "abc", + }); + }); +}); diff --git a/apps/cli/src/auth/command-credentials.layer.ts b/apps/cli/src/auth/command-credentials.layer.ts index 61abed0833..8701566a7c 100644 --- a/apps/cli/src/auth/command-credentials.layer.ts +++ b/apps/cli/src/auth/command-credentials.layer.ts @@ -383,49 +383,51 @@ const readFallbackFile = ( * construction. Fails with the same validation error as `resolveAccessToken` and propagates * credential storage failures. */ -export const accessTokenForProfile = Effect.fnUntraced(function* (profileAccount: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const runtimeInfo = yield* RuntimeInfo; - const cliSettings = yield* CommandSettings; - const configProvider = yield* ConfigProvider.ConfigProvider; - // Keeps the logger optional — a no-op outside the real CLI tree. - const debugLogger: DebugLoggerShape = Option.getOrElse( - yield* Effect.serviceOption(DebugLogger), - () => ({ debug: () => Effect.void, http: () => Effect.void }), - ); +export const accessTokenForProfile = Effect.fn("CommandCredentials.accessTokenForProfile")( + function* (profileAccount: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; + const cliSettings = yield* CommandSettings; + const configProvider = yield* ConfigProvider.ConfigProvider; + // Keeps the logger optional — a no-op outside the real CLI tree. + const debugLogger: DebugLoggerShape = Option.getOrElse( + yield* Effect.serviceOption(DebugLogger), + () => ({ debug: () => Effect.void, http: () => Effect.void }), + ); - if (Option.isSome(cliSettings.accessToken)) { - yield* debugLogger.debug("Using access token from env var..."); - yield* validateAccessToken(Redacted.value(cliSettings.accessToken.value)); - return Option.some(cliSettings.accessToken.value); - } + if (Option.isSome(cliSettings.accessToken)) { + yield* debugLogger.debug("Using access token from env var..."); + yield* validateAccessToken(Redacted.value(cliSettings.accessToken.value)); + return Option.some(cliSettings.accessToken.value); + } - const noKeyring = yield* Config.option(Config.string("SUPABASE_NO_KEYRING")).parse( - configProvider, - ); - const keyringModule = yield* loadKeyringModule(fs, noKeyring); - const keyringValue = yield* readKeyringForAccount( - keyringModule, - profileAccount, - runtimeInfo.platform, - debugLogger, - ); - if (Option.isSome(keyringValue)) { - yield* validateAccessToken(keyringValue.value); - return Option.some(Redacted.make(keyringValue.value)); - } + const noKeyring = yield* Config.option(Config.string("SUPABASE_NO_KEYRING")).parse( + configProvider, + ); + const keyringModule = yield* loadKeyringModule(fs, noKeyring); + const keyringValue = yield* readKeyringForAccount( + keyringModule, + profileAccount, + runtimeInfo.platform, + debugLogger, + ); + if (Option.isSome(keyringValue)) { + yield* validateAccessToken(keyringValue.value); + return Option.some(Redacted.make(keyringValue.value)); + } - const fallbackPath = path.join(cliSettings.supabaseHome, "access-token"); - const fileValue = yield* readFallbackFile(fs, fallbackPath); - if (Option.isSome(fileValue)) { - yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); - yield* validateAccessToken(fileValue.value); - return Option.some(Redacted.make(fileValue.value)); - } + const fallbackPath = path.join(cliSettings.supabaseHome, "access-token"); + const fileValue = yield* readFallbackFile(fs, fallbackPath); + if (Option.isSome(fileValue)) { + yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); + yield* validateAccessToken(fileValue.value); + return Option.some(Redacted.make(fileValue.value)); + } - return Option.none>(); -}); + return Option.none>(); + }, +); const makeCommandCredentials = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -444,6 +446,7 @@ const makeCommandCredentials = Effect.gen(function* () { const fallbackPath = path.join(fallbackDir, "access-token"); const keyringModule = yield* loadKeyringModule(fs, noKeyring); + yield* Effect.annotateCurrentSpan({ "keyring.enabled": Option.isSome(keyringModule) }); const readKeyring = readKeyringForAccount( keyringModule, @@ -459,6 +462,7 @@ const makeCommandCredentials = Effect.gen(function* () { if (Option.isSome(cliSettings.accessToken)) { yield* debugLogger.debug("Using access token from env var..."); yield* validateAccessToken(Redacted.value(cliSettings.accessToken.value), "env"); + yield* Effect.annotateCurrentSpan({ "credential.source": "env" }); return Option.some(cliSettings.accessToken.value); } @@ -466,6 +470,7 @@ const makeCommandCredentials = Effect.gen(function* () { const keyringValue = yield* readKeyring; if (Option.isSome(keyringValue)) { yield* validateAccessToken(keyringValue.value, "stored"); + yield* Effect.annotateCurrentSpan({ "credential.source": "keyring" }); return Option.some(Redacted.make(keyringValue.value)); } @@ -473,29 +478,34 @@ const makeCommandCredentials = Effect.gen(function* () { if (Option.isSome(fileValue)) { yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); yield* validateAccessToken(fileValue.value, "stored"); + yield* Effect.annotateCurrentSpan({ "credential.source": "file" }); return Option.some(Redacted.make(fileValue.value)); } + yield* Effect.annotateCurrentSpan({ "credential.source": "none" }); return Option.none(); - }), + }).pipe(Effect.withSpan("CommandCredentials.getAccessToken")), - saveAccessToken: (token: string) => - Effect.gen(function* () { - yield* validateAccessToken(token); - if (Option.isSome(keyringModule)) { - const ok = yield* tryKeyringWrite( - keyringModule.value, - profileAccount, - token, - runtimeInfo.platform, - ); - if (ok) return; + saveAccessToken: Effect.fn("CommandCredentials.saveAccessToken")(function* (token: string) { + yield* validateAccessToken(token); + if (Option.isSome(keyringModule)) { + const ok = yield* tryKeyringWrite( + keyringModule.value, + profileAccount, + token, + runtimeInfo.platform, + ); + if (ok) { + yield* Effect.annotateCurrentSpan({ "credential.destination": "keyring" }); + return; } - // The containing directory is world-readable (0755); only the token file itself must - // be private (0600). - yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o755 }); - yield* fs.writeFileString(fallbackPath, token, { mode: 0o600 }); - }), + } + // The containing directory is world-readable (0755); only the token file itself must + // be private (0600). + yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o755 }); + yield* fs.writeFileString(fallbackPath, token, { mode: 0o600 }); + yield* Effect.annotateCurrentSpan({ "credential.destination": "file" }); + }), deleteAccessToken: Effect.gen(function* () { // Removes the fallback token file first; a missing file is ignored, but any other @@ -531,24 +541,21 @@ const makeCommandCredentials = Effect.gen(function* () { if (outcome === "notFound") { return yield* new NotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE }); } - }), + }).pipe(Effect.withSpan("CommandCredentials.deleteAccessToken")), deleteAllProjectCredentials: Effect.gen(function* () { if (Option.isNone(keyringModule)) return; yield* deleteAllKeyringEntries(keyringModule.value, runtimeInfo.platform); + }).pipe(Effect.withSpan("CommandCredentials.deleteAllProjectCredentials")), + + deleteProjectCredential: Effect.fn("CommandCredentials.deleteProjectCredential")(function* ( + projectRef: string, + ) { + // WSL or no keyring module: no-op success, nothing to delete. + if (Option.isNone(keyringModule)) return false; + return yield* deleteKeyringEntryStrict(keyringModule.value, projectRef, runtimeInfo.platform); }), - - deleteProjectCredential: (projectRef: string) => - Effect.gen(function* () { - // WSL or no keyring module: no-op success, nothing to delete. - if (Option.isNone(keyringModule)) return false; - return yield* deleteKeyringEntryStrict( - keyringModule.value, - projectRef, - runtimeInfo.platform, - ); - }), }); -}); +}).pipe(Effect.withSpan("CommandCredentials.load")); export const commandCredentialsLayer = Layer.effect(CommandCredentials, makeCommandCredentials); diff --git a/apps/cli/src/auth/command-platform-api.layer.ts b/apps/cli/src/auth/command-platform-api.layer.ts index 9e4fdbbd53..d0bc9f2a46 100644 --- a/apps/cli/src/auth/command-platform-api.layer.ts +++ b/apps/cli/src/auth/command-platform-api.layer.ts @@ -69,6 +69,6 @@ export const makeCommandPlatformApi = Effect.gen(function* () { transformClient, }, ); -}); +}).pipe(Effect.withSpan("CommandPlatformApi.build")); export const commandPlatformApiLayer = Layer.effect(CommandPlatformApi, makeCommandPlatformApi); diff --git a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts index cec5494de0..84dc920389 100644 --- a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts @@ -77,9 +77,7 @@ function mockTelemetryRuntime( TelemetryRuntime, TelemetryRuntime.of({ configDir: opts.configDir ?? "/tmp/supabase-cli-test-home", - tracesDir: path.join(opts.configDir ?? "/tmp/supabase-cli-test-home", "traces"), consent: "granted", - showDebug: false, deviceId: opts.deviceId ?? "device-123", sessionId: "session-123", identity: makeTelemetryIdentity(opts.distinctId), diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index 0898836706..c305a00051 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -19,7 +19,10 @@ import { PropExitCode, PropOutputFormat, } from "../shared/telemetry/event-catalog.ts"; -import { standaloneAnalyticsConfigLayer } from "../shared/telemetry/standalone-analytics-config.layer.ts"; +import { cliProjectContextLayer } from "../shared/config/cli-project-context.layer.ts"; +import { cliSettingsLayer } from "../shared/config/cli-settings.layer.ts"; +import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { ttyLayer } from "../shared/runtime/tty.layer.ts"; import { cliConfigProviderLayer } from "../shared/config/cli-config-provider.layer.ts"; import { analyticsLayer } from "../telemetry/analytics.layer.ts"; import { formatCliError, normalizeCliError } from "../shared/output/normalize-error.ts"; @@ -1113,11 +1116,16 @@ export function captureCompleteTelemetryEffect( const COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; -// `analyticsLayer` needs `CliSettings`/`RuntimeInfo`/`Tty` plus a platform layer; -// `standaloneAnalyticsConfigLayer` packages that small set for a caller outside the full -// CLI runtime tree. +// Completion telemetry fires before `runCli` builds its layer tree, so the +// `CliSettings`/`RuntimeInfo`/`Tty` set that `analyticsLayer` needs is resolved here. const completeAnalyticsLayer = analyticsLayer.pipe( - Layer.provide(standaloneAnalyticsConfigLayer), + Layer.provide( + Layer.mergeAll( + cliSettingsLayer.pipe(Layer.provide(cliProjectContextLayer), Layer.provide(runtimeInfoLayer)), + runtimeInfoLayer, + ttyLayer, + ), + ), Layer.provide(FetchHttpClient.layer), Layer.provide(cliConfigProviderLayer), Layer.provide(BunServices.layer), diff --git a/apps/cli/src/cli/main.ts b/apps/cli/src/cli/main.ts index 6f946e73f0..f6c506c85d 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -40,9 +40,11 @@ if ( defaultCompleteDeps(Exit.isSuccess(selectionExit) ? selectedRoot : undefined, selectionCause), )) ) { - await runCli(selectedRoot, { - analyticsLayer: analyticsLayer.pipe(Layer.provide(FetchHttpClient.layer)), - afterSuccess: upgradeNoticeHook, - ...(selectionCause ? { beforeParse: Effect.failCause(selectionCause) } : {}), - }); + await Effect.runPromise( + runCli(selectedRoot, { + analyticsLayer: analyticsLayer.pipe(Layer.provide(FetchHttpClient.layer)), + afterSuccess: upgradeNoticeHook, + ...(selectionCause ? { beforeParse: Effect.failCause(selectionCause) } : {}), + }), + ); } diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 058401a9fd..620d42231f 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -51,7 +51,6 @@ import { telemetryCommand } from "../commands/telemetry/telemetry.command.ts"; import { unlinkCommand } from "../commands/unlink/unlink.command.ts"; import { vanitySubdomainsCommand } from "../commands/vanity-subdomains/vanity-subdomains.command.ts"; import { whoamiCommand } from "../commands/whoami/whoami.command.ts"; -import { OutputFormatFlag } from "../shared/cli/global-flags.ts"; import { CLI_VERSION, cliBuildChannel } from "../shared/cli/version.ts"; import { outputLayerFor } from "../shared/output/output.layer.ts"; import { quietProgressTextOutputLayer } from "../output/quiet-progress-text-output.layer.ts"; @@ -71,6 +70,7 @@ import { ExperimentalFlag, NetworkIdFlag, OutputFlag, + OutputFormatFlag, ProfileFlag, WorkdirFlag, YesFlag, @@ -145,7 +145,7 @@ export const rootCommandForFeatures = ( readonly stackBackend?: StackBackend; readonly computeEnabled?: boolean; } = {}, -): CliRootCommand => +) => Command.make("supabase").pipe( Command.withDescription(rootDescription(CLI_VERSION)), Command.withSubcommands([ @@ -245,7 +245,9 @@ export const rootCommandForFeatures = ( : outputLayerFor(outputFormat); return Layer.mergeAll( - stackBackendLayer(options.stackBackend ?? "legacy"), + options.stackBackend === undefined + ? Layer.empty + : stackBackendLayer(options.stackBackend), outputLayer, makeGoProxyLayer({ globalArgs, parentOwnsCapturedSuccessTail: true }), ); @@ -253,6 +255,6 @@ export const rootCommandForFeatures = ( ), ), Command.withGlobalFlags([OutputFormatFlag, ...GLOBAL_FLAGS]), - ); + ) satisfies CliRootCommand; -export const rootCommand: CliRootCommand = rootCommandForFeatures(); +export const rootCommand = rootCommandForFeatures(); diff --git a/apps/cli/src/command-internal/branch-ref.resolver.ts b/apps/cli/src/command-internal/branch-ref.resolver.ts index 36d3564653..b5e2e6a570 100644 --- a/apps/cli/src/command-internal/branch-ref.resolver.ts +++ b/apps/cli/src/command-internal/branch-ref.resolver.ts @@ -40,22 +40,28 @@ export function resolveBranchProjectRef Effect.annotateCurrentSpan("project.ref", ref)), + Effect.withSpan("BranchRef.resolve"), + ); } diff --git a/apps/cli/src/command-internal/branch-target.ts b/apps/cli/src/command-internal/branch-target.ts index 8481b4c21d..1733f7a1b2 100644 --- a/apps/cli/src/command-internal/branch-target.ts +++ b/apps/cli/src/command-internal/branch-target.ts @@ -109,7 +109,7 @@ export const BRANCH_LOOKUP_TIMEOUT = Duration.seconds(5); * timeout degrades like any other failure. Spinner cleanup runs via `Effect.ensuring` so it * still fires when the timeout interrupts the in-flight listing call. */ -export const findBranchName = Effect.fnUntraced(function* ( +export const findBranchName = Effect.fn("BranchTarget.findName")(function* ( parentRef: string, linkedRef: string, options: { readonly spinnerLabel?: string } = {}, @@ -135,7 +135,12 @@ export const findBranchName = Effect.fnUntraced(function* ( Effect.catch(() => Effect.succeed(Option.none())), ); - return Option.isSome(branchesOption) + const name = Option.isSome(branchesOption) ? branchesOption.value.find((branch) => branch.project_ref === linkedRef)?.name : undefined; + yield* Effect.annotateCurrentSpan( + "branch_lookup.outcome", + Option.isNone(branchesOption) ? "unavailable" : name === undefined ? "not_found" : "found", + ); + return name; }); diff --git a/apps/cli/src/command-internal/bundled-postgres-client.ts b/apps/cli/src/command-internal/bundled-postgres-client.ts index df9476e09b..8495a41661 100644 --- a/apps/cli/src/command-internal/bundled-postgres-client.ts +++ b/apps/cli/src/command-internal/bundled-postgres-client.ts @@ -26,6 +26,7 @@ import { ProcessControl } from "../shared/runtime/process-control.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; +import { withProcessSpanScoped } from "../shared/telemetry/spans.ts"; const BUNDLED_CLIENT_SUGGESTION = "Re-download the Postgres artifact, or create a new stack that uses a container runtime."; @@ -141,20 +142,23 @@ const nativeRun = Effect.fn("BundledPostgresClient.nativeRun")(function* ( ...options.env, PATH: `${path.join(artifactRoot, "bin")}:${Option.getOrElse(inheritedPath, () => "")}`, }; - const child = yield* spawner - .spawn( - ChildProcess.make(tool, args, { - cwd: options.cwd, - env, - extendEnv: true, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - killSignal: command === "pg_prove" ? "SIGKILL" : undefined, - forceKillAfter: "5 seconds", - }), - ) - .pipe(Effect.mapError(mapSpawnError)); + const child = yield* withProcessSpanScoped( + "BundledPostgresClient.spawn", + { executable: tool, argCount: args.length }, + (traceEnv) => + spawner.spawn( + ChildProcess.make(tool, args, { + cwd: options.cwd, + env: { ...env, ...traceEnv }, + extendEnv: true, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + killSignal: command === "pg_prove" ? "SIGKILL" : undefined, + forceKillAfter: "5 seconds", + }), + ), + ).pipe(Effect.mapError(mapSpawnError)); let stderr = ""; const stderrDecoder = new TextDecoder(); yield* Effect.all( diff --git a/apps/cli/src/command-internal/colors.ts b/apps/cli/src/command-internal/colors.ts index 50addc7657..b1b6933cf1 100644 --- a/apps/cli/src/command-internal/colors.ts +++ b/apps/cli/src/command-internal/colors.ts @@ -77,3 +77,18 @@ export function green(text: string, stream: ColorStream = process.stderr): strin export function gray(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("gray", text, { validateStream: false }) : text; } + +/** Renders in magenta. */ +export function magenta(text: string, stream: ColorStream = process.stderr): string { + return supportsColor(stream) ? styleText("magenta", text, { validateStream: false }) : text; +} + +/** Renders in blue. */ +export function blue(text: string, stream: ColorStream = process.stderr): string { + return supportsColor(stream) ? styleText("blue", text, { validateStream: false }) : text; +} + +/** Renders dimmed. */ +export function dim(text: string, stream: ColorStream = process.stderr): string { + return supportsColor(stream) ? styleText("dim", text, { validateStream: false }) : text; +} diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 9395168666..8327855dba 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -571,7 +571,7 @@ export interface ConfigPullSource { * read, silently becoming the accepted baseline while the plan below is computed against the * now-stale parsed values. */ -export const openConfigPullSource = Effect.fnUntraced(function* () { +export const openConfigPullSource = Effect.fn("ConfigPull.openSource")(function* () { const cliSettings = yield* CommandSettings; const { loadConfig, toRelativeConfigPath } = makeConfigLoader(cliSettings); @@ -597,7 +597,9 @@ export const openConfigPullSource = Effect.fnUntraced(function* () { * writes, or calls `output.success`; the caller decides what to do with the returned * {@link ConfigPullRunPlan}. */ -export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPullPlanRequest) { +export const planConfigPullRun = Effect.fn("ConfigPull.plan")(function* ( + request: ConfigPullPlanRequest, +) { const output = yield* Output; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; @@ -625,6 +627,10 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul }); } const destination = scopeResult.destination; + yield* Effect.annotateCurrentSpan({ + "project.ref": ref, + "config.destination.kind": destination.kind, + }); yield* output.raw(configPullDestinationLine({ projectRef: ref, branch }, destination), "stderr"); // A brand-new block has nothing to overlay yet. @@ -723,6 +729,11 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul // reach the git guard/confirmation like any other write. const hasBlockToCreate = finalPlan.createdTable !== undefined; const hasWork = finalPlan.writes.length > 0 || hasBlockToCreate; + yield* Effect.annotateCurrentSpan({ + "change.count": changeSet.changes.length, + "config.write_count": finalPlan.writes.length, + "config.skip_count": finalPlan.skipped.length, + }); return { changeSet, @@ -739,12 +750,13 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul * while the confirmation prompt was on screen), `applyConfigEdits`, and the atomic write. No * emission — the caller renders the final summary/payload once this succeeds. */ -export const applyConfigPullRun = Effect.fnUntraced(function* (input: { +export const applyConfigPullRun = Effect.fn("ConfigPull.apply")(function* (input: { readonly runPlan: ConfigPullRunPlan; readonly source: ConfigPullSource; }) { const fs = yield* FileSystem.FileSystem; const { plan, context, configFilePath } = input.runPlan; + yield* Effect.annotateCurrentSpan("config.write_count", plan.writes.length); const currentText = yield* fs.readFileString(configFilePath).pipe( Effect.catchTag( diff --git a/apps/cli/src/command-internal/container-cli.ts b/apps/cli/src/command-internal/container-cli.ts index 71d8cabe65..8ea7a0addc 100644 --- a/apps/cli/src/command-internal/container-cli.ts +++ b/apps/cli/src/command-internal/container-cli.ts @@ -2,6 +2,11 @@ import { Data, Effect, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + withChildTraceEnv, + withProcessSpan, + withProcessSpanScoped, +} from "../shared/telemetry/spans.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -54,10 +59,10 @@ export const spawnContainerCliWithRuntime = ( args: ReadonlyArray, options?: ChildProcess.CommandOptions, ) => - spawner.spawn(ChildProcess.make("docker", args, options)).pipe( + spawnRuntime(spawner, dockerRuntime, args, options).pipe( Effect.map((handle) => ({ handle, runtime: dockerRuntime })), Effect.catch(() => - spawner.spawn(ChildProcess.make("podman", args, options)).pipe( + spawnRuntime(spawner, podmanRuntime, args, options).pipe( Effect.map((handle) => ({ handle, runtime: podmanRuntime })), Effect.catch(() => Effect.fail( @@ -73,6 +78,78 @@ export const spawnContainerCliWithRuntime = ( const dockerRuntime: ContainerRuntime = "docker"; const podmanRuntime: ContainerRuntime = "podman"; +const MANAGEMENT_COMMANDS: ReadonlySet = new Set([ + "container", + "context", + "image", + "network", + "system", + "volume", +]); +const COMMANDS: ReadonlySet = new Set([ + "build", + "cp", + "create", + "exec", + "images", + "info", + "inspect", + "kill", + "load", + "logs", + "ls", + "ps", + "prune", + "pull", + "restart", + "rm", + "run", + "save", + "start", + "stop", + "tag", + "version", + "wait", +]); + +function containerSubcommand(args: ReadonlyArray): string { + const [first, second] = args; + if (first === undefined) return "none"; + if (MANAGEMENT_COMMANDS.has(first)) { + return second !== undefined && COMMANDS.has(second) ? `${first} ${second}` : first; + } + return COMMANDS.has(first) ? first : "other"; +} + +const processTarget = (runtime: ContainerRuntime, args: ReadonlyArray) => ({ + executable: runtime, + argCount: args.length, + subcommand: containerSubcommand(args), + hasFallback: runtime === dockerRuntime, +}); + +function spawnRuntime( + spawner: Spawner, + runtime: ContainerRuntime, + args: ReadonlyArray, + options?: ChildProcess.CommandOptions, +) { + return withProcessSpanScoped("ContainerCli.spawn", processTarget(runtime, args), (traceEnv) => + spawner.spawn(ChildProcess.make(runtime, args, withChildTraceEnv(options, traceEnv))), + ); +} + +function runtimeExitCode( + spawner: Spawner, + runtime: ContainerRuntime, + args: ReadonlyArray, + options?: ChildProcess.CommandOptions, +) { + return withProcessSpan("ContainerCli.run", processTarget(runtime, args), (traceEnv) => + spawner.exitCode(ChildProcess.make(runtime, args, withChildTraceEnv(options, traceEnv))), + ); +} + /** * Spawn a container-CLI command and return the process handle. Use when the * caller needs to read stdout/stderr or await the exit code itself. @@ -95,9 +172,9 @@ export const containerCliExitCode = ( options?: ChildProcess.CommandOptions, podmanArgs?: ReadonlyArray, ) => - spawner.exitCode(ChildProcess.make("docker", args, options)).pipe( + runtimeExitCode(spawner, dockerRuntime, args, options).pipe( Effect.catch(() => - spawner.exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)).pipe( + runtimeExitCode(spawner, podmanRuntime, podmanArgs ?? args, options).pipe( Effect.catch(() => Effect.fail( new ContainerRuntimeNotFoundError({ @@ -188,9 +265,9 @@ export const containerCliExitCodeAndStdout = ( stdout: "pipe", stderr: "ignore", } satisfies ChildProcess.CommandOptions; - const handle = yield* spawner.spawn(ChildProcess.make("docker", args, options)).pipe( + const handle = yield* spawnRuntime(spawner, dockerRuntime, args, options).pipe( Effect.catch(() => - spawner.spawn(ChildProcess.make("podman", podmanArgs ?? args, options)).pipe( + spawnRuntime(spawner, podmanRuntime, podmanArgs ?? args, options).pipe( Effect.catch(() => Effect.fail( new ContainerRuntimeNotFoundError({ diff --git a/apps/cli/src/command-internal/container-cli.unit.test.ts b/apps/cli/src/command-internal/container-cli.unit.test.ts index 5cc719f1d0..26d92e53ca 100644 --- a/apps/cli/src/command-internal/container-cli.unit.test.ts +++ b/apps/cli/src/command-internal/container-cli.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, PlatformError, Sink, Stream } from "effect"; +import { Deferred, Effect, Exit, PlatformError, Sink, Stream, Tracer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -127,6 +127,102 @@ describe("containerCliExitCode", () => { }); }); +describe("container CLI process spans", () => { + const recordedAttributes = (args: ReadonlyArray) => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + yield* containerCliExitCode(mockSpawner().spawner, args).pipe( + Effect.withTracer(tracer), + Effect.withTracerEnabled(true), + ); + return Object.fromEntries(spans.find((span) => span.name === "ContainerCli.run")!.attributes); + }); + + it.live("records the docker verb but no free-form arguments", () => + recordedAttributes(["container", "inspect", "supabase_db_secret-project"]).pipe( + Effect.map((attributes) => { + expect(attributes).toEqual({ + "process.executable.name": "docker", + "process.arg_count": 3, + "process.subcommand": "container inspect", + "process.exit_code": 0, + }); + }), + ), + ); + + it.live("labels an unknown leading argument as other", () => + recordedAttributes(["--context", "my-private-context", "ps"]).pipe( + Effect.map((attributes) => { + expect(attributes["process.subcommand"]).toBe("other"); + }), + ), + ); + + it.live("records a missing docker as a failed attempt, not a failed span, on podman hosts", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const mock = mockSpawner({ dockerMissing: true }); + + yield* containerCliExitCode(mock.spawner, ["ps"]).pipe( + Effect.andThen(spawnContainerCli(mock.spawner, ["ps"]).pipe(Effect.scoped)), + Effect.withTracer(tracer), + Effect.withTracerEnabled(true), + ); + + const docker = spans.filter( + (span) => span.attributes.get("process.executable.name") === "docker", + ); + expect(docker.map((span) => span.name)).toEqual(["ContainerCli.run", "ContainerCli.spawn"]); + expect(docker.map((span) => span.attributes.get("process.attempt_failed"))).toEqual([ + true, + true, + ]); + expect( + spans.map((span) => span.status._tag === "Ended" && Exit.isSuccess(span.status.exit)), + ).toEqual([true, true, true, true]); + }), + ); + + it.live("fails the podman span when neither runtime can be spawned", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + + yield* containerCliExitCode(mockSpawner({ bothMissing: true }).spawner, ["ps"]).pipe( + Effect.ignore, + Effect.withTracer(tracer), + Effect.withTracerEnabled(true), + ); + + const podman = spans.find( + (span) => span.attributes.get("process.executable.name") === "podman", + ); + expect(podman?.status._tag === "Ended" && Exit.isFailure(podman.status.exit)).toBe(true); + }), + ); +}); + describe("dockerSupportsVolumePruneAllFlag", () => { it.live("returns true when the daemon reports an API version at or above 1.42", () => { const mock = mockSpawner({ stdout: "1.42" }); 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 3a68a3fe22..bc078643c8 100644 --- a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts @@ -725,5 +725,13 @@ export function createContainer( ); yield* dockerStartContainer(spawner, containerId, finalSpec); return containerId; - }); + }).pipe( + Effect.withSpan("ContainerLifecycle.createContainer", { + attributes: { + "container.name": spec.containerName, + "image.name": spec.image, + "container.pre_start_archive_count": spec.preStartArchives?.length ?? 0, + }, + }), + ); } diff --git a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts index 08ba19d556..4ad1081f39 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -387,7 +387,7 @@ const startInitSchemaPre15 = Effect.fnUntraced(function* ( * long-running containers' own ambient-only resolver never sees `opts.projectEnvValues`, and * neither caller pre-pulls these three images as a batch. */ -const runStartMigrateJob = Effect.fnUntraced(function* ( +const runStartMigrateJob = Effect.fn("DbSetup.runMigrateJob")(function* ( spawner: Spawner, opts: { readonly image: string; @@ -400,6 +400,7 @@ const runStartMigrateJob = Effect.fnUntraced(function* ( readonly debug: boolean; }, ) { + yield* Effect.annotateCurrentSpan("image.name", opts.image); const docker = yield* DockerRun; const runtimeInfo = yield* RuntimeInfo; const resolvedImages = yield* ensureImagesCached(spawner, [opts.image], opts.projectEnvValues); @@ -591,11 +592,12 @@ const startInitSchema15 = Effect.fnUntraced(function* ( * Branches on `majorVersion`. The setup banner prints from {@link resolveDbSetupPrelude}, the * caller-side step that runs immediately before this one, not from here. */ -const startInitSchema = Effect.fnUntraced(function* ( +const startInitSchema = Effect.fn("DbSetup.initSchema")(function* ( spawner: Spawner, input: SetupDatabaseInput, tmpDir: string, ) { + yield* Effect.annotateCurrentSpan("db.major_version", input.majorVersion); if (input.majorVersion <= 14) { yield* startInitSchemaPre15(input.session, input.fs, input.path, tmpDir, input.majorVersion); return; @@ -779,7 +781,11 @@ export const applyDatabaseOverlay = ( (message) => new DbSetupError({ message, reason: "database" }), ); } - }); + }).pipe( + Effect.withSpan("DbSetup.applyOverlay", { + attributes: { "vault.secret_count": overlay.vault.length }, + }), + ); /** * Runs schema init through the custom-roles seed; see {@link SetupDatabaseInput} for exactly @@ -830,7 +836,7 @@ export const setupDatabase = ( vault: input.vault, webhooks: options.webhooks, }); - }); + }).pipe(Effect.withSpan("DbSetup.setupDatabase")); /** * Runs the full setup sequence described in this module's header. Call once, right after the @@ -913,8 +919,10 @@ const connectLocalPostgres = (input: { }) => Effect.gen(function* () { const dbConnection = yield* DbConnection; - return yield* dbConnection - .connect( + let attempts = 0; + return yield* Effect.suspend(() => { + attempts += 1; + return dbConnection.connect( { host: input.hostname, port: input.dbPort, @@ -923,14 +931,17 @@ const connectLocalPostgres = (input: { database: "postgres", }, { isLocal: true, dnsResolver: "native" }, - ) - .pipe( - Effect.retry({ - schedule: Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(10)]), - while: (error) => error.retryable === true, - }), ); - }); + }).pipe( + Effect.retry({ + schedule: Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(10)]), + while: (error) => error.retryable === true, + }), + Effect.ensuring( + Effect.suspend(() => Effect.annotateCurrentSpan("retry.attempt_count", attempts)), + ), + ); + }).pipe(Effect.withSpan("DbSetup.connectLocalPostgres")); /** Converges pg_net while preserving extensions installed by user migrations. */ export const runDatabaseWebhooksSetup = (input: { @@ -980,6 +991,10 @@ export const runDatabaseWebhooksSetup = (input: { } yield* applyDatabaseWebhooks(session, input.fs, input.path, tmpDir, input.enabled); }), + ).pipe( + Effect.withSpan("DbSetup.runDatabaseWebhooksSetup", { + attributes: { "webhooks.enabled": input.enabled }, + }), ); /** @@ -1049,4 +1064,8 @@ export const runFreshDbSetup = ( seedFlags: input.seedFlags, }); }), + ).pipe( + Effect.withSpan("DbSetup.runFreshDbSetup", { + attributes: { "db.major_version": input.setup.majorVersion }, + }), ); diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.ts index c2bd3dabff..c8420daf07 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.ts @@ -15,6 +15,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; +import { ChildTracePropagation } from "../../shared/telemetry/spans.ts"; import { spawnContainerCliWithRuntime, type ContainerRuntime } from "../container-cli.ts"; import { DbConnection, type PgConnInput } from "../db-connection.service.ts"; import { inspectContainerState } from "../docker-lifecycle.ts"; @@ -288,11 +289,13 @@ export function waitForHealthyServices( return Effect.gen(function* () { const timeoutSeconds = opts.timeoutSeconds ?? (yield* HealthCheckTimeoutSeconds); let stillWatching = containerIds; + let attempts = 0; // Each round narrows `stillWatching` to just the containers that failed, so a container // that becomes healthy mid-run stops being probed on later rounds. const probe: Effect.Effect = Effect.gen( function* () { + attempts += 1; const outcomes = yield* Effect.forEach( stillWatching, (containerId) => @@ -315,7 +318,7 @@ export function waitForHealthyServices( return yield* Effect.fail(new HealthCheckProbeError({ failures })); } }, - ); + ).pipe(Effect.withTracerEnabled(false), Effect.provideService(ChildTracePropagation, false)); // A 1-second constant delay, capped at `timeoutSeconds` retries after the initial attempt. const schedule = Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(timeoutSeconds)]); @@ -350,8 +353,20 @@ export function waitForHealthyServices( ); }), ), + Effect.ensuring( + Effect.suspend(() => + Effect.annotateCurrentSpan({ + "retry.attempt_count": attempts, + "health.unhealthy_count": stillWatching.length, + }), + ), + ), ); - }); + }).pipe( + Effect.withSpan("HealthCheck.waitHealthyServices", { + attributes: { "health.container_count": containerIds.length }, + }), + ); } /** Caps a hung dial so one probe cannot swallow the whole poll budget. */ @@ -422,8 +437,10 @@ export function waitForShadowReady( // Per-evaluation state: the retry rounds within one evaluation share the latest failure for // the timeout diagnostic, while re-evaluating the returned Effect starts from a fresh slot. let lastFailure: ShadowReadyFailure | undefined; + let attempts = 0; const probe: Effect.Effect = Effect.gen(function* () { + attempts += 1; const state = yield* inspectContainerState(spawner, containerId).pipe( Effect.mapError((cause) => shadowNotReady(cause.message)), ); @@ -435,6 +452,8 @@ export function waitForShadowReady( } yield* probeShadowConnect(connConfig); }).pipe( + Effect.withTracerEnabled(false), + Effect.provideService(ChildTracePropagation, false), Effect.tapError((failure) => Effect.sync(() => { lastFailure = failure; @@ -472,6 +491,9 @@ export function waitForShadowReady( ); }), ), + Effect.ensuring( + Effect.suspend(() => Effect.annotateCurrentSpan("retry.attempt_count", attempts)), + ), ); - }); + }).pipe(Effect.withSpan("HealthCheck.waitShadowReady")); } diff --git a/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts index 8c0a2cda72..2e45bbcc4f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; +import { Deferred, Effect, Exit, Fiber, Layer, Sink, Stream, Tracer } from "effect"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -656,6 +656,36 @@ describe("waitForHealthyServices", () => { ]); }), ); + + it.effect("records the probe count on the wait span instead of a span per HTTP probe", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const mock = mockHealthSpawner(() => runningHealthy); + + const fiber = yield* waitForHealthyServices(mock.spawner, ["supabase_rest_proj"], { + timeoutSeconds: 1, + postgrest: postgrestGateway("sb_secret_local"), + }).pipe( + Effect.provide(httpLayer(503, () => {})), + Effect.withTracer(tracer), + Effect.withTracerEnabled(true), + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 seconds"); + yield* Fiber.await(fiber); + + const wait = spans.find((span) => span.name === "HealthCheck.waitHealthyServices"); + expect(spans.map((span) => span.name)).not.toContain("http.client HEAD"); + expect(wait?.attributes.get("retry.attempt_count")).toBe(2); + }), + ); }); describe("Edge Runtime HTTP-HEAD readiness", () => { diff --git a/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts b/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts index 54480f63d3..2aabd6536d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts +++ b/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts @@ -56,7 +56,12 @@ export function ensureImagesCached( return Effect.gen(function* () { const results = yield* Effect.all( - uniqueImages.map((image) => resolveImage(image).pipe(Effect.result)), + uniqueImages.map((image) => + resolveImage(image).pipe( + Effect.result, + Effect.withSpan("ImagePrepull.resolveImage", { attributes: { "image.name": image } }), + ), + ), { concurrency: "unbounded" }, ); @@ -86,6 +91,7 @@ export function ensureImagesCached( resolved.set(image, result.success); } + yield* Effect.annotateCurrentSpan("image.failed_count", failures.length); if (failures.length > 0) { // The install hint is appended once after every resolve finishes, rather than emitted // from inside the resolver, to avoid duplicate hints from concurrent failures. @@ -99,5 +105,9 @@ export function ensureImagesCached( } return resolved; - }); + }).pipe( + Effect.withSpan("ImagePrepull.ensureImagesCached", { + attributes: { "image.count": uniqueImages.length }, + }), + ); } diff --git a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts index 185b34966f..ce589339a3 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts @@ -253,4 +253,8 @@ export const buildLocalDbContainerInputs = ( experimental, setup, }; - }); + }).pipe( + Effect.withSpan("DbBootstrap.buildLocalDbContainerInputs", { + attributes: { "config.preloaded": preloadedContext !== undefined }, + }), + ); 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 4fa01f37a2..bc873b6c43 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 @@ -340,6 +340,10 @@ export function isLocalDbRunning( // Engine probe first; `Option.none()` falls through to the CLI spawn below. const engine = yield* LocalDockerEngine; const engineAnswer = yield* engine.containerExists(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. @@ -388,5 +392,5 @@ export function isLocalDbRunning( } return false; }), - ); + ).pipe(Effect.withSpan("LocalDb.isRunning")); } diff --git a/apps/cli/src/command-internal/db-bootstrap/messages.ts b/apps/cli/src/command-internal/db-bootstrap/messages.ts deleted file mode 100644 index 5da0dc466e..0000000000 --- a/apps/cli/src/command-internal/db-bootstrap/messages.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Stderr progress messages for the Postgres container bootstrap, shared by `supabase start` and - * `db start`. - */ - -/** Printed when no existing volume is found (a brand-new, first-ever start). */ -export const START_STARTING_DATABASE_MESSAGE = "Starting database...\n"; - -/** - * Printed instead of {@link START_STARTING_DATABASE_MESSAGE} when an existing volume is reused. - * Despite the wording, this is unrelated to the `--from-backup` flag; it only reflects that the - * volume already exists. - */ -export const START_STARTING_DATABASE_FROM_BACKUP_MESSAGE = "Starting database from backup...\n"; diff --git a/apps/cli/src/command-internal/db-bootstrap/messages.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/messages.unit.test.ts deleted file mode 100644 index aa51b12eef..0000000000 --- a/apps/cli/src/command-internal/db-bootstrap/messages.unit.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, - START_STARTING_DATABASE_MESSAGE, -} from "./messages.ts"; - -describe("START_STARTING_DATABASE_MESSAGE", () => { - it("matches Go's exact stderr line, with a single trailing newline", () => { - expect(START_STARTING_DATABASE_MESSAGE).toBe("Starting database...\n"); - }); -}); - -describe("START_STARTING_DATABASE_FROM_BACKUP_MESSAGE", () => { - it("matches Go's exact stderr line, with a single trailing newline", () => { - expect(START_STARTING_DATABASE_FROM_BACKUP_MESSAGE).toBe("Starting database from backup...\n"); - }); -}); diff --git a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts index 4cf3f85a21..8bf6d9d4b0 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts @@ -202,7 +202,10 @@ export const exportPgDataTar = ( pgDataSnapshotUnavailable(`failed to publish ${tarPath}: ${cause.message}`), ), ); - }).pipe(Effect.onError(() => fs.remove(tempPath).pipe(Effect.orElseSucceed(() => undefined)))); + }).pipe( + Effect.onError(() => fs.remove(tempPath).pipe(Effect.orElseSucceed(() => undefined))), + Effect.withSpan("PgDataSnapshot.export"), + ); }; /** POSIX tar's fixed block size: headers, file content, and the end marker are all multiples of it. */ @@ -507,6 +510,13 @@ export const validatePgDataArchive = ( Effect.mapError((cause) => pgDataSnapshotUnavailable(`failed to read ${tarPath}: ${cause.message}`), ), + Effect.tap((problem) => + Effect.annotateCurrentSpan( + "snapshot.problem", + Option.isSome(problem) ? problem.value._tag : "none", + ), + ), + Effect.withSpan("PgDataSnapshot.validate"), ); /** {@link validatePgDataArchive}'s verdict, split out so it is pure and directly testable. */ 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 2be34aba41..148166dbeb 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 @@ -143,7 +143,9 @@ const PG_INVALID_CATALOG_NAME = "3D000"; * Disables new connections to `postgres`/`_supabase`, terminates existing backends, then waits * for WAL senders to drop their replication slots (1-second backoff, 10 retries max). */ -export const resetDisconnectClients = Effect.fnUntraced(function* (session: DbSession) { +export const resetDisconnectClients = Effect.fn("DbBootstrap.disconnectClients")(function* ( + session: DbSession, +) { // Must run sequentially, unwrapped: looping these in a transaction is unsupported, and // Effect's short-circuit-on-failure stops at the first bad statement. const disconnectResult = yield* Effect.forEach( @@ -197,11 +199,19 @@ export const resetDisconnectClients = Effect.fnUntraced(function* (session: DbSe : Effect.void; }), ); - yield* countReplicationSlots.pipe( + let slotChecks = 0; + yield* Effect.suspend(() => { + slotChecks += 1; + return countReplicationSlots; + }).pipe( + Effect.withTracerEnabled(false), Effect.retry({ schedule: Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(10)]), while: (error) => error.retryable, }), + Effect.ensuring( + Effect.suspend(() => Effect.annotateCurrentSpan("retry.attempt_count", slotChecks)), + ), ); }); @@ -221,7 +231,9 @@ const RESET_RECREATE_DATABASES_STATEMENTS = [ * statements. Roles are not dropped here since they are cluster-level entities — use stop then * start instead. */ -const resetRecreateDatabases = Effect.fnUntraced(function* (session: DbSession) { +const resetRecreateDatabases = Effect.fn("DbBootstrap.recreateDatabases")(function* ( + session: DbSession, +) { yield* resetDisconnectClients(session); for (const [index, statement] of RESET_RECREATE_DATABASES_STATEMENTS.entries()) { yield* session.exec(statement).pipe( @@ -361,7 +373,7 @@ const recreateLocalDatabase14 = ( yield* applyApiPrivileges(session, fs, path, tmpDir, toml.baseline.apiAutoExposeNewTables); yield* applyDatabaseWebhooks(session, fs, path, tmpDir, toml.webhooksEnabled); }), - ); + ).pipe(Effect.withSpan("DbBootstrap.initSchema14")); // "Restarting containers..." first, then an actual restart of the `db` container itself // (pg_cron must restart after `pg_terminate_backend`); not tolerant of "not found", unlike @@ -407,6 +419,11 @@ export const recreateLocalDatabase = ( | FileSystem.FileSystem | Path.Path > => - input.setup.majorVersion <= 14 + (input.setup.majorVersion <= 14 ? recreateLocalDatabase14(spawner, input) - : recreateLocalDatabase15(spawner, input); + : recreateLocalDatabase15(spawner, input) + ).pipe( + Effect.withSpan("DbBootstrap.recreateLocalDatabase", { + attributes: { "db.major_version": input.setup.majorVersion }, + }), + ); diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 2cb21499ce..cb61d07cf2 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -118,6 +118,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu // Abort on a bad config before wiping the local database. yield* checkDbToml(fs, path, workdir); + yield* Effect.annotateCurrentSpan("stack.backend", backend.kind); if (backend.kind === "stack") { const opened = yield* stackOpenReadyProject; @@ -156,18 +157,20 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu ) : catalogDatabaseServices(members); yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); - yield* opened.value.stack.composition.stop.pipe( - Effect.mapError((cause) => resetFailed(`failed to stop local stack: ${cause.message}`)), - ); - yield* opened.value.database.resetData.pipe( - Effect.mapError((cause) => resetFailed(`failed to reset local database: ${cause.message}`)), - ); - yield* opened.value.database.start.pipe( - Effect.mapError((cause) => resetFailed(`failed to start local database: ${cause.message}`)), - ); - yield* opened.value.database.ready.pipe( - Effect.mapError((cause) => resetFailed(`failed to ready local database: ${cause.message}`)), - ); + yield* Effect.gen(function* () { + yield* opened.value.stack.composition.stop.pipe( + Effect.mapError((cause) => resetFailed(`failed to stop local stack: ${cause.message}`)), + ); + yield* opened.value.database.resetData.pipe( + Effect.mapError((cause) => resetFailed(`failed to reset local database: ${cause.message}`)), + ); + yield* opened.value.database.start.pipe( + Effect.mapError((cause) => resetFailed(`failed to start local database: ${cause.message}`)), + ); + yield* opened.value.database.ready.pipe( + Effect.mapError((cause) => resetFailed(`failed to ready local database: ${cause.message}`)), + ); + }).pipe(Effect.withSpan("DbBootstrap.resetStackDatabase")); yield* initializeStackDatabase({ target: { stack: opened.value.stack, @@ -237,7 +240,10 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu projectEnvValues: projectEnv, workdir, }); - }).pipe(Effect.catch((error) => skipSeeding(error.message, suggestionOf(error)))); + }).pipe( + Effect.catch((error) => skipSeeding(error.message, suggestionOf(error))), + Effect.withSpan("DbBootstrap.seedStorageBuckets"), + ); const branch = yield* detectGitBranch(workdir); yield* output.raw( `Finished ${aqua("supabase db reset")}${branchClause(branch, aqua)}.\n`, @@ -312,8 +318,10 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu // Seed objects from supabase/buckets when storage is up; summary is suppressed since reset // emits its own result. See docs/stack-commands.md#storage-and-bucket-seeding. - const storageReady = yield* awaitStorageReady(spawner, projectId); - if (storageReady) { + yield* Effect.gen(function* () { + const storageReady = yield* awaitStorageReady(spawner, projectId); + yield* Effect.annotateCurrentSpan("storage.ready", storageReady); + if (!storageReady) return; // Non-interactive: overwrite/prune confirmations never open a TTY prompt. In text mode // each still prints its label and scans one stdin line (bounded) — a parsed y/n answer // wins, otherwise its default applies (overwrite → yes, prune → no); machine formats take @@ -343,7 +351,7 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu ), ), ); - } + }).pipe(Effect.withSpan("DbBootstrap.seedStorageBuckets")); const branch = yield* detectGitBranch(workdir); yield* output.raw( 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 a81619a12b..ef7a370b04 100644 --- a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts +++ b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts @@ -123,7 +123,7 @@ function restartSatelliteServices( if (failures.length > 0) { return yield* Effect.fail(new RestartServicesError({ message: failures.join("\n") })); } - }); + }).pipe(Effect.withSpan("RestartServices.restartSatellites")); } /** Gateway-recovery hint, rendered as a `Suggestion:` line by `Output.fail`. */ @@ -217,7 +217,7 @@ function reloadKong(spawner: Spawner, projectId: string): Effect.Effect( }, }, } satisfies ShadowCacheKeyInputs); - }); + }).pipe(Effect.withSpan("ShadowCache.resolveKeyInputs")); /** Filename prefix shared by every key's snapshot — the handle the retention sweep enumerates by. */ const SHADOW_BASELINE_TAR_PREFIX = "shadow-baseline-"; @@ -481,12 +481,17 @@ const sweepShadowBaselineRetention = ( if (mtime === undefined) continue; entries.push({ fileName, mtimeMs: mtime.getTime() }); } + const evicted = shadowBaselineTarsToEvict(entries, now, { retainFileName }); + yield* Effect.annotateCurrentSpan({ + "cache.entry_count": entries.length, + "cache.evicted_count": evicted.length, + }); yield* Effect.forEach( - shadowBaselineTarsToEvict(entries, now, { retainFileName }), + evicted, (fileName) => forgetShadowBaselineTar(input.fs, input.path.join(cacheDir, fileName)), { discard: true }, ); - }); + }).pipe(Effect.withSpan("ShadowCache.sweepRetention")); /** Refresh mtime on a warm hit so frequently used keys survive LRU/TTL. Best-effort. */ const touchShadowBaselineTar = (fs: FileSystem.FileSystem, tarPath: string): Effect.Effect => @@ -637,6 +642,7 @@ const exportShadowBaseline = ( }), ), ); + yield* Effect.annotateCurrentSpan("cache.published", Result.isSuccess(exported)); if (Result.isFailure(exported)) { const output = yield* Output; yield* output.raw( @@ -644,7 +650,7 @@ const exportShadowBaseline = ( "stderr", ); } - }); + }).pipe(Effect.withSpan("ShadowCache.export")); export interface ShadowCacheOpts { /** `sync --no-cache`: neither restore nor publish, regardless of the env gate. */ @@ -694,12 +700,13 @@ export const peekShadowBaseline = ( shadowBaselineTarFileName(key), ); const cached = yield* input.fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); + yield* Effect.annotateCurrentSpan("cache.hit", cached); return { state: cached ? ("warm" as const) : ("cold" as const), key, keyInputs: keyInputs.value, }; - }); + }).pipe(Effect.withSpan("ShadowCache.peek")); /** * What `acquireUseRelease`'s `acquire` hands the `use` phase: the container, whether its cluster @@ -840,7 +847,10 @@ const warmShadow = ( snapshotRequired: false, snapshotBaseline: Effect.void, } satisfies ShadowAcquiredHandle; - }); + }).pipe(Effect.withSpan("ShadowCache.restore")); + +const annotateCacheState = (state: "disabled" | "ineligible" | "unwritable" | "cold" | "warm") => + Effect.annotateCurrentSpan("cache.state", state); /** * `Effect.acquireUseRelease`'s `acquire` for every shadow that runs the platform baseline (`db @@ -862,6 +872,7 @@ export const acquireShadowDatabase = ( whenUnset: true, }) ) { + yield* annotateCacheState("disabled"); return yield* uncachedShadow(spawner, input); } @@ -871,7 +882,10 @@ export const acquireShadowDatabase = ( ? Effect.succeed(Option.some(opts.precomputedKeyInputs)) : resolveShadowCacheKeyInputs(input, opts), ); - if (Option.isNone(keyInputs)) return yield* uncachedShadow(spawner, input); + if (Option.isNone(keyInputs)) { + yield* annotateCacheState("ineligible"); + return yield* uncachedShadow(spawner, input); + } // The cache root must be usable before committing to the cached lifecycle: the cold path // drops `--rm` and pays a stop/export/restart cycle that's already doomed if this directory @@ -890,6 +904,7 @@ export const acquireShadowDatabase = ( `Warning: shadow baseline cache unavailable (cannot write ${cacheDir}: ${cacheRoot.failure.message}); continuing uncached.\n`, "stderr", ); + yield* annotateCacheState("unwritable"); return yield* uncachedShadow(spawner, input); } @@ -897,8 +912,11 @@ export const acquireShadowDatabase = ( const tarPath = input.path.join(cacheDir, shadowBaselineTarFileName(key)); const cached = yield* input.fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); - if (!cached) + if (!cached) { + yield* annotateCacheState("cold"); return yield* coldCachedShadow(spawner, input, key, tarPath, keyInputs.value.rolesSql, true); + } + yield* annotateCacheState("warm"); // Warm hits refresh mtime and sweep leftovers the cold path would otherwise never see again. yield* touchShadowBaselineTar(input.fs, tarPath); @@ -916,6 +934,10 @@ export const acquireShadowDatabase = ( // Delete only when the failure implicates the tar's contents — see // {@link ShadowCacheUnavailable.tarSuspect}; an infra failure leaves it in place, since // the cold fallback's own export republishes over a genuinely bad tar anyway. + yield* Effect.annotateCurrentSpan({ + "cache.state": "warm_fallback", + "cache.tar_suspect": cause.tarSuspect === true, + }); if (cause.tarSuspect === true) { yield* forgetShadowBaselineTar(input.fs, tarPath); } @@ -930,7 +952,7 @@ export const acquireShadowDatabase = ( }), ), ); - }); + }).pipe(Effect.withSpan("ShadowCache.acquire")); /** * Acquire/use/release for a platform-baseline shadow. `acquireUseRelease` registers removal in diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts index 8ad2daee30..9451314dc1 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -143,11 +143,18 @@ export const connectShadowDatabase = ( ): Effect.Effect => Effect.gen(function* () { const dbConnection = yield* DbConnection; - return yield* dbConnection.connect(cfg, { isLocal: true, dnsResolver: "native" }).pipe( + let attempts = 0; + return yield* Effect.suspend(() => { + attempts += 1; + return dbConnection.connect(cfg, { isLocal: true, dnsResolver: "native" }); + }).pipe( Effect.mapError((cause) => new ShadowDbError({ message: cause.message, reason: "connect" })), Effect.retry({ schedule: SHADOW_CONNECT_SCHEDULE }), + Effect.ensuring( + Effect.suspend(() => Effect.annotateCurrentSpan("retry.attempt_count", attempts)), + ), ); - }); + }).pipe(Effect.withSpan("ShadowDatabase.connect")); /** * Input to {@link createShadowDatabase} — the subset of the real `db` container's own bootstrap @@ -237,7 +244,11 @@ export const createShadowDatabase = ( ), ); return { containerId }; - }); + }).pipe( + Effect.withSpan("ShadowDatabase.create", { + attributes: { "shadow.restore_archive": input.restoreArchive !== undefined }, + }), + ); /** * `docker rm -f -v `. Best-effort for the overall operation — a removal failure must never @@ -600,6 +611,10 @@ export const setupShadowDatabase = ( ), ); }), + ).pipe( + Effect.withSpan("ShadowDatabase.setup", { + attributes: { "shadow.baseline_state": shadowBaselineStateName(baseline) }, + }), ); /** @@ -640,6 +655,9 @@ const SHADOW_BASELINE_COLD: ShadowBaselineState = { snapshotBaseline: Effect.void, }; +const shadowBaselineStateName = (baseline: ShadowBaselineState): string => + baseline.baselinePresent ? "warm" : baseline.snapshotRequired ? "cold_snapshot" : "cold"; + /** * Lists local migrations first, so a bad migrations directory fails before any DB connection, * then connects, resolves the setup prelude, and runs the platform baseline before applying @@ -669,6 +687,7 @@ const migrateShadowDatabaseWith = ( (cause) => new ShadowDbError({ message: cause.message, reason: "filesystem" }), ), ); + yield* Effect.annotateCurrentSpan("migration.count", pending.length); if (!baseline.baselinePresent && baseline.snapshotRequired) { // Own scope: the baseline session must be closed before `snapshotBaseline` — see this @@ -725,6 +744,10 @@ const migrateShadowDatabaseWith = ( ), ); }), + ).pipe( + Effect.withSpan("ShadowDatabase.migrate", { + attributes: { "shadow.baseline_state": shadowBaselineStateName(baseline) }, + }), ); /** 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 78adf0bc54..a5f52cca2f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -45,15 +45,15 @@ import { } from "./db-setup.ts"; import type { ImagePrepullError } from "./image-prepull.ts"; import { waitForHealthyServices, type HealthCheckTimeoutError } from "./health-check.ts"; -import { - START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, - START_STARTING_DATABASE_MESSAGE, -} from "./messages.ts"; import { buildPostgresStartContainerSpec, type PostgresStartServiceInput, } from "./postgres.service.ts"; +const START_STARTING_DATABASE_MESSAGE = "Starting database...\n"; +// Printed when an existing volume is reused; unrelated to `--from-backup`. +const START_STARTING_DATABASE_FROM_BACKUP_MESSAGE = "Starting database from backup...\n"; + type Spawner = ChildProcessSpawner["Service"]; /** @@ -142,6 +142,10 @@ export const startDatabase = ( // guard below is about to reject. const isFreshVolume = !(yield* volumeExists(spawner, input.dbContainerId)); const fromBackup = input.postgresSpec.fromBackup; + yield* Effect.annotateCurrentSpan({ + "db.fresh_volume": isFreshVolume, + "db.from_backup": fromBackup !== undefined, + }); if (!isFreshVolume && fromBackup !== undefined) { // Refused before any container or network is created, and before freshness is published, @@ -226,4 +230,8 @@ export const startDatabase = ( // Reached on every path that doesn't already return or fail above: a fresh volume, a // non-fresh restart, and a swallowed `fromBackup` health-check timeout. yield* startInitCurrentBranch(input.fs, input.path, input.workdir); - }); + }).pipe( + Effect.withSpan("DbBootstrap.startDatabase", { + attributes: { "db.major_version": input.setup.majorVersion }, + }), + ); 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 d1670d8103..db9961f353 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 @@ -93,7 +93,9 @@ interface StartLocalDatabaseResult { * before being passed in; the declarative seam's `ensureLocalDatabaseStarted` always calls with * no argument. */ -export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: string) { +export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(function* ( + fromBackupFlag?: string, +) { const output = yield* Output; const cliSettings = yield* CommandSettings; const fs = yield* FileSystem.FileSystem; @@ -581,6 +583,7 @@ export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: cliSettings.workdir, Option.getOrUndefined(cliSettings.projectId), ); + yield* Effect.annotateCurrentSpan("db.already_running", running); if (running) { return { status: "already-running" } satisfies StartLocalDatabaseResult; } 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 9e8a09e3ae..1002a2b5d7 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -465,7 +465,7 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { registered: true, }), followStatus: Stream.empty, - logs: Stream.empty, + readLogs: () => Stream.empty, credentials: () => unused, saveSnapshot: () => unused, restoreSnapshot: () => unused, diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 90f101b602..c4c5ff6f96 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -103,6 +103,8 @@ const tcpReachable = (host: string, port: number): Effect.Effect => return Effect.sync(() => socket.destroy()); }).pipe( Effect.timeoutOrElse({ duration: TCP_PROBE_TIMEOUT, orElse: () => Effect.succeed(false) }), + Effect.tap((reachable) => Effect.annotateCurrentSpan("network.reachable", reachable)), + Effect.withSpan("DbConfig.tcpProbe", { attributes: { "server.port": port } }), ); // POST /v1/projects/{ref}/cli/login-role → mint a temporary postgres role. The Management API @@ -110,7 +112,10 @@ const tcpReachable = (host: string, port: number): Effect.Effect => // `CommandPlatformApi` stack), so the access token is resolved only here — when a temp role is // actually minted. `--linked --password` returns before reaching this, so it stays auth-free; // `--local`/`--db-url` never build this layer at all. -const initLoginRole = Effect.fnUntraced(function* (ref: string, conn: PgConnInput) { +const initLoginRole = Effect.fn("DbConfig.initLoginRole")(function* ( + ref: string, + conn: PgConnInput, +) { const output = yield* Output; const api = yield* (yield* CommandPlatformApiFactory).make; // Written to stderr unconditionally (not gated on --debug). @@ -133,7 +138,7 @@ const listAndUnban = Effect.fnUntraced(function* (ref: string) { // Verify-connect with backoff while the pooler refreshes the temp password. On attempt ≥ 3, // clear any network ban on the requester. -const waitForTempRole = Effect.fnUntraced(function* ( +const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, dnsResolver: "native" | "https", @@ -144,6 +149,7 @@ const waitForTempRole = Effect.fnUntraced(function* ( // The temp-role probe always targets the remote Supavisor pooler, so it connects with TLS // and honors `--dns-resolver`. Effect.scoped(dbConn.connect(conn, { isLocal: false, dnsResolver }).pipe(Effect.asVoid)).pipe( + Effect.ensuring(Effect.annotateCurrentSpan("retry.attempt_count", n)), Effect.catch((cause) => { // 8 retries after the initial attempt allows 9 total attempts. `n` is 1-based, so give // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. @@ -224,7 +230,7 @@ const resolveDbPassword = Effect.fnUntraced(function* ( * * `workdir`/`poolerHost` are explicit parameters (see {@link resolveDbPassword}). */ -const resolvePoolerConn = Effect.fnUntraced(function* ( +const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( ref: string, workdir: string, poolerHost: string, @@ -306,7 +312,7 @@ const resolvePoolerConn = Effect.fnUntraced(function* ( * `DbConfigResolver`/`ProjectRefResolver` (both keyed off the ambient, potentially-stale * `CommandSettings.workdir`). */ -export const resolveLinkedConn = Effect.fnUntraced(function* ( +export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(function* ( ref: string, workdir: string, projectHost: string, @@ -352,6 +358,7 @@ export const resolveLinkedConn = Effect.fnUntraced(function* ( }; const reachable = yield* tcpReachable(host, DIRECT_PORT); + yield* Effect.annotateCurrentSpan("db.direct_reachable", reachable); if (reachable) { if (base.password.length > 0) { yield* debug.debug("Using database password from env var..."); @@ -669,9 +676,23 @@ export const dbConfigResolverLayer = Layer.effect( }); return DbConfigResolver.of({ resolve: (flags) => - resolve(flags).pipe(Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) }))), + resolve(flags).pipe( + Effect.tap((r) => Effect.annotateCurrentSpan("db.is_local", r.isLocal)), + Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) })), + Effect.withSpan("DbConfig.resolve", { + attributes: { "db.conn_type": flags.connType ?? "local" }, + }), + ), resolvePoolerFallback: (flags) => - resolvePoolerFallback(flags).pipe(Effect.map(Option.map(withSuggestion))), + resolvePoolerFallback(flags).pipe( + Effect.tap((pooler) => + Effect.annotateCurrentSpan("db.pooler.found", Option.isSome(pooler)), + ), + Effect.map(Option.map(withSuggestion)), + Effect.withSpan("DbConfig.resolvePoolerFallback", { + attributes: { "db.conn_type": flags.connType ?? "local" }, + }), + ), }); }), ); 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 c92052d930..60bcd879d9 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -23,6 +23,7 @@ import { validateResolvedConfig, } from "./config-validate.ts"; import { DbConfigLoadError } from "./db-config.errors.ts"; +import { recordOrioleDbTelemetry, selectsOrioleDb } from "./db-image.ts"; import { parseDotEnv } from "./dotenv.ts"; import { strToArr } from "./local-config-values.ts"; import { ramInBytes } from "./size-units.ts"; @@ -1283,7 +1284,11 @@ const readDbTomlCore = Effect.fnUntraced(function* ( (remoteWins("db.orioledb_version") ? undefined : envOverride("SUPABASE_DB_ORIOLEDB_VERSION")) ?? db?.["orioledb_version"]; const orioledbVersion = expandString(orioledbVersionRaw); - if (Option.isSome(orioledbVersion) && (majorVersion === 15 || majorVersion === 17)) { + // The defaults-only fallback says nothing about the project's engine. + if (!ignoreConfigFile) { + yield* recordOrioleDbTelemetry(Option.getOrUndefined(orioledbVersion), majorVersion); + } + if (selectsOrioleDb(Option.getOrUndefined(orioledbVersion), majorVersion)) { // Warns (does not fail) when an S3 field still holds an unexpanded `env(VAR)`; // matches the established stderr line, with the env var name from the capture. const s3Fields = ["s3_host", "s3_region", "s3_access_key", "s3_secret_key"] as const; 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 ffa3a140d3..37e65f0246 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 @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { ConfigProvider, Effect, Exit, FileSystem, Option, Path, Ref } from "effect"; import { checkDbToml, @@ -12,6 +12,10 @@ import { resolveDeclarativeDir, resolveSeedSqlPath, } from "./db-config.toml-read.ts"; +import { + CommandTelemetryAttributes, + type CommandTelemetryAttributeValues, +} from "../telemetry/command-telemetry-attributes.ts"; function withConfig(content: string | undefined, poolerUrl?: string) { const dir = mkdtempSync(join(tmpdir(), "db-toml-")); @@ -3408,3 +3412,42 @@ describe("readDbToml remoteOverrideKeys — auth.captcha.provider / auth.email.t }, ); }); + +describe("readDbToml OrioleDB telemetry", () => { + const recordedAfterRead = (workdir: string) => + Effect.gen(function* () { + const recorded = yield* Ref.make({}); + yield* read(workdir).pipe( + Effect.provideService(CommandTelemetryAttributes, { + record: (values) => Ref.update(recorded, (current) => ({ ...current, ...values })), + }), + ); + return yield* Ref.get(recorded); + }); + + it.effect.each([ + { + name: "a configured version on 17", + toml: 'major_version = 17\norioledb_version = "17.0.0.1"', + env: undefined, + expected: true, + }, + { name: "the env override", toml: "major_version = 15", env: "15.1.1.14", expected: true }, + { name: "no version", toml: "major_version = 17", env: undefined, expected: false }, + ])("records orioledb=$expected for $name", ({ toml, env, expected }) => { + const dir = withConfig(`[db]\n${toml}\n`); + const previous = process.env["SUPABASE_DB_ORIOLEDB_VERSION"]; + if (env === undefined) delete process.env["SUPABASE_DB_ORIOLEDB_VERSION"]; + else process.env["SUPABASE_DB_ORIOLEDB_VERSION"] = env; + return recordedAfterRead(dir).pipe( + Effect.tap((recorded) => Effect.sync(() => expect(recorded.orioledb).toBe(expected))), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DB_ORIOLEDB_VERSION"]; + else process.env["SUPABASE_DB_ORIOLEDB_VERSION"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/command-internal/db-connection.layer.ts b/apps/cli/src/command-internal/db-connection.layer.ts deleted file mode 100644 index f75cf737a4..0000000000 --- a/apps/cli/src/command-internal/db-connection.layer.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { dbConnectionSqlPgLayer } from "./db-connection.sql-pg.layer.ts"; - -/** - * The active `DbConnection` layer. Re-exports the `@effect/sql-pg` layer; call sites import this - * name, not the driver, so swapping the implementation is a one-line change here. - */ -export const dbConnectionLayer = dbConnectionSqlPgLayer; diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts index 06dc217ffc..721ff27bc7 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts @@ -11,11 +11,7 @@ import { Duration, Effect } from "effect"; import { SUGGEST_ENV_VAR, SUGGEST_LOCAL_STACK } from "./connect-errors.ts"; import type { DbConnectError, DbExecError } from "./db-connection.errors.ts"; import { type DbSession, type PgConnInput, DbConnection } from "./db-connection.service.ts"; -import { - acquirePgPool, - dbConnectionSqlPgLayer, - PgBatchQuery, -} from "./db-connection.sql-pg.layer.ts"; +import { acquirePgPool, dbConnectionLayer, PgBatchQuery } from "./db-connection.sql-pg.layer.ts"; const SUGGESTION_CONTEXT = { dashboardUrl: "https://supabase.com/dashboard", @@ -55,7 +51,7 @@ const connectFailure = ( Effect.mapError(() => new Error("expected the connection to fail")), Effect.orDie, ); - }).pipe(Effect.provide(dbConnectionSqlPgLayer)); + }).pipe(Effect.provide(dbConnectionLayer)); /** A TCP port that is guaranteed closed: bind an ephemeral port, then release it. */ const acquireClosedPort = (): Promise => @@ -403,7 +399,7 @@ const fakeQueryServer = ( }); }); -describe("dbConnectionSqlPgLayer connect failures", () => { +describe("dbConnectionLayer connect failures", () => { it.live( "surfaces host, user, database, and the driver cause when a remote (--linked) connection is refused", () => @@ -487,7 +483,7 @@ describe("dbConnectionSqlPgLayer connect failures", () => { ); }); -describe("dbConnectionSqlPgLayer exec failures", () => { +describe("dbConnectionLayer exec failures", () => { it.live( "maps a real wire ErrorResponse to pgconn's PgError rendering with detail and position", () => @@ -540,7 +536,7 @@ describe("dbConnectionSqlPgLayer exec failures", () => { Effect.mapError(() => new Error("expected the statement to fail")), Effect.orDie, ); - }).pipe(Effect.provide(dbConnectionSqlPgLayer), Effect.ensuring(Effect.sync(server.close))); + }).pipe(Effect.provide(dbConnectionLayer), Effect.ensuring(Effect.sync(server.close))); expect(error._tag).toBe("DbExecError"); expect(error.message).toBe('FEHLER: type "ltree" does not exist (SQLSTATE 42704)'); if (error._tag === "DbExecError") { @@ -552,7 +548,7 @@ describe("dbConnectionSqlPgLayer exec failures", () => { ); }); -describe("dbConnectionSqlPgLayer extended batches", () => { +describe("dbConnectionLayer extended batches", () => { /** * Narrow a batch failure to its statement-execution error. `execBatch` also fails * with `DbConnectError` when it cannot check a connection out of the pool, @@ -584,7 +580,7 @@ describe("dbConnectionSqlPgLayer extended batches", () => { return yield* use(session); }).pipe( Effect.scoped, - Effect.provide(dbConnectionSqlPgLayer), + Effect.provide(dbConnectionLayer), Effect.ensuring(Effect.sync(server.close)), ); diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index 72de3279e2..e8d99fea43 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -1054,4 +1054,5 @@ const connect = ( return session; }); -export const dbConnectionSqlPgLayer = Layer.succeed(DbConnection, { connect }); +/** The active `DbConnection` layer, backed by `@effect/sql-pg`. */ +export const dbConnectionLayer = Layer.succeed(DbConnection, { connect }); diff --git a/apps/cli/src/command-internal/db-image.ts b/apps/cli/src/command-internal/db-image.ts index a154f4aab2..8db35f7ded 100644 --- a/apps/cli/src/command-internal/db-image.ts +++ b/apps/cli/src/command-internal/db-image.ts @@ -2,6 +2,8 @@ import { Effect, type FileSystem, type Path } from "effect"; import { dockerfileServiceImageRaw } from "../shared/services/dockerfile-images.ts"; import { postgresImageForDbMajorVersion } from "../shared/services/services.shared.ts"; import { slimImageForCurrentPin, slimImagesEnabled } from "../shared/services/slim-images.ts"; +import { PropOrioleDb } from "../shared/telemetry/event-catalog.ts"; +import { recordCommandTelemetry } from "../telemetry/command-telemetry-attributes.ts"; /** * Resolves the local Postgres Docker image for commands that run a pg_dump/shadow-DB container @@ -48,6 +50,21 @@ function compareSemver(a: string, b: string): number { return 0; } +/** OrioleDB replaces the Postgres image only on 15/17 projects with a version set. */ +export const selectsOrioleDb = ( + orioledbVersion: string | undefined, + majorVersion: number, +): orioledbVersion is string => + orioledbVersion !== undefined && + orioledbVersion.length > 0 && + (majorVersion === 15 || majorVersion === 17); + +/** Records whether the local project selects OrioleDB on the enclosing command event. */ +export const recordOrioleDbTelemetry = ( + orioledbVersion: string | undefined, + majorVersion: number, +) => recordCommandTelemetry({ [PropOrioleDb]: selectsOrioleDb(orioledbVersion, majorVersion) }); + /** * Resolves the Postgres image for `majorVersion`, honoring the pinned version written by * `supabase start` to `supabase/.temp/postgres-version`. The tag is only replaced when the @@ -60,13 +77,9 @@ export const resolveDbImage = Effect.fnUntraced(function* ( majorVersion: number, orioledbVersion?: string, ) { - // OrioleDB override: on a 15/17 project with `db.orioledb_version` set, the Postgres image is - // replaced with the OrioleDB tag, taking precedence over the default/pinned image. - if ( - orioledbVersion !== undefined && - orioledbVersion.length > 0 && - (majorVersion === 15 || majorVersion === 17) - ) { + yield* recordOrioleDbTelemetry(orioledbVersion, majorVersion); + // The OrioleDB tag takes precedence over the default/pinned image. + if (selectsOrioleDb(orioledbVersion, majorVersion)) { const image = versionCompare(orioledbVersion, "15.1.1.13") > 0 ? `supabase/postgres:${orioledbVersion}-orioledb` diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 455a000c2e..caf4e0ced2 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -235,6 +235,10 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( : Option.isSome(flags.local) ? "local" : "linked"; + yield* Effect.annotateCurrentSpan({ + "db.conn_type": connType, + "db.pull.mode": useDeclarativeExport ? "declarative" : "migration", + }); // `--project-ref` never implies `--linked` and must not be silently // discarded on a non-linked target — see push.handler.ts's identical guard @@ -375,6 +379,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( if (Option.getOrElse(flags.diffEngine, () => "pg-delta") === "migra") { yield* stackRejectNativeDockerDiffEngine("--diff-engine migra"); } + const diffEngine = usePgDeltaDiff ? "pg-delta" : "migra"; // Connectivity check, run before dialing. return yield* Effect.scoped( @@ -407,6 +412,9 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }); const exported = yield* withPoolerFallback(targetEndpoint, (target) => exportSchema(target), + ).pipe( + Effect.tap((result) => Effect.annotateCurrentSpan("file.count", result.files.length)), + Effect.withSpan("db.pull.exportDeclarative"), ); const written = yield* writeDeclarativeSchemas(fs, path, declarativeDir, exported).pipe( Effect.mapError((cause) => new DbPullWriteError({ message: cause.message })), @@ -554,6 +562,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }), ), ), + Effect.withSpan("db.pull.dumpSchema"), ); }; // Prints this once, before the pooler-fallback retry. @@ -687,7 +696,15 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, ); }); - const diffOutcome = yield* withPoolerFallback(targetEndpoint, runShadowDiff); + const diffOutcome = yield* withPoolerFallback(targetEndpoint, runShadowDiff).pipe( + Effect.tap((outcome) => + Effect.annotateCurrentSpan({ + "diff.empty": outcome.sql.trim().length === 0, + "migration.count": outcome.files?.length ?? (outcome.sql.trim().length === 0 ? 0 : 1), + }), + ), + Effect.withSpan("db.pull.plan", { attributes: { "db.pull.engine": diffEngine } }), + ); const out = diffOutcome.sql; const diffEmpty = out.trim().length === 0; @@ -840,7 +857,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // string field from its first entry. schemaFiles: writtenMigrations.map((written) => written.path), remoteHistoryUpdated, - engine: usePgDeltaDiff ? "pg-delta" : "migra", + engine: diffEngine, } as const; }), ); diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index 70cdba0d80..68c7029e77 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -84,7 +84,7 @@ export interface DbPushCoreInput { readonly emitStructuredResult: boolean; } -export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { +export const dbPushCore = Effect.fn("DbPush.run")(function* (input: DbPushCoreInput) { const output = yield* Output; const dbConn = yield* DbConnection; const fs = yield* FileSystem.FileSystem; @@ -184,6 +184,14 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { if (exists) globals.push(CUSTOM_ROLES_PATH); } + yield* Effect.annotateCurrentSpan({ + "db.is_local": isLocal, + "db.push.dry_run": dryRun, + "migration.count": pending.length, + "seed.count": seeds.length, + "role_file.count": globals.length, + }); + if (pending.length === 0 && seeds.length === 0 && globals.length === 0) { if (output.format === "text") { yield* output.raw(`${statusTarget} is up to date.\n`); diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index 59a2a106ed..035d2546fe 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -139,6 +139,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // experimental stack flags "capability", "service", + "since", "stack", "stack-id", "preparation", diff --git a/apps/cli/src/command-internal/db-target-flags.unit.test.ts b/apps/cli/src/command-internal/db-target-flags.unit.test.ts index 84c576779d..bc7f841570 100644 --- a/apps/cli/src/command-internal/db-target-flags.unit.test.ts +++ b/apps/cli/src/command-internal/db-target-flags.unit.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { + changedLinkedLocalFlags, resolveDbTargetFlags, VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS, @@ -258,3 +259,65 @@ describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness expect(missing).toEqual([]); }); }); + +describe("changedLinkedLocalFlags", () => { + it("returns nothing when neither selector is present", () => { + expect(changedLinkedLocalFlags(["seed", "buckets"])).toEqual([]); + expect(changedLinkedLocalFlags(["storage", "ls", "ss:///"])).toEqual([]); + }); + + it("returns a single selector", () => { + expect(changedLinkedLocalFlags(["seed", "buckets", "--linked"])).toEqual(["linked"]); + expect(changedLinkedLocalFlags(["seed", "buckets", "--local"])).toEqual(["local"]); + }); + + it("returns both selectors in cobra's sorted order when both are set", () => { + expect(changedLinkedLocalFlags(["seed", "buckets", "--local", "--linked"])).toEqual([ + "linked", + "local", + ]); + }); + + it("handles = forms", () => { + expect(changedLinkedLocalFlags(["--local=true", "--linked=false"])).toEqual([ + "linked", + "local", + ]); + }); + + it("treats the --no-* negation form as changed", () => { + expect(changedLinkedLocalFlags(["seed", "buckets", "--no-linked"])).toEqual(["linked"]); + expect(changedLinkedLocalFlags(["storage", "ls", "--no-local"])).toEqual(["local"]); + expect(changedLinkedLocalFlags(["seed", "buckets", "--no-local", "--linked"])).toEqual([ + "linked", + "local", + ]); + }); + + it("does not treat a value-consuming flag's value as a selector", () => { + expect(changedLinkedLocalFlags(["seed", "buckets", "--workdir", "--linked"])).toEqual([]); + expect(changedLinkedLocalFlags(["-o", "--linked", "--local"])).toEqual(["local"]); + expect( + changedLinkedLocalFlags(["storage", "cp", "--content-type", "--local", "a", "b"]), + ).toEqual([]); + expect( + changedLinkedLocalFlags(["storage", "cp", "--cache-control", "--linked", "a", "b"]), + ).toEqual([]); + expect(changedLinkedLocalFlags(["storage", "cp", "--jobs", "--local", "a", "b"])).toEqual([]); + expect(changedLinkedLocalFlags(["storage", "cp", "-j", "--linked", "a", "b"])).toEqual([]); + }); + + it("still detects a selector after a value-consuming flag's value", () => { + expect(changedLinkedLocalFlags(["storage", "cp", "--jobs", "5", "--local", "a", "b"])).toEqual([ + "local", + ]); + }); + + it("stops scanning at the -- terminator", () => { + expect(changedLinkedLocalFlags(["seed", "buckets", "--", "--local", "--linked"])).toEqual([]); + }); + + it("detects selectors given after positional arguments", () => { + expect(changedLinkedLocalFlags(["storage", "rm", "ss:///b/x", "--local"])).toEqual(["local"]); + }); +}); diff --git a/apps/cli/src/command-internal/docker-lifecycle.ts b/apps/cli/src/command-internal/docker-lifecycle.ts index d2ad2ca18a..c9e8f11232 100644 --- a/apps/cli/src/command-internal/docker-lifecycle.ts +++ b/apps/cli/src/command-internal/docker-lifecycle.ts @@ -12,6 +12,7 @@ import { describeContainerCliFailure, spawnContainerCli, } from "./container-cli.ts"; +import { ChildTracePropagation } from "../shared/telemetry/spans.ts"; import { CLI_WORKDIR_LABEL } from "./docker-ids.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -190,6 +191,7 @@ export const listContainerIdsAndNames = ( * Inspects a container's state via `docker container inspect --format {{json .State}}`. * A missing container isn't special-cased — every non-zero exit, including "no such container", * propagates as {@link DockerLifecycleInspectError} carrying the real Docker stderr text. + * Emits no spans: wait loops poll it, and their wait span records the attempt count. */ export const inspectContainerState = (spawner: Spawner, containerId: string) => Effect.scoped( @@ -243,7 +245,7 @@ export const inspectContainerState = (spawner: Spawner, containerId: string) => } return parseContainerState(stdout); }), - ); + ).pipe(Effect.withTracerEnabled(false), Effect.provideService(ChildTracePropagation, false)); function parseContainerState(stdout: string): { readonly running: boolean; diff --git a/apps/cli/src/command-internal/ensure-login.ts b/apps/cli/src/command-internal/ensure-login.ts index e6c453bfe4..012e69b44a 100644 --- a/apps/cli/src/command-internal/ensure-login.ts +++ b/apps/cli/src/command-internal/ensure-login.ts @@ -30,13 +30,14 @@ export const LOGGED_IN_MSG = "You are now logged in. Happy coding!\n"; * `stitchLogin` only aliases — it does not call `identify`. Do not add `analytics.identify` here; * it would emit an event the established telemetry never sends. */ -export const postLoginTelemetry = Effect.fnUntraced(function* (token: string) { +export const postLoginTelemetry = Effect.fn("Login.postTelemetry")(function* (token: string) { const loginApi = yield* LoginApi; const telemetryState = yield* TelemetryState; const analytics = yield* Analytics; const cliSettings = yield* CommandSettings; const gotrueId = yield* loginApi.fetchGotrueId(cliSettings.apiUrl, token); + yield* Effect.annotateCurrentSpan("login.identity_resolved", Option.isSome(gotrueId)); if (Option.isSome(gotrueId)) { yield* telemetryState.stitchLogin(gotrueId.value); yield* analytics @@ -62,7 +63,7 @@ export interface BrowserLoginOptions { * post-login telemetry and prints the success banners. Owns the single `cli_login_completed` * capture for this path. */ -export const browserLogin = Effect.fnUntraced(function* (opts: BrowserLoginOptions) { +export const browserLogin = Effect.fn("Login.browser")(function* (opts: BrowserLoginOptions) { const output = yield* Output; const crypto = yield* LoginCrypto; const loginApi = yield* LoginApi; @@ -121,12 +122,19 @@ export const browserLogin = Effect.fnUntraced(function* (opts: BrowserLoginOptio const code = yield* output.promptText("Enter your verification code: ", { validate: (v) => (v.trim().length > 0 ? undefined : "Verification code is required"), }); - return yield* loginApi.fetchLoginSession(apiHost, sessionId, code.trim()); + return yield* loginApi + .fetchLoginSession(apiHost, sessionId, code.trim()) + .pipe( + Effect.tap(() => + Effect.annotateCurrentSpan("login.verify_attempt_count", failuresSoFar + 1), + ), + ); }).pipe( Effect.catchTag("LoginVerificationError", (err: LoginVerificationError) => Effect.gen(function* () { const failures = failuresSoFar + 1; if (failures > MAX_LOGIN_RETRIES) { + yield* Effect.annotateCurrentSpan("login.verify_attempt_count", failures); return yield* Effect.fail( new LoginFailedError({ message: err.message, @@ -168,8 +176,9 @@ export const browserLogin = Effect.fnUntraced(function* (opts: BrowserLoginOptio * (env/keyring/file), otherwise runs the browser login flow and fires `cli_login_completed` * once. */ -export const ensureLogin = Effect.fnUntraced(function* (opts: { openBrowser: boolean }) { +export const ensureLogin = Effect.fn("Login.ensure")(function* (opts: { openBrowser: boolean }) { const existing = yield* resolveAccessToken; + yield* Effect.annotateCurrentSpan("login.required", Option.isNone(existing)); if (Option.isSome(existing)) { return; } diff --git a/apps/cli/src/command-internal/git-status.ts b/apps/cli/src/command-internal/git-status.ts index 36e45449da..136f4014ac 100644 --- a/apps/cli/src/command-internal/git-status.ts +++ b/apps/cli/src/command-internal/git-status.ts @@ -3,6 +3,7 @@ import { Effect, Option } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { withChildTraceEnv, withProcessSpan } from "../shared/telemetry/spans.ts"; import { collectText } from "./container-cli.ts"; /** @@ -19,22 +20,36 @@ import { collectText } from "./container-cli.ts"; export function pathHasUncommittedChanges( path: string, ): Effect.Effect, never, ChildProcessSpawner> { - return Effect.scoped( - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner; - const handle = yield* spawner.spawn( - ChildProcess.make("git", ["status", "--porcelain", "--", basename(path)], { - cwd: dirname(path), - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", + const args = ["status", "--porcelain", "--", basename(path)]; + return withProcessSpan( + "Git.status", + { executable: "git", argCount: args.length }, + (traceEnv) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make( + "git", + args, + withChildTraceEnv( + { cwd: dirname(path), stdin: "ignore", stdout: "pipe", stderr: "ignore" }, + traceEnv, + ), + ), + ); + const [exitCode, stdout] = yield* Effect.all( + [handle.exitCode.pipe(Effect.map(Number)), collectText(handle.stdout)], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout }; }), - ); - const [exitCode, stdout] = yield* Effect.all( - [handle.exitCode.pipe(Effect.map(Number)), collectText(handle.stdout)], - { concurrency: "unbounded" }, - ); - return exitCode === 0 ? Option.some(stdout.trim().length > 0) : Option.none(); - }), - ).pipe(Effect.orElseSucceed(() => Option.none())); + ), + (result) => result.exitCode, + ).pipe( + Effect.map(({ exitCode, stdout }) => + exitCode === 0 ? Option.some(stdout.trim().length > 0) : Option.none(), + ), + Effect.orElseSucceed(() => Option.none()), + ); } diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 7e6b994ad4..2348e9a7cf 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -35,6 +35,19 @@ export const OutputFlag = GlobalFlag.setting("output")({ ), }); +/** + * The TS-only `--output-format` global, accepted on any subcommand. + * + * A value-taking global flag must also be registered in `PERSISTENT_VALUE_FLAG_NAMES` + * (`shared/cli/cobra-flag-groups.ts`), or the pre-parse argv scanners won't consume its value. + */ +export const OutputFormatFlag = GlobalFlag.setting("output-format")({ + flag: Flag.choice("output-format", ["text", "json", "stream-json"]).pipe( + Flag.withDescription("Output format: text (default), json, or stream-json (NDJSON)"), + Flag.optional, + ), +}); + export const ProfileFlag = GlobalFlag.setting("profile")({ flag: Flag.string("profile").pipe( Flag.withDescription("use a specific profile for connecting to Supabase API"), diff --git a/apps/cli/src/command-internal/go-proxy.layer.ts b/apps/cli/src/command-internal/go-proxy.layer.ts index e5c10d39ea..553d2c9acb 100644 --- a/apps/cli/src/command-internal/go-proxy.layer.ts +++ b/apps/cli/src/command-internal/go-proxy.layer.ts @@ -11,6 +11,7 @@ import { ProcessControl } from "../shared/runtime/process-control.service.ts"; import { GoChildExitError } from "./go-child-exit.error.ts"; import { GoProxyInvocation } from "./go-proxy-invocation.ts"; import { GoProxy } from "./go-proxy.service.ts"; +import { withProcessSpan } from "../shared/telemetry/spans.ts"; const markDelegated = Effect.serviceOption(GoProxyInvocation).pipe( Effect.flatMap((invocation) => @@ -193,16 +194,25 @@ export function makeGoProxyLayer(opts?: { ? { SUPABASE_TELEMETRY_DISABLED: "1" } : {}), }; - const command = ChildProcess.make(binary, [...globalArgs, ...args], { - cwd: execOpts?.cwd ?? opts?.cwd, - env, - extendEnv: true, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - detached: false, - }); - const exitCode = yield* spawner.exitCode(command).pipe(Effect.orDie); + const argv = [...globalArgs, ...args]; + const exitCode = yield* withProcessSpan( + "GoProxy.exec", + { executable: binary, argCount: argv.length }, + (traceEnv) => + spawner + .exitCode( + ChildProcess.make(binary, argv, { + cwd: execOpts?.cwd ?? opts?.cwd, + env: { ...env, ...traceEnv }, + extendEnv: true, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + detached: false, + }), + ) + .pipe(Effect.orDie), + ); if (exitCode !== 0) { return yield* Effect.fail( new GoChildExitError({ @@ -246,22 +256,35 @@ export function makeGoProxyLayer(opts?: { // while stdout is collected for wrapping. Callers pass stdin: "ignore" to give the // child a non-TTY stdin so it can't block on a prompt before the wrapper emits its // machine-output envelope. - const command = ChildProcess.make(binary, [...globalArgs, ...args], { - cwd: execOpts?.cwd ?? opts?.cwd, - env, - extendEnv: true, - stdin: execOpts?.stdin ?? "inherit", - stdout: "pipe", - stderr: "inherit", - detached: false, - }); - const handle = yield* spawner.spawn(command).pipe(Effect.orDie); - // Drain stdout fully before awaiting exit so a full pipe buffer can't - // deadlock the child. - const captured = yield* Stream.mkString(Stream.decodeText(handle.stdout)).pipe( - Effect.orDie, + const argv = [...globalArgs, ...args]; + const { captured, exitCode } = yield* withProcessSpan( + "GoProxy.execCapture", + { executable: binary, argCount: argv.length }, + (traceEnv) => + Effect.gen(function* () { + const handle = yield* spawner + .spawn( + ChildProcess.make(binary, argv, { + cwd: execOpts?.cwd ?? opts?.cwd, + env: { ...env, ...traceEnv }, + extendEnv: true, + stdin: execOpts?.stdin ?? "inherit", + stdout: "pipe", + stderr: "inherit", + detached: false, + }), + ) + .pipe(Effect.orDie); + // Drain stdout fully before awaiting exit so a full pipe buffer can't + // deadlock the child. + const captured = yield* Stream.mkString(Stream.decodeText(handle.stdout)).pipe( + Effect.orDie, + ); + const exitCode = yield* handle.exitCode.pipe(Effect.orDie); + return { captured, exitCode }; + }), + (result) => result.exitCode, ); - const exitCode = yield* handle.exitCode.pipe(Effect.orDie); if (exitCode !== 0) { return yield* Effect.fail( new GoChildExitError({ diff --git a/apps/cli/src/command-internal/link-services-core.ts b/apps/cli/src/command-internal/link-services-core.ts index e5171a61cb..461162dafa 100644 --- a/apps/cli/src/command-internal/link-services-core.ts +++ b/apps/cli/src/command-internal/link-services-core.ts @@ -38,7 +38,13 @@ type WriteTempFile = (filePath: string, content: string) => Effect.Effect( ), ); + yield* Effect.annotateCurrentSpan({ + "config.found": loaded !== null, + "config.remote_applied": loaded?.appliedRemote !== undefined, + }); return { config, projectEnvValues, loaded, hostname, projectId }; - }); + }).pipe(Effect.withSpan("LocalProjectContext.load")); + +/** Records OrioleDB selection for commands whose only local config read is this context. */ +export const recordLocalProjectOrioleDbTelemetry = (context: LocalProjectContext) => + Effect.try(() => ({ + version: envOverride( + "SUPABASE_DB_ORIOLEDB_VERSION", + context.config.db.orioledb_version, + context.projectEnvValues, + ), + majorVersion: envOverrideMajorVersion( + context.config.db.major_version, + context.projectEnvValues, + ), + })).pipe( + Effect.flatMap(({ version, majorVersion }) => recordOrioleDbTelemetry(version, majorVersion)), + Effect.ignore, + ); diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 937709cd4f..96aea47001 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -441,6 +441,11 @@ const execMigrationBatch = ( const matches = MIGRATE_FILE_PATTERN.exec(filename); const version = forceNoVersion ? "" : (matches?.[1] ?? ""); const name = matches?.[2] ?? ""; + yield* Effect.annotateCurrentSpan({ + "statement.count": statements.length, + "migration.transaction_mode": transactionMode, + ...(version !== "" ? { "migration.version": version } : {}), + }); const restoreRole = session.restoreRoleSql; @@ -592,7 +597,7 @@ const execMigrationBatch = ( : mapError(errorMessage(error), "exec", formattedExecBatchDbError(error)), ), ); - }); + }).pipe(Effect.withSpan("MigrationApply.file")); /** * Clears any connection settings a prior statement on the same session may have changed (e.g. @@ -660,7 +665,9 @@ export const applyMigrations = ( yield* resetConnectionState(session, mapError); yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); } - }); + }).pipe( + Effect.withSpan("MigrationApply.run", { attributes: { "migration.count": pending.length } }), + ); /** * Applies custom-role/globals files: for each file, emits `Seeding globals from ...` to @@ -680,7 +687,9 @@ export const seedGlobals = ( yield* output.raw(`Seeding globals from ${path.basename(globalPath)}...\n`, "stderr"); yield* execMigrationBatch(session, fs, path, globalPath, mapError, true); } - }); + }).pipe( + Effect.withSpan("MigrationApply.seedGlobals", { attributes: { "file.count": globals.length } }), + ); /** * Runs one SQL file's statements transactionally, without `seedGlobals`'s per-file stderr @@ -734,6 +743,7 @@ export const applySchemaFiles = ( ): Effect.Effect => Effect.gen(function* () { const { files, warnings } = yield* sqlFilesGlob(fs, path, schemaPaths, workdir); + yield* Effect.annotateCurrentSpan("file.count", files.length); if (files.length === 0) { // Succeeds when there were no patterns to glob at all; fails with the joined per-pattern // warnings otherwise. @@ -760,4 +770,4 @@ export const applySchemaFiles = ( projectEnv, ); } - }); + }).pipe(Effect.withSpan("MigrationApply.schemaFiles")); diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index b4ea180eb0..0390201928 100644 --- a/apps/cli/src/command-internal/migration-fetch-run.ts +++ b/apps/cli/src/command-internal/migration-fetch-run.ts @@ -36,7 +36,9 @@ export interface MigrationFetchOutcome { readonly files: ReadonlyArray; } -export const runMigrationFetch = Effect.fnUntraced(function* (input: MigrationFetchInput) { +export const runMigrationFetch = Effect.fn("MigrationFetch.run")(function* ( + input: MigrationFetchInput, +) { const { flags, target, assumeYes } = input; const output = yield* Output; const resolver = yield* DbConfigResolver; @@ -56,6 +58,7 @@ export const runMigrationFetch = Effect.fnUntraced(function* (input: MigrationFe } const connType = target.connType ?? "linked"; // fetch defaults to `--linked`. + yield* Effect.annotateCurrentSpan("db.conn_type", connType); // `--project-ref` never implies `--linked` and must not be silently // discarded on a non-linked target — see push.handler.ts's identical guard @@ -178,6 +181,7 @@ export const runMigrationFetch = Effect.fnUntraced(function* (input: MigrationFe ); written.push(filePath); } + yield* Effect.annotateCurrentSpan("migration.count", written.length); return { files: written } satisfies MigrationFetchOutcome; }); diff --git a/apps/cli/src/command-internal/migration-history.ts b/apps/cli/src/command-internal/migration-history.ts index 9cf6e62243..3ff20c7f21 100644 --- a/apps/cli/src/command-internal/migration-history.ts +++ b/apps/cli/src/command-internal/migration-history.ts @@ -268,6 +268,8 @@ export const listRemoteMigrations = (session: DbSession) => ? Effect.succeed>([]) : Effect.fail(new MigrationsReadError({ message: error.message })), ), + Effect.tap((versions) => Effect.annotateCurrentSpan("migration.count", versions.length)), + Effect.withSpan("MigrationHistory.listRemote"), ); /** Whether a query error is Postgres's `undefined_table` (42P01). */ @@ -428,6 +430,8 @@ export const readMigrationTable = (session: DbSession) => message: `failed to read migration table: ${error.message}`, }), ), + Effect.tap((files) => Effect.annotateCurrentSpan("migration.count", files.length)), + Effect.withSpan("MigrationHistory.readTable"), ); /** diff --git a/apps/cli/src/command-internal/migration-list.ts b/apps/cli/src/command-internal/migration-list.ts index 556b9d5d76..b1c6af5412 100644 --- a/apps/cli/src/command-internal/migration-list.ts +++ b/apps/cli/src/command-internal/migration-list.ts @@ -25,7 +25,7 @@ const NO_MIGRATIONS: ReadonlyArray = []; * fires for the `db diff/pull/schema declarative` paths too, not only the `migration` * commands. */ -export const listLocalMigrations = Effect.fnUntraced(function* ( +export const listLocalMigrations = Effect.fn("MigrationList.local")(function* ( fs: FileSystem.FileSystem, path: Path.Path, migrationsDir: string, @@ -80,5 +80,6 @@ export const listLocalMigrations = Effect.fnUntraced(function* ( } result.push(entryPath); } + yield* Effect.annotateCurrentSpan("migration.count", result.length); return result; }); diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index ed14c356fd..751f259c44 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -126,20 +126,24 @@ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { * always wins; otherwise it prefers `resolveLinkedParentRef`'s cached parent, falling back to * `resolver.resolve` unchanged when no parent is resolvable. */ -export const resolveParentScopedProjectRef = Effect.fnUntraced(function* ( +export const resolveParentScopedProjectRef = Effect.fn("ParentProjectRef.resolve")(function* ( flagValue: Option.Option, ) { const resolver = yield* ProjectRefResolver; if (Option.isSome(flagValue) && flagValue.value.length > 0) { + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "flag"); return yield* resolver.resolve(flagValue); } const parent = yield* resolveLinkedParentRef(); + yield* Effect.annotateCurrentSpan("parent_project_ref.linked_parent", parent.kind); if (parent.kind === "resolved") { + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "linked_parent"); return parent.ref; } + yield* Effect.annotateCurrentSpan("parent_project_ref.source", "resolver"); return yield* resolver.resolve(Option.none()); }); diff --git a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts index a80e16ff0e..46dd9471c1 100644 --- a/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts +++ b/apps/cli/src/command-internal/pgdelta-engine-runtime.layer.ts @@ -3,7 +3,7 @@ import { Layer } from "effect"; import { httpClientLayer } from "../auth/http-debug.layer.ts"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; import { dbConfigLayer } from "./db-config.layer.ts"; -import { dbConnectionLayer } from "./db-connection.layer.ts"; +import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; import { dockerRunLayer } from "./docker-run.layer.ts"; import { edgeRuntimeScriptLayer } from "./edge-runtime-script.layer.ts"; diff --git a/apps/cli/src/command-internal/pgdelta-ssl-probe.layer.ts b/apps/cli/src/command-internal/pgdelta-ssl-probe.layer.ts index a8821702ca..ef08852176 100644 --- a/apps/cli/src/command-internal/pgdelta-ssl-probe.layer.ts +++ b/apps/cli/src/command-internal/pgdelta-ssl-probe.layer.ts @@ -126,9 +126,12 @@ export const pgDeltaSslProbeLayer = Layer.effect( return Effect.sync(() => socket.destroy()); }, ); + yield* Effect.annotateCurrentSpan("db.ssl.supported", outcome === "tls"); if (outcome === "refused") return false; return true; - }); + }).pipe( + Effect.withSpan("PgDeltaSslProbe.probe", { attributes: { "server.port": target.port } }), + ); return PgDeltaSslProbe.of({ requireSsl: (dbUrl) => Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/profile-load.ts b/apps/cli/src/command-internal/profile-load.ts index ffe2f3d897..28a1c66c65 100644 --- a/apps/cli/src/command-internal/profile-load.ts +++ b/apps/cli/src/command-internal/profile-load.ts @@ -52,7 +52,9 @@ export function loadProfile( // Built-in names are all ASCII lower-case, so case-insensitive matching is plain // lower-casing. const folded = token.toLowerCase(); - if (isBuiltinProfileName(folded)) { + const builtin = isBuiltinProfileName(folded); + yield* Effect.annotateCurrentSpan("profile.source", builtin ? "builtin" : "file"); + if (builtin) { return { apiUrl: apiUrl(folded), name: folded, @@ -159,7 +161,7 @@ export function loadProfile( poolerHost: values.get("pooler_host") ?? "", dashboardUrl: values.get("dashboard_url") ?? "", }; - }); + }).pipe(Effect.withSpan("Profile.load")); } const fail = (message: string) => Effect.fail(new ProfileLoadError({ message })); diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index aa1aca15e3..55238d6a59 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -68,7 +68,9 @@ function printKeyValue(key: string, value: string): string { * (the standalone command's own pre-run does that) and does not write the linked-project * cache (the caller owns that via `Effect.ensuring`). */ -export const projectCreateCore = Effect.fnUntraced(function* (input: ProjectCreateInput) { +export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( + input: ProjectCreateInput, +) { const output = yield* Output; const goOutputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; @@ -143,6 +145,7 @@ export const projectCreateCore = Effect.fnUntraced(function* (input: ProjectCrea yield* creating?.clear ?? Effect.void; const id = readProjectField(created, "id"); + yield* Effect.annotateCurrentSpan("project.ref", id); // Printed to stderr for every output format. const projectUrl = `${dashboardUrlForProfile(cliSettings.profile)}/project/${id}`; diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 6ba150f089..9f66fdbbae 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -170,9 +170,11 @@ export function resolveConfigTarget; - if (BRANCH_UUID_PATTERN.test(target)) { + if (byId) { parentRef = resolveParentScopedProjectRef(Option.none()); } else { const parent = yield* resolveLinkedParentRef(); @@ -204,9 +206,11 @@ export function resolveConfigTarget> = { "us-west-2": "West US (Oregon)", }; -export function formatRegion(region: string): string { +/** Renders a known region code as its display name; unknown codes pass through. */ +export function formatRegion(region: string) { return REGION_NAMES[region] ?? region; } diff --git a/apps/cli/src/commands/backups/backups.format.unit.test.ts b/apps/cli/src/command-internal/region.format.unit.test.ts similarity index 95% rename from apps/cli/src/commands/backups/backups.format.unit.test.ts rename to apps/cli/src/command-internal/region.format.unit.test.ts index a0f13e9e1f..eb6c6f3548 100644 --- a/apps/cli/src/commands/backups/backups.format.unit.test.ts +++ b/apps/cli/src/command-internal/region.format.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatRegion } from "./backups.format.ts"; +import { formatRegion } from "./region.format.ts"; describe("formatRegion", () => { it.each([ diff --git a/apps/cli/src/command-internal/stack-backend.ts b/apps/cli/src/command-internal/stack-backend.ts index 9c12d7023a..aef22f5306 100644 --- a/apps/cli/src/command-internal/stack-backend.ts +++ b/apps/cli/src/command-internal/stack-backend.ts @@ -55,17 +55,18 @@ export class StackBackendContext extends Context.Service< export const stackBackendLayer = (kind: StackBackend) => Layer.succeed(StackBackendContext, { kind }); -/** Handlers default to legacy when tests omit the root-provided backend service. */ +/** Handlers default to legacy when no backend was routed for the command. */ export const currentStackBackend: Effect.Effect<{ readonly kind: StackBackend }, never, never> = Effect.serviceOption(StackBackendContext).pipe( Effect.map((value) => Option.getOrElse(value, () => ({ kind: "legacy" as const }))), ); +/** Resolves the backend, or `undefined` for commands that `experimental.stack` does not route. */ export const resolveStackBackend = (input: { readonly args: ReadonlyArray; readonly cwd: string; readonly env: Readonly>; -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { // Completion passes the final token as the cursor word, so it is not part of // the resolved command path. @@ -73,7 +74,7 @@ export const resolveStackBackend = (input: { input.args[0] === "__complete" || input.args[0] === "__completeNoDesc" ? input.args.slice(0, -1) : input.args; - if (hasRootVersionFlag(routingArgs)) return "legacy"; + if (hasRootVersionFlag(routingArgs)) return undefined; const commandPath = extractCommandPath(routingArgs); const completePath = @@ -87,7 +88,7 @@ export const resolveStackBackend = (input: { !STACK_BACKEND_COMMANDS.has(command) && !isFunctionsServePath(completePath) ) - return "legacy"; + return undefined; const enabled = yield* resolveExperimentalFeature({ feature: "stack", diff --git a/apps/cli/src/command-internal/stack-catalog-setup.ts b/apps/cli/src/command-internal/stack-catalog-setup.ts index 714e683ac5..f7c6710969 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.ts @@ -10,7 +10,7 @@ import { import { parseConnectionString } from "./db-config.parse.ts"; import type { DbConnectError } from "./db-connection.errors.ts"; import { DbConnection } from "./db-connection.service.ts"; -import { dbConnectionLayer } from "./db-connection.layer.ts"; +import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { applyDatabaseOverlay, type ApplyDatabaseOverlayInput, @@ -116,6 +116,7 @@ const applyCatalog = Effect.fn("StackCatalogSetup.apply")(function* ( const dbConn = yield* DbConnection; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + yield* Effect.annotateCurrentSpan("service.count", input.target.databaseServices.length); const runtimeCredentials = yield* input.target.database .credentials({ from: "runtime" }) .pipe(Effect.mapError(catalogError)); @@ -167,7 +168,7 @@ const applyCatalog = Effect.fn("StackCatalogSetup.apply")(function* ( vault: input.overlay.vault, webhooks: input.overlay.webhooks, announceRoles: input.overlay.announceRoles, - }); + }).pipe(Effect.withSpan("StackCatalogSetup.applyOverlay")); }), ); }); diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index c9a41e75f1..3009d58552 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -54,6 +54,7 @@ import { strToArr, } from "./local-config-values.ts"; import { generateAsymmetricGoJwt } from "./go-jwt.ts"; +import { recordOrioleDbTelemetry } from "./db-image.ts"; import { resolveRemoteJwks, resolveThirdPartyIssuerUrl, @@ -161,6 +162,135 @@ const envNestedPortOrConfigured = ( return typeof nested?.[key] === "number" ? configured : undefined; }; +/** A setting's `config.toml` key and the `SUPABASE_*` env var that overrides it. */ +export interface StackEndpointSetting { + readonly configPath: string; + readonly envVar: string; +} + +/** + * A port setting's `config.toml` key and env var, shared by `createCreations` and + * `stackEndpointSetting` so both report the same names; a new endpoint still needs an entry in + * `endpointSettingsByServiceEndpoint`. + */ +interface PortSetting extends StackEndpointSetting { + readonly section: string; + readonly nestedSection?: string; + readonly key: string; +} + +const DB_PORT: PortSetting = { + envVar: "SUPABASE_DB_PORT", + section: "db", + key: "port", + configPath: "db.port", +}; +const API_PORT: PortSetting = { + envVar: "SUPABASE_API_PORT", + section: "api", + key: "port", + configPath: "api.port", +}; +const STUDIO_PORT: PortSetting = { + envVar: "SUPABASE_STUDIO_PORT", + section: "studio", + key: "port", + configPath: "studio.port", +}; +const DB_POOLER_PORT: PortSetting = { + envVar: "SUPABASE_DB_POOLER_PORT", + section: "db", + nestedSection: "pooler", + key: "port", + configPath: "db.pooler.port", +}; +const LOCAL_SMTP_PORT: PortSetting = { + envVar: "SUPABASE_LOCAL_SMTP_PORT", + section: "local_smtp", + key: "port", + configPath: "local_smtp.port", +}; +const LOCAL_SMTP_SMTP_PORT: PortSetting = { + envVar: "SUPABASE_LOCAL_SMTP_SMTP_PORT", + section: "local_smtp", + key: "smtp_port", + configPath: "local_smtp.smtp_port", +}; +const LOCAL_SMTP_POP3_PORT: PortSetting = { + envVar: "SUPABASE_LOCAL_SMTP_POP3_PORT", + section: "local_smtp", + key: "pop3_port", + configPath: "local_smtp.pop3_port", +}; +const ANALYTICS_PORT: PortSetting = { + envVar: "SUPABASE_ANALYTICS_PORT", + section: "analytics", + key: "port", + configPath: "analytics.port", +}; +const EDGE_RUNTIME_INSPECTOR_PORT: PortSetting = { + envVar: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", + section: "edge_runtime", + key: "inspector_port", + configPath: "edge_runtime.inspector_port", +}; + +/** Resolves one `PortSetting` against the loaded document and env, picking the nested variant when needed. */ +const resolvePort = ( + setting: PortSetting, + document: Readonly> | undefined, + configured: number, + env: Readonly>, +): number | undefined => + setting.nestedSection === undefined + ? envPortOrConfigured(setting.envVar, document, setting.section, setting.key, configured, env) + : envNestedPortOrConfigured( + setting.envVar, + document, + setting.section, + setting.nestedSection, + setting.key, + configured, + env, + ); + +/** + * Maps a saved stack endpoint (service + endpoint name) to the `PortSetting` that controls it. + * An endpoint missing here (e.g. `pooler.http`, `realtime.rpc`) is always automatic. + */ +const endpointSettingsByServiceEndpoint: Readonly> = { + "database.sql": DB_PORT, + "pooler.sql": DB_POOLER_PORT, + "analytics.http": ANALYTICS_PORT, + "studio.http": STUDIO_PORT, + "mail.http": LOCAL_SMTP_PORT, + "mail.smtp": LOCAL_SMTP_SMTP_PORT, + "mail.pop3": LOCAL_SMTP_POP3_PORT, + "functions.inspector": EDGE_RUNTIME_INSPECTOR_PORT, + "rest.http": API_PORT, + "auth.http": API_PORT, + "realtime.http": API_PORT, + "storage.http": API_PORT, + "functions.http": API_PORT, +}; + +/** The config.toml key and env var override for a service endpoint, when the CLI exposes one. */ +export const stackEndpointSetting = ( + service: string, + endpoint: string, +): StackEndpointSetting | undefined => endpointSettingsByServiceEndpoint[`${service}.${endpoint}`]; + +/** + * `db.major_version`'s config key and `SUPABASE_DB_MAJOR_VERSION` override. Unlike the ports + * above, `envOverrideMajorVersion` (shared with `db-bootstrap` and the legacy local stack) hardcodes + * its own name/field, so there is no single call site to read this from without widening that + * shared helper's signature; the two literals here are kept in sync by hand. + */ +export const stackMajorVersionSetting: StackEndpointSetting = { + configPath: "db.major_version", + envVar: "SUPABASE_DB_MAJOR_VERSION", +}; + const authProviderNames = [ "apple", "azure", @@ -522,17 +652,6 @@ const resolveEffectiveCliConfig = ( env, ), port: resolvedPort("SUPABASE_ANALYTICS_PORT", analytics.port, "analytics.port", env), - ...(analytics.vector_port === undefined && - envOverride("SUPABASE_ANALYTICS_VECTOR_PORT", undefined, env) === undefined - ? {} - : { - vector_port: resolvedPort( - "SUPABASE_ANALYTICS_VECTOR_PORT", - analytics.vector_port ?? 0, - "analytics.vector_port", - env, - ), - }), backend: envOverrideAnalyticsBackend(analytics.backend, env), gcp_project_id: envOverride("SUPABASE_ANALYTICS_GCP_PROJECT_ID", analytics.gcp_project_id, env), gcp_project_number: envOverride( @@ -852,6 +971,10 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( }); }), ); + yield* recordOrioleDbTelemetry( + validatedConfig.db.orioledb_version, + validatedConfig.db.major_version, + ); const validationError = configValidationError(validatedConfig); if (validationError !== undefined) return yield* new StackConfigError({ message: validationError }); @@ -951,6 +1074,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( yield* resolveRemoteJwks(issuer).pipe( Effect.provide(FetchHttpClient.layer), Effect.mapError((cause) => new StackConfigError({ message: cause.message })), + Effect.withSpan("StackConfig.fetchRemoteJwks"), ), ); }); @@ -1064,68 +1188,51 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( }, catch: (cause) => new StackConfigError({ message: String(cause) }), }); - const dbPort = envPortOrConfigured( - "SUPABASE_DB_PORT", + const dbPort = resolvePort( + DB_PORT, document, - "db", - "port", validatedConfig.db.port, context.projectEnvValues, ); - const apiPort = envPortOrConfigured( - "SUPABASE_API_PORT", + const apiPort = resolvePort( + API_PORT, document, - "api", - "port", validatedConfig.api.port, context.projectEnvValues, ); - const studioPort = envPortOrConfigured( - "SUPABASE_STUDIO_PORT", + const studioPort = resolvePort( + STUDIO_PORT, document, - "studio", - "port", validatedConfig.studio.port, context.projectEnvValues, ); - const poolerPort = envNestedPortOrConfigured( - "SUPABASE_DB_POOLER_PORT", + const poolerPort = resolvePort( + DB_POOLER_PORT, document, - "db", - "pooler", - "port", validatedConfig.db.pooler.port, context.projectEnvValues, ); - const mailPort = envPortOrConfigured( - "SUPABASE_LOCAL_SMTP_PORT", + const mailPort = resolvePort( + LOCAL_SMTP_PORT, document, - "local_smtp", - "port", validatedConfig.local_smtp.port, context.projectEnvValues, ); - const mailSmtpPort = envPortOrConfigured( - "SUPABASE_LOCAL_SMTP_SMTP_PORT", + const mailSmtpPort = resolvePort( + LOCAL_SMTP_SMTP_PORT, document, - "local_smtp", - "smtp_port", validatedConfig.local_smtp.smtp_port ?? 0, context.projectEnvValues, ); - const mailPop3Port = envPortOrConfigured( - "SUPABASE_LOCAL_SMTP_POP3_PORT", + const mailPop3Port = resolvePort( + LOCAL_SMTP_POP3_PORT, document, - "local_smtp", - "pop3_port", validatedConfig.local_smtp.pop3_port ?? 0, context.projectEnvValues, ); - const analyticsPort = envPortOrConfigured( - "SUPABASE_ANALYTICS_PORT", + const analyticsPort = resolvePort( + ANALYTICS_PORT, document, - "analytics", - "port", validatedConfig.analytics.port, context.projectEnvValues, ); @@ -1250,26 +1357,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( } satisfies ServiceCreationType, ] : []), - ...(validatedConfig.analytics.enabled - ? [ - { - service: "vector" as const, - config: { apiKey: "api-key" }, - endpoints: { - http: endpoint( - envPortOrConfigured( - "SUPABASE_ANALYTICS_VECTOR_PORT", - document, - "analytics", - "vector_port", - validatedConfig.analytics.vector_port ?? 0, - context.projectEnvValues, - ), - ), - }, - } satisfies ServiceCreationType, - ] - : []), ...(validatedConfig.storage.image_transformation?.enabled === true ? [ { @@ -1295,11 +1382,9 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( endpoints: { http: endpoint(apiPort), inspector: endpoint( - envPortOrConfigured( - "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", + resolvePort( + EDGE_RUNTIME_INSPECTOR_PORT, document, - "edge_runtime", - "inspector_port", validatedConfig.edge_runtime.inspector_port, context.projectEnvValues, ), diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 1dddca074e..613bb47d10 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -20,7 +20,11 @@ import { initializeStackDatabase, projectCatalogOverlay, } from "./stack-bootstrap.ts"; -import { automaticRuntimeNotice, selectStackRuntime } from "./stack-runtime.ts"; +import { + automaticRuntimeNotice, + recordStackRuntimeTelemetry, + selectStackRuntime, +} from "./stack-runtime.ts"; import { Output } from "../shared/output/output.service.ts"; import type { PgConnInput } from "./db-connection.service.ts"; @@ -41,9 +45,12 @@ const databaseFor = (instances: StackInstances): DatabaseInstance | undefined => instances.find((instance): instance is DatabaseInstance => instance.service === "database"); const stackForProject = (api: StackApi["Service"], settings: Settings, path: Path.Path) => - api - .find({ stateRoot: stateRoot(settings, path), projectRoot: settings.workdir }) - .pipe(Effect.map(Option.getOrUndefined)); + api.find({ stateRoot: stateRoot(settings, path), projectRoot: settings.workdir }).pipe( + Effect.map(Option.getOrUndefined), + Effect.tap((found) => + found === undefined ? Effect.void : recordStackRuntimeTelemetry(found.definition.runtime), + ), + ); const openProjectStack = Effect.fn("StackLocalDatabase.openProject")(function* () { const api = yield* StackApi; diff --git a/apps/cli/src/command-internal/stack-log-events.ts b/apps/cli/src/command-internal/stack-log-events.ts new file mode 100644 index 0000000000..f8ef296b09 --- /dev/null +++ b/apps/cli/src/command-internal/stack-log-events.ts @@ -0,0 +1,37 @@ +import type { LogRecord, StackLogRecord } from "@supabase/stack/effect"; +import type { StreamEvent } from "../shared/output/types.ts"; + +/** Whether a record was read as history or followed live. */ +export type LogSource = "history" | "live"; + +/** The `log-entry` or `log-marker` event of a record. */ +export const logEvent = (record: StackLogRecord, source: LogSource): StreamEvent => { + const subject = { service: record.service, instance_id: record.instanceId }; + if (record.kind === "stdout" || record.kind === "stderr") + return { + type: "log-entry", + timestamp: record.timestamp, + source, + ...subject, + stream: record.kind, + line: record.text ?? "", + }; + return { + type: "log-marker", + timestamp: record.timestamp, + source, + ...subject, + kind: record.kind, + ...(record.stream === undefined ? {} : { stream: record.stream }), + ...(record.count === undefined ? {} : { count: record.count }), + }; +}; + +/** The text of a launch or lost marker, as `stack logs` prints it. */ +export const markerText = (record: LogRecord) => { + if (record.kind === "launch") + return record.launchId === undefined ? "--- launch ---" : `--- launch ${record.launchId} ---`; + if (record.count === undefined) return "--- older records were removed by retention ---"; + const unit = record.count === 1 ? "chunk" : "chunks"; + return `--- ${record.count} ${record.stream ?? "output"} ${unit} lost ---`; +}; diff --git a/apps/cli/src/command-internal/stack-runtime.ts b/apps/cli/src/command-internal/stack-runtime.ts index 86aa22ef6b..aa7fb4dcf3 100644 --- a/apps/cli/src/command-internal/stack-runtime.ts +++ b/apps/cli/src/command-internal/stack-runtime.ts @@ -8,10 +8,17 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; +import { PropStackRuntime } from "../shared/telemetry/event-catalog.ts"; +import { withChildTraceEnv, withProcessSpan } from "../shared/telemetry/spans.ts"; +import { recordCommandTelemetry } from "../telemetry/command-telemetry-attributes.ts"; /** Runtime that executes a local stack's services. */ export type StackRuntime = "native" | "docker" | "podman"; +/** Records the runtime of the stack the enclosing command operates on. */ +export const recordStackRuntimeTelemetry = (runtime: StackRuntime) => + recordCommandTelemetry({ [PropStackRuntime]: runtime }); + /** Raised when no reachable container engine exists or native is requested on an unsupported host. */ export class StackRuntimeSelectionError extends Data.TaggedError("StackRuntimeSelectionError")<{ readonly reason: "engine-unreachable" | "native-unsupported"; @@ -38,20 +45,30 @@ const engineReachable = ( spawner: ChildProcessSpawner["Service"], probe: (typeof engineProbes)[number], ) => - spawner - .exitCode( - ChildProcess.make(probe.runtime, probe.args, { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - forceKillAfter: "1 second", - }), - ) - .pipe( - Effect.timeout(PROBE_TIMEOUT), - Effect.map((exitCode) => exitCode === 0), - Effect.orElseSucceed(() => false), - ); + withProcessSpan( + "StackRuntime.probeEngine", + { + executable: probe.runtime, + argCount: probe.args.length, + subcommand: probe.args[0], + hasFallback: true, + }, + (traceEnv) => + spawner.exitCode( + ChildProcess.make( + probe.runtime, + probe.args, + withChildTraceEnv( + { stdin: "ignore", stdout: "ignore", stderr: "ignore", forceKillAfter: "1 second" }, + traceEnv, + ), + ), + ), + ).pipe( + Effect.timeout(PROBE_TIMEOUT), + Effect.map((exitCode) => exitCode === 0), + Effect.orElseSucceed(() => false), + ); /** * Notice for a new stack whose automatic selection skipped Docker, since that runtime is saved with @@ -97,4 +114,4 @@ export const selectStackRuntime = Effect.fn("StackRuntime.select")(function* ( message: `Neither Docker nor Podman is reachable, and native stacks are not supported on ${platform}/${arch}.`, suggestion: "Start Docker or Podman, then rerun the command.", }); -}); +}, Effect.tap(recordStackRuntimeTelemetry)); diff --git a/apps/cli/src/command-internal/stack-runtime.unit.test.ts b/apps/cli/src/command-internal/stack-runtime.unit.test.ts new file mode 100644 index 0000000000..79794d8306 --- /dev/null +++ b/apps/cli/src/command-internal/stack-runtime.unit.test.ts @@ -0,0 +1,26 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Ref } from "effect"; +import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; +import { + CommandTelemetryAttributes, + type CommandTelemetryAttributeValues, +} from "../telemetry/command-telemetry-attributes.ts"; +import { selectStackRuntime } from "./stack-runtime.ts"; + +describe("selectStackRuntime", () => { + it.effect("records the selected runtime on the command event", () => + Effect.gen(function* () { + const recorded = yield* Ref.make({}); + + const runtime = yield* selectStackRuntime("podman").pipe( + Effect.provideService(CommandTelemetryAttributes, { + record: (values) => Ref.update(recorded, (current) => ({ ...current, ...values })), + }), + ); + + expect(runtime).toBe("podman"); + expect((yield* Ref.get(recorded)).stack_runtime).toBe("podman"); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer))), + ); +}); diff --git a/apps/cli/src/command-internal/stack-shadow.integration.test.ts b/apps/cli/src/command-internal/stack-shadow.integration.test.ts index ca7dae1a4b..40b038279b 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -7,7 +7,7 @@ import { mockOutput } from "../../tests/helpers/mocks.ts"; import { mockCommandSettings } from "../../tests/helpers/command-mocks.ts"; import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts"; import { DbConnection } from "./db-connection.service.ts"; -import { dbConnectionLayer } from "./db-connection.layer.ts"; +import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { StackCatalogSetup, stackCatalogSetupLayer } from "./stack-catalog-setup.ts"; import { parseConnectionString } from "./db-config.parse.ts"; diff --git a/apps/cli/src/command-internal/stack-storage.native.integration.test.ts b/apps/cli/src/command-internal/stack-storage.native.integration.test.ts index 0abdee8627..15b735a500 100644 --- a/apps/cli/src/command-internal/stack-storage.native.integration.test.ts +++ b/apps/cli/src/command-internal/stack-storage.native.integration.test.ts @@ -4,7 +4,7 @@ import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { CommandPlatformApiFactory } from "../auth/command-platform-api-factory.service.ts"; -import { dbConnectionLayer } from "./db-connection.layer.ts"; +import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { stackCatalogSetupLayer } from "./stack-catalog-setup.ts"; import { classifyStorageCapability, diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index 9c5c430a62..5f5e1d6d36 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -337,9 +337,10 @@ export function gateStatusState( const { edgeRuntimeEnabled, storageS3ProtocolEnabled } = localState; const isExcluded = (id: string) => excluded.includes(id); - const kongEnabled = apiEnabled && !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); + // `start` runs Kong regardless of `api.enabled`, which gates only PostgREST. + const kongEnabled = !isExcluded(containerIds.kong) && !isExcluded(KONG_IMAGE_NAME); const postgrestEnabled = - kongEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); + apiEnabled && !isExcluded(containerIds.rest) && !isExcluded(POSTGREST_IMAGE_NAME); const studioEnabled = studioSectionEnabled && !isExcluded(containerIds.studio) && !isExcluded(STUDIO_IMAGE_NAME); const authEnabled = diff --git a/apps/cli/src/command-internal/status-values.unit.test.ts b/apps/cli/src/command-internal/status-values.unit.test.ts index 87e9167744..a5793494f8 100644 --- a/apps/cli/src/command-internal/status-values.unit.test.ts +++ b/apps/cli/src/command-internal/status-values.unit.test.ts @@ -40,7 +40,14 @@ describe("statusValues", () => { storage: { enabled: false }, edge_runtime: { enabled: false }, }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = statusValues( + config, + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ); expect(Object.keys(values)).toEqual(["DB_URL"]); expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); }); @@ -58,10 +65,14 @@ describe("statusValues", () => { expect(values.API_URL).toBeDefined(); }); - it("omits API_URL when api.enabled is false", () => { + it("keeps the Kong URLs but omits REST/GraphQL when api.enabled is false", () => { const config = baseConfig({ api: { enabled: false } }); const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); - expect(values.API_URL).toBeUndefined(); + expect(values.API_URL).toBeDefined(); + expect(values.FUNCTIONS_URL).toBeDefined(); + expect(values.MCP_URL).toBeDefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.GRAPHQL_URL).toBeUndefined(); }); it("omits API_URL when the kong container id is excluded", () => { @@ -89,8 +100,14 @@ describe("statusValues", () => { }); it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { - const config = baseConfig({ api: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ); expect(values.REST_URL).toBeUndefined(); expect(values.GRAPHQL_URL).toBeUndefined(); }); @@ -157,8 +174,14 @@ describe("statusValues", () => { }); it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { - const config = baseConfig({ api: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -245,8 +268,14 @@ describe("statusValues", () => { }); it("omits MCP_URL when kong is disabled", () => { - const config = baseConfig({ api: { enabled: false } }); - const { values } = statusValues(config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR); + const { values } = statusValues( + baseConfig(), + CONTAINER_IDS, + HOSTNAME, + [CONTAINER_IDS.kong], + NO_OVERRIDES, + WORKDIR, + ); expect(values.MCP_URL).toBeUndefined(); }); @@ -432,7 +461,7 @@ describe("statusValues", () => { expect(values.REST_URL).toBeDefined(); }); - it("omits API_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { + it("omits REST_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { const { values } = statusValues( baseConfig(), CONTAINER_IDS, @@ -442,7 +471,8 @@ describe("statusValues", () => { WORKDIR, { SUPABASE_API_ENABLED: "false" }, ); - expect(values.API_URL).toBeUndefined(); + expect(values.REST_URL).toBeUndefined(); + expect(values.API_URL).toBeDefined(); }); it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { diff --git a/apps/cli/src/command-internal/test-db.layers.ts b/apps/cli/src/command-internal/test-db.layers.ts index 1da3775110..2ba9df6c0f 100644 --- a/apps/cli/src/command-internal/test-db.layers.ts +++ b/apps/cli/src/command-internal/test-db.layers.ts @@ -2,7 +2,7 @@ import { Layer } from "effect"; import { commandSettingsLayer } from "../config/command-settings.layer.ts"; import { dbConfigLayer } from "./db-config.layer.ts"; -import { dbConnectionLayer } from "./db-connection.layer.ts"; +import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { dockerRunLayer } from "./docker-run.layer.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; import { debugLoggerLayer } from "./debug-logger.layer.ts"; diff --git a/apps/cli/src/command-internal/test-db.native.integration.test.ts b/apps/cli/src/command-internal/test-db.native.integration.test.ts index d8f52694fb..6377dabdbe 100644 --- a/apps/cli/src/command-internal/test-db.native.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.native.integration.test.ts @@ -13,7 +13,7 @@ import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; -import { dbConnectionLayer } from "./db-connection.layer.ts"; +import { dbConnectionLayer } from "./db-connection.sql-pg.layer.ts"; import { DbConnection } from "./db-connection.service.ts"; import { parseConnectionString } from "./db-config.parse.ts"; import { DebugFlag, DnsResolverFlag, NetworkIdFlag } from "./global-flags.ts"; diff --git a/apps/cli/src/command-internal/upgrade-notice.integration.test.ts b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts new file mode 100644 index 0000000000..75dbb060d4 --- /dev/null +++ b/apps/cli/src/command-internal/upgrade-notice.integration.test.ts @@ -0,0 +1,94 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stripVTControlCharacters } from "node:util"; + +import { Effect } from "effect"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { upgradeNoticeHook } from "./upgrade-notice.ts"; + +describe("upgrade notice user-level cache", () => { + let root: string; + let workdir: string; + let supabaseHome: string; + let cacheFile: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-home-")); + workdir = join(root, "outside-project"); + supabaseHome = join(root, "supabase-home"); + cacheFile = join(supabaseHome, "cli-latest"); + mkdirSync(workdir); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function writeUserCache(tag: string, ageMs: number) { + mkdirSync(supabaseHome, { recursive: true }); + writeFileSync(cacheFile, tag); + const then = new Date(Date.now() - ageMs); + utimesSync(cacheFile, then, then); + } + + /** Runs the production hook with real `SUPABASE_HOME` resolution and a stub release fetch. */ + async function runHook(latestTag: string) { + let fetchCalls = 0; + const written: Array = []; + const realWrite = process.stderr.write.bind(process.stderr); + const realEnv = { + SUPABASE_HOME: process.env["SUPABASE_HOME"], + SUPABASE_NO_UPDATE_NOTIFIER: process.env["SUPABASE_NO_UPDATE_NOTIFIER"], + }; + process.env["SUPABASE_HOME"] = supabaseHome; + process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = "0"; + process.stderr.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + try { + await Effect.runPromise( + upgradeNoticeHook( + ["projects", "list"], + { + cleanShowHelp: false, + delegatedToGo: false, + workingDirectory: workdir, + isValueTakingFlagToken: () => false, + }, + () => { + fetchCalls += 1; + return Promise.resolve(latestTag); + }, + ), + ); + } finally { + process.stderr.write = realWrite; + for (const [key, value] of Object.entries(realEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + return { fetchCalls, stderr: stripVTControlCharacters(written.join("")) }; + } + + it("serves a fresh SUPABASE_HOME cache outside a project without fetching", async () => { + writeUserCache("v99.99.99", 60_000); + + const { fetchCalls, stderr } = await runHook("v99.99.100"); + + expect(fetchCalls).toBe(0); + expect(stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); + }); + + it("refetches a stale SUPABASE_HOME cache and records the new tag", async () => { + writeUserCache("v2.100.0", 11 * 60 * 60 * 1000); + + const { fetchCalls } = await runHook("v99.99.100"); + + expect(fetchCalls).toBe(1); + expect(readFileSync(cacheFile, "utf8")).toBe("v99.99.100"); + }); +}); diff --git a/apps/cli/src/command-internal/upgrade-notice.ts b/apps/cli/src/command-internal/upgrade-notice.ts index c9cdb271cc..7c3d1509c4 100644 --- a/apps/cli/src/command-internal/upgrade-notice.ts +++ b/apps/cli/src/command-internal/upgrade-notice.ts @@ -1,12 +1,14 @@ /** * Post-command upgrade notice: checks GitHub's latest release against the * running version and prints a notice to stderr, honoring - * `SUPABASE_NO_UPDATE_NOTIFIER`. A failed fetch writes an empty cache as an - * offline backoff. + * `SUPABASE_NO_UPDATE_NOTIFIER`. The tag is cached in the project's + * `supabase/.temp/cli-latest`, else `/cli-latest`. A failed + * fetch writes an empty cache as an offline backoff. */ import { lstat, mkdir, open, readFile } from "node:fs/promises"; import { constants as fsConstants, existsSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { stripVTControlCharacters } from "node:util"; @@ -14,11 +16,11 @@ import { Effect } from "effect"; import { hasRootHelpOrVersionFlag, - hasRootVersionFlag, lastGlobalFlagValue, rootFlagTokens, } from "../shared/cli/run.ts"; import { CLI_UPGRADE_GUIDE_URL, CLI_VERSION, parseSemver } from "../shared/cli/version.ts"; +import { resolveSupabaseHome } from "../shared/config/supabase-home.ts"; import { bold, yellow } from "./colors.ts"; import { parseDotEnv } from "./dotenv.ts"; import { candidateDotenvFilenames } from "./project-environment.ts"; @@ -26,7 +28,7 @@ import { candidateDotenvFilenames } from "./project-environment.ts"; const LATEST_RELEASE_URL = "https://api.github.com/repos/supabase/cli/releases/latest"; const CACHE_TTL_MS = 10 * 60 * 60 * 1000; /** Bounds this pre-exit hook's latency. */ -const FETCH_TIMEOUT_MS = 3000; +const FETCH_TIMEOUT_MS = 800; /** Recognized "true" spellings; anything else, including garbage, leaves the notifier on. */ const PARSE_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); @@ -214,12 +216,23 @@ export interface UpgradeNoticeDeps { readonly cwd: string; readonly resolvedCwd?: string; readonly currentVersion: string; + /** The cache directory when `/supabase` is not a real directory. */ + readonly supabaseHome: string; readonly now: () => number; readonly fetchLatestTag: () => Promise; readonly writeStderr: (text: string) => void; } -export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise { +/** The release-tag cache a check used, recorded on its trace span. */ +export interface UpgradeCheckOutcome { + readonly cache: "project" | "user" | "disabled"; + readonly cacheFresh: boolean; +} + +/** Resolves to `undefined` when the notifier is opted out. */ +export async function runUpgradeNotice( + deps: UpgradeNoticeDeps, +): Promise { if (updateNotifierDisabled(deps.env["SUPABASE_NO_UPDATE_NOTIFIER"])) return; // `--help`/`--version` and a bare group's clean ShowHelp resolve the cache @@ -240,8 +253,10 @@ export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise { if (updateNotifierDisabled(effectiveEnv("SUPABASE_NO_UPDATE_NOTIFIER"))) return; const debug = debugEnabled(deps, builtin, effectiveEnv("SUPABASE_DEBUG")); const supabaseDir = join(base, "supabase"); - const tempDir = join(supabaseDir, ".temp"); - const cacheFile = join(tempDir, "cli-latest"); + const inProject = (await lstat(supabaseDir).catch(() => undefined))?.isDirectory() === true; + // `resolve` drops a trailing `/` or `/.`, which would make `lstat` follow a symlinked home. + const cacheDir = inProject ? join(supabaseDir, ".temp") : resolve(deps.supabaseHome); + const cacheFile = join(cacheDir, "cli-latest"); // A hostile checkout can commit a symlink at any level of this well-known // path to clobber an arbitrary user-writable file (CWE-59), so a symlink @@ -250,29 +265,25 @@ export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise { // `writeCacheFileNoFollow` for the write-time guarantee. const cacheLstat = await lstat(cacheFile).catch(() => undefined); const cachePathIsSafe = - cacheLstat?.isSymbolicLink() !== true && - (await isRealDirOrAbsent(supabaseDir)) && - (await isRealDirOrAbsent(tempDir)); + cacheLstat?.isSymbolicLink() !== true && (await isRealDirOrAbsent(cacheDir)); - // A subcommand's own `--version` must not bypass the cache. - const forceFetch = hasRootVersionFlag(deps.args, deps.isValueTakingFlagToken); const cacheFresh = cachePathIsSafe && cacheLstat !== undefined && deps.now() <= cacheLstat.mtime.getTime() + CACHE_TTL_MS; let latestTag: string; - if (forceFetch || !cacheFresh) { + if (!cacheFresh) { let notifyError: Error | undefined; latestTag = await deps.fetchLatestTag().catch((error: unknown) => { notifyError = new Error(`Failed to fetch latest release: ${errorMessage(error)}`); return ""; }); - // The offline-backoff write's result overwrites the fetch error when - // inside a project, so a successful write silences the diagnostic; only a - // missing project (no backoff) or a failing write leaves an error to log. - if (cachePathIsSafe && existsSync(supabaseDir)) { - notifyError = await mkdir(tempDir, { recursive: true, mode: 0o755 }).then( + // The offline-backoff write's result overwrites the fetch error, so a + // successful write silences the diagnostic; only an unsafe cache path (no + // backoff) or a failing write leaves an error to log. + if (cachePathIsSafe) { + notifyError = await mkdir(cacheDir, { recursive: true, mode: 0o755 }).then( () => writeCacheFileNoFollow(cacheFile, latestTag).then( () => undefined, @@ -299,6 +310,7 @@ export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise { if (isNewerCliVersion(latestTag, deps.currentVersion)) { deps.writeStderr(`${formatUpgradeNotice(latestTag, deps.currentVersion)}\n`); } + return { cache: cachePathIsSafe ? (inProject ? "project" : "user") : "disabled", cacheFresh }; } async function fetchLatestReleaseTag(): Promise { @@ -331,22 +343,39 @@ export const upgradeNoticeHook = ( readonly workingDirectory?: string; readonly isValueTakingFlagToken: (token: string) => boolean; }, + fetchLatestTag: () => Promise = fetchLatestReleaseTag, ): Effect.Effect => info.delegatedToGo ? Effect.void - : Effect.promise(() => - runUpgradeNotice({ - env: process.env, - args, - cleanShowHelp: info.cleanShowHelp, - isValueTakingFlagToken: info.isValueTakingFlagToken, - cwd: process.cwd(), - resolvedCwd: info.workingDirectory, - currentVersion: CLI_VERSION, - now: Date.now, - fetchLatestTag: fetchLatestReleaseTag, - writeStderr: (text) => { - process.stderr.write(text); - }, - }), - ).pipe(Effect.ignoreCause); + : Effect.gen(function* () { + const context = yield* Effect.context(); + const outcome = yield* Effect.promise(() => + runUpgradeNotice({ + env: process.env, + args, + cleanShowHelp: info.cleanShowHelp, + isValueTakingFlagToken: info.isValueTakingFlagToken, + cwd: process.cwd(), + resolvedCwd: info.workingDirectory, + currentVersion: CLI_VERSION, + supabaseHome: resolveSupabaseHome({ join }, process.env, homedir()), + now: Date.now, + // Runs under the check span's context so the fetch is its child span. + fetchLatestTag: () => + Effect.runPromiseWith(context)( + Effect.tryPromise({ try: fetchLatestTag, catch: (error) => error }).pipe( + Effect.withSpan("UpgradeNotice.fetch"), + ), + ), + writeStderr: (text) => { + process.stderr.write(text); + }, + }), + ); + if (outcome !== undefined) { + yield* Effect.annotateCurrentSpan({ + "cache.location": outcome.cache, + "cache.hit": outcome.cacheFresh, + }); + } + }).pipe(Effect.withSpan("UpgradeNotice.check"), Effect.ignoreCause); diff --git a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts index 62c3243b89..89458d139b 100644 --- a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts @@ -16,7 +16,7 @@ import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; +import { Effect, Option, Tracer } from "effect"; import { type UpgradeNoticeDeps, @@ -109,6 +109,11 @@ describe("runUpgradeNotice", () => { utimesSync(cacheFile, then, then); } } + // A symlinked SUPABASE_HOME disables the user-level cache, so runs outside a + // project record no backoff. + const supabaseHome = join(workdir, "home"); + mkdirSync(join(workdir, "home-target")); + symlinkSync(join(workdir, "home-target"), supabaseHome); let fetchCalls = 0; const stderr: Array = []; const deps: UpgradeNoticeDeps = { @@ -116,6 +121,7 @@ describe("runUpgradeNotice", () => { args: opts.args ?? ["db", "start"], cwd: workdir, currentVersion: opts.currentVersion ?? "2.113.0", + supabaseHome, now: Date.now, fetchLatestTag: () => { fetchCalls += 1; @@ -191,10 +197,11 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("--version forces a fetch through a fresh cache", async () => { + it("--version reads a fresh cache instead of fetching", async () => { const ctx = setup({ args: ["--version"], cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); await runUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); ctx.cleanup(); }); @@ -248,7 +255,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("a failed fetch surfaces its error under --debug when there is no project to back off in", async () => { + it("a failed fetch surfaces its error under --debug when no cache can record the backoff", async () => { const ctx = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug"] }); await runUpgradeNotice(ctx.deps); expect(ctx.stderr).toContain("Failed to fetch latest release"); @@ -339,8 +346,8 @@ describe("runUpgradeNotice", () => { }); it("a false root version flag before a leaf runs the normal path, workdir included", async () => { - // `--version=false ` still marks the flag as set, forcing the fetch; - // only the built-in classification must not trigger. + // `--version=false ` is not the version built-in, so the cache + // resolves under `--workdir`. const ctx = setup({ project: false }); const flagged = join(workdir, "flagged"); mkdirSync(join(flagged, "supabase"), { recursive: true }); @@ -399,14 +406,6 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("outside a project the notice still prints but nothing is cached", async () => { - const ctx = setup({ project: false }); - await runUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("v2.114.0"); - expect(() => readFileSync(ctx.cachePath, "utf8")).toThrow(); - ctx.cleanup(); - }); - it("resolves the cache under --workdir, then SUPABASE_WORKDIR, ahead of the cwd walk", async () => { const ctx = setup({ project: false }); const flagDir = join(workdir, "flag-project"); @@ -614,6 +613,16 @@ describe("runUpgradeNotice", () => { }, ); + it.each(["", "/", "/."])( + "never writes through a symlinked SUPABASE_HOME spelled with suffix %j", + async (suffix) => { + const ctx = setup({ project: false }); + await runUpgradeNotice({ ...ctx.deps, supabaseHome: `${ctx.deps.supabaseHome}${suffix}` }); + expect(readdirSync(join(workdir, "home-target"))).toEqual([]); + ctx.cleanup(); + }, + ); + it("validates exact cache bytes and rejects embedded escape bytes", async () => { for (const cacheContent of ["v2.115.0\n", "v2.115.0\r\n", "v2.115.0\u001b[31mboo"]) { const ctx = setup({ cacheContent, cacheAgeMs: 60_000 }); @@ -631,13 +640,21 @@ describe("runUpgradeNotice", () => { * to one command would break the moment it's ported. */ describe("upgradeNoticeHook", () => { - async function stderrFromHook(delegatedToGo: boolean): Promise { + async function runHook(opts: { readonly delegatedToGo?: boolean; readonly cached?: boolean }) { const workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-hook-")); mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); - // Fresh cache: the hook reads it instead of fetching, so this never touches - // the network. - writeFileSync(join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); + if (opts.cached !== false) { + writeFileSync(join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); + } + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); const written: Array = []; const realWrite = process.stderr.write.bind(process.stderr); @@ -650,12 +667,16 @@ describe("upgradeNoticeHook", () => { try { await Effect.runPromise( - upgradeNoticeHook(["db", "branch", "list"], { - cleanShowHelp: false, - delegatedToGo, - workingDirectory: workdir, - isValueTakingFlagToken: () => false, - }), + upgradeNoticeHook( + ["db", "branch", "list"], + { + cleanShowHelp: false, + delegatedToGo: opts.delegatedToGo === true, + workingDirectory: workdir, + isValueTakingFlagToken: () => false, + }, + () => Promise.resolve("v99.99.99"), + ).pipe(Effect.withTracer(tracer), Effect.withTracerEnabled(true)), ); } finally { process.stderr.write = realWrite; @@ -663,16 +684,37 @@ describe("upgradeNoticeHook", () => { else process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = realOptOut; rmSync(workdir, { recursive: true, force: true }); } - return stripVTControlCharacters(written.join("")); + const span = (name: string) => spans.find((candidate) => candidate.name === name); + return { stderr: stripVTControlCharacters(written.join("")), span }; } it("prints the notice for a natively handled command", async () => { - expect(await stderrFromHook(false)).toContain( + expect((await runHook({})).stderr).toContain( "A new version of Supabase CLI is available: v99.99.99", ); }); it("stays silent when the run delegated to Go, which printed its own notice", async () => { - expect(await stderrFromHook(true)).toBe(""); + expect((await runHook({ delegatedToGo: true })).stderr).toBe(""); + }); + + it("traces a cache hit on the check span without a fetch span", async () => { + const { span } = await runHook({}); + expect(Object.fromEntries(span("UpgradeNotice.check")!.attributes)).toEqual({ + "cache.location": "project", + "cache.hit": true, + }); + expect(span("UpgradeNotice.fetch")).toBeUndefined(); + }); + + it("traces a cache miss with the release fetch as a child of the check span", async () => { + const { span } = await runHook({ cached: false }); + const check = span("UpgradeNotice.check")!; + expect(Object.fromEntries(check.attributes)).toEqual({ + "cache.location": "project", + "cache.hit": false, + }); + const parent = span("UpgradeNotice.fetch")!.parent; + expect(Option.isSome(parent) ? parent.value.spanId : undefined).toBe(check.spanId); }); }); diff --git a/apps/cli/src/command-internal/upgrade-suggest.ts b/apps/cli/src/command-internal/upgrade-suggest.ts index d370386cc1..345b95182d 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.ts @@ -80,7 +80,7 @@ export const gateMapError = * `linked-project-cache.layer.ts`). Returns whether the feature was * confirmed plan-gated. */ -export const suggestUpgrade = Effect.fnUntraced(function* (opts: { +export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: { readonly projectRef: string; /** * Entitlements-fallback key. Omit for envelope-only sites: the domains @@ -116,6 +116,7 @@ export const suggestUpgrade = Effect.fnUntraced(function* (opts: { const analytics = yield* Analytics; const cliSettings = yield* CommandSettings; const httpClient = yield* HttpClient.HttpClient; + yield* Effect.annotateCurrentSpan("upgrade.gated", false); let gate: | { readonly billingUrl: string; readonly feature: string; readonly orgSlug: string } @@ -203,6 +204,7 @@ export const suggestUpgrade = Effect.fnUntraced(function* (opts: { }; } + yield* Effect.annotateCurrentSpan("upgrade.gated", true); const suggestion = `Your organization does not have access to this feature. Upgrade your plan: ${styleText("bold", gate.billingUrl)}`; if (output.format === "text") { diff --git a/apps/cli/src/commands/backups/list/list.handler.ts b/apps/cli/src/commands/backups/list/list.handler.ts index 7c6289d062..adfaf43399 100644 --- a/apps/cli/src/commands/backups/list/list.handler.ts +++ b/apps/cli/src/commands/backups/list/list.handler.ts @@ -22,7 +22,7 @@ import { } from "../../../command-internal/go-struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { formatTimestamp } from "../../../command-internal/timestamp.format.ts"; -import { formatRegion } from "../backups.format.ts"; +import { formatRegion } from "../../../command-internal/region.format.ts"; import type { BackupsListFlags } from "./list.command.ts"; /** Struct shape for `-o yaml|toml` encoding of the backups response. */ diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 44d8752802..771a43daa0 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -129,6 +129,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ); starter = allTemplates.find((t) => t.name === choice) ?? SCRATCH_TEMPLATE; } + yield* Effect.annotateCurrentSpan("template.name", starter.name); const experimentalStack = starter.url.length === 0 @@ -181,7 +182,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( withVscodeSettings: false, withIntellijSettings: false, experimentalStack, - }); + }).pipe(Effect.withSpan("bootstrap.initProject")); } yield* ensureLogin({ openBrowser: tty.stdinIsTty }); @@ -205,6 +206,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( }); const projectRef = created.ref; createdRef = projectRef.length > 0 ? projectRef : undefined; + yield* Effect.annotateCurrentSpan("project.ref", projectRef); // A fresh notifier per retry block keeps this step's "Retry (n/8)" counter independent of // the health-poll and push retries below. @@ -212,14 +214,18 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const keys = yield* Effect.gen(function* () { if (isText) yield* output.raw("Linking project...\n", "stderr"); return yield* getProjectApiKeys(projectRef); - }).pipe(apiKeysNotify, Effect.retry(retry)); + }).pipe(apiKeysNotify, Effect.retry(retry), Effect.withSpan("bootstrap.fetchApiKeys")); const { anon } = extractServiceKeys(keys); // Config load must run before link/health/`.env` steps: a malformed config.toml aborts here // rather than after side effects start. - const projectEnv = yield* loadProjectEnv(fs, path, workdir); - const pushYes = yield* resolveYesWithProjectEnv(projectEnv); - const toml = yield* checkDbToml(fs, path, workdir, projectRef); + const { pushYes, toml } = yield* Effect.gen(function* () { + const projectEnv = yield* loadProjectEnv(fs, path, workdir); + return { + pushYes: yield* resolveYesWithProjectEnv(projectEnv), + toml: yield* checkDbToml(fs, path, workdir, projectRef), + }; + }).pipe(Effect.withSpan("bootstrap.loadConfig")); if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } @@ -247,7 +253,12 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( }); } } - }).pipe(healthNotify, Effect.retry(retry)); + }).pipe( + Effect.withTracerEnabled(false), + healthNotify, + Effect.retry(retry), + Effect.withSpan("bootstrap.waitHealthy"), + ); // Uses a naive direct-host db config with no IPv6/pooler fallback, since `.env` is written // for reference only and, unlike the push connection below, is never used to actually connect. @@ -281,6 +292,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ); }), ), + Effect.withSpan("bootstrap.writeDotEnv"), ); // `resolveLinkedConn` doesn't attach a suggestionContext like the full resolver does; build @@ -323,7 +335,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( toml, yes: pushYes, emitStructuredResult: false, - }).pipe(pushNotify, Effect.retry(retry)); + }).pipe(pushNotify, Effect.retry(retry), Effect.withSpan("bootstrap.pushMigrations")); if (isText) { const suggestion = suggestAppStart(path, runtimeInfo.cwd, workdir, starter.start, aqua); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.layers.ts b/apps/cli/src/commands/bootstrap/bootstrap.layers.ts index f59a2e1746..c1c35c81f6 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.ts @@ -6,7 +6,7 @@ import { commandPlatformApiFactoryFromApiLayer } from "../../auth/command-platfo import { commandPlatformApiLayer } from "../../auth/command-platform-api.layer.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; import { projectRefLayer } from "../../config/project-ref.layer.ts"; -import { dbConnectionLayer } from "../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; import { linkedProjectCacheLayer } from "../../telemetry/linked-project-cache.layer.ts"; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.retry.ts b/apps/cli/src/commands/bootstrap/bootstrap.retry.ts index 773901111f..13e65cf74b 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.retry.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.retry.ts @@ -40,6 +40,7 @@ export const bootstrapRetryNotify = () => { Effect.tapError((error) => Effect.gen(function* () { failureCount += 1; + yield* Effect.annotateCurrentSpan("retry.failure_count", failureCount); // No notify on the final, exhausted attempt. if (failureCount > BOOTSTRAP_MAX_RETRIES) return; const toStderr = failureCount * 3 > BOOTSTRAP_MAX_RETRIES; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.templates.ts b/apps/cli/src/commands/bootstrap/bootstrap.templates.ts index a2c6353156..c789f0d65f 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.templates.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.templates.ts @@ -136,8 +136,10 @@ export const templateServiceLayer = Layer.effect( ), ); const samples = (parsed as { samples?: ReadonlyArray }).samples ?? []; - return samples.filter(isStarterTemplate); - }); + const templates = samples.filter(isStarterTemplate); + yield* Effect.annotateCurrentSpan("template.count", templates.length); + return templates; + }).pipe(Effect.withSpan("TemplateService.listSamples")); const downloadFile = (localPath: string, remoteUrl: string) => Effect.gen(function* () { @@ -152,85 +154,84 @@ export const templateServiceLayer = Layer.effect( yield* fs.writeFile(localPath, bytes); }).pipe(Effect.catch(mapDownloadError)); - const download = (templateUrl: string, targetDir: string) => - Effect.gen(function* () { - // e.g. https://github.com/supabase/supabase/tree/master/examples/user-management - const parsed = new URL(templateUrl); - const parts = parsed.pathname.split("/"); - const owner = parts[1] ?? ""; - const repo = parts[2] ?? ""; - const ref = parts[4] ?? ""; - const root = parts.slice(5).join("/"); - - const downloads: Array<{ readonly localPath: string; readonly remoteUrl: string }> = []; - const queue: Array = [root]; - while (queue.length > 0) { - const contentPath = queue.shift() ?? ""; - const response = yield* httpClient.execute( - contentsRequest(owner, repo, contentPath, ref), - ); - if (response.status !== 200) { - const body = sanitizeErrorBody( - yield* response.text.pipe(Effect.orElseSucceed(() => "")), - ); - return yield* new BootstrapTemplateDownloadError({ - message: `failed to download template: status ${response.status}: ${body}`, - }); - } - const payload = yield* response.json; - if (!Array.isArray(payload)) { - return yield* new BootstrapTemplateDownloadError({ - message: `failed to download template: expected a directory listing for ${contentPath}`, - }); - } - const listing = payload as ReadonlyArray; - for (const entry of listing) { - const entryPath = entry.path ?? ""; - if (entry.type === "file") { - // Strip `` on a path-segment boundary so a sibling directory that merely - // shares the prefix (e.g. `examples/app-2` under `root="examples/app"`) is never - // mis-sliced. - const relative = - root === "" - ? entryPath - : entryPath === root - ? "" - : entryPath.startsWith(`${root}/`) - ? entryPath.slice(root.length + 1) - : entryPath; - const localPath = path.join(targetDir, ...relative.split("/").filter(Boolean)); - // Defence-in-depth: reject a malicious `path` (e.g. `../../etc/x`) - // that would escape the target directory. - const resolvedTarget = path.resolve(targetDir); - const resolvedLocal = path.resolve(localPath); - if ( - resolvedLocal !== resolvedTarget && - !resolvedLocal.startsWith(resolvedTarget + path.sep) - ) { - return yield* new BootstrapTemplateDownloadError({ - message: `failed to download template: entry escapes target directory: ${entryPath}`, - }); - } - // GitHub returns a null `download_url` for files over 1 MB and submodules; without - // this guard, the `?? ""` fallback would issue `GET ""` with a confusing error. - if (entry.download_url == null || entry.download_url.length === 0) { - return yield* new BootstrapTemplateDownloadError({ - message: `failed to download template: unsupported entry (no download URL): ${entryPath}`, - }); - } - downloads.push({ localPath, remoteUrl: entry.download_url }); - } else if (entry.type === "dir") { - queue.push(entryPath); - } else { - yield* output.raw(`Ignoring ${entry.type}: ${entryPath}\n`, "stderr"); + const download = Effect.fn("TemplateService.download")(function* ( + templateUrl: string, + targetDir: string, + ) { + // e.g. https://github.com/supabase/supabase/tree/master/examples/user-management + const parsed = new URL(templateUrl); + const parts = parsed.pathname.split("/"); + const owner = parts[1] ?? ""; + const repo = parts[2] ?? ""; + const ref = parts[4] ?? ""; + const root = parts.slice(5).join("/"); + + const downloads: Array<{ readonly localPath: string; readonly remoteUrl: string }> = []; + const queue: Array = [root]; + while (queue.length > 0) { + const contentPath = queue.shift() ?? ""; + const response = yield* httpClient.execute(contentsRequest(owner, repo, contentPath, ref)); + if (response.status !== 200) { + const body = sanitizeErrorBody(yield* response.text.pipe(Effect.orElseSucceed(() => ""))); + return yield* new BootstrapTemplateDownloadError({ + message: `failed to download template: status ${response.status}: ${body}`, + }); + } + const payload = yield* response.json; + if (!Array.isArray(payload)) { + return yield* new BootstrapTemplateDownloadError({ + message: `failed to download template: expected a directory listing for ${contentPath}`, + }); + } + const listing = payload as ReadonlyArray; + for (const entry of listing) { + const entryPath = entry.path ?? ""; + if (entry.type === "file") { + // Strip `` on a path-segment boundary so a sibling directory that merely + // shares the prefix (e.g. `examples/app-2` under `root="examples/app"`) is never + // mis-sliced. + const relative = + root === "" + ? entryPath + : entryPath === root + ? "" + : entryPath.startsWith(`${root}/`) + ? entryPath.slice(root.length + 1) + : entryPath; + const localPath = path.join(targetDir, ...relative.split("/").filter(Boolean)); + // Defence-in-depth: reject a malicious `path` (e.g. `../../etc/x`) + // that would escape the target directory. + const resolvedTarget = path.resolve(targetDir); + const resolvedLocal = path.resolve(localPath); + if ( + resolvedLocal !== resolvedTarget && + !resolvedLocal.startsWith(resolvedTarget + path.sep) + ) { + return yield* new BootstrapTemplateDownloadError({ + message: `failed to download template: entry escapes target directory: ${entryPath}`, + }); + } + // GitHub returns a null `download_url` for files over 1 MB and submodules; without + // this guard, the `?? ""` fallback would issue `GET ""` with a confusing error. + if (entry.download_url == null || entry.download_url.length === 0) { + return yield* new BootstrapTemplateDownloadError({ + message: `failed to download template: unsupported entry (no download URL): ${entryPath}`, + }); } + downloads.push({ localPath, remoteUrl: entry.download_url }); + } else if (entry.type === "dir") { + queue.push(entryPath); + } else { + yield* output.raw(`Ignoring ${entry.type}: ${entryPath}\n`, "stderr"); } } + } - yield* Effect.forEach(downloads, (job) => downloadFile(job.localPath, job.remoteUrl), { - concurrency: DOWNLOAD_CONCURRENCY, - }); - }).pipe(Effect.catch(mapDownloadError)); + yield* Effect.annotateCurrentSpan("file.count", downloads.length); + yield* Effect.forEach(downloads, (job) => downloadFile(job.localPath, job.remoteUrl), { + concurrency: DOWNLOAD_CONCURRENCY, + }); + }, Effect.catch(mapDownloadError)); return { listSamples, download }; }), diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index c736a4a9f5..3ad0354670 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -81,6 +81,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra // holds the branch's own ref, and the platform 403s on that ref for every branches-management // endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { const creating = diff --git a/apps/cli/src/commands/branches/delete/delete.handler.ts b/apps/cli/src/commands/branches/delete/delete.handler.ts index c9c22c378f..b48ced489e 100644 --- a/apps/cli/src/commands/branches/delete/delete.handler.ts +++ b/apps/cli/src/commands/branches/delete/delete.handler.ts @@ -35,6 +35,7 @@ export const branchesDelete = Effect.fn("branches.delete")(function* (flags: Bra // holds the branch's own ref, and the platform 403s on that ref for every branches-management // endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { const branchInput = yield* promptBranchId(flags.name, ref); diff --git a/apps/cli/src/commands/branches/disable/disable.handler.ts b/apps/cli/src/commands/branches/disable/disable.handler.ts index 3ee6f6e07a..0ed631772d 100644 --- a/apps/cli/src/commands/branches/disable/disable.handler.ts +++ b/apps/cli/src/commands/branches/disable/disable.handler.ts @@ -31,6 +31,7 @@ export const branchesDisable = Effect.fn("branches.disable")(function* ( // `supabase/.temp/project-ref` holds the branch's own ref, and the platform // 403s on that ref for every branches-management endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { const disabling = diff --git a/apps/cli/src/commands/branches/get/get.handler.ts b/apps/cli/src/commands/branches/get/get.handler.ts index 8ba167a3fb..f6d436fd20 100644 --- a/apps/cli/src/commands/branches/get/get.handler.ts +++ b/apps/cli/src/commands/branches/get/get.handler.ts @@ -86,6 +86,7 @@ export const branchesGet = Effect.fn("branches.get")(function* (flags: BranchesG // `branches` is parent-scoped: after `supabase link `, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { const branchInput = yield* promptBranchId(flags.name, ref); diff --git a/apps/cli/src/commands/branches/list/list.handler.ts b/apps/cli/src/commands/branches/list/list.handler.ts index 3e3ddbc0d5..cb6f5f5b89 100644 --- a/apps/cli/src/commands/branches/list/list.handler.ts +++ b/apps/cli/src/commands/branches/list/list.handler.ts @@ -40,6 +40,7 @@ export const branchesList = Effect.fn("branches.list")(function* (flags: Branche // `branches` is parent-scoped: after `supabase link `, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { const fetching = @@ -48,6 +49,7 @@ export const branchesList = Effect.fn("branches.list")(function* (flags: Branche Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); + yield* Effect.annotateCurrentSpan("branch.count", branches.length); yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/branches/pause/pause.handler.ts b/apps/cli/src/commands/branches/pause/pause.handler.ts index 3ad94a3b86..01191e6ade 100644 --- a/apps/cli/src/commands/branches/pause/pause.handler.ts +++ b/apps/cli/src/commands/branches/pause/pause.handler.ts @@ -32,6 +32,7 @@ export const branchesPause = Effect.fn("branches.pause")(function* (flags: Branc // `branches` is parent-scoped: after `supabase link `, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { const branchInput = yield* promptBranchId(flags.name, ref); diff --git a/apps/cli/src/commands/branches/unpause/unpause.handler.ts b/apps/cli/src/commands/branches/unpause/unpause.handler.ts index 3c030b4d5e..c030a57194 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.handler.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.handler.ts @@ -34,6 +34,7 @@ export const branchesUnpause = Effect.fn("branches.unpause")(function* ( // `branches` is parent-scoped: after `supabase link `, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { const branchInput = yield* promptBranchId(flags.name, ref); diff --git a/apps/cli/src/commands/branches/update/update.handler.ts b/apps/cli/src/commands/branches/update/update.handler.ts index 28d3fa838e..ea9b26fcee 100644 --- a/apps/cli/src/commands/branches/update/update.handler.ts +++ b/apps/cli/src/commands/branches/update/update.handler.ts @@ -44,6 +44,7 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra // `branches` is parent-scoped: after `supabase link `, `supabase/.temp/project-ref` // holds the branch's own ref, which the platform 403s on for every branches-management endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); + yield* Effect.annotateCurrentSpan("project.ref", ref); yield* Effect.gen(function* () { const branchInput = yield* promptBranchId(flags.branchId, ref); diff --git a/apps/cli/src/commands/config/config.load.ts b/apps/cli/src/commands/config/config.load.ts index 85efa6b119..09f67694ec 100644 --- a/apps/cli/src/commands/config/config.load.ts +++ b/apps/cli/src/commands/config/config.load.ts @@ -43,5 +43,8 @@ export function loadLocalConfig( }) : Effect.succeed(loaded), ), + Effect.tap((loaded) => + Effect.annotateCurrentSpan("config.remote_applied", loaded.appliedRemote !== undefined), + ), ); } diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index b5c3d29d4b..891923d793 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -121,6 +121,10 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF mapBranchResolveError, ); resolvedRef = ref; + yield* Effect.annotateCurrentSpan({ + "project.ref": ref, + "config.target_is_branch": branch !== undefined, + }); // Reload only if a `[remotes.*]` entry matches the resolved ref (ADR 0018), matched against // the raw pre-`env()` `project_id` literal so an `env(REF)` entry that merely resolves to @@ -183,7 +187,8 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF // the same ProjectConfigParseError boundary applies here. const changeSet = yield* configProjectConfigTry(() => diffProjectConfig({ local: loaded, remote }), - ); + ).pipe(Effect.withSpan("config.diff.computeDiff")); + yield* Effect.annotateCurrentSpan("change.count", changeSet.counts.total); const data = configIsRecord(responseJson) ? responseJson["data"] : undefined; const scope = configApiScope( diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 77f19458ce..746416baa6 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -212,6 +212,29 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("a disabled Data API diffs clean against a declared enabled = false", () => { + const { layer, out, processControl } = setup({ + toml: 'project_id = "test"\n[api]\nenabled = false\n', + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + api: { + ...(attributes["api"] as Record), + db_schema: "pg_pgrst_no_exposed_schemas", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configDiff({ ...noFlags, exitCode: true }); + expect(out.stdoutText).toContain("No config differences found."); + expect(processControl.exitCode).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + it.live("a configured provider still diffs clean", () => { const { layer, out } = setup({ toml: [ diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index 2f2ab4f3ec..5f4e084f88 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -89,7 +89,7 @@ export interface ConfigPullInput { readonly source: ConfigPullSource; } -export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput) { +export const runConfigPull = Effect.fn("config.pull.run")(function* (input: ConfigPullInput) { const output = yield* Output; const runPlan = yield* planConfigPullRun({ @@ -99,6 +99,12 @@ export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput }); const { changeSet, scope, plan: finalPlan, context, configFilePath } = runPlan; const ref = context.projectRef; + yield* Effect.annotateCurrentSpan({ + "project.ref": ref, + "config.dry_run": input.dryRun, + "change.count": changeSet.counts.total, + "config.write_count": finalPlan.writes.length, + }); // The text one-line disposition drops the caveats (opts.withCaveats: false) since the // change-by-change body above already rendered the same Note: lines; the machine-mode message @@ -250,6 +256,7 @@ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullF mapBranchResolveError, ); resolvedRef = ref; + yield* Effect.annotateCurrentSpan("config.target_is_branch", branch !== undefined); yield* runConfigPull({ target: { ref, branch }, diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 30f0a36ff4..eb4c7722a6 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -603,6 +603,31 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("a disabled Data API leaves a declared enabled = false untouched", () => { + const before = 'project_id = "test"\n[api]\nenabled = false\n'; + const { layer, out } = setup({ + toml: before, + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + api: { + ...(attributes["api"] as Record), + db_schema: "pg_pgrst_no_exposed_schemas", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPull(noFlags); + expect(yield* readConfig).toBe(before); + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + it.live("declining the confirmation prompt leaves the file unchanged", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; const { layer, out } = setup({ diff --git a/apps/cli/src/commands/config/push/push.auth-email-content.ts b/apps/cli/src/commands/config/push/push.auth-email-content.ts index 2c1950e274..60cf929f00 100644 --- a/apps/cli/src/commands/config/push/push.auth-email-content.ts +++ b/apps/cli/src/commands/config/push/push.auth-email-content.ts @@ -85,7 +85,10 @@ const resolveContentPath = ( * nothing was configured or all `content_path` values were empty. Fails when a configured * `content_path` points to a missing or unreadable file. */ -export const loadAuthEmailContent = Effect.fnUntraced(function* (cwd: string, email: AuthEmail) { +export const loadAuthEmailContent = Effect.fn("config.push.loadAuthEmailContent")(function* ( + cwd: string, + email: AuthEmail, +) { const template: Record = {}; const notification: Record = {}; @@ -130,7 +133,9 @@ export const loadAuthEmailContent = Effect.fnUntraced(function* (cwd: string, em notification[name] = yield* readTemplateContent("notification", name, resolved); } - if (Object.keys(template).length === 0 && Object.keys(notification).length === 0) { + const fileCount = Object.keys(template).length + Object.keys(notification).length; + yield* Effect.annotateCurrentSpan("file.count", fileCount); + if (fileCount === 0) { return EMPTY_AUTH_EMAIL_CONTENT; } diff --git a/apps/cli/src/commands/config/push/push.cost-matrix.ts b/apps/cli/src/commands/config/push/push.cost-matrix.ts index 31b99cb733..5abff00304 100644 --- a/apps/cli/src/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/commands/config/push/push.cost-matrix.ts @@ -29,7 +29,7 @@ export interface CostItem { * (e.g. the `"api"` GraphQL addon). Mirrors the `sso add` / * `postgres-config` raw-HTTP precedent. */ -export const getCostMatrix = Effect.fn("config.push.cost-matrix")(function* (ref: string) { +export const getCostMatrix = Effect.fn("config.push.fetchCostMatrix")(function* (ref: string) { const httpClient = yield* HttpClient.HttpClient; const cliSettings = yield* CommandSettings; const tokenOpt = yield* resolveAccessToken; @@ -78,6 +78,7 @@ export const getCostMatrix = Effect.fn("config.push.cost-matrix")(function* (ref costMatrix.set(addon.type, { name: variant.name, price: variant.price.description }); } } + yield* Effect.annotateCurrentSpan("addon.count", costMatrix.size); return costMatrix; }); diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index fa74c15386..2d15d23a69 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -230,6 +230,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF mapPushBranchResolveError, ); resolvedRef = ref; + yield* Effect.annotateCurrentSpan({ + "project.ref": ref, + "config.target_is_branch": branch !== undefined, + }); // 2. Load config.toml with the resolved ref (a TOML parse error aborts before any network // call); a matching `[remotes.]` overlay merges before decode in the same call. @@ -265,13 +269,15 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // Deprecated `auth.external.{linkedin,slack}` blocks are stripped from `loaded.document` // before this decode, so scan `removedDeprecatedExternalProviders` too, or a secret hiding in // one of them would skip the check. - const secretError = - assertDecryptableSecrets(loaded.document, secretEnvLookup, dotenvPrivateKeys) ?? - assertDecryptableSecrets( - { auth: { external: loaded.removedDeprecatedExternalProviders } }, - secretEnvLookup, - dotenvPrivateKeys, - ); + const secretError = yield* Effect.sync( + () => + assertDecryptableSecrets(loaded.document, secretEnvLookup, dotenvPrivateKeys) ?? + assertDecryptableSecrets( + { auth: { external: loaded.removedDeprecatedExternalProviders } }, + secretEnvLookup, + dotenvPrivateKeys, + ), + ).pipe(Effect.withSpan("config.push.verifyDecryptable")); if (secretError !== undefined) { return yield* new ConfigPushLoadConfigError({ message: secretError }); } @@ -310,7 +316,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF !yes && tty.stdinIsTty && !output.interactive ? Effect.succeed(defaultValue) : promptYesNo(output, yes, label, defaultValue, true, { readMachineStdin: true }); - const target = yield* resolveConfigPushTarget(ref, { knownBranch }); + const target = yield* resolveConfigPushTarget(ref, { knownBranch }).pipe( + Effect.withSpan("config.push.probeTarget"), + ); + yield* Effect.annotateCurrentSpan("config.target_kind", target.kind); yield* output.raw(configPushTargetLines(target), "stderr"); if (target.kind === "branch" && knownBranch === undefined) { // Defaults `false`: an unattended run without affirmative consent @@ -406,7 +415,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const local = yield* configProjectConfigTry(() => fromConfigDocument(loaded)); const changeSet = yield* configProjectConfigTry(() => diffProjectConfig({ local: loaded, remote }), - ); + ).pipe(Effect.withSpan("config.push.computeDiff")); + yield* Effect.annotateCurrentSpan("change.count", changeSet.counts.total); // 9. Route pushable changes to their v1 write endpoint and resolve every // declared secret's send/unchanged/not_set/gated status. @@ -427,7 +437,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF new ConfigPushLoadConfigError({ message: cause instanceof Error ? cause.message : String(cause), }), - }); + }).pipe(Effect.withSpan("config.push.resolveAuthSecrets")); const now = yield* DateTime.now; // Whether each resource's local gate is on, computed once for the resource loop below and @@ -482,7 +492,11 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF "stderr", ); if (yield* keep(pushPromptKey(resource))) { - yield* write(body); + yield* write(body).pipe( + Effect.withSpan("config.push.updateResource", { + attributes: { "config.resource": resource }, + }), + ); const sentSecretPaths = pushSentSecretPaths(encoded); return { service: resource, @@ -715,6 +729,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF if (notes !== "") { yield* output.raw(notes, "stderr"); } + yield* Effect.annotateCurrentSpan( + "resource.updated_count", + services.filter((service) => service.status === "updated").length, + ); // 13. Machine-readable summary in `json` / `stream-json` mode. if (output.format !== "text") { 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 4cddc7ba2e..391bd6def0 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -560,6 +560,57 @@ max_rows = 1000 }).pipe(Effect.provide(layer)); }); + it.live("reports up-to-date when the project already has the Data API disabled", () => { + const { layer, out, api } = setup({ + toml: 'project_id = "test"\n[api]\nenabled = false\n', + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + api: { + ...(a["api"] as Record), + db_schema: "pg_pgrst_no_exposed_schemas", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain("Remote API config is up to date."); + expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( + false, + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("re-enables a disabled Data API with the default schemas", () => { + const { layer, api } = setup({ + toml: 'project_id = "test"\n[api]\nenabled = true\n', + yes: true, + v2: { + status: 200, + body: v2Response({ + attributes: (a) => ({ + ...a, + api: { + ...(a["api"] as Record), + db_schema: "pg_pgrst_no_exposed_schemas", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + const update = api.requests.find((r) => r.method === "PATCH" && r.url.includes("/postgrest")); + expect(update).toBeDefined(); + expect(update?.body).toMatchObject({ db_schema: "public,graphql_public" }); + }).pipe(Effect.provide(layer)); + }); + it.live("stops a service when the user declines the prompt (exit 0)", () => { const { layer, out, api } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index 085f6ed9af..750938f8d5 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -41,7 +41,7 @@ import { splitLintsSql } from "./advisors.lints-sql.ts"; const loginSuggestion = (): string => `Run ${aqua("supabase login")} first.`; /** Queries and scans the lints, minus the transaction the caller owns. */ -const queryLints = Effect.fnUntraced(function* (session: DbSession) { +const queryLints = Effect.fn("DbAdvisors.queryLints")(function* (session: DbSession) { const [setupSql, querySql] = splitLintsSql(); yield* session.exec(setupSql).pipe( Effect.mapError( @@ -58,11 +58,12 @@ const queryLints = Effect.fnUntraced(function* (session: DbSession) { (cause) => new DbAdvisorsQueryError({ message: `failed to query lints: ${cause.message}` }), ), ); + yield* Effect.annotateCurrentSpan("advisor.count", rows.length); return rows.map(scanAdvisorLintRow); }); /** Gathers lints from a local (or `--db-url`) database connection. */ -const runLocal = Effect.fnUntraced(function* ( +const runLocal = Effect.fn("db.advisors.runLocal")(function* ( flags: DbAdvisorsFlags, dnsResolver: "native" | "https", advisorType: string, @@ -108,7 +109,7 @@ const runLocal = Effect.fnUntraced(function* ( }); /** Gathers lints from the Management API for the linked project. */ -const runLinked = Effect.fnUntraced(function* ( +const runLinked = Effect.fn("db.advisors.runLinked")(function* ( flags: DbAdvisorsFlags, dnsResolver: "native" | "https", advisorType: string, @@ -233,6 +234,12 @@ const runAdvisors = Effect.fnUntraced(function* ( const advisorType = Option.getOrElse(flags.type, () => "all"); const level = Option.getOrElse(flags.level, () => "warn"); const failOn = Option.getOrElse(flags.failOn, () => "none"); + yield* Effect.annotateCurrentSpan({ + "db.conn_type": target.connType ?? "local", + "advisor.type": advisorType, + "advisor.level": level, + "advisor.fail_on": failOn, + }); // Branches on whether `--linked` was explicitly set: linked → Management // API; otherwise local / `--db-url`. @@ -240,6 +247,7 @@ const runAdvisors = Effect.fnUntraced(function* ( target.connType === "linked" ? yield* runLinked(flags, dnsResolver, advisorType, level) : yield* runLocal(flags, dnsResolver, advisorType, level, target); + yield* Effect.annotateCurrentSpan("advisor.reported_count", filtered.length); yield* outputAndCheck(filtered, failOn); }); diff --git a/apps/cli/src/commands/db/advisors/advisors.layers.ts b/apps/cli/src/commands/db/advisors/advisors.layers.ts index e5b9698133..30b0239646 100644 --- a/apps/cli/src/commands/db/advisors/advisors.layers.ts +++ b/apps/cli/src/commands/db/advisors/advisors.layers.ts @@ -7,7 +7,7 @@ import { commandPlatformApiFactoryLayer } from "../../../auth/command-platform-a import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; import { projectRefLayer } from "../../../config/project-ref.layer.ts"; import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; diff --git a/apps/cli/src/commands/db/advisors/advisors.linked.ts b/apps/cli/src/commands/db/advisors/advisors.linked.ts index ca337bc1d6..a397f56553 100644 --- a/apps/cli/src/commands/db/advisors/advisors.linked.ts +++ b/apps/cli/src/commands/db/advisors/advisors.linked.ts @@ -89,10 +89,12 @@ const fetchAdvisors = Effect.fnUntraced(function* ( // A decode error folds into the same `failed to fetch … advisors: %w` path, // so map both JSON syntax errors and structural-shape rejections (thrown by // `apiResponseToAdvisorLints`) to the endpoint's network error. - return yield* Effect.try({ + const lints = yield* Effect.try({ try: () => decodeAdvisorsBody(rawBody), catch: (cause) => endpoint.network(String(cause), { decode: true }), }); + yield* Effect.annotateCurrentSpan("advisor.count", lints.length); + return lints; }); export const fetchSecurityAdvisors = (ref: string, stitch: StitchFn) => @@ -113,6 +115,10 @@ export const fetchSecurityAdvisors = (ref: string, stitch: StitchFn) => }), }, stitch, + ).pipe( + Effect.withSpan("v1GetSecurityAdvisors", { + attributes: { "api.operation": "v1GetSecurityAdvisors" }, + }), ); export const fetchPerformanceAdvisors = (ref: string, stitch: StitchFn) => @@ -133,4 +139,8 @@ export const fetchPerformanceAdvisors = (ref: string, stitch: StitchFn) => }), }, stitch, + ).pipe( + Effect.withSpan("v1GetPerformanceAdvisors", { + attributes: { "api.operation": "v1GetPerformanceAdvisors" }, + }), ); diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 4eb11d7469..9556a0282f 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -60,7 +60,10 @@ it, and JSON `null` disables formatting without disabling safe compaction. - Edge-runtime container (migra engine only). It and the `supabase/migra` fallback get loopback `SOURCE`/`TARGET` hosts rewritten to `host.docker.internal` when `--network-id` puts them on a named network, or when a stack-backend URL is used outside Linux host - networking. + networking. The edge-runtime run mounts the `supabase_edge_runtime_` Deno-cache + volume, first created by `docker volume create` with the `com.supabase.cli.project`/ + `com.docker.compose.project` labels so `stop --no-backup` removes it (skipped under Bitbucket + Pipelines, which drops the mount). - Shadow Postgres container — provisioned and torn down natively (`prepareShadowSource` in `commands/db/shared/shadow-source.ts`, over the lower-level primitives in `command-internal/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Explicit diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index a92de2a624..85a048de5a 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -204,6 +204,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const fromSet = from.length > 0; const toSet = to.length > 0; if (fromSet || toSet) { + yield* Effect.annotateCurrentSpan({ "diff.mode": "explicit", "diff.engine": "pg-delta" }); if (!fromSet || !toSet) { return yield* new DbDiffExplicitFlagsError({ message: "must set both --from and --to when using explicit diff mode", @@ -570,91 +571,93 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { ); }); - let diffResult: { + const engine = usePgAdmin ? "pgadmin" : useDelta ? "pg-delta" : "migra"; + yield* Effect.annotateCurrentSpan("diff.engine", engine); + const diffResult: { readonly sql: string; readonly files: ReadonlyArray | undefined; readonly hazards?: PgDeltaDiffResult["hazards"]; - }; - if (usePgAdmin) { - // The running-db check runs after the config load + target resolve above, and — unlike - // every other engine on this command — runs for `--linked`/`--db-url` too, not just the - // local target. Uses `ctx.projectId` (already remote-merge-resolved), not the raw - // `cliSettings.projectId` env reader, so it reflects a resolved remote merge. - const running = yield* isLocalDbRunning( - spawner, - fs, - path, - cliSettings.workdir, - ctx.projectId, - ).pipe( - Effect.mapError( - (cause) => - new DbDiffDbNotRunningError({ - message: cause.message, - daemonDown: cause.daemonDown, - suggestion: cause.suggestion, + } = yield* Effect.gen(function* () { + if (usePgAdmin) { + // The running-db check runs after the config load + target resolve above, and — unlike + // every other engine on this command — runs for `--linked`/`--db-url` too, not just the + // local target. Uses `ctx.projectId` (already remote-merge-resolved), not the raw + // `cliSettings.projectId` env reader, so it reflects a resolved remote merge. + const running = yield* isLocalDbRunning( + spawner, + fs, + path, + cliSettings.workdir, + ctx.projectId, + ).pipe( + Effect.mapError( + (cause) => + new DbDiffDbNotRunningError({ + message: cause.message, + daemonDown: cause.daemonDown, + suggestion: cause.suggestion, + }), + ), + ); + if (!running) { + return yield* new DbDiffDbNotRunningError({ + message: `${aqua("supabase start")} is not running.`, + }); + } + yield* emitStatus("Creating shadow database..."); + const shadowBase = yield* resolveShadowRunInput(); + const shadowConnConfig: PgConnInput = { + host: shadowBase.hostname, + port: shadowBase.shadowPort, + user: "postgres", + password: shadowBase.password, + database: "postgres", + }; + // Register cleanup atomically with shadow creation; preparation stays interruptible. + const sql = yield* Effect.acquireUseRelease( + createShadowDatabase(spawner, shadowBase), + (handle) => + Effect.gen(function* () { + yield* waitForHealthyServices(spawner, [handle.containerId], { + timeoutSeconds: shadowBase.healthTimeoutSeconds, + }); + yield* migrateShadowDatabase(spawner, { + fs, + path, + workdir: cliSettings.workdir, + projectId: shadowBase.projectId, + container: handle.containerId, + networkId: shadowBase.networkId, + connConfig: shadowConnConfig, + setup: shadowBase.setup, + }); + yield* emitStatus("Diffing local database with current migrations..."); + const differHost = (host: string) => + toolContainerUsesHostNetwork(shadowBase.networkId) + ? host + : rewriteDumpHostForToolContainer(host, { + platform: runtimeInfo.platform, + usesHostNetwork: false, + }); + return yield* diffSchemaPgAdmin({ + // `source`/`target` are inverted relative to the migra/pg-delta path below: + // `source` is the user's db, `target` is the shadow. + source: toPostgresURL({ ...resolved.conn, host: differHost(resolved.conn.host) }), + // Hardcoded, not built via `toPostgresURL`: this ignores + // `SUPABASE_SERVICES_HOSTNAME`/`[db] password` by design, not a bug to fix. + target: `postgresql://postgres:postgres@${differHost("127.0.0.1")}:${shadowBase.shadowPort}/postgres`, + schema: flags.schema, + projectEnvValues: cfg.projectEnv, + projectId: shadowBase.projectId, + networkId: shadowBase.networkId, + extraHosts: shadowBase.extraHosts, + emitStatus, + }); }), - ), - ); - if (!running) { - return yield* new DbDiffDbNotRunningError({ - message: `${aqua("supabase start")} is not running.`, - }); + (handle) => removeShadowDatabase(spawner, handle.containerId), + ); + return { sql, files: undefined }; } - yield* emitStatus("Creating shadow database..."); - const shadowBase = yield* resolveShadowRunInput(); - const shadowConnConfig: PgConnInput = { - host: shadowBase.hostname, - port: shadowBase.shadowPort, - user: "postgres", - password: shadowBase.password, - database: "postgres", - }; - // Register cleanup atomically with shadow creation; preparation stays interruptible. - const sql = yield* Effect.acquireUseRelease( - createShadowDatabase(spawner, shadowBase), - (handle) => - Effect.gen(function* () { - yield* waitForHealthyServices(spawner, [handle.containerId], { - timeoutSeconds: shadowBase.healthTimeoutSeconds, - }); - yield* migrateShadowDatabase(spawner, { - fs, - path, - workdir: cliSettings.workdir, - projectId: shadowBase.projectId, - container: handle.containerId, - networkId: shadowBase.networkId, - connConfig: shadowConnConfig, - setup: shadowBase.setup, - }); - yield* emitStatus("Diffing local database with current migrations..."); - const differHost = (host: string) => - toolContainerUsesHostNetwork(shadowBase.networkId) - ? host - : rewriteDumpHostForToolContainer(host, { - platform: runtimeInfo.platform, - usesHostNetwork: false, - }); - return yield* diffSchemaPgAdmin({ - // `source`/`target` are inverted relative to the migra/pg-delta path below: - // `source` is the user's db, `target` is the shadow. - source: toPostgresURL({ ...resolved.conn, host: differHost(resolved.conn.host) }), - // Hardcoded, not built via `toPostgresURL`: this ignores - // `SUPABASE_SERVICES_HOSTNAME`/`[db] password` by design, not a bug to fix. - target: `postgresql://postgres:postgres@${differHost("127.0.0.1")}:${shadowBase.shadowPort}/postgres`, - schema: flags.schema, - projectEnvValues: cfg.projectEnv, - projectId: shadowBase.projectId, - networkId: shadowBase.networkId, - extraHosts: shadowBase.extraHosts, - emitStatus, - }); - }), - (handle) => removeShadowDatabase(spawner, handle.containerId), - ); - diffResult = { sql, files: undefined }; - } else { yield* output.raw("Creating shadow database...\n", "stderr"); const migrationMode: "legacy" | "pgdelta-next" = useDelta ? "pgdelta-next" : "legacy"; const shadowInput = { @@ -719,7 +722,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // `prepareShadowSource` selects for this mode (legacy migrate forces `pg_net` on, // next follows config), or the two engines could restore each other's tars. const stackBackend = (yield* currentStackBackend).kind === "stack"; - diffResult = stackBackend + return stackBackend ? yield* stackWithShadowDatabase(shadowInput, (handle) => stackPrepareShadowSource(handle, shadowInput).pipe(Effect.flatMap(runDiff)), ) @@ -733,7 +736,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }), { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, ); - } + }).pipe(Effect.withSpan("db.diff.shadowDatabase")); const out = diffResult.sql; // The pgAdmin path skips the branch banner and drop-statement scan below entirely. @@ -749,13 +752,16 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } // The file-write + drop-statement warning below is bypassed by the pgadmin path. - const engine = usePgAdmin ? "pgadmin" : useDelta ? "pg-delta" : "migra"; const drops: ReadonlyArray = usePgAdmin ? [] : diffResult.hazards !== undefined ? diffResult.hazards.dataLoss.map((action) => action.sql) : findDropStatements(out); const writtenFiles: Array = []; + yield* Effect.annotateCurrentSpan({ + "schema.count": flags.schema.length, + "diff.drop_statement_count": drops.length, + }); let ignoredDeclarativeAdvisory: ReturnType | undefined; if (out.length >= 2 && useDelta && Option.isSome(flags.file) && flags.file.value.length > 0) { // This is an informational, best-effort probe only. Declarative files are diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index d3a6ff0c3a..76832600d3 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -251,9 +251,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { ); const edgeCalls: EdgeRuntimeRunOpts[] = []; + const spawnedBeforeEdgeRun: Array>> = []; const edge = Layer.succeed(EdgeRuntimeScript, { run: (runOpts: EdgeRuntimeRunOpts) => { edgeCalls.push(runOpts); + spawnedBeforeEdgeRun.push(shadowSpawner.spawned.map((call) => call.args)); if (opts.oom) { return Effect.fail( new EdgeRuntimeScriptError({ message: "Fatal JavaScript out of memory" }), @@ -458,6 +460,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { explicitDiffCalls, databaseDiffCalls, edgeCalls, + spawnedBeforeEdgeRun, resolverCalls, proxyCalls, proxyCaptureCalls, @@ -598,6 +601,40 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("creates the labeled Deno-cache volume before the migra run mounts it", () => { + const s = setup(tmp.current, { diffSql: "create table players ();\n" }); + return Effect.gen(function* () { + yield* dbDiff(flags()); + expect(s.edgeCalls[0]?.binds).toEqual(["supabase_edge_runtime_test:/root/.cache/deno:rw"]); + expect(s.spawnedBeforeEdgeRun[0]).toContainEqual([ + "volume", + "create", + "--label", + "com.supabase.cli.project=test", + "--label", + "com.docker.compose.project=test", + "supabase_edge_runtime_test", + ]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("skips creating the Deno-cache volume under Bitbucket Pipelines", () => { + const s = setup(tmp.current, { + diffSql: "create table players ();\n", + files: { "supabase/.env": "BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n" }, + }); + return Effect.gen(function* () { + yield* dbDiff(flags()); + expect(s.edgeCalls).toHaveLength(1); + expect( + s.shadowSpawned.filter( + (call) => call.args[0] === "volume" && call.args.includes("supabase_edge_runtime_test"), + ), + ).toEqual([]); + expect(stdout(s.out)).toBe("create table players ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("diffs local with pgdelta when --use-pg-delta is set", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts index c0518adc45..37603ac7da 100644 --- a/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.stack-cache.e2e.test.ts @@ -1,11 +1,11 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Exit, FileSystem, Path, Predicate, Redacted, Schema, Stream } from "effect"; +import { Effect, FileSystem, Path, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS * 4 + 2 * 60_000; @@ -31,38 +31,6 @@ enabled = false enabled = false `; -const storageMarker = Schema.Struct({ - backend: Schema.Literals(["docker", "host"]), - volume: Schema.optionalKey(Schema.String), -}); - -class DockerCleanupError extends Data.TaggedError("DockerCleanupError")<{ - readonly message: string; -}> {} - -const removeDockerVolume = Effect.fn("DbDiffStackCacheE2e.removeDockerVolume")((volume: string) => - Effect.scoped( - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn( - ChildProcess.make("docker", ["volume", "rm", volume], { stdin: "ignore" }), - ); - const [stderr, code] = yield* Effect.all( - [ - child.stdout.pipe(Stream.runDrain), - child.stderr.pipe(Stream.decodeText, Stream.mkString), - child.exitCode, - ], - { concurrency: "unbounded" }, - ).pipe(Effect.map(([, stderr, code]) => [stderr, code] as const)); - if (Number(code) !== 0 && !/no such volume/iu.test(stderr)) - return yield* new DockerCleanupError({ - message: `docker volume rm ${volume} failed: ${stderr.trim() || `exit ${code}`}`, - }); - }), - ), -); - const composeStack = Effect.fn("DbDiffStackCacheE2e.composeStack")(function* ( root: string, home: string, @@ -74,39 +42,7 @@ const composeStack = Effect.fn("DbDiffStackCacheE2e.composeStack")(function* ( cacheRoot: `${home}/cache/stack`, runtime, }); - let databaseId: string | undefined; - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const marker = yield* Effect.gen(function* () { - if (runtime !== "docker" || databaseId === undefined) return undefined; - const fs = yield* FileSystem.FileSystem; - const markerText = yield* fs - .readFileString( - `${home}/stacks/${stack.id}/data/${databaseId}/.supabase-database-storage.json`, - ) - .pipe( - Effect.catchIf( - (cause) => Predicate.isTagged(cause.reason, "NotFound"), - () => Effect.void, - ), - ); - if (markerText === undefined) return undefined; - return yield* Schema.decodeEffect(Schema.fromJsonString(storageMarker))(markerText); - }).pipe(Effect.exit); - const destroyed = yield* stack.destroy.pipe(Effect.exit); - const volume = - Exit.isSuccess(marker) && marker.value !== undefined && marker.value.backend === "docker" - ? marker.value.volume - : undefined; - const removed = - volume === undefined - ? Exit.succeed(undefined) - : yield* removeDockerVolume(volume).pipe(Effect.exit); - if (Exit.isFailure(destroyed)) return yield* Effect.failCause(destroyed.cause); - if (Exit.isFailure(marker)) return yield* Effect.failCause(marker.cause); - if (Exit.isFailure(removed)) return yield* Effect.failCause(removed.cause); - }).pipe(Effect.catchCause((cause) => Effect.die(cause))), - ); + yield* Effect.addFinalizer(() => destroyTestStack(stack)); const [database] = yield* stack.composition.supabase([ { service: "database", @@ -120,7 +56,6 @@ const composeStack = Effect.fn("DbDiffStackCacheE2e.composeStack")(function* ( }, ]); if (database === undefined) return yield* Effect.die("database composition missing"); - databaseId = database.id; yield* stack.composition.start; return stack; }); diff --git a/apps/cli/src/commands/db/diff/pgadmin-diff.ts b/apps/cli/src/commands/db/diff/pgadmin-diff.ts index 35b3e23dde..a1c868777a 100644 --- a/apps/cli/src/commands/db/diff/pgadmin-diff.ts +++ b/apps/cli/src/commands/db/diff/pgadmin-diff.ts @@ -18,10 +18,14 @@ import { dockerfileServiceImage } from "../../../shared/services/dockerfile-imag import { COMPOSE_PROJECT_LABEL } from "../../../command-internal/db-bootstrap/container-lifecycle.ts"; import { CLI_PROJECT_LABEL } from "../../../command-internal/docker-ids.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; -import { trimGoSpace } from "../shared/go-string.ts"; import { INTERNAL_SCHEMAS } from "../../../command-internal/pg-dump.env.ts"; import { DbDiffPgAdminError } from "./diff.errors.ts"; +// Trims the Unicode `White_Space` set like Go's `strings.TrimSpace`; unlike JS `trim`, it +// leaves a leading U+FEFF (BOM) in place. +const trimGoSpace = (value: string): string => + value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""); + // `differ` has no slim build, so the slim flag never applies to it. const DIFFER_IMAGE = dockerfileServiceImage("differ", false); diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 0d92fec4fa..c4d32a5dc9 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -351,6 +351,12 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { .pipe(Effect.mapError(toOpenFileError)); } + yield* Effect.annotateCurrentSpan({ + "db.dump.mode": mode.verb, + "db.is_local": isLocal, + "db.dump.dry_run": flags.dryRun, + }); + // 6. Diagnostic to stderr (printed for both real and dry-run paths). yield* output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"); @@ -430,6 +436,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { reprintOnRetry: output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"), }), ), + Effect.withSpan("db.dump.run"), Effect.catchIf( (error): error is DockerRunError => Predicate.isTagged(error, "DockerRunError") && diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index af9f38fc3b..e020809e10 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -124,7 +124,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { registered: true, }), followStatus: Stream.empty, - logs: Stream.empty, + readLogs: () => Stream.empty, credentials: () => Effect.succeed({ databaseUrl: "postgresql://postgres:secret@127.0.0.1:54322/postgres" }), saveSnapshot: () => Effect.die("unused"), diff --git a/apps/cli/src/commands/db/dump/dump.layers.ts b/apps/cli/src/commands/db/dump/dump.layers.ts index 0bf46f8693..85229a4aa9 100644 --- a/apps/cli/src/commands/db/dump/dump.layers.ts +++ b/apps/cli/src/commands/db/dump/dump.layers.ts @@ -6,7 +6,7 @@ import { commandPlatformApiFactoryLayer } from "../../../auth/command-platform-a import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; import { projectRefLayer } from "../../../config/project-ref.layer.ts"; import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; diff --git a/apps/cli/src/commands/db/lint/lint.handler.ts b/apps/cli/src/commands/db/lint/lint.handler.ts index 71e0bdff60..7f739bef56 100644 --- a/apps/cli/src/commands/db/lint/lint.handler.ts +++ b/apps/cli/src/commands/db/lint/lint.handler.ts @@ -42,7 +42,7 @@ const asString = (value: unknown): string => value === null || value === undefined ? "" : String(value); /** Lists the user schemas — used when `--schema` is omitted. */ -const listUserSchemas = Effect.fnUntraced(function* (session: DbSession) { +const listUserSchemas = Effect.fn("DbLint.listUserSchemas")(function* (session: DbSession) { const rows = yield* session .query(LIST_SCHEMAS_SQL, [MANAGED_SCHEMAS]) .pipe( @@ -55,12 +55,13 @@ const listUserSchemas = Effect.fnUntraced(function* (session: DbSession) { }); /** Runs the pgsql_check-based lint, minus the transaction setup the handler owns. */ -const lintDatabase = Effect.fnUntraced(function* ( +const lintDatabase = Effect.fn("DbLint.lintDatabase")(function* ( session: DbSession, schemaFlags: ReadonlyArray, ) { const output = yield* Output; const schemas = schemaFlags.length > 0 ? schemaFlags : yield* listUserSchemas(session); + yield* Effect.annotateCurrentSpan({ "schema.count": schemas.length }); yield* session.exec(ENABLE_PGSQL_CHECK).pipe( Effect.mapError( @@ -141,6 +142,12 @@ const runLint = Effect.fnUntraced(function* ( linkedProjectRef: flags.projectRef, }); + yield* Effect.annotateCurrentSpan({ + "db.is_local": cfg.isLocal, + "db.lint.level": level, + "db.lint.fail_on": failOn, + }); + const results = yield* Effect.scoped( Effect.gen(function* () { yield* output.raw( @@ -179,6 +186,7 @@ const runLint = Effect.fnUntraced(function* ( } const filtered = filterLintResult(results, LINT_LEVEL_ENUM.toEnum(level)); + yield* Effect.annotateCurrentSpan({ "db.lint.issue_count": filtered.length }); if (output.format === "text") { // Encoding no-ops on an empty slice. diff --git a/apps/cli/src/commands/db/lint/lint.layers.ts b/apps/cli/src/commands/db/lint/lint.layers.ts index bb4086960c..f3c5c291cc 100644 --- a/apps/cli/src/commands/db/lint/lint.layers.ts +++ b/apps/cli/src/commands/db/lint/lint.layers.ts @@ -7,7 +7,7 @@ import { commandPlatformApiFactoryLayer } from "../../../auth/command-platform-a import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; import { projectRefLayer } from "../../../config/project-ref.layer.ts"; import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index 07ae92efc8..e5577f534a 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -66,7 +66,10 @@ disables formatting without disabling safe compaction. ## Docker -- Edge-runtime container (migra engine only). +- Edge-runtime container (migra engine only). It mounts the `supabase_edge_runtime_` + Deno-cache volume, first created by `docker volume create` with the `com.supabase.cli.project`/ + `com.docker.compose.project` labels so `stop --no-backup` removes it (skipped under Bitbucket + Pipelines, which drops the mount). - Shadow Postgres container — provisioned and torn down natively (`prepareShadowSource` in `commands/db/shared/shadow-source.ts`, over the lower-level primitives in `command-internal/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Torn down with diff --git a/apps/cli/src/commands/db/pull/pull.handler.ts b/apps/cli/src/commands/db/pull/pull.handler.ts index 7092cdbdab..5c972d065a 100644 --- a/apps/cli/src/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/commands/db/pull/pull.handler.ts @@ -14,6 +14,11 @@ export const dbPull = Effect.fn("db.pull")(function* (flags: DbPullFlags, invoke const output = yield* Output; const outcome = yield* runDbPull(flags, invoke); + yield* Effect.annotateCurrentSpan({ + "db.pull.outcome": outcome.kind, + "db.pull.engine": outcome.engine, + }); + if (output.format !== "text") { if (outcome.kind === "declarative") { yield* output.success("Declarative schema pulled.", { diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index e6f6b6b7b0..e0129d4e8a 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -277,10 +277,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { let edgeRunCount = 0; const edgeCalls: EdgeRuntimeRunOpts[] = []; + const spawnedBeforeEdgeRun: Array>> = []; const edge = Layer.succeed(EdgeRuntimeScript, { run: (runOpts: EdgeRuntimeRunOpts) => { edgeRunCount += 1; edgeCalls.push(runOpts); + spawnedBeforeEdgeRun.push(shadowSpawner.spawned.map((call) => call.args)); if (opts.edgeFailFirstWith !== undefined && edgeRunCount === 1) { return Effect.fail(new EdgeRuntimeScriptError({ message: opts.edgeFailFirstWith })); } @@ -513,6 +515,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { return edgeRunCount; }, edgeCalls, + spawnedBeforeEdgeRun, cache, }; } @@ -845,6 +848,54 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("creates the labeled Deno-cache volume before the migra run mounts it", () => { + const s = setup(tmp.current, { + migrations: ["20240101000000"], + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* dbPull(flags()); + expect(s.edgeCalls).toHaveLength(1); + expect(s.edgeCalls[0]?.binds).toEqual(["supabase_edge_runtime_test:/root/.cache/deno:rw"]); + expect(s.spawnedBeforeEdgeRun[0]).toContainEqual([ + "volume", + "create", + "--label", + "com.supabase.cli.project=test", + "--label", + "com.docker.compose.project=test", + "supabase_edge_runtime_test", + ]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("skips creating the Deno-cache volume under Bitbucket Pipelines", () => { + const s = setup(tmp.current, { + migrations: ["20240101000000"], + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + files: { "supabase/.env": "BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n" }, + }); + return Effect.gen(function* () { + yield* dbPull(flags()); + expect(s.edgeCalls).toHaveLength(1); + expect( + s.shadowSpawned.filter( + (call) => call.args[0] === "volume" && call.args.includes("supabase_edge_runtime_test"), + ), + ).toEqual([]); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(tmp.current, "supabase", "migrations"); + const file = (yield* fs.readDirectory(dir)).find((f) => f.endsWith("_remote_schema.sql")); + expect(yield* readFileText(path.join(dir, file ?? ""))).toContain("create table remote ();"); + expect(streamText(s.out, "stdout")).toContain("Finished supabase db pull."); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "validates the shadow's own local config (api.tls cert file) BEFORE resolving the connection", () => { diff --git a/apps/cli/src/commands/db/pull/pull.sync.ts b/apps/cli/src/commands/db/pull/pull.sync.ts index 2c5c20a7d6..6395a28e3c 100644 --- a/apps/cli/src/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/commands/db/pull/pull.sync.ts @@ -23,60 +23,60 @@ export interface PulledMigration { * A pg-delta pull whose plan crosses a transaction boundary writes several files, so * several versions are recorded in one pass. */ -export const updateMigrationHistory = ( +export const updateMigrationHistory = Effect.fn("DbPull.updateMigrationHistory")(function* ( session: DbSession, fs: FileSystem.FileSystem, path: Path.Path, migrations: ReadonlyArray, -) => - Effect.gen(function* () { - const output = yield* Output; - // The glob (`_*.sql`) never crosses a path separator, so a migration name - // with one writes a nested file it can't reach — require the basename to both match - // the pattern and carry the generated version, rather than trusting `path.basename`. - const resolved: Array<{ version: string; name: string; migrationPath: string }> = []; - for (const migration of migrations) { - const match = MIGRATE_FILE_PATTERN.exec(path.basename(migration.path)); - if (match === null || match[1] !== migration.version) { - return yield* new DbPullWriteError({ - message: `glob supabase/migrations/${migration.version}_*.sql: file does not exist`, - }); - } - resolved.push({ - version: migration.version, - name: match[2] ?? "", - migrationPath: migration.path, +) { + const output = yield* Output; + yield* Effect.annotateCurrentSpan({ "migration.count": migrations.length }); + // The glob (`_*.sql`) never crosses a path separator, so a migration name + // with one writes a nested file it can't reach — require the basename to both match + // the pattern and carry the generated version, rather than trusting `path.basename`. + const resolved: Array<{ version: string; name: string; migrationPath: string }> = []; + for (const migration of migrations) { + const match = MIGRATE_FILE_PATTERN.exec(path.basename(migration.path)); + if (match === null || match[1] !== migration.version) { + return yield* new DbPullWriteError({ + message: `glob supabase/migrations/${migration.version}_*.sql: file does not exist`, }); } + resolved.push({ + version: migration.version, + name: match[2] ?? "", + migrationPath: migration.path, + }); + } + yield* Effect.gen(function* () { + // Created in its own transaction, outside the upsert transaction below, to avoid + // nesting BEGINs (`createMigrationTable` issues its own BEGIN/COMMIT). + yield* createMigrationTable(session); + // One explicit transaction: without it, each UPSERT autocommits, so a mid-loop + // failure would leave partial remote history that fails the next pull's sync check. yield* Effect.gen(function* () { - // Created in its own transaction, outside the upsert transaction below, to avoid - // nesting BEGINs (`createMigrationTable` issues its own BEGIN/COMMIT). - yield* createMigrationTable(session); - // One explicit transaction: without it, each UPSERT autocommits, so a mid-loop - // failure would leave partial remote history that fails the next pull's sync check. - yield* Effect.gen(function* () { - yield* session.exec("BEGIN"); - for (const entry of resolved) { - const content = yield* fs.readFileString(entry.migrationPath); - const statements = splitAndTrim(content); - yield* session.query(UPSERT_MIGRATION_VERSION, [entry.version, entry.name, statements]); - } - yield* session.exec("COMMIT"); - }).pipe( - // `Effect.ignore` keeps a ROLLBACK failure from masking the original error - // (`tapError` re-raises it); mirrors `createMigrationTable`'s rollback handling. - Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), - ); + yield* session.exec("BEGIN"); + for (const entry of resolved) { + const content = yield* fs.readFileString(entry.migrationPath); + const statements = splitAndTrim(content); + yield* session.query(UPSERT_MIGRATION_VERSION, [entry.version, entry.name, statements]); + } + yield* session.exec("COMMIT"); }).pipe( - Effect.mapError( - (cause) => - new DbPullWriteError({ - message: `failed to update migration table: ${cause.message}`, - }), - ), + // `Effect.ignore` keeps a ROLLBACK failure from masking the original error + // (`tapError` re-raises it); mirrors `createMigrationTable`'s rollback handling. + Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), ); - // Established output contract; printed to stderr so it never interferes with a - // machine-output payload on stdout. - const versions = resolved.map((entry) => entry.version).join(" "); - yield* output.raw(`Repaired migration history: [${versions}] => applied\n`, "stderr"); - }); + }).pipe( + Effect.mapError( + (cause) => + new DbPullWriteError({ + message: `failed to update migration table: ${cause.message}`, + }), + ), + ); + // Established output contract; printed to stderr so it never interferes with a + // machine-output payload on stdout. + const versions = resolved.map((entry) => entry.version).join(" "); + yield* output.raw(`Repaired migration history: [${versions}] => applied\n`, "stderr"); +}); diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index bc53c39887..42e93b72e9 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -95,6 +95,15 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { linkedProjectRef: flags.projectRef, }); + yield* Effect.annotateCurrentSpan({ + "db.conn_type": connType, + "db.is_local": cfg.isLocal, + "db.push.dry_run": flags.dryRun, + "db.push.include_all": flags.includeAll, + "db.push.include_roles": flags.includeRoles, + "db.push.include_seed": flags.includeSeed, + }); + yield* dbPushCore({ workdir, projectRef, diff --git a/apps/cli/src/commands/db/push/push.layers.ts b/apps/cli/src/commands/db/push/push.layers.ts index adf0d0a288..92008ab9c8 100644 --- a/apps/cli/src/commands/db/push/push.layers.ts +++ b/apps/cli/src/commands/db/push/push.layers.ts @@ -7,7 +7,7 @@ import { commandPlatformApiFactoryLayer } from "../../../auth/command-platform-a import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; import { projectRefLayer } from "../../../config/project-ref.layer.ts"; import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 2a7557820d..d021e2214d 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -127,6 +127,10 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { .queryRaw(sql) .pipe(Effect.mapError((cause) => new DbQueryExecError({ message: cause.message }))); + yield* Effect.annotateCurrentSpan({ + "query.row_count": result.rows.length, + "query.column_count": result.fields.length, + }); // DDL/DML statements expose no columns → print the command tag. if (result.fields.length === 0) { return yield* output.raw(`${result.commandTag}\n`); @@ -207,6 +211,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { return yield* output.raw(`${body}\n`); } const rows = parsed as ReadonlyArray | null>; + yield* Effect.annotateCurrentSpan("query.row_count", rows.length); if (rows.length === 0) { return yield* emit(format, [], [], agentMode, Option.none()); } @@ -334,16 +339,26 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // Mirrors the resolved format onto the global telemetry reads, or instrumentation would // report the table/human default as `text`. yield* telemetryOutputFormat.set(format); + yield* Effect.annotateCurrentSpan({ + "db.conn_type": + linkedAuth !== undefined ? "linked" : Option.isSome(flags.dbUrl) ? "db-url" : "local", + "query.format": format, + "query.agent_mode": agentMode, + }); // 3. Linked queries go through the Management API; local/--db-url connect directly. if (linkedAuth !== undefined) { - return yield* runLinked(sql, format, agentMode, linkedAuth.ref, linkedAuth.token); + return yield* runLinked(sql, format, agentMode, linkedAuth.ref, linkedAuth.token).pipe( + Effect.withSpan("db.query.runLinked"), + ); } if (localTarget === undefined) { // Unreachable: the non-linked branch always resolves a target above. return yield* Effect.die(new Error("db query: connection target was not resolved")); } - return yield* runLocal(localTarget, sql, format, agentMode); + return yield* runLocal(localTarget, sql, format, agentMode).pipe( + Effect.withSpan("db.query.runLocal"), + ); }).pipe( // Writes the linked-project cache whenever a ref was resolved, whether the query succeeds or // fails — `linkedRefForCache` is only set on the linked path, so `--local`/`--db-url` never diff --git a/apps/cli/src/commands/db/query/query.layers.ts b/apps/cli/src/commands/db/query/query.layers.ts index 84654f2f51..d6d2376e1d 100644 --- a/apps/cli/src/commands/db/query/query.layers.ts +++ b/apps/cli/src/commands/db/query/query.layers.ts @@ -2,7 +2,7 @@ import { Layer } from "effect"; import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; import { linkedDbResolverRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index 26e78fbaab..a85fee1d23 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -187,6 +187,12 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { linkedProjectRef: flags.projectRef, }); + yield* Effect.annotateCurrentSpan({ + "db.conn_type": connType, + "db.is_local": cfg.isLocal, + ...(resolvedVersion !== "" ? { "migration.version": resolvedVersion } : {}), + }); + // Local target: the composition (container recreate, storage-health gate, bucket seeding, // git-branch line) is hoisted into `resetLocalDatabase`, shared with `db schema declarative`'s // recovery reset; this call site keeps only version/seed-flags plumbing and the JSON envelope. @@ -263,6 +269,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); return m?.[1] !== undefined && m[1] <= resolvedVersion; }); + yield* Effect.annotateCurrentSpan({ "migration.count": pending.length }); yield* applyMigrations(session, fs, path, pending, applyError); } @@ -276,9 +283,10 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { ); if (resolvedSeed.enabled) { const seeds = yield* getPendingSeeds(session, fs, path, resolvedSeed.sqlPaths, workdir); + yield* Effect.annotateCurrentSpan({ "seed.count": seeds.length }); yield* seedData(session, fs, workdir, path, seeds, applyError); } - }), + }).pipe(Effect.withSpan("db.reset.applyMigrations")), ); if (output.format !== "text") { 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 4e960d99b1..1f73deb1b8 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -565,7 +565,7 @@ const stackService = ( prepare: Effect.void, status: observation, followStatus: Stream.empty, - logs: Stream.empty, + readLogs: () => Stream.empty, credentials: () => Effect.succeed({}), } satisfies Omit; switch (creation.service) { diff --git a/apps/cli/src/commands/db/reset/reset.layers.ts b/apps/cli/src/commands/db/reset/reset.layers.ts index 7a2db4e800..5de6afff43 100644 --- a/apps/cli/src/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/commands/db/reset/reset.layers.ts @@ -8,7 +8,7 @@ import { commandPlatformApiFactoryLayer } from "../../../auth/command-platform-a import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; import { projectRefLayer } from "../../../config/project-ref.layer.ts"; import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts index 5d2de28330..0ac2b76df8 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts @@ -71,7 +71,7 @@ const formatImplicitExtensionLoadFailure = ( * The pg-delta engine owns both sides of the plan, planning against its scoped * migrations/declarative shadows. */ -export const diffDeclarativeToMigrations = Effect.fnUntraced(function* ( +export const diffDeclarativeToMigrations = Effect.fn("DeclarativeSchema.plan")(function* ( run: DeclarativeRunContext, toml: DbTomlValues, ) { @@ -91,6 +91,10 @@ export const diffDeclarativeToMigrations = Effect.fnUntraced(function* ( const manifest = yield* ReadPgDeltaExportManifest(fs, path, run.declarativeDir).pipe( Effect.mapError((error) => declarativeError(error.message)), ); + yield* Effect.annotateCurrentSpan({ + "file.count": files.length, + "declarative.manifest_present": manifest !== undefined, + }); const result = yield* engine .planDeclarativeSchema({ context: run.pgDelta, @@ -120,15 +124,20 @@ export const diffDeclarativeToMigrations = Effect.fnUntraced(function* ( }); }), ); + const dropWarnings = + result.hazards !== undefined + ? result.hazards.dataLoss.map((action) => action.sql) + : findDropStatements(result.sql); + yield* Effect.annotateCurrentSpan({ + "diff.empty": result.sql.trim().length === 0, + "diff.drop_statement_count": dropWarnings.length, + }); return { diffSQL: result.sql, files: result.files, sourceRef: result.sourceRef, targetRef: result.targetRef, - dropWarnings: - result.hazards !== undefined - ? result.hazards.dataLoss.map((action) => action.sql) - : findDropStatements(result.sql), + dropWarnings, manifestPresent: manifest !== undefined, removals: result.removals ?? { extensions: [], extensionIntents: [] }, } satisfies DeclarativeSyncResult; diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts index 5089c2cabf..fcaa2d654a 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts @@ -114,7 +114,7 @@ export const resolveRemoteEndpoint = Effect.fnUntraced(function* (flags: SmartTa * mode) and `sync` (no-declarative-files bootstrap) so both offer the same local/linked/custom * choice and local-reset prompt. */ -export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( +export const resolveSmartTargetEndpoint = Effect.fn("DeclarativeSchema.smartTarget")(function* ( flags: SmartTargetFlags, local: LocalConn, hasMigrations: boolean, @@ -125,6 +125,7 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( beforeLocalTarget: Effect.Effect = Effect.void, ) { if (!hasMigrations) { + yield* Effect.annotateCurrentSpan("declarative.target", "local"); // No migrations: generate from local, starting a stopped stack first. yield* beforeLocalTarget; yield* (yield* DeclarativeSeam).ensureLocalDatabaseStarted; @@ -152,6 +153,7 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( : []), { value: "custom", label: "Custom database URL", hint: "enter a connection string" }, ]); + yield* Effect.annotateCurrentSpan("declarative.target", choice); if (choice === "linked") { // Same path as an explicit `--linked`: login-role mint + pooler fallback, then the resolved URL. @@ -195,6 +197,7 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( false, ); } + yield* Effect.annotateCurrentSpan("declarative.local_reset", shouldReset); if (shouldReset) { // `resetLocalDatabase` runs in-process, sharing this command's own context: it resolves // `NetworkIdFlag` itself, so no argv-forwarding is needed to stay on a custom Docker network, diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts index fb34c77bfd..f5152f5bc9 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts @@ -162,6 +162,10 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene const hasExplicitTarget = Option.isSome(flags.local) || Option.isSome(flags.linked) || Option.isSome(flags.dbUrl); + yield* Effect.annotateCurrentSpan({ + "declarative.target_mode": hasExplicitTarget ? "explicit" : "smart", + "schema.count": flags.schema.length, + }); let target: PgDeltaDatabaseEndpoint; let overwrite: boolean; diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 7344c9711b..f6838085f6 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -112,7 +112,7 @@ function generateStackApi(workdir: string) { saveSnapshot: unusedStackFn, restoreSnapshot: unusedStackFn, resetData: unusedStack, - logs: Stream.empty, + readLogs: () => Stream.empty, followStatus: Stream.empty, status: Effect.succeed({ id: "primary", diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index cdbd06e22a..508ecaad0f 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -164,6 +164,10 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent; yield* warnFormerDeclarativeDefault(fs, path, cliSettings.workdir, toml.pgDelta); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); + yield* Effect.annotateCurrentSpan({ + "declarative.bootstrap": !declarativeFilesExist, + "schema.count": flags.schema.length, + }); // Warns (rather than masking the apply error) and treats the bundle path as empty when the // debug directory cannot be created, so an apply failure still surfaces without claiming a @@ -248,7 +252,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f } // Step 2: diff migrations state vs declarative; on error, save a debug bundle. - const stageNextExport = Effect.fnUntraced(function* () { + const stageNextExport = Effect.fn("db.schema.declarative.sync.stageNextExport")(function* () { const stagedDir = path.resolve(cliSettings.workdir, stagedDirRel); // Reject the active directory itself AND anything nested under it: a // staged export inside the declarative tree would be loaded recursively @@ -494,6 +498,10 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f result = replanned.value; } + yield* Effect.annotateCurrentSpan({ + "diff.empty": result.diffSQL.trim().length < 2, + "diff.drop_statement_count": result.dropWarnings.length, + }); // Step 3: empty diff. if (result.diffSQL.trim().length < 2) { yield* output.raw("No schema changes found\n", "stderr"); @@ -563,6 +571,10 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f : yield* output.promptConfirm("Apply this migration to local database?", { defaultValue: true, }); + yield* Effect.annotateCurrentSpan({ + "migration.count": migrationPaths.length, + "declarative.apply": shouldApply, + }); if (!shouldApply) return; // Step 8: apply the migration to the local database (native). @@ -728,4 +740,9 @@ const applyMigrationToLocal = ( (message) => new DeclarativeApplyError({ message }), ); } - }).pipe(Effect.scoped); + }).pipe( + Effect.scoped, + Effect.withSpan("db.schema.declarative.sync.apply", { + attributes: { "migration.count": migrationPaths.length }, + }), + ); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 85ebc7eaaa..c7035c2c4f 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -114,7 +114,7 @@ function syncStackApi(workdir: string, port: number) { saveSnapshot: unusedSyncFn, restoreSnapshot: unusedSyncFn, resetData: unusedSync, - logs: Stream.empty, + readLogs: () => Stream.empty, followStatus: Stream.empty, status: Effect.succeed({ id: "primary", diff --git a/apps/cli/src/commands/db/shared/debug-bundle.ts b/apps/cli/src/commands/db/shared/debug-bundle.ts index 6f3bd3fe71..d894841427 100644 --- a/apps/cli/src/commands/db/shared/debug-bundle.ts +++ b/apps/cli/src/commands/db/shared/debug-bundle.ts @@ -42,7 +42,7 @@ const copyBestEffort = (fs: FileSystem.FileSystem, from: string, to: string): Ef * top-level directory is fatal (so callers don't claim a bundle was saved), while every * individual artifact write is best-effort (a failed copy must not mask the original error). */ -export const saveDebugBundle = Effect.fnUntraced(function* ( +export const saveDebugBundle = Effect.fn("DebugBundle.save")(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, diff --git a/apps/cli/src/commands/db/shared/drop-schemas.ts b/apps/cli/src/commands/db/shared/drop-schemas.ts index a5a27ae626..eaecc8014d 100644 --- a/apps/cli/src/commands/db/shared/drop-schemas.ts +++ b/apps/cli/src/commands/db/shared/drop-schemas.ts @@ -23,4 +23,7 @@ export const dropUserSchemas = ( .exec(dropObjectsSql) .pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); yield* session.exec("COMMIT"); - }).pipe(Effect.mapError((error: DbExecError) => mapError(error.message))); + }).pipe( + Effect.mapError((error: DbExecError) => mapError(error.message)), + Effect.withSpan("DropSchemas.dropUserSchemas"), + ); diff --git a/apps/cli/src/commands/db/shared/go-string.ts b/apps/cli/src/commands/db/shared/go-string.ts deleted file mode 100644 index adfd8de329..0000000000 --- a/apps/cli/src/commands/db/shared/go-string.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Trims exactly the Unicode `White_Space` set, matching Go's - * `strings.TrimSpace`/`bytes.TrimSpace`. Unlike JS's `String.prototype.trim`, - * it does not strip U+FEFF (BOM/ZWNBSP), so a BOM-prefixed value is left untouched. - */ -export const trimGoSpace = (value: string): string => - value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""); diff --git a/apps/cli/src/commands/db/shared/migra.ts b/apps/cli/src/commands/db/shared/migra.ts index 0b7aa47d25..57d1814e56 100644 --- a/apps/cli/src/commands/db/shared/migra.ts +++ b/apps/cli/src/commands/db/shared/migra.ts @@ -1,5 +1,12 @@ import { Config, Effect, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { isBitbucketPipeline } from "../../../command-internal/bitbucket-pipeline.ts"; +import { + COMPOSE_PROJECT_LABEL, + ensureVolume, +} from "../../../command-internal/db-bootstrap/container-lifecycle.ts"; +import { CLI_PROJECT_LABEL } from "../../../command-internal/docker-ids.ts"; import { NetworkIdFlag } from "../../../command-internal/global-flags.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { @@ -128,7 +135,7 @@ const containerDatabaseUrl = Effect.fnUntraced(function* (url: string) { }); /** Builds the shared SOURCE/TARGET/SSL/schema env for both migra paths. */ -const buildMigraEnv = Effect.fnUntraced(function* (params: { +const buildMigraEnv = Effect.fn("Migra.buildEnv")(function* (params: { readonly source: string; readonly target: string; readonly schema: ReadonlyArray; @@ -154,7 +161,7 @@ const buildMigraEnv = Effect.fnUntraced(function* (params: { * Loads the target's user-defined schemas for the bash fallback: migra.sh iterates over an * explicit schema list and cannot diff in exclude mode. */ -const loadTargetUserSchemas = Effect.fnUntraced(function* ( +const loadTargetUserSchemas = Effect.fn("Migra.loadTargetUserSchemas")(function* ( target: string, connectOptions: DbConnectOptions, ) { @@ -186,7 +193,7 @@ const loadTargetUserSchemas = Effect.fnUntraced(function* ( * network. When no `--schema` is given, the included schemas are loaded from the target and * passed as positional args to migra.sh. */ -const diffMigraBash = Effect.fnUntraced(function* (params: { +const diffMigraBash = Effect.fn("Migra.diffBash")(function* (params: { readonly source: string; readonly target: string; readonly schema: ReadonlyArray; @@ -247,12 +254,37 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { return new TextDecoder().decode(result.stdout); }); +/** Pre-creates the project-labeled Deno-cache volume so `stop --no-backup` prunes it. */ +const ensureCacheVolume = Effect.fn("Migra.ensureCacheVolume")(function* (ctx: PgDeltaContext) { + const inBitbucket = yield* isBitbucketPipeline(ctx.projectEnv).pipe( + Effect.mapError( + (error) => + new MigraDiffError({ + message: `error diffing schema: failed to resolve Docker environment: ${error.message}`, + }), + ), + ); + if (inBitbucket) return; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* ensureVolume(spawner, edgeRuntimeId(ctx.projectId), { + [CLI_PROJECT_LABEL]: ctx.projectId, + [COMPOSE_PROJECT_LABEL]: ctx.projectId, + }).pipe( + Effect.mapError((cause) => { + const message = `error diffing schema: ${cause.message}`; + return cause.reason === "runtime" + ? new MigraDiffError({ message, docker: "daemon" }) + : new MigraDiffError({ message }); + }), + ); +}); + /** * Diffs SOURCE → TARGET with migra via the edge-runtime template, falling back to the * `supabase/migra` Docker image when the edge-runtime worker runs out of memory. * `source`/`target` are live Postgres URLs (the shadow source and the diff target). */ -export const diffMigra = Effect.fnUntraced(function* ( +export const diffMigra = Effect.fn("Migra.diff")(function* ( ctx: PgDeltaContext, params: { readonly source: string; @@ -263,6 +295,7 @@ export const diffMigra = Effect.fnUntraced(function* ( ) { const edgeRuntime = yield* EdgeRuntimeScript; const env = yield* buildMigraEnv(params); + yield* ensureCacheVolume(ctx); const result = yield* edgeRuntime .run({ script: migraDiffScript, diff --git a/apps/cli/src/commands/db/shared/pgdelta-declarative-shadow-prep.ts b/apps/cli/src/commands/db/shared/pgdelta-declarative-shadow-prep.ts index 773c05268d..056d7c98b5 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-declarative-shadow-prep.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-declarative-shadow-prep.ts @@ -127,25 +127,27 @@ const queryShadow = (client: DeclarativeShadowClient, sql: string) => catch: (cause) => queryError(sql, cause), }); -export const prepareDeclarativeShadow = ( +export const prepareDeclarativeShadow = Effect.fn("PgDeltaDeclarativeShadow.prepare")(function* ( client: DeclarativeShadowClient, - files: ReadonlyArray<{ readonly name: string; readonly sql: string }>, -) => - Effect.gen(function* () { - const declared = declaredImageExtensions(files); - if (declared.size === 0) return { restorePgjwt: false } satisfies DeclarativeShadowPrepResult; - let restorePgjwt = false; - if (declared.has("pgcrypto") && !declared.has("pgjwt")) { - const installed = yield* queryShadow(client, INSTALLED_PGJWT_SQL); - restorePgjwt = rowHasPgjwt(installed.rows); - } - const versionRows = yield* queryShadow(client, "SHOW server_version"); - const statements = declarativeBaselinePrepStatements( - parsePostgresMajorVersion(readServerVersion(versionRows.rows)), - declared, - ); - for (const sql of statements) { - yield* queryShadow(client, sql); - } - return { restorePgjwt } satisfies DeclarativeShadowPrepResult; - }); + files: ReadonlyArray<{ + readonly name: string; + readonly sql: string; + }>, +) { + const declared = declaredImageExtensions(files); + if (declared.size === 0) return { restorePgjwt: false } satisfies DeclarativeShadowPrepResult; + let restorePgjwt = false; + if (declared.has("pgcrypto") && !declared.has("pgjwt")) { + const installed = yield* queryShadow(client, INSTALLED_PGJWT_SQL); + restorePgjwt = rowHasPgjwt(installed.rows); + } + const versionRows = yield* queryShadow(client, "SHOW server_version"); + const statements = declarativeBaselinePrepStatements( + parsePostgresMajorVersion(readServerVersion(versionRows.rows)), + declared, + ); + for (const sql of statements) { + yield* queryShadow(client, sql); + } + return { restorePgjwt } satisfies DeclarativeShadowPrepResult; +}); diff --git a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts index 6b7996b808..353283f9ae 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts @@ -198,9 +198,11 @@ export const pgDeltaNextEngineLayer = Layer.effect( ); }; - const diffPools = ( + const diffPools = Effect.fn("PgDeltaNextEngine.diff")(function* ( input: { - readonly context: { readonly cwd: string }; + readonly context: { + readonly cwd: string; + }; readonly schema: ReadonlyArray; readonly formatOptions: string; readonly debug: boolean; @@ -208,26 +210,29 @@ export const pgDeltaNextEngineLayer = Layer.effect( }, sourcePool: Pool, desiredPool: Pool, - ) => - Effect.gen(function* () { - const result = yield* adapter.diff({ - sourcePool, - desiredPool, - allowDrops: true, - debug: input.debug, - schema: input.schema, - formatOptions: input.formatOptions, - }); - const debugDirectory = - result.debug !== undefined - ? yield* saveDebugArtifacts(input.context.cwd, "diff", { - ...result.debug, - diagnostics: result.diagnostics, - }) - : undefined; - yield* reportDiagnostics("diff", result.diagnostics, input.strictCoverage, input.debug); - return normalizeNextDiff(result, debugDirectory); + ) { + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: true, + debug: input.debug, + schema: input.schema, + formatOptions: input.formatOptions, }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "diff", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* reportDiagnostics("diff", result.diagnostics, input.strictCoverage, input.debug); + yield* Effect.annotateCurrentSpan({ + "diff.empty": !result.changes, + "diff.hazard_count": result.hazards.kinds.length, + }); + return normalizeNextDiff(result, debugDirectory); + }); return PgDeltaEngine.of({ diffExplicit: (input) => @@ -281,7 +286,10 @@ export const pgDeltaNextEngineLayer = Layer.effect( ); return yield* diffPools(input, sourcePool, desiredPool); }), - ).pipe(Effect.mapError(pgDeltaNextEngineError)), + ).pipe( + Effect.mapError(pgDeltaNextEngineError), + Effect.withSpan("PgDeltaNextEngine.diffExplicit"), + ), diffDatabase: (input) => Effect.scoped( Effect.gen(function* () { @@ -289,7 +297,10 @@ export const pgDeltaNextEngineLayer = Layer.effect( const desiredPool = yield* acquireDatabase(input.target, input.context.projectEnv); return yield* diffPools(input, migrationsPool, desiredPool); }), - ).pipe(Effect.mapError(pgDeltaNextEngineError)), + ).pipe( + Effect.mapError(pgDeltaNextEngineError), + Effect.withSpan("PgDeltaNextEngine.diffDatabase"), + ), exportDeclarativeSchema: (input) => Effect.scoped( Effect.gen(function* () { @@ -319,7 +330,10 @@ export const pgDeltaNextEngineLayer = Layer.effect( ); return { files: result.files, manifest: result.manifest }; }), - ).pipe(Effect.mapError(pgDeltaNextEngineError)), + ).pipe( + Effect.mapError(pgDeltaNextEngineError), + Effect.withSpan("PgDeltaNextEngine.exportDeclarativeSchema"), + ), planDeclarativeSchema: (input) => Effect.scoped( Effect.gen(function* () { @@ -375,7 +389,10 @@ export const pgDeltaNextEngineLayer = Layer.effect( targetRef: "pg-delta-next:declarative", }; }), - ).pipe(Effect.mapError(pgDeltaNextEngineError)), + ).pipe( + Effect.mapError(pgDeltaNextEngineError), + Effect.withSpan("PgDeltaNextEngine.planDeclarativeSchema"), + ), }); }), ); diff --git a/apps/cli/src/commands/db/shared/pgdelta-files.ts b/apps/cli/src/commands/db/shared/pgdelta-files.ts index d96c8fcb3b..000c95f9e2 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-files.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-files.ts @@ -29,7 +29,7 @@ function parseManifestJson(raw: string): unknown { } /** Reads a next-engine export manifest from an explicit declarative directory. */ -export const ReadPgDeltaExportManifest = Effect.fnUntraced(function* ( +export const ReadPgDeltaExportManifest = Effect.fn("PgDeltaFiles.readExportManifest")(function* ( fs: FileSystem.FileSystem, path: Path.Path, directory: string, @@ -90,7 +90,7 @@ export const ReadPgDeltaExportManifest = Effect.fnUntraced(function* ( }); /** Recursively loads path-safe `.sql` files in stable POSIX-relative order. */ -export const LoadPgDeltaSqlFiles = Effect.fnUntraced(function* ( +export const LoadPgDeltaSqlFiles = Effect.fn("PgDeltaFiles.loadSqlFiles")(function* ( fs: FileSystem.FileSystem, path: Path.Path, directory: string, @@ -120,5 +120,6 @@ export const LoadPgDeltaSqlFiles = Effect.fnUntraced(function* ( ); files.push({ name, sql }); } + yield* Effect.annotateCurrentSpan({ "file.count": files.length }); return files; }); diff --git a/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts b/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts index 6696251661..84f6dee4ba 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts @@ -64,6 +64,7 @@ export const writePgDeltaMigrations = ( ): Effect.Effect, PgDeltaMigrationWriteError> => Effect.gen(function* () { const { workdir, name, files } = opts; + yield* Effect.annotateCurrentSpan({ "file.count": files.length }); for (const file of files) { if (file.transactionMode !== "transactional" && file.transactionMode !== "none") { return yield* new PgDeltaMigrationWriteError({ @@ -176,4 +177,4 @@ export const writePgDeltaMigrations = ( Effect.forEach(written, (w) => fs.remove(w.path).pipe(Effect.ignore), { discard: true }), ), ); - }); + }).pipe(Effect.withSpan("PgDeltaMigrations.write")); diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts index be454c1dc1..8816ea41aa 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts @@ -434,178 +434,173 @@ function makePgDeltaNextAdapter, ): PgDeltaNextAdapterShape { return { - diff: (input: PgDeltaNextDiffInput) => - Effect.gen(function* () { - const format = yield* attemptPgDeltaNext("diff", () => - pgDeltaNextFormatOptions(input.formatOptions), - ); - const profile = yield* tryPgDeltaNext("diff", () => - libraries.resolveProfile(input.sourcePool, { redactSecrets: true }, input.schema), - ); - const [source, desired] = yield* tryPgDeltaNext("diff", () => - Promise.all([ - profile.extract(input.sourcePool, { redactSecrets: true }), - profile.extract(input.desiredPool, { redactSecrets: true }), + diff: Effect.fn("PgDeltaNextAdapter.diff")(function* (input: PgDeltaNextDiffInput) { + const format = yield* attemptPgDeltaNext("diff", () => + pgDeltaNextFormatOptions(input.formatOptions), + ); + const profile = yield* tryPgDeltaNext("diff", () => + libraries.resolveProfile(input.sourcePool, { redactSecrets: true }, input.schema), + ); + const [source, desired] = yield* tryPgDeltaNext("diff", () => + Promise.all([ + profile.extract(input.sourcePool, { redactSecrets: true }), + profile.extract(input.desiredPool, { redactSecrets: true }), + ]), + ); + return yield* attemptPgDeltaNext("diff", () => { + const generatedPlan = libraries.plan(source.factBase, desired.factBase, { + ...profile.planOptions, + redactSecrets: true, + }); + const rendered = libraries.renderPlanFiles(generatedPlan, { + allowDrops: input.allowDrops, + }); + const renderedFiles = formatPgDeltaNextRenderedFiles(rendered.files, format); + const planDiagnostics = readPlanDiagnostics(generatedPlan); + const diagnostics = [ + ...normalizePgDeltaNextDiagnostics(source.diagnostics, "source", libraries.encodeSubject), + ...normalizePgDeltaNextDiagnostics( + desired.diagnostics, + "desired", + libraries.encodeSubject, + ), + ...normalizePgDeltaNextDiagnostics(planDiagnostics, "plan", libraries.encodeSubject), + ]; + return { + changes: rendered.changes, + sql: renderedFiles.map((file) => file.contents).join("\n\n"), + files: normalizePgDeltaNextRenderedFiles(renderedFiles), + diagnostics, + hazards: libraries.summarizeHazards(generatedPlan, [ + ...source.diagnostics, + ...desired.diagnostics, + ...planDiagnostics, ]), - ); - return yield* attemptPgDeltaNext("diff", () => { - const generatedPlan = libraries.plan(source.factBase, desired.factBase, { - ...profile.planOptions, - redactSecrets: true, - }); - const rendered = libraries.renderPlanFiles(generatedPlan, { - allowDrops: input.allowDrops, - }); - const renderedFiles = formatPgDeltaNextRenderedFiles(rendered.files, format); - const planDiagnostics = readPlanDiagnostics(generatedPlan); - const diagnostics = [ + ...(input.debug + ? { + debug: { + sourceSnapshot: libraries.serializeSnapshot(source.factBase, { + pgVersion: source.pgVersion, + redactSecrets: true, + profile: profile.id, + }), + desiredSnapshot: libraries.serializeSnapshot(desired.factBase, { + pgVersion: desired.pgVersion, + redactSecrets: true, + profile: profile.id, + }), + plan: libraries.serializePlan(generatedPlan), + }, + } + : {}), + }; + }); + }), + exportDeclarativeSchema: Effect.fn("PgDeltaNextAdapter.exportDeclarativeSchema")(function* ( + input: PgDeltaNextDeclarativeExportInput, + ) { + const result = yield* tryPgDeltaNext("declarativeExport", () => + libraries.buildSchemaExport(input.pool, pgDeltaNextExportOptions(input)), + ); + return yield* attemptPgDeltaNext("declarativeExport", () => ({ + files: result.files.map((file) => ({ name: file.name, sql: file.sql })), + manifest: { + ...result.manifest, + files: result.files.map((file) => file.name).sort(), + }, + diagnostics: normalizePgDeltaNextDiagnostics( + result.diagnostics, + "export", + libraries.encodeSubject, + ), + })); + }), + planDeclarativeSchema: Effect.fn("PgDeltaNextAdapter.planDeclarativeSchema")(function* ( + input: PgDeltaNextDeclarativePlanInput, + ) { + const format = yield* attemptPgDeltaNext("declarativePlan", () => + pgDeltaNextFormatOptions(input.formatOptions), + ); + const result = yield* tryPgDeltaNext("declarativePlan", () => + libraries.planSchemaFiles( + input.targetPool, + input.shadowPool, + input.files, + pgDeltaNextPlanOptions(input), + ), + ); + return yield* attemptPgDeltaNext("declarativePlan", () => { + const rendered = libraries.renderPlanFiles(result.plan, { + allowDrops: input.allowDrops, + }); + const renderedFiles = formatPgDeltaNextRenderedFiles(rendered.files, format); + const planDiagnostics = readPlanDiagnostics(result.plan); + const libraryDiagnostics = [ + ...result.loadDiagnostics, + ...result.targetDiagnostics, + ...result.driftDiagnostics, + ...planDiagnostics, + ]; + return { + changes: rendered.changes, + sql: renderedFiles.map((file) => file.contents).join("\n\n"), + files: normalizePgDeltaNextRenderedFiles(renderedFiles), + diagnostics: [ ...normalizePgDeltaNextDiagnostics( - source.diagnostics, - "source", + result.loadDiagnostics, + "declarativeLoad", libraries.encodeSubject, ), ...normalizePgDeltaNextDiagnostics( - desired.diagnostics, - "desired", + result.targetDiagnostics, + "declarativeTarget", libraries.encodeSubject, ), - ...normalizePgDeltaNextDiagnostics(planDiagnostics, "plan", libraries.encodeSubject), - ]; - return { - changes: rendered.changes, - sql: renderedFiles.map((file) => file.contents).join("\n\n"), - files: normalizePgDeltaNextRenderedFiles(renderedFiles), - diagnostics, - hazards: libraries.summarizeHazards(generatedPlan, [ - ...source.diagnostics, - ...desired.diagnostics, - ...planDiagnostics, - ]), - ...(input.debug - ? { - debug: { - sourceSnapshot: libraries.serializeSnapshot(source.factBase, { - pgVersion: source.pgVersion, - redactSecrets: true, - profile: profile.id, - }), - desiredSnapshot: libraries.serializeSnapshot(desired.factBase, { - pgVersion: desired.pgVersion, - redactSecrets: true, - profile: profile.id, - }), - plan: libraries.serializePlan(generatedPlan), - }, - } - : {}), - }; - }); - }), - exportDeclarativeSchema: (input: PgDeltaNextDeclarativeExportInput) => - Effect.gen(function* () { - const result = yield* tryPgDeltaNext("declarativeExport", () => - libraries.buildSchemaExport(input.pool, pgDeltaNextExportOptions(input)), - ); - return yield* attemptPgDeltaNext("declarativeExport", () => ({ - files: result.files.map((file) => ({ name: file.name, sql: file.sql })), - manifest: { - ...result.manifest, - files: result.files.map((file) => file.name).sort(), - }, - diagnostics: normalizePgDeltaNextDiagnostics( - result.diagnostics, - "export", - libraries.encodeSubject, - ), - })); - }), - planDeclarativeSchema: (input: PgDeltaNextDeclarativePlanInput) => - Effect.gen(function* () { - const format = yield* attemptPgDeltaNext("declarativePlan", () => - pgDeltaNextFormatOptions(input.formatOptions), - ); - const result = yield* tryPgDeltaNext("declarativePlan", () => - libraries.planSchemaFiles( - input.targetPool, - input.shadowPool, - input.files, - pgDeltaNextPlanOptions(input), - ), - ); - return yield* attemptPgDeltaNext("declarativePlan", () => { - const rendered = libraries.renderPlanFiles(result.plan, { - allowDrops: input.allowDrops, - }); - const renderedFiles = formatPgDeltaNextRenderedFiles(rendered.files, format); - const planDiagnostics = readPlanDiagnostics(result.plan); - const libraryDiagnostics = [ - ...result.loadDiagnostics, - ...result.targetDiagnostics, - ...result.driftDiagnostics, - ...planDiagnostics, - ]; - return { - changes: rendered.changes, - sql: renderedFiles.map((file) => file.contents).join("\n\n"), - files: normalizePgDeltaNextRenderedFiles(renderedFiles), - diagnostics: [ - ...normalizePgDeltaNextDiagnostics( - result.loadDiagnostics, - "declarativeLoad", - libraries.encodeSubject, - ), - ...normalizePgDeltaNextDiagnostics( - result.targetDiagnostics, - "declarativeTarget", - libraries.encodeSubject, - ), - ...normalizePgDeltaNextDiagnostics( - result.driftDiagnostics, - "declarativeDrift", - libraries.encodeSubject, - ), - ...normalizePgDeltaNextDiagnostics(planDiagnostics, "plan", libraries.encodeSubject), - ...skippedStatementDiagnostics(result.skipped), - ], - hazards: libraries.summarizeHazards(result.plan, libraryDiagnostics), - skipped: result.skipped.map((skipped) => ({ - file: skipped.file, - statement: skipped.stmt, - })), - removals: libraries.summarizeRemovals(result.plan), - ...(input.debug ? { debug: { plan: libraries.serializePlan(result.plan) } } : {}), - }; - }); - }), - captureSnapshot: (input: PgDeltaNextSnapshotCaptureInput) => - Effect.gen(function* () { - const profile = yield* tryPgDeltaNext("snapshotCapture", () => - libraries.resolveProfile(input.pool, { - redactSecrets: true, - skipBaseline: true, - }), - ); - const result = yield* tryPgDeltaNext("snapshotCapture", () => - profile.extract(input.pool, { redactSecrets: true }), - ); - return yield* attemptPgDeltaNext( - "snapshotCapture", - () => ({ - generation: "v2", - snapshot: libraries.serializeSnapshot(result.factBase, { - pgVersion: result.pgVersion, - redactSecrets: true, - profile: profile.id, - }), - pgVersion: result.pgVersion, - diagnostics: normalizePgDeltaNextDiagnostics( - result.diagnostics, - "snapshot", + ...normalizePgDeltaNextDiagnostics( + result.driftDiagnostics, + "declarativeDrift", libraries.encodeSubject, ), - }), - ); - }), + ...normalizePgDeltaNextDiagnostics(planDiagnostics, "plan", libraries.encodeSubject), + ...skippedStatementDiagnostics(result.skipped), + ], + hazards: libraries.summarizeHazards(result.plan, libraryDiagnostics), + skipped: result.skipped.map((skipped) => ({ + file: skipped.file, + statement: skipped.stmt, + })), + removals: libraries.summarizeRemovals(result.plan), + ...(input.debug ? { debug: { plan: libraries.serializePlan(result.plan) } } : {}), + }; + }); + }), + captureSnapshot: Effect.fn("PgDeltaNextAdapter.captureSnapshot")(function* ( + input: PgDeltaNextSnapshotCaptureInput, + ) { + const profile = yield* tryPgDeltaNext("snapshotCapture", () => + libraries.resolveProfile(input.pool, { + redactSecrets: true, + skipBaseline: true, + }), + ); + const result = yield* tryPgDeltaNext("snapshotCapture", () => + profile.extract(input.pool, { redactSecrets: true }), + ); + return yield* attemptPgDeltaNext("snapshotCapture", () => ({ + generation: "v2", + snapshot: libraries.serializeSnapshot(result.factBase, { + pgVersion: result.pgVersion, + redactSecrets: true, + profile: profile.id, + }), + pgVersion: result.pgVersion, + diagnostics: normalizePgDeltaNextDiagnostics( + result.diagnostics, + "snapshot", + libraries.encodeSubject, + ), + })); + }), }; } diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-artifacts.ts b/apps/cli/src/commands/db/shared/pgdelta-next-artifacts.ts index fa8cb440f9..14cc4105e1 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-artifacts.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-artifacts.ts @@ -39,7 +39,7 @@ interface PgDeltaNextArtifactMetadata { * Writes bundled-engine debug data below the v2 generation. These files are * diagnostics only: they are never considered catalog-cache inputs. */ -export const savePgDeltaNextDebugArtifacts = Effect.fnUntraced(function* ( +export const savePgDeltaNextDebugArtifacts = Effect.fn("PgDeltaNextArtifacts.save")(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index a0b19f1a3f..6fa1993936 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -331,77 +331,32 @@ export const pgDeltaNextShadowLayer = Layer.effect( }); return PgDeltaNextShadow.of({ - provisionMigrations: (opts) => - Effect.gen(function* () { - const backend = yield* currentStackBackend; - const port = backend.kind === "stack" ? 0 : yield* nextPort(); - const built = yield* buildNativeBase(opts); - const input = buildNativeInput(opts, built, port); - return backend.kind === "stack" - ? yield* stackProvisionMigrations(input, cacheOpts(opts, "config")) - : yield* provisionMigrations(input, cacheOpts(opts, "config")); - }).pipe(Effect.mapError(nextShadowError)), - provisionPlan: (opts) => - Effect.gen(function* () { - const backend = yield* currentStackBackend; - const migrationsPort = backend.kind === "stack" ? 0 : yield* nextPort(); - const declarativePort = backend.kind === "stack" ? 0 : yield* nextPort(migrationsPort); - const built = yield* buildNativeBase(opts); - const migrationsInput = buildNativeInput(opts, built, migrationsPort); - const declarativeInput = buildNativeInput(opts, built, declarativePort); - if (backend.kind === "stack") { - const migrations = yield* stackProvisionMigrations( - migrationsInput, - cacheOpts(opts, "config"), - ); - const declarative = yield* stackProvisionDeclarative( - declarativeInput, - cacheOpts(opts, "disabled"), - ); - return { - migrationsUrl: migrations.migrationsUrl, - declarativeUrl: declarative.declarativeUrl, - allowSameDatabaseIdentity: allowSameDatabaseIdentityForPlanShadows({ - declarativeRestoredFromPgDataSnapshot: declarative.restoredFromPgDataSnapshot, - sameSnapshotKey: - migrations.snapshotKey !== undefined && - migrations.snapshotKey === declarative.snapshotKey, - }), - } satisfies PgDeltaNextPlanShadows; - } - const [migrationsPeek, declarativePeek] = yield* Effect.all([ - peekShadowBaseline(migrationsInput.base, cacheOpts(opts, "config")), - peekShadowBaseline(declarativeInput.base, cacheOpts(opts, "disabled")), - ]); - const withPeek = (cache: ShadowCacheOpts, peek: ShadowBaselinePeek): ShadowCacheOpts => - peek.state === "uncachable" - ? cache - : { ...cache, precomputedKeyInputs: peek.keyInputs }; - const strategy = resolvePlanShadowStrategy(migrationsPeek, declarativePeek); - // Peeked inputs are reused only when acquire immediately follows peek: always for - // migrations, only under `parallel` for declarative. Delayed declarative acquires - // re-resolve so a mid-run `roles.sql` edit can't publish under a stale key — identity - // still comes from the acquired handles' snapshot keys, so this can't lie about lineage. - const migrationsOpts = withPeek(cacheOpts(opts, "config"), migrationsPeek); - const declarativeOpts = - strategy === "parallel" - ? withPeek(cacheOpts(opts, "disabled"), declarativePeek) - : cacheOpts(opts, "disabled"); - - const buffered = strategy === "sequential" ? undefined : bufferedShadowOutput(output); - const provisions = runPlanShadowProvisions({ - strategy, - provisionMigrations: (onBaselineSeam) => - provisionMigrations(migrationsInput, migrationsOpts, onBaselineSeam), - provisionDeclarative: provisionDeclarative( - declarativeInput, - declarativeOpts, - buffered === undefined ? output : buffered.output, - ), - }); - const [migrations, declarative] = yield* buffered === undefined - ? provisions - : provisions.pipe(Effect.ensuring(buffered.flush)); + provisionMigrations: Effect.fn("PgDeltaNextShadow.provisionMigrations")(function* (opts) { + const backend = yield* currentStackBackend; + const port = backend.kind === "stack" ? 0 : yield* nextPort(); + const built = yield* buildNativeBase(opts); + const input = buildNativeInput(opts, built, port); + return backend.kind === "stack" + ? yield* stackProvisionMigrations(input, cacheOpts(opts, "config")) + : yield* provisionMigrations(input, cacheOpts(opts, "config")); + }, Effect.mapError(nextShadowError)), + provisionPlan: Effect.fn("PgDeltaNextShadow.provisionPlan")(function* (opts) { + const backend = yield* currentStackBackend; + const migrationsPort = backend.kind === "stack" ? 0 : yield* nextPort(); + const declarativePort = backend.kind === "stack" ? 0 : yield* nextPort(migrationsPort); + const built = yield* buildNativeBase(opts); + const migrationsInput = buildNativeInput(opts, built, migrationsPort); + const declarativeInput = buildNativeInput(opts, built, declarativePort); + yield* Effect.annotateCurrentSpan({ "stack.backend": backend.kind }); + if (backend.kind === "stack") { + const migrations = yield* stackProvisionMigrations( + migrationsInput, + cacheOpts(opts, "config"), + ); + const declarative = yield* stackProvisionDeclarative( + declarativeInput, + cacheOpts(opts, "disabled"), + ); return { migrationsUrl: migrations.migrationsUrl, declarativeUrl: declarative.declarativeUrl, @@ -412,7 +367,49 @@ export const pgDeltaNextShadowLayer = Layer.effect( migrations.snapshotKey === declarative.snapshotKey, }), } satisfies PgDeltaNextPlanShadows; - }).pipe(Effect.mapError(nextShadowError)), + } + const [migrationsPeek, declarativePeek] = yield* Effect.all([ + peekShadowBaseline(migrationsInput.base, cacheOpts(opts, "config")), + peekShadowBaseline(declarativeInput.base, cacheOpts(opts, "disabled")), + ]); + const withPeek = (cache: ShadowCacheOpts, peek: ShadowBaselinePeek): ShadowCacheOpts => + peek.state === "uncachable" ? cache : { ...cache, precomputedKeyInputs: peek.keyInputs }; + const strategy = resolvePlanShadowStrategy(migrationsPeek, declarativePeek); + yield* Effect.annotateCurrentSpan({ "shadow.plan_strategy": strategy }); + // Peeked inputs are reused only when acquire immediately follows peek: always for + // migrations, only under `parallel` for declarative. Delayed declarative acquires + // re-resolve so a mid-run `roles.sql` edit can't publish under a stale key — identity + // still comes from the acquired handles' snapshot keys, so this can't lie about lineage. + const migrationsOpts = withPeek(cacheOpts(opts, "config"), migrationsPeek); + const declarativeOpts = + strategy === "parallel" + ? withPeek(cacheOpts(opts, "disabled"), declarativePeek) + : cacheOpts(opts, "disabled"); + const buffered = strategy === "sequential" ? undefined : bufferedShadowOutput(output); + const provisions = runPlanShadowProvisions({ + strategy, + provisionMigrations: (onBaselineSeam) => + provisionMigrations(migrationsInput, migrationsOpts, onBaselineSeam), + provisionDeclarative: provisionDeclarative( + declarativeInput, + declarativeOpts, + buffered === undefined ? output : buffered.output, + ), + }); + const [migrations, declarative] = yield* buffered === undefined + ? provisions + : provisions.pipe(Effect.ensuring(buffered.flush)); + return { + migrationsUrl: migrations.migrationsUrl, + declarativeUrl: declarative.declarativeUrl, + allowSameDatabaseIdentity: allowSameDatabaseIdentityForPlanShadows({ + declarativeRestoredFromPgDataSnapshot: declarative.restoredFromPgDataSnapshot, + sameSnapshotKey: + migrations.snapshotKey !== undefined && + migrations.snapshotKey === declarative.snapshotKey, + }), + } satisfies PgDeltaNextPlanShadows; + }, Effect.mapError(nextShadowError)), }); }), ); 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 8073f19f52..9b4a0acf11 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 @@ -15,7 +15,7 @@ import { } from "../../../command-internal/global-flags.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { parseConnectionString } from "../../../command-internal/db-config.parse.ts"; import { StackApi, stackApiLayer } from "../../../command-internal/stack-api.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; 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 1223142205..a493fd8ea4 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -139,7 +139,7 @@ export const declarativeSeamLayer = Layer.effect( }), ), ); - }), + }).pipe(Effect.withSpan("DeclarativeSeam.ensureLocalDatabaseStarted")), ensureLocalPostgresImageCurrent: Effect.gen(function* () { const backend = yield* currentStackBackend; if (backend.kind === "stack") return; @@ -286,7 +286,7 @@ export const declarativeSeamLayer = Layer.effect( }); }), ); - }), + }).pipe(Effect.withSpan("DeclarativeSeam.ensureLocalPostgresImageCurrent")), }); }), ).pipe(Layer.provide(stackApiLayer)); diff --git a/apps/cli/src/commands/db/shared/pgdelta.write.ts b/apps/cli/src/commands/db/shared/pgdelta.write.ts index 326fa6c335..06da3da61f 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.write.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.write.ts @@ -82,7 +82,7 @@ const readManagedDeclarativeSqlFiles = Effect.fnUntraced(function* ( * files were preserved (see {@link warnPreservedUnmanagedDeclarativeFiles}). Never touches * `[db.migrations] schema_paths`; `db pull --declarative` handles that separately. */ -export const writeDeclarativeSchemas = Effect.fnUntraced(function* ( +export const writeDeclarativeSchemas = Effect.fn("PgDelta.writeDeclarativeSchemas")(function* ( fs: FileSystem.FileSystem, path: Path.Path, declarativeDir: string, @@ -147,6 +147,11 @@ export const writeDeclarativeSchemas = Effect.fnUntraced(function* ( yield* fs.makeDirectory(declarativeDir, { recursive: true }); const changed = new Set([...classification.created, ...classification.updated]); + yield* Effect.annotateCurrentSpan({ + "file.count": proposed.length, + "file.changed_count": changed.size, + "file.removed_count": classification.removed.length, + }); for (const file of proposed) { if (!changed.has(file.name)) continue; const targetPath = path.join(declarativeDir, file.name); @@ -253,7 +258,9 @@ const SCHEMA_PATHS_PATTERN = /\nschema_paths = \[[\s\S]*?\]\n/g; * literal byte-edit, not a TOML re-serialize, so the rest of the file's comments and * formatting are preserved exactly. */ -export const updateDeclarativeSchemaPathsConfig = Effect.fnUntraced(function* ( +export const updateDeclarativeSchemaPathsConfig = Effect.fn( + "PgDelta.updateDeclarativeSchemaPathsConfig", +)(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, diff --git a/apps/cli/src/commands/db/shared/pooler-fallback.ts b/apps/cli/src/commands/db/shared/pooler-fallback.ts index 69381230a6..cd374fb157 100644 --- a/apps/cli/src/commands/db/shared/pooler-fallback.ts +++ b/apps/cli/src/commands/db/shared/pooler-fallback.ts @@ -41,7 +41,7 @@ export const isDirectLinkedHost = (params: { * warning and retries once via `runWithConn`. Otherwise returns the original `result` * unchanged, so the caller's failure classification always reads the correct stderr. */ -export const runWithPoolerFallback = Effect.fnUntraced(function* (params: { +export const runWithPoolerFallback = Effect.fn("PoolerFallback.run")(function* (params: { /** The first attempt's result; returned unchanged when no fallback fires. */ readonly result: PoolerFallbackResult; readonly connType: DbConnType; diff --git a/apps/cli/src/commands/db/shared/shadow-source.ts b/apps/cli/src/commands/db/shared/shadow-source.ts index 7dcf6df614..e6483fc8a7 100644 --- a/apps/cli/src/commands/db/shared/shadow-source.ts +++ b/apps/cli/src/commands/db/shared/shadow-source.ts @@ -83,6 +83,10 @@ export const prepareShadowSource = ( > => Effect.gen(function* () { const { containerId } = handle; + yield* Effect.annotateCurrentSpan({ + "shadow.migration_mode": input.migrationMode ?? "legacy", + "shadow.target_local": input.targetLocal, + }); const connConfig: PgConnInput = { host: input.hostname, @@ -140,7 +144,7 @@ export const prepareShadowSource = ( sourceUrl, targetUrlOverride, } satisfies ShadowSourceResult; - }); + }).pipe(Effect.withSpan("ShadowSource.prepare")); /** Glob metacharacters recognized here: `*?[` only — not `\`. */ function hasConfigGlobMeta(pattern: string): boolean { @@ -331,7 +335,7 @@ export function loadDeclaredSchemas( ); if (!isSchemasDir) return []; return yield* walkSqlFilesSorted(fs, path, workdir, schemasDirRel, "failed to walk dir"); - }); + }).pipe(Effect.withSpan("ShadowSource.loadDeclaredSchemas")); } /** diff --git a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md index 05ade20e61..c0f7385151 100644 --- a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ composition reuses too — see that command's `SIDE_EFFECTS.md`): container is created on this path. 4. Print `Starting database...` (fresh volume) or `Starting database from backup...` (existing volume — despite the wording, unrelated to `--from-backup`; see - `command-internal/db-bootstrap/messages.ts`). + `command-internal/db-bootstrap/start-database.ts`). 5. Resolve the Postgres image (version-pin-aware) and create + start the container. `--from-backup` set: a THIRD entrypoint variant (`buildPostgresStartContainerSpec`'s `fromBackup` branch) — schema.sql + `_supabase.sql` (no `webhook.sql`), a ported diff --git a/apps/cli/src/commands/db/start/start.handler.ts b/apps/cli/src/commands/db/start/start.handler.ts index ced7190d4c..f6c543fbdc 100644 --- a/apps/cli/src/commands/db/start/start.handler.ts +++ b/apps/cli/src/commands/db/start/start.handler.ts @@ -25,6 +25,10 @@ export const dbStart = Effect.fn("db.start")(function* (flags: DbStartFlags) { const body = Effect.gen(function* () { const backend = yield* currentStackBackend; const fromBackup = Option.getOrUndefined(flags.fromBackup); + yield* Effect.annotateCurrentSpan({ + "stack.backend": backend.kind, + "db.start.from_backup": fromBackup !== undefined && fromBackup.length > 0, + }); if (backend.kind === "stack") { if (fromBackup !== undefined && fromBackup.length > 0) { return yield* new DbStartFromBackupUnsupportedError({ 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 debb4eae72..48f591f4dc 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -1633,7 +1633,7 @@ describe("db start stack backend", () => { registered: true, })), followStatus: Stream.empty, - logs: Stream.empty, + readLogs: () => Stream.empty, credentials: () => Effect.sync(() => { state.credentialsCalled = true; diff --git a/apps/cli/src/commands/db/start/start.layers.ts b/apps/cli/src/commands/db/start/start.layers.ts index 41c3eb2661..bb56504df5 100644 --- a/apps/cli/src/commands/db/start/start.layers.ts +++ b/apps/cli/src/commands/db/start/start.layers.ts @@ -4,7 +4,7 @@ import { localDockerEngineLayer } from "../../../command-internal/db-bootstrap/l import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { commandSettingsLayer } from "../../../config/command-settings.layer.ts"; import { httpClientLayer } from "../../../auth/http-debug.layer.ts"; -import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { dbConnectionLayer } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts index b86d13a8d0..02d4e86192 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -150,11 +150,13 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu // Skipped only when the GET confirmed nothing exists; an unreadable compute // still gets the DELETE, since that's the request the credential may hold. - if (deployed !== undefined || !lookup.readable) { + const deleteSent = deployed !== undefined || !lookup.readable; + if (deleteSent) { const deleting = yield* output.task("Deleting compute..."); yield* deleteCompute(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); yield* deleting.clear; } + yield* Effect.annotateCurrentSpan({ "compute.confirmed": yes, "compute.deleted": deleteSent }); // A compute deployed from another checkout has neither a local entry nor a // local directory, so there is nothing here that was kept. diff --git a/apps/cli/src/commands/experimental/compute/list/list.handler.ts b/apps/cli/src/commands/experimental/compute/list/list.handler.ts index 296e4ad1fb..05e68e5766 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.handler.ts @@ -143,6 +143,8 @@ export const computeList = Effect.fn("compute.list")(function* (flags: ComputeLi }; }); + yield* Effect.annotateCurrentSpan({ "compute.count": rows.length }); + const payload = { project_ref: projectRef, compute: rows.map((row) => ({ diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts index cd26ce471b..41c5e68ada 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts @@ -265,6 +265,11 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( } } + yield* Effect.annotateCurrentSpan({ + "compute.log_count": entries.length, + "compute.follow": flags.follow, + }); + const payload = { compute_name: name, project_ref: projectRef, @@ -328,7 +333,9 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( // window and filtering by actual timestamp lets both cases coexist. const followFloorMs = flags.tail === 0 ? startedAtMs : Number.NEGATIVE_INFINITY; + let pollAttempts = 0; const pollOnce = Effect.gen(function* () { + pollAttempts += 1; const cursor = yield* Ref.get(newestSeen); // One request only ever answers with the newest page of its window, so a @@ -416,8 +423,10 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( // A 429 or a blip should not end a tail the user is watching; the spaced // schedule rides out a transient failure without spending the rate limit. // Anything definitive surfaces on the first attempt. + // A tail can run for hours, so polls emit no request spans; the attempt count stands in. const poll = pollOnce.pipe( Effect.retry({ schedule: readRetrySchedule, while: isRetryableFollowFailure }), + Effect.withTracerEnabled(false), ); // `repeat` runs the body before applying the schedule, so the first poll is @@ -436,6 +445,10 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( .pipe( Effect.flatMap((signal) => processControl.setExitCode(signal === "SIGINT" ? 130 : 0)), ), + ).pipe( + Effect.ensuring( + Effect.suspend(() => Effect.annotateCurrentSpan({ "poll.attempt_count": pollAttempts })), + ), ); }).pipe(Effect.ensuring(linkedProjectCache.cache(projectRef))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts index 2235925289..1efe2a159a 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts @@ -11,6 +11,7 @@ import { DateTime, Schema, Stdio, + Tracer, } from "effect"; import { Command } from "effect/unstable/cli"; import { @@ -796,6 +797,42 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("traces the history read but records follow polls only as a count", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [LOGS_ROUTE]: [ + logsResponse([computeLogRow({ id: "a", tsMs: T1, message: "first" })]), + logsResponse([computeLogRow({ id: "b", tsMs: T2, message: "second" })]), + ], + }, + }); + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + + yield* computeLogs(flags({ follow: true }), followFor(3)).pipe( + Effect.provide(layer), + Effect.withTracer(tracer), + Effect.withTracerEnabled(true), + ); + + expect(spans.map((span) => span.name)).toEqual([ + "compute.logs", + "v1GetProjectLogs", + "http.client GET", + ]); + expect(spans[0]?.attributes.get("poll.attempt_count")).toBe(4); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("does not reprint a line an overlapping window returns again", () => Effect.gen(function* () { const repo = yield* project(); diff --git a/apps/cli/src/commands/experimental/compute/new/new.handler.ts b/apps/cli/src/commands/experimental/compute/new/new.handler.ts index 1711ea0e95..e08d23720a 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.handler.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.handler.ts @@ -341,6 +341,12 @@ export const computeNew = Effect.fn("compute.new")(function* (flags: ComputeNewF // Everything below this line changes the user's disk, and nothing below it // can fail for a reason the plan above could have caught. const starters = Object.entries(COMPUTE_STACKS[runtime]); + yield* Effect.annotateCurrentSpan({ + "compute.runtime": runtime, + "compute.size": size, + "compute.exposure": exposure, + "compute.starter_file_count": starters.length, + }); const destinationExisted = yield* fs.exists(destination); const removeScaffold = Effect.gen(function* () { for (const [filename] of starters) { diff --git a/apps/cli/src/commands/experimental/compute/push/push.handler.ts b/apps/cli/src/commands/experimental/compute/push/push.handler.ts index 2d33bef76f..3afe8aee0a 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.handler.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.handler.ts @@ -217,7 +217,7 @@ function addYourCode(sourceDisplay: string): string { return `Add your compute's code to ${sourceDisplay}, then run this command again.`; } -const deployOneCompute = Effect.fnUntraced(function* (input: { +const deployOneCompute = Effect.fn("compute.push.deployOne")(function* (input: { readonly project: ComputeProject; readonly name: string; readonly projectRef: string; @@ -324,6 +324,13 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { // finding it out mid-walk would mean reporting it after the packaging step announced itself. const exclude = yield* compileComputeExclude({ name, patterns: compute.entry?.exclude }); + yield* Effect.annotateCurrentSpan({ + "compute.runtime": runtime, + "compute.size": size, + "compute.exposure": exposure, + "compute.instances": instances, + }); + let contextUploadId: string; { const packaging = yield* output.task("Packaging compute..."); @@ -331,6 +338,11 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { Effect.tapError(() => packaging.fail()), ); yield* packaging.clear; + yield* Effect.annotateCurrentSpan({ + "compute.file_count": packaged.fileCount, + "compute.excluded_count": packaged.excludedCount, + "compute.bundle_bytes": packaged.archive.length, + }); // The excluded count rides along on the same line, and only when patterns are configured: // an over-broad pattern is otherwise visible only as a file count nobody had a number to // compare against, and by then the archive is already uploaded. @@ -409,6 +421,11 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { : Effect.void, }).pipe(Effect.tapError(() => deploying.fail())); + yield* Effect.annotateCurrentSpan({ + "compute.waited": !input.noWait && accepted.buildState === "building", + "compute.build_state": settled.buildState, + }); + // Checked regardless of whether the build was waited on: the verdict can // arrive on the deploy response as readily as on a poll. if (settled.buildState === "failed") { diff --git a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md index 8f7f722738..fbd23fd571 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -6,7 +6,7 @@ files remain. The experimental feature flag controls command registration. ## Selection and confirmation -Select the current project/branch/name, `--stack `, or `--stack-id `. +Select the current project/branch/name, `--stack `, or `--stack-id `. The selectors are mutually exclusive; a missing target fails. Explicit legacy `-o/--output` is rejected in favor of `--output-format`. diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts index 3a05bced3d..d32d57f57e 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -13,7 +13,7 @@ const config = { Flag.optional, ), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Destroy an existing stack by id."), + Flag.withDescription("Destroy an existing stack by id or unique id prefix."), Flag.optional, ), } as const; diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts index 606b0da0f8..07cde13aee 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -107,6 +107,10 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( ), Effect.mapError(destroyError), ); + yield* Effect.annotateCurrentSpan({ + "stack.prompted": !yes, + "stack.runtime_cleanup": result.runtimeCleanup, + }); if (result.runtimeCleanup === "skipped") yield* output.warn(skippedRuntimeCleanupWarning(`stack ${target.id}`, result)); if (output.format !== "text") diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts index 402938083a..b09a00292a 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.integration.test.ts @@ -119,6 +119,27 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); + it.live("destroys only the stack addressed by the short ID that stack list shows", () => + Effect.gen(function* () { + const f = yield* fixture(true); + const other = yield* f.api.create({ + ...f.locations, + projectRoot: f.root, + name: "other", + runtime: "native", + }); + + yield* stackDestroy({ ...f.flags, stackId: Option.some(f.stack.id.slice(0, 8)) }).pipe( + Effect.provide(f.layer), + ); + + expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ + other.id, + ]); + expect(f.output.stdoutText).toContain(`Stack ${f.stack.id} destroyed.`); + }).pipe(Effect.provide(live)), + ); + it.live( "destroys a live namespace and standalone services while preserving caller-owned uploads", () => diff --git a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md index d4ada831f3..a006346557 100644 --- a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md @@ -19,8 +19,8 @@ Entries contain `id`, `project_root`, `name`, `branch_context`, `runtime` (nativ docker, or podman), and `owner` (reachable or unavailable). Owner availability is not service lifecycle or health. Entries sort by project root, name, then ID. Text shows NAME, PROJECT, BRANCH, RUNTIME, OWNER, and a compact ID. An empty -registry prints `No managed stacks found.` Use `--output-format json` to obtain -the full ID required by `--stack-id`; the text column shows only a prefix. +registry prints `No managed stacks found.` The text column shows an ID prefix that +`--stack-id` accepts while it stays unique; `--output-format json` shows full IDs. JSON emits `{ "stacks": [...], "message": "" }`; stream-json wraps that data in one result event. State entries that cannot be read or decoded are skipped with a warning on diff --git a/apps/cli/src/commands/experimental/stack/list/list.handler.ts b/apps/cli/src/commands/experimental/stack/list/list.handler.ts index 0d8b268265..a0bb647a19 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.handler.ts @@ -58,6 +58,7 @@ export const stackList = Effect.fn("experimental.stack.list")(function* () { } return 0; }); + yield* Effect.annotateCurrentSpan({ "stack.count": stacks.length }); if (output.format === "text") { yield* output.raw( stacks.length === 0 diff --git a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md index 2a9a9e92d3..d924a73b05 100644 --- a/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md @@ -1,42 +1,80 @@ # `supabase stack logs` -Streams live stdout/stderr from the selected saved stack. The experimental -feature flag controls command registration. It connects to an existing owner; -it never launches an owner or starts/stops a service. +Prints the retained service logs of the selected saved stack and exits; with +`-f/--follow` it then streams new lines until interrupted. The experimental +feature flag controls command registration. It never launches an owner or +starts/stops a service. ## Selection and files -Select the current project/branch/name, `--stack `, or `--stack-id `. +Select the current project/branch/name, `--stack `, or `--stack-id `. The selectors are mutually exclusive. By default, only composition members are -included. `--service ` can also select standalone instances. -An unavailable owner, missing stack, or unmatched service fails with status 1. +included. `--service ` is repeatable and can also select +standalone instances; a value that matches no saved instance fails with status 1. +A missing stack fails with status 1. -Reads saved definitions under `/stacks//`. -Discovery ensures the registry directory exists with mode 0700 and probes local -owners. Shared routing/settings may read project config and profiles. No project -files, service configuration, artifacts, or data are changed. +Reads saved definitions under `/stacks//` and +the persisted segments under +`/stacks//logs///.log` +directly, so history is readable while the owner is down. Discovery ensures the +registry directory exists with mode 0700 and probes local owners. Shared +routing/settings may read project config and profiles. No project files, +service configuration, artifacts, logs, or data are changed. -## Output and cancellation +## History, follow, and flags -This is a live-only stream with no retained history, cursor, or `--tail` option. -It continues until interrupted or the selected streams close. `--follow` is -unnecessary and is not accepted. Legacy `-o/--output` and finite JSON output are -rejected; use text or `--output-format stream-json`. +`--tail N` (default 200, 0 prints none) keeps the newest N output lines across +the selected services, with the `launch` and `lost` markers between them. +`--since` accepts a duration before now (`30s`, `10m`, `1h30m`, `2d`), an +ISO-8601 time, or `start`, which keeps the records of each instance's current +launch and later ones, in history and while following. The current launch is the +launch id saved in the stack definition, which keeps increasing across owner +restarts, or else the highest launch record in history (all records when +retention removed that launch record). Records are +ordered by timestamp, service, instance, and file position. Timestamps are the +owner's clock at each line's first byte. Lines end at `\n`, `\r\n`, or a lone +`\r`, so carriage-return progress updates print as separate lines. History is +read as a stream holding at most N lines per instance, while every matching line +is counted for the footer. -Text writes ` //: `. Terminal -control sequences are stripped from text. Stream JSON emits `log-entry` events -with `timestamp`, `service`, `instance_id`, `stream`, `line`, and `source: "live"`. -Lines preserve their content in machine output. UTF-8 and line fragments are -assembled separately for each instance and stdout/stderr channel. Timestamps -reflect receipt by the CLI. Delivery is best effort: slow subscribers can lose entries. Ordering across -stdout/stderr channels and different services is not guaranteed. +`-f/--follow` requires a reachable owner: without one it fails with status 1 +before printing anything and suggests running without `--follow`. It prints the +history, then streams each instance's new records through the owner from the +last record read, so no record is repeated or skipped between history and live +output. With `--tail 0` no history is read and the owner starts each follow at +its current end. A selected instance the running owner does not serve is named +in a warning and not followed; when it serves none of them, the command fails +with status 1 after printing the history. The owner's log shipping to Analytics reads the persisted logs +separately and does not affect this command. -Interrupting the command cancels its subscriptions, exits with status 130, and -leaves the owner and services running. Successful stream completion exits 0; -selection, connection, or stream failures exit 1. +## Output + +Text writes ` | ` with the service labels padded +to one width; a service kind selected more than once is labelled +`:`. Times are local. `launch` +and `lost` records are dim separator lines (`--- launch 2 ---`, +`--- 3 output chunks lost ---`, `--- older records were removed by retention ---`). +Labels are coloured and markers dimmed only when stdout is a colour-capable +terminal. Terminal control sequences are stripped from text. When a non-zero +tail hides older lines, stderr gets +`showing last 200 of 5,234 lines, use --tail/--since`; an empty history without +`--follow` prints `No retained log lines for the selected services.` on stderr. + +Stream JSON emits a `log-entry` event per output line with `timestamp`, +`service`, `instance_id`, `stream` (`stdout` or `stderr`), `line`, and `source` +(`history` or `live`), and a `log-marker` event per marker with `timestamp`, +`service`, `instance_id`, `kind` (`launch` or `lost`), `source`, and, for chunks +lost before they were written, `count` and, when known, `stream`. A `lost` marker without +`count` reports segments removed by retention. `--output-format json` prints +one array of the same objects; it cannot be combined with `--follow`. Lines +preserve their content in machine output. Legacy `-o/--output` is rejected. + +Interrupting a follow cancels its subscriptions, exits with status 130, and +leaves the owner and services running. History reads and completed follows exit +0; selection, owner, or read failures exit 1. ## Telemetry -Standard command telemetry is retained; log contents are not custom telemetry -properties. Telemetry flushes on every exit to +Standard command telemetry is retained, with `-f` reported as `follow`; log +contents are not custom telemetry properties. Telemetry flushes on every exit to `/telemetry.json`. diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index f895e7151d..8b707c123b 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -12,12 +12,35 @@ const config = { Flag.optional, ), stackId: Flag.string("stack-id").pipe( - Flag.withDescription("Read logs from an existing stack by id."), + Flag.withDescription("Read logs from an existing stack by id or unique id prefix."), Flag.optional, ), service: Flag.string("service").pipe( Flag.withDescription( - "Stream one service kind or instance ID; defaults to composition members.", + "Read one service kind or instance ID; repeat to select several. Defaults to composition members.", + ), + Flag.atLeast(0), + ), + follow: Flag.boolean("follow").pipe( + Flag.withAlias("f"), + Flag.withDescription( + "After the history, keep streaming new lines until interrupted; requires a running stack.", + ), + Flag.withDefault(false), + ), + tail: Flag.integer("tail").pipe( + Flag.filter( + (tail) => tail >= 0, + (tail) => `Expected --tail to be 0 or more, got ${tail}`, + ), + Flag.withDescription( + "Number of retained lines to print across the selected services (default 200); 0 prints none.", + ), + Flag.withDefault(200), + ), + since: Flag.string("since").pipe( + Flag.withDescription( + "Only print lines since a duration ago (10m, 1h30m), an ISO-8601 time, or start for each service's latest launch.", ), Flag.optional, ), @@ -26,19 +49,28 @@ const config = { export type StackLogsFlags = CliCommand.Command.Config.Infer; export const stackLogsCommand = Command.make("logs", config).pipe( - Command.withDescription("Stream live logs until interrupted; retained history is unavailable."), - Command.withShortDescription("Stream managed local stack logs"), + Command.withDescription( + "Print retained service logs and exit; with --follow, keep streaming new lines. History is readable while the stack is stopped.", + ), + Command.withShortDescription("Show managed local stack logs"), Command.withExamples([ { - command: "supabase stack logs --service database", - description: "Stream new database logs", + command: "supabase stack logs --service database --since 10m", + description: "Print database lines from the last ten minutes", }, { - command: "supabase stack logs --output-format stream-json", - description: "Stream new stack logs as structured events", + command: "supabase stack logs -f --tail 0", + description: "Stream only new lines until interrupted", + }, + { + command: "supabase stack logs --follow --output-format stream-json", + description: "Stream history and new lines as structured events", }, ]), Command.withHandler((flags) => - stackLogs(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), + stackLogs(flags).pipe( + withCommandTelemetry({ flags, config, aliases: { f: "follow" } }), + withJsonErrorHandling, + ), ), ); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.format.ts b/apps/cli/src/commands/experimental/stack/logs/logs.format.ts new file mode 100644 index 0000000000..d1a204c66f --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/logs/logs.format.ts @@ -0,0 +1,241 @@ +import { DateTime, Option } from "effect"; +import type { LogPosition, LogRecord, StackLogRecord } from "@supabase/stack/effect"; +import { + aqua, + blue, + type ColorStream, + dim, + green, + magenta, + yellow, +} from "../../../../command-internal/colors.ts"; +import { stripControlSequences } from "../../../../shared/output/strip-control-sequences.ts"; +import { markerText } from "../../../../command-internal/stack-log-events.ts"; + +/** A `--since` bound: an absolute time, or each instance's latest launch. */ +export type SinceBound = + | { readonly kind: "time"; readonly iso: string } + | { readonly kind: "start" }; + +const durationShape = /^(?:\d+(?:\.\d+)?(?:ms|s|m|h|d))+$/u; +const durationPart = /(\d+(?:\.\d+)?)(ms|s|m|h|d)/gu; +const unitMillis: Readonly> = { + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; +const isoDate = /^\d{4}-\d{2}-\d{2}/u; + +/** Parses `start`, a duration before `nowMillis` such as `1h30m`, or an ISO-8601 time. */ +export const parseSince = (value: string, nowMillis: number): SinceBound | undefined => { + if (value === "start") return { kind: "start" }; + if (durationShape.test(value)) { + let millis = 0; + for (const [, amount, unit] of value.matchAll(durationPart)) + millis += Number(amount) * (unitMillis[unit ?? ""] ?? 0); + return Option.match(DateTime.make(nowMillis - millis), { + onNone: () => undefined, + onSome: (time) => ({ kind: "time", iso: DateTime.formatIso(time) }), + }); + } + if (!isoDate.test(value)) return undefined; + return Option.match(DateTime.make(value), { + onNone: () => undefined, + onSome: (time) => ({ kind: "time", iso: DateTime.formatIso(time) }), + }); +}; + +const isLine = (record: LogRecord) => record.kind === "stdout" || record.kind === "stderr"; + +/** The newest `tail` output lines, with the markers between them, and how many lines exist. */ +export interface LogWindow { + readonly records: ReadonlyArray; + readonly shown: number; + readonly total: number; +} + +const tailWindow = (records: ReadonlyArray, tail: number): LogWindow => { + const total = records.filter(isLine).length; + if (total <= tail) return { records, shown: total, total }; + let seen = 0; + let start = records.length; + while (seen < tail && start > 0) { + start -= 1; + const record = records[start]; + if (record !== undefined && isLine(record)) seen += 1; + } + return { records: seen === 0 ? [] : records.slice(start), shown: seen, total }; +}; + +const comparePositions = (left: LogPosition, right: LogPosition) => + left.generation - right.generation || left.byteOffset - right.byteOffset; + +const origin: LogPosition = { generation: 0, byteOffset: 0 }; +const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); +const compareRecords = (left: StackLogRecord, right: StackLogRecord) => + compareText(left.timestamp, right.timestamp) || + compareText(left.service, right.service) || + compareText(left.instanceId, right.instanceId) || + comparePositions( + left.position ?? left.resumeAt ?? origin, + right.position ?? right.resumeAt ?? origin, + ); + +interface Launch { + readonly id: number; + /** Unknown until its launch record is read; a saved launch may not have written one. */ + readonly timestamp: string | undefined; +} + +interface InstanceTail { + records: Array; + head: number; + lines: number; + total: number; + launch: Launch | undefined; +} + +/** Whether a record belongs to `launchId` or a later launch; a gap marker carries no launch id. */ +export const isFromLaunch = (record: LogRecord, launchId: number | undefined) => + launchId === undefined || record.launchId === undefined || record.launchId >= launchId; + +/** Launch ids increase per instance; a gap marker carries none, so its time decides. */ +const fromLaunch = (record: StackLogRecord, launch: Launch) => + record.launchId === undefined + ? launch.timestamp === undefined || record.timestamp >= launch.timestamp + : isFromLaunch(record, launch.id); + +/** + * Collects each instance's newest `tail` lines and last position. With `startLaunches`, it keeps + * only the records of each instance's current launch: its saved launch id, or else its highest + * launch record, which `launches` reports for following. + */ +export const makeHistoryCollector = ( + tail: number, + startLaunches: ReadonlyMap | undefined, +) => { + const instances = new Map(); + const positions = new Map(); + const launches = new Map(startLaunches); + const push = (record: StackLogRecord) => { + const position = record.position; + const last = positions.get(record.instanceId); + if (position !== undefined && (last === undefined || comparePositions(position, last) > 0)) + positions.set(record.instanceId, position); + let state = instances.get(record.instanceId); + if (state === undefined) { + const saved = startLaunches?.get(record.instanceId); + state = { + records: [], + head: 0, + lines: 0, + total: 0, + launch: saved === undefined ? undefined : { id: saved, timestamp: undefined }, + }; + instances.set(record.instanceId, state); + } + if (startLaunches !== undefined) { + const latest = state.launch; + if ( + record.kind === "launch" && + record.launchId !== undefined && + (latest === undefined || + record.launchId > latest.id || + (record.launchId === latest.id && latest.timestamp === undefined)) + ) { + const launch = { id: record.launchId, timestamp: record.timestamp }; + state.records = state.records.slice(state.head).filter((kept) => fromLaunch(kept, launch)); + state.head = 0; + state.lines = state.records.filter(isLine).length; + state.total = state.lines; + state.launch = launch; + launches.set(record.instanceId, launch.id); + } else if (latest !== undefined && !fromLaunch(record, latest)) return; + } + const line = isLine(record); + if (line) state.total += 1; + // Kept records stay in display order; a record older than every kept line of a full window + // would be trimmed at once. + const oldest = state.records[state.head]; + if (state.lines >= tail && oldest !== undefined && compareRecords(record, oldest) < 0) return; + let low = state.head; + let high = state.records.length; + while (low < high) { + const middle = (low + high) >>> 1; + const kept = state.records[middle]; + if (kept !== undefined && compareRecords(kept, record) <= 0) low = middle + 1; + else high = middle; + } + if (low === state.records.length) state.records.push(record); + else state.records.splice(low, 0, record); + if (line) state.lines += 1; + while (state.lines > tail && state.head < state.records.length) { + const dropped = state.records[state.head]; + state.head += 1; + if (dropped !== undefined && isLine(dropped)) state.lines -= 1; + } + if (state.head > 1024 && state.head * 2 > state.records.length) { + state.records = state.records.slice(state.head); + state.head = 0; + } + }; + const finish = (): { + readonly window: LogWindow; + readonly positions: typeof positions; + readonly launches: ReadonlyMap; + } => { + const kept = [...instances.values()].flatMap((state) => state.records.slice(state.head)); + const total = [...instances.values()].reduce((sum, state) => sum + state.total, 0); + const window = tailWindow(kept.toSorted(compareRecords), tail); + return { window: { ...window, total }, positions, launches }; + }; + return { push, finish }; +}; + +/** Whether a followed record comes after the history already printed. */ +export const isAfter = (record: LogRecord, printed: LogPosition | undefined) => + printed === undefined || + record.position === undefined || + comparePositions(record.position, printed) > 0; + +const palette = [aqua, yellow, green, magenta, blue]; +const pad = (value: number, width: number) => String(value).padStart(width, "0"); + +const localTime = (iso: string) => + Option.match(DateTime.make(iso), { + onNone: () => iso, + onSome: (time) => { + const parts = DateTime.toParts(DateTime.setZone(time, DateTime.zoneMakeLocal())); + return `${pad(parts.hour, 2)}:${pad(parts.minute, 2)}:${pad(parts.second, 2)}.${pad(parts.millisecond, 3)}`; + }, + }); + +/** Formats records as `