diff --git a/.bun-version b/.bun-version index 347f5833ee..9df886c42a 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.4.1 +1.4.2 diff --git a/.gitattributes b/.gitattributes index 1f199bbbdb..7f30866cca 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,6 +26,7 @@ apps/cli-go/api/v1-openapi.yaml linguist-generated=true apps/cli-go/pkg/api/*.gen.go linguist-generated=true packages/api/src/generated/* linguist-generated=true packages/api/scripts/openapi-source.json linguist-generated=true +packages/stack/src/functions/generated/* text eol=lf linguist-generated=true apps/cli/src/shared/feedback/database.types.ts linguist-generated=true apps/docs/public/cli/config.schema.json linguist-generated=true apps/docs/public/cli/project-config.schema.json linguist-generated=true diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 1fdd077f3b..a05bc49616 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -14,6 +14,12 @@ inputs: fails with a path validation error. required: false default: "true" + go-cache: + description: >- + Whether to restore the Go module and build caches. Disable this in jobs + that never run Go. + required: false + default: "true" runs: using: "composite" @@ -52,8 +58,25 @@ runs: restore-keys: | pnpm-store-files-${{ runner.os }}-${{ runner.arch }}- - - name: Resolve Go cache paths + - name: Resolve pnpm cache path + if: inputs.dependency-cache == 'true' + id: pnpm-cache + shell: bash + run: echo "path=$(pnpm cache path --silent)" >> "$GITHUB_OUTPUT" + + # pnpm stores the lockfile's supply-chain verdict in lockfile-verified.jsonl; + # without it every job re-verifies all entries against registry metadata, + # which dominates a warm-store install. The verdict is bound to the policy + # and to the registry it was checked against. + - name: Configure pnpm verification cache if: inputs.dependency-cache == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.pnpm-cache.outputs.path }}/lockfile-verified.jsonl + key: pnpm-verify-${{ runner.os }}-${{ inputs.dependency-firewall-token != '' && 'firewall' || 'public' }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml') }} + + - name: Resolve Go cache paths + if: inputs.dependency-cache == 'true' && inputs.go-cache == 'true' id: go-cache shell: bash run: | @@ -61,7 +84,7 @@ runs: echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" - name: Configure Go dependency cache - if: inputs.dependency-cache == 'true' + if: inputs.dependency-cache == 'true' && inputs.go-cache == 'true' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3b6a0cd801..b66df12186 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -85,11 +85,26 @@ updates: update-types: - minor - patch + # These lines are generated from packages/stack/src/Artifacts.ts (the single version table + # for slim-capable services); updates to them arrive through slim-release-published.yml, not + # Dependabot. Kong and pg14 (supabase/postgres) have no slim build either, but are bumped by + # hand, not by Dependabot. Dependabot keeps bumping only the images that remain genuinely + # upstream-only: migra, pg_prove, pgadmin-schema-diff. ignore: - dependency-name: "library/kong" - dependency-name: "axllent/mailpit" - dependency-name: "darthsim/imgproxy" - dependency-name: "timberio/vector" + - dependency-name: "supabase/postgres" + - dependency-name: "supabase/gotrue" + - dependency-name: "postgrest/postgrest" + - dependency-name: "supabase/realtime" + - dependency-name: "supabase/storage-api" + - dependency-name: "supabase/edge-runtime" + - dependency-name: "supabase/studio" + - dependency-name: "supabase/postgres-meta" + - dependency-name: "supabase/logflare" + - dependency-name: "supabase/supavisor" cooldown: default-days: 7 exclude: diff --git a/.github/scripts/slim-mirror-payload.ts b/.github/scripts/slim-mirror-payload.ts index 74191dfebf..5b61fff58e 100644 --- a/.github/scripts/slim-mirror-payload.ts +++ b/.github/scripts/slim-mirror-payload.ts @@ -4,7 +4,7 @@ */ const SERVICE_PATTERN = /^[a-z][a-z0-9-]*$/; -export const VERSION_PATTERN = /^[A-Za-z0-9._-]+$/; +const VERSION_PATTERN = /^[A-Za-z0-9._-]+$/; export const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; export const SOURCE_REGISTRY = "ghcr.io/supabase/cli"; diff --git a/.github/scripts/sync-artifacts-catalog.test.ts b/.github/scripts/sync-artifacts-catalog.test.ts index 809dce551f..1a266d12be 100644 --- a/.github/scripts/sync-artifacts-catalog.test.ts +++ b/.github/scripts/sync-artifacts-catalog.test.ts @@ -1,132 +1,1331 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -import { InvalidPayloadError } from "./slim-mirror-payload.ts"; +import { InvalidPayloadError, nativeFileNames, nativeObjectUrl } from "./slim-mirror-payload.ts"; import { CATALOG_PATH, - planArtifactCatalogUpdate, - type ReleasePublication, + planSlimUpdates, + planUpdatesForService, + refreshCatalogPin, + resolveRevisionPin, + validateSlimReleasePublishedPayload, + waitForExpectedRelease, + type RevisionIo, } from "./sync-artifacts-catalog.ts"; -const DIGEST_B = `sha256:${"b".repeat(64)}`; -const POSTGRES_17 = `ghcr.io/supabase/cli/postgres:17.6.1.168@sha256:${"9".repeat(64)}`; -const POSTGRES_15 = `ghcr.io/supabase/cli/postgres:15.14.1.168@sha256:${"f".repeat(64)}`; +const NATIVE_TARGETS = ["darwin-arm64", "linux-amd64", "linux-arm64"] as const; + +const hex = (seed: string): string => new Bun.CryptoHasher("sha256").update(seed).digest("hex"); +const digest = (seed: string): string => `sha256:${hex(seed)}`; + +type NativeDigests = Record<(typeof NATIVE_TARGETS)[number], { archive: string; manifest: string }>; + +const nativeDigests = (seed: string): NativeDigests => ({ + "darwin-arm64": { archive: hex(`${seed}a`), manifest: hex(`${seed}A`) }, + "linux-amd64": { archive: hex(`${seed}b`), manifest: hex(`${seed}B`) }, + "linux-arm64": { archive: hex(`${seed}c`), manifest: hex(`${seed}C`) }, +}); + +/** Builds a `SHA256SUMS` body via the same file-name helper the sync script reads. */ +function checksumsFor(service: string, releaseVersion: string, digests: NativeDigests): string { + return NATIVE_TARGETS.map((target) => { + const files = nativeFileNames(service, releaseVersion, target); + return `${digests[target].archive} ${files.archive}\n${digests[target].manifest} ${files.manifest}`; + }).join("\n"); +} + +/** An `io` whose S3 copies always match the given checksums. */ +function matchingS3( + service: string, + releaseVersion: string, + digests: NativeDigests, +): RevisionIo["s3Sha256"] { + const byUrl = new Map(); + for (const target of NATIVE_TARGETS) { + const files = nativeFileNames(service, releaseVersion, target); + byUrl.set(nativeObjectUrl(service, releaseVersion, files.archive), digests[target].archive); + byUrl.set(nativeObjectUrl(service, releaseVersion, files.manifest), digests[target].manifest); + } + return async (url) => byUrl.get(url); +} + +/** + * 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 + * (line-wrapping, trailing commas) instead of a hand-written single-line literal. + */ +async function formatWithOxfmt(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "sync-artifacts-catalog-")); + const filePath = join(dir, "Artifacts.ts"); + try { + await writeFile(filePath, source); + const proc = Bun.spawn(["node_modules/.bin/oxfmt", "--config", ".oxfmtrc.json", filePath], { + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); + if (exitCode !== 0) throw new Error(`oxfmt failed: ${stderr}`); + return await readFile(filePath, "utf8"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** + * A resolved `ArtifactPin` literal, for fixtures that exercise refreshing an already-resolved + * catalog entry to a new revision (the committed catalog only carries resolved pins). `seed` keys a real, distinct digest via the module's `digest` helper. + */ +function resolvedPinLiteral( + service: string, + version: string, + revision: number, + seed: string, +): string { + return `{ upstreamVersion: "${version}", revision: ${revision}, image: "ghcr.io/supabase/cli/${service}:${version}-r${revision}@${digest(seed)}", natives: {} }`; +} + +const postgres17Pin = resolvedPinLiteral("postgres", "17.6.1.168", 0, "postgres-17"); +/** + * The default pin's `image` field, quoted. A formatter may move a long property value onto its + * own line, but never breaks a string literal's contents — so this survives real formatting the + * way asserting the whole (potentially re-wrapped) `postgres17Pin` literal would not. + */ +const postgres17Image = `"ghcr.io/supabase/cli/postgres:17.6.1.168-r0@${digest("postgres-17")}"`; const fixture = `const workloadCatalog = { database: definition( "postgres", - "17.6.1.168", - "${POSTGRES_17}", + ${postgres17Pin}, "bin/supabase-postgres-start", ["bin/supabase-postgres-start"], - { "15.14.1.168": "${POSTGRES_15}" }, + { "15.14.1.168": ${resolvedPinLiteral("postgres", "15.14.1.168", 0, "postgres-15")} }, ), - rest: definition("postgrest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", "bin/postgrest"), + rest: definition("postgrest", ${resolvedPinLiteral("postgrest", "v16.2", 0, "postgrest")}, "bin/postgrest"), + storage: definition("storage", ${resolvedPinLiteral("storage", "v1.73.0", 0, "storage")}, "bin/storage", ["bin/storage"]), analytics: definition( "analytics", - "v1.50.9", - "ghcr.io/supabase/cli/analytics:v1.50.9@sha256:${"a".repeat(64)}", + ${resolvedPinLiteral("analytics", "v1.50.9", 0, "analytics")}, "bin/logflare", ), }; `; -const published = (digest?: string): ReleasePublication => ({ status: "published", digest }); - -describe("planArtifactCatalogUpdate", () => { - test("pins Dockerfile tags that changed and leaves the other postgres line", async () => { - const plan = await planArtifactCatalogUpdate({ - baseDockerfile: `FROM supabase/postgres:17.6.1.168 AS pg -FROM postgrest/postgrest:v16.2 AS postgrest -FROM supabase/logflare:1.50.9 AS logflare -`, - dockerfile: `FROM supabase/postgres:17.6.1.171 AS pg -FROM library/kong:2.8.1 AS kong -FROM postgrest/postgrest:v16.3 AS postgrest -FROM supabase/logflare:1.50.9 AS logflare -`, - catalog: fixture, - publication: async (service) => published(service === "postgres" ? DIGEST_B : undefined), +const vectorFixture = `const workloadCatalog = { + vector: definition("vector", ${resolvedPinLiteral("vector", "0.53.0", 0, "vector")}, "bin/vector"), +}; +`; + +/** + * Fixtures for `planSlimUpdates`: a committed catalog always carries resolved pins, so these + * give every pin a real revision. + */ +const plannerFixture = `const workloadCatalog = { + rest: definition( + "postgrest", + { + upstreamVersion: "v16.2", + revision: 0, + image: "ghcr.io/supabase/cli/postgrest:v16.2-r0@sha256:1111111111111111111111111111111111111111111111111111111111111", + natives: {}, + }, + "bin/postgrest", + ), + storage: definition( + "storage", + { + upstreamVersion: "v1.73.0", + revision: 0, + image: "ghcr.io/supabase/cli/storage:v1.73.0-r0@sha256:2222222222222222222222222222222222222222222222222222222222222", + natives: {}, + }, + "bin/storage", + ["bin/storage"], + ), +}; +`; + +/** Postgres carries two lines (17 default, 15 additional), both resolved. */ +const postgresPlannerFixture = `const workloadCatalog = { + database: definition( + "postgres", + { + upstreamVersion: "17.6.1.168", + revision: 1, + image: "ghcr.io/supabase/cli/postgres:17.6.1.168-r1@sha256:3333333333333333333333333333333333333333333333333333333333333", + natives: {}, + }, + "bin/supabase-postgres-start", + ["bin/supabase-postgres-start"], + { + "15.14.1.168": { + upstreamVersion: "15.14.1.168", + revision: 3, + image: "ghcr.io/supabase/cli/postgres:15.14.1.168-r3@sha256:4444444444444444444444444444444444444444444444444444444444444", + natives: {}, + }, + }, + ), +}; +`; + +describe("validateSlimReleasePublishedPayload", () => { + test("rejects a newline injected into upstream_version", () => { + expect(() => + validateSlimReleasePublishedPayload({ + service: "postgres", + upstream_version: "15.14.1.168\nrevision=999\ninjected=yes", + revision: "0", + release_version: "15.14.1.168\nrevision=999\ninjected=yes-r0", + }), + ).toThrow(InvalidPayloadError); + }); + + test("a newline-bearing service value produces a single-line error", () => { + let caught: unknown; + try { + validateSlimReleasePublishedPayload({ + service: "postgres\n::error ::injected", + upstream_version: "15.14.1.168", + revision: "0", + release_version: "15.14.1.168-r0", + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(InvalidPayloadError); + const message = (caught as InvalidPayloadError).message; + expect(message.split("\n")).toHaveLength(1); + expect(message).toContain(JSON.stringify("postgres\n::error ::injected")); + }); + + test("accepts a Studio-style calendar-versioned upstream", () => { + const payload = validateSlimReleasePublishedPayload({ + service: "studio", + upstream_version: "2026.09.04-sha-5a67366", + revision: "1", + release_version: "2026.09.04-sha-5a67366-r1", }); - expect(plan.updates.map((update) => `${update.service} ${update.version}`)).toEqual([ - "postgres 17.6.1.171", - "postgrest v16.3", + expect(payload).toEqual({ + service: "studio", + upstream_version: "2026.09.04-sha-5a67366", + revision: 1, + release_version: "2026.09.04-sha-5a67366-r1", + }); + }); + + test("accepts a Postgres four-part upstream version", () => { + const payload = validateSlimReleasePublishedPayload({ + service: "postgres", + upstream_version: "15.14.1.168", + revision: "3", + release_version: "15.14.1.168-r3", + }); + + expect(payload).toEqual({ + service: "postgres", + upstream_version: "15.14.1.168", + revision: 3, + release_version: "15.14.1.168-r3", + }); + }); + + test("rejects a release_version that does not match the derived value", () => { + expect(() => + validateSlimReleasePublishedPayload({ + service: "postgres", + upstream_version: "15.14.1.168", + revision: "3", + release_version: "15.14.1.168-r4", + }), + ).toThrow(InvalidPayloadError); + }); +}); + +describe("planSlimUpdates", () => { + test("hotfix only: a higher committed revision of the pinned upstream", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.2-r1", + ]); + + expect(warnings).toEqual([]); + 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", + }, ]); - expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:17.6.1.171@${DIGEST_B}"`); - expect(plan.source).toContain(`"${POSTGRES_15}"`); - expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgrest:v16.3"`); - expect(plan.source).toContain(`"v1.50.9"`); - expect(plan.skipped).toEqual([]); }); - test("a missing release blocks the commit and OrioleDB does not", async () => { - const movesPostgres = await planArtifactCatalogUpdate({ - baseDockerfile: "FROM supabase/postgres:17.6.1.168 AS pg\n", - dockerfile: `FROM supabase/postgres:17.6.1.171 AS pg -FROM postgrest/postgrest:v16.3 AS postgrest -`, - catalog: fixture, - publication: async () => ({ status: "missing" }), - }); - expect(movesPostgres.updates).toEqual([]); - expect(movesPostgres.source).toBe(fixture); - expect(movesPostgres.skipped.every((skip) => skip.blocking)).toBe(true); - - const oriole = await planArtifactCatalogUpdate({ - baseDockerfile: - "FROM supabase/postgres:17.6.1.168 AS pg\nFROM postgrest/postgrest:v16.2 AS postgrest\n", - dockerfile: `FROM supabase/postgres:16.0.0.1-orioledb AS pg -FROM postgrest/postgrest:v16.3 AS postgrest -`, - catalog: fixture, - publication: async () => published(), + test("upgrade only: a newer committed upstream on the same line", () => { + const { updates } = planSlimUpdates(plannerFixture, "postgrest", ["postgrest-v16.4-r0"]); + + expect(updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/postgrest", + title: "chore(stack): bump postgrest to v16.4-r0", + fromRelease: "v16.2-r0", + toUpstream: "v16.4", + toRelease: "v16.4-r0", + }, + ]); + }); + + test("both a hotfix and an upgrade can be planned in the same run", () => { + const { updates } = 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, + branch: "slim-bump/postgrest", + title: "chore(stack): bump postgrest to v16.4-r0", + fromRelease: "v16.2-r0", + toUpstream: "v16.4", + toRelease: "v16.4-r0", + }, + ]); + }); + + test("an older committed upstream is a backlog republish: it plans nothing", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.1-r0", + ]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([]); + }); + + test("postgres: upgrade on the 17 line, hotfix on the 15 line, a major-18 release ignored and warned about", () => { + const { updates, warnings } = planSlimUpdates(postgresPlannerFixture, "postgres", [ + "postgres-17.11.0.002-r0", + "postgres-15.14.1.168-r4", + "postgres-18.0.0.001-r0", + ]); + + // 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). + 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.", + ]); + expect(updates).toEqual([ + { + kind: "upgrade", + line: "17", + branch: "slim-bump/postgres-17", + title: "chore(stack): bump postgres to 17.11.0.002-r0", + fromRelease: "17.6.1.168-r1", + toUpstream: "17.11.0.002", + toRelease: "17.11.0.002-r0", + }, + { + kind: "hotfix", + line: "15", + branch: "slim-hotfix/postgres-15", + title: "chore(stack): pin postgres 15.14.1.168-r4", + fromRelease: "15.14.1.168-r3", + toUpstream: "15.14.1.168", + toRelease: "15.14.1.168-r4", + }, + ]); + }); + + test("Studio: a newer date upgrades; the same date with a different sha does not", () => { + const studioFixture = `const workloadCatalog = { + studio: definition( + "studio", + { + upstreamVersion: "2026.09.14-sha-aaaaaaa", + revision: 0, + image: "ghcr.io/supabase/cli/studio:2026.09.14-sha-aaaaaaa-r0@sha256:1111111111111111111111111111111111111111111111111111111111111", + natives: {}, + }, + "bin/studio", + ), +}; +`; + + const sameDate = planSlimUpdates(studioFixture, "studio", ["studio-2026.09.14-sha-bbbbbbb-r0"]); + expect(sameDate.updates).toEqual([]); + + const newerDate = planSlimUpdates(studioFixture, "studio", [ + "studio-2026.09.28-sha-ccccccc-r0", + ]); + expect(newerDate.updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/studio", + title: "chore(stack): bump studio to 2026.09.28-sha-ccccccc-r0", + fromRelease: "2026.09.14-sha-aaaaaaa-r0", + toUpstream: "2026.09.28-sha-ccccccc", + toRelease: "2026.09.28-sha-ccccccc-r0", + }, + ]); + }); + + test("Studio: a year rollover upgrades even though releaseLine differs (single-pin services accept any upstream)", () => { + const studioFixture = `const workloadCatalog = { + studio: definition( + "studio", + { + upstreamVersion: "2026.09.28-sha-5e59b60", + revision: 0, + image: "ghcr.io/supabase/cli/studio:2026.09.28-sha-5e59b60-r0@sha256:5555555555555555555555555555555555555555555555555555555555555", + natives: {}, + }, + "bin/studio", + ), +}; +`; + + const { updates, warnings } = planSlimUpdates(studioFixture, "studio", [ + "studio-2027.01.01-sha-abcdef0-r0", + ]); + + expect(warnings).toEqual([]); + expect(updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/studio", + title: "chore(stack): bump studio to 2027.01.01-sha-abcdef0-r0", + fromRelease: "2026.09.28-sha-5e59b60-r0", + toUpstream: "2027.01.01-sha-abcdef0", + toRelease: "2027.01.01-sha-abcdef0-r0", + }, + ]); + }); + + test("postgrest: a major-version bump upgrades even though releaseLine differs (single-pin services accept any upstream)", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v17.0-r0", + ]); + + expect(warnings).toEqual([]); + expect(updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/postgrest", + title: "chore(stack): bump postgrest to v17.0-r0", + fromRelease: "v16.2-r0", + toUpstream: "v17.0", + toRelease: "v17.0-r0", + }, + ]); + }); + + test("a non-comparable version (OrioleDB-style suffix) is ignored and warned about", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.2-orioledb-r0", + ]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([ + "::warning ::postgrest v16.2-orioledb is not a comparable version; ignoring.", + ]); + }); + + test("a `%` in a non-comparable version is encoded, staying a single workflow-command line", () => { + // `.` in the tag pattern allows `%` (only a real newline can't reach this far — the pattern + // can't match across one), so a real, legitimately-listed tag can still carry a `%` here. + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ + "postgrest-v16.2%off-r0", + ]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([ + "::warning ::postgrest v16.2%25off is not a comparable version; ignoring.", + ]); + expect(warnings[0]?.split("\n")).toHaveLength(1); + }); + + test("a legacy tag with no -rN is ignored", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", ["postgrest-v16.4"]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([]); + }); + + test("postgrest tags never match postgres, even as a prefix", () => { + const { updates } = planSlimUpdates(postgresPlannerFixture, "postgres", [ + "postgrest-v16.4-r0", + "postgres-17.11.0.002-r0", + ]); + + expect(updates).toEqual([ + { + kind: "upgrade", + line: "17", + branch: "slim-bump/postgres-17", + title: "chore(stack): bump postgres to 17.11.0.002-r0", + fromRelease: "17.6.1.168-r1", + toUpstream: "17.11.0.002", + toRelease: "17.11.0.002-r0", + }, + ]); + }); + + test("branch and title carry no - suffix for a service with a single line", () => { + const { updates } = planSlimUpdates(plannerFixture, "storage", ["storage-v1.74.0-r0"]); + + expect(updates).toEqual([ + { + kind: "upgrade", + line: undefined, + branch: "slim-bump/storage", + title: "chore(stack): bump storage to v1.74.0-r0", + fromRelease: "v1.73.0-r0", + toUpstream: "v1.74.0", + toRelease: "v1.74.0-r0", + }, + ]); + }); + + test("rejects an emitted value that fails the anchored patterns, even from a trusted-looking catalog", () => { + // The catalog's own pinned upstream carries a space here — never written by `refreshCatalogPin` + // (which validates every field it resolves), but this simulates a corrupted catalog, or a + // release tag whose upstream portion isn't newline-only-unsafe (`.` already excludes + // newlines) yet still fails `UPSTREAM_VERSION_PATTERN`. Every value the planner emits is + // checked, regardless of source. + const adversarial = `const workloadCatalog = { + rest: definition( + "postgrest", + { + upstreamVersion: "v16.2 injected", + revision: 0, + image: "ghcr.io/supabase/cli/postgrest:v16.2-r0@sha256:2222222222222222222222222222222222222222222222222222222222222", + natives: {}, + }, + "bin/postgrest", + ), +}; +`; + + expect(() => + planSlimUpdates(adversarial, "postgrest", ["postgrest-v16.2 injected-r1"]), + ).toThrow(InvalidPayloadError); + }); + + test("plans nothing for a service the catalog does not model, and warns about it", () => { + const { updates, warnings } = planSlimUpdates(plannerFixture, "no-such-service", [ + "no-such-service-v1.0-r0", + ]); + + expect(updates).toEqual([]); + expect(warnings).toEqual([ + "::warning ::packages/stack/src/Artifacts.ts has no slim entry for no-such-service; nothing to plan.", + ]); + }); +}); + +describe("planUpdatesForService (the plan-updates transport's IO seam)", () => { + test("an ignored tag alongside a valid update: the valid record comes back, plus a separate warning", async () => { + const result = await planUpdatesForService({ + catalog: postgresPlannerFixture, + service: "postgres", + listReleaseTags: async () => [ + "postgres-17.11.0.002-r0", + "postgres-18.0.0.001-r0", // ignored: no line 18 + ], }); - expect(oriole.updates.map((update) => update.service)).toEqual(["postgrest"]); - expect(oriole.skipped).toEqual([ + + expect(result.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.", + ]); + expect(result.updates).toEqual([ { - alias: "pg", - reason: "pg supabase/postgres:16.0.0.1-orioledb has no slim image.", - blocking: false, + kind: "upgrade", + line: "17", + branch: "slim-bump/postgres-17", + title: "chore(stack): bump postgres to 17.11.0.002-r0", + fromRelease: "17.6.1.168-r1", + toUpstream: "17.11.0.002", + toRelease: "17.11.0.002-r0", }, ]); - expect(oriole.source).toContain(`"${POSTGRES_17}"`); + }); +}); + +describe("waitForExpectedRelease (item A's eventual-consistency wait, call counts a subprocess can't assert)", () => { + test("visible on the first listing: returns immediately without waiting", async () => { + let calls = 0; + const waits: number[] = []; + const result = await waitForExpectedRelease( + "postgrest", + "v16.4-r1", + { + listReleaseTags: async () => { + calls += 1; + return ["postgrest-v16.4-r1"]; + }, + wait: async (ms) => { + waits.push(ms); + }, + }, + 6, + 10_000, + ); + + expect(result).toEqual({ visible: true, tags: ["postgrest-v16.4-r1"] }); + expect(calls).toBe(1); + expect(waits).toEqual([]); + }); + + test("visible on a later listing: re-lists and waits between attempts until it appears", async () => { + let calls = 0; + const waits: number[] = []; + const result = await waitForExpectedRelease( + "postgrest", + "v16.4-r1", + { + listReleaseTags: async () => { + calls += 1; + return calls < 3 ? ["postgrest-v16.4-r0"] : ["postgrest-v16.4-r0", "postgrest-v16.4-r1"]; + }, + wait: async (ms) => { + waits.push(ms); + }, + }, + 6, + 10_000, + ); + + expect(result).toEqual({ visible: true, tags: ["postgrest-v16.4-r0", "postgrest-v16.4-r1"] }); + expect(calls).toBe(3); + expect(waits).toEqual([10_000, 10_000]); + }); + + test("never visible: exhausts every bounded attempt, waiting between but not after the last", async () => { + let calls = 0; + const waits: number[] = []; + const result = await waitForExpectedRelease( + "postgrest", + "v16.4-r1", + { + listReleaseTags: async () => { + calls += 1; + return ["postgrest-v16.4-r0"]; + }, + wait: async (ms) => { + waits.push(ms); + }, + }, + 3, + 10, + ); + + expect(result).toEqual({ visible: false, tags: ["postgrest-v16.4-r0"] }); + expect(calls).toBe(3); + expect(waits).toEqual([10, 10]); + }); +}); + +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 () => { + // 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 + // wiring together, not just the pure planner or an in-process stub. + const catalog = await Bun.file(CATALOG_PATH).text(); + const pinMatch = + /definition\(\s*"postgrest",\s*\{\s*upstreamVersion:\s*"([^"]+)",\s*revision:\s*(\d+)/.exec( + catalog, + ); + if (pinMatch === null) throw new Error("postgrest pin not found in the real catalog"); + const pinnedUpstream = pinMatch[1] as string; + const pinnedRevision = Number(pinMatch[2]); + // Higher than any real pinned postgrest version, so it always plans as an upgrade. + const dispatchedRelease = "v999.0-r0"; + + const server = Bun.serve({ + port: 0, + fetch: () => + Response.json([ + { tag_name: `postgrest-${pinnedUpstream}-orioledb-r0`, draft: false }, // ignored + { tag_name: `postgrest-${dispatchedRelease}`, draft: false }, // the valid upgrade + ]), + }); + const dir = await mkdtemp(join(tmpdir(), "plan-updates-subprocess-")); + const outputPath = join(dir, "slim-updates.tsv"); + try { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "postgrest", + "--format", + "lines", + "--expect-release", + dispatchedRelease, + "--output", + outputPath, + ], + { + stdout: "pipe", + stderr: "pipe", + env: { + ...globalThis.process.env, + SLIM_SERVICES_RELEASES_API: `http://127.0.0.1:${server.port}`, + }, + }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(0); + expect(stdout).toContain( + `::warning ::postgrest ${pinnedUpstream}-orioledb is not a comparable version; ignoring.`, + ); + const content = await readFile(outputPath, "utf8"); + expect(content).toBe( + `upgrade\x1fslim-bump/postgrest\x1fchore(stack): bump postgrest to ${dispatchedRelease}\x1f${dispatchedRelease}\x1f${pinnedUpstream}-r${pinnedRevision}\n`, + ); + } finally { + await server.stop(true); + await rm(dir, { recursive: true, force: true }); + } + }); + + test("the expected dispatched release never becomes visible: the process exits non-zero with an actionable error, and writes no output file", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => Response.json([{ tag_name: "postgrest-v16.4-r0", draft: false }]), + }); + const dir = await mkdtemp(join(tmpdir(), "plan-updates-subprocess-never-")); + const outputPath = join(dir, "slim-updates.tsv"); + try { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "postgrest", + "--format", + "lines", + "--expect-release", + "v999.0-r0", + "--output", + outputPath, + ], + { + stdout: "pipe", + stderr: "pipe", + env: { + ...globalThis.process.env, + SLIM_SERVICES_RELEASES_API: `http://127.0.0.1:${server.port}`, + SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS: "5", + }, + }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("::error ::"); + expect(stdout).toContain("postgrest-v999.0-r0 is not visible yet"); + await expect(readFile(outputPath, "utf8")).rejects.toThrow(); + } finally { + await server.stop(true); + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a missing --output exits non-zero before any network call", async () => { + const proc = Bun.spawn( + ["bun", ".github/scripts/sync-artifacts-catalog.ts", "plan-updates", "--service", "auth"], + { stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stdout).toContain( + "Usage: sync-artifacts-catalog.ts plan-updates --service --output ", + ); + }); + + test("a newline-bearing --service is rejected before any listing or wait", async () => { + const dir = await mkdtemp(join(tmpdir(), "plan-updates-bad-service-")); + const outputPath = join(dir, "slim-updates.tsv"); + try { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "auth\n::error ::injected", + "--output", + outputPath, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("invalid --service"); + await expect(readFile(outputPath, "utf8")).rejects.toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("a newline-bearing --format is rejected, producing exactly one ::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 { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "auth", + "--output", + outputPath, + "--format", + "lines\n::error ::injected", + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + const lines = stdout.trimEnd().split("\n"); + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(/^::error ::/); + expect(lines[0]).toContain("%0A"); + await expect(readFile(outputPath, "utf8")).rejects.toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test.each(["5ms", "9".repeat(309)])( + "an invalid SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS (%s) exits non-zero with a configuration error", + async (waitMs) => { + const proc = Bun.spawn( + [ + "bun", + ".github/scripts/sync-artifacts-catalog.ts", + "plan-updates", + "--service", + "postgrest", + "--expect-release", + "v999.0-r0", + "--output", + join(await mkdtemp(join(tmpdir(), "plan-updates-bad-wait-")), "slim-updates.tsv"), + ], + { + stdout: "pipe", + stderr: "pipe", + env: { ...globalThis.process.env, SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS: waitMs }, + }, + ); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + + expect(exitCode).toBe(1); + expect(stdout).toContain("SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS"); + expect(stdout).toContain("non-negative integer"); + }, + ); +}); + +describe("refreshCatalogPin", () => { + test("refreshes a service to the highest revision of its currently pinned upstream", async () => { + const digests = nativeDigests("g"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0", "analytics-v1.50.9-r1"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r1", digests), + imageDigest: async () => digest("h"), + s3Sha256: matchingS3("analytics", "v1.50.9-r1", digests), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.update).toEqual({ + service: "analytics", + version: "v1.50.9", + revision: 1, + previousVersion: "v1.50.9", + target: "default", + }); + expect(result.source).toContain("revision: 1"); + expect(result.source).toContain( + `image: "ghcr.io/supabase/cli/analytics:v1.50.9-r1@${digest("h")}"`, + ); + }); + + test("a resolved pin survives real formatting and can be refreshed again", async () => { + const first = nativeDigests("i"); + const firstIo: RevisionIo = { + listReleaseTags: async () => ["postgrest-v16.2-r0"], + fetchChecksums: async () => checksumsFor("postgrest", "v16.2-r0", first), + imageDigest: async () => digest("j"), + s3Sha256: matchingS3("postgrest", "v16.2-r0", first), + }; + const written = await refreshCatalogPin({ + catalog: fixture, + service: "postgrest", + io: firstIo, + }); + expect(written.update?.revision).toBe(0); + + const formatted = await formatWithOxfmt(written.source); + // The formatter actually wrapped the literal onto several lines with trailing commas; + // otherwise this test would not be exercising what it claims to. + expect(formatted.split("\n").length).toBeGreaterThan(written.source.split("\n").length); + + const second = nativeDigests("k"); + const secondIo: RevisionIo = { + listReleaseTags: async () => ["postgrest-v16.2-r0", "postgrest-v16.2-r1"], + fetchChecksums: async () => checksumsFor("postgrest", "v16.2-r1", second), + imageDigest: async () => digest("l"), + s3Sha256: matchingS3("postgrest", "v16.2-r1", second), + }; + const refreshed = await refreshCatalogPin({ + catalog: formatted, + service: "postgrest", + io: secondIo, + }); + + expect(refreshed.update).toEqual({ + service: "postgrest", + version: "v16.2", + revision: 1, + previousVersion: "v16.2", + target: "default", + }); + expect(refreshed.source).toContain("revision: 1"); + expect(refreshed.source).toContain(second["darwin-arm64"].archive); + }); + + test("refreshes the Postgres 15 additional pin, including after formatting", async () => { + const digests = nativeDigests("m"); + const io: RevisionIo = { + listReleaseTags: async () => ["postgres-15.14.1.168-r0"], + fetchChecksums: async () => checksumsFor("postgres", "15.14.1.168-r0", digests), + imageDigest: async () => digest("n"), + s3Sha256: matchingS3("postgres", "15.14.1.168-r0", digests), + }; + + const written = await refreshCatalogPin({ + catalog: fixture, + service: "postgres", + upstream: "15.14.1.168", + io, + }); + expect(written.update).toEqual({ + service: "postgres", + version: "15.14.1.168", + revision: 0, + previousVersion: "15.14.1.168", + target: "additional", + }); + // The default (17.x) postgres line is untouched. + expect(written.source).toContain(postgres17Image); + + const formatted = await formatWithOxfmt(written.source); + const nextDigests = nativeDigests("o"); + const nextIo: RevisionIo = { + listReleaseTags: async () => ["postgres-15.14.1.168-r0", "postgres-15.14.1.168-r1"], + fetchChecksums: async () => checksumsFor("postgres", "15.14.1.168-r1", nextDigests), + imageDigest: async () => digest("p"), + s3Sha256: matchingS3("postgres", "15.14.1.168-r1", nextDigests), + }; + + const refreshed = await refreshCatalogPin({ + catalog: formatted, + service: "postgres", + upstream: "15.14.1.168", + io: nextIo, + }); + + expect(refreshed.update).toEqual({ + service: "postgres", + version: "15.14.1.168", + revision: 1, + previousVersion: "15.14.1.168", + target: "additional", + }); + expect(refreshed.source).toContain(postgres17Image); + expect(refreshed.source).toContain(nextDigests["linux-arm64"].manifest); + }); + + test("moves the Postgres 15 additional pin to a new upstream version, updating its key", async () => { + const digests = nativeDigests("u"); + const io: RevisionIo = { + listReleaseTags: async () => ["postgres-15.19.0.002-r0"], + fetchChecksums: async () => checksumsFor("postgres", "15.19.0.002-r0", digests), + imageDigest: async () => digest("v"), + s3Sha256: matchingS3("postgres", "15.19.0.002-r0", digests), + }; + + const written = await refreshCatalogPin({ + catalog: fixture, + service: "postgres", + upstream: "15.19.0.002", + io, + }); + expect(written.update).toEqual({ + service: "postgres", + version: "15.19.0.002", + revision: 0, + previousVersion: "15.14.1.168", + target: "additional", + }); + // The old key is gone; the new key matches the resolved pin's `upstreamVersion`. + expect(written.source).not.toContain('"15.14.1.168"'); + expect(written.source).toContain('"15.19.0.002": { upstreamVersion: "15.19.0.002"'); + // The default (17.x) postgres line is untouched. + expect(written.source).toContain(postgres17Image); + + // The renamed key resolves the entry again after real formatting, e.g. for a later hotfix. + const formatted = await formatWithOxfmt(written.source); + const nextDigests = nativeDigests("w"); + const nextIo: RevisionIo = { + listReleaseTags: async () => ["postgres-15.19.0.002-r0", "postgres-15.19.0.002-r1"], + fetchChecksums: async () => checksumsFor("postgres", "15.19.0.002-r1", nextDigests), + imageDigest: async () => digest("x"), + s3Sha256: matchingS3("postgres", "15.19.0.002-r1", nextDigests), + }; + const refreshed = await refreshCatalogPin({ + catalog: formatted, + service: "postgres", + upstream: "15.19.0.002", + io: nextIo, + }); + + expect(refreshed.update).toEqual({ + service: "postgres", + version: "15.19.0.002", + revision: 1, + previousVersion: "15.19.0.002", + target: "additional", + }); + expect(refreshed.source).toContain(nextDigests["linux-arm64"].manifest); + }); +}); + +describe("resolveRevisionPin waits for the S3 mirror", () => { + test("waits while one S3 object is missing, then resolves once it appears", async () => { + const digests = nativeDigests("s3-wait"); + const releaseVersion = "v16.2-r0"; + const missingUrl = nativeObjectUrl( + "postgrest", + releaseVersion, + nativeFileNames("postgrest", releaseVersion, "darwin-arm64").archive, + ); + const present = matchingS3("postgrest", releaseVersion, digests); + let missingCalls = 0; + const waits: number[] = []; + const io: RevisionIo = { + listReleaseTags: async () => [`postgrest-${releaseVersion}`], + fetchChecksums: async () => checksumsFor("postgrest", releaseVersion, digests), + imageDigest: async () => digest("s3-wait-digest"), + s3Sha256: async (url) => { + if (url === missingUrl) { + missingCalls += 1; + if (missingCalls < 2) return undefined; + } + return present(url); + }, + wait: async (ms) => { + waits.push(ms); + }, + }; + + const resolution = await resolveRevisionPin("postgrest", "v16.2", io); + + expect(resolution.status).toBe("resolved"); + expect(missingCalls).toBe(2); + expect(waits).toHaveLength(1); }); - test("refuses a backward pin and a tag that would escape the catalog string", async () => { - const backward = await planArtifactCatalogUpdate({ - baseDockerfile: "FROM supabase/postgres:17.6.1.170 AS pg\n", - dockerfile: "FROM supabase/postgres:17.6.1.171 AS pg\n", - catalog: fixture.replaceAll("17.6.1.168", "17.6.1.173"), - publication: async () => { - throw new Error("publication lookup"); + test("fails immediately on a digest mismatch, without waiting", async () => { + const digests = nativeDigests("s3-mismatch"); + const releaseVersion = "v16.2-r0"; + const waits: number[] = []; + const io: RevisionIo = { + listReleaseTags: async () => [`postgrest-${releaseVersion}`], + fetchChecksums: async () => checksumsFor("postgrest", releaseVersion, digests), + imageDigest: async () => digest("s3-mismatch-digest"), + s3Sha256: async () => hex("wrong-bytes"), + wait: async (ms) => { + waits.push(ms); }, + }; + + const resolution = await resolveRevisionPin("postgrest", "v16.2", io); + + expect(resolution.status).toBe("stale"); + if (resolution.status !== "stale") throw new Error("expected status 'stale'"); + expect(resolution.message).toContain("digest mismatch"); + expect(waits).toEqual([]); + }); + + test("fails after the bounded attempts when an S3 object never appears, with an actionable message", async () => { + const digests = nativeDigests("s3-never"); + const releaseVersion = "v16.2-r0"; + const waits: number[] = []; + const io: RevisionIo = { + listReleaseTags: async () => [`postgrest-${releaseVersion}`], + fetchChecksums: async () => checksumsFor("postgrest", releaseVersion, digests), + imageDigest: async () => digest("s3-never-digest"), + s3Sha256: async () => undefined, + wait: async (ms) => { + waits.push(ms); + }, + }; + + const resolution = await resolveRevisionPin("postgrest", "v16.2", io); + + expect(resolution.status).toBe("stale"); + if (resolution.status !== "stale") throw new Error("expected status 'stale'"); + expect(resolution.message).toContain("mirror-slim-image.yml"); + expect(resolution.message).toContain("hasn't finished"); + expect(waits.length).toBeGreaterThan(0); + }); +}); + +describe("refreshCatalogPin --release", () => { + test("pins exactly 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), + }; + + const result = await refreshCatalogPin({ + catalog: fixture, + service: "postgrest", + release: "v16.2-r0", + io, }); - expect(backward.updates).toEqual([]); - expect(backward.skipped.map((skip) => skip.blocking)).toEqual([true]); - expect(backward.source).toContain(`"17.6.1.173"`); + + expect(result.update).toEqual({ + service: "postgrest", + version: "v16.2", + revision: 0, + previousVersion: "v16.2", + target: "default", + }); + }); + + test("fails when the requested release is not committed", async () => { + const io: RevisionIo = { + listReleaseTags: async () => ["postgrest-v16.2-r0"], + fetchChecksums: async () => undefined, + imageDigest: async () => undefined, + s3Sha256: async () => undefined, + }; await expect( - planArtifactCatalogUpdate({ - dockerfile: 'FROM supabase/gotrue:v1" AS gotrue\n', + refreshCatalogPin({ catalog: fixture, service: "postgrest", release: "v16.2-r1", io }), + ).rejects.toThrow(InvalidPayloadError); + }); + + test("rejects --upstream and --release given together", async () => { + const io: RevisionIo = { + listReleaseTags: async () => [], + fetchChecksums: async () => undefined, + imageDigest: async () => undefined, + s3Sha256: async () => undefined, + }; + + await expect( + refreshCatalogPin({ catalog: fixture, - publication: async () => published(), + service: "postgrest", + upstream: "v16.2", + release: "v16.2-r0", + io, }), ).rejects.toThrow(InvalidPayloadError); }); }); +describe("refreshCatalogPin backfills upstreamImage", () => { + test("resolves a derived service's upstreamImage from its per-target manifests", async () => { + const digests = nativeDigests("y"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0", "analytics-v1.50.9-r1"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r1", digests), + imageDigest: async () => digest("z"), + s3Sha256: matchingS3("analytics", "v1.50.9-r1", digests), + fetchManifest: async () => JSON.stringify({ upstream_image: "supabase/logflare:1.50.9" }), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.source).toContain('upstreamImage: "supabase/logflare:1.50.9"'); + }); + + test("falls back to source_image when a target's manifest has no upstream_image (image-derived build)", async () => { + const digests = nativeDigests("aa"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("ab"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + fetchManifest: async () => JSON.stringify({ source_image: "supabase/logflare:1.50.9" }), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.source).toContain('upstreamImage: "supabase/logflare:1.50.9"'); + }); + + test("strips a docker.io prefix and a digest from the resolved upstreamImage", async () => { + const digests = nativeDigests("ac"); + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("ad"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + fetchManifest: async () => + JSON.stringify({ upstream_image: "docker.io/supabase/logflare:1.50.9@sha256:deadbeef" }), + }; + + const result = await refreshCatalogPin({ catalog: fixture, service: "analytics", io }); + + expect(result.source).toContain('upstreamImage: "supabase/logflare:1.50.9"'); + }); + + test("fails loudly when a derived service's manifests disagree across native targets", async () => { + const digests = nativeDigests("ae"); + let call = 0; + const io: RevisionIo = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("af"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + fetchManifest: async () => { + call += 1; + return JSON.stringify({ upstream_image: `supabase/logflare:1.50.${call}` }); + }, + }; + + await expect(refreshCatalogPin({ catalog: fixture, service: "analytics", io })).rejects.toThrow( + /manifests disagree/, + ); + }); + + test("resolves a mirrored service's upstreamImage from its oci-provenance source", async () => { + const digests = nativeDigests("ag"); + const io: RevisionIo = { + listReleaseTags: async () => ["vector-0.53.0-r0"], + 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" }), + }; + + const result = await refreshCatalogPin({ catalog: vectorFixture, service: "vector", io }); + + 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 = { + listReleaseTags: async () => ["analytics-v1.50.9-r0"], + fetchChecksums: async () => checksumsFor("analytics", "v1.50.9-r0", digests), + imageDigest: async () => digest("al"), + s3Sha256: matchingS3("analytics", "v1.50.9-r0", digests), + fetchManifest: async () => + JSON.stringify({ upstream_image: 'supabase/logflare:1.50.9"] }; import("evil"); //' }), + }; + + await expect(refreshCatalogPin({ catalog: fixture, service: "analytics", io })).rejects.toThrow( + InvalidPayloadError, + ); + }); +}); + describe("against the real catalog", () => { - test("every slim Dockerfile alias is a catalog entry", async () => { - const dockerfile = await Bun.file("apps/cli/src/shared/services/Dockerfile").text(); + test("a real catalog entry survives real formatting and can be refreshed again", async () => { const catalog = await Bun.file(CATALOG_PATH).text(); - const plan = await planArtifactCatalogUpdate({ - dockerfile, + const pinnedVersion = /definition\(\s*"auth",\s*\{\s*upstreamVersion:\s*"([^"]+)"/.exec( catalog, - publication: async () => published(`sha256:${"a".repeat(64)}`), + )?.[1]; + if (pinnedVersion === undefined) throw new Error("auth pin not found in the real catalog"); + + const first = nativeDigests("q"); + const firstIo: RevisionIo = { + listReleaseTags: async () => [`auth-${pinnedVersion}-r0`], + fetchChecksums: async () => checksumsFor("auth", `${pinnedVersion}-r0`, first), + imageDigest: async () => digest("r"), + s3Sha256: matchingS3("auth", `${pinnedVersion}-r0`, first), + }; + const written = await refreshCatalogPin({ catalog, service: "auth", io: firstIo }); + expect(written.update?.revision).toBe(0); + + const formatted = await formatWithOxfmt(written.source); + expect(formatted).toContain(`upstreamVersion: "${pinnedVersion}"`); + + const second = nativeDigests("s"); + const secondIo: RevisionIo = { + listReleaseTags: async () => [`auth-${pinnedVersion}-r0`, `auth-${pinnedVersion}-r1`], + fetchChecksums: async () => checksumsFor("auth", `${pinnedVersion}-r1`, second), + imageDigest: async () => digest("t"), + s3Sha256: matchingS3("auth", `${pinnedVersion}-r1`, second), + }; + const refreshed = await refreshCatalogPin({ + catalog: formatted, + service: "auth", + io: secondIo, }); - expect( - plan.skipped.filter((skip) => skip.blocking && !skip.reason.includes("older than")), - ).toEqual([]); + expect(refreshed.update).toEqual({ + service: "auth", + version: pinnedVersion, + revision: 1, + previousVersion: pinnedVersion, + target: "default", + }); + expect(refreshed.source).toContain("revision: 1"); + expect(refreshed.source).toContain(second["darwin-arm64"].manifest); }); }); diff --git a/.github/scripts/sync-artifacts-catalog.ts b/.github/scripts/sync-artifacts-catalog.ts index 838f2c5b75..0ec85a6b02 100644 --- a/.github/scripts/sync-artifacts-catalog.ts +++ b/.github/scripts/sync-artifacts-catalog.ts @@ -1,28 +1,145 @@ /** - * Pins `packages/stack/src/Artifacts.ts` to the slim workloads named by - * `apps/cli/src/shared/services/Dockerfile`. Native archive URLs are derived - * from service + version. An entry that already carries a digest keeps one: - * the published `ghcr.io/supabase/cli/:` manifest digest. + * 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. * - * Run: `bun .github/scripts/sync-artifacts-catalog.ts [base-dockerfile]` + * 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 */ -import { parseDockerfileServiceImages } from "../../apps/cli/src/shared/services/parse-dockerfile-service-images.ts"; -import { isOrioleImage, slimCatalogPin } from "../../apps/cli/src/shared/services/slim-images.ts"; import { DIGEST_PATTERN, InvalidPayloadError, SOURCE_REGISTRY, - VERSION_PATTERN, + checksumFor, escapeRegExp, + nativeFileNames, + nativeObjectUrl, } from "./slim-mirror-payload.ts"; export const CATALOG_PATH = "packages/stack/src/Artifacts.ts"; -const DOCKERFILE_PATH = "apps/cli/src/shared/services/Dockerfile"; -const NATIVE_RELEASES = "https://api.github.com/repos/supabase/slim-services/releases/tags"; const SLIM_IMAGE_PREFIX = `${SOURCE_REGISTRY}/`; +/** The three native targets the catalog pins per revision. Kept self-contained; see module docs. */ +const NATIVE_TARGETS = ["darwin-arm64", "linux-amd64", "linux-arm64"] as const; +type NativeTargetName = (typeof NATIVE_TARGETS)[number]; + +/** Overridable so an integration test can point at a local fixture server instead of GitHub. */ +const RELEASES_API = + process.env.SLIM_SERVICES_RELEASES_API ?? + "https://api.github.com/repos/supabase/slim-services/releases"; +const RELEASE_DOWNLOAD_BASE = "https://github.com/supabase/slim-services/releases/download"; + +/** 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`). */ +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. + +/** + * Parses a millisecond wait-interval override from `envVar` fresh on every call, not once at + * module load, so a test can set it right before spawning. Unset keeps `fallback`; anything else + * must be a non-negative integer of at most `MAX_TIMER_DELAY_MS`, or this throws. + */ +function waitIntervalMs(envVar: string, fallback: number): number { + const raw = process.env[envVar]; + if (raw === undefined) return fallback; + const value = Number(raw); + if (!/^(0|[1-9][0-9]*)$/.test(raw) || value > MAX_TIMER_DELAY_MS) { + throw new InvalidPayloadError( + `invalid ${envVar} ${JSON.stringify(raw)}: expected a non-negative integer of at most ${MAX_TIMER_DELAY_MS}`, + ); + } + return value; +} + +const expectReleaseIntervalMs = (): number => + waitIntervalMs("SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS", DEFAULT_EXPECT_RELEASE_INTERVAL_MS); +const s3WaitIntervalMs = (): number => + waitIntervalMs("SLIM_UPDATES_S3_WAIT_MS", DEFAULT_S3_WAIT_INTERVAL_MS); + +/** Real delay, for a caller that didn't override `wait`. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type BoundedCheck = + | { readonly kind: "done"; readonly value: T } + | { readonly kind: "retry" } + | { readonly kind: "failed"; readonly message: string }; + +type BoundedWaitOutcome = + | { readonly status: "done"; readonly value: T } + | { 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"`. + */ +async function boundedWait( + check: () => Promise>, + wait: (ms: number) => Promise, + attempts: number, + intervalMs: number, +): Promise> { + for (let attempt = 0; attempt < attempts; attempt++) { + const result = await check(); + if (result.kind === "done") return { status: "done", value: result.value }; + if (result.kind === "failed") return { status: "failed", message: result.message }; + if (attempt < attempts - 1) await wait(intervalMs); + } + return { status: "timed-out" }; +} + +/** + * A GitHub Actions workflow-command line, `message` run through the workflow-command data + * encoding (`%` first, so encoding `\r`/`\n` never gets re-escaped) — the boundary every + * `::error ::`/`::warning ::` this script emits goes through. + */ +function workflowCommand(kind: "error" | "warning", message: string): string { + const encoded = message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + return `::${kind} ::${encoded}`; +} + /** Leading numeric component, `v` stripped. Only postgres carries more than one line. */ function releaseLine(version: string): string { const withoutPrefix = version.replace(/^[vV]/, ""); @@ -30,369 +147,1240 @@ function releaseLine(version: string): string { return separator === -1 ? withoutPrefix : withoutPrefix.slice(0, separator); } -/** True when every numeric prefix of `next` is older than `current`. Equal prefixes are not older. */ -function isOlderRelease(next: string, current: string): boolean { - const nextParts = next.replace(/^[vV]/, "").split("."); - const currentParts = current.replace(/^[vV]/, "").split("."); - const count = Math.max(nextParts.length, currentParts.length); - for (let index = 0; index < count; index++) { - const nextValue = leadingInteger(nextParts[index] ?? "0"); - const currentValue = leadingInteger(currentParts[index] ?? "0"); - if (nextValue === undefined || currentValue === undefined) return false; - if (nextValue !== currentValue) return nextValue < currentValue; - } - return false; +/** + * Matches a `--r` release tag. With `upstream` given, matches only that + * exact upstream (what `resolveRevisionPin` needs); with it omitted, captures any upstream as + * group 1 and the revision as group 2 (what the planner needs to enumerate every committed + * revision of every upstream a service carries). A tag with no `-r` suffix — legacy — never + * matches either shape. + */ +function releaseTagPattern(service: string, upstream?: string): RegExp { + const upstreamPart = upstream === undefined ? "(.+)" : escapeRegExp(upstream); + return new RegExp(`^${escapeRegExp(service)}-${upstreamPart}-r(0|[1-9][0-9]*)$`); +} + +/** + * Strips a leading `v`/`V` and one trailing `-sha-`, then parses the remainder as dot- + * separated integers. Returns undefined when the remainder isn't `^\d+(\.\d+)*$` — a version that + * isn't comparable this way, such as an OrioleDB-style suffix. + */ +function comparableVersion(version: string): ReadonlyArray | undefined { + const stripped = version.replace(/^[vV]/, "").replace(/-sha-[0-9a-f]+$/i, ""); + if (!/^\d+(\.\d+)*$/.test(stripped)) return undefined; + return stripped.split(".").map(Number); } -function leadingInteger(part: string): number | undefined { - const match = /^(\d+)/.exec(part); - return match === null ? undefined : Number(match[1]); +/** + * Numeric ordering of two upstream versions: `-1` when `a` is older, `0` when neither is newer + * (including two versions that only differ in a stripped `-sha-` suffix, e.g. two Studio + * builds on the same date), `1` when `a` is newer. Undefined when either isn't comparable. + */ +function compareVersions(a: string, b: string): number | undefined { + const av = comparableVersion(a); + const bv = comparableVersion(b); + if (av === undefined || bv === undefined) return undefined; + const length = Math.max(av.length, bv.length); + for (let index = 0; index < length; index++) { + const left = av[index] ?? 0; + const right = bv[index] ?? 0; + if (left !== right) return left < right ? -1 : 1; + } + return 0; } export interface CatalogPinUpdate { readonly service: string; readonly version: string; + readonly revision: number; readonly previousVersion: string; readonly target: "default" | "additional"; } -export interface SkippedCatalogPin { - readonly alias: string; - readonly reason: string; - /** OrioleDB has no slim image and must not fail the other pins. */ - readonly blocking: boolean; +/** Content pin for one resolved `-r` revision, ready to serialize into the catalog. */ +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 + >; } -export interface CatalogPlan { - readonly source: string; - readonly updates: ReadonlyArray; - readonly skipped: ReadonlyArray; +export type RevisionResolution = + | { readonly status: "resolved"; readonly pin: ResolvedPin } + | { + readonly status: "missing" | "incomplete" | "stale" | "lookup-failed"; + readonly message: string; + }; + +/** + * Network ports the resolver needs: the release list (for revision allocation), a release's + * `SHA256SUMS` body, the GHCR manifest digest, and a byte source's sha256. Tests stub these + * directly. + */ +export interface RevisionIo { + /** Tags of every published, non-draft release — a draft is not yet a committed revision. */ + readonly listReleaseTags: () => Promise>; + readonly fetchChecksums: (service: string, releaseVersion: string) => Promise; + readonly imageDigest: (service: string, releaseVersion: string) => Promise; + readonly s3Sha256: (url: string) => Promise; + /** Overridable real delay between the S3-mirror wait's bounded attempts. */ + readonly wait?: (ms: number) => Promise; + /** + * 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. + */ + 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`. + */ + readonly fetchProvenance?: ( + service: string, + releaseVersion: string, + upstreamVersion: string, + ) => Promise; } -export type ReleasePublication = - | { readonly status: "published"; readonly digest?: string } - | { readonly status: "missing" | "lookup-failed" }; +function desiredImage(service: string, releaseVersion: string, digest: string): string { + if (!DIGEST_PATTERN.test(digest)) { + throw new InvalidPayloadError(`missing slim digest for ${service}:${releaseVersion}`); + } + return `${SLIM_IMAGE_PREFIX}${service}:${releaseVersion}@${digest}`; +} -/** `definition("", "", ""`. */ -function defaultEntryPattern(service: string): RegExp { - const s = escapeRegExp(service); - return new RegExp( - `(definition\\(\\s*"${s}",\\s*")([^"]+)("\\s*,\\s*")(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)(")`, - ); +/** + * Resolves `service`'s committed `-r` revision, pinned by content: the GHCR + * 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). + */ +export async function resolveRevisionPin( + service: string, + upstream: string, + io: RevisionIo, + requiredRevision?: number, +): Promise { + const tagPattern = releaseTagPattern(service, upstream); + const seenRevisions = new Set(); + let highest: number | undefined; + for (const tag of await io.listReleaseTags()) { + const match = tagPattern.exec(tag); + if (match === null) continue; + const revision = Number(match[1]); + seenRevisions.add(revision); + if (highest === undefined || revision > highest) highest = revision; + } + + let target: number; + if (requiredRevision !== undefined) { + if (!seenRevisions.has(requiredRevision)) { + return { + status: "missing", + message: `${service}:${upstream}-r${requiredRevision} is not a committed slim-services release.`, + }; + } + target = requiredRevision; + } else { + if (highest === undefined) { + return { + status: "missing", + message: `${service}:${upstream} has no published slim-services revision.`, + }; + } + target = highest; + } + + const releaseVersion = `${upstream}-r${target}`; + const checksums = await io.fetchChecksums(service, releaseVersion); + if (checksums === undefined) { + return { + status: "incomplete", + message: `${service}-${releaseVersion} has no SHA256SUMS asset.`, + }; + } + + const natives: Record = {}; + for (const target of NATIVE_TARGETS) { + const files = nativeFileNames(service, releaseVersion, target); + const archive = checksumFor(checksums, files.archive); + const manifest = checksumFor(checksums, files.manifest); + if (archive === undefined || manifest === undefined) { + return { + status: "incomplete", + message: `${service}-${releaseVersion} SHA256SUMS has no line for ${ + archive === undefined ? files.archive : files.manifest + }.`, + }; + } + natives[target] = { archive, manifest }; + } + + const digest = await io.imageDigest(service, releaseVersion); + if (digest === undefined || !DIGEST_PATTERN.test(digest)) { + return { + status: "lookup-failed", + message: `${SLIM_IMAGE_PREFIX}${service}:${releaseVersion} has no published manifest.`, + }; + } + + // `mirror-slim-image.yml`'s S3 upload runs in parallel, best-effort, so a freshly committed + // revision's S3 copy can briefly lag GitHub: a missing object waits, bounded; a present object + // with the wrong bytes fails immediately instead of waiting. + for (const target of NATIVE_TARGETS) { + const files = nativeFileNames(service, releaseVersion, target); + for (const part of ["archive", "manifest"] as const) { + const url = nativeObjectUrl(service, releaseVersion, files[part]); + const expected = natives[target]?.[part] as string; + const outcome = await boundedWait( + async () => { + const actual = await io.s3Sha256(url); + if (actual === undefined) { + console.log( + `Waiting for the S3 mirror of ${service}-${releaseVersion} ${target} (${part})...`, + ); + return { kind: "retry" }; + } + if (actual !== expected) { + return { + kind: "failed", + message: `S3 copy of ${releaseVersion} ${target} (${part}) is stale (digest mismatch); run the slim-services mirror backfill.`, + }; + } + return { kind: "done", value: true }; + }, + io.wait ?? sleep, + S3_WAIT_ATTEMPTS, + s3WaitIntervalMs(), + ); + if (outcome.status === "failed") { + return { status: "stale", message: outcome.message }; + } + if (outcome.status === "timed-out") { + return { + status: "stale", + message: `S3 copy of ${releaseVersion} ${target} (${part}) never appeared; the S3 mirror hasn't finished — check mirror-slim-image.yml for this release, backfill if needed, and re-run.`, + }; + } + } + } + + return { + status: "resolved", + pin: { + upstreamVersion: upstream, + revision: target, + image: desiredImage(service, releaseVersion, digest), + natives: natives as Record, + }, + }; } -/** Additional release entries: `"": ""`. */ -function additionalEntryPattern(service: string, version?: string): RegExp { - const s = escapeRegExp(service); - const key = version === undefined ? `[^"]+` : escapeRegExp(version); - return new RegExp( - `"(${key})"(\\s*:\\s*)"(${escapeRegExp(SLIM_IMAGE_PREFIX)}${s}:[^"]+)"`, - version === undefined ? "g" : "", - ); +/** + * Services slim-services mirrors from an unmodified upstream image rather than building from + * source: their `upstreamImage` comes from the release's `oci-provenance.json` `source` field, + * not a native target's manifest. Keep this in sync with `SlimServicesSource`'s mirror-mode + * services. + */ +const MIRROR_MODE_SOURCE_SERVICES: ReadonlySet = new Set(["vector", "mailpit", "imgproxy"]); + +function parseJsonRecord(raw: string): Record | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : undefined; +} + +function stringField(record: Record | undefined, key: string): string | undefined { + const value = record?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; } -function imageHasDigest(image: string): boolean { - return /@sha256:[0-9a-f]{64}$/.test(image); +/** Strips a `docker.io/` prefix and any `@sha256:…` digest, to match the Dockerfile's `FROM` form. */ +function normalizeUpstreamImage(image: string): string { + const withoutDigest = image.split("@")[0] ?? image; + return withoutDigest.startsWith("docker.io/") + ? withoutDigest.slice("docker.io/".length) + : withoutDigest; } -function desiredImage( +/** + * A normalized `upstreamImage`: one or more lowercase `registry`/`repository` path segments + * (each `[a-z0-9]`, optionally separated internally by `.`/`_`/`-`), a `:`, and a tag matching + * Docker's own tag grammar. Anchored, so no whitespace, quote, or template/expression syntax can + * slip through — this value gets embedded as a TypeScript string literal in `Artifacts.ts`. + */ +const IMAGE_REFERENCE_PATTERN = + /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*:[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/; + +/** Validates a normalized `upstreamImage` before it's ever written to `Artifacts.ts`. */ +function validateUpstreamImage(image: string, context: string): string { + if (!IMAGE_REFERENCE_PATTERN.test(image)) { + throw new InvalidPayloadError( + `${context} has an invalid upstreamImage ${JSON.stringify(image)}.`, + ); + } + return image; +} + +/** + * Resolves `service`'s `upstreamImage` for the release `releaseVersion` (`-r`): + * a mirrored service's `oci-provenance.json` `source`, or a derived service's per-target + * 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`. + */ +async function resolveUpstreamImage( service: string, - version: string, - currentImage: string, - digest: string, -): string { - const tagged = `${SLIM_IMAGE_PREFIX}${service}:${version}`; - if (!imageHasDigest(currentImage)) return tagged; - if (!DIGEST_PATTERN.test(digest)) { - throw new InvalidPayloadError(`missing slim digest for ${service}:${version}`); + releaseVersion: string, + upstreamVersion: string, + 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.`); + } + const source = stringField(parseJsonRecord(provenance), "source"); + if (source === undefined) { + throw new InvalidPayloadError( + `${service}-${releaseVersion} oci-provenance has no 'source' field.`, + ); + } + 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); + if (manifest === undefined) { + throw new InvalidPayloadError( + `${service}-${releaseVersion}-${target} has no manifest asset.`, + ); + } + const record = parseJsonRecord(manifest); + const value = stringField(record, "upstream_image") ?? stringField(record, "source_image"); + if (value === undefined) { + throw new InvalidPayloadError( + `${service}-${releaseVersion}-${target} manifest has neither 'upstream_image' nor 'source_image'.`, + ); + } + values.add(normalizeUpstreamImage(value)); + } + if (values.size !== 1) { + throw new InvalidPayloadError( + `${service}-${releaseVersion} manifests disagree on the upstream image: ${[...values] + .sort() + .map((value) => JSON.stringify(value)) + .join(", ")}.`, + ); } - return `${tagged}@${digest}`; + return validateUpstreamImage([...values][0] as string, `${service}-${releaseVersion}`); +} + +/** + * Serializes a resolved pin into the object literal `Artifacts.ts` embeds, in catalog order. + * Every string field goes through `JSON.stringify`, not manual `"${…}"` interpolation — this + * 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 { + 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} } }`; +} + +interface PinSpan { + readonly start: number; + readonly end: number; + readonly version: string; + /** + * Span of an additional (release-line-keyed) pin's own string key, content only (no quotes). + * Absent for the default pin, which carries no separate key to keep in sync. + */ + readonly key?: { readonly start: number; readonly end: number }; +} + +/** + * Index right after the matching close-bracket for the open-bracket character at `openIndex` + * (which must itself be `open`). Skips string contents, so a formatter's line-wrapping, trailing + * commas, or reordered properties never confuse the boundary — only bracket balance matters. + */ +function scanBalanced(source: string, openIndex: number, open: string, close: string): number { + let depth = 0; + let index = openIndex; + for (; index < source.length; index++) { + const ch = source[index]; + if (ch === '"' || ch === "'" || ch === "`") { + const quote = ch; + index++; + while (index < source.length && source[index] !== quote) { + if (source[index] === "\\") index++; + index++; + } + continue; + } + if (ch === open) depth++; + else if (ch === close) { + depth--; + if (depth === 0) return index + 1; + } + } + throw new InvalidPayloadError(`unterminated '${open}' while parsing ${CATALOG_PATH}`); +} + +/** Loosely finds `upstreamVersion` anywhere inside a resolved pin literal's text. */ +const PIN_UPSTREAM_VERSION = /upstreamVersion:\s*"([^"]+)"/; +/** Loosely finds `revision` anywhere inside a resolved pin literal's text. */ +const PIN_REVISION = /revision:\s*(\d+)/; + +/** + * Matches a resolved `ArtifactPin` object literal starting exactly 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. + */ +function matchPinAt(source: string, index: number): PinSpan | undefined { + if (source[index] === "{") { + const end = scanBalanced(source, index, "{", "}"); + const version = PIN_UPSTREAM_VERSION.exec(source.slice(index, end))?.[1]; + return version === undefined ? undefined : { start: index, end, version }; + } + return undefined; +} + +interface ServicePins { + readonly defaultPin: PinSpan; + readonly additional: ReadonlyArray; +} + +/** + * Finds every pin expression `service` carries in `source`: the `definition("", , + * ...)` default pin, plus any additional (release-line-keyed) pins in its trailing object + * argument. Both `selectEntry` and `planSlimUpdates` build on this single traversal. + */ +function collectServicePins(source: string, service: string): ServicePins | undefined { + const prefix = new RegExp(`definition\\(\\s*"${escapeRegExp(service)}"\\s*,\\s*`); + const prefixMatch = prefix.exec(source); + if (prefixMatch === null) return undefined; + const pinIndex = prefixMatch.index + prefixMatch[0].length; + const defaultPin = matchPinAt(source, pinIndex); + if (defaultPin === undefined) return undefined; + + const openParenIndex = prefixMatch.index + prefixMatch[0].indexOf("("); + const callEnd = scanBalanced(source, openParenIndex, "(", ")"); + + const additional: PinSpan[] = []; + const keyPattern = /"([^"]+)"\s*:\s*/g; + keyPattern.lastIndex = defaultPin.end; + for ( + let keyMatch = keyPattern.exec(source); + keyMatch !== null; + keyMatch = keyPattern.exec(source) + ) { + if (keyMatch.index >= callEnd) break; + const valueStart = keyMatch.index + keyMatch[0].length; + const pin = matchPinAt(source, valueStart); + if (pin === undefined) { + keyPattern.lastIndex = valueStart; + continue; + } + const keyText = keyMatch[1] ?? ""; + const keyStart = keyMatch.index + keyMatch[0].indexOf(keyText); + additional.push({ ...pin, key: { start: keyStart, end: keyStart + keyText.length } }); + keyPattern.lastIndex = pin.end; + } + + return { defaultPin, additional }; } type SelectedEntry = - | { readonly kind: "default" | "additional"; readonly version: string; readonly image: string } + | { readonly kind: "default" | "additional"; readonly version: string; readonly span: PinSpan } | { readonly kind: "unmodelled-service" } | { readonly kind: "unmodelled-release-line"; readonly known: ReadonlyArray }; -function selectEntry(source: string, service: string, version: string): SelectedEntry { - const defaultMatch = defaultEntryPattern(service).exec(source); - if (defaultMatch === null) return { kind: "unmodelled-service" }; +/** + * Locates `service`'s pin expression in `source`: the `definition("", , ...)` + * default pin, and, when `version` is given, whichever pin (default or additional) sits on its + * release line. With no `version`, returns the default pin unconditionally. + */ +function selectEntry(source: string, service: string, version: string | undefined): SelectedEntry { + const pins = collectServicePins(source, service); + if (pins === undefined) return { kind: "unmodelled-service" }; + const { defaultPin, additional } = pins; + + if (version === undefined) { + return { kind: "default", version: defaultPin.version, span: defaultPin }; + } - const currentDefaultVersion = defaultMatch[2] ?? ""; - const currentDefaultImage = defaultMatch[4] ?? ""; - const additional = [...source.matchAll(additionalEntryPattern(service))].map((match) => ({ - version: match[1] ?? "", - image: match[3] ?? "", - })); const bumpsDefault = - additional.length === 0 || releaseLine(version) === releaseLine(currentDefaultVersion); + additional.length === 0 || releaseLine(version) === releaseLine(defaultPin.version); if (bumpsDefault) { - return { kind: "default", version: currentDefaultVersion, image: currentDefaultImage }; + return { kind: "default", version: defaultPin.version, span: defaultPin }; } - - const sameLine = additional.find((entry) => releaseLine(entry.version) === releaseLine(version)); + const sameLine = additional.find((pin) => releaseLine(pin.version) === releaseLine(version)); if (sameLine === undefined) { return { kind: "unmodelled-release-line", - known: [currentDefaultVersion, ...additional.map((entry) => entry.version)], + known: [defaultPin.version, ...additional.map((pin) => pin.version)], }; } - return { kind: "additional", version: sameLine.version, image: sameLine.image }; + return { kind: "additional", version: sameLine.version, span: sameLine }; } -function skipReason(alias: string, version: string, entry: SelectedEntry): string | undefined { - if (entry.kind === "unmodelled-service") { - return `${CATALOG_PATH} has no slim entry for ${alias}.`; +/** The `{service, upstream_version, revision, release_version}` payload a `slim-release-published` dispatch carries. */ +export interface SlimReleasePublishedPayload { + readonly service: string; + readonly upstream_version: string; + readonly revision: number; + readonly release_version: string; +} + +/** Every upstream tag format the catalog carries, including Studio's `2026.09.04-sha-5a67366`. */ +const UPSTREAM_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SERVICE_NAME_PATTERN = /^[a-z0-9-]+$/; +const RELEASE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*-r(0|[1-9][0-9]*)$/; + +/** + * Validates an untrusted `slim-release-published` dispatch payload before any of its fields are + * written anywhere (a `$GITHUB_OUTPUT` line, a branch name, a PR title, …). Every field is checked + * against an anchored charset — `upstream_version` and `release_version` included, not just + * `service` and `revision` — so a value carrying a newline or shell/Actions metacharacter is + * rejected here instead of being echoed downstream. + */ +export function validateSlimReleasePublishedPayload(input: { + readonly service: string; + readonly upstream_version: string; + readonly revision: string; + readonly release_version: string; +}): SlimReleasePublishedPayload { + if (!SERVICE_NAME_PATTERN.test(input.service)) { + throw new InvalidPayloadError(`invalid service: ${JSON.stringify(input.service)}`); } - if (entry.kind === "unmodelled-release-line") { - return `${alias} ${version} is not on a release line ${CATALOG_PATH} carries (${entry.known.join(", ")}).`; + if (!UPSTREAM_VERSION_PATTERN.test(input.upstream_version)) { + throw new InvalidPayloadError( + `invalid upstream_version: ${JSON.stringify(input.upstream_version)}`, + ); } - return undefined; + if (!/^(0|[1-9][0-9]*)$/.test(input.revision)) { + throw new InvalidPayloadError(`invalid revision: ${JSON.stringify(input.revision)}`); + } + if (!RELEASE_VERSION_PATTERN.test(input.release_version)) { + throw new InvalidPayloadError( + `invalid release_version: ${JSON.stringify(input.release_version)}`, + ); + } + const revision = Number(input.revision); + const expected = `${input.upstream_version}-r${revision}`; + if (input.release_version !== expected) { + throw new InvalidPayloadError( + `release_version ${JSON.stringify(input.release_version)} does not match derived ${JSON.stringify(expected)}`, + ); + } + return { + service: input.service, + upstream_version: input.upstream_version, + revision, + release_version: input.release_version, + }; } -function slimVersions(dockerfile: string): ReadonlyMap { - const versions = new Map(); - for (const from of parseDockerfileServiceImages(dockerfile)) { - const pin = slimCatalogPin(from.alias, from.image); - if (pin !== undefined) versions.set(from.alias, pin.version); +const normalizeText = (text: string): string => text.replace(/\s+/g, " ").trim(); + +/** + * Writes `pin` over `entry`'s span in `source`, or leaves it unchanged when it already matches. + * An additional (release-line-keyed) pin also gets its own string key rewritten to `pin`'s + * `upstreamVersion` when it moves to a different upstream version, so the key an additional pin + * is looked up by never drifts from the `upstreamVersion` its resolved pin literal carries. + */ +function writePin( + source: string, + entry: Extract, + pin: ResolvedPin, +): { readonly source: string; readonly changed: boolean } { + const desired = serializePin(pin); + const current = source.slice(entry.span.start, entry.span.end); + let next = source; + let changed = false; + if (normalizeText(current) !== normalizeText(desired)) { + next = next.slice(0, entry.span.start) + desired + next.slice(entry.span.end); + changed = true; } - return versions; + const key = entry.span.key; + if (key !== undefined && source.slice(key.start, key.end) !== pin.upstreamVersion) { + next = next.slice(0, key.start) + pin.upstreamVersion + next.slice(key.end); + changed = true; + } + return { source: next, changed }; } -type PinResult = - | { - readonly kind: "updated"; - readonly source: string; - readonly previousVersion: string; - readonly target: "default" | "additional"; - } - | { readonly kind: "unchanged" }; +/** + * One hotfix or upgrade a `slim-release-published` run should apply, on one of `service`'s + * release lines. `line` is the leading numeric component (`releaseLine`), present only when the + * service carries more than one line (only postgres does today) — it's already folded into + * `branch`, so a caller never needs to consume it separately. + */ +export interface SlimUpdate { + readonly kind: "hotfix" | "upgrade"; + readonly line?: string; + readonly branch: string; + readonly title: string; + /** The release version pinned before this update, e.g. `v2.195.0-r0`. */ + readonly fromRelease: string; + readonly toUpstream: string; + /** The release version this update pins, e.g. `v2.195.0-r1`. */ + readonly toRelease: string; +} -function pinService( - source: string, +/** + * Builds one `SlimUpdate`, validating every value it emits — `service`, `toUpstream` and + * `toRelease` — against the same anchored patterns `validateSlimReleasePublishedPayload` uses, + * before any of them reaches a branch name or PR title. Release tag names (the ultimate source of + * `toUpstream`) come from an external API, so this check applies even to values derived from the + * catalog itself (`fromRelease`'s upstream), not only to values freshly parsed from a tag. + */ +function buildUpdate( + kind: "hotfix" | "upgrade", service: string, - version: string, - digest: string | undefined, -): PinResult { - const entry = selectEntry(source, service, version); - if (entry.kind !== "default" && entry.kind !== "additional") { - throw new InvalidPayloadError(`${service} ${version} is not a catalog entry.`); + line: string, + hasLines: boolean, + pinned: { readonly upstream: string; readonly revision: number }, + toUpstream: string, + toRevision: number, +): SlimUpdate { + if (!SERVICE_NAME_PATTERN.test(service)) { + throw new InvalidPayloadError(`invalid service: ${JSON.stringify(service)}`); } - if (imageHasDigest(entry.image) && (digest === undefined || !DIGEST_PATTERN.test(digest))) { - throw new InvalidPayloadError(`missing slim digest for ${service}:${version}`); + if (!UPSTREAM_VERSION_PATTERN.test(toUpstream)) { + throw new InvalidPayloadError(`invalid upstream version: ${JSON.stringify(toUpstream)}`); } - const desired = desiredImage(service, version, entry.image, digest ?? ""); - if (entry.version === version && entry.image === desired) return { kind: "unchanged" }; - - if (entry.kind === "default") { - return { - kind: "updated", - source: source.replace( - defaultEntryPattern(service), - (_full, prefix: string, _version: string, mid: string, _image: string, suffix: string) => - `${prefix}${version}${mid}${desired}${suffix}`, - ), - previousVersion: entry.version, - target: "default", - }; + const toRelease = `${toUpstream}-r${toRevision}`; + if (!RELEASE_VERSION_PATTERN.test(toRelease)) { + throw new InvalidPayloadError(`invalid release version: ${JSON.stringify(toRelease)}`); } + const fromRelease = `${pinned.upstream}-r${pinned.revision}`; + const suffix = hasLines ? `-${line}` : ""; + const branch = + kind === "hotfix" ? `slim-hotfix/${service}${suffix}` : `slim-bump/${service}${suffix}`; + const title = + kind === "hotfix" + ? `chore(stack): pin ${service} ${toRelease}` + : `chore(stack): bump ${service} to ${toRelease}`; return { - kind: "updated", - source: source.replace( - additionalEntryPattern(service, entry.version), - (_full, _key: string, separator: string) => `"${version}"${separator}"${desired}"`, - ), - previousVersion: entry.version, - target: "additional", + kind, + line: hasLines ? line : undefined, + branch, + title, + fromRelease, + toUpstream, + toRelease, }; } +export interface PlanSlimUpdatesResult { + readonly updates: ReadonlyArray; + /** `::warning ::…` workflow-command lines, for the caller to print to stdout. */ + readonly warnings: ReadonlyArray; +} + +/** Groups every candidate release tag under one bucket, for a service with no additional pins. */ +const SINGLE_LINE_KEY = "*"; + /** - * Rewrites `catalog` from Dockerfile tags that differ from `baseDockerfile`. - * With no base, every slim tag is in scope. A pin that cannot be applied is - * reported in `skipped`. OrioleDB is the only non-blocking skip. + * 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. */ -export async function planArtifactCatalogUpdate(input: { - readonly dockerfile: string; - readonly baseDockerfile?: string; - readonly catalog: string; - readonly publication: (service: string, version: string) => Promise; -}): Promise { - let source = input.catalog; - const updates: CatalogPinUpdate[] = []; - const skipped: SkippedCatalogPin[] = []; - const baseVersions = - input.baseDockerfile === undefined ? undefined : slimVersions(input.baseDockerfile); - const changed = (alias: string, version: string | undefined): boolean => - baseVersions === undefined || baseVersions.get(alias) !== version; - - for (const from of parseDockerfileServiceImages(input.dockerfile)) { - if (isOrioleImage(from.image)) { - if (!changed(from.alias, undefined)) continue; - skipped.push({ - alias: from.alias, - reason: `${from.alias} ${from.image} has no slim image.`, - blocking: false, - }); - continue; +export function planSlimUpdates( + catalog: string, + service: string, + releaseTags: ReadonlyArray, +): PlanSlimUpdatesResult { + const warnings: string[] = []; + const pins = collectServicePins(catalog, service); + if (pins === undefined) { + warnings.push( + workflowCommand( + "warning", + `${CATALOG_PATH} has no slim entry for ${service}; nothing to plan.`, + ), + ); + return { updates: [], warnings }; + } + + const allPins = [pins.defaultPin, ...pins.additional]; + const hasLines = pins.additional.length > 0; + + const pinnedByLine = new Map(); + for (const span of allPins) { + const line = hasLines ? releaseLine(span.version) : SINGLE_LINE_KEY; + const text = catalog.slice(span.start, span.end); + const revisionMatch = PIN_REVISION.exec(text); + if (revisionMatch === null) { + throw new InvalidPayloadError( + `${CATALOG_PATH} has no revision for ${service} ${span.version}; a committed catalog always pins a resolved revision.`, + ); } - const pin = slimCatalogPin(from.alias, from.image); - if (pin === undefined || !changed(from.alias, pin.version)) continue; - if (!VERSION_PATTERN.test(pin.version)) { - throw new InvalidPayloadError(`invalid version for ${from.alias}: '${pin.version}'`); + pinnedByLine.set(line, { upstream: span.version, revision: Number(revisionMatch[1]) }); + } + + const tagPattern = releaseTagPattern(service); + const candidatesByLine = new Map>(); + for (const tag of releaseTags) { + const match = tagPattern.exec(tag); + if (match === null) continue; // not this service, or a legacy tag with no `-rN`: ignored. + const upstream = match[1] as string; + const revision = Number(match[2]); + let line: string; + if (hasLines) { + line = releaseLine(upstream); + if (!pinnedByLine.has(line)) { + warnings.push( + workflowCommand( + "warning", + `${service} ${upstream} is not on a release line ${CATALOG_PATH} carries for it; ignoring ${tag}.`, + ), + ); + continue; + } + } else { + line = SINGLE_LINE_KEY; } + const list = candidatesByLine.get(line) ?? []; + list.push({ upstream, revision }); + candidatesByLine.set(line, list); + } - const entry = selectEntry(source, pin.service, pin.version); - const reason = skipReason(from.alias, pin.version, entry); - if (reason !== undefined) { - skipped.push({ alias: from.alias, reason, blocking: true }); - continue; + const updates: SlimUpdate[] = []; + for (const [line, pinned] of pinnedByLine) { + const candidates = candidatesByLine.get(line) ?? []; + + 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), + ); + } } - if (entry.kind !== "default" && entry.kind !== "additional") continue; - if (isOlderRelease(pin.version, entry.version)) { - skipped.push({ - alias: from.alias, - reason: `${pin.service} ${pin.version} is older than the catalog pin ${entry.version}.`, - blocking: true, - }); - continue; + + const highestRevisionByUpstream = new Map(); + for (const candidate of candidates) { + const current = highestRevisionByUpstream.get(candidate.upstream); + if (current === undefined || candidate.revision > current) { + highestRevisionByUpstream.set(candidate.upstream, candidate.revision); + } } - if (entry.version === pin.version && !imageHasDigest(entry.image)) continue; - - const release = await input.publication(pin.service, pin.version); - if (release.status !== "published") { - skipped.push({ - alias: from.alias, - reason: - release.status === "missing" - ? `${pin.service}:${pin.version} has no published slim image and native release.` - : `${pin.service}:${pin.version} publication check failed.`, - blocking: true, - }); - continue; + + let bestUpstream: string | undefined; + for (const upstream of highestRevisionByUpstream.keys()) { + const comparedToPinned = compareVersions(upstream, pinned.upstream); + if (comparedToPinned === undefined) { + warnings.push( + workflowCommand( + "warning", + `${service} ${upstream} is not a comparable version; ignoring.`, + ), + ); + continue; + } + if (bestUpstream === undefined || (compareVersions(upstream, bestUpstream) ?? 0) > 0) { + bestUpstream = upstream; + } } - const digest = imageHasDigest(entry.image) ? release.digest : undefined; - if (imageHasDigest(entry.image) && (digest === undefined || !DIGEST_PATTERN.test(digest))) { - skipped.push({ - alias: from.alias, - reason: `${pin.service}:${pin.version} has no published slim manifest.`, - blocking: true, - }); - continue; + 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)); } - const result = pinService(source, pin.service, pin.version, digest); - if (result.kind === "unchanged") continue; - source = result.source; - updates.push({ - service: pin.service, - version: pin.version, - previousVersion: result.previousVersion, - target: result.target, - }); } - return { source, updates, skipped }; + return { updates, warnings }; } -type Probe = "published" | "missing" | "lookup-failed"; +/** + * Reads every committed release tag through `listReleaseTags` and plans against `catalog`. The + * injectable lister is the seam a dry run or an end-to-end test uses to exercise the whole + * `plan-updates` transport — this function plus the CLI's file/stdout wiring — without a network + * call, the same pattern `RevisionIo.listReleaseTags` already uses. + */ +export async function planUpdatesForService(input: { + readonly catalog: string; + readonly service: string; + readonly listReleaseTags: () => Promise>; +}): Promise { + const releaseTags = await input.listReleaseTags(); + return planSlimUpdates(input.catalog, input.service, releaseTags); +} + +export interface WaitForExpectedReleaseResult { + readonly visible: boolean; + /** The last listing `listReleaseTags` returned, whichever attempt it came from. */ + readonly tags: ReadonlyArray; +} + +/** + * Eventual-consistency handling of the releases API lagging behind the `slim-release-published` + * dispatch that triggered this run: re-lists up to `attempts` times, `io.wait`-ing between + * attempts, until `-` appears. Not a test retry — a real, bounded wait + * for an external API to catch up. `io.wait` is the seam a unit test overrides to skip the real + * delay; an integration test instead shrinks the real delay via `SLIM_UPDATES_EXPECT_RELEASE_WAIT_MS` + * (parsed fresh by `expectReleaseIntervalMs` on every call this default reaches), since a + * subprocess can't be handed a function. + */ +export async function waitForExpectedRelease( + service: string, + releaseVersion: string, + io: { + readonly listReleaseTags: () => Promise>; + readonly wait: (ms: number) => Promise; + }, + attempts: number = EXPECT_RELEASE_ATTEMPTS, + intervalMs: number = expectReleaseIntervalMs(), +): Promise { + const expectedTag = `${service}-${releaseVersion}`; + let tags: ReadonlyArray = []; + const outcome = await boundedWait( + async () => { + tags = await io.listReleaseTags(); + return tags.includes(expectedTag) ? { kind: "done", value: true } : { kind: "retry" }; + }, + io.wait, + attempts, + intervalMs, + ); + return { visible: outcome.status === "done", tags }; +} + +export interface CatalogRefreshResult { + readonly source: string; + readonly update?: CatalogPinUpdate; +} + +/** + * Refreshes one catalog entry: to exactly 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 + * reading and writing `Artifacts.ts`. + */ +export async function refreshCatalogPin(input: { + readonly catalog: string; + readonly service: string; + readonly upstream?: string; + readonly release?: string; + readonly io: RevisionIo; +}): Promise { + if (input.upstream !== undefined && input.release !== undefined) { + throw new InvalidPayloadError("--upstream and --release are mutually exclusive."); + } + if (input.upstream !== undefined && !UPSTREAM_VERSION_PATTERN.test(input.upstream)) { + throw new InvalidPayloadError(`invalid --upstream '${input.upstream}'`); + } + + let upstream = input.upstream; + let requiredRevision: number | undefined; + if (input.release !== undefined) { + if (!RELEASE_VERSION_PATTERN.test(input.release)) { + throw new InvalidPayloadError(`invalid --release '${input.release}'`); + } + const match = /^(.+)-r(0|[1-9][0-9]*)$/.exec(input.release) as RegExpExecArray; + upstream = match[1]; + requiredRevision = Number(match[2]); + } + + const entry = selectEntry(input.catalog, input.service, upstream); + if (entry.kind === "unmodelled-service") { + throw new InvalidPayloadError(`${CATALOG_PATH} has no slim entry for ${input.service}.`); + } + if (entry.kind === "unmodelled-release-line") { + throw new InvalidPayloadError( + `${input.service} ${upstream ?? ""} is not on a release line ${CATALOG_PATH} carries (${entry.known.join(", ")}).`, + ); + } + const resolvedUpstream = upstream ?? entry.version; + const resolution = await resolveRevisionPin( + input.service, + resolvedUpstream, + input.io, + requiredRevision, + ); + 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 written = writePin(input.catalog, entry, pin); + if (!written.changed) return { source: input.catalog }; + return { + source: written.source, + update: { + service: input.service, + version: resolvedUpstream, + revision: resolution.pin.revision, + previousVersion: entry.version, + target: entry.kind, + }, + }; +} + +function githubHeaders(): Record { + const headers: Record = { + Accept: "application/vnd.github+json", + "User-Agent": "supabase-cli-catalog-sync", + }; + const token = process.env.GITHUB_TOKEN; + if (token !== undefined && token !== "") headers.Authorization = `Bearer ${token}`; + return headers; +} -async function probeManifest(reference: string): Promise<{ probe: Probe; digest?: string }> { +/** Tags of every published, non-draft `supabase/slim-services` release. A draft is never a committed revision. */ +async function listReleaseTags(): Promise> { + const tags: string[] = []; + for (let page = 1; ; page++) { + const response = await fetch(`${RELEASES_API}?per_page=100&page=${page}`, { + headers: githubHeaders(), + }); + if (!response.ok) { + throw new InvalidPayloadError( + `slim-services releases list failed (HTTP ${response.status}).`, + ); + } + const batch: unknown = await response.json(); + if (!Array.isArray(batch)) { + throw new InvalidPayloadError("Malformed slim-services releases response."); + } + for (const item of batch) { + const record = item as { tag_name?: unknown; draft?: unknown } | null; + if (record?.draft === true) continue; + const tagName = record?.tag_name; + if (typeof tagName === "string") tags.push(tagName); + } + if (batch.length < 100) break; + } + return tags; +} + +async function fetchChecksums( + service: string, + releaseVersion: string, +): Promise { + const response = await fetch(`${RELEASE_DOWNLOAD_BASE}/${service}-${releaseVersion}/SHA256SUMS`); + if (response.status === 404) return undefined; + if (!response.ok) { + throw new InvalidPayloadError( + `SHA256SUMS download failed for ${service}-${releaseVersion} (HTTP ${response.status}).`, + ); + } + return response.text(); +} + +async function imageDigest(service: string, releaseVersion: string): Promise { + const reference = `${SLIM_IMAGE_PREFIX}${service}:${releaseVersion}`; const proc = Bun.spawn(["regctl", "manifest", "head", reference], { stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exit] = await Promise.all([ + const [stdout, , exit] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); const digest = stdout.trim(); - if (exit === 0 && DIGEST_PATTERN.test(digest)) return { probe: "published", digest }; - const detail = stderr.toLowerCase(); - if ( - detail.includes("manifest unknown") || - detail.includes("name unknown") || - detail.includes("not found") || - detail.includes("404") - ) { - return { probe: "missing" }; + return exit === 0 && DIGEST_PATTERN.test(digest) ? digest : undefined; +} + +async function s3Sha256(url: string): Promise { + const response = await fetch(url); + if (!response.ok) return undefined; + const hasher = new Bun.CryptoHasher("sha256"); + hasher.update(new Uint8Array(await response.arrayBuffer())); + return hasher.digest("hex"); +} + +async function fetchManifest( + service: string, + releaseVersion: string, + target: NativeTargetName, +): Promise { + const files = nativeFileNames(service, releaseVersion, target); + const response = await fetch( + `${RELEASE_DOWNLOAD_BASE}/${service}-${releaseVersion}/${files.manifest}`, + ); + if (response.status === 404) return undefined; + if (!response.ok) { + throw new InvalidPayloadError( + `manifest download failed for ${service}-${releaseVersion} ${target} (HTTP ${response.status}).`, + ); } - const message = stderr.trim(); - console.log( - `::warning ::${reference} publication check failed${message === "" ? "" : `: ${message}`}`, + return response.text(); +} + +async function fetchProvenance( + service: string, + releaseVersion: string, + upstreamVersion: string, +): Promise { + const response = await fetch( + `${RELEASE_DOWNLOAD_BASE}/${service}-${releaseVersion}/${service}-${upstreamVersion}.oci-provenance.json`, ); - return { probe: "lookup-failed" }; + if (response.status === 404) return undefined; + if (!response.ok) { + throw new InvalidPayloadError( + `oci-provenance download failed for ${service}-${releaseVersion} (HTTP ${response.status}).`, + ); + } + return response.text(); } -async function probeNativeRelease(service: string, version: string): Promise { - const tag = `${service}-${version}`; - const headers: Record = { - Accept: "application/vnd.github+json", - "User-Agent": "supabase-cli-catalog-sync", - }; - const token = process.env.GITHUB_TOKEN; - if (token !== undefined && token !== "") headers.Authorization = `Bearer ${token}`; - try { - const response = await fetch(`${NATIVE_RELEASES}/${encodeURIComponent(tag)}`, { headers }); - if (response.status === 200) return "published"; - if (response.status === 404) return "missing"; - console.log(`::warning ::${tag} native release check returned HTTP ${response.status}.`); - return "lookup-failed"; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.log(`::warning ::${tag} native release check failed: ${message}`); - return "lookup-failed"; - } -} - -async function lookupPublication(service: string, version: string): Promise { - const reference = `${SLIM_IMAGE_PREFIX}${service}:${version}`; - const [manifest, native] = await Promise.all([ - probeManifest(reference), - probeNativeRelease(service, version), - ]); - if (manifest.probe === "lookup-failed" || native === "lookup-failed") { - return { status: "lookup-failed" }; +function defaultRevisionIo(): RevisionIo { + return { listReleaseTags, fetchChecksums, imageDigest, s3Sha256, fetchManifest, fetchProvenance }; +} + +function parseFlags(argv: ReadonlyArray): ReadonlyMap { + const flags = new Map(); + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === undefined || !arg.startsWith("--")) { + throw new InvalidPayloadError(`unexpected argument '${arg ?? ""}'`); + } + const key = arg.slice(2); + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new InvalidPayloadError(`missing value for --${key}`); + } + flags.set(key, value); + index++; } - if (manifest.probe === "missing" || native === "missing") return { status: "missing" }; - return { status: "published", digest: manifest.digest }; + return flags; } -async function main(argv: ReadonlyArray): Promise { - const [dockerfilePath = DOCKERFILE_PATH, catalogPath = CATALOG_PATH, baseDockerfilePath] = argv; - const dockerfile = await Bun.file(dockerfilePath).text(); +async function runManual(argv: ReadonlyArray): Promise { + const flags = parseFlags(argv); + const service = flags.get("service"); + if (service === undefined) { + throw new InvalidPayloadError( + "Usage: sync-artifacts-catalog.ts --service [--upstream | --release -r]", + ); + } + const catalogPath = CATALOG_PATH; const catalog = await Bun.file(catalogPath).text(); - const baseDockerfile = - baseDockerfilePath === undefined ? undefined : await Bun.file(baseDockerfilePath).text(); - const plan = await planArtifactCatalogUpdate({ - dockerfile, - baseDockerfile, + const result = await refreshCatalogPin({ catalog, - publication: lookupPublication, + service, + upstream: flags.get("upstream"), + release: flags.get("release"), + io: defaultRevisionIo(), }); - for (const skip of plan.skipped) { - console.log(`::warning ::Left ${skip.alias} unchanged: ${skip.reason}`); + if (result.update === undefined) { + console.log(`${service} already pins the highest committed revision.`); + return; } - if (plan.skipped.some((skip) => skip.blocking)) { - console.log("::error ::Refusing to commit a partial catalog update."); - process.exit(1); + await Bun.write(catalogPath, result.source); + console.log( + `Pinned ${service} ${result.update.target} ${result.update.previousVersion} -> ${result.update.version} r${result.update.revision}.`, + ); +} + +/** + * Line-oriented encoding of one `SlimUpdate`, for a bash loop: `kind`, `branch`, `title` and + * `release` (the loop's four required fields, driving the checkout/pin/commit/PR steps) plus + * `from` (the release replaced, for the PR body's "from -> to"). Every field is guaranteed + * non-empty by construction. `\x1f` (unit separator) is the delimiter, not a tab: a title can + * carry ordinary whitespace, and `IFS=$'\t' read` would collapse it. + */ +function updateLine(update: SlimUpdate): string { + return [update.kind, update.branch, update.title, update.toRelease, update.fromRelease].join( + "\x1f", + ); +} + +/** + * `plan-updates`' records go only into `--output `, never stdout: a caller (the workflow) + * reads warnings from stdout as `::warning ::…` lines, and would otherwise mistake one for a + * malformed record and abort a run that had valid updates alongside it. + * + * `io.listReleaseTags` defaults to the real network lister but is overridable — the minimal seam + * a test uses to exercise this CLI mode's own file/stdout wiring (not just the pure planner) + * without a network call, the same pattern `RevisionIo` already uses. `io.wait` likewise defaults + * to a real delay but is overridable, the seam `waitForExpectedRelease`'s own unit tests use. The + * `--service`/`--output` usage check runs before either the catalog read or the lister, so a test + * can also assert this mode fails fast on a missing flag without touching the network. + * + * With `--expect-release -r` given, `--r` must be among the listed tags + * before planning runs at all — otherwise this dispatch's own release could be missing from a + * releases API that hasn't caught up yet, and the run would plan and pass quietly without it + * (`waitForExpectedRelease` handles the resulting eventual-consistency wait). Still missing after + * the bounded retries: this mode throws instead of planning, so `main()`'s handler reports an + * actionable `::error ::` and exits non-zero, and no `--output` file is written. + */ +export async function runPlanUpdates( + argv: ReadonlyArray, + io: { + readonly listReleaseTags: () => Promise>; + readonly wait?: (ms: number) => Promise; + } = { listReleaseTags }, +): Promise { + const flags = parseFlags(argv); + const service = flags.get("service"); + const output = flags.get("output"); + const expectRelease = flags.get("expect-release"); + if (service === undefined || output === undefined) { + throw new InvalidPayloadError( + "Usage: sync-artifacts-catalog.ts plan-updates --service --output " + + "[--format lines] [--expect-release -r]", + ); } - await Bun.write(catalogPath, plan.source); - if (plan.updates.length === 0) { - console.log("Workload catalog already matches the Dockerfile."); - return; + if (!SERVICE_NAME_PATTERN.test(service)) { + throw new InvalidPayloadError(`invalid --service ${JSON.stringify(service)}`); + } + const format = flags.get("format") ?? "json"; + if (format !== "json" && format !== "lines") { + throw new InvalidPayloadError(`invalid --format '${format}' (expected 'json' or 'lines')`); } - for (const update of plan.updates) { - console.log( - `Pinned ${update.service} ${update.target} ${update.previousVersion} -> ${update.version}.`, + if (expectRelease !== undefined && !RELEASE_VERSION_PATTERN.test(expectRelease)) { + throw new InvalidPayloadError(`invalid --expect-release ${JSON.stringify(expectRelease)}`); + } + + let listReleaseTags = io.listReleaseTags; + if (expectRelease !== undefined) { + const waited = await waitForExpectedRelease(service, expectRelease, { + listReleaseTags: io.listReleaseTags, + wait: io.wait ?? sleep, + }); + if (!waited.visible) { + throw new InvalidPayloadError( + `${service}-${expectRelease} is not visible yet from the slim-services releases API; re-run this workflow once it has propagated.`, + ); + } + // Reuses the listing the successful attempt already fetched, instead of listing again. + listReleaseTags = async () => waited.tags; + } + + const catalog = await Bun.file(CATALOG_PATH).text(); + const { updates, warnings } = await planUpdatesForService({ catalog, service, listReleaseTags }); + for (const warning of warnings) console.log(warning); + const content = + format === "lines" + ? updates.map((update) => `${updateLine(update)}\n`).join("") + : `${JSON.stringify(updates)}\n`; + await Bun.write(output, content); +} + +async function runValidatePayload(argv: ReadonlyArray): Promise { + const flags = parseFlags(argv); + const service = flags.get("service"); + const upstream = flags.get("upstream"); + const revision = flags.get("revision"); + const release = flags.get("release"); + if ( + service === undefined || + upstream === undefined || + revision === undefined || + release === undefined + ) { + throw new InvalidPayloadError( + "Usage: sync-artifacts-catalog.ts validate-payload --service --upstream --revision --release ", ); } + const payload = validateSlimReleasePublishedPayload({ + service, + upstream_version: upstream, + revision, + release_version: release, + }); + console.log(`service=${payload.service}`); + console.log(`upstream_version=${payload.upstream_version}`); + console.log(`revision=${payload.revision}`); + console.log(`release_version=${payload.release_version}`); +} + +async function main(argv: ReadonlyArray): Promise { + if (argv[0] === "validate-payload") { + await runValidatePayload(argv.slice(1)); + return; + } + if (argv[0] === "plan-updates") { + await runPlanUpdates(argv.slice(1)); + return; + } + if (argv[0]?.startsWith("--") === true) { + await runManual(argv); + return; + } + + throw new InvalidPayloadError( + "Usage: sync-artifacts-catalog.ts --service [--upstream | --release -r], " + + "or validate-payload / plan-updates --service ", + ); } if (import.meta.main) { main(process.argv.slice(2)).catch((error: unknown) => { - console.log(`::error ::${error instanceof Error ? error.message : String(error)}`); + console.log(workflowCommand("error", error instanceof Error ? error.message : String(error))); process.exit(1); }); } diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 5d7792d51a..8b96edfda8 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -28,7 +28,7 @@ permissions: {} # post-review's footer all read these instead of hardcoding them separately. env: CLAUDE_MODEL: claude-opus-5-5 - CODEX_MODEL: gpt-6-sol + CODEX_MODEL: gpt-6.1-sol # Non-command issue_comment events fire for every comment on every PR, so grouping by PR number # alone would let any comment cancel an in-flight review; give those runs their own per-run @@ -308,8 +308,8 @@ jobs: effort: high output-schema-file: /tmp/ai-review/findings.schema.json output-file: /tmp/ai-review/codex-findings.json - # 0.156.1 is the first stable CLI whose model catalog carries gpt-6-sol. - codex-version: &codex-cli-version "0.156.1" + # 0.159.1 is the first stable CLI whose model catalog carries gpt-6.1-sol. + codex-version: &codex-cli-version "0.159.2" working-directory: ${{ github.workspace }} safety-strategy: drop-sudo sandbox: read-only diff --git a/.github/workflows/mirror-template-images.yml b/.github/workflows/mirror-template-images.yml index 3b8cb03676..dabc95430e 100644 --- a/.github/workflows/mirror-template-images.yml +++ b/.github/workflows/mirror-template-images.yml @@ -3,9 +3,10 @@ name: Mirror template images # Keeps the ghcr.io/ECR mirror in sync with the image versions pinned in # apps/cli/src/shared/services/Dockerfile, which `dockerfileServiceImages` # parses as the single source of truth for local service images. When the -# Dockerfile changes on develop — most often via a merged dependabot `docker` -# bump — this workflow detects any tag that is not yet mirrored and backfills -# it the same way `cli-go-mirror-image.yml` does. +# Dockerfile changes on develop — most often via a merged catalog bump or +# hotfix PR from slim-release-published.yml, or a Dependabot `docker` bump for +# an upstream-only image — this workflow detects any tag that is not yet +# mirrored and backfills it the same way `cli-go-mirror-image.yml` does. # # It runs on `push` to develop (not on the PR) on purpose: mirroring needs the # AWS role + packages:write, which a dependabot-triggered `pull_request` run diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 57b1d3e8a5..9fbb1e2479 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -375,6 +375,7 @@ jobs: cp "dist/supabase_${VERSION}_${triple}.tar.gz" "dist/supabase_${triple}.tar.gz" done + # Created empty; the next step uploads the assets so each one can be retried on its own. - name: Create draft GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: @@ -382,29 +383,18 @@ jobs: name: v${{ inputs.version }} draft: true prerelease: ${{ inputs.prerelease }} - files: | - dist/supabase_${{ inputs.version }}_darwin_arm64.tar.gz - dist/supabase_${{ inputs.version }}_darwin_amd64.tar.gz - dist/supabase_${{ inputs.version }}_linux_arm64.tar.gz - dist/supabase_${{ inputs.version }}_linux_amd64.tar.gz - dist/supabase_${{ inputs.version }}_linux_arm64.deb - dist/supabase_${{ inputs.version }}_linux_amd64.deb - dist/supabase_${{ inputs.version }}_linux_arm64.rpm - dist/supabase_${{ inputs.version }}_linux_amd64.rpm - dist/supabase_${{ inputs.version }}_linux_arm64.apk - dist/supabase_${{ inputs.version }}_linux_amd64.apk - dist/supabase_${{ inputs.version }}_windows_amd64.zip - dist/supabase_${{ inputs.version }}_windows_arm64.zip - dist/supabase_${{ inputs.version }}_windows_amd64.tar.gz - dist/supabase_${{ inputs.version }}_windows_arm64.tar.gz - dist/checksums.txt - dist/supabase_darwin_arm64.tar.gz - dist/supabase_darwin_amd64.tar.gz - dist/supabase_linux_arm64.tar.gz - dist/supabase_linux_amd64.tar.gz - dist/supabase_windows_arm64.tar.gz - dist/supabase_windows_amd64.tar.gz - install + + # One asset at a time with per-asset retries; see apps/cli/scripts/upload-release-assets.ts. + - name: Upload release assets + env: + GH_TOKEN: ${{ github.token }} + run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts upload --version "${VERSION}" + + # Publishing makes the release immutable, so require every expected asset to be uploaded first. + - name: Verify release assets + env: + GH_TOKEN: ${{ github.token }} + run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts verify --version "${VERSION}" - name: Publish GitHub Release (immutable) env: diff --git a/.github/workflows/slim-release-published.yml b/.github/workflows/slim-release-published.yml new file mode 100644 index 0000000000..9e90d9a362 --- /dev/null +++ b/.github/workflows/slim-release-published.yml @@ -0,0 +1,227 @@ +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. + +on: + repository_dispatch: + types: + - slim-release-published + +permissions: + contents: read + +concurrency: + group: slim-release-published-${{ github.event.client_payload.service }} + cancel-in-progress: false + +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 + permissions: + contents: read + steps: + - name: Checkout the default branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + 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. + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + + - name: Record the base commit + 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. + - name: Validate payload + id: validate + env: + SERVICE: ${{ github.event.client_payload.service }} + UPSTREAM_VERSION: ${{ github.event.client_payload.upstream_version }} + REVISION: ${{ github.event.client_payload.revision }} + RELEASE_VERSION: ${{ github.event.client_payload.release_version }} + run: | + set -euo pipefail + if ! output="$(bun .github/scripts/sync-artifacts-catalog.ts validate-payload \ + --service "$SERVICE" --upstream "$UPSTREAM_VERSION" --revision "$REVISION" --release "$RELEASE_VERSION")"; then + echo "$output" + exit 1 + 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. + - name: Plan updates + id: plan + env: + SERVICE: ${{ steps.validate.outputs.service }} + RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + bun .github/scripts/sync-artifacts-catalog.ts plan-updates --service "$SERVICE" --format lines \ + --expect-release "$RELEASE_VERSION" --output "${RUNNER_TEMP}/slim-updates.tsv" + count="$(wc -l < "${RUNNER_TEMP}/slim-updates.tsv" | tr -d ' ')" + echo "count=${count}" >>"$GITHUB_OUTPUT" + + - name: Nothing to do + if: steps.plan.outputs.count == '0' + env: + SERVICE: ${{ steps.validate.outputs.service }} + RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} + run: echo "No catalog updates for ${SERVICE} from ${RELEASE_VERSION}; nothing to do." + + - name: Install regctl + if: steps.plan.outputs.count != '0' + run: | + set -euo pipefail + install -d "${RUNNER_TEMP}/regctl-bin" + curl -fsSLo "${RUNNER_TEMP}/regctl-bin/regctl" \ + https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 + echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${RUNNER_TEMP}/regctl-bin/regctl" | sha256sum -c - + chmod +x "${RUNNER_TEMP}/regctl-bin/regctl" + echo "${RUNNER_TEMP}/regctl-bin" >>"$GITHUB_PATH" + "${RUNNER_TEMP}/regctl-bin/regctl" version + + - name: Generate token + if: steps.plan.outputs.count != '0' + 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-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. + - 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 }} + RELEASE_VERSION: ${{ steps.validate.outputs.release_version }} + BASE_SHA: ${{ steps.base.outputs.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + 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. + 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}'." + exit 1 + fi + + git checkout -B "$branch" "$BASE_SHA" + + env -u APP_TOKEN bun .github/scripts/sync-artifacts-catalog.ts --service "$SERVICE" --release "$release" + env -u APP_TOKEN -u GITHUB_TOKEN pnpm exec oxfmt --config .oxfmtrc.json packages/stack/src/Artifacts.ts + env -u APP_TOKEN -u GITHUB_TOKEN bun apps/cli/scripts/render-service-dockerfile.ts + + generated_files=( + packages/stack/src/Artifacts.ts + apps/cli/src/shared/services/Dockerfile + apps/cli-go/pkg/config/templates/Dockerfile + ) + if git diff --quiet -- "${generated_files[@]}"; then + echo "${branch}: catalog and Dockerfiles already match ${release}; skipping." + continue + fi + + 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 + fi + + 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. + 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")" + + # `--head` matches by branch name only and ignores the owner, so a fork PR with the + # same head branch name would otherwise match too; `isCrossRepository` excludes it. + existing="$(GH_TOKEN="$APP_TOKEN" gh pr list --head "$branch" --base "$DEFAULT_BRANCH" --state open --json number,isCrossRepository --jq 'map(select(.isCrossRepository | not)) | .[0].number // empty')" + if [ -n "$existing" ]; then + if ! GH_TOKEN="$APP_TOKEN" gh pr edit "$existing" --title "$title" --body "$body"; then + echo "::error ::Pushed ${branch} but failed to edit pull request #${existing}. Run manually: ${manual_prefix} ${release}, then edit the pull request from ${branch} by hand." + exit 1 + fi + 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." + exit 1 + fi + done 3< "${RUNNER_TEMP}/slim-updates.tsv" diff --git a/.github/workflows/sync-artifacts-catalog.yml b/.github/workflows/sync-artifacts-catalog.yml deleted file mode 100644 index d57b5d7b28..0000000000 --- a/.github/workflows/sync-artifacts-catalog.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Sync Artifacts catalog - -# The script is the base revision. The pull request only supplies the Dockerfile -# and catalog text, and the job runs for Dependabot so that pull request cannot -# edit this workflow and then execute it with a write token. The catalog commit -# is pushed with the app token so the new head triggers CI. Dependabot's -# GITHUB_TOKEN would not. -on: - pull_request: - paths: - - apps/cli/src/shared/services/Dockerfile - -permissions: - contents: read - -concurrency: - group: sync-artifacts-catalog-${{ github.event.pull_request.number }} - cancel-in-progress: false - -jobs: - sync: - name: Pin workload catalog - if: github.actor == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - persist-credentials: true - fetch-depth: 1 - - # Bun only. The script imports no workspace packages. The firewall token - # is empty on a Dependabot run; setup is not required to format one file. - - name: Install toolchains - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - with: - version: 2026.9.0 - - - name: Install regctl - run: | - set -euo pipefail - install -d "${RUNNER_TEMP}/regctl-bin" - curl -fsSLo "${RUNNER_TEMP}/regctl-bin/regctl" \ - https://github.com/regclient/regclient/releases/download/v0.11.5/regctl-linux-amd64 - echo "c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467 ${RUNNER_TEMP}/regctl-bin/regctl" | sha256sum -c - - chmod +x "${RUNNER_TEMP}/regctl-bin/regctl" - echo "${RUNNER_TEMP}/regctl-bin" >> "$GITHUB_PATH" - "${RUNNER_TEMP}/regctl-bin/regctl" version - - - 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-contents: write - - - name: Pin the catalog to the Dockerfile - env: - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_NUMBER: ${{ github.event.pull_request.number }} - APP_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - git fetch --no-tags origin "pull/${PR_NUMBER}/head" - fetched="$(git rev-parse FETCH_HEAD)" - if [ "$fetched" != "$HEAD_SHA" ]; then - echo "::error ::pull/${PR_NUMBER} head is ${fetched}, expected ${HEAD_SHA}." - exit 1 - fi - dockerfile="$(mktemp)" - catalog="$(mktemp)" - original="$(mktemp)" - git show "$HEAD_SHA:apps/cli/src/shared/services/Dockerfile" > "$dockerfile" - git show "$HEAD_SHA:packages/stack/src/Artifacts.ts" > "$catalog" - cp "$catalog" "$original" - bun .github/scripts/sync-artifacts-catalog.ts "$dockerfile" "$catalog" apps/cli/src/shared/services/Dockerfile - if cmp -s "$original" "$catalog"; then - echo "Workload catalog already matches the Dockerfile." - exit 0 - fi - git checkout --detach "$HEAD_SHA" - cp "$catalog" packages/stack/src/Artifacts.ts - bun x oxfmt@0.65.0 --config .oxfmtrc.json packages/stack/src/Artifacts.ts - git add -- packages/stack/src/Artifacts.ts - if git diff --cached --quiet -- packages/stack/src/Artifacts.ts; then - echo "Workload catalog already matches the Dockerfile." - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "chore(stack): pin the workload catalog to the Dockerfile image tags" - git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git push origin "HEAD:refs/heads/${HEAD_REF}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bc1b76d14b..3c934ecae5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,9 +95,10 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + go-cache: "false" - name: Run unit tests - run: pnpm run test:unit --coverage.enabled + run: pnpm run test:unit test-integration: if: | @@ -117,9 +118,10 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + go-cache: "false" - name: Run integration tests - run: pnpm run test:integration --coverage.enabled + run: pnpm run test:integration test-stack-ports: if: | @@ -132,7 +134,9 @@ jobs: strategy: fail-fast: false matrix: - os: [blacksmith-8vcpu-ubuntu-2404, macos-latest, windows-latest] + # Linux coverage for these files comes from the integration job, which + # runs the whole stack integration project. + os: [macos-latest, windows-latest] steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -143,6 +147,7 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + go-cache: "false" - name: Run stack integration tests working-directory: packages/stack @@ -159,6 +164,16 @@ jobs: src/HttpProxy.integration.test.ts --passWithNoTests=false + # Windows-only CLI subset: paths, PATH lookup, and permission semantics diverge there. + - name: Run CLI integration tests + if: runner.os == 'Windows' + working-directory: apps/cli + run: >- + pnpm test:integration + src/commands/db/reset/reset.integration.test.ts + src/commands/experimental/stack/start/start.integration.test.ts + --passWithNoTests=false + test-summary: if: | always() && @@ -189,12 +204,17 @@ jobs: (github.event_name == 'merge_group' || inputs.force || github.event.pull_request.draft == false) - name: Run CLI end-to-end tests (shard ${{ matrix.shard }}/3) + name: Run CLI end-to-end tests (${{ matrix.name }}) runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: fail-fast: false matrix: - shard: [1, 2, 3] + include: + - { suite: cli, shard: 1, shards: 4, name: cli 1/4 } + - { suite: cli, shard: 2, shards: 4, name: cli 2/4 } + - { suite: cli, shard: 3, shards: 4, name: cli 3/4 } + - { suite: cli, shard: 4, shards: 4, name: cli 4/4 } + - { suite: cli-e2e, name: cli-e2e } steps: - name: Checkout uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta @@ -227,22 +247,41 @@ jobs: run: pnpm exec turbo run supabase#build - name: Run end-to-end tests - run: pnpm exec turbo run test:e2e:run --only --concurrency=1 --filter=supabase --filter=@supabase/cli-e2e -- --shard=${{ matrix.shard }}/3 + if: matrix.suite == 'cli' + run: pnpm exec turbo run test:e2e:run --only --filter=supabase -- --shard=${{ matrix.shard }}/${{ matrix.shards }} env: SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go + - name: Run cli-e2e end-to-end tests + if: matrix.suite == 'cli-e2e' + run: pnpm exec turbo run test:e2e:run --only --filter=@supabase/cli-e2e + env: + SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go + + # Vitest writes its results cache under a sha1-named directory. The refresh script + # (apps/cli/scripts/refresh-e2e-timings.ts) merges these into tests/e2e-timings.json. + - name: Upload e2e timings + if: always() && matrix.suite == 'cli' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-timings-${{ matrix.shard }} + path: apps/cli/node_modules/.vite/vitest/**/results.json + retention-days: 7 + if-no-files-found: warn + test-stack-e2e: if: | !startsWith(github.head_ref, 'release-notes/') && (github.event_name == 'merge_group' || inputs.force || github.event.pull_request.draft == false) - name: Run stack end-to-end tests (${{ matrix.runtime }}) + name: Run stack end-to-end tests (${{ matrix.runtime }}, ${{ matrix.suite }}) runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: fail-fast: false matrix: runtime: [native, docker] + suite: [lifecycle, idle-parallel] steps: - name: Checkout uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta @@ -251,15 +290,16 @@ jobs: uses: ./.github/actions/setup with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + go-cache: "false" - name: Run stack end-to-end tests run: >- pnpm --filter @supabase/stack test:e2e:run - src/whole-stack.${{ matrix.runtime }}.e2e.test.ts + src/whole-stack.${{ matrix.runtime }}.${{ matrix.suite }}.e2e.test.ts --passWithNoTests=false - name: Run public stack end-to-end tests - if: matrix.runtime == 'native' + if: matrix.runtime == 'native' && matrix.suite == 'lifecycle' run: >- pnpm --filter @supabase/stack test:e2e:run src/public.e2e.test.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index cdddd6ab52..e364381a7e 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -2,6 +2,7 @@ "ignorePatterns": [ ".repos/", "apps/cli/src/shared/feedback/database.types.ts", + "packages/stack/src/functions/generated/", "apps/cli-go/", "apps/cli-e2e/fixtures/", "apps/docs/content/docs/commands/", diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 0b7056e787..137f3fb227 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -7,56 +7,26 @@ "ignorePatterns": [ "**", "!packages/stack/**", - "!apps/cli/src/commands/backups/**", - "!apps/cli/src/commands/bootstrap/**", - "!apps/cli/src/commands/branches/**", - "!apps/cli/src/commands/completion/**", - "!apps/cli/src/commands/config/**", - "!apps/cli/src/commands/db/**", - "!apps/cli/src/commands/domains/**", - "!apps/cli/src/commands/encryption/**", - "!apps/cli/src/commands/experimental/**", - "!apps/cli/src/commands/feedback/**", - "!apps/cli/src/commands/functions/**", - "!apps/cli/src/commands/gen/**", - "!apps/cli/src/commands/init/**", - "!apps/cli/src/commands/inspect/**", - "!apps/cli/src/commands/issue/**", - "!apps/cli/src/commands/link/**", - "!apps/cli/src/commands/login/**", - "!apps/cli/src/commands/logout/**", - "!apps/cli/src/commands/migration/**", - "!apps/cli/src/commands/network-bans/**", - "!apps/cli/src/commands/network-restrictions/**", - "!apps/cli/src/commands/notebooks/**", - "!apps/cli/src/commands/orgs/**", - "!apps/cli/src/commands/postgres-config/**", - "!apps/cli/src/commands/projects/**", - "!apps/cli/src/commands/pull/**", - "!apps/cli/src/commands/secrets/**", - "!apps/cli/src/commands/seed/**", - "!apps/cli/src/commands/services/**", - "!apps/cli/src/commands/snippets/**", - "!apps/cli/src/commands/ssl-enforcement/**", - "!apps/cli/src/commands/sso/**", - "!apps/cli/src/commands/status/**", - "!apps/cli/src/commands/stop/**", - "!apps/cli/src/commands/storage/**", - "!apps/cli/src/commands/telemetry/**", - "!apps/cli/src/commands/test/**", - "!apps/cli/src/commands/unlink/**", - "!apps/cli/src/commands/vanity-subdomains/**", - "!apps/cli/src/commands/whoami/**", + "!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/runtime/file-watcher.service.ts", - "!apps/cli/src/commands/start/**", + "!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/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/compute/stacks/**", + "apps/cli/src/shared/feedback/database.types.ts", "!apps/cli/tests/helpers/branches-live.ts", "!apps/cli/tests/helpers/compute.ts", "!apps/cli/tests/helpers/migration-live.ts", diff --git a/apps/cli-go/internal/db/diff/templates/migra.ts b/apps/cli-go/internal/db/diff/templates/migra.ts index 11884e9c59..d67124140f 100644 --- a/apps/cli-go/internal/db/diff/templates/migra.ts +++ b/apps/cli-go/internal/db/diff/templates/migra.ts @@ -1,4 +1,4 @@ -import { createClient, sql } from "npm:@pgkit/client"; +import { createClient } from "npm:@pgkit/client"; import { Migration } from "npm:@pgkit/migra"; // Avoids error on self-signed certificate @@ -30,9 +30,38 @@ if (sslDebug) { ); } -const clientBase = createClient(source); +type PoolClient = { + query: (stmt: string) => Promise; + on: (event: "error", listener: () => void) => void; + off: (event: "error", listener: () => void) => void; +}; +// Step down from login role to postgres and force schema qualified references for +// pg_get_expr on every pooled connection, including one reopened after an idle timeout +const verify = (stmt: string) => (client: PoolClient, done: (err?: Error) => void) => { + // pg-pool drops its error listener during verify, so a socket error must reject, not throw + const ignore = () => {}; + client.on("error", ignore); + client + .query(stmt) + .finally(() => client.off("error", ignore)) + .then( + () => done(), + (err: Error) => { + err.message = `${stmt}: ${err.message}`; + done(err); + }, + ); +}; +const clientBase = createClient(source, { + pgpOptions: { connect: { verify: verify("set search_path = ''") } }, +}); const clientHead = createClient(target, { - pgpOptions: { connect: { ssl: ca && { ca } } }, + pgpOptions: { + connect: { + ssl: ca && { ca }, + verify: verify("set role postgres; set search_path = ''"), + }, + }, }); const includedSchemas = Deno.env.get("INCLUDED_SCHEMAS")?.split(",") ?? []; const excludedSchemas = Deno.env.get("EXCLUDED_SCHEMAS")?.split(",") ?? []; @@ -47,11 +76,6 @@ const extensionSchemas = [ ]; try { - // Step down from login role to postgres - await clientHead.query(sql`set role postgres`); - // Force schema qualified references for pg_get_expr - await clientHead.query(sql`set search_path = ''`); - await clientBase.query(sql`set search_path = ''`); const result: string[] = []; for (const schema of includedSchemas) { const m = await Migration.create(clientBase, clientHead, { diff --git a/apps/cli-go/internal/db/start/start.go b/apps/cli-go/internal/db/start/start.go index dc8327c816..d4c97ba1d5 100644 --- a/apps/cli-go/internal/db/start/start.go +++ b/apps/cli-go/internal/db/start/start.go @@ -66,7 +66,7 @@ func NewContainerConfig(args ...string) container.Config { "JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, fmt.Sprintf("JWT_EXP=%d", utils.Config.Auth.JwtExpiry), } - if len(utils.Config.Experimental.OrioleDBVersion) > 0 { + if len(utils.Config.Db.OrioleDBVersion) > 0 { env = append(env, "POSTGRES_INITDB_ARGS=--lc-collate=C --lc-ctype=C", fmt.Sprintf("S3_ENABLED=%t", true), diff --git a/apps/cli-go/internal/utils/config.go b/apps/cli-go/internal/utils/config.go index d171d06753..2ec13b26aa 100644 --- a/apps/cli-go/internal/utils/config.go +++ b/apps/cli-go/internal/utils/config.go @@ -224,7 +224,7 @@ func InitConfig(params InitParams, fsys afero.Fs) error { c := config.NewConfig() c.ProjectId = params.ProjectId if params.UseOrioleDB { - c.Experimental.OrioleDBVersion = "15.1.0.150" + c.Db.OrioleDBVersion = "17.11.0.002" } // The supabase init command opts new projects into pg-delta. Existing configs are // unaffected because mergeDefaultValues ejects with this flag false (default stays diff --git a/apps/cli-go/internal/utils/config_test.go b/apps/cli-go/internal/utils/config_test.go index 6d829304f1..98cd4da9e8 100644 --- a/apps/cli-go/internal/utils/config_test.go +++ b/apps/cli-go/internal/utils/config_test.go @@ -113,7 +113,7 @@ func TestInitConfig(t *testing.T) { assert.NoError(t, err) content, err := afero.ReadFile(fsys, ConfigPath) assert.NoError(t, err) - assert.Contains(t, string(content), "15.1.0.150") + assert.Contains(t, string(content), "17.11.0.002") }) t.Run("fails if config exists and no overwrite", func(t *testing.T) { diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index 01c426edd8..5c2eca797e 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -598,6 +598,7 @@ func (c *config) loadFromFile(filename string, fsys fs.FS) error { return err } v = normalizeDeprecatedSMTPConfig(v, fileConfig) + v = normalizeDeprecatedOrioleDBConfig(v, fileConfig) // Find [remotes.*] block to override base config idToName := map[string]string{} for name, remote := range v.GetStringMap("remotes") { @@ -644,10 +645,14 @@ func normalizeDeprecatedSMTPConfig(v, fileConfig *viper.Viper) *viper.Viper { if !changed { return v } - // Rebuild the viper from the rewritten settings so the now-removed - // `inbucket` key does not trip UnmarshalExact. Preserve the env-binding - // options from the original instance, otherwise SUPABASE_-prefixed env - // overrides bound via ExperimentalBindStruct would be silently dropped. + return rebuildViperFromSettings(settings, v) +} + +// rebuildViperFromSettings rebuilds a viper instance from rewritten settings so a +// now-removed deprecated key does not trip UnmarshalExact. It preserves the +// env-binding options from the original instance, otherwise SUPABASE_-prefixed +// env overrides bound via ExperimentalBindStruct would be silently dropped. +func rebuildViperFromSettings(settings map[string]any, original *viper.Viper) *viper.Viper { u := viper.NewWithOptions( viper.ExperimentalBindStruct(), viper.EnvKeyReplacer(strings.NewReplacer(".", "_")), @@ -655,11 +660,65 @@ func normalizeDeprecatedSMTPConfig(v, fileConfig *viper.Viper) *viper.Viper { u.SetEnvPrefix("SUPABASE") u.AutomaticEnv() if err := u.MergeConfigMap(settings); err != nil { - return v + return original } return u } +func normalizeDeprecatedOrioleDBConfig(v, fileConfig *viper.Viper) *viper.Viper { + settings := v.AllSettings() + changed := promoteDeprecatedOrioleDBVersion(settings, fileConfig, "", "experimental.orioledb_version", "db.orioledb_version") + if remotes, ok := settings["remotes"].(map[string]any); ok { + for name, raw := range remotes { + remote, ok := raw.(map[string]any) + if !ok { + continue + } + legacyKey := fmt.Sprintf("remotes.%s.experimental.orioledb_version", name) + dbKey := fmt.Sprintf("remotes.%s.db.orioledb_version", name) + if promoteDeprecatedOrioleDBVersion(remote, fileConfig, name, legacyKey, dbKey) { + changed = true + } + } + } + if !changed { + return v + } + return rebuildViperFromSettings(settings, v) +} + +// promoteDeprecatedOrioleDBVersion promotes a non-empty legacy `experimental.orioledb_version` +// onto `db.orioledb_version` in settings when the latter is absent or empty in the user's +// file (the template always writes `db.orioledb_version = ""`, so an unset value is +// indistinguishable from an explicit empty string), and warns on stderr whenever the legacy +// value is non-empty. remoteName is empty for the top-level config, or a `[remotes.*]` name +// for the warning message. +func promoteDeprecatedOrioleDBVersion(settings map[string]any, fileConfig *viper.Viper, remoteName, legacyKey, dbKey string) bool { + legacyVal := fileConfig.GetString(legacyKey) + if legacyVal == "" { + return false + } + if fileConfig.GetString(dbKey) == "" { + db, ok := settings["db"].(map[string]any) + if !ok { + db = map[string]any{} + settings["db"] = db + } + db["orioledb_version"] = legacyVal + } + if remoteName == "" { + fmt.Fprintln(os.Stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") + } else { + fmt.Fprintf( + os.Stderr, + "WARN: remotes.%s.experimental.orioledb_version is deprecated. Please use remotes.%s.db.orioledb_version instead.\n", + remoteName, + remoteName, + ) + } + return true +} + // renameDeprecatedSMTP removes the deprecated `inbucket` key from settings. When // promote is true (no explicit `local_smtp` is present), the inbucket values are // deep-merged over any existing `local_smtp` defaults so a partial `[inbucket]` @@ -824,11 +883,11 @@ func (c *config) Load(path string, fsys fs.FS, overrides ...ConfigEditor) error // Update image versions switch c.Db.MajorVersion { case 13: - c.Db.Image = pg15 + c.Db.Image = Images.Pg15 case 14: - c.Db.Image = pg14 + c.Db.Image = Images.Pg14 case 15: - c.Db.Image = pg15 + c.Db.Image = Images.Pg15 } if c.Db.MajorVersion > 14 { if version, err := fs.ReadFile(fsys, builder.PostgresVersionPath); err == nil { @@ -1034,11 +1093,11 @@ func (c *config) Validate(fsys fs.FS) error { return errors.New("Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.") case 13, 14: case 15, 17: - if len(c.Experimental.OrioleDBVersion) > 0 { - if VersionCompare(c.Experimental.OrioleDBVersion, "15.1.1.13") > 0 { - c.Db.Image = fmt.Sprintf("supabase/postgres:%s-orioledb", c.Experimental.OrioleDBVersion) + if len(c.Db.OrioleDBVersion) > 0 { + if VersionCompare(c.Db.OrioleDBVersion, "15.1.1.13") > 0 { + c.Db.Image = fmt.Sprintf("supabase/postgres:%s-orioledb", c.Db.OrioleDBVersion) } else { - c.Db.Image = "supabase/postgres:orioledb-" + c.Experimental.OrioleDBVersion + c.Db.Image = "supabase/postgres:orioledb-" + c.Db.OrioleDBVersion } if err := assertEnvLoaded(c.Experimental.S3Host); err != nil { return err diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index c3c3f871da..0f51bf5153 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -4,6 +4,7 @@ import ( "bytes" _ "embed" "fmt" + "io" "os" "path" "strings" @@ -1158,3 +1159,107 @@ port = 12345 assert.Equal(t, uint16(12345), config.Inbucket.Port) }) } + +func TestDeprecatedOrioleDBVersionConfig(t *testing.T) { + captureStderr := func(t *testing.T, run func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + defer w.Close() + orig := os.Stderr + os.Stderr = w + defer func() { os.Stderr = orig }() + run() + require.NoError(t, w.Close()) + var out bytes.Buffer + _, err = io.Copy(&out, r) + require.NoError(t, err) + return out.String() + } + + t.Run("promotes deprecated [experimental] orioledb_version to [db]", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "15.1.0.150" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "15.1.0.150", config.Db.OrioleDBVersion) + assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") + }) + + t.Run("does not warn when [experimental] orioledb_version is empty", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "", config.Db.OrioleDBVersion) + assert.NotContains(t, stderr, "orioledb_version is deprecated") + }) + + t.Run("promotes deprecated [experimental] orioledb_version when [db] is explicitly empty", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "x" + +[db] +orioledb_version = "" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "x", config.Db.OrioleDBVersion) + assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") + }) + + t.Run("prefers explicit [db] orioledb_version over deprecated [experimental]", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "15.1.0.150" + +[db] +orioledb_version = "15.1.1.13" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "15.1.1.13", config.Db.OrioleDBVersion) + assert.Contains(t, stderr, "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.") + }) + + t.Run("normalizes deprecated [remotes.*.experimental] orioledb_version", func(t *testing.T) { + config := NewConfig() + config.ProjectId = "abcdefghijklmnopqrst" + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[remotes.staging] +project_id = "abcdefghijklmnopqrst" + +[remotes.staging.experimental] +orioledb_version = "15.1.0.150" +`)}, + } + stderr := captureStderr(t, func() { + require.NoError(t, config.Load("", fsys)) + }) + assert.Equal(t, "15.1.0.150", config.Db.OrioleDBVersion) + assert.Contains(t, stderr, "WARN: remotes.staging.experimental.orioledb_version is deprecated. Please use remotes.staging.db.orioledb_version instead.") + }) +} diff --git a/apps/cli-go/pkg/config/constants.go b/apps/cli-go/pkg/config/constants.go index 08d572d2da..93bb482f69 100644 --- a/apps/cli-go/pkg/config/constants.go +++ b/apps/cli-go/pkg/config/constants.go @@ -9,14 +9,15 @@ import ( ) const ( - pg13 = "supabase/postgres:13.3.0" - pg14 = "supabase/postgres:14.1.0.89" - pg15 = "supabase/postgres:15.8.1.085" deno1 = "supabase/edge-runtime:v1.68.4" ) type images struct { Pg string `mapstructure:"pg"` + // PG13/15 and PG14 come from the Dockerfile's generated `pg15`/hand-pinned `pg14` stages + // (the single version table), not hardcoded constants. + Pg15 string `mapstructure:"pg15"` + Pg14 string `mapstructure:"pg14"` // Append to Services when adding new dependencies below Kong string `mapstructure:"kong"` Inbucket string `mapstructure:"mailpit"` diff --git a/apps/cli-go/pkg/config/db.go b/apps/cli-go/pkg/config/db.go index 1f2b3d1ded..821c0a554b 100644 --- a/apps/cli-go/pkg/config/db.go +++ b/apps/cli-go/pkg/config/db.go @@ -85,6 +85,7 @@ type ( ShadowPort uint16 `toml:"shadow_port" json:"shadow_port"` HealthTimeout time.Duration `toml:"health_timeout" json:"health_timeout"` MajorVersion uint `toml:"major_version" json:"major_version"` + OrioleDBVersion string `toml:"orioledb_version" json:"orioledb_version"` Password string `toml:"-" json:"-"` RootKey Secret `toml:"root_key" json:"root_key"` Pooler pooler `toml:"pooler" json:"pooler"` diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index fa87292ca5..aee8c87f83 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,21 +1,29 @@ -# Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.171 AS pg +# The tag pinned below is generated from packages/stack/src/Artifacts.ts by +# apps/cli/scripts/render-service-dockerfile.ts, for these aliases only: pg, pg15, mailpit, +# 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 +# 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.30.2 AS mailpit -FROM postgrest/postgrest:v16.3 AS postgrest +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.14-sha-4dd8a95 AS studio -FROM darthsim/imgproxy:v3.8.0 AS imgproxy -FROM supabase/edge-runtime:v1.76.2 AS edgeruntime -FROM timberio/vector:0.53.0-alpine AS vector +FROM supabase/studio:2026.09.28-sha-5e59b60 AS studio +FROM darthsim/imgproxy:v3.26.0 AS imgproxy +FROM supabase/edge-runtime:v1.77.1 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.135.3 AS realtime -FROM supabase/storage-api:v1.77.0 AS storage -FROM supabase/logflare:1.50.12 AS logflare +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 # 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 diff --git a/apps/cli-go/pkg/config/templates/config.toml b/apps/cli-go/pkg/config/templates/config.toml index 07bacc0ade..c26043d5c9 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -40,6 +40,8 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. major_version = 17 +# OrioleDB version to use as the Postgres storage engine. Leave empty to use the default engine. +orioledb_version = "{{ .Db.OrioleDBVersion }}" [db.pooler] enabled = false @@ -393,8 +395,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "{{ .Experimental.OrioleDBVersion }}" # Configures S3 bucket URL, eg. .s3-.amazonaws.com s3_host = "env(S3_HOST)" # Configures S3 bucket region, eg. us-east-1 diff --git a/apps/cli-go/pkg/config/testdata/config.toml b/apps/cli-go/pkg/config/testdata/config.toml index 1c60b8af17..4c6ffc0c80 100644 --- a/apps/cli-go/pkg/config/testdata/config.toml +++ b/apps/cli-go/pkg/config/testdata/config.toml @@ -34,6 +34,7 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. major_version = 17 +orioledb_version = "15.1.0.150" [db.migrations] # If disabled, migrations will be skipped during a db push or reset. @@ -347,8 +348,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "15.1.0.150" # Configures S3 bucket URL, eg. .s3-.amazonaws.com s3_host = "orioledb.s3-accelerate.amazonaws.com" # Configures S3 bucket region, eg. us-east-1 diff --git a/apps/cli/docs/release-process.md b/apps/cli/docs/release-process.md index 9e2433ca6c..3ed85be5f8 100644 --- a/apps/cli/docs/release-process.md +++ b/apps/cli/docs/release-process.md @@ -232,7 +232,7 @@ flowchart TD shared --> build["build
sync-versions, build.ts, nfpm
upload-artifact"] build --> smoke["smoke-test matrix
ubuntu-latest, macos-latest,
macos-15-intel, windows-latest"] smoke --> pub["publish
id-token: write (OIDC trusted publishing)
bun publish --provenance × 8 platform pkgs
then bun publish --provenance umbrella supabase"] - pub --> rel["softprops/action-gh-release
(draft) → gh release edit --draft=false"] + pub --> rel["softprops/action-gh-release (empty draft)
→ upload-release-assets.ts upload (gh release upload per asset)
→ upload-release-assets.ts verify → gh release edit --draft=false"] rel --> hb["publish-homebrew
App-token-authed clone of homebrew-tap
update-homebrew.ts --name "] rel --> sc["publish-scoop
App-token-authed clone of scoop-bucket
update-scoop.ts --name "] rel --> sucs["setup-cli-smoke
install via supabase/setup-cli
(GitHub Release download)"] @@ -300,8 +300,12 @@ The matrix does not yet include `windows-11-arm` (gate 6) or an Alpine musl runn 1. Re-runs `sync-versions.ts` (download-artifact restores file modes but not JSON mutations). 2. `[pnpm exec bun apps/cli/scripts/publish.ts --tag ](../scripts/publish.ts)` — publishes the eight platform packages in parallel via OIDC trusted publishing (`bun publish --provenance`, no `NPM_TOKEN`), then the umbrella package last so `optionalDependencies` resolve cleanly at install time. -3. `softprops/action-gh-release` creates a **draft** Release `v` on `supabase/cli` with all tar / zip / deb / rpm / apk + `checksums.txt`. -4. `gh release edit v --draft=false` finalises it (immutable from this point). +3. `softprops/action-gh-release` creates an **empty draft** Release `v` on `supabase/cli`. +4. `[upload-release-assets.ts upload](../scripts/upload-release-assets.ts)` uploads the archives, packages, `checksums.txt`, unversioned aliases, and `install` script **one asset at a time** with `gh release upload --clobber`, up to three attempts each with a five-minute timeout. `gh` itself retries 5xx responses and dropped connections three times within a second; the outer loop covers longer stalls and the `--clobber` delete, which `gh` does not retry. `uploads.github.com` fails single uploads often enough that this is routine: in September 2026 one beta release hit a multi-hour backend degradation and another lost one connection on a healthy backend. The asset list, retry policy, and timeout handling are covered by the unit and integration tests next to the script. +5. `upload-release-assets.ts verify` compares `gh release view --json assets` with the expected asset names. The job fails if any asset is missing or not `uploaded`, so a partial set is never published. +6. `gh release edit v --draft=false` finalises it (immutable from this point). + +A failed `publish` job can be re-run while the run's build-artifact cache exists (about a week); npm publish, the tag push, and the channel-note push skip work already done. Once the cache is evicted, the options are a `workflow_dispatch` on the tag, which rebuilds bytes that differ from npm and re-pushes the Homebrew/Scoop manifests, or deleting the draft. ### Post-publish: Homebrew + Scoop diff --git a/apps/cli/docs/stack-commands.md b/apps/cli/docs/stack-commands.md index 3ddf1f803c..dd8cbdd896 100644 --- a/apps/cli/docs/stack-commands.md +++ b/apps/cli/docs/stack-commands.md @@ -2,9 +2,13 @@ `supabase stack` manages local stacks with the new experimental runtime. It is unstable, its command interface may change, and it is excluded from the CLI compatibility promise. It is -available when the `experimental.stack` feature flag is enabled and supports both Docker and +available when the `experimental.stack` feature flag is enabled and supports Docker, Podman, and native runtimes. +Native PostgreSQL requires passwords for every role except `supabase_admin`. Its local bootstrap +and password-reconciliation connection uses that administrative role, so a native +`supabase_admin` connection is not password-checked. + | Command | Purpose | | ------------------------ | --------------------------------------------------------------------------------- | | `supabase stack destroy` | Permanently delete one stack and its data. | @@ -18,6 +22,16 @@ native runtimes. Use each command's `--help` for its available targeting and runtime options. +A new stack created with `--runtime auto` uses Docker when its daemon answers, otherwise Podman +when its engine answers, and otherwise native on Linux x64/arm64 and macOS arm64. On other +platforms without a reachable engine, the command fails and asks you to start Docker or Podman. The +selected runtime is saved with the stack and reused without probing; when auto selection skips +Docker, the command prints a notice saying so. To switch, destroy the stack or choose a different +`--stack` name. When an explicit `--runtime docker` or a saved Docker stack cannot reach Docker, +the failure asks you to install or start it, and also suggests `--runtime native` for a new stack +on platforms that support native. Project stacks created by database commands, and shadow stacks +created without a project stack, use the same selection. + `supabase stack prepare` downloads or pulls artifacts for the selected stack without starting services. If the target does not exist, prepare creates and registers it; the stack then appears in `supabase stack list` and can be removed with `supabase stack destroy`. Omit `--capability` to @@ -29,6 +43,51 @@ supabase stack prepare supabase stack prepare --capability rest --capability auth --output-format json ``` +## Connection details after start + +When the stack is ready, `supabase stack start` prints the API, REST, Functions, +database, Studio, MCP, and Mailpit URLs, the local publishable and secret keys, each service's +state, and the runtime. Ports are assigned per project, so read the MCP URL from this output rather +than assuming a default port. MCP is served at `/mcp`, present only when the shared API +listener is up and Studio is a composition member with an HTTP endpoint. +With `--output-format json`, start returns: + +```json +{ + "id": "", + "runtime": "docker", + "endpoints": { + "database.sql": { + "protocol": "tcp", + "address": "127.0.0.1", + "port": 54322, + "url": "tcp://127.0.0.1:54322" + } + }, + "lazy_services": ["rest", "auth", "studio"], + "env": { + "API_URL": "http://127.0.0.1:54321", + "DB_URL": "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + "STUDIO_URL": "http://127.0.0.1:54323", + "MCP_URL": "http://127.0.0.1:54321/mcp", + "MAILPIT_URL": "http://127.0.0.1:54324", + "PUBLISHABLE_KEY": "sb_publishable_...", + "SECRET_KEY": "sb_secret_...", + "ANON_KEY": "ey...", + "SERVICE_ROLE_KEY": "ey..." + }, + "message": "" +} +``` + +`endpoints` uses the same `service.endpoint` keys as `stack status`, with no synthetic entries. +`lazy_services` lists the services that start on their first request. `env` is the same connection +map `supabase stack status --env` exports, present on every success path; `stack status` (without +`--env`) returns the same `env` key, degrading to whatever is available when credentials or the +owner are unreachable. Plain `status` JSON `env` comes from saved bindings and can list values for +stopped or sleeping members, while `--env` requires a reachable owner and a running primary +database. + ## Exporting environment variables ```sh @@ -42,19 +101,29 @@ and credentials available from the observed composition; text mode emits dotenv JSON or stream-JSON mode emits a variable map. Values that are unavailable because a member is stopped or unhealthy are omitted. Add `--output-format text` for an explicit dotenv file regardless of automatic agent output detection; this is dotenv data, not a shell script, and values -are quoted so that sourcing the file performs no shell expansion. Only this -explicit export reveals credentials. Ordinary status remains free of secrets. `--override-name` -accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` entries, requires `--env`, and rejects -unknown variables, invalid names, and collisions. The DB-derived service-role JWT remains available -when Auth is disabled; unavailable service URLs and credentials are omitted. +are quoted so that sourcing the file performs no shell expansion. The exported variable set is +`API_URL`, `REST_URL`, `FUNCTIONS_URL`, `DB_URL`, `STUDIO_URL`, `MCP_URL`, `MAILPIT_URL`, +`PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY`, and `SERVICE_ROLE_KEY`; `REST_URL` and +`FUNCTIONS_URL` are `/rest/v1` and `/functions/v1`, `MCP_URL` is `/mcp`, +whose port is assigned per project, and `DB_URL` uses the `postgres` role with the saved database +password, URI-encoded, and no query string. `INBUCKET_URL` is also exported alongside +`MAILPIT_URL`, with the same value, as a deprecated alias. `start` and `status` text output shows +only the publishable and secret keys; `ANON_KEY` and `SERVICE_ROLE_KEY` appear only in JSON `env` +and `status --env`. `start` and `status` JSON/stream-JSON results include this same connection map +under `env`; `status --env` remains the dotenv/variable-map export, and `--override-name` only +applies there. `--override-name` accepts repeated or comma-separated `EXPORTED_VARIABLE=NAME` +entries, requires `--env`, and rejects unknown variables, invalid names, and collisions. The +DB-derived service-role JWT remains available when Auth is disabled; unavailable service URLs and +credentials are omitted. The stack backend rejects every explicit legacy `-o/--output` value: `env`, `pretty`, `json`, `toml`, `yaml`, `table`, and `csv`. `--output-format text`, `json`, or `stream-json` replace them. `-o env` becomes `--env`. `supabase stack list` reads the global managed-stack registry and reports each readable stack's -project, branch, runtime, and owner availability. A corrupt or unsupported registry entry fails the -whole discovery operation with a diagnostic; readable entries are not emitted as a partial list. +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. @@ -198,7 +267,8 @@ Host listener assignment for `supabase stack` is documented in [Port intents](./ ## Service selection and shutdown With the current defaults, enabled non-database services with endpoints are lazy and stop after -60 seconds without traffic; Functions has no automatic idle stop. An active HTTP request keeps a +60 seconds without traffic, Studio after 5 minutes; Functions has no automatic idle stop. A service +that a running service depends on stays up until that dependent stops. An active HTTP request keeps a capability running; an idle HTTP keep-alive socket does not. Open WebSocket or TCP connections keep a capability running during idle periods. Use `supabase stack start --eager` to activate all enabled capabilities and disable automatic idle stops. A request arriving while a capability is @@ -225,8 +295,9 @@ Excluding `rest` while Studio remains selected is rejected; excluding `analytics Studio. The effective configuration is retained in stack state, so starting without `--exclude` restores the project's configured services. -`supabase stack stop --all` stops every managed stack while preserving data. Discovery fails closed -when any registry entry is unreadable, so no partial stop operation is attempted. Individual stop +`supabase stack stop --all` stops every managed stack while preserving data. Registry entries that +cannot be read or decoded are skipped with a warning on stderr; only a failure to read the stacks +directory itself fails discovery, before any stop is attempted. Individual stop failures make the command fail and identify the affected stack IDs with their error details; no success or unavailable summary is emitted when a stop fails. diff --git a/apps/cli/docs/supabase-home.md b/apps/cli/docs/supabase-home.md index 4f04baf5f3..b40e2c7217 100644 --- a/apps/cli/docs/supabase-home.md +++ b/apps/cli/docs/supabase-home.md @@ -89,9 +89,11 @@ no separate command that rewrites it, and no second project-level pinned-version Raw `supabase/config.toml` values and their origins are loaded before defaults are applied. Explicit sticky values are persisted as `exact` intents in each managed document. Omitted values remain -`automatic`; sibling worktrees have independent stack identities and allocations, while live exact -port conflicts are rejected by the manager. Runtime-only service ports are selected by the managed -supervisor and are not written to the document. +`automatic`; sibling worktrees and branches have independent stack identities and allocations. +Automatic allocation avoids ports saved by any stack, so stopped stacks keep their URLs. An exact +port is rejected only when a listener already answers on it or the port cannot be bound; another +stack's saved claim alone never blocks it, and a conflict names the stack that saved the port. Runtime-only service ports are +selected by the managed supervisor and are not written to the document. ## Command behavior diff --git a/apps/cli/docs/supabase/config/pull.md b/apps/cli/docs/supabase/config/pull.md index eb35848d67..3e238c1d59 100644 --- a/apps/cli/docs/supabase/config/pull.md +++ b/apps/cli/docs/supabase/config/pull.md @@ -12,7 +12,7 @@ Several kinds of values are never written, and are reported instead of silently `config pull` never writes a `config.toml`/`.json` it cannot load back on the next command. Pulling a value that GATES other declared fields — e.g. flipping a disabled provider's `enabled` on — also pulls its now-required sibling values into the same write (not just the gate), so the file never comes out half-configured. When one of those siblings genuinely can't be supplied — the remote doesn't report it, or supplying it would still leave a REQUIRED value missing — the whole toggle is skipped instead, reported with a new reason (`requires values pull cannot write` in text output, `would_invalidate` in the machine payload) plus a note naming the missing field(s) and, when a missing field's local spelling is itself an unresolved `env(VAR)` reference, the exact environment variable to set before rerunning. -`--dry-run` computes and prints exactly what `config diff` would show, then stops — no git check, no prompt, no write. `--output-format json|stream-json` never prompts at all — it returns the confirmation's default value without reading anything, so use `--dry-run` for a preview in those shapes rather than relying on the prompt. A non-interactive run in the default text format (no TTY on stdin) still prints the confirmation to stderr and reads a single line from piped stdin: an explicit `y`/`n` answer is honored, and an empty or unparseable line falls back to the default. `--yes` (or `SUPABASE_YES`) answers the confirmation prompt without asking on an interactive TTY; it does not bypass the uncommitted-changes guard — on the contrary, `--yes` together with uncommitted (or untracked) changes aborts instead of silently overwriting them, since no human is left to read the warning. `--force` writes even when `supabase/config.toml` has uncommitted or untracked changes in git — without it, an interactive run's prompt defaults to "no" instead of "yes", and every other case (non-interactive text, machine-format, or `--yes` on any TTY) aborts outright. +`--dry-run` computes and prints exactly what `config diff` would show, then stops — no git check, no prompt, no write. `--output-format json|stream-json` never prompts at all — it returns the confirmation's default value without reading anything, so use `--dry-run` for a preview in those shapes rather than relying on the prompt. A non-interactive run in the default text format (no TTY on stdin) still prints the confirmation to stderr and reads a single line from piped stdin: an explicit `y`/`yes`/`n`/`no` answer is honored, an empty line falls back to the default, and any other answer declines. `--yes` (or `SUPABASE_YES`) answers the confirmation prompt without asking on an interactive TTY; it does not bypass the uncommitted-changes guard — on the contrary, `--yes` together with uncommitted (or untracked) changes aborts instead of silently overwriting them, since no human is left to read the warning. `--force` writes even when `supabase/config.toml` has uncommitted or untracked changes in git — without it, an interactive run's prompt defaults to "no" instead of "yes", and every other case (non-interactive text, machine-format, or `--yes` on any TTY) aborts outright. A second `config pull` against an already-tracked, unchanged remote always writes nothing — that convergence is the design's own acceptance property, not a special case. It never even checks for uncommitted local changes to the config file in that case, since there is nothing to write either way. A target that isn't tracked yet (a branch or `--remote-label` name with no existing `[remotes.*]` block) still creates that block on its first pull, even when every value it carries already matches the local defaults — it's the second pull against that now-tracked block that then writes nothing. diff --git a/apps/cli/docs/supabase/init.md b/apps/cli/docs/supabase/init.md index 1290561dba..a854093c12 100644 --- a/apps/cli/docs/supabase/init.md +++ b/apps/cli/docs/supabase/init.md @@ -7,3 +7,5 @@ A `supabase/config.toml` file is created in your current working directory. This > You may override the directory path by specifying the `SUPABASE_WORKDIR` environment variable or `--workdir` flag. In addition to `config.toml`, the `supabase` directory may also contain other Supabase objects, such as `migrations`, `functions`, `tests`, etc. + +To use OrioleDB as the Postgres storage engine, run `supabase init --use-orioledb`. The command writes the OrioleDB version to `db.orioledb_version` in `supabase/config.toml`; `--experimental` is not required. diff --git a/apps/cli/docs/templates/examples.yaml b/apps/cli/docs/templates/examples.yaml index bc451edb14..8d0e70222d 100644 --- a/apps/cli/docs/templates/examples.yaml +++ b/apps/cli/docs/templates/examples.yaml @@ -7,6 +7,10 @@ supabase-init: name: Initialize from an existing directory code: supabase init --workdir . response: Finished supabase init. + - id: with-orioledb + name: Initialize with OrioleDB + code: supabase init --use-orioledb + response: Finished supabase init. supabase-login: - id: basic-usage name: Basic usage diff --git a/apps/cli/package.json b/apps/cli/package.json index 0a7c8b2fed..c0251fc102 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.52", - "@supabase/pg-topo": "1.0.0-alpha.6", - "@supabase/postgrest-typegen": "0.2.2", + "@supabase/pg-delta": "1.0.0-alpha.56", + "@supabase/pg-topo": "1.0.0-alpha.7", "@supabase/stack": "workspace:*", "@supabase/supabase-js": "catalog:", + "@supabase/typegen": "0.2.1", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/pg": "^8.23.1", diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 0145a1dc20..f2dcb02b00 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,8 +1,5 @@ import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; -import { bundleStackFunctionsServeMainTemplate } from "../src/command-internal/stack-functions-bundler.ts"; -import { Effect } from "effect"; -import { oxfmtStubPlugin } from "./bundle-externals.ts"; -import { compileOptions } from "./compile-options.ts"; +import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; /** * Compiles the CLI to a standalone binary, run via `pnpm build:binary`. Embeds the pre-bundled @@ -23,13 +20,10 @@ const result = await Bun.build({ entrypoints: [entrypoint], compile: { outfile }, ...compileOptions, - plugins: [oxfmtStubPlugin], define: { SUPABASE_CLI_VERSION: JSON.stringify(packageJson.version), + ...(await stackReleaseDefine()), SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), - SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( - await Effect.runPromise(bundleStackFunctionsServeMainTemplate()), - ), // 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 9874b44824..87131b408e 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -4,11 +4,8 @@ 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 { bundleStackFunctionsServeMainTemplate } from "../src/command-internal/stack-functions-bundler.ts"; -import { oxfmtStubPlugin } from "./bundle-externals.ts"; -import { compileOptions } from "./compile-options.ts"; +import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; const MUSL_TARGETS = [ @@ -91,12 +88,10 @@ const entrypoint = path.join(root, "apps/cli/src/main.ts"); 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_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( - await Effect.runPromise(bundleStackFunctionsServeMainTemplate()), - ), - "process.env.SUPABASE_CLI_POSTHOG_KEY": JSON.stringify(process.env.POSTHOG_API_KEY ?? ""), - "process.env.SUPABASE_CLI_POSTHOG_HOST": JSON.stringify(process.env.POSTHOG_ENDPOINT ?? ""), + 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 // on macOS goes through the automounter and can hang every command (supabase/cli#6771). "process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": JSON.stringify("true"), @@ -119,7 +114,7 @@ async function runBunBuild(config: Bun.BuildConfig) { const result = await Bun.build({ ...config, ...compileOptions, - plugins: [...(config.plugins ?? []), oxfmtStubPlugin], + plugins: config.plugins ?? [], }); for (const log of result.logs) { console.warn(log); diff --git a/apps/cli/scripts/bundle-externals.ts b/apps/cli/scripts/bundle-externals.ts deleted file mode 100644 index 77f78b623e..0000000000 --- a/apps/cli/scripts/bundle-externals.ts +++ /dev/null @@ -1,15 +0,0 @@ -import path from "node:path"; -import type { BunPlugin } from "bun"; - -const oxfmtStub = path.join(import.meta.dir, "oxfmt-stub.ts"); - -/** - * `Bun.build` `alias` does not rewrite imports inside dependencies. - * `@supabase/postgrest-typegen` imports `oxfmt`, which the CLI never calls. - */ -export const oxfmtStubPlugin: BunPlugin = { - name: "oxfmt-stub", - setup(build) { - build.onResolve({ filter: /^oxfmt$/ }, () => ({ path: oxfmtStub })); - }, -}; diff --git a/apps/cli/scripts/compile-options.ts b/apps/cli/scripts/compile-options.ts index 19392766ac..b18cf9a2e2 100644 --- a/apps/cli/scripts/compile-options.ts +++ b/apps/cli/scripts/compile-options.ts @@ -1,10 +1,27 @@ +import { BunServices } from "@effect/platform-bun"; +import { stackSourceDigest } from "@supabase/stack/internal/release"; +import { Effect } from "effect"; + /** * Shared compile options keep release, local, and E2E builds exercising the shipped binary - * compilation behavior. `bytecodeDepth` is accepted by Bun 1.4.1 but is not yet in bun-types. + * compilation behavior. */ export const compileOptions = { minify: true, bytecode: true, bytecodeDepth: 2, - format: "esm" as const, -}; + format: "esm", + // `oxfmt` is an optional peer of `@supabase/postgrest-typegen`, loaded only by the default + // TypeScript formatter; `gen types` supplies its own, so the binary ships without it. + external: ["oxfmt"], +} as const satisfies Partial; + +/** + * Embeds the stack release of the sources being compiled, so the binary drives exactly the owners + * started from the same stack sources, whether compiled or run from source. + */ +export const stackReleaseDefine = async () => ({ + SUPABASE_STACK_BUILD_ID: JSON.stringify( + await Effect.runPromise(stackSourceDigest.pipe(Effect.provide(BunServices.layer))), + ), +}); diff --git a/apps/cli/scripts/oxfmt-stub.ts b/apps/cli/scripts/oxfmt-stub.ts deleted file mode 100644 index 552f1fe19e..0000000000 --- a/apps/cli/scripts/oxfmt-stub.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Stand-in for the `oxfmt` package `@supabase/postgrest-typegen` imports. - * `gen types` passes its own formatter and never calls this. - */ -export function format(): never { - throw new Error("oxfmt is not bundled in the Supabase CLI"); -} diff --git a/apps/cli/scripts/refresh-e2e-timings.ts b/apps/cli/scripts/refresh-e2e-timings.ts new file mode 100644 index 0000000000..dd5396410b --- /dev/null +++ b/apps/cli/scripts/refresh-e2e-timings.ts @@ -0,0 +1,169 @@ +// Rewrites apps/cli/tests/e2e-timings.json from the Vitest results caches that the test-e2e +// workflow job uploads as `e2e-timings-` artifacts. +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { version as installedVitestVersion } from "vitest/node"; + +export interface VitestFileResult { + readonly duration: number; + readonly failed: boolean; +} + +/** Shape of Vitest's `results.json` cache: `[:, result]` entries. */ +export interface VitestResultsCache { + readonly version: string; + readonly results: ReadonlyArray; +} + +export interface MergeOptions { + /** Version of the Vitest that will consume the timings; caches from another major are rejected. */ + readonly vitestVersion: string; + readonly warn?: (message: string) => void; +} + +const PROJECT_PREFIX = "e2e:"; + +function major(version: string): string { + return version.split(".")[0] ?? version; +} + +/** + * Merges e2e project entries from several results caches into `{ path: ms }`, sorted by path. + * Failed entries are skipped since their duration reflects the failure, not the file. + */ +export function mergeTimings( + caches: ReadonlyArray, + { vitestVersion, warn = () => {} }: MergeOptions, +): Record { + const merged = new Map(); + for (const cache of caches) { + if (major(cache.version) !== major(vitestVersion)) { + throw new Error( + `results cache was written by Vitest ${cache.version} but Vitest ${vitestVersion} is installed`, + ); + } + for (const [key, result] of cache.results) { + if (!key.startsWith(PROJECT_PREFIX)) { + continue; + } + const path = key.slice(PROJECT_PREFIX.length); + if (result.failed) { + warn(`skipping failed ${path}`); + continue; + } + const duration = Math.round(result.duration); + merged.set(path, Math.max(merged.get(path) ?? 0, duration)); + } + } + return Object.fromEntries([...merged].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +} + +function isFileResult(value: unknown): value is VitestFileResult { + return ( + typeof value === "object" && + value !== null && + "duration" in value && + typeof value.duration === "number" && + "failed" in value && + typeof value.failed === "boolean" + ); +} + +function isResultsEntry(value: unknown): value is readonly [string, VitestFileResult] { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "string" && + isFileResult(value[1]) + ); +} + +function isResultsCache(value: unknown): value is VitestResultsCache { + return ( + typeof value === "object" && + value !== null && + "version" in value && + typeof value.version === "string" && + "results" in value && + Array.isArray(value.results) && + value.results.every(isResultsEntry) + ); +} + +function readResultsCaches(dir: string): VitestResultsCache[] { + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name === "results.json") + .map((entry) => { + const file = join(entry.parentPath, entry.name); + const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); + if (!isResultsCache(parsed)) { + throw new Error(`${file} is not a Vitest results cache`); + } + return parsed; + }); +} + +const usage = `Usage: pnpm exec bun apps/cli/scripts/refresh-e2e-timings.ts --run [--repo supabase/cli] + + Downloads the e2e-timings-* artifacts of a test workflow run and rewrites + apps/cli/tests/e2e-timings.json from them.`; + +if (import.meta.main) { + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { run: { type: "string" }, repo: { type: "string", default: "supabase/cli" } }, + }); + if (!values.run) { + console.error(usage); + process.exit(2); + } + + const downloadDir = mkdtempSync(join(tmpdir(), "e2e-timings-")); + try { + const download = spawnSync( + "gh", + [ + "run", + "download", + values.run, + "--repo", + values.repo, + "--pattern", + "e2e-timings-*", + "--dir", + downloadDir, + ], + { stdio: "inherit" }, + ); + if (download.error !== undefined) { + throw new Error(`could not run gh: ${download.error.message}`); + } + if (download.status !== 0) { + throw new Error( + `gh run download exited with ${download.status ?? `signal ${download.signal}`}`, + ); + } + + const caches = readResultsCaches(downloadDir); + if (caches.length === 0) { + throw new Error(`no results.json found in the e2e-timings-* artifacts of run ${values.run}`); + } + const timings = mergeTimings(caches, { + vitestVersion: installedVitestVersion, + warn: (message) => console.error(message), + }); + const target = fileURLToPath(new URL("../tests/e2e-timings.json", import.meta.url)); + writeFileSync(target, `${JSON.stringify({ version: 1, files: timings }, null, 2)}\n`); + console.error(`wrote ${Object.keys(timings).length} timings to ${target}`); + } catch (cause) { + console.error(`error: ${cause instanceof Error ? cause.message : String(cause)}`); + process.exitCode = 1; + } finally { + rmSync(downloadDir, { recursive: true, force: true }); + } +} diff --git a/apps/cli/scripts/refresh-e2e-timings.unit.test.ts b/apps/cli/scripts/refresh-e2e-timings.unit.test.ts new file mode 100644 index 0000000000..8571e883f7 --- /dev/null +++ b/apps/cli/scripts/refresh-e2e-timings.unit.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vitest"; +import { mergeTimings, type VitestResultsCache } from "./refresh-e2e-timings.ts"; + +const vitestVersion = "5.0.0"; + +function cache(...results: VitestResultsCache["results"]): VitestResultsCache { + return { version: vitestVersion, results }; +} + +describe("mergeTimings", () => { + test("keeps only e2e project entries and strips the project prefix", () => { + const timings = mergeTimings( + [ + cache( + ["e2e:src/start.e2e.test.ts", { duration: 1200, failed: false }], + ["unit:src/start.unit.test.ts", { duration: 5, failed: false }], + [":src/start.integration.test.ts", { duration: 40, failed: false }], + ), + ], + { vitestVersion }, + ); + + expect(timings).toEqual({ "src/start.e2e.test.ts": 1200 }); + }); + + test("skips failed entries and reports each one", () => { + const warnings: string[] = []; + + const timings = mergeTimings( + [ + cache( + ["e2e:src/ok.e2e.test.ts", { duration: 100, failed: false }], + ["e2e:src/broken.e2e.test.ts", { duration: 5, failed: true }], + ), + ], + { vitestVersion, warn: (message) => warnings.push(message) }, + ); + + expect(timings).toEqual({ "src/ok.e2e.test.ts": 100 }); + expect(warnings).toEqual(["skipping failed src/broken.e2e.test.ts"]); + }); + + test("takes the longest duration when a file appears in several caches", () => { + const timings = mergeTimings( + [ + cache(["e2e:src/shared.e2e.test.ts", { duration: 300, failed: false }]), + cache(["e2e:src/shared.e2e.test.ts", { duration: 450, failed: false }]), + cache(["e2e:src/shared.e2e.test.ts", { duration: 120, failed: false }]), + ], + { vitestVersion }, + ); + + expect(timings).toEqual({ "src/shared.e2e.test.ts": 450 }); + }); + + test("rounds durations to whole milliseconds", () => { + const timings = mergeTimings( + [cache(["e2e:src/fast.e2e.test.ts", { duration: 99.6, failed: false }])], + { vitestVersion }, + ); + + expect(timings).toEqual({ "src/fast.e2e.test.ts": 100 }); + }); + + test("orders the output by path so the committed file diffs cleanly", () => { + const timings = mergeTimings( + [ + cache( + ["e2e:src/zeta.e2e.test.ts", { duration: 1, failed: false }], + ["e2e:scripts/alpha.e2e.test.ts", { duration: 2, failed: false }], + ["e2e:src/beta.e2e.test.ts", { duration: 3, failed: false }], + ), + ], + { vitestVersion }, + ); + + expect(Object.keys(timings)).toEqual([ + "scripts/alpha.e2e.test.ts", + "src/beta.e2e.test.ts", + "src/zeta.e2e.test.ts", + ]); + }); + + test("rejects a cache written by a different Vitest major", () => { + const stale: VitestResultsCache = { + version: "4.2.1", + results: [["e2e:src/a.e2e.test.ts", { duration: 1, failed: false }]], + }; + + expect(() => mergeTimings([stale], { vitestVersion })).toThrow( + "results cache was written by Vitest 4.2.1 but Vitest 5.0.0 is installed", + ); + }); + + test("accepts a cache from a different minor of the same major", () => { + const newer: VitestResultsCache = { + version: "5.3.0", + results: [["e2e:src/a.e2e.test.ts", { duration: 1, failed: false }]], + }; + + expect(mergeTimings([newer], { vitestVersion })).toEqual({ "src/a.e2e.test.ts": 1 }); + }); +}); diff --git a/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts new file mode 100644 index 0000000000..73080b1628 --- /dev/null +++ b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts @@ -0,0 +1,115 @@ +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. +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 }; + const pin = (sourceService: string, upstreamImage: string, isDefault: boolean) => ({ + service: sourceService, + sourceService, + isDefault, + pin: { + upstreamVersion: upstreamImage.split(":").at(-1), + revision: 0, + image: `ghcr.io/supabase/cli/${sourceService}:fixture-r0@sha256:${"c".repeat(64)}`, + upstreamImage, + natives, + }, + }); + return { + catalogPins: () => [ + pin("postgres", "supabase/postgres:17.0.0", true), + pin("postgres", "supabase/postgres:15.0.0", false), + pin("mailpit", "axllent/mailpit:v1.0.0", true), + pin("postgrest", "postgrest/postgrest:v1.0.0", true), + pin("pgmeta", "supabase/postgres-meta:v1.0.0", true), + pin("studio", "supabase/studio:1.0.0", true), + pin("imgproxy", "ghcr.io/imgproxy/imgproxy:v1.0.0", true), + pin("edge-runtime", "supabase/edge-runtime:v1.0.0", true), + pin("vector", "timberio/vector:1.0.0-alpine", true), + pin("pooler", "supabase/supavisor:1.0.0", true), + pin("auth", "supabase/gotrue:v1.0.0", true), + pin("realtime", "supabase/realtime:v1.0.0", true), + pin("storage", "supabase/storage-api:v1.0.0", true), + pin("analytics", "supabase/logflare:1.0.0", true), + ], + }; +}); + +/** A minimal Dockerfile with every slim-capable alias, plus kong and pg14 (hand-managed). */ +function baseDockerfile(overrides: Readonly> = {}): string { + const lines: Readonly> = { + pg: "FROM supabase/postgres:16.9.9 AS pg", + pg15: "FROM supabase/postgres:14.9.9 AS pg15", + mailpit: "FROM axllent/mailpit:v0.9.9 AS mailpit", + postgrest: "FROM postgrest/postgrest:v0.9.9 AS postgrest", + pgmeta: "FROM supabase/postgres-meta:v0.9.9 AS pgmeta", + studio: "FROM supabase/studio:0.9.9 AS studio", + imgproxy: "FROM darthsim/imgproxy:v0.9.9 AS imgproxy", + edgeruntime: "FROM supabase/edge-runtime:v0.9.9 AS edgeruntime", + vector: "FROM timberio/vector:0.9.9-alpine AS vector", + supavisor: "FROM supabase/supavisor:0.9.9 AS supavisor", + gotrue: "FROM supabase/gotrue:v0.9.9 AS gotrue", + realtime: "FROM supabase/realtime:v0.9.9 AS realtime", + storage: "FROM supabase/storage-api:v0.9.9 AS storage", + logflare: "FROM supabase/logflare:0.9.9 AS logflare", + kong: "FROM library/kong:2.8.1 AS kong", + }; + const merged = { ...lines, ...overrides }; + return `# hand-authored header\n${Object.values(merged).join("\n")}\n# hand-authored footer\n`; +} + +describe("renderDockerfile: pin selection", () => { + test("pg takes the default pin and pg15 takes the additional one, by isDefault, not a version-string check", () => { + const rendered = renderDockerfile(baseDockerfile()); + expect(rendered).toContain("FROM supabase/postgres:17.0.0 AS pg\n"); + expect(rendered).toContain("FROM supabase/postgres:15.0.0 AS pg15\n"); + }); +}); + +describe("renderDockerfile: repository rule", () => { + test("keeps the existing repository and only replaces the tag", () => { + const rendered = renderDockerfile(baseDockerfile()); + expect(rendered).toContain("FROM supabase/gotrue:v1.0.0 AS gotrue\n"); + }); + + test("fails loudly when the Dockerfile's repository no longer matches the catalog's, for a non-overridden alias", () => { + expect(() => + renderDockerfile(baseDockerfile({ gotrue: "FROM some-other-org/gotrue:v0.9.9 AS gotrue" })), + ).toThrow(/gotrue.*repository/i); + }); + + test("tolerates imgproxy's documented repository mismatch (the allowlisted override)", () => { + const rendered = renderDockerfile(baseDockerfile()); + // Catalog upstreamImage repo is ghcr.io/imgproxy/imgproxy; the Dockerfile keeps darthsim. + expect(rendered).toContain("FROM darthsim/imgproxy:v1.0.0 AS imgproxy\n"); + }); + + test("still fails loudly when imgproxy's Dockerfile repository drifts from its override entry", () => { + expect(() => + renderDockerfile( + baseDockerfile({ imgproxy: "FROM some-other-org/imgproxy:v0.9.9 AS imgproxy" }), + ), + ).toThrow(/imgproxy.*REPOSITORY_OVERRIDES/i); + }); +}); + +describe("renderDockerfile: line preservation", () => { + test("never inserts a line for a missing slim-capable alias", () => { + const withoutPg15 = baseDockerfile().replace(/FROM supabase\/postgres:14\.9\.9 AS pg15\n/, ""); + expect(() => renderDockerfile(withoutPg15)).toThrow(/pg15/); + }); + + test("leaves non-slim-capable lines (kong) and comments byte-for-byte untouched", () => { + const current = baseDockerfile(); + const rendered = renderDockerfile(current); + expect(rendered).toContain("FROM library/kong:2.8.1 AS kong"); + expect(rendered).toContain("# hand-authored header"); + expect(rendered).toContain("# hand-authored footer"); + }); +}); diff --git a/apps/cli/scripts/render-service-dockerfile.ts b/apps/cli/scripts/render-service-dockerfile.ts new file mode 100644 index 0000000000..6c6c114eb6 --- /dev/null +++ b/apps/cli/scripts/render-service-dockerfile.ts @@ -0,0 +1,192 @@ +// Rewrites the slim-capable `FROM ... AS ` lines of +// apps/cli/src/shared/services/Dockerfile (and its byte-identical Go copy, +// apps/cli-go/pkg/config/templates/Dockerfile) in place, from the stack catalog +// (packages/stack/src/Artifacts.ts): each such line's tag comes from that service's catalog pin +// `upstreamImage`. Every other line — comments, kong, the Postgres 14 pin (no slim build), and +// the one-shot job images — is hand- or Dependabot-managed and left byte-for-byte untouched. +// +// bun apps/cli/scripts/render-service-dockerfile.ts # writes both files +// bun apps/cli/scripts/render-service-dockerfile.ts --check # fails on drift, writes nothing +// +// The `--check` mode is what CI runs (as a `render-service-dockerfile.unit.test.ts` assertion) to +// catch a hand-edited generated line, or a catalog change that hasn't been regenerated yet. +import { fileURLToPath } from "node:url"; +import { catalogPins, type ArtifactPin } from "@supabase/stack/internal/artifacts"; + +// Resolved from this module's own URL, so both the CLI invocation (cwd = repo root) and the +// vitest unit test (cwd = apps/cli) read the same files. +export const TS_DOCKERFILE_PATH = fileURLToPath( + new URL("../src/shared/services/Dockerfile", import.meta.url), +); +export const GO_DOCKERFILE_PATH = fileURLToPath( + new URL("../../cli-go/pkg/config/templates/Dockerfile", import.meta.url), +); + +type CatalogEntry = ReturnType[number]; + +/** + * Dockerfile repository overrides, for the one documented exception to the repository rule + * below. Keep this list to that exception; add a reason alongside any addition. + */ +const REPOSITORY_OVERRIDES: Readonly> = { + // slim-services mirrors ghcr.io/imgproxy/imgproxy (its `upstreamImage`), but the Dockerfile — + // and `mirror-template-images.yml` and the docker.io registry rewrite — keep darthsim/imgproxy, + // which carries the same tags on Docker Hub. + imgproxy: "darthsim/imgproxy", +}; + +interface SlimAliasSpec { + readonly sourceService: string; + /** Selects the catalog's default pin (every alias but `pg15`) or its lone additional pin. */ + readonly wantDefault: boolean; +} + +/** Every slim-capable Dockerfile alias, mapped to the catalog pin it tracks. */ +const SLIM_ALIAS_SPECS: ReadonlyMap = new Map([ + ["pg", { sourceService: "postgres", wantDefault: true }], + ["pg15", { sourceService: "postgres", wantDefault: false }], + ["mailpit", { sourceService: "mailpit", wantDefault: true }], + ["postgrest", { sourceService: "postgrest", wantDefault: true }], + ["pgmeta", { sourceService: "pgmeta", wantDefault: true }], + ["studio", { sourceService: "studio", wantDefault: true }], + ["imgproxy", { sourceService: "imgproxy", wantDefault: true }], + ["edgeruntime", { sourceService: "edge-runtime", wantDefault: true }], + ["vector", { sourceService: "vector", wantDefault: true }], + ["supavisor", { sourceService: "pooler", wantDefault: true }], + ["gotrue", { sourceService: "auth", wantDefault: true }], + ["realtime", { sourceService: "realtime", wantDefault: true }], + ["storage", { sourceService: "storage", wantDefault: true }], + ["logflare", { sourceService: "analytics", wantDefault: true }], +]); + +function selectPin( + alias: string, + spec: SlimAliasSpec, + pins: ReadonlyArray, +): ArtifactPin { + const candidates = pins.filter( + (entry) => entry.sourceService === spec.sourceService && entry.isDefault === spec.wantDefault, + ); + if (candidates.length !== 1) { + throw new Error( + `expected exactly one ${spec.wantDefault ? "default" : "additional"} catalog pin for ` + + `'${spec.sourceService}' (alias '${alias}'), found ${candidates.length}`, + ); + } + return candidates[0]!.pin; +} + +function tagOf(upstreamImage: string): string { + const sep = upstreamImage.lastIndexOf(":"); + if (sep === -1) throw new Error(`upstreamImage has no tag: ${upstreamImage}`); + return upstreamImage.slice(sep + 1); +} + +function repositoryOf(upstreamImage: string): string { + const sep = upstreamImage.lastIndexOf(":"); + if (sep === -1) throw new Error(`upstreamImage has no tag: ${upstreamImage}`); + return upstreamImage.slice(0, sep); +} + +/** Matches a `FROM : AS ` line, keeping every other byte for reuse. */ +const FROM_LINE = /^(FROM\s+)(.+):([^:\s]+)(\s+AS\s+)([^\s#]+)(.*)$/i; + +/** + * Rewrites only the slim-capable `FROM ... AS ` lines of `currentDockerfile`, in place: + * the repository is read from the existing line (and asserted to still match the catalog pin's + * `upstreamImage` repository, except for `REPOSITORY_OVERRIDES`); only the tag is replaced, from + * that pin's `upstreamImage`. Every other line — comments, kong, `pg14`, the job images — passes + * through untouched. A slim-capable alias missing from the file is an error; the generator never + * inserts a line. + */ +export function renderDockerfile(currentDockerfile: string): string { + const pins = catalogPins(); + const seen = new Set(); + + const lines = currentDockerfile.split("\n").map((line) => { + const match = FROM_LINE.exec(line); + if (match === null) return line; + const [, fromKeyword, repository, , asKeyword, alias, rest] = match as unknown as [ + string, + string, + string, + string, + string, + string, + string, + ]; + const spec = SLIM_ALIAS_SPECS.get(alias); + if (spec === undefined) return line; + seen.add(alias); + + const pin = selectPin(alias, spec, pins); + const pinRepository = repositoryOf(pin.upstreamImage); + const override = REPOSITORY_OVERRIDES[alias]; + if (override === undefined) { + if (repository !== pinRepository) { + throw new Error( + `${alias}'s Dockerfile repository '${repository}' no longer matches the catalog's ` + + `'${pinRepository}'. If this is intentional, add '${alias}' to REPOSITORY_OVERRIDES with a reason.`, + ); + } + } else if (repository !== override) { + throw new Error( + `${alias}'s Dockerfile repository '${repository}' no longer matches its ` + + `REPOSITORY_OVERRIDES entry '${override}'. Update the override if this is intentional.`, + ); + } + + return `${fromKeyword}${repository}:${tagOf(pin.upstreamImage)}${asKeyword}${alias}${rest}`; + }); + + for (const alias of SLIM_ALIAS_SPECS.keys()) { + if (!seen.has(alias)) { + throw new Error( + `no Dockerfile line for slim-capable alias '${alias}'; the generator never inserts one`, + ); + } + } + + return lines.join("\n"); +} + +async function main(argv: ReadonlyArray): Promise { + const check = argv.includes("--check"); + const current = await Bun.file(TS_DOCKERFILE_PATH).text(); + const rendered = renderDockerfile(current); + + if (check) { + const go = await Bun.file(GO_DOCKERFILE_PATH).text(); + let failed = false; + if (current !== rendered) { + console.log( + `::error ::${TS_DOCKERFILE_PATH} has drifted from packages/stack/src/Artifacts.ts. Run ` + + "`bun apps/cli/scripts/render-service-dockerfile.ts` and commit the result.", + ); + failed = true; + } + if (go !== rendered) { + console.log( + `::error ::${GO_DOCKERFILE_PATH} is not a byte copy of ${TS_DOCKERFILE_PATH}. Run ` + + "`bun apps/cli/scripts/render-service-dockerfile.ts` and commit the result.", + ); + failed = true; + } + if (failed) { + process.exit(1); + } + console.log("Both Dockerfiles match the catalog."); + return; + } + + await Bun.write(TS_DOCKERFILE_PATH, rendered); + await Bun.write(GO_DOCKERFILE_PATH, rendered); + console.log(`Regenerated ${TS_DOCKERFILE_PATH} and ${GO_DOCKERFILE_PATH} from the catalog.`); +} + +if (import.meta.main) { + main(process.argv.slice(2)).catch((error: unknown) => { + console.log(`::error ::${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/apps/cli/scripts/render-service-dockerfile.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.unit.test.ts new file mode 100644 index 0000000000..0c4f9036d9 --- /dev/null +++ b/apps/cli/scripts/render-service-dockerfile.unit.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +import { + GO_DOCKERFILE_PATH, + TS_DOCKERFILE_PATH, + renderDockerfile, +} from "./render-service-dockerfile.ts"; + +// Both against the real, checked-in Dockerfile and the real catalog — no hardcoded version +// literal, so a catalog bump never makes this drift-detection test itself go stale. +const currentTsDockerfile = readFileSync(TS_DOCKERFILE_PATH, "utf8"); + +describe("renderDockerfile against the real catalog and Dockerfile", () => { + test("round-trips: rewriting the checked-in Dockerfile reproduces it byte for byte (no drift)", () => { + expect(renderDockerfile(currentTsDockerfile)).toBe(currentTsDockerfile); + }); + + test("rendering twice is stable", () => { + expect(renderDockerfile(currentTsDockerfile)).toBe(renderDockerfile(currentTsDockerfile)); + }); + + // 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. + 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); + }); + + test("detects drift when a generated line is hand-edited", () => { + // A hand-edit to a generated tag (bumping gotrue without touching the catalog) must not + // round-trip back to itself — this is the drift check's whole point. + const tampered = currentTsDockerfile.replace( + /FROM supabase\/gotrue:v[^\s]+ AS gotrue/, + "FROM supabase/gotrue:v9.9.9 AS gotrue", + ); + expect(tampered).not.toBe(currentTsDockerfile); + expect(renderDockerfile(tampered)).not.toBe(tampered); + // It heals back to the catalog-pinned tag, not just "detects a difference". + expect(renderDockerfile(tampered)).toBe(currentTsDockerfile); + }); + + test("preserves hand-/Dependabot-managed lines verbatim, even when their own tags change", () => { + // Simulates a Dependabot bump of kong/differ/migra/pgprove: give each a synthetic marker + // tag (never a real pin) and confirm the generator leaves those exact lines alone rather + // than reverting or otherwise touching them. + const marker = "99.99.99-fixture-marker"; + let tampered = currentTsDockerfile; + for (const alias of ["kong", "differ", "migra", "pgprove"]) { + const pattern = new RegExp(`^FROM (\\S+):(\\S+) AS ${alias}$`, "m"); + const match = pattern.exec(tampered); + expect(match, `no line for hand-managed alias '${alias}'`).not.toBeNull(); + tampered = tampered.replace(pattern, `FROM $1:${marker} AS ${alias}`); + } + expect(tampered).not.toBe(currentTsDockerfile); + + const rendered = renderDockerfile(tampered); + for (const alias of ["kong", "differ", "migra", "pgprove"]) { + const tamperedLine = tampered.split("\n").find((line) => line.endsWith(`AS ${alias}`)); + expect(tamperedLine).toBeDefined(); + expect(tamperedLine).toContain(marker); + expect(rendered).toContain(tamperedLine); + } + }); +}); diff --git a/apps/cli/scripts/sweep-live-projects.integration.test.ts b/apps/cli/scripts/sweep-live-projects.integration.test.ts index ac124dce97..faaaac4c2b 100644 --- a/apps/cli/scripts/sweep-live-projects.integration.test.ts +++ b/apps/cli/scripts/sweep-live-projects.integration.test.ts @@ -6,6 +6,13 @@ import path from "node:path"; const script = path.resolve(import.meta.dirname, "sweep-live-projects.sh"); const directories: string[] = []; +// Per-sweep hang guard: a spawned run gets this long before the safety net kills +// it. A test's own timeout must cover every sweep it runs plus report margin, so +// derive it from the sweep count rather than a single shared budget. +const SWEEP_GUARD_MS = 30_000; +const REPORT_MARGIN_MS = 10_000; +const sweepBudget = (sweeps: number) => sweeps * SWEEP_GUARD_MS + REPORT_MARGIN_MS; + afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); }); @@ -73,13 +80,22 @@ async function runSweep(scenario: Scenario) { stdout: "pipe", stderr: "pipe", }); - const timeout = setTimeout(() => child.kill(), 10_000); + let hung = false; + const timeout = setTimeout(() => { + hung = true; + child.kill(); + }, SWEEP_GUARD_MS); const [exitCode, stdout, stderr] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]); clearTimeout(timeout); + if (hung) { + throw new Error( + `sweep script hung: killed after ${SWEEP_GUARD_MS}ms without exiting (stdout: ${stdout}, stderr: ${stderr})`, + ); + } return { exitCode, stdout, stderr, deletes }; } finally { await server.stop(true); @@ -88,75 +104,91 @@ async function runSweep(scenario: Scenario) { const active = (ref: string, name = `e2e-${ref}`) => ({ ref, name, status: "ACTIVE" }); -describe.skipIf(process.platform === "win32")("sweep-live-projects.sh", { timeout: 40_000 }, () => { - test("accepts refused deletion when a fresh authenticated list shows absence", async () => { - const result = await runSweep({ - lists: [[active("gone")], []], - deletes: { gone: { status: 403, body: { message: "already removed" } } }, +describe.skipIf(process.platform === "win32")( + "sweep-live-projects.sh", + { timeout: sweepBudget(1) }, + () => { + test("accepts refused deletion when a fresh authenticated list shows absence", async () => { + const result = await runSweep({ + lists: [[active("gone")], []], + deletes: { gone: { status: 403, body: { message: "already removed" } } }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["gone"]); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); }); - expect(result.exitCode).toBe(0); - expect(result.deletes).toEqual(["gone"]); - expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); - }); - test("accepts a terminal status and converges after a stale list", async () => { - const result = await runSweep({ - lists: [[active("stale")], [active("stale")], [{ ...active("stale"), status: "REMOVED" }]], - deletes: { stale: { status: 202 } }, + test("accepts a terminal status and converges after a stale list", async () => { + const result = await runSweep({ + lists: [[active("stale")], [active("stale")], [{ ...active("stale"), status: "REMOVED" }]], + deletes: { stale: { status: 202 } }, + }); + expect(result.exitCode).toBe(0); }); - expect(result.exitCode).toBe(0); - }); - test("retries a transient read failure but rejects malformed evidence", async () => { - const transient = await runSweep({ - lists: [503, [active("retry")], []], - deletes: { retry: { status: 202 } }, - }); - expect(transient.exitCode).toBe(0); + test( + "retries a transient read failure but rejects malformed evidence", + async () => { + const transient = await runSweep({ + lists: [503, [active("retry")], []], + deletes: { retry: { status: 202 } }, + }); + expect(transient.exitCode).toBe(0); - const malformed = await runSweep({ lists: [{ projects: [] }], deletes: {} }); - expect(malformed.exitCode).not.toBe(0); - expect(malformed.deletes).toEqual([]); - }); + const malformed = await runSweep({ lists: [{ projects: [] }], deletes: {} }); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.deletes).toEqual([]); + }, + sweepBudget(2), + ); - test("fails terminal listing errors without retrying or deleting", async () => { - const forbidden = await runSweep({ lists: [403, []], deletes: {} }); - expect(forbidden.exitCode).not.toBe(0); - expect(forbidden.deletes).toEqual([]); + test( + "fails terminal listing errors without retrying or deleting", + async () => { + const forbidden = await runSweep({ lists: [403, []], deletes: {} }); + expect(forbidden.exitCode).not.toBe(0); + expect(forbidden.deletes).toEqual([]); - const unavailable = await runSweep({ lists: [503, 503, 503], deletes: {} }); - expect(unavailable.exitCode).not.toBe(0); - expect(unavailable.deletes).toEqual([]); + const unavailable = await runSweep({ lists: [503, 503, 503], deletes: {} }); + expect(unavailable.exitCode).not.toBe(0); + expect(unavailable.deletes).toEqual([]); - const malformedReconciliation = await runSweep({ - lists: [[active("bad-evidence")], { projects: [] }], - deletes: { "bad-evidence": { status: 403 } }, - }); - expect(malformedReconciliation.exitCode).not.toBe(0); - }); + const malformedReconciliation = await runSweep({ + lists: [[active("bad-evidence")], { projects: [] }], + deletes: { "bad-evidence": { status: 403 } }, + }); + expect(malformedReconciliation.exitCode).not.toBe(0); + }, + sweepBudget(3), + ); - test("attempts every owned project and fails if one remains active", async () => { - const result = await runSweep({ - lists: [ - [active("stuck"), active("other"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], - [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], - ], - deletes: { stuck: { status: 403 }, other: { status: 204 } }, + test("attempts every owned project and fails if one remains active", async () => { + const result = await runSweep({ + lists: [ + [ + active("stuck"), + active("other"), + { ref: "foreign", name: "unrelated", status: "ACTIVE" }, + ], + [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], + ], + deletes: { stuck: { status: 403 }, other: { status: 204 } }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.deletes).toEqual(["stuck", "other"]); }); - expect(result.exitCode).not.toBe(0); - expect(result.deletes).toEqual(["stuck", "other"]); - }); - test("retries a transient deletion after fresh evidence still shows the project", async () => { - const result = await runSweep({ - lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), - deletes: {}, - deleteStatuses: { flaky: [503, 204] }, + test("retries a transient deletion after fresh evidence still shows the project", async () => { + const result = await runSweep({ + lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), + deletes: {}, + deleteStatuses: { flaky: [503, 204] }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["flaky", "flaky"]); + expect(result.stderr).toContain( + "delete retry completed for project flaky (HTTP 204 request delete-flaky)", + ); }); - expect(result.exitCode).toBe(0); - expect(result.deletes).toEqual(["flaky", "flaky"]); - expect(result.stderr).toContain( - "delete retry completed for project flaky (HTTP 204 request delete-flaky)", - ); - }); -}); + }, +); diff --git a/apps/cli/scripts/upload-release-assets.integration.test.ts b/apps/cli/scripts/upload-release-assets.integration.test.ts new file mode 100644 index 0000000000..699cae4bf1 --- /dev/null +++ b/apps/cli/scripts/upload-release-assets.integration.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createSpawnRun, + KILL_GRACE_MS, + releaseAssets, + uploadAssets, + uploadedAssetNames, + type ReleaseIo, +} from "./upload-release-assets.ts"; + +const scriptPath = fileURLToPath(new URL("./upload-release-assets.ts", import.meta.url)); +const asset = { + path: "dist/supabase_1.0.0_darwin_arm64.tar.gz", + name: "supabase_1.0.0_darwin_arm64.tar.gz", +}; +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +// A stand-in `gh` that logs every call, fails or hangs once per marker file, and serves +// `release view` from view.json. +const fakeGh = `#!/usr/bin/env bash +dir="$FAKE_GH_DIR" +echo "$*" >> "$dir/calls.log" +if [ "$1 $2" = "release view" ]; then + cat "$dir/view.json" + exit 0 +fi +name="$(basename "$4")" +if [ -f "$dir/fail-once-$name" ]; then + rm "$dir/fail-once-$name" + echo "HTTP 500: Error saving asset" >&2 + exit 1 +fi +if [ -f "$dir/hang-once-$name" ]; then + rm "$dir/hang-once-$name" + exec sleep 30 +fi +if [ -f "$dir/ignore-term-once-$name" ]; then + rm "$dir/ignore-term-once-$name" + trap '' TERM + sleep 30 & wait $! +fi +exit 0 +`; + +async function fakeGhOnPath() { + const directory = await mkdtemp(path.join(tmpdir(), "upload-release-assets-")); + temporaryDirectories.push(directory); + const bin = path.join(directory, "bin"); + await mkdir(bin); + await writeFile(path.join(bin, "gh"), fakeGh); + await chmod(path.join(bin, "gh"), 0o755); + const env = { ...process.env, FAKE_GH_DIR: directory, PATH: `${bin}:${process.env.PATH}` }; + const calls = async () => + (await readFile(path.join(directory, "calls.log"), "utf8")).trimEnd().split("\n"); + return { directory, env, calls }; +} + +function recordingIo(run: ReleaseIo["run"]): { io: ReleaseIo; logs: string[] } { + const logs: string[] = []; + return { + logs, + io: { + run, + fileExists: async () => true, + sleep: () => Promise.resolve(), + log: (line) => { + logs.push(line); + }, + }, + }; +} + +describe("upload-release-assets against a fake gh", () => { + test("kills an upload that outlives the timeout and succeeds on the retry", async () => { + const { directory, env, calls } = await fakeGhOnPath(); + await writeFile(path.join(directory, `hang-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + const startedAt = Date.now(); + + await uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + const elapsed = Date.now() - startedAt; + expect(elapsed).toBeGreaterThanOrEqual(5_000); + expect(elapsed).toBeLessThan(25_000); + expect(await calls()).toEqual([ + `release upload v1.0.0 ${asset.path} --clobber`, + `release upload v1.0.0 ${asset.path} --clobber`, + ]); + expect(logs).toEqual([ + `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, + `Uploaded ${asset.name} (attempt 2).`, + ]); + }, 40_000); + + test("kills an upload that ignores SIGTERM once the grace period passes", async () => { + const { directory, env, calls } = await fakeGhOnPath(); + await writeFile(path.join(directory, `ignore-term-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + const startedAt = Date.now(); + + await uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + const elapsed = Date.now() - startedAt; + expect(elapsed).toBeGreaterThanOrEqual(5_000 + KILL_GRACE_MS); + expect(elapsed).toBeLessThan(25_000); + expect(await calls()).toHaveLength(2); + expect(logs[0]).toBe( + `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, + ); + }, 40_000); + + test("surfaces gh's stderr for a failed attempt and retries it", async () => { + const { directory, env, calls } = await fakeGhOnPath(); + await writeFile(path.join(directory, `fail-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + + await uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + expect(await calls()).toHaveLength(2); + expect(logs[0]).toBe( + `Upload of ${asset.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0s.`, + ); + }); + + test("reads uploaded asset names from gh release view", async () => { + const { directory, env } = await fakeGhOnPath(); + await writeFile( + path.join(directory, "view.json"), + JSON.stringify({ + assets: [ + { name: "install", state: "uploaded" }, + { name: "checksums.txt", state: "starter" }, + ], + }), + ); + const { io } = recordingIo(createSpawnRun(env)); + + await expect(uploadedAssetNames("v1.0.0", io)).resolves.toEqual(["install"]); + }); + + test("runs as a command and uploads all 22 assets from the working directory", async () => { + const { directory, env, calls } = await fakeGhOnPath(); + const workdir = path.join(directory, "work"); + await mkdir(path.join(workdir, "dist"), { recursive: true }); + for (const asset of releaseAssets("1.0.0")) { + await writeFile(path.join(workdir, asset.path), asset.name); + } + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) throw new Error("Bun executable not found"); + + const child = Bun.spawn([bunExecutable, scriptPath, "upload", "--version", "1.0.0"], { + cwd: workdir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + + expect(exitCode, stderr).toBe(0); + expect(await calls()).toHaveLength(22); + expect(stdout.trim().split("\n")).toHaveLength(22); + expect(stdout).toContain("Uploaded install (attempt 1)."); + }, 20_000); +}); diff --git a/apps/cli/scripts/upload-release-assets.ts b/apps/cli/scripts/upload-release-assets.ts new file mode 100644 index 0000000000..d0edef1a35 --- /dev/null +++ b/apps/cli/scripts/upload-release-assets.ts @@ -0,0 +1,251 @@ +import { parseArgs } from "node:util"; +import process from "node:process"; + +export interface ReleaseAsset { + path: string; + name: string; +} + +const TRIPLES = [ + "darwin_arm64", + "darwin_amd64", + "linux_arm64", + "linux_amd64", + "windows_arm64", + "windows_amd64", +] as const; + +/** Every file the publish job attaches to a GitHub Release, in upload order. */ +export function releaseAssets(version: string): ReleaseAsset[] { + const paths = [ + ...TRIPLES.map((triple) => `dist/supabase_${version}_${triple}.tar.gz`), + `dist/supabase_${version}_linux_arm64.deb`, + `dist/supabase_${version}_linux_amd64.deb`, + `dist/supabase_${version}_linux_arm64.rpm`, + `dist/supabase_${version}_linux_amd64.rpm`, + `dist/supabase_${version}_linux_arm64.apk`, + `dist/supabase_${version}_linux_amd64.apk`, + `dist/supabase_${version}_windows_amd64.zip`, + `dist/supabase_${version}_windows_arm64.zip`, + "dist/checksums.txt", + // setup-cli, the install script, and docs download releases/latest/download/. + ...TRIPLES.map((triple) => `dist/supabase_${triple}.tar.gz`), + "install", + ]; + return paths.map((path) => ({ path, name: path.slice(path.lastIndexOf("/") + 1) })); +} + +export interface RunResult { + exitCode: number | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +export interface ReleaseIo { + run: (argv: string[], options: { timeoutMs: number }) => Promise; + fileExists: (path: string) => Promise; + sleep: (ms: number) => Promise; + log: (line: string) => void; +} + +export interface RetryPolicy { + maxAttempts: number; + timeoutMs: number; + backoffMs: (failedAttempts: number) => number; +} + +// gh retries a failed asset three times within a second on 5xx or a dropped connection. This +// outer policy covers the longer stalls uploads.github.com produces and the --clobber delete, +// which gh does not retry. Background: apps/cli/docs/release-process.md. +export const defaultRetryPolicy: RetryPolicy = { + maxAttempts: 3, + timeoutMs: 300_000, + backoffMs: (failedAttempts) => failedAttempts * 10_000, +}; + +function describeFailure(result: RunResult, timeoutMs: number): string { + if (result.timedOut) return `timed out after ${timeoutMs / 1000}s`; + const detail = result.stderr.trim().split("\n").at(-1) ?? ""; + return detail ? `exit ${result.exitCode}: ${detail}` : `exit ${result.exitCode}`; +} + +/** Uploads each asset in turn, retrying per asset, and throws on the first asset that never lands. */ +export async function uploadAssets( + tag: string, + assets: ReleaseAsset[], + io: ReleaseIo, + policy: RetryPolicy = defaultRetryPolicy, +): Promise { + for (const asset of assets) { + if (!(await io.fileExists(asset.path))) { + throw new Error(`Release asset ${asset.path} does not exist.`); + } + for (let attempt = 1; ; attempt++) { + const result = await io.run(["gh", "release", "upload", tag, asset.path, "--clobber"], { + timeoutMs: policy.timeoutMs, + }); + if (result.exitCode === 0) { + io.log(`Uploaded ${asset.name} (attempt ${attempt}).`); + break; + } + const failure = describeFailure(result, policy.timeoutMs); + if (attempt >= policy.maxAttempts) { + throw new Error( + `Upload of ${asset.name} to ${tag} failed after ${attempt} attempts (${failure}).`, + ); + } + const delayMs = policy.backoffMs(attempt); + io.log( + `Upload of ${asset.name} failed on attempt ${attempt} (${failure}); retrying in ${delayMs / 1000}s.`, + ); + await io.sleep(delayMs); + } + } +} + +interface ReleaseAssetView { + name: string; + state: string; +} + +/** Names of the release's assets that GitHub reports as fully uploaded. */ +export async function uploadedAssetNames(tag: string, io: ReleaseIo): Promise { + const result = await io.run(["gh", "release", "view", tag, "--json", "assets"], { + timeoutMs: 60_000, + }); + if (result.exitCode !== 0) { + throw new Error(`Could not read assets of ${tag} (${describeFailure(result, 60_000)}).`); + } + return parseAssetView(result.stdout, tag) + .filter((asset) => asset.state === "uploaded") + .map((asset) => asset.name); +} + +function isAssetView(value: unknown): value is ReleaseAssetView { + return ( + typeof value === "object" && + value !== null && + typeof (value as { name?: unknown }).name === "string" && + typeof (value as { state?: unknown }).state === "string" + ); +} + +function parseAssetView(stdout: string, tag: string): ReleaseAssetView[] { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + throw new Error(`Could not read assets of ${tag}: gh returned invalid JSON.`); + } + const assets = (parsed as { assets?: unknown } | null)?.assets; + if (!Array.isArray(assets) || !assets.every(isAssetView)) { + throw new Error( + `Could not read assets of ${tag}: gh output has no assets array with name and state.`, + ); + } + return assets; +} + +/** Expected asset names that are absent from the uploaded set, sorted. */ +export function missingAssets(expected: string[], uploaded: string[]): string[] { + const present = new Set(uploaded); + return expected.filter((name) => !present.has(name)).sort(); +} + +const usage = `Usage: pnpm exec bun apps/cli/scripts/upload-release-assets.ts --version + + upload Upload every release asset to the draft release v, one at a time with retries. + verify Fail unless every expected asset is present on v and reported as uploaded.`; + +export async function main(argv: string[], io: ReleaseIo): Promise { + const { values, positionals } = parseArgs({ + args: argv, + options: { version: { type: "string" } }, + allowPositionals: true, + }); + const [command] = positionals; + if (!values.version || (command !== "upload" && command !== "verify")) { + io.log(usage); + return 2; + } + const tag = `v${values.version}`; + const assets = releaseAssets(values.version); + + if (command === "upload") { + await uploadAssets(tag, assets, io); + return 0; + } + + const missing = missingAssets( + assets.map((asset) => asset.name), + await uploadedAssetNames(tag, io), + ); + if (missing.length > 0) { + io.log(`::error::Release ${tag} is missing assets or has incomplete uploads:`); + for (const name of missing) io.log(` ${name}`); + return 1; + } + io.log(`All ${assets.length} expected assets are present on ${tag}.`); + return 0; +} + +/** + * Time a timed-out command gets to exit on SIGTERM before it is killed outright, and the bound on + * draining its pipes afterwards, since an orphaned descendant can hold them open. + */ +export const KILL_GRACE_MS = 5_000; + +function within(promise: Promise, ms: number, fallback: T): Promise { + let timer: ReturnType | undefined; + const expiry = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), ms); + }); + return Promise.race([promise, expiry]).finally(() => clearTimeout(timer)); +} + +/** Runs a command with the given environment and terminates it once the timeout elapses. */ +export function createSpawnRun( + env: Record = process.env, +): ReleaseIo["run"] { + return async (argv, { timeoutMs }) => { + const child = Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }); + let timedOut = false; + let killTimer: ReturnType | undefined; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS); + }, timeoutMs); + // Start draining before the child exits so a chatty child never blocks on a full pipe. + const stdoutText = new Response(child.stdout).text(); + const stderrText = new Response(child.stderr).text(); + try { + const exitCode = await child.exited; + const [stdout, stderr] = await Promise.all([ + within(stdoutText, KILL_GRACE_MS, ""), + within(stderrText, KILL_GRACE_MS, ""), + ]); + return { exitCode, stdout, stderr, timedOut }; + } finally { + clearTimeout(timer); + clearTimeout(killTimer); + } + }; +} + +export const processIo: ReleaseIo = { + run: createSpawnRun(), + fileExists: (path) => Bun.file(path).exists(), + sleep: (ms) => Bun.sleep(ms), + log: (line) => console.log(line), +}; + +if (import.meta.main) { + try { + process.exit(await main(process.argv.slice(2), processIo)); + } catch (cause) { + console.error(`::error::${cause instanceof Error ? cause.message : String(cause)}`); + process.exit(1); + } +} diff --git a/apps/cli/scripts/upload-release-assets.unit.test.ts b/apps/cli/scripts/upload-release-assets.unit.test.ts new file mode 100644 index 0000000000..8c4c99d88c --- /dev/null +++ b/apps/cli/scripts/upload-release-assets.unit.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "vitest"; +import { + main, + missingAssets, + releaseAssets, + uploadAssets, + uploadedAssetNames, + type ReleaseIo, + type RetryPolicy, + type RunResult, +} from "./upload-release-assets.ts"; + +const ok: RunResult = { exitCode: 0, stdout: "", stderr: "", timedOut: false }; +const failed: RunResult = { + exitCode: 1, + stdout: "", + stderr: "HTTP 500: Error saving asset\n", + timedOut: false, +}; +const timedOut: RunResult = { exitCode: null, stdout: "", stderr: "", timedOut: true }; + +const fastPolicy: RetryPolicy = { + maxAttempts: 3, + timeoutMs: 1_000, + backoffMs: (failedAttempts) => failedAttempts * 10, +}; + +/** Fake IO that replays scripted results per asset path and records what the script did. */ +function fakeIo( + script: Record = {}, + options: { missingFiles?: string[] } = {}, +) { + const state = { runs: [] as string[][], sleeps: [] as number[], logs: [] as string[] }; + const io: ReleaseIo = { + run: async (argv) => { + state.runs.push(argv); + const target = (argv[2] === "upload" ? argv[4] : argv[3]) ?? ""; + return script[target]?.shift() ?? ok; + }, + fileExists: async (path) => !options.missingFiles?.includes(path), + sleep: async (ms) => { + state.sleeps.push(ms); + }, + log: (line) => { + state.logs.push(line); + }, + }; + return { io, state }; +} + +describe("releaseAssets", () => { + test("lists the versioned archives, packages, checksums, unversioned aliases, and install script", () => { + const assets = releaseAssets("2.118.0-beta.60"); + const names = assets.map((asset) => asset.name); + + expect(assets).toHaveLength(22); + expect(names).toContain("supabase_2.118.0-beta.60_darwin_arm64.tar.gz"); + expect(names).toContain("supabase_2.118.0-beta.60_linux_amd64.deb"); + expect(names).toContain("checksums.txt"); + expect(names).toContain("supabase_darwin_arm64.tar.gz"); + expect(names).toContain("install"); + expect(new Set(names).size).toBe(22); + expect(assets.find((asset) => asset.name === "install")?.path).toBe("install"); + }); +}); + +describe("uploadAssets", () => { + const first = { + path: "dist/supabase_1.0.0_darwin_arm64.tar.gz", + name: "supabase_1.0.0_darwin_arm64.tar.gz", + }; + const second = { + path: "dist/supabase_1.0.0_linux_amd64.deb", + name: "supabase_1.0.0_linux_amd64.deb", + }; + const third = { path: "install", name: "install" }; + const assets = [first, second, third]; + + test("uploads every asset once with --clobber when nothing fails", async () => { + const { io, state } = fakeIo(); + + await uploadAssets("v1.0.0", assets, io, fastPolicy); + + expect(state.runs).toEqual( + assets.map((asset) => ["gh", "release", "upload", "v1.0.0", asset.path, "--clobber"]), + ); + expect(state.sleeps).toEqual([]); + expect(state.logs.filter((line) => line.startsWith("Uploaded "))).toHaveLength(3); + }); + + test("retries a failed asset with growing pauses and moves on once it lands", async () => { + const { io, state } = fakeIo({ [second.path]: [failed, failed] }); + + await uploadAssets("v1.0.0", assets, io, fastPolicy); + + const secondAttempts = state.runs.filter((argv) => argv[4] === second.path); + expect(secondAttempts).toHaveLength(3); + expect(state.sleeps).toEqual([10, 20]); + expect(state.logs).toContain( + `Upload of ${second.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0.01s.`, + ); + expect(state.logs).toContain(`Uploaded ${first.name} (attempt 1).`); + expect(state.logs).toContain(`Uploaded ${second.name} (attempt 3).`); + }); + + test("gives up on an asset after the last attempt and does not touch later assets", async () => { + const { io, state } = fakeIo({ [second.path]: [failed, failed, failed] }); + + await expect(uploadAssets("v1.0.0", assets, io, fastPolicy)).rejects.toThrow( + `Upload of ${second.name} to v1.0.0 failed after 3 attempts (exit 1: HTTP 500: Error saving asset).`, + ); + + expect(state.runs.filter((argv) => argv[4] === third.path)).toHaveLength(0); + expect(state.sleeps).toEqual([10, 20]); + }); + + test("treats a timed-out upload as a failed attempt", async () => { + const { io, state } = fakeIo({ [first.path]: [timedOut] }); + + await uploadAssets("v1.0.0", [first], io, fastPolicy); + + expect(state.runs).toHaveLength(2); + expect(state.logs[0]).toBe( + `Upload of ${first.name} failed on attempt 1 (timed out after 1s); retrying in 0.01s.`, + ); + }); + + test("fails before calling gh when an asset file is missing", async () => { + const { io, state } = fakeIo({}, { missingFiles: [first.path] }); + + await expect(uploadAssets("v1.0.0", [first], io, fastPolicy)).rejects.toThrow( + `Release asset ${first.path} does not exist.`, + ); + expect(state.runs).toEqual([]); + }); +}); + +describe("uploadedAssetNames", () => { + test("keeps only assets GitHub reports as uploaded", async () => { + const view = { + assets: [ + { name: "checksums.txt", state: "uploaded" }, + { name: "install", state: "starter" }, + ], + }; + const { io, state } = fakeIo({ "v1.0.0": [{ ...ok, stdout: JSON.stringify(view) }] }); + + await expect(uploadedAssetNames("v1.0.0", io)).resolves.toEqual(["checksums.txt"]); + expect(state.runs).toEqual([["gh", "release", "view", "v1.0.0", "--json", "assets"]]); + }); + + test("fails when the release cannot be read", async () => { + const { io } = fakeIo({ "v1.0.0": [{ ...failed, stderr: "release not found\n" }] }); + + await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( + "Could not read assets of v1.0.0 (exit 1: release not found).", + ); + }); + + test("names the release when gh prints something other than JSON", async () => { + const { io } = fakeIo({ "v1.0.0": [{ ...ok, stdout: "gh: rate limited\n" }] }); + + await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( + "Could not read assets of v1.0.0: gh returned invalid JSON.", + ); + }); + + test("names the release when the JSON has no usable assets array", async () => { + const { io } = fakeIo({ + "v1.0.0": [{ ...ok, stdout: JSON.stringify({ assets: [{ name: "install" }] }) }], + }); + + await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( + "Could not read assets of v1.0.0: gh output has no assets array with name and state.", + ); + }); +}); + +describe("missingAssets", () => { + test("returns the expected names that are absent, sorted", () => { + expect(missingAssets(["b", "a", "c"], ["c"])).toEqual(["a", "b"]); + expect(missingAssets(["a"], ["a", "extra"])).toEqual([]); + }); +}); + +describe("main", () => { + const view = (names: string[]) => ({ + ...ok, + stdout: JSON.stringify({ assets: names.map((name) => ({ name, state: "uploaded" })) }), + }); + + test("prints usage for an unknown command or a missing version", async () => { + const { io, state } = fakeIo(); + + await expect(main(["publish", "--version", "1.0.0"], io)).resolves.toBe(2); + await expect(main(["upload"], io)).resolves.toBe(2); + expect(state.runs).toEqual([]); + expect(state.logs[0]).toContain("Usage:"); + }); + + test("verify succeeds when every expected asset is uploaded", async () => { + const names = releaseAssets("1.0.0").map((asset) => asset.name); + const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); + + await expect(main(["verify", "--version", "1.0.0"], io)).resolves.toBe(0); + expect(state.logs).toEqual(["All 22 expected assets are present on v1.0.0."]); + }); + + test("verify fails and names each missing asset", async () => { + const names = releaseAssets("1.0.0") + .map((asset) => asset.name) + .filter((name) => name !== "checksums.txt" && name !== "install"); + const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); + + await expect(main(["verify", "--version", "1.0.0"], io)).resolves.toBe(1); + expect(state.logs).toEqual([ + "::error::Release v1.0.0 is missing assets or has incomplete uploads:", + " checksums.txt", + " install", + ]); + }); +}); diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index 314477702e..0898836706 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -1,5 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { Cause, Effect, Layer, Option } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { GlobalFlag } from "effect/unstable/cli"; import type { Command, Param, Primitive } from "effect/unstable/cli"; import process from "node:process"; @@ -1117,6 +1118,7 @@ const COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; // CLI runtime tree. const completeAnalyticsLayer = analyticsLayer.pipe( Layer.provide(standaloneAnalyticsConfigLayer), + 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 972d154e36..6f946e73f0 100644 --- a/apps/cli/src/cli/main.ts +++ b/apps/cli/src/cli/main.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, Stdio } from "effect"; +import { Effect, Exit, Layer, Stdio } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { runCli } from "../shared/cli/run.ts"; import { upgradeNoticeHook } from "../command-internal/upgrade-notice.ts"; import { analyticsLayer } from "../telemetry/analytics.layer.ts"; @@ -40,7 +41,7 @@ if ( )) ) { await runCli(selectedRoot, { - analyticsLayer: analyticsLayer, + analyticsLayer: analyticsLayer.pipe(Layer.provide(FetchHttpClient.layer)), afterSuccess: upgradeNoticeHook, ...(selectionCause ? { beforeParse: Effect.failCause(selectionCause) } : {}), }); diff --git a/apps/cli/src/cli/root-stack-aliases.integration.test.ts b/apps/cli/src/cli/root-stack-aliases.integration.test.ts new file mode 100644 index 0000000000..2d6f2266d9 --- /dev/null +++ b/apps/cli/src/cli/root-stack-aliases.integration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "@effect/vitest"; +import { Console, Effect, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { emptyEnv, fakeConsole, mockOutput } from "../../tests/helpers/mocks.ts"; +import { CliArgs } from "../shared/cli/cli-args.service.ts"; +import { textCliOutputFormatter } from "../shared/output/text-formatter.ts"; +import { + DebugFlag, + DnsResolverFlag, + ExperimentalFlag, + ProfileFlag, + WorkdirFlag, + YesFlag, +} from "../command-internal/global-flags.ts"; +import { stackStartAliasCommand, stackStatusAliasCommand, stackStopAliasCommand } from "./root.ts"; + +const layerFor = (args: ReadonlyArray, console: Console.Console) => + Layer.mergeAll( + emptyEnv(), + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + Layer.succeed(Console.Console, console), + Layer.succeed(DebugFlag, false), + Layer.succeed(ExperimentalFlag, false), + Layer.succeed(ProfileFlag, "supabase"), + Layer.succeed(WorkdirFlag, Option.none()), + Layer.succeed(YesFlag, false), + Layer.succeed(DnsResolverFlag, "native"), + mockOutput({ format: "text" }).layer, + ); + +// The stack backend aliases `stack start`/`stack status`/`stack stop` to the top-level +// `start`/`status`/`stop` commands (root.ts) — their `--help` text must describe the +// top-level command, not leak the `stack ` invocation it is built from. +describe("stack backend top-level alias help text", () => { + test("`start --help` describes the top-level command, not `stack start`", async () => { + const { console, calls } = fakeConsole(); + await Effect.runPromise( + Effect.scoped( + Command.runWith(stackStartAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( + Effect.provide(layerFor(["--help"], console)), + ), + ), + ); + const text = calls.join("\n"); + expect(text).toContain("supabase start"); + expect(text).not.toContain("stack start"); + expect(text).not.toContain("Start a managed local stack"); + }); + + test("`status --help` describes the top-level command, not `stack status`", async () => { + const { console, calls } = fakeConsole(); + await Effect.runPromise( + Effect.scoped( + Command.runWith(stackStatusAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( + Effect.provide(layerFor(["--help"], console)), + ), + ), + ); + const text = calls.join("\n"); + expect(text).toContain("supabase status"); + expect(text).not.toContain("stack status"); + }); + + test("`stop --help` describes the top-level command, not `stack stop`", async () => { + const { console, calls } = fakeConsole(); + await Effect.runPromise( + Effect.scoped( + Command.runWith(stackStopAliasCommand, { version: "0.0.0-test" })(["--help"]).pipe( + Effect.provide(layerFor(["--help"], console)), + ), + ), + ); + const text = calls.join("\n"); + expect(text).toContain("supabase stop"); + expect(text).not.toContain("stack stop"); + }); +}); diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 023a556dd1..058401a9fd 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -52,6 +52,7 @@ 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"; import { makeGoProxyLayer } from "../command-internal/go-proxy.layer.ts"; @@ -75,20 +76,70 @@ import { YesFlag, } from "../command-internal/global-flags.ts"; -const stackStartAliasCommand = stackStartCommand.pipe( +// The stack backend's `start`/`status`/`stop` are the same commands as `stack +// start`/`stack status`/`stack stop`, aliased to the top level — their help text +// is rewritten below so `--help` refers to `supabase start` etc., not `stack start`. +export const stackStartAliasCommand = stackStartCommand.pipe( Command.provide(commandRuntimeLayer(["start"])), Command.provide(stackRuntimeLayer), Command.provide(stackStartRuntimeLayer), + Command.withShortDescription("Start the local Supabase stack"), + Command.withExamples([ + { + command: "supabase start", + description: "Start the current project stack", + }, + { + command: "supabase start --stack feature-a --runtime docker", + description: "Start a named Docker stack", + }, + ]), ); export const stackStopAliasCommand = stackStopCommand.pipe( Command.provide(commandRuntimeLayer(["stop"])), Command.provide(stackRuntimeLayer), + Command.withShortDescription("Stop the local Supabase stack"), + Command.withExamples([ + { + command: "supabase stop --stack feature-a", + description: "Stop the existing feature-a stack", + }, + ]), ); -const stackStatusAliasCommand = stackStatusCommand.pipe( +export const stackStatusAliasCommand = stackStatusCommand.pipe( Command.provide(commandRuntimeLayer(["status"])), Command.provide(stackRuntimeLayer), + Command.withShortDescription("Show the local Supabase stack status"), + Command.withExamples([ + { + command: "supabase status", + description: "Show the current project stack", + }, + { + command: "supabase status --stack feature-a", + description: "Show a named stack", + }, + { + command: "supabase status --env --output-format text > .env.local", + description: "Export connection variables as dotenv", + }, + ]), ); +/** Stable builds carry no label; other builds say what they are so help output never claims stability it lacks. */ +export function rootDescription(version: string): string { + switch (cliBuildChannel(version)) { + case "stable": + return "Supabase CLI."; + case "beta": + return "Supabase CLI (beta channel)."; + case "preview": + return "Supabase CLI (preview build)."; + case "development": + return "Supabase CLI (development build)."; + } +} + export const rootCommandForFeatures = ( options: { readonly stackBackend?: StackBackend; @@ -96,7 +147,7 @@ export const rootCommandForFeatures = ( } = {}, ): CliRootCommand => Command.make("supabase").pipe( - Command.withDescription("Supabase CLI (stable channel)."), + Command.withDescription(rootDescription(CLI_VERSION)), Command.withSubcommands([ backupsCommand, bootstrapCommand, diff --git a/apps/cli/src/cli/root.unit.test.ts b/apps/cli/src/cli/root.unit.test.ts new file mode 100644 index 0000000000..43796bdb46 --- /dev/null +++ b/apps/cli/src/cli/root.unit.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { rootDescription } from "./root.ts"; + +describe("rootDescription", () => { + it("leaves stable releases unlabeled", () => { + expect(rootDescription("3.0.0")).toBe("Supabase CLI."); + }); + + it.each([ + ["3.0.0-beta.12", "Supabase CLI (beta channel)."], + ["0.0.0-pr.1234", "Supabase CLI (preview build)."], + ["0.0.0-dev", "Supabase CLI (development build)."], + ["0.0.0-automated", "Supabase CLI (development build)."], + ])("labels %j as %j", (version, expected) => { + expect(rootDescription(version)).toBe(expected); + }); +}); diff --git a/apps/cli/src/command-internal/branch-target.ts b/apps/cli/src/command-internal/branch-target.ts index 0a19e5a22f..8481b4c21d 100644 --- a/apps/cli/src/command-internal/branch-target.ts +++ b/apps/cli/src/command-internal/branch-target.ts @@ -127,7 +127,7 @@ export const findBranchName = Effect.fnUntraced(function* ( : undefined; return yield* api.v1 .listAllBranches({ ref: parentRef }) - .pipe(Effect.map(Option.some), Effect.ensuring(task?.clear() ?? Effect.void)); + .pipe(Effect.map(Option.some), Effect.ensuring(task?.clear ?? Effect.void)); }).pipe( Effect.timeout(BRANCH_LOOKUP_TIMEOUT), // Any failure or the timeout above degrades to `None`; this helper never fails on a diff --git a/apps/cli/src/command-internal/bundled-postgres-client.ts b/apps/cli/src/command-internal/bundled-postgres-client.ts index 78668af596..df9476e09b 100644 --- a/apps/cli/src/command-internal/bundled-postgres-client.ts +++ b/apps/cli/src/command-internal/bundled-postgres-client.ts @@ -17,7 +17,7 @@ import { postgresVersion, prepareNativeArtifact, resolveArtifact, -} from "@supabase/stack/internal/postgres-artifact"; +} from "@supabase/stack/internal/artifacts"; import { DockerRun, type DockerRunOpts } from "./docker-run.service.ts"; import { DockerRunError } from "./docker-run.errors.ts"; diff --git a/apps/cli/src/command-internal/colors.ts b/apps/cli/src/command-internal/colors.ts index fe31b8f853..50addc7657 100644 --- a/apps/cli/src/command-internal/colors.ts +++ b/apps/cli/src/command-internal/colors.ts @@ -72,3 +72,8 @@ export function red(text: string, stream: ColorStream = process.stderr): string export function green(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("green", text, { validateStream: false }) : text; } + +/** Renders in gray for secondary text. */ +export function gray(text: string, stream: ColorStream = process.stderr): string { + return supportsColor(stream) ? styleText("gray", 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 4ef17d7449..9395168666 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -675,7 +675,7 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul }), ), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // Normalizes and classifies the response through the config family's shared // typed/defect boundary (ADR 0021). diff --git a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts index 690bc0604d..577eb95193 100644 --- a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts @@ -113,13 +113,9 @@ export const resolveDbBootstrapConfig = ( // orioledb_version and the four S3 fields feed the Postgres container's image/env directly. // `envOverride` never throws, so these don't need `wrapConfigOverride`. Same remote-over-env // precedence as `majorVersion` applies to each. - const orioledbVersion = remoteWins("experimental.orioledb_version") - ? config.experimental.orioledb_version - : envOverride( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - config.experimental.orioledb_version, - projectEnvValues, - ); + const orioledbVersion = remoteWins("db.orioledb_version") + ? config.db.orioledb_version + : envOverride("SUPABASE_DB_ORIOLEDB_VERSION", config.db.orioledb_version, projectEnvValues); const s3Host = remoteWins("experimental.s3_host") ? config.experimental.s3_host : envOverride("SUPABASE_EXPERIMENTAL_S3_HOST", config.experimental.s3_host, projectEnvValues); 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 bab4a35bc1..08ba19d556 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -14,6 +14,7 @@ import { Data, Effect, type FileSystem, Option, type Path, Schedule } from "effe import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { Output } from "../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { @@ -139,11 +140,12 @@ export interface StartDbSetupImages { */ function resolveDbSetupImages( serviceVersionOverrides: LocalServiceVersionOverrides, + slim: boolean, ): StartDbSetupImages { return { - realtime: resolvePinnedImage("realtime", "realtime", serviceVersionOverrides), - storage: resolvePinnedImage("storage", "storage", serviceVersionOverrides), - auth: resolvePinnedImage("gotrue", "auth", serviceVersionOverrides), + realtime: resolvePinnedImage("realtime", "realtime", serviceVersionOverrides, slim), + storage: resolvePinnedImage("storage", "storage", serviceVersionOverrides, slim), + auth: resolvePinnedImage("gotrue", "auth", serviceVersionOverrides, slim), }; } @@ -169,7 +171,7 @@ export const resolveDbSetupPrelude = (setup: { const output = yield* Output; yield* output.raw("Initialising schema...\n", "stderr"); const jwks = setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; - const images = resolveDbSetupImages(setup.serviceVersionOverrides); + const images = resolveDbSetupImages(setup.serviceVersionOverrides, yield* slimImagesEnabled); return { jwks, images }; }); 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 1b8757bcaa..c2bd3dabff 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.ts @@ -5,7 +5,7 @@ * final timeout's failures surface to the caller. */ -import { Data, Duration, Effect, Schedule, Stream } from "effect"; +import { Context, Data, Duration, Effect, Schedule, Stream } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -22,8 +22,11 @@ import { kongAuthHeaders } from "../kong-auth.ts"; type Spawner = ChildProcessSpawner["Service"]; -/** Default retry budget when the caller doesn't specify one. */ -const HEALTH_CHECK_TIMEOUT_SECONDS = 30; +/** Retry budget, in seconds, for health waits whose caller passes no `timeoutSeconds`. */ +export const HealthCheckTimeoutSeconds = Context.Reference( + "supabase/db-bootstrap/HealthCheckTimeoutSeconds", + { defaultValue: () => 30 }, +); /** Caps a single HTTP readiness probe so a hung response cannot stall the retry loop. */ const HTTP_PROBE_TIMEOUT_SECONDS = 10; @@ -269,7 +272,6 @@ export function waitForHealthyServices( containerIds: ReadonlyArray, opts: WaitForHealthyServicesOptions = {}, ): Effect.Effect { - const timeoutSeconds = opts.timeoutSeconds ?? HEALTH_CHECK_TIMEOUT_SECONDS; const postgrest = opts.postgrest; const edgeRuntime = opts.edgeRuntime; @@ -284,6 +286,7 @@ export function waitForHealthyServices( }; return Effect.gen(function* () { + const timeoutSeconds = opts.timeoutSeconds ?? (yield* HealthCheckTimeoutSeconds); let stillWatching = containerIds; // Each round narrows `stillWatching` to just the containers that failed, so a container @@ -405,19 +408,19 @@ export function waitForShadowReady( connConfig: PgConnInput, opts: WaitForShadowReadyOptions = {}, ): Effect.Effect { - const timeoutSeconds = opts.timeoutSeconds ?? HEALTH_CHECK_TIMEOUT_SECONDS; - - // Twice the second-counted budget: 500ms spacing would otherwise exhaust `timeoutSeconds` - // retries in half the wall time of a 1-second poll. - const schedule = Schedule.max([ - Schedule.spaced("500 millis"), - Schedule.recurs(timeoutSeconds * 2), - ]); - const boundSeconds = timeoutSeconds + SHADOW_READY_CONNECT_TIMEOUT_SECONDS; - - // 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. - return Effect.suspend(() => { + return Effect.gen(function* () { + const timeoutSeconds = opts.timeoutSeconds ?? (yield* HealthCheckTimeoutSeconds); + + // Twice the second-counted budget: 500ms spacing would otherwise exhaust `timeoutSeconds` + // retries in half the wall time of a 1-second poll. + const schedule = Schedule.max([ + Schedule.spaced("500 millis"), + Schedule.recurs(timeoutSeconds * 2), + ]); + const boundSeconds = timeoutSeconds + SHADOW_READY_CONNECT_TIMEOUT_SECONDS; + + // 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; const probe: Effect.Effect = Effect.gen(function* () { @@ -439,7 +442,7 @@ export function waitForShadowReady( ), ); - return probe.pipe( + return yield* probe.pipe( Effect.retry({ schedule, while: (failure) => !failure.fatal }), Effect.timeoutOrElse({ duration: Duration.seconds(boundSeconds), 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 24126157ea..8c0a2cda72 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 @@ -277,6 +277,30 @@ describe("waitForHealthyServices", () => { }), ); + it.effect("keeps retrying for 30 seconds when the caller passes no timeout", () => + Effect.gen(function* () { + const mock = mockHealthSpawner(() => runningStarting); + + const fiber = yield* waitForHealthyServices(mock.spawner, ["supabase_kong_proj"]).pipe( + Effect.provide(unusedHttpClientLayer), + Effect.forkChild({ startImmediately: true }), + ); + + for (let second = 0; second < 29; second++) { + yield* TestClock.adjust("1 seconds"); + } + const pendingAfter29Seconds = fiber.pollUnsafe(); + yield* TestClock.adjust("1 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + expect(pendingAfter29Seconds).toBeUndefined(); + expect(error).toBeInstanceOf(HealthCheckTimeoutError); + expect( + mock.spawned.filter((args) => args[0] === "container" && args[1] === "inspect"), + ).toHaveLength(31); + }), + ); + it.effect("dumps container logs to stderr on a genuine timeout", () => Effect.gen(function* () { const mock = mockHealthSpawner(() => notRunning); diff --git a/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts index 359cb6ec70..f048dbf70c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Ref, Sink, Stream } from "effect"; +import { Deferred, Effect, Fiber, Ref, Sink, Stream } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ImagePrepullError, ensureImagesCached } from "./image-prepull.ts"; @@ -48,6 +49,26 @@ function mockSpawner( }; } +const backoffClockLimitSeconds = 120; + +/** Steps `TestClock` a second at a time through the pull backoff until `fiber` settles. */ +const joinAdvancingClock = (fiber: Fiber.Fiber) => + Effect.gen(function* () { + for ( + let second = 0; + second < backoffClockLimitSeconds && fiber.pollUnsafe() === undefined; + second++ + ) { + yield* TestClock.adjust("1 seconds"); + } + if (fiber.pollUnsafe() === undefined) { + return yield* Effect.die( + `fiber still pending after ${backoffClockLimitSeconds} virtual seconds of pull backoff`, + ); + } + return yield* Fiber.join(fiber); + }); + describe("ensureImagesCached", () => { it.live("dedupes images before resolving, returning original ref -> resolved URL", () => { const mock = mockSpawner((args) => { @@ -125,11 +146,8 @@ describe("ensureImagesCached", () => { }), ); - // Every pull attempt fails, driving the real `DOCKER_PULL_RETRY_DELAYS_MS` backoff to - // exhaustion across all 3 registry candidates (~36s) — needs more than Vitest's 5s default. - it.live( - "aggregates every failed image's message into one combined error", - () => { + it.effect("aggregates every failed image's message into one combined error", () => + Effect.gen(function* () { const mock = mockSpawner((args) => { if (args[0] === "image" && args[1] === "inspect") { return { @@ -141,45 +159,44 @@ describe("ensureImagesCached", () => { return { exitCode: 1 }; }); - return ensureImagesCached(mock.spawner, ["supabase/a:1", "supabase/b:1"]).pipe( - Effect.flip, - Effect.map((error) => { - expect(error).toBeInstanceOf(ImagePrepullError); - expect(error.message).toContain("supabase/a:1"); - expect(error.message).toContain("supabase/b:1"); - }), + const fiber = yield* ensureImagesCached(mock.spawner, ["supabase/a:1", "supabase/b:1"]).pipe( + Effect.forkChild({ startImmediately: true }), ); - }, - 60_000, + const error = yield* joinAdvancingClock(fiber).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ImagePrepullError); + expect(error.message).toContain("supabase/a:1"); + expect(error.message).toContain("supabase/b:1"); + }), ); - it.live( + it.effect( "appends the install hint once when a failure indicates the daemon is unreachable", - () => { - const mock = mockSpawner((args) => { - if (args[0] === "image" && args[1] === "inspect") { - return { - exitCode: 1, - stderr: `Error response from daemon: No such image: ${args[2]}`, - }; - } - if (args[0] === "pull") { - return { - exitCode: 1, - stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock\n", - }; - } - return { exitCode: 1 }; - }); + () => + Effect.gen(function* () { + const mock = mockSpawner((args) => { + if (args[0] === "image" && args[1] === "inspect") { + return { + exitCode: 1, + stderr: `Error response from daemon: No such image: ${args[2]}`, + }; + } + if (args[0] === "pull") { + return { + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock\n", + }; + } + return { exitCode: 1 }; + }); - return ensureImagesCached(mock.spawner, ["supabase/a:1"]).pipe( - Effect.flip, - Effect.map((error) => { - expect(error.message).toContain("Docker Desktop is a prerequisite for local development"); - }), - ); - }, - 60_000, + const fiber = yield* ensureImagesCached(mock.spawner, ["supabase/a:1"]).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const error = yield* joinAdvancingClock(fiber).pipe(Effect.flip); + + expect(error.message).toContain("Docker Desktop is a prerequisite for local development"); + }), ); it.live("resolves an empty map for an empty image list without spawning anything", () => { 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 5f5c2ac711..185b34966f 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 @@ -156,6 +156,7 @@ export const buildLocalDbContainerInputs = ( ...config.db, port: values.dbPort, major_version: bootstrapConfig.majorVersion, + orioledb_version: bootstrapConfig.orioledbVersion, settings: resolveDbSettingsEnvOverrides( config.db.settings, projectEnvValues, @@ -164,7 +165,6 @@ export const buildLocalDbContainerInputs = ( }, experimental: { ...config.experimental, - orioledb_version: bootstrapConfig.orioledbVersion, s3_host: bootstrapConfig.s3Host, s3_region: bootstrapConfig.s3Region, s3_access_key: bootstrapConfig.s3AccessKey, diff --git a/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts b/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts index 521c67a6fe..61c9c7120e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts @@ -16,10 +16,12 @@ export function resolvePinnedImage( alias: string, localServiceName: LocalServiceVersionName, serviceVersions: LocalServiceVersionOverrides, + slim: boolean, ): string { return slimImageForCurrentPin( alias, dockerfileServiceImageRaw(alias), serviceVersions[localServiceName], + slim, ); } diff --git a/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts index 408f9adefa..006cbf4ae6 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pinned-image.unit.test.ts @@ -1,7 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../../shared/services/slim-images.ts"; +import { + expectedPinnedImage, + GHCR_SLIM_IMAGE_PATTERN, +} from "../../../tests/helpers/slim-images.ts"; import { resolvePinnedImage } from "./pinned-image.ts"; const currentTag = (alias: string) => dockerfileServiceImageRaw(alias).split(":")[1] ?? ""; @@ -12,68 +15,60 @@ const currentPoolerTag = currentTag("supavisor"); const currentPostgres = dockerfileServiceImageRaw("pg"); const currentPostgresTag = currentTag("pg"); -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("resolvePinnedImage", () => { it("resolves docker.io images while the slim flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(resolvePinnedImage("gotrue", "auth", {})).toBe(currentAuth); - expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + expect(resolvePinnedImage("gotrue", "auth", {}, false)).toBe(currentAuth); + expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" }, false)).toBe( "supabase/gotrue:v2.100.0", ); - expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" }, false)).toBe( "supabase/supavisor:2.0.0", ); }); it("resolves slim images when the flag is on and the pin is current", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(resolvePinnedImage("gotrue", "auth", {})).toBe(toSlimImage("gotrue", currentAuth)); - expect(resolvePinnedImage("gotrue", "auth", { auth: currentAuthTag })).toBe( - toSlimImage("gotrue", currentAuth), - ); + const pinned = expectedPinnedImage("gotrue", currentAuth); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); + expect(resolvePinnedImage("gotrue", "auth", {}, true)).toBe(pinned); + expect(resolvePinnedImage("gotrue", "auth", { auth: currentAuthTag }, true)).toBe(pinned); }); it("keeps a historical pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" })).toBe( + expect(resolvePinnedImage("gotrue", "auth", { auth: "v2.100.0" }, true)).toBe( "supabase/gotrue:v2.100.0", ); - expect(resolvePinnedImage("storage", "storage", { storage: "v1.67.0" })).toBe( + expect(resolvePinnedImage("storage", "storage", { storage: "v1.67.0" }, true)).toBe( "supabase/storage-api:v1.67.0", ); - expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" })).toBe( + expect(resolvePinnedImage("supavisor", "pooler", { pooler: "2.0.0" }, true)).toBe( "supabase/supavisor:2.0.0", ); }); it("normalizes a current pooler pin onto the slim tag scheme", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(resolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag })).toBe( - toSlimImage("supavisor", currentPooler), + const pinned = expectedPinnedImage("supavisor", currentPooler); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); + expect(resolvePinnedImage("supavisor", "pooler", { pooler: currentPoolerTag }, true)).toBe( + pinned, ); - expect( - resolvePinnedImage("supavisor", "pooler", { - pooler: currentPoolerTag.startsWith("v") - ? currentPoolerTag.slice(1) - : `v${currentPoolerTag}`, - }), - ).toBe(toSlimImage("supavisor", currentPooler)); + // The opposite `v`-prefix variant of the same pin still counts as current + // (`pinMatchesCurrentImage` normalizes both before comparing), so it resolves to the very same + // catalog-pinned slim image. + const altPoolerTag = currentPoolerTag.startsWith("v") + ? currentPoolerTag.slice(1) + : `v${currentPoolerTag}`; + expect(resolvePinnedImage("supavisor", "pooler", { pooler: altPoolerTag }, true)).toBe(pinned); }); it("keeps a historical postgres pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" }, false)).toBe( "supabase/postgres:17.4.1.1", ); - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" })).toBe( + expect(resolvePinnedImage("pg", "postgres", { postgres: "17.4.1.1" }, true)).toBe( "supabase/postgres:17.4.1.1", ); - expect(resolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag })).toBe( - toSlimImage("pg", currentPostgres), + expect(resolvePinnedImage("pg", "postgres", { postgres: currentPostgresTag }, true)).toBe( + expectedPinnedImage("pg", currentPostgres), ); }); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts index e1343d75af..be97398051 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts @@ -64,9 +64,9 @@ const POSTGRES_INITDB_VERSION_THRESHOLD = "15.8.1.005"; const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; export interface PostgresStartServiceInput { - /** Decoded `[db]` section: `port`, `major_version`, and `settings`. */ + /** Decoded `[db]` section: `port`, `major_version`, `orioledb_version`, and `settings`. */ readonly db: CliConfig["db"]; - /** Decoded `[experimental]` section — only the OrioleDB/S3 fields are read. */ + /** Decoded `[experimental]` section — only the S3 fields are read. */ readonly experimental: CliConfig["experimental"]; /** Resolved `auth.jwt_secret`, as produced by `resolveLocalConfigValues`. */ readonly jwtSecret: string; @@ -170,10 +170,11 @@ export function postgresImageVersionTag(image: string): string { * for images older than {@link POSTGRES_INITDB_VERSION_THRESHOLD}. At most one branch fires. */ function postgresExtraEnv( + orioledbVersion: string | undefined, experimental: CliConfig["experimental"], image: string, ): Readonly> { - if (experimental.orioledb_version !== undefined && experimental.orioledb_version.length > 0) { + if (orioledbVersion !== undefined && orioledbVersion.length > 0) { return { POSTGRES_INITDB_ARGS: "--lc-collate=C --lc-ctype=C", S3_ENABLED: "true", @@ -285,7 +286,7 @@ export function buildPostgresStartContainerSpec( POSTGRES_HOST: "/var/run/postgresql", JWT_SECRET: input.jwtSecret, JWT_EXP: String(input.jwtExpiry), - ...postgresExtraEnv(input.experimental, input.configImage), + ...postgresExtraEnv(input.db.orioledb_version, input.experimental, input.configImage), }; const script = isRestore @@ -339,7 +340,7 @@ export const SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; * since the shadow container has no name and never restores from a backup) plus its own host port. */ export interface ShadowPostgresContainerSpecInput { - readonly db: Pick; + readonly db: Pick; readonly experimental: CliConfig["experimental"]; readonly jwtSecret: string; readonly jwtExpiry: number; @@ -377,7 +378,7 @@ export function buildShadowPostgresContainerSpec( POSTGRES_HOST: "/var/run/postgresql", JWT_SECRET: input.jwtSecret, JWT_EXP: String(input.jwtExpiry), - ...postgresExtraEnv(input.experimental, input.configImage), + ...postgresExtraEnv(input.db.orioledb_version, input.experimental, input.configImage), }; const script = isPg14OrEarlier diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts index 74ab5dd41b..f431f2181c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts @@ -138,8 +138,8 @@ describe("buildPostgresStartContainerSpec", () => { test("OrioleDB branch: adds POSTGRES_INITDB_ARGS + S3 env vars and skips the version-compare branch", () => { const spec = buildPostgresStartContainerSpec( baseInput({ + db: baseDb({ orioledb_version: "17.4.1.030" }), experimental: baseExperimental({ - orioledb_version: "17.4.1.030", s3_host: "s3.example.com", s3_region: "us-east-1", s3_access_key: "access-key", @@ -165,7 +165,7 @@ describe("buildPostgresStartContainerSpec", () => { test("OrioleDB branch defaults unset S3 fields to empty strings, matching Go's zero-value string fields", () => { const spec = buildPostgresStartContainerSpec( - baseInput({ experimental: baseExperimental({ orioledb_version: "17.4.1.030" }) }), + baseInput({ db: baseDb({ orioledb_version: "17.4.1.030" }) }), ); expect(spec.env).toMatchObject({ S3_HOST: "", 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 cdf1bdeed8..2cb21499ce 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 @@ -12,7 +12,7 @@ import { Data, Effect, FileSystem, Option, Path } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { detectGitBranch } from "../../shared/git/git-branch.ts"; +import { branchClause, detectGitBranch } from "../../shared/git/git-branch.ts"; import { DebugFlag, NetworkIdFlag, @@ -29,9 +29,7 @@ import { import { aqua, yellow } from "../colors.ts"; import { CommandSettings } from "../../config/command-settings.service.ts"; import { checkDbToml, loadProjectEnv, readDbToml } from "../db-config.toml-read.ts"; -import { DbConnection } from "../db-connection.service.ts"; import { loadLocalProjectContext } from "../local-project-context.ts"; -import { migrateAndSeed } from "../migrate-and-seed.ts"; import { hasConfiguredBuckets, seedBucketsRun } from "../seed-buckets.ts"; import { awaitStorageReady } from "./await-storage-ready.ts"; import { resolveResetSeedConfig } from "./db-setup.ts"; @@ -39,7 +37,7 @@ import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; import { currentStackBackend } from "../stack-backend.ts"; -import { stackLocalDatabaseConn, stackOpenReadyProject } from "../stack-local-database.ts"; +import { stackOpenReadyProject } from "../stack-local-database.ts"; import { classifyStorageCapability, describeStorageCapability, @@ -48,7 +46,12 @@ import { stackStorageEndpointFor, } from "../stack-storage.ts"; import { loadStackConfig } from "../stack-config.ts"; -import { StackCatalogSetup } from "../stack-catalog-setup.ts"; +import { catalogDatabaseServices, StackCatalogSetup } from "../stack-catalog-setup.ts"; +import { + initializeStackDatabase, + projectCatalogOverlay, + StackBootstrapError, +} from "../stack-bootstrap.ts"; /** The local database container is not running. */ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunningError")<{ @@ -145,18 +148,13 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu ), ), ); - const composed = members.flatMap((member) => - member.service === "auth" || member.service === "storage" || member.service === "realtime" - ? [member.service] - : [], - ); // A database-only composition is the `db start` overlay, which provisions schemas from config; // a full stack keeps its saved members so `stack start --exclude` choices hold. const databaseServices = members.every((member) => member.service === "database") ? (["auth", "realtime", "storage"] as const).filter( (service) => config.source[service].enabled, ) - : composed; + : 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}`)), @@ -170,44 +168,30 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu yield* opened.value.database.ready.pipe( Effect.mapError((cause) => resetFailed(`failed to ready local database: ${cause.message}`)), ); - yield* catalog.value - .apply({ - target: { - stack: opened.value.stack, - database: opened.value.database, - databaseServices, - }, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, - workdir, - }, - }) - .pipe(Effect.mapError((cause) => resetFailed(cause.message))); - const dbConn = yield* DbConnection; - const conn = yield* stackLocalDatabaseConn.pipe( - Effect.mapError((cause) => new ResetLocalDbNotRunningError({ message: cause.message })), - ); - yield* Effect.scoped( - Effect.gen(function* () { - const session = yield* dbConn - .connect(conn, { isLocal: true, dnsResolver: "native" }) - .pipe( - Effect.mapError((cause) => - resetFailed(`failed to connect after reset: ${cause.message}`), - ), - ); - yield* migrateAndSeed(session, fs, path, workdir, input.version, { - migrationsEnabled: toml.migrationsEnabled, - seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), - experimental, - pgDeltaEnabled: toml.pgDelta.enabled, - schemaPaths: toml.schemaPaths, - localDatabaseWebhooksEnabled: toml.webhooksEnabled, - }).pipe(Effect.mapError((cause) => resetFailed(cause.message))); - }), + yield* initializeStackDatabase({ + target: { + stack: opened.value.stack, + database: opened.value.database, + databaseServices, + }, + overlay: projectCatalogOverlay(toml, workdir), + migrations: { + workdir, + toml: { ...toml, seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path) }, + experimental, + version: input.version, + }, + }).pipe( + Effect.provideService(StackCatalogSetup, catalog.value), + Effect.mapError((cause) => + !(cause instanceof StackBootstrapError) + ? resetFailed(cause.message) + : cause.reason === "unavailable" + ? new ResetLocalDbNotRunningError({ message: cause.message }) + : cause.reason === "connect" + ? resetFailed(`failed to connect after reset: ${cause.message}`) + : resetFailed(cause.message), + ), ); yield* opened.value.stack.composition.start.pipe( Effect.mapError((cause) => @@ -254,9 +238,9 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu workdir, }); }).pipe(Effect.catch((error) => skipSeeding(error.message, suggestionOf(error)))); - const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + const branch = yield* detectGitBranch(workdir); yield* output.raw( - `Finished ${aqua("supabase db reset")} on branch ${aqua(branch)}.\n`, + `Finished ${aqua("supabase db reset")}${branchClause(branch, aqua)}.\n`, "stderr", ); return; @@ -361,6 +345,9 @@ export const resetLocalDatabase = Effect.fn("DbBootstrap.resetLocalDatabase")(fu ); } - const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); - yield* output.raw(`Finished ${aqua("supabase db reset")} on branch ${aqua(branch)}.\n`, "stderr"); + const branch = yield* detectGitBranch(workdir); + yield* output.raw( + `Finished ${aqua("supabase db reset")}${branchClause(branch, aqua)}.\n`, + "stderr", + ); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts index 098313470a..de81930a4e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.integration.test.ts @@ -307,7 +307,7 @@ describe("acquireShadowDatabase", () => { // tar is not a coherent snapshot — the acquire must degrade to the bare uncached shadow. const input = { ...shadowInput(fs, path), - experimental: { ...defaultConfig.experimental, orioledb_version: "15" }, + db: { major_version: 17, settings: {}, orioledb_version: "15" }, }; const handle = yield* acquireShadowDatabase(docker.spawner, input); expect(handle.baselinePresent).toBe(false); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts index 5caf37116c..7945658358 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -48,6 +48,7 @@ import { } from "./pgdata-snapshot.ts"; import type { PgDataArchiveProblem, PgDataSnapshotUnavailable } from "./pgdata-snapshot.ts"; import { resolvePinnedImage } from "./pinned-image.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { createShadowDatabase, removeShadowDatabase, @@ -257,7 +258,7 @@ const resolveShadowCacheKeyInputs = ( ): Effect.Effect, E> => Effect.gen(function* () { // OrioleDB keeps cluster state in S3, so a PGDATA tar is not a coherent snapshot. - const orioledbVersion = input.experimental.orioledb_version; + const orioledbVersion = input.db.orioledb_version; if (orioledbVersion !== undefined && orioledbVersion.length > 0) return Option.none(); // PG<=14 applies `ALTER ROLE … SET` on the setup session; a snapshot reconnect would @@ -290,13 +291,14 @@ const resolveShadowCacheKeyInputs = ( const realtimeConsumesJwks = input.setup.majorVersion >= 15 && input.setup.config.realtime.enabled; const jwks = realtimeConsumesJwks ? yield* input.setup.jwks : ""; + const slim = yield* slimImagesEnabled; const realtimeImage = yield* resolveJobImage( - resolvePinnedImage("realtime", "realtime", overrides), + resolvePinnedImage("realtime", "realtime", overrides, slim), ); const storageImage = yield* resolveJobImage( - resolvePinnedImage("storage", "storage", overrides), + resolvePinnedImage("storage", "storage", overrides, slim), ); - const authImage = yield* resolveJobImage(resolvePinnedImage("gotrue", "auth", overrides)); + const authImage = yield* resolveJobImage(resolvePinnedImage("gotrue", "auth", overrides, slim)); if (Option.isNone(realtimeImage) || Option.isNone(storageImage) || Option.isNone(authImage)) { return Option.none(); } 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 41ad72b60e..8ad2daee30 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -279,10 +279,9 @@ export interface ShadowSourceResult { /** The diff source Postgres URL (the provisioned shadow). */ readonly sourceUrl: string; /** - * When set, replaces the diff target with a second database on the same shadow container - * (`contrib_regression`, cloned from `postgres` during shadow setup — see - * {@link setupShadowConn}) with declarative schemas applied, so the user's local DB is never - * diffed directly in that branch. + * Legacy migra only. When set, replaces the diff target with `contrib_regression` on the + * same shadow container (cloned from `postgres` by {@link setupShadowConn}) after declarative + * schemas are applied. pg-delta leaves this unset and diffs `postgres`. */ readonly targetUrlOverride: string | undefined; } @@ -358,6 +357,7 @@ export function shadowRunInputFromLocalContainerInputs( return { db: { major_version: postgresSpecBase.db.major_version, + orioledb_version: postgresSpecBase.db.orioledb_version, settings: postgresSpecBase.db.settings, }, experimental: postgresSpecBase.experimental, @@ -461,10 +461,9 @@ export const setupShadowConn = ( }); /** - * {@link setupShadowConn}'s trailing {@link SHADOW_CREATE_TEMPLATE_SQL} step on its own. Split - * out because it's the one part a warm shadow-cache hit still has to run: the cache's PGDATA - * snapshot is taken before this statement, so a restored cluster carries the platform baseline - * but no `contrib_regression`. + * Clones `contrib_regression` for the legacy migra shadow. The cache snapshot is taken + * before this statement, so a warm hit still runs it when that caller asks for the clone. + * pg-delta does not call this. */ const createShadowTemplateDatabase = ( session: DbSession, @@ -546,14 +545,13 @@ export const buildShadowSetupDatabaseInput = ( }); /** - * Connects to the shadow, then resolves the setup prelude (JWKS/pinned image names) and runs - * {@link setupShadowConn} — the platform baseline plus the template database, no user - * migrations. Connect-then-setup so an unconnectable shadow surfaces a connect error immediately - * rather than paying for JWKS work first. The connection closes once this resolves. + * Connects to the shadow, resolves the setup prelude, and applies the platform baseline. + * No user migrations. Does not create `contrib_regression`: pg-delta diffs this database, + * and `CREATE DATABASE … TEMPLATE` crashes OrioleDB when the baseline has an enum-indexed + * OrioleDB table. * - * `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm cache hit skips the prelude and - * setup (the restored cluster already has them) and only recreates `contrib_regression`; a - * cache-enabled cold provision snapshots between the baseline and the template. + * `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm cache hit returns immediately. + * A cache-enabled cold provision snapshots after the baseline and does not reconnect. */ export const setupShadowDatabase = ( spawner: Spawner, @@ -567,7 +565,8 @@ export const setupShadowDatabase = ( > => Effect.scoped( Effect.gen(function* () { - if (!baseline.baselinePresent && baseline.snapshotRequired) { + if (baseline.baselinePresent) return; + if (baseline.snapshotRequired) { yield* Effect.scoped( Effect.gen(function* () { const setupSession = yield* connectShadowDatabase(input.connConfig); @@ -586,22 +585,20 @@ export const setupShadowDatabase = ( }), ); yield* baseline.snapshotBaseline; + return; } const session = yield* connectShadowDatabase(input.connConfig); - if (!baseline.baselinePresent && !baseline.snapshotRequired) { - const resolved = yield* resolveDbSetupPrelude(input.setup); - yield* setupDatabase( - spawner, - buildShadowSetupDatabaseInput(input, session, resolved), - options, - ).pipe( - // Same connect-failure classification as the snapshot branch above. - Effect.catchTag("DbConnectError", (cause) => - Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), - ), - ); - } - yield* createShadowTemplateDatabase(session); + const resolved = yield* resolveDbSetupPrelude(input.setup); + yield* setupDatabase( + spawner, + buildShadowSetupDatabaseInput(input, session, resolved), + options, + ).pipe( + // Same connect-failure classification as the snapshot branch above. + Effect.catchTag("DbConnectError", (cause) => + Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), + ), + ); }), ); @@ -645,12 +642,12 @@ const SHADOW_BASELINE_COLD: ShadowBaselineState = { /** * Lists local migrations first, so a bad migrations directory fails before any DB connection, - * then connects, resolves the setup prelude, and runs {@link setupShadowConn} (platform baseline - * plus template database) before applying every listed migration. Connect-then-setup for the - * same reason as {@link setupShadowDatabase}. + * then connects, resolves the setup prelude, and runs the platform baseline before applying + * every listed migration. `options.createTemplateDatabase` clones `contrib_regression` for the + * legacy engine only. Connect-then-setup for the same reason as {@link setupShadowDatabase}. * * `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm hit skips the prelude and setup; a - * cold cache-enabled provision snapshots between the baseline and the template. Only that + * cold cache-enabled provision snapshots between the baseline and later steps. Only that * snapshotting branch splits sessions — see {@link ShadowBaselineState.snapshotRequired}. */ const migrateShadowDatabaseWith = ( @@ -658,6 +655,7 @@ const migrateShadowDatabaseWith = ( input: ShadowSetupRunInput, setupOptions: SetupDatabaseOptions, baseline: ShadowBaselineState = SHADOW_BASELINE_COLD, + options: { readonly createTemplateDatabase?: boolean } = {}, ): Effect.Effect< void, StartSetupLocalDatabaseError | ShadowDbError | ImagePrepullError | E, @@ -696,8 +694,8 @@ const migrateShadowDatabaseWith = ( } const session = yield* connectShadowDatabase(input.connConfig); if (!baseline.baselinePresent && !baseline.snapshotRequired) { - // The established single-session flow: baseline + template + migrations all on this - // one session — see this function's own doc comment. + // The established single-session flow: baseline and migrations share this session. + // A reconnect would pick up role-level defaults `roles.sql` just installed. const resolved = yield* resolveDbSetupPrelude(input.setup); yield* setupDatabase( spawner, @@ -710,7 +708,9 @@ const migrateShadowDatabaseWith = ( ), ); } - yield* createShadowTemplateDatabase(session); + if (options.createTemplateDatabase !== false) { + yield* createShadowTemplateDatabase(session); + } yield* applyMigrations( session, input.fs, @@ -743,9 +743,9 @@ export const migrateShadowDatabase = ( > => migrateShadowDatabaseWith(spawner, input, { webhooks: "enabled" }, baseline); /** - * Migrates a shadow for the in-process pg-delta engine. Unlike the legacy engine, - * extension activation follows project config through `setupDatabase`'s - * default options. + * Migrates a shadow for the in-process pg-delta engine. Extension activation follows + * project config. Does not create `contrib_regression`: pg-delta never connects to it, + * and the clone crashes OrioleDB when the baseline has an enum-indexed OrioleDB table. */ export const migrateNextShadowDatabase = ( spawner: Spawner, @@ -755,4 +755,4 @@ export const migrateNextShadowDatabase = ( void, StartSetupLocalDatabaseError | ShadowDbError | ImagePrepullError | E, Output | DockerRun | RuntimeInfo | DbConnection -> => migrateShadowDatabaseWith(spawner, input, {}, baseline); +> => migrateShadowDatabaseWith(spawner, input, {}, baseline, { createTemplateDatabase: false }); diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts index 2b0fe943e7..4bf1d5ceac 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts @@ -504,7 +504,7 @@ describe("buildShadowSetupDatabaseInput", () => { describe("setupShadowDatabase / migrateShadowDatabase", () => { it.effect( - "setupShadowDatabase connects, sets up the platform baseline, and creates the template database", + "setupShadowDatabase applies the platform baseline without creating contrib_regression", () => { const { session, calls } = fakeSession(); const workdir = tempRoot.current; @@ -528,7 +528,8 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { }, setup: baseShadowSetup(), }); - expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + expect(mock.spawned.length).toBeGreaterThan(0); + expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false); }).pipe( Effect.provide( Layer.mergeAll( @@ -596,6 +597,7 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { Effect.tap(() => Effect.sync(() => { expect(calls.some((call) => call.sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(false); + expect(calls.some((call) => call.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false); }), ), ); @@ -654,6 +656,7 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { ); expect(jwksEvaluated).toBe(false); expect(calls.some((call) => call.sql === "drop extension if exists pg_net")).toBe(true); + expect(calls.some((call) => call.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false); }).pipe( Effect.provide( Layer.mergeAll( @@ -713,63 +716,60 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { ); }); - it.effect( - "skips the platform baseline on a warm cache hit but still creates the template", - () => { - const { session, calls } = fakeSession(); - const workdir = tempRoot.current; - const mock = mockSpawner(); - let jwksEvaluated = false; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* setupShadowDatabase( - mock.spawner, - { - fs, - path, - workdir, - projectId: "proj", - container: "shadow-container-id-0123456789abcdef", - networkId: "supabase_network_proj", - connConfig: { - host: "127.0.0.1", - port: 54320, - user: "postgres", - password: "postgres", - database: "postgres", - }, - setup: baseShadowSetup({ - majorVersion: 17, - realtimeEnabledForSetup: true, - jwks: Effect.sync(() => { - jwksEvaluated = true; - return '{"keys":[]}'; - }), - }), - }, - {}, - { - baselinePresent: true, - snapshotRequired: false, - snapshotBaseline: Effect.void, + it.effect("skips the platform baseline and contrib_regression on a warm cache hit", () => { + const { session, calls } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + let jwksEvaluated = false; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* setupShadowDatabase( + mock.spawner, + { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", }, - ); - expect(jwksEvaluated).toBe(false); - expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - mockOutput().layer, - mockDockerRun(), - mockRuntimeInfo(), - mockDbConnection(session), - ), - ), + setup: baseShadowSetup({ + majorVersion: 17, + realtimeEnabledForSetup: true, + jwks: Effect.sync(() => { + jwksEvaluated = true; + return '{"keys":[]}'; + }), + }), + }, + {}, + { + baselinePresent: true, + snapshotRequired: false, + snapshotBaseline: Effect.void, + }, ); - }, - ); + expect(jwksEvaluated).toBe(false); + expect(calls).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }); it.effect( "migrateShadowDatabase lists local migrations BEFORE connecting, tolerating a missing migrations directory as an empty list rather than a failure", 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 02ce30b130..9e8a09e3ae 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Exit, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Exit, Layer, Option, Redacted, Stream } from "effect"; +import type { DatabaseInstance, Stack } from "@supabase/stack/effect"; import { vi } from "vitest"; // Keep reserved `.invalid` fixture hosts from depending on ambient DNS/TCP timing. @@ -41,10 +42,12 @@ import { } from "./global-flags.ts"; import { DebugLogger } from "./debug-logger.service.ts"; import { identityStitchLayer } from "./identity-stitch.ts"; -import { dbConfigLayer } from "./db-config.layer.ts"; +import { dbConfigLayer, dbConfigResolverLayer } from "./db-config.layer.ts"; import { DbConfigResolver } from "./db-config.service.ts"; import type { DbConfigFlags } from "./db-config.types.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; +import { StackApi } from "./stack-api.ts"; +import { stackBackendLayer } from "./stack-backend.ts"; // `--local` / `--db-url` never touch the Management API stack, so the resolver // builds with simple ambient stubs. The `--linked` sub-flow (login-role, @@ -66,6 +69,7 @@ function buildResolver( readonly poolerHost?: string; readonly dbConnection?: Layer.Layer; readonly configEnv?: Record; + readonly stackApi?: Layer.Layer; } = {}, ) { const deps = Layer.mergeAll( @@ -102,7 +106,9 @@ function buildResolver( ), ), ); - return dbConfigLayer.pipe(Layer.provide(deps)); + return opts.stackApi === undefined + ? dbConfigLayer.pipe(Layer.provide(deps)) + : dbConfigResolverLayer.pipe(Layer.provide(Layer.merge(deps, opts.stackApi))); } function withWorkdir(toml?: string) { @@ -306,6 +312,22 @@ describe("dbConfigResolver (local + db-url)", () => { ); }); + it.effect("db-url mode: a multi-host url stays remote even when its primary is local", () => { + const dir = withWorkdir(); + return resolve( + dir, + dbUrlFlags("postgres://postgres:pw@127.0.0.1:54322,db.example.com:5432/postgres"), + ).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn.fallbacks).toEqual([{ host: "db.example.com", port: 5432 }]); + expect(r.isLocal).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("db-url mode: a passwordless local url fills the password from config", () => { const dir = withWorkdir(["[db]", "port = 54322", 'password = "hunter2"', ""].join("\n")); return resolve(dir, dbUrlFlags("postgres://postgres@127.0.0.1:54322/postgres")).pipe( @@ -397,6 +419,195 @@ describe("dbConfigResolver (local + db-url)", () => { ); }); +describe("dbConfigResolver (db-url under the stack backend)", () => { + const STACK_SQL_PORT = 54329; + const stackUrl = (port: number) => + `postgresql://supabase_admin:postgres@127.0.0.1:${port}/postgres?connect_timeout=10`; + + type StackState = "running" | "stopped" | "unregistered"; + + const projectStackApi = (root: string, state: StackState) => { + const unused = Effect.die("unused by the resolver"); + const database: DatabaseInstance = { + id: "database-primary", + service: "database", + start: unused, + ready: unused, + stop: unused, + restart: () => unused, + destroy: unused, + prepare: unused, + status: Effect.succeed({ + id: "database-primary", + endpoints: + state === "running" + ? [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: STACK_SQL_PORT }] + : [], + config: { + service: "database", + config: { + version: "17.6.1.173", + databasePassword: Redacted.make("stack-password"), + jwtSecret: Redacted.make("secret"), + jwtExpiry: 3600, + }, + endpoints: {}, + }, + lifecycle: state === "running" ? "running" : "stopped", + health: state === "running" ? "healthy" : undefined, + error: undefined, + cleanupError: undefined, + exit: undefined, + currentOperation: undefined, + launchId: undefined, + intentRevision: 0, + wakeEnabled: state === "running", + registered: true, + }), + followStatus: Stream.empty, + logs: Stream.empty, + credentials: () => unused, + saveSnapshot: () => unused, + restoreSnapshot: () => unused, + resetData: unused, + }; + const stack: Stack = { + id: "b".repeat(64), + services: { create: () => unused, get: () => Effect.succeed(database), list: unused }, + credentials: { get: unused }, + composition: { + plan: () => unused, + describe: Effect.succeed({ + members: [{ id: database.id, activation: "eager" }], + dependencies: [], + }), + supabase: () => unused, + configure: () => unused, + start: unused, + stop: unused, + restart: unused, + }, + stop: unused, + destroy: unused, + commands: { run: () => unused }, + }; + return Layer.succeed(StackApi, { + create: () => unused, + open: () => Effect.succeed(stack), + discover: () => unused, + find: () => + Effect.succeed( + state !== "unregistered" + ? Option.some({ + definition: { + id: stack.id, + identity: { projectRoot: root, branchContext: "main", stackName: "default" }, + runtime: "native" as const, + instances: [], + lifetime: "detached" as const, + composition: { members: [], dependencies: [] }, + ports: [], + }, + host: undefined, + }) + : Option.none(), + ), + }); + }; + + const resolveOnStack = ( + dir: string, + url: string, + stackApi: Layer.Layer = projectStackApi(dir, "running"), + ) => + resolve(dir, dbUrlFlags(url), { stackApi }).pipe( + Effect.provide(stackBackendLayer("stack")), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + + it.effect("treats the url printed by `stack status --env` as the local database", () => + Effect.gen(function* () { + const resolved = yield* resolveOnStack(withWorkdir(), stackUrl(STACK_SQL_PORT)); + expect(resolved.isLocal).toBe(true); + }), + ); + + it.effect("fills a passwordless stack url from the stack's credentials, not [db].password", () => + Effect.gen(function* () { + const dir = withWorkdir(["[db]", 'password = "config-password"', ""].join("\n")); + const resolved = yield* resolveOnStack( + dir, + `postgresql://postgres@127.0.0.1:${STACK_SQL_PORT}/postgres`, + ); + expect(resolved.isLocal).toBe(true); + expect(resolved.conn.password).toBe("stack-password"); + }), + ); + + it.effect("keeps a loopback url on another port remote so it still requires TLS", () => + Effect.gen(function* () { + const resolved = yield* resolveOnStack(withWorkdir(), stackUrl(STACK_SQL_PORT + 1)); + expect(resolved.isLocal).toBe(false); + }), + ); + + it.effect("fills from the stack's credentials when the stack port is also [db].port", () => + Effect.gen(function* () { + const dir = withWorkdir( + ["[db]", `port = ${STACK_SQL_PORT}`, 'password = "config-password"', ""].join("\n"), + ); + const resolved = yield* resolveOnStack( + dir, + `postgresql://postgres@127.0.0.1:${STACK_SQL_PORT}/postgres`, + ); + expect(resolved.isLocal).toBe(true); + expect(resolved.conn.password).toBe("stack-password"); + }), + ); + + for (const state of ["unregistered", "stopped"] as const) { + it.effect(`resolves the stack url as remote when the project stack is ${state}`, () => + Effect.gen(function* () { + const dir = withWorkdir(); + const resolved = yield* resolveOnStack( + dir, + stackUrl(STACK_SQL_PORT), + projectStackApi(dir, state), + ); + expect(resolved.isLocal).toBe(false); + }), + ); + } + + it.effect("keeps a multi-host url remote even when its primary is the stack endpoint", () => + Effect.gen(function* () { + const resolved = yield* resolveOnStack( + withWorkdir(), + `postgresql://postgres:pw@127.0.0.1:${STACK_SQL_PORT},db.example.com:5432/postgres`, + ); + expect(resolved.conn.fallbacks).toEqual([{ host: "db.example.com", port: 5432 }]); + expect(resolved.isLocal).toBe(false); + }), + ); + + it.effect("does not consult the stack for a non-loopback host", () => + Effect.gen(function* () { + const untouchedStackApi = Layer.succeed(StackApi, { + create: () => Effect.die("unexpected stack create"), + open: () => Effect.die("unexpected stack open"), + discover: () => Effect.die("unexpected stack discover"), + find: () => Effect.die("unexpected stack lookup"), + }); + const resolved = yield* resolveOnStack( + withWorkdir(), + `postgresql://postgres:pw@db.example.com:${STACK_SQL_PORT}/postgres`, + untouchedStackApi, + ); + expect(resolved.isLocal).toBe(false); + }), + ); +}); + describe("dbConfigResolver (linked config ordering)", () => { it.effect( "validates the ref-merged config before any network work (Go ParseDatabaseConfig order)", diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index de5d68d5da..90f101b602 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -80,6 +80,16 @@ function isLocalDatabase( return host === localHost && (port === dbPort || port === shadowPort); } +/** Stack databases listen on loopback, so only loopback or services-hostname URLs can match one. */ +function mayBeStackDatabaseHost(host: string, localHost: string): boolean { + return ( + host === localHost || + host === "localhost" || + host === "::1" || + (net.isIPv4(host) && host.startsWith("127.")) + ); +} + /** Best-effort TCP reachability probe with a 5s timeout. */ const tcpReachable = (host: string, port: number): Effect.Effect => Effect.callback((resume) => { @@ -379,7 +389,8 @@ export const resolveLinkedConn = Effect.fnUntraced(function* ( return poolerConn.value; }); -const dbConfigResolverLayer = Layer.effect( +/** The resolver without its `StackApi`, for callers that supply their own stack boundary. */ +export const dbConfigResolverLayer = Layer.effect( DbConfigResolver, Effect.gen(function* () { const cliSettings = yield* CommandSettings; @@ -456,6 +467,13 @@ const dbConfigResolverLayer = Layer.effect( ambientLayer; void _ambientCoverageCheck; + // `resolve`'s R is `never`, so capture StackApi at layer build. + const stackDatabaseConn = stackLocalDatabaseConn.pipe( + Effect.provideService(CommandSettings, cliSettings), + Effect.provideService(StackApi, stackApi), + Effect.provideService(Path.Path, path), + ); + const resolve = (flags: DbConfigFlags) => Effect.gen(function* () { const resolveVaultSecrets = flags.resolveVaultSecrets ?? true; @@ -492,21 +510,34 @@ const dbConfigResolverLayer = Layer.effect( }), ); } - const isLocal = isLocalDatabase( - conn.host, - localHost, - conn.port, - tomlValues.port, - tomlValues.shadowPort, - ); - // A local direct URL fills an empty password from the local `[db].password` config, - // so a passwordless local DSN like `postgresql://postgres@127.0.0.1:54322/postgres` + // A multi-host URL stays remote: local disables TLS for every fallback host as well. + const singleHost = conn.fallbacks === undefined; + const legacyLocal = + singleHost && + isLocalDatabase( + conn.host, + localHost, + conn.port, + tomlValues.port, + tomlValues.shadowPort, + ); + // The stack backend publishes its database on a runtime-assigned port rather than + // `[db].port`, so a URL naming the running stack's SQL endpoint is local too. + const stackConn = + singleHost && + (yield* currentStackBackend).kind === "stack" && + mayBeStackDatabaseHost(conn.host, localHost) + ? Option.getOrUndefined(yield* Effect.option(stackDatabaseConn)) + : undefined; + const onStack = stackConn?.host === conn.host && stackConn.port === conn.port; + const isLocal = legacyLocal || onStack; + // A local direct URL fills an empty password from the local database's own + // credentials, so a passwordless DSN like `postgresql://postgres@127.0.0.1:54322/postgres` // still authenticates. + const localPassword = onStack ? stackConn.password : tomlValues.password; return { conn: - isLocal && conn.password.length === 0 - ? { ...conn, password: tomlValues.password } - : conn, + isLocal && conn.password.length === 0 ? { ...conn, password: localPassword } : conn, isLocal, }; } @@ -575,13 +606,7 @@ const dbConfigResolverLayer = Layer.effect( }); const backend = yield* currentStackBackend; if (backend.kind === "stack") { - // `resolve`'s R is `never`, so capture StackApi at layer build. - const conn = yield* stackLocalDatabaseConn.pipe( - Effect.provideService(CommandSettings, cliSettings), - Effect.provideService(StackApi, stackApi), - Effect.provideService(Path.Path, path), - ); - return { conn, isLocal: true }; + return { conn: yield* stackDatabaseConn, isLocal: true }; } return { conn: { 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 de871c6fde..c92052d930 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -1,3 +1,4 @@ +import { normalizeDeprecatedOrioleDBVersion } from "@supabase/config/internal"; import { Config, Effect, Match, type FileSystem, Option, type Path } from "effect"; import * as SmolToml from "smol-toml"; import { @@ -61,8 +62,9 @@ export interface DbTomlValues { /** `[db] major_version`, default 17. */ readonly majorVersion: number; /** - * `[experimental] orioledb_version` (env-expanded). Set on a 15/17 project to - * rewrite the Postgres image to the OrioleDB tag; `None` for a vanilla project. + * `[db] orioledb_version` (env-expanded); the deprecated `[experimental] orioledb_version` is + * already promoted into this path by `normalizeDeprecatedOrioleDBVersion`. Set on a 15/17 + * project to rewrite the Postgres image to the OrioleDB tag; `None` for a vanilla project. */ readonly orioledbVersion: Option.Option; /** @@ -286,7 +288,7 @@ const ENV_OVERRIDABLE_KEYS = [ "analytics.gcp_project_id", "analytics.gcp_project_number", "analytics.gcp_jwt_path", - "experimental.orioledb_version", + "db.orioledb_version", "experimental.s3_host", "experimental.s3_region", "experimental.s3_access_key", @@ -1113,6 +1115,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } + // Same per-section promotion as the config loader, before the remote merge; the loader owns + // the deprecation warning. + doc = asRecord(normalizeDeprecatedOrioleDBVersion(doc).document); // Config load aborts when two `[remotes.*]` blocks share a `project_id`, // regardless of which command runs — check before merging. const duplicateRemote = findDuplicateRemoteProjectId(doc, lookup); @@ -1274,7 +1279,10 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // checks the four S3 fields below; the image rewrite itself happens in `resolveDbImage`. const expandString = (value: unknown): Option.Option => typeof value === "string" ? nonEmptyString(expandEnv(value, lookup)) : Option.none(); - const orioledbVersion = expandString(experimentalRaw?.["orioledb_version"]); + const orioledbVersionRaw = + (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)) { // 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. 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 105c2f31ac..ffa3a140d3 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 @@ -1926,14 +1926,14 @@ describe("readDbToml", () => { ); }); - it.effect("parses experimental.orioledb_version (env-expanded) on a 15/17 project", () => { + it.effect("parses db.orioledb_version (env-expanded) on a 15/17 project", () => { process.env["ORIOLE_VER"] = "16.0.0.1"; const dir = withConfig( [ "[db]", "major_version = 17", - "[experimental]", 'orioledb_version = "env(ORIOLE_VER)"', + "[experimental]", 's3_host = "s3.example.com"', 's3_region = "us-east-1"', 's3_access_key = "key"', @@ -1952,6 +1952,140 @@ describe("readDbToml", () => { ); }); + it.effect( + "falls back to a non-empty legacy experimental.orioledb_version when db.orioledb_version is absent", + () => { + const dir = withConfig( + [ + "[db]", + "major_version = 17", + "[experimental]", + 'orioledb_version = "15.1.0.150"', + "", + ].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.150"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "falls back to a non-empty legacy experimental.orioledb_version when db.orioledb_version is empty", + () => { + const dir = withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = ""', + "[experimental]", + 'orioledb_version = "15.1.0.150"', + "", + ].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.150"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect("prefers an explicit db.orioledb_version over a non-empty legacy value", () => { + const dir = withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "17.0.0.1"', + "[experimental]", + 'orioledb_version = "15.1.0.150"', + "", + ].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("17.0.0.1"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "a matched remote's legacy experimental.orioledb_version overrides the base db.orioledb_version", + () => { + // Matches `@supabase/config`'s loader precedence: each `[remotes.*]` block's own legacy + // value is promoted before the remote merge, so it can override the base canonical value. + const ref = "abcdefghijklmnopqrst"; + const dir = withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "A"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental]", + 'orioledb_version = "B"', + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "a matched remote's legacy experimental.orioledb_version still beats a conflicting SUPABASE_DB_ORIOLEDB_VERSION", + () => { + // Same precedence as any other `ENV_OVERRIDABLE_KEYS` field (e.g. db.major_version): + // an explicit remote value beats its matching `SUPABASE_*` env override. + const ref = "abcdefghijklmnopqrst"; + const previous = process.env["SUPABASE_DB_ORIOLEDB_VERSION"]; + process.env["SUPABASE_DB_ORIOLEDB_VERSION"] = "env-value"; + const dir = withConfig( + [ + "[db]", + "major_version = 17", + 'orioledb_version = "A"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental]", + 'orioledb_version = "B"', + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.orioledbVersion)).toBe("B"); + }), + ), + 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 }); + }), + ), + ); + }, + ); + it.effect("warns (does not fail) for an unset S3 env on an OrioleDB project", () => { delete process.env["S3_KEY"]; const writes: Array = []; @@ -1964,8 +2098,8 @@ describe("readDbToml", () => { [ "[db]", "major_version = 15", - "[experimental]", 'orioledb_version = "15.1.0.55"', + "[experimental]", 's3_access_key = "env(S3_KEY)"', "", ].join("\n"), @@ -1999,8 +2133,8 @@ describe("readDbToml", () => { [ "[db]", "major_version = 15", - "[experimental]", 'orioledb_version = "15.1.0.55"', + "[experimental]", 's3_access_key = "env(S3_KEY_QUIET)"', "", ].join("\n"), diff --git a/apps/cli/src/command-internal/db-image.ts b/apps/cli/src/command-internal/db-image.ts index 1747f84ace..a154f4aab2 100644 --- a/apps/cli/src/command-internal/db-image.ts +++ b/apps/cli/src/command-internal/db-image.ts @@ -1,7 +1,7 @@ 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 } from "../shared/services/slim-images.ts"; +import { slimImageForCurrentPin, slimImagesEnabled } from "../shared/services/slim-images.ts"; /** * Resolves the local Postgres Docker image for commands that run a pg_dump/shadow-DB container @@ -60,8 +60,8 @@ export const resolveDbImage = Effect.fnUntraced(function* ( majorVersion: number, orioledbVersion?: string, ) { - // OrioleDB override: on a 15/17 project with `experimental.orioledb_version` set, the Postgres - // image is replaced with the OrioleDB tag, taking precedence over the default/pinned image. + // 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 && @@ -73,6 +73,7 @@ export const resolveDbImage = Effect.fnUntraced(function* ( : `supabase/postgres:orioledb-${orioledbVersion}`; return { image, configImage: image }; } + const slim = yield* slimImagesEnabled; const currentRaw = postgresImageForDbMajorVersion(majorVersion) ?? pgImageRaw(); let appliedPin: string | undefined; if (majorVersion > 14) { @@ -96,7 +97,7 @@ export const resolveDbImage = Effect.fnUntraced(function* ( const configImage = appliedPin !== undefined ? replaceImageTag(currentRaw, appliedPin) : currentRaw; return { - image: slimImageForCurrentPin("pg", currentRaw, appliedPin), + image: slimImageForCurrentPin("pg", currentRaw, appliedPin, slim), configImage, }; }); diff --git a/apps/cli/src/command-internal/db-image.unit.test.ts b/apps/cli/src/command-internal/db-image.unit.test.ts index 72946b7efa..d66aabd1b2 100644 --- a/apps/cli/src/command-internal/db-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-image.unit.test.ts @@ -10,17 +10,17 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { - POSTGRES_FALLBACK_IMAGE_PG14, - POSTGRES_FALLBACK_IMAGE_PG15, - POSTGRES_FALLBACK_IMAGE_PG15_SLIM, -} from "../shared/services/services.shared.ts"; -import { imageTag, toSlimImage } from "../shared/services/slim-images.ts"; +import { imageTag } from "../shared/services/slim-images.ts"; +import { expectedPinnedImage, GHCR_SLIM_IMAGE_PATTERN } from "../../tests/helpers/slim-images.ts"; import { resolveDbImage } from "./db-image.ts"; const currentPostgres = dockerfileServiceImageRaw("pg"); const currentPostgresTag = imageTag(currentPostgres) ?? ""; -const pg15SlimTag = imageTag(POSTGRES_FALLBACK_IMAGE_PG15_SLIM) ?? ""; +// PG13/15 and PG14 now come from the Dockerfile's generated `pg15`/hand-pinned `pg14` stages +// (the single version table), not hardcoded fallback constants. +const pg15Image = dockerfileServiceImageRaw("pg15"); +const pg15Tag = imageTag(pg15Image) ?? ""; +const pg14Image = dockerfileServiceImageRaw("pg14"); const withTemp = () => mkdtempSync(join(tmpdir(), "db-image-")); @@ -49,19 +49,19 @@ describe("resolveDbImage", () => { const dir = withTemp(); return Effect.gen(function* () { expect(yield* resolve(dir, 13)).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG15, - configImage: POSTGRES_FALLBACK_IMAGE_PG15, + image: pg15Image, + configImage: pg15Image, }); expect(yield* resolve(dir, 14)).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG14, - configImage: POSTGRES_FALLBACK_IMAGE_PG14, + image: pg14Image, + configImage: pg14Image, }); expect(yield* resolve(dir, 15)).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG15, - configImage: POSTGRES_FALLBACK_IMAGE_PG15, + image: pg15Image, + configImage: pg15Image, }); expect(yield* resolve(dir, 17)).toEqual({ - image: dockerfileServiceImage("pg"), + image: dockerfileServiceImage("pg", false), configImage: currentPostgres, }); rmSync(dir, { recursive: true, force: true }); @@ -93,8 +93,8 @@ describe("resolveDbImage", () => { const dir = withTemp(); return Effect.gen(function* () { expect(yield* resolve(dir, 14, "16.0.0.1")).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG14, - configImage: POSTGRES_FALLBACK_IMAGE_PG14, + image: pg14Image, + configImage: pg14Image, }); rmSync(dir, { recursive: true, force: true }); }); @@ -106,24 +106,26 @@ describe("resolveDbImage", () => { const dir = withTemp(); return Effect.gen(function* () { expect(yield* resolve(dir, 14)).toEqual({ - image: POSTGRES_FALLBACK_IMAGE_PG14, - configImage: POSTGRES_FALLBACK_IMAGE_PG14, + image: pg14Image, + configImage: pg14Image, }); rmSync(dir, { recursive: true, force: true }); }); }); - it.effect("rewrites the current PG15 fallback to the slim registry", () => { + it.effect("rewrites the current PG15 default tag to its catalog-pinned slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = withTemp(); + const pinned = expectedPinnedImage("pg", pg15Image); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); return Effect.gen(function* () { expect(yield* resolve(dir, 15)).toEqual({ - image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), - configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + image: pinned, + configImage: pg15Image, }); expect(yield* resolve(dir, 13)).toEqual({ - image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), - configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + image: pinned, + configImage: pg15Image, }); rmSync(dir, { recursive: true, force: true }); }); @@ -142,14 +144,16 @@ describe("resolveDbImage", () => { }); }); - it.effect("rewrites a current PG15 pin to the slim registry", () => { + it.effect("rewrites a current PG15 pin to its catalog-pinned slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = withTemp(); - writePin(dir, pg15SlimTag); + writePin(dir, pg15Tag); + const pinned = expectedPinnedImage("pg", pg15Image); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); return Effect.gen(function* () { expect(yield* resolve(dir, 15)).toEqual({ - image: toSlimImage("pg", POSTGRES_FALLBACK_IMAGE_PG15_SLIM), - configImage: POSTGRES_FALLBACK_IMAGE_PG15_SLIM, + image: pinned, + configImage: pg15Image, }); rmSync(dir, { recursive: true, force: true }); }); @@ -168,13 +172,15 @@ describe("resolveDbImage", () => { }); }); - it.effect("rewrites the current Dockerfile pin to the slim registry", () => { + it.effect("rewrites the current Dockerfile pin to its catalog-pinned slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = withTemp(); writePin(dir, currentPostgresTag); + const pinned = expectedPinnedImage("pg", currentPostgres); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); return Effect.gen(function* () { expect(yield* resolve(dir, 17)).toEqual({ - image: toSlimImage("pg", currentPostgres), + image: pinned, configImage: currentPostgres, }); rmSync(dir, { recursive: true, force: true }); diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 25104e1d09..455a000c2e 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -46,7 +46,11 @@ import { import { diffMigra } from "../commands/db/shared/migra.ts"; import { writePgDeltaMigrations } from "../commands/db/shared/pgdelta-migrations.write.ts"; import { type DumpOptions, buildSchemaDumpEnv } from "./pg-dump.env.ts"; -import { streamPgDumpWithClient } from "./pg-dump.run.ts"; +import { dumpNetworkMode, streamPgDumpWithClient } from "./pg-dump.run.ts"; +import { + rewriteDumpHostForToolContainer, + toolContainerUsesHostNetwork, +} from "./postgres-client.run.ts"; import { emitPoolerFallbackWarning, isDirectLinkedHost, @@ -492,6 +496,24 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( message: `failed to open dump file: ${cause.message}`, fileOpen: true, }); + const stackBackend = (yield* currentStackBackend).kind === "stack"; + const seedNetwork = dumpNetworkMode( + Option.getOrUndefined(networkIdFlag), + stackBackend, + projectEnv, + ); + const seedUsesHostNetwork = + seedNetwork._tag === "host" || toolContainerUsesHostNetwork(seedNetwork.name); + const seedDumpConn = (target: PgConnInput): PgConnInput => + stackBackend || !seedUsesHostNetwork + ? { + ...target, + host: rewriteDumpHostForToolContainer(target.host, { + platform: runtimeInfo.platform, + usesHostNetwork: seedUsesHostNetwork, + }), + } + : target; // Stream pg_dump → migration file, (re)truncating per attempt so a pooler // retry leaves only the successful attempt's bytes. const runSchemaDump = (target: PgConnInput) => { @@ -513,9 +535,10 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( return yield* streamPgDumpWithClient({ image, script: dumpSchemaScript, - env: buildSchemaDumpEnv(target, dumpEnvOpt), + env: buildSchemaDumpEnv(seedDumpConn(target), dumpEnvOpt), projectEnvValues: projectEnv, client: { kind: "container" }, + forceHostNetwork: stackBackend, onStdout: (chunk) => { if (chunk.length > 0) seedWroteBytes = true; return file.writeAll(chunk).pipe( @@ -782,8 +805,8 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( } // Prompt to update the remote migration history table. Returns the default - // (`true`) on `--yes`, on a non-interactive stdin, or on any prompt error — it - // never fails the command. + // (`true`) on `--yes`, on an empty non-interactive stdin, or on any prompt error — + // it never fails the command. let remoteHistoryUpdated = false; const updateHistoryTitle = "Update remote migration history table?"; // `invoke?.assumeYes` overrides this resolution entirely for an in-process diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index 74c8f86113..59a2a106ed 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -9,6 +9,8 @@ * skipped during the scan, so e.g. `--schema --linked` does not misdetect `--linked` as changed. */ +import { GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES } from "../commands/gen/types/types.languages.ts"; + export type DbConnType = "db-url" | "linked" | "local"; export interface DbTargetSelection { @@ -108,7 +110,7 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "source", "status", "sub", - "swift-access-control", + ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, "tail", "template", "timestamp", diff --git a/apps/cli/src/command-internal/edge-runtime-image.ts b/apps/cli/src/command-internal/edge-runtime-image.ts index 3f602bdc0b..2f1c29d222 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.ts @@ -4,7 +4,7 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; +import { slimImageForCurrentPin, slimImagesEnabled } from "../shared/services/slim-images.ts"; /** * Resolves the edge-runtime Docker image: the default tag comes from the Dockerfile image, a @@ -12,9 +12,10 @@ import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; * `edge_runtime.deno_version = 1` selects the legacy `deno1` image instead. */ -// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is -// observed by the resolver (and by tests that stub the env). -export const edgeRuntimeDockerfileImage = () => dockerfileServiceImage("edgeruntime"); +// Read per run, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is observed. +export const edgeRuntimeDockerfileImage = Effect.map(slimImagesEnabled, (slim) => + dockerfileServiceImage("edgeruntime", slim), +); // Used when `deno_version = 1`. No slim build exists for it, so it stays on docker.io regardless // of the flag — the same exception `edgeRuntimeImage` (`shared/functions/functions.shared.ts`) // applies for the functions Docker paths reading the same pin file. @@ -44,5 +45,10 @@ export const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( if (pinned === DENO1_EDGE_RUNTIME_VERSION) { return EDGE_RUNTIME_DENO1_IMAGE; } - return slimImageForCurrentPin("edgeruntime", raw, pinned.length > 0 ? pinned : undefined); + return slimImageForCurrentPin( + "edgeruntime", + raw, + pinned.length > 0 ? pinned : undefined, + yield* slimImagesEnabled, + ); }); diff --git a/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts b/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts index 458531b686..dcb6df4838 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.unit.test.ts @@ -10,7 +10,8 @@ import { dockerfileServiceImage, dockerfileServiceImageRaw, } from "../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../shared/services/slim-images.ts"; +import { expectedPinnedImage, GHCR_SLIM_IMAGE_PATTERN } from "../../tests/helpers/slim-images.ts"; +import { slimImagesEnabled } from "../shared/services/slim-images.ts"; import { resolveEdgeRuntimeImage } from "./edge-runtime-image.ts"; const currentEdgeRuntime = dockerfileServiceImageRaw("edgeruntime"); @@ -26,10 +27,10 @@ const resolve = (workdir: string, denoVersion: number) => describe("resolveEdgeRuntimeImage", () => { it.effect("returns the edge-runtime image from the Dockerfile when nothing is pinned", () => { const dir = mkdtempSync(join(tmpdir(), "edge-img-")); - return resolve(dir, 2).pipe( - Effect.tap((image) => + return Effect.zip(resolve(dir, 2), slimImagesEnabled).pipe( + Effect.tap(([image, slim]) => Effect.sync(() => { - expect(image).toBe(dockerfileServiceImage("edgeruntime")); + expect(image).toBe(dockerfileServiceImage("edgeruntime", slim)); rmSync(dir, { recursive: true, force: true }); }), ), @@ -82,7 +83,7 @@ describe("resolveEdgeRuntimeImage", () => { ); }); - it.effect("rewrites the current Dockerfile pin onto the slim base", () => { + it.effect("rewrites the current Dockerfile pin to its catalog-pinned slim image", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const dir = mkdtempSync(join(tmpdir(), "edge-img-")); mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); @@ -90,10 +91,12 @@ describe("resolveEdgeRuntimeImage", () => { join(dir, "supabase", ".temp", "edge-runtime-version"), `${currentEdgeRuntimeTag}\n`, ); + const pinned = expectedPinnedImage("edgeruntime", currentEdgeRuntime); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); return resolve(dir, 2).pipe( Effect.tap((image) => Effect.sync(() => { - expect(image).toBe(toSlimImage("edgeruntime", currentEdgeRuntime)); + expect(image).toBe(pinned); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts index 8a3f1b4bee..1b163a63d6 100644 --- a/apps/cli/src/command-internal/pg-dump.native.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.native.integration.test.ts @@ -2,7 +2,8 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { FetchHttpClient } from "effect/unstable/http"; import { Effect, FileSystem, Layer, Option, Redacted, Ref, Stream } from "effect"; -import { postgres, type ServiceCreation } from "@supabase/stack/effect"; +import { type ServiceCreation } from "@supabase/stack/effect"; +import { postgres } from "@supabase/stack/commands"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { streamPgDumpWithClient } from "./pg-dump.run.ts"; @@ -11,7 +12,7 @@ import { DockerRun } from "./docker-run.service.ts"; import { BundledPostgresClient } from "./bundled-postgres-client.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { mockOutput } from "../../tests/helpers/mocks.ts"; -import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); @@ -58,7 +59,7 @@ describe("managed pg_dump against a live stack", { timeout: 180_000 }, () => { PGPASSWORD: decodeURIComponent(connection.password), PGDATABASE: connection.pathname.slice(1), }; - const setupResult = yield* stack.tools.run(postgres.psql({ major: 17 }), { + const setupResult = yield* stack.commands.run(postgres.psql({ major: 17 }), { args: ["-X", "-v", "ON_ERROR_STOP=1"], env, stdin: Stream.make( diff --git a/apps/cli/src/command-internal/pg-dump.run.integration.test.ts b/apps/cli/src/command-internal/pg-dump.run.integration.test.ts index aef3605340..a2a35a8118 100644 --- a/apps/cli/src/command-internal/pg-dump.run.integration.test.ts +++ b/apps/cli/src/command-internal/pg-dump.run.integration.test.ts @@ -12,7 +12,7 @@ import { mockOutput } from "../../tests/helpers/mocks.ts"; const stackFixture = (output: string) => { const calls: Array<{ readonly args: ReadonlyArray; readonly command: string }> = []; const stack = { - tools: { + commands: { run: ( _tool: { readonly command: string }, options: { diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index ddddeee07f..8e50a6cf6c 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -1,5 +1,6 @@ import { Effect, Option } from "effect"; -import { postgres, type Stack } from "@supabase/stack/effect"; +import type { Stack } from "@supabase/stack/effect"; +import { postgres } from "@supabase/stack/commands"; type StackRuntimePreference = | { readonly kind: "native" } | { readonly kind: "container"; readonly engine: "docker" | "podman" }; @@ -104,7 +105,8 @@ export const pgDumpClientExitMessage = (client: PgDumpClient, exitCode: number): ? `error running ${client.command}: exit ${exitCode}` : `error running container: exit ${exitCode}`; -const dumpNetworkMode = ( +/** Network for a pg_dump tool container; host unless `--network-id` or `SUPABASE_NETWORK_ID` names one. */ +export const dumpNetworkMode = ( networkId: string | undefined, forceHostNetwork: boolean, projectEnvValues: Readonly>, @@ -294,7 +296,7 @@ export const streamPgDumpWithClient = Effect.fn("streamPgDumpWithClient")(functi const emit = stackDumpStdout(params.script, params.env, params.onStdout); if (params.script.includes("--data-only")) yield* params.onStdout(new TextEncoder().encode("SET session_replication_role = replica;\n")); - const result = yield* params.client.stack.tools.run( + const result = yield* params.client.stack.commands.run( params.client.command === "pg_dump" ? postgres.pgDump({ major: params.client.major }) : postgres.pgDumpAll({ major: params.client.major }), diff --git a/apps/cli/src/command-internal/pgdelta.paths.ts b/apps/cli/src/command-internal/pgdelta.paths.ts index f63d028826..f4f10f139c 100644 --- a/apps/cli/src/command-internal/pgdelta.paths.ts +++ b/apps/cli/src/command-internal/pgdelta.paths.ts @@ -31,5 +31,5 @@ export function shadowBaselineCacheDir( env: Readonly> = process.env, homeDir: string = homedir(), ): string { - return path.join(resolveSupabaseHome(env, homeDir), "cache", "shadow-baseline"); + return path.join(resolveSupabaseHome(path, env, homeDir), "cache", "shadow-baseline"); } diff --git a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts index 489fb65380..b32a0f8444 100644 --- a/apps/cli/src/command-internal/pooler-fallback.unit.test.ts +++ b/apps/cli/src/command-internal/pooler-fallback.unit.test.ts @@ -37,7 +37,7 @@ function captureOutput() { fail: () => Effect.void, info: () => Effect.void, cancel: () => Effect.void, - clear: () => Effect.void, + clear: Effect.void, }), promptText: () => Effect.die("unexpected promptText"), promptPassword: () => Effect.die("unexpected promptPassword"), diff --git a/apps/cli/src/command-internal/postgres-url.ts b/apps/cli/src/command-internal/postgres-url.ts index a489f8cd0a..02aa1b22ef 100644 --- a/apps/cli/src/command-internal/postgres-url.ts +++ b/apps/cli/src/command-internal/postgres-url.ts @@ -46,6 +46,21 @@ export interface PostgresUrlInput { readonly runtimeParams?: Readonly>; } +export interface UserFacingDatabaseUrlInput { + readonly host: string; + readonly port: number; + readonly password: string; +} + +/** + * Builds the `postgres`-role connection string shown to users (stack summary, `db url` + * commands), without the tool-only query parameters {@link toPostgresURL} adds. + */ +export function toUserFacingDatabaseUrl(conn: UserFacingDatabaseUrlInput): string { + const host = isIPv6Host(conn.host) ? `[${conn.host}]` : conn.host; + return `postgresql://postgres:${encodeURIComponent(conn.password)}@${host}:${conn.port}/postgres`; +} + export function toPostgresURL(conn: PostgresUrlInput): string { const timeout = conn.connectTimeoutSeconds !== undefined && conn.connectTimeoutSeconds > 0 diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 3c7cd8f5c4..aa1aca15e3 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -140,7 +140,7 @@ export const projectCreateCore = Effect.fnUntraced(function* (input: ProjectCrea } const created = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; const id = readProjectField(created, "id"); diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 5a032a9270..6ba150f089 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -196,7 +196,7 @@ export function resolveConfigTarget { const s = input.trim().toLowerCase(); @@ -26,8 +26,9 @@ export const parseYesNo = (input: string): boolean | undefined => { * `yes` echoes an affirmative answer and returns `true` immediately; non-text output * uses the default silently unless the caller opts into machine-mode piped answers; * a real interactive text TTY prompts via clack; otherwise (including text callers with - * `interactive: false`) it reads one line via the shared `Stdin` reader, falling back to - * the default only when the line is empty or unparseable. + * `interactive: false`) it reads one line via the shared `Stdin` reader: a parsed answer + * wins and an empty line takes the default. Any other line declines, except under + * `interactive: false`, where it takes the default. */ export const promptYesNo = Effect.fnUntraced(function* ( output: typeof Output.Service, @@ -52,19 +53,14 @@ export const promptYesNo = Effect.fnUntraced(function* ( // Text `interactive: false` still prints the label and reads one line instead of // silently returning the default — it uses the same non-TTY read path below. if (!interactive || !tty.stdinIsTty) { - // A parsed piped answer wins; an empty or unparseable line falls back to the default. yield* output.raw(`${label} [${choices}] `, "stderr"); const stdin = yield* Stdin; const line = yield* stdin.readLine(NON_TTY_TIMEOUT_MILLIS); const input = Option.getOrElse(line, () => ""); - yield* output.raw(`${input}\n`, "stderr"); - if (input.length > 0) { - const answer = parseYesNo(input); - if (answer !== undefined) { - return answer; - } - } - return defaultValue; + yield* output.raw(`${input.trim()}\n`, "stderr"); + // An unrecognised answer is never consent; under `interactive: false` the line may be + // the caller's own script text, so it keeps the default. + return parseYesNo(input) ?? (interactive && input.length > 0 ? false : defaultValue); } return yield* output .promptConfirm(label, { defaultValue }) diff --git a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts index 9702f9f938..230e9b380d 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { Effect, Layer, Option, Stream } from "effect"; -import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../tests/helpers/mocks.ts"; import { Output } from "../shared/output/output.service.ts"; import { Stdin } from "../shared/runtime/stdin.service.ts"; @@ -107,3 +107,73 @@ describe("promptYesNo machine consent", () => { }); } }); + +describe("promptYesNo piped text answers", () => { + const ask = (piped: string, defaultValue: boolean, interactive = true) => + Effect.runPromise( + Effect.gen(function* () { + const output = yield* Output; + return yield* promptYesNo(output, false, "Confirm?", defaultValue, interactive); + }).pipe( + Effect.provide( + Layer.mergeAll( + mockOutput().layer, + mockStdin(false, piped), + mockTty({ stdinIsTty: false }), + ), + ), + ), + ); + + it.each(["u", "yess", "nope", " "])( + "declines the unrecognised answer %j whatever the default", + async (answer) => { + expect(await ask(`${answer}\n`, true)).toBe(false); + expect(await ask(`${answer}\n`, false)).toBe(false); + }, + ); + + it("keeps the default for an unrecognised line under interactive: false", async () => { + expect(await ask("echo next-step\n", true, false)).toBe(true); + expect(await ask("echo next-step\n", false, false)).toBe(false); + expect(await ask(" \n", true, false)).toBe(true); + }); + + it.each([true, false])("takes the default %j for an empty line or closed stdin", async (def) => { + for (const interactive of [true, false]) { + expect(await ask("\n", def, interactive)).toBe(def); + expect(await ask("", def, interactive)).toBe(def); + } + }); + + it("reads a blank line as present, then the next answer", async () => { + const lines = await Effect.runPromise( + Effect.gen(function* () { + const output = yield* Output; + const { readLine } = yield* Stdin; + const first = yield* promptYesNo(output, false, "Confirm?", true); + const second = yield* promptYesNo(output, false, "Confirm?", true); + return [first, second, yield* readLine(0), yield* readLine(0)]; + }).pipe( + Effect.provide( + Layer.mergeAll( + mockOutput().layer, + mockStdin(false, "\nn\n\n"), + mockTty({ stdinIsTty: false }), + ), + ), + ), + ); + expect(lines).toEqual([true, false, Option.some(""), Option.none()]); + }); + + it.each([ + ["y", true], + ["yes", true], + ["n", false], + ["no", false], + ] as const)("honours %j over the opposite default", async (answer, expected) => { + expect(await ask(`${answer}\n`, !expected)).toBe(expected); + expect(await ask(`${answer}\n`, !expected, false)).toBe(expected); + }); +}); diff --git a/apps/cli/src/command-internal/seed.ts b/apps/cli/src/command-internal/seed.ts index 191313c707..4c5eb12396 100644 --- a/apps/cli/src/command-internal/seed.ts +++ b/apps/cli/src/command-internal/seed.ts @@ -39,8 +39,8 @@ interface PendingSeed { } // Resolves `[db.seed].sql_paths` to existing files via the shared {@link sqlFilesGlob} -// traversal, printing a single `WARN: ` line for any glob problem — unlike the -// schema-files apply path, which only warns when no pattern matched anything at all. +// traversal, warning once with all joined glob problems — unlike the schema-files apply +// path, which only warns when no pattern matched anything at all. const resolveSeedFiles = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -50,7 +50,7 @@ const resolveSeedFiles = ( Effect.gen(function* () { const output = yield* Output; const { files, warnings } = yield* sqlFilesGlob(fs, path, patterns, workdir); - if (warnings.length > 0) yield* output.raw(`WARN: ${warnings.join("\n")}\n`, "stderr"); + if (warnings.length > 0) yield* output.warn(warnings.join("\n")); return files; }); diff --git a/apps/cli/src/command-internal/seed.unit.test.ts b/apps/cli/src/command-internal/seed.unit.test.ts index 2247f26e56..1863e6919f 100644 --- a/apps/cli/src/command-internal/seed.unit.test.ts +++ b/apps/cli/src/command-internal/seed.unit.test.ts @@ -66,8 +66,11 @@ describe("applySeedFiles seed glob", () => { Effect.tap(() => Effect.sync(() => { expect(queries.some((q) => q.sql.includes("seed_files"))).toBe(false); - expect(out.rawChunks.map((c) => c.text).join("")).toContain( - "no files matched pattern: missing\\.sql", + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: "no files matched pattern: missing\\.sql", + }), ); rmSync(dir, { recursive: true, force: true }); }), diff --git a/apps/cli/src/command-internal/shell-quote.ts b/apps/cli/src/command-internal/shell-quote.ts new file mode 100644 index 0000000000..44f4a56147 --- /dev/null +++ b/apps/cli/src/command-internal/shell-quote.ts @@ -0,0 +1,17 @@ +/** Shell family a printed command is rendered for. */ +export type ShellPlatform = "posix" | "windows"; + +export const currentShellPlatform = (): ShellPlatform => + process.platform === "win32" ? "windows" : "posix"; + +const BARE_SAFE_ARGUMENT = /^[a-zA-Z0-9_./:@%+=,-]+$/; + +/** Quotes one argument so a printed command can be pasted into the given shell as-is. */ +export function shellQuoteArgument(value: string, platform: ShellPlatform): string { + if (BARE_SAFE_ARGUMENT.test(value)) return value; + // PowerShell single-quoted strings escape a quote by doubling it; POSIX + // shells need the classic '"'"' dance. + return platform === "windows" + ? `'${value.replaceAll("'", "''")}'` + : `'${value.replaceAll("'", `'"'"'`)}'`; +} diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index 0f3fb1665c..66c7a71742 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -40,6 +40,9 @@ function endsWithKeyword(data: string, keyword: string): boolean { const isSqlWhitespace = (rune: string): boolean => " \t\n\r\f\v".includes(rune); +// scan.l `newline`: a `--` comment ends at either. +const isNewline = (rune: string): boolean => rune === "\n" || rune === "\r"; + function isBeginAtomic(data: string): boolean { if (!endsWithKeyword(data, BEGIN_ATOMIC)) return false; let end = data.length - BEGIN_ATOMIC.length; @@ -53,9 +56,9 @@ function isCommentsAndWhitespace(text: string): boolean { if (isSqlWhitespace(text[i]!)) { i += 1; } else if (text.startsWith("--", i)) { - const newline = text.indexOf("\n", i + 2); + const newline = text.slice(i + 2).search(/[\n\r]/u); if (newline === -1) return true; - i = newline + 1; + i += newline + 3; } else if (text.startsWith("/*", i)) { // Match `BlockState`'s sliding-window scan so both agree on overlapping delimiters. let depth = 1; @@ -85,8 +88,12 @@ class ReadyState implements State { return new TagState(offset); } case "'": + // `E'…'` is an escape string constant only when the `E` starts a token (scan.l + // `xestart`); in `type'…'` it ends an identifier. A digit or `$` before the `E` + // counts as one too, unlike PostgreSQL; valid SQL never has that. + return new QuoteState(rune, endsWithKeyword(data.slice(0, -1), "E")); case '"': - return new QuoteState(rune); + return new QuoteState(rune, false); case "-": return new CommentState(); case "/": @@ -109,12 +116,17 @@ class ReadyState implements State { class CommentState implements State { next(rune: string, data: string): State | null { - // A line comment escapes nothing until the newline — same shape as a dollar quote. - if (rune === "-") return new DollarState("\n"); + if (rune === "-") return new LineCommentState(); return new ReadyState().next(rune, data); } } +class LineCommentState implements State { + next(rune: string): State { + return isNewline(rune) ? new ReadyState() : this; + } +} + class BlockState implements State { private depth = 0; next(rune: string, data: string): State | null { @@ -134,7 +146,11 @@ class BlockState implements State { class QuoteState implements State { private escape = false; - constructor(private readonly delimiter: string) {} + private backslash = false; + constructor( + private readonly delimiter: string, + private readonly backslashEscapes: boolean, + ) {} next(rune: string, data: string): State | null { if (this.escape) { // Preserve a doubled quote ('' or ""). @@ -142,13 +158,51 @@ class QuoteState implements State { this.escape = false; return this; } + if (this.backslashEscapes) return new QuoteContinueState().next(rune, data); return new ReadyState().next(rune, data); } + if (this.backslash) { + // Preserve the rune after a backslash (\' or \\). + this.backslash = false; + return this; + } + if (this.backslashEscapes && rune === "\\") { + this.backslash = true; + return this; + } if (rune === this.delimiter) this.escape = true; return this; } } +// After an escape string's closing quote, whitespace holding a newline and then a quote +// continues the same literal (scan.l `quotecontinue`); `--` comments count as whitespace. +class QuoteContinueState implements State { + private newline = false; + private dashes = 0; + next(rune: string, data: string): State | null { + if (this.dashes === 2) { + if (isNewline(rune)) { + this.dashes = 0; + this.newline = true; + } + return this; + } + if (rune === "-") { + this.dashes += 1; + return this; + } + if (this.dashes === 0) { + if (isSqlWhitespace(rune)) { + this.newline ||= isNewline(rune); + return this; + } + if (this.newline && rune === "'") return new QuoteState(rune, true); + } + return new ReadyState().next(rune, data); + } +} + class DollarState implements State { constructor(private readonly delimiter: string) {} next(_rune: string, data: string): State | null { diff --git a/apps/cli/src/command-internal/sql-split.unit.test.ts b/apps/cli/src/command-internal/sql-split.unit.test.ts index d8b1febd22..b904d19e85 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { findDropStatements, splitAndTrim, splitSql } from "./sql-split.ts"; +import { findDropStatements, splitAndTrim, splitSql, splitSqlTokens } from "./sql-split.ts"; describe("splitAndTrim", () => { it("splits simple statements and trims trailing ; + whitespace", () => { @@ -23,6 +23,85 @@ describe("splitAndTrim", () => { expect(splitAndTrim("SELECT 'a''; b'; SELECT 2")).toEqual(["SELECT 'a''; b'", "SELECT 2"]); }); + it.each([ + String.raw`SELECT E'it\'s; here'`, + String.raw`SELECT e'it\'s; here'`, + String.raw`E'it\'s; here'`, + String.raw`SELECT (E'a\'; b'),E'c\'; d',1=E'e\'; f'`, + String.raw`SELECT E'a\\\'; b'`, + String.raw`SELECT E'a''\'; b'`, + String.raw`SELECT 'a'E'b\'; c'`, + String.raw`CREATE FUNCTION f() BEGIN ATOMIC SELECT E'a\'; END; b'; END`, + String.raw`SELECT $$a$$ /* b */E'c\'; d'`, + ])("keeps a backslash-escaped quote inside an escape string: %s", (statement) => { + expect(splitAndTrim(`${statement}; SELECT 2`)).toEqual([statement, "SELECT 2"]); + expect(splitSqlTokens(`${statement}; SELECT 2`).map((token) => token.trimmed)).toEqual([ + statement, + "SELECT 2", + ]); + }); + + it.each([String.raw`SELECT E'a\\'`, String.raw`SELECT E'a''; b'`, String.raw`SELECT E'a\\\\'`])( + "ends an escape string at its closing quote: %s", + (statement) => { + expect(splitAndTrim(`${statement}; SELECT 2`)).toEqual([statement, "SELECT 2"]); + }, + ); + + it("keeps an unterminated escape string ending in a backslash", () => { + const sql = String.raw`SELECT E'a; b` + "\\"; + expect(splitAndTrim(sql)).toEqual([sql]); + expect(splitSqlTokens(sql)).toEqual([{ raw: sql, trimmed: sql, terminated: false }]); + }); + + it.each([ + String.raw`SELECT 'a\'`, + String.raw`SELECT type'a\'`, + String.raw`SELECT éE'a\'`, + String.raw`SELECT 😀E'a\'`, + String.raw`SELECT _e'a\'`, + String.raw`SELECT U&'a\'`, + String.raw`SELECT 1 AS E"a\"`, + ])("keeps the backslash literal outside escape strings: %s", (statement) => { + expect(splitAndTrim(`${statement}; SELECT 2`)).toEqual([statement, "SELECT 2"]); + }); + + it.each([ + "SELECT E'first'\n'second\\'; third'", + "SELECT E'first'\r\n'second\\'; third'", + "SELECT E'first'\r'second\\'; third'", + "SELECT E'first' \t\v\f\n\n \v'second\\'; third'", + "SELECT E'a'\n'b'\n'c\\'; d'", + "SELECT E'a'''\n'b\\'; c'", + "SELECT E'a' -- note;\n -- more\n'b\\'; c'", + "SELECT E'a' -- note;\r'b\\'; c'", + "SELECT (E'a'\n'b\\'; c')", + "CREATE FUNCTION f() BEGIN ATOMIC SELECT E'a'\n'b\\'; END; c'; END", + ])("keeps an escape string continued on a later line together: %j", (statement) => { + expect(splitAndTrim(`${statement}; SELECT 2`)).toEqual([statement, "SELECT 2"]); + expect(splitSqlTokens(`${statement}; SELECT 2`).map((token) => token.trimmed)).toEqual([ + statement, + "SELECT 2", + ]); + }); + + it.each([ + ["SELECT E'a' 'b\\'; SELECT 2", ["SELECT E'a' 'b\\'", "SELECT 2"]], + ["SELECT E'a'\n/* x */'b\\'; SELECT 2", ["SELECT E'a'\n/* x */'b\\'", "SELECT 2"]], + ["SELECT E'a'\n-'b\\'; SELECT 2", ["SELECT E'a'\n-'b\\'", "SELECT 2"]], + ["SELECT E'a'\n- 'b\\'; SELECT 2", ["SELECT E'a'\n- 'b\\'", "SELECT 2"]], + ["SELECT E'a'\n\"b\\\"; SELECT 2", ["SELECT E'a'\n\"b\\\"", "SELECT 2"]], + [ + "SELECT E'a' -- c\r|| 'b'\n'c\\'; SELECT 'd', 'e;f'", + ["SELECT E'a' -- c\r|| 'b'\n'c\\'", "SELECT 'd', 'e;f'"], + ], + ["SELECT E'a';\n'b\\'; c'", ["SELECT E'a'", "'b\\'", "c'"]], + ["SELECT 'a'\n'b\\'; SELECT 2", ["SELECT 'a'\n'b\\'", "SELECT 2"]], + ])("starts a standard string when it is not a continuation: %j", (sql, statements) => { + expect(splitAndTrim(sql)).toEqual(statements); + expect(splitSqlTokens(sql).map((token) => token.trimmed)).toEqual(statements); + }); + it("does not split on a ; inside a dollar-quoted function body", () => { const sql = "CREATE FUNCTION f() RETURNS int AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql; SELECT 2;"; @@ -49,6 +128,13 @@ describe("splitAndTrim", () => { expect(splitAndTrim("SELECT 1 -- a; b\n; SELECT 2")).toEqual(["SELECT 1 -- a; b", "SELECT 2"]); }); + it.each(["E'a'", "'a'"])("ends a line comment after %s at a bare carriage return", (literal) => { + expect(splitAndTrim(`SELECT ${literal} -- a; b\r; SELECT 2`)).toEqual([ + `SELECT ${literal} -- a; b`, + "SELECT 2", + ]); + }); + it("ignores a ; inside a block comment (nested)", () => { expect(splitAndTrim("SELECT 1 /* a; /* n; */ b; */; SELECT 2")).toEqual([ "SELECT 1 /* a; /* n; */ b; */", @@ -106,6 +192,12 @@ describe("splitAndTrim", () => { expect(splitAndTrim(`${body}; SELECT 2;`)).toEqual([body, "SELECT 2"]); }); + it("does not close a BEGIN ATOMIC body at an END after a carriage-return-ended comment", () => { + const body = + "CREATE FUNCTION f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; -- c\rSELECT CASE WHEN true THEN 2 END; END"; + expect(splitAndTrim(`${body}; SELECT 3;`)).toEqual([body, "SELECT 3"]); + }); + it.each(["-- note END\n", "/* note; */ ", "\n/* a /* b; */ */ -- c\n"])( "closes a BEGIN ATOMIC body at an END preceded only by comments (%s)", (comment) => { diff --git a/apps/cli/src/command-internal/stack-api.ts b/apps/cli/src/command-internal/stack-api.ts index ed753d6071..4c09eff029 100644 --- a/apps/cli/src/command-internal/stack-api.ts +++ b/apps/cli/src/command-internal/stack-api.ts @@ -1,33 +1,32 @@ -import { Context, Crypto, Effect, FileSystem, Layer, Path } from "effect"; +import { Context, Crypto, Effect, FileSystem, Layer, Option, Path, Scope } from "effect"; import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { create, discover, + find, open, type CreateOptions, + type DestroyResult, + type FindOptions, + type FoundStack, type OpenOptions, type Stack, + type StackError, } from "@supabase/stack/effect"; -import { resolveStackIdentity } from "@supabase/stack/internal/identity"; type DiscoverResult = Effect.Success>; -type StackError = Effect.Error>; -type IdentityResult = Effect.Success>; -type IdentityError = Effect.Error>; -/** Operations used by the CLI stack boundary. */ +/** Operations used by the CLI stack boundary; a handle lasts until its scope closes. */ export class StackApi extends Context.Service< StackApi, { - readonly create: (options: CreateOptions) => Effect.Effect; - readonly open: (options: OpenOptions) => Effect.Effect; + readonly create: (options: CreateOptions) => Effect.Effect; + readonly open: (options: OpenOptions) => Effect.Effect; readonly discover: ( options: Parameters[0], ) => Effect.Effect; - readonly resolveIdentity: ( - options: Parameters[0], - ) => Effect.Effect; + readonly find: (options: FindOptions) => Effect.Effect, StackError>; } >()("supabase/stack/StackApi") {} @@ -56,17 +55,28 @@ export const stackApiLayer = Layer.effect( const discoverStacks = Effect.fn("StackApi.discover")( (options: Parameters[0]) => provideServices(discover(options)), ); - const resolveIdentity = Effect.fn("StackApi.resolveIdentity")( - (options: Parameters[0]) => - provideServices(resolveStackIdentity(options)), + const findStack = Effect.fn("StackApi.find")((options: FindOptions) => + provideServices(find(options)), ); return StackApi.of({ create: createStack, open: openStack, discover: discoverStacks, - resolveIdentity, + find: findStack, }); }), ).pipe(Layer.provide(FetchHttpClient.layer)); +/** Describes the engine resources a destroy left behind and the commands that remove them. */ +export const skippedRuntimeCleanupWarning = ( + subject: string, + result: Extract, +): string => { + const engineName = result.engine === "docker" ? "Docker" : "Podman"; + return [ + `${engineName} was unavailable, so ${engineName} resources for ${subject} were not removed. Once it is running, remove them with:`, + ...result.cleanupCommands.map((command) => ` ${command}`), + ].join("\n"); +}; + export type { Stack }; diff --git a/apps/cli/src/command-internal/stack-auth-config.ts b/apps/cli/src/command-internal/stack-auth-config.ts index a42ab69216..d5f818e5d9 100644 --- a/apps/cli/src/command-internal/stack-auth-config.ts +++ b/apps/cli/src/command-internal/stack-auth-config.ts @@ -5,6 +5,27 @@ import type { ResolvedAuthExternalProvider } from "./local-config-values.ts"; import { passwordRequirementsToChar } from "./password-requirements.ts"; type AuthConfig = Extract["config"]; +type AuthSettings = NonNullable; + +/** Maps every key of `Source`, at any depth, that `Target` does not declare to `never`. */ +type DeclaredKeys = + Source extends ReadonlyArray + ? ReadonlyArray< + DeclaredKeys ? TargetItem : never> + > + : Source extends object + ? { + readonly [K in keyof Source]: K extends keyof Target + ? DeclaredKeys> + : never; + } + : Source; + +/** Passes a config section through unchanged; a field the stack does not declare fails type-checking. */ +const passThrough = + () => + (section: Source & DeclaredKeys>): Target => + section; interface ResolvedAuthOptions { readonly authExternalUrl?: string; @@ -26,7 +47,6 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( options: ResolvedAuthOptions, ): Effect.Effect => Effect.succeed({ - databaseUrl: "postgresql://placeholder", siteUrl: auth.site_url, ...(options.apiExternalUrl === undefined ? {} : { apiExternalUrl: options.apiExternalUrl }), ...(options.authExternalUrl === undefined @@ -47,7 +67,7 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( ...(auth.jwt_issuer === undefined ? {} : { jwtIssuer: auth.jwt_issuer }), ...(options.passkeyEnabled === undefined ? {} : { passkeyEnabled: options.passkeyEnabled }), ...(options.webauthn === undefined ? {} : { webauthn: options.webauthn }), - rateLimit: auth.rate_limit, + rateLimit: passThrough()(auth.rate_limit), email: { enable_signup: auth.email.enable_signup, double_confirm_changes: auth.email.double_confirm_changes, @@ -69,11 +89,15 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( ]), ), }, - sms: auth.sms, - ...(auth.captcha === undefined ? {} : { captcha: auth.captcha }), - hooks: auth.hook, - mfa: auth.mfa, - ...(auth.sessions === undefined ? {} : { sessions: auth.sessions }), + sms: passThrough()(auth.sms), + ...(auth.captcha === undefined + ? {} + : { captcha: passThrough()(auth.captcha) }), + hooks: passThrough()(auth.hook), + mfa: passThrough()(auth.mfa), + ...(auth.sessions === undefined + ? {} + : { sessions: passThrough()(auth.sessions) }), external: Object.fromEntries( Object.entries(options.externalProviders).map(([name, provider]) => [ name, @@ -88,8 +112,8 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( }, ]), ), - web3: auth.web3, - oauthServer: auth.oauth_server, + web3: passThrough()(auth.web3), + oauthServer: passThrough()(auth.oauth_server), }, ...(auth.email.smtp?.enabled !== true ? localSmtp.enabled @@ -110,5 +134,5 @@ export const resolveAuthConfig = Effect.fn("StackAuthConfig.resolve")( : { senderName: auth.email.smtp.sender_name }), }, }), - }), + } satisfies AuthConfig), ); diff --git a/apps/cli/src/command-internal/stack-bootstrap.ts b/apps/cli/src/command-internal/stack-bootstrap.ts index 78dc1b3ee4..106f845702 100644 --- a/apps/cli/src/command-internal/stack-bootstrap.ts +++ b/apps/cli/src/command-internal/stack-bootstrap.ts @@ -6,20 +6,40 @@ import { parseConnectionString } from "./db-config.parse.ts"; import { DbConnection, type DbSession } from "./db-connection.service.ts"; import type { DbTomlValues } from "./db-config.toml-read.ts"; import { migrateAndSeed } from "./migrate-and-seed.ts"; +import { StackCatalogSetup, type StackCatalogSetupInput } from "./stack-catalog-setup.ts"; -/** Failure while applying the CLI-owned database bootstrap steps. */ +/** + * Failure while applying the CLI-owned database bootstrap steps: the database address is + * `unavailable`, the connection failed (`connect`), or a bootstrap statement failed (`apply`). + */ export class StackBootstrapError extends Data.TaggedError("StackBootstrapError")<{ + readonly reason: "unavailable" | "connect" | "apply"; readonly message: string; readonly cause?: unknown; }> {} +const bootstrapError = + (reason: StackBootstrapError["reason"]) => + (error: unknown): StackBootstrapError => + new StackBootstrapError({ + reason, + message: + typeof error === "object" && error !== null && "message" in error + ? String(error.message) + : String(error), + cause: error, + }); + const databaseConn = Effect.fn("StackBootstrap.databaseConnection")(function* ( database: DatabaseInstance, ) { const credentials = yield* database.credentials({ from: "host" }); const conn = parseConnectionString(credentials.databaseUrl ?? ""); if (conn === undefined) - return yield* new StackBootstrapError({ message: "failed to parse stack database URL" }); + return yield* new StackBootstrapError({ + reason: "unavailable", + message: "failed to parse stack database URL", + }); return conn; }); @@ -31,12 +51,16 @@ const withDatabaseSession = ( Effect.scoped( Effect.gen(function* () { const dbConn = yield* DbConnection; - const conn = yield* databaseConn(database); - const session = yield* dbConn.connect(user === undefined ? conn : { ...conn, user }, { - isLocal: true, - dnsResolver: "native", - }); - return yield* body(session); + const conn = yield* databaseConn(database).pipe( + Effect.catchTag("StackError", (cause) => Effect.fail(bootstrapError("unavailable")(cause))), + ); + const session = yield* dbConn + .connect(user === undefined ? conn : { ...conn, user }, { + isLocal: true, + dnsResolver: "native", + }) + .pipe(Effect.mapError(bootstrapError("connect"))); + return yield* body(session).pipe(Effect.mapError(bootstrapError("apply"))); }), ); @@ -52,28 +76,23 @@ export const applyStackWebhooksOnly = ( const tmpDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-stack-webhooks-" }); yield* applyDatabaseWebhooks(session, fs, path, tmpDir, webhooksEnabled); }), - ).pipe( - Effect.mapError( - (error) => - new StackBootstrapError({ - message: - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : String(error), - cause: error, - }), - ), ); -/** Applies migrations and seeds after the stack catalog has initialized the database. */ -export const applyStackMigrateAndSeed = ( - database: DatabaseInstance, - workdir: string, - toml: Pick< +/** Project migrations and seeds applied after the stack catalog. */ +interface StackMigrations { + readonly workdir: string; + readonly toml: Pick< DbTomlValues, "migrationsEnabled" | "seed" | "pgDelta" | "schemaPaths" | "webhooksEnabled" - >, - experimental: boolean, + >; + readonly experimental: boolean; + /** Applies migrations up to this version; omitted applies all of them. */ + readonly version?: string; +} + +const applyStackMigrateAndSeed = ( + database: DatabaseInstance, + migrations: StackMigrations, ): Effect.Effect< void, StackBootstrapError, @@ -85,25 +104,40 @@ export const applyStackMigrateAndSeed = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* migrateAndSeed(session, fs, path, workdir, "", { + const { toml } = migrations; + yield* migrateAndSeed(session, fs, path, migrations.workdir, migrations.version ?? "", { migrationsEnabled: toml.migrationsEnabled, seed: toml.seed, - experimental, + experimental: migrations.experimental, pgDeltaEnabled: toml.pgDelta.enabled, schemaPaths: toml.schemaPaths, localDatabaseWebhooksEnabled: toml.webhooksEnabled, }); }), "postgres", - ).pipe( - Effect.mapError( - (error) => - new StackBootstrapError({ - message: - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : String(error), - cause: error, - }), - ), ); + +/** The catalog overlay declared by a project's `config.toml`. */ +export const projectCatalogOverlay = ( + toml: Pick, + workdir: string, +): StackCatalogSetupInput["overlay"] => ({ + webhooks: "config", + webhooksEnabled: toml.webhooksEnabled, + apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, + vault: toml.vault, + workdir, +}); + +/** + * Initialises a started stack database: the catalog with its service schemas and overlay, then + * the project migrations and seeds when requested. + */ +export const initializeStackDatabase = Effect.fn("StackBootstrap.initializeDatabase")(function* ( + input: StackCatalogSetupInput & { readonly migrations?: StackMigrations }, +) { + const catalog = yield* StackCatalogSetup; + yield* catalog.apply({ target: input.target, overlay: input.overlay }); + if (input.migrations !== undefined) + yield* applyStackMigrateAndSeed(input.target.database, input.migrations); +}); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts index 9dc8afcce1..8601b9b809 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.integration.test.ts @@ -1,144 +1,270 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Redacted, Stream } from "effect"; +import { + Deferred, + Effect, + Exit, + FileSystem, + Fiber, + Layer, + Option, + Redacted, + Ref, + Result, + Stream, +} from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { tmpdir } from "node:os"; -import { create, postgres } from "@supabase/stack/effect"; +import { create, StackError, type Stack } from "@supabase/stack/effect"; +import { postgres } from "@supabase/stack/commands"; import { mockOutput } from "../../tests/helpers/mocks.ts"; +import type { Command } from "@supabase/stack/commands"; import { stackCatalogSetupLayer, StackCatalogSetup } from "./stack-catalog-setup.ts"; -import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const jwtSecret = "stack-catalog-setup-integration-secret"; describe("stack catalog setup", { timeout: 180_000 }, () => { for (const runtime of ["native", "docker"] as const) { - it.live(`initializes selected database services on a stopped ${runtime} composition`, () => { - const buildOutput = mockOutput(); - const callOutput = mockOutput(); - return Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-catalog-${runtime}-` }); - yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); - yield* fs.writeFileString( - `${root}/supabase/roles.sql`, - "CREATE TABLE IF NOT EXISTS public.catalog_overlay(owner uuid REFERENCES auth.users(id), value text NOT NULL);\n", - ); - const stack = yield* create({ - projectRoot: root, - stateRoot: `${root}/state`, - cacheRoot, - runtime, - }); - yield* Effect.acquireUseRelease( - Effect.succeed(stack), - (stack) => - Effect.gen(function* () { - const members = yield* stack.composition.supabase([ - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("postgres"), - jwtSecret: Redacted.make(jwtSecret), - jwtExpiry: 3600, + it.live( + `initializes service schemas without temporary services on a stopped ${runtime} composition`, + () => { + const buildOutput = mockOutput(); + const callOutput = mockOutput(); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-catalog-${runtime}-` }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/roles.sql`, + "CREATE TABLE IF NOT EXISTS public.catalog_overlay(owner uuid REFERENCES auth.users(id), session_id uuid REFERENCES auth.sessions(id), upload_id text REFERENCES storage.s3_multipart_uploads(id), value text NOT NULL);\n", + ); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot, + runtime, + }); + yield* Effect.acquireUseRelease( + Effect.succeed(stack), + (stack) => + Effect.gen(function* () { + const members = yield* stack.composition.supabase([ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, }, - endpoints: { sql: { port: "auto" } }, - }, - { - service: "auth", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, - endpoints: { http: { port: "auto" } }, - }, - { - service: "storage", - config: { - databaseUrl: "postgresql://placeholder", - jwtSecret, - filePath: `${root}/unused-storage`, + { + service: "auth", + config: { jwtSecret }, + endpoints: { http: { port: "auto" } }, }, - endpoints: { http: { port: "auto" } }, - }, - { - service: "realtime", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, - endpoints: { http: { port: "auto" }, rpc: { port: "auto" } }, - }, - ]); - const database = members.find((member) => member.service === "database"); - if (database === undefined) return yield* Effect.die("database member missing"); - yield* database.start; - yield* database.ready; - const runtimeCredentials = yield* database.credentials({ from: "runtime" }); - const databaseUrl = runtimeCredentials.databaseUrl; - if (databaseUrl === undefined) return yield* Effect.die("database URL missing"); - - const setup = yield* Effect.service(StackCatalogSetup); - yield* setup - .apply({ - target: { - stack, - database, - databaseServices: ["auth", "storage", "realtime"], + { + service: "storage", + config: { + jwtSecret, + filePath: `${root}/unused-storage`, + }, + endpoints: { http: { port: "auto" } }, }, - overlay: { - webhooks: "disabled", - webhooksEnabled: false, - apiAutoExposeNewTables: Option.none(), - vault: [], - workdir: root, - announceRoles: true, + { + service: "realtime", + config: { jwtSecret }, + endpoints: { http: { port: "auto" }, rpc: { port: "auto" } }, }, - }) - .pipe(Effect.provide(callOutput.layer)); - expect(callOutput.stderrText).toContain("Seeding globals from roles.sql..."); - const realtime = members.find((member) => member.service === "realtime"); - if (realtime === undefined) return yield* Effect.die("realtime member missing"); - yield* realtime.start; - yield* realtime.ready; - yield* realtime.stop; + ]); + const database = members.find((member) => member.service === "database"); + if (database === undefined) return yield* Effect.die("database member missing"); + yield* database.start; + yield* database.ready; + const runtimeCredentials = yield* database.credentials({ from: "runtime" }); + const databaseUrl = runtimeCredentials.databaseUrl; + if (databaseUrl === undefined) return yield* Effect.die("database URL missing"); + + const setup = yield* Effect.service(StackCatalogSetup); + yield* setup + .apply({ + target: { + stack, + database, + databaseServices: ["auth", "storage", "realtime"], + }, + overlay: { + webhooks: "disabled", + webhooksEnabled: false, + apiAutoExposeNewTables: Option.none(), + vault: [], + workdir: root, + announceRoles: true, + }, + }) + .pipe(Effect.provide(callOutput.layer)); + expect(callOutput.stderrText).toContain("Seeding globals from roles.sql..."); + const rows: Array = []; + const errors: Array = []; + const query = yield* stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--dbname", databaseUrl, "-At"], + stdin: Stream.make( + new TextEncoder().encode( + "select coalesce(to_regclass('auth.users')::text,'missing'), coalesce(to_regclass('auth.sessions')::text,'missing'), coalesce(to_regclass('storage.objects')::text,'missing'), coalesce(to_regclass('storage.s3_multipart_uploads')::text,'missing'), coalesce(to_regclass('realtime.messages')::text,'missing'), coalesce(to_regclass('realtime.subscription')::text,'missing'), coalesce(to_regclass('public.catalog_overlay')::text,'missing');", + ), + ), + stdout: (bytes) => + Effect.sync(() => rows.push(new TextDecoder().decode(bytes))), + stderr: (bytes) => + Effect.sync(() => errors.push(new TextDecoder().decode(bytes))), + }); + expect(query.exitCode, errors.join("")).toBe(0); + expect(rows.join("").trim()).toBe( + "users|sessions|storage.objects|storage.s3_multipart_uploads|realtime.messages|realtime.subscription|catalog_overlay", + ); - const rows: Array = []; - const errors: Array = []; - const query = yield* stack.tools.run(postgres.psql({ major: 17 }), { - args: ["--dbname", databaseUrl, "-At"], - stdin: Stream.make( - new TextEncoder().encode( - "select coalesce(to_regclass('auth.users')::text,'missing'), coalesce(to_regclass('storage.objects')::text,'missing'), coalesce(to_regclass('realtime.messages')::text,'missing'), coalesce(to_regclass('realtime.subscription')::text,'missing'), coalesce(to_regclass('public.catalog_overlay')::text,'missing');", + const credentials = yield* stack.credentials.get; + if (credentials === undefined) + return yield* Effect.die("stack credentials missing"); + const tenantJwks: Array = []; + const tenantJwksQuery = yield* stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--dbname", databaseUrl, "-At"], + stdin: Stream.make( + new TextEncoder().encode( + "SELECT jwt_jwks::text FROM _realtime.tenants WHERE external_id = 'realtime-dev';", + ), ), - ), - stdout: (bytes) => Effect.sync(() => rows.push(new TextDecoder().decode(bytes))), - stderr: (bytes) => - Effect.sync(() => errors.push(new TextDecoder().decode(bytes))), - }); - expect(query.exitCode, errors.join("")).toBe(0); - expect(rows.join("").trim()).toBe( - "users|storage.objects|realtime.messages|realtime.subscription|catalog_overlay", - ); + stdout: (bytes) => + Effect.sync(() => tenantJwks.push(new TextDecoder().decode(bytes))), + stderr: (bytes) => + Effect.sync(() => errors.push(new TextDecoder().decode(bytes))), + }); + expect(tenantJwksQuery.exitCode, errors.join("")).toBe(0); + expect(JSON.parse(tenantJwks.join("").trim())).toEqual( + JSON.parse(credentials.jwks), + ); + + const realtime = members.find((member) => member.service === "realtime"); + if (realtime === undefined) return yield* Effect.die("realtime member missing"); + yield* realtime.start; + yield* realtime.ready; + yield* realtime.stop; + + const listed = yield* stack.services.list; + expect(listed.map((instance) => instance.id).sort()).toEqual( + members.map((instance) => instance.id).sort(), + ); + for (const instance of listed) { + if (instance.service !== "database") + expect((yield* instance.status).lifecycle).toBe("stopped"); + } + + const runControlledSetup = Effect.fn("StackCatalogSetup.integration.controlled")( + function* (mode: "failure" | "interruption") { + const storageReady = yield* Deferred.make(); + const authReady = yield* Deferred.make(); + const releaseStorage = yield* Deferred.make(); + const releaseAuth = yield* Deferred.make(); + const temporaryDirectory = yield* Ref.make(undefined); + const controlledStack: Stack = { + ...stack, + commands: { + ...stack.commands, + run: (invocation: Command) => { + if (!("type" in invocation)) + return Effect.die("postgres command is not used in this fixture"); + if (invocation.type === "storage.initialize") + return Ref.set(temporaryDirectory, invocation.filePath).pipe( + Effect.andThen(Deferred.succeed(storageReady, undefined)), + Effect.andThen(Deferred.await(releaseStorage)), + Effect.as({ jobId: "controlled-storage", exitCode: 0 as const }), + ); + if (mode === "interruption") + return Deferred.succeed(authReady, undefined).pipe( + Effect.andThen(Deferred.await(releaseAuth)), + Effect.as({ jobId: "controlled-auth", exitCode: 0 as const }), + ); + return Deferred.await(storageReady).pipe( + Effect.andThen( + Effect.fail( + new StackError({ + operation: "initialize", + message: "controlled Auth initialization failure", + }), + ), + ), + ); + }, + }, + }; + const beforeOutput = callOutput.stderrText; + const run = setup + .apply({ + target: { + stack: controlledStack, + database, + databaseServices: ["auth", "storage"], + }, + overlay: { + webhooks: "disabled", + webhooksEnabled: false, + apiAutoExposeNewTables: Option.none(), + vault: [], + workdir: root, + announceRoles: true, + }, + }) + .pipe(Effect.provide(callOutput.layer)); + + if (mode === "failure") { + const result = yield* Effect.result(run); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) + expect(result.failure.message).toContain( + "controlled Auth initialization failure", + ); + } else { + const fiber = yield* Effect.forkScoped(run); + yield* Deferred.await(storageReady); + yield* Deferred.await(authReady); + yield* Fiber.interrupt(fiber); + const exit = yield* Fiber.await(fiber); + expect(Exit.hasInterrupts(exit)).toBe(true); + } + + expect(callOutput.stderrText).toBe(beforeOutput); + const temporaryPath = yield* Ref.get(temporaryDirectory); + if (temporaryPath === undefined) + return yield* Effect.die("temporary storage directory was not created"); + expect(yield* fs.exists(temporaryPath)).toBe(false); + const current = yield* stack.services.list; + expect(current.map((instance) => instance.id).sort()).toEqual( + members.map((instance) => instance.id).sort(), + ); + }, + ); - const listed = yield* stack.services.list; - expect(listed.map((instance) => instance.id).sort()).toEqual( - members.map((instance) => instance.id).sort(), - ); - for (const instance of listed) { - if (instance.service !== "database") - expect((yield* instance.status).lifecycle).toBe("stopped"); - } - }), - destroyTestStack, - ); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - FetchHttpClient.layer, - buildOutput.layer, - stackCatalogSetupLayer, + yield* runControlledSetup("failure"); + yield* runControlledSetup("interruption"); + }), + destroyTestStack, + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + FetchHttpClient.layer, + buildOutput.layer, + stackCatalogSetupLayer, + ), ), - ), - ); - }); + ); + }, + ); } }); diff --git a/apps/cli/src/command-internal/stack-catalog-setup.ts b/apps/cli/src/command-internal/stack-catalog-setup.ts index 01c152d8bf..714e683ac5 100644 --- a/apps/cli/src/command-internal/stack-catalog-setup.ts +++ b/apps/cli/src/command-internal/stack-catalog-setup.ts @@ -1,10 +1,6 @@ import { Context, Data, Effect, FileSystem, Layer, Path } from "effect"; -import { - type DatabaseInstance, - type ServiceCreationInput, - type ServiceInstance, - type Stack, -} from "@supabase/stack/effect"; +import { type DatabaseInstance, type Stack } from "@supabase/stack/effect"; +import { initialization, type InitializationCommand } from "@supabase/stack/commands"; import { Output } from "../shared/output/output.service.ts"; import { actionability, @@ -26,17 +22,21 @@ import type { MigrationVaultError, VaultSecret } from "./vault.ts"; const DATABASE_SERVICES = ["auth", "storage", "realtime"] as const; type DatabaseService = (typeof DATABASE_SERVICES)[number]; +const isDatabaseService = (service: string): service is DatabaseService => + DATABASE_SERVICES.some((candidate) => candidate === service); + +/** Selects the services whose database schemas the catalog provisions. */ +export const catalogDatabaseServices = ( + services: ReadonlyArray<{ readonly service: string }>, +): ReadonlyArray => + services.flatMap(({ service }) => (isDatabaseService(service) ? [service] : [])); + interface ServiceCredentials { readonly databaseUrl: string; readonly authDatabaseUrl: string; readonly storageDatabaseUrl: string; } -type TemporaryServiceInstance = - | ServiceInstance<"auth"> - | ServiceInstance<"storage"> - | ServiceInstance<"realtime">; - export class StackCatalogSetupError extends Data.TaggedError("StackCatalogSetupError")<{ readonly message: string; readonly cause?: unknown; @@ -78,16 +78,6 @@ type StackCatalogSetupFailure = | MigrationVaultError | DbConnectError; -const temporaryServiceError = ( - operation: "start" | "destroy", - instance: TemporaryServiceInstance, - cause: unknown, -): StackCatalogSetupError => - new StackCatalogSetupError({ - message: `temporary ${instance.service} service ${instance.id} failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`, - cause, - }); - const credential = ( credentials: Readonly>, name: string, @@ -98,55 +88,28 @@ const credential = ( : Effect.succeed(value); }; -const serviceDefinition = ( +const serviceCommand = ( service: DatabaseService, credentials: ServiceCredentials, storagePath: string, -): Extract => { +): InitializationCommand => { switch (service) { case "auth": - return { - service, - config: { - databaseUrl: credentials.authDatabaseUrl, - }, - endpoints: {}, - }; + return initialization.auth({ + databaseUrl: credentials.authDatabaseUrl, + }); case "storage": - return { - service, - config: { - databaseUrl: credentials.storageDatabaseUrl, - filePath: storagePath, - }, - endpoints: {}, - }; + return initialization.storage({ + databaseUrl: credentials.storageDatabaseUrl, + filePath: storagePath, + }); case "realtime": - return { - service, - config: { - databaseUrl: credentials.databaseUrl, - }, - endpoints: {}, - }; + return initialization.realtime({ + databaseUrl: credentials.databaseUrl, + }); } }; -const startTemporaryService = ( - instance: TemporaryServiceInstance, -): Effect.Effect => - instance.start.pipe( - Effect.andThen(instance.ready), - Effect.mapError((cause) => temporaryServiceError("start", instance, cause)), - ); - -const destroyTemporaryService = ( - instance: TemporaryServiceInstance, -): Effect.Effect => - instance.destroy.pipe( - Effect.mapError((cause) => temporaryServiceError("destroy", instance, cause)), - ); - const applyCatalog = Effect.fn("StackCatalogSetup.apply")(function* ( input: StackCatalogSetupInput, ) { @@ -177,14 +140,16 @@ const applyCatalog = Effect.fn("StackCatalogSetup.apply")(function* ( yield* Effect.forEach( input.target.databaseServices, (service) => - Effect.acquireUseRelease( - input.target.stack.services - .create(serviceDefinition(service, serviceCredentials, storagePath)) - .pipe(Effect.mapError(catalogError)), - startTemporaryService, - destroyTemporaryService, - ), - { discard: true }, + input.target.stack.commands + .run(serviceCommand(service, serviceCredentials, storagePath)) + .pipe( + Effect.asVoid, + Effect.mapError(catalogError), + Effect.withSpan("StackCatalogSetup.initializeCatalogService", { + attributes: { service, operation: "initialize" }, + }), + ), + { concurrency: "unbounded", discard: true }, ); const connection = parseConnectionString(hostDatabaseUrl); diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index 53455e45e3..c9a41e75f1 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -1,18 +1,23 @@ import { getDefaultCliConfig, type CliConfig } from "@supabase/config"; import { resolveCliConfigSubtree } from "@supabase/config/internal"; import { validateCliConfig } from "@supabase/config/effect"; -import { DEFAULT_SIGNING_KEY } from "@supabase/stack/defaults"; +import { + DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + DEFAULT_LOCAL_S3_REGION, + DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, + DEFAULT_SIGNING_KEY, +} from "@supabase/stack/defaults"; import { type ServiceCreationInput as ServiceCreationType } from "@supabase/stack/effect"; import { Crypto, Effect, Data, FileSystem, Path, Redacted, Schema, SchemaIssue } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { loadLocalProjectContext, type LocalProjectContext } from "./local-project-context.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { CLI_VERSION } from "../shared/cli/version.ts"; import { resolveAuthConfig } from "./stack-auth-config.ts"; import { parseGoDuration } from "./go-duration.ts"; import { parseFileSizeLimit } from "./storage-bucket-config.ts"; -declare const SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; import { decryptAuthSecret, resolveJwtSecret, @@ -78,7 +83,7 @@ interface StackStartConfig { readonly projectEnvValues: Readonly>; readonly document?: Record; readonly remoteJwks: Effect.Effect; - readonly identity: Effect.Effect< + readonly keys: Effect.Effect< { readonly publishableKey?: string; readonly secretKey?: string; @@ -375,14 +380,7 @@ const resolveEffectiveCliConfig = ( const mail = config.local_smtp; const pooler = db.pooler; const edge = config.edge_runtime; - const experimental = { - ...config.experimental, - orioledb_version: envOverride( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - config.experimental.orioledb_version, - env, - ), - }; + const experimental = config.experimental; const apiSchemasOverride = envOverride("SUPABASE_API_SCHEMAS", undefined, env); const apiExtraSearchPathOverride = envOverride("SUPABASE_API_EXTRA_SEARCH_PATH", undefined, env); const imageDocument = section(section(document, "storage"), "image_transformation"); @@ -666,6 +664,7 @@ const resolveEffectiveCliConfig = ( port: resolvedPort("SUPABASE_DB_PORT", db.port, "db.port", env), major_version: envOverrideMajorVersion(db.major_version, env), health_timeout: envOverride("SUPABASE_DB_HEALTH_TIMEOUT", db.health_timeout, env), + orioledb_version: envOverride("SUPABASE_DB_ORIOLEDB_VERSION", db.orioledb_version, env), settings: resolveDbSettingsEnvOverrides(db.settings, env), pooler: resolvedPooler, }, @@ -731,7 +730,7 @@ const unsupportedConfigPaths = [ { path: "analytics.gcp_jwt_path", active: (config: CliConfig) => config.analytics.enabled }, { path: "edge_runtime.deno_version", active: (config: CliConfig) => config.edge_runtime.enabled }, { path: "storage.analytics", active: (config: CliConfig) => config.storage.enabled }, - { path: "experimental.orioledb_version", active: (_config: CliConfig) => true }, + { path: "db.orioledb_version", active: (_config: CliConfig) => true }, ] as const; const pathValue = (value: unknown, path: string): unknown => { @@ -896,7 +895,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( message: cause instanceof Error ? cause.message : String(cause), }), }); - const localIdentity = Effect.try({ + const localKeys = Effect.try({ try: () => { const configured = (value: string | undefined) => value === undefined || value === "" @@ -955,8 +954,8 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ), ); }); - const identity = Effect.gen(function* () { - const configuredKeys = yield* localIdentity; + const keys = Effect.gen(function* () { + const configuredKeys = yield* localKeys; const refreshedRemoteJwks = yield* remoteJwks; return { ...configuredKeys, @@ -1133,29 +1132,15 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( const poolMode = validatedConfig.db.pooler.pool_mode === "session" ? ("session" as const) : "transaction"; const storagePath = `${projectRoot}/supabase/.temp/stack-uploads`; + const pgmetaCreation: ServiceCreationType = { + service: "pgmeta", + config: {}, + endpoints: { http: endpoint(undefined) }, + }; const createCreations = ( stackId: string, ): Effect.Effect, StackConfigError> => - Effect.gen(function* () { - const bootstrap = validatedConfig.edge_runtime.enabled - ? yield* typeof SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string" - ? Effect.succeed(SUPABASE_STACK_FUNCTIONS_SERVE_MAIN_TEMPLATE) - : Effect.tryPromise({ - try: () => import("./stack-functions-bundler.ts"), - catch: (cause) => - new StackConfigError({ - message: `Unable to load Functions bundler: ${String(cause)}`, - }), - }).pipe( - Effect.flatMap(({ bundleStackFunctionsServeMainTemplate }) => - bundleStackFunctionsServeMainTemplate().pipe( - Effect.mapError( - (cause) => new StackConfigError({ message: cause.message }), - ), - ), - ), - ) - : undefined; + Effect.sync(() => { return [ { service: "database", @@ -1174,7 +1159,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "analytics" as const, config: { - databaseUrl: "postgresql://placeholder", backend: "postgres" as const, apiKey: "api-key", }, @@ -1187,7 +1171,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "pooler" as const, config: { - databaseUrl: "postgresql://placeholder", ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), poolMode, tenant: "pooler-dev", @@ -1198,21 +1181,12 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( } satisfies ServiceCreationType, ] : []), - ...(validatedConfig.studio.enabled - ? [ - { - service: "pgmeta" as const, - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: endpoint(undefined) }, - } satisfies ServiceCreationType, - ] - : []), + ...(validatedConfig.studio.enabled ? [pgmetaCreation] : []), ...(validatedConfig.api.enabled ? [ { service: "rest" as const, config: { - databaseUrl: "postgresql://placeholder", schemas: validatedConfig.api.schemas.join(","), extraSearchPath: validatedConfig.api.extra_search_path.join(","), maxRows: validatedConfig.api.max_rows, @@ -1242,7 +1216,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "realtime" as const, config: { - databaseUrl: "postgresql://placeholder", ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), ipVersion: validatedConfig.realtime.ip_version === "IPv6" @@ -1262,11 +1235,13 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "storage" as const, config: { - databaseUrl: "postgresql://placeholder", ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), filePath: `${storagePath}/${stackId}`, fileSizeLimit: storageFileSizeLimit, s3ProtocolEnabled: validatedConfig.storage.s3_protocol.enabled, + s3AccessKeyId: DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + s3SecretAccessKey: DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, + s3Region: DEFAULT_LOCAL_S3_REGION, vectorEnabled: validatedConfig.storage.vector.enabled, vectorMaxBuckets: validatedConfig.storage.vector.max_buckets, vectorMaxIndexes: validatedConfig.storage.vector.max_indexes, @@ -1279,7 +1254,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ? [ { service: "vector" as const, - config: { analyticsUrl: "http://analytics", apiKey: "api-key" }, + config: { apiKey: "api-key" }, endpoints: { http: endpoint( envPortOrConfigured( @@ -1304,7 +1279,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( } satisfies ServiceCreationType, ] : []), - ...(validatedConfig.edge_runtime.enabled && bootstrap !== undefined + ...(validatedConfig.edge_runtime.enabled ? [ { service: "functions" as const, @@ -1313,7 +1288,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( filesRoot: projectRoot, functions, env: functionsEnv, - bootstrap, policy: validatedConfig.edge_runtime.policy, verifyJwt: true, ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), @@ -1339,6 +1313,11 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( { service: "studio" as const, config: { + snippetsRoot: `${projectRoot}/supabase/snippets`, + apiSchemas: validatedConfig.api.schemas.join(","), + apiExtraSearchPath: validatedConfig.api.extra_search_path.join(","), + apiMaxRows: validatedConfig.api.max_rows, + cliVersion: CLI_VERSION, ...(jwtSecret === undefined ? {} : { jwtSecret: Redacted.value(jwtSecret) }), ...(validatedConfig.studio.openai_api_key === undefined ? {} @@ -1371,7 +1350,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( source: validatedConfig, projectEnvValues: context.projectEnvValues, remoteJwks, - identity, + keys, ...(context.loaded?.document === undefined ? {} : { document: context.loaded.document }), }; }), diff --git a/apps/cli/src/command-internal/stack-functions-bundler.ts b/apps/cli/src/command-internal/stack-functions-bundler.ts deleted file mode 100644 index e2a44e6b21..0000000000 --- a/apps/cli/src/command-internal/stack-functions-bundler.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { fileURLToPath } from "node:url"; - -import { build } from "esbuild"; -import { Data, Effect } from "effect"; - -export class StackFunctionsBundleError extends Data.TaggedError("StackFunctionsBundleError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -/** Bundles the stack runtime's Functions entrypoint for an offline native stack. */ -export const bundleStackFunctionsServeMainTemplate = Effect.fn( - "StackFunctionsBundler.bundleServeMainTemplate", -)(function* () { - const entrypoint = fileURLToPath( - import.meta.resolve("@supabase/stack/internal/functions/serve-main"), - ); - const result = yield* Effect.tryPromise({ - try: () => - build({ - entryPoints: [entrypoint], - bundle: true, - format: "esm", - platform: "browser", - minify: true, - write: false, - legalComments: "none", - logLevel: "silent", - }), - catch: (cause) => - new StackFunctionsBundleError({ - message: "Unable to bundle the stack Functions runtime", - cause, - }), - }); - const output = result.outputFiles[0]?.text; - if (output === undefined) - return yield* new StackFunctionsBundleError({ - message: "esbuild produced no stack Functions template", - }); - return output; -}); diff --git a/apps/cli/src/command-internal/stack-functions-env.ts b/apps/cli/src/command-internal/stack-functions-env.ts index 427306d46e..27f55fe62f 100644 --- a/apps/cli/src/command-internal/stack-functions-env.ts +++ b/apps/cli/src/command-internal/stack-functions-env.ts @@ -1,4 +1,5 @@ import { Data, Effect, FileSystem, Predicate } from "effect"; +import type { ServiceCreationInput } from "@supabase/stack/effect"; import { parseDotEnv } from "./dotenv.ts"; import { actionability, @@ -59,3 +60,14 @@ export const readStackFunctionsEnv = Effect.fn("StackFunctionsEnv.read")(functio }); return env; }); + +/** Adds the project Functions dotenv values to a Functions creation; configured values win. */ +export const withProjectFunctionsEnv = (creation: ServiceCreationInput) => + creation.service === "functions" + ? readStackFunctionsEnv(`${creation.config.functionsRoot}/.env`, true).pipe( + Effect.map((env): ServiceCreationInput => ({ + ...creation, + config: { ...creation.config, env: { ...env, ...creation.config.env } }, + })), + ) + : Effect.succeed(creation); diff --git a/apps/cli/src/command-internal/stack-local-database.ts b/apps/cli/src/command-internal/stack-local-database.ts index 2813d3466b..1dddca074e 100644 --- a/apps/cli/src/command-internal/stack-local-database.ts +++ b/apps/cli/src/command-internal/stack-local-database.ts @@ -1,24 +1,27 @@ import { Data, Effect, FileSystem, Option, Path, Redacted } from "effect"; import type { DatabaseInstance, ServiceCreationInput, Stack } from "@supabase/stack/effect"; -import { postgresVersion } from "@supabase/stack/internal/postgres-artifact"; import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; import { parseConnectionString } from "./db-config.parse.ts"; +import { toUserFacingDatabaseUrl } from "./postgres-url.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { StackApi } from "./stack-api.ts"; import { loadStackConfig } from "./stack-config.ts"; import { readDbToml } from "./db-config.toml-read.ts"; -import { StackCatalogSetup } from "./stack-catalog-setup.ts"; +import { catalogDatabaseServices } from "./stack-catalog-setup.ts"; import { resolveExperimentalWithProjectEnv } from "./global-flags.ts"; -import { applyStackMigrateAndSeed, applyStackWebhooksOnly } from "./stack-bootstrap.ts"; -import { defaultStackRuntime } from "./stack-runtime.ts"; +import { + applyStackWebhooksOnly, + initializeStackDatabase, + projectCatalogOverlay, +} from "./stack-bootstrap.ts"; +import { automaticRuntimeNotice, selectStackRuntime } from "./stack-runtime.ts"; import { Output } from "../shared/output/output.service.ts"; -import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import type { PgConnInput } from "./db-connection.service.ts"; type Settings = CommandSettings["Service"]; @@ -37,20 +40,10 @@ type StackInstances = Effect.Success; const databaseFor = (instances: StackInstances): DatabaseInstance | undefined => instances.find((instance): instance is DatabaseInstance => instance.service === "database"); -const stackForProject = Effect.fn("StackLocalDatabase.findProject")(function* ( - api: StackApi["Service"], - settings: Settings, - path: Path.Path, -) { - const identity = yield* api.resolveIdentity({ projectRoot: settings.workdir }); - const discovered = yield* api.discover({ stateRoot: stateRoot(settings, path) }); - return discovered.find( - ({ definition }) => - definition.identity.projectRoot === identity.projectRoot && - definition.identity.branchContext === identity.branchContext && - definition.identity.stackName === identity.stackName, - ); -}); +const stackForProject = (api: StackApi["Service"], settings: Settings, path: Path.Path) => + api + .find({ stateRoot: stateRoot(settings, path), projectRoot: settings.workdir }) + .pipe(Effect.map(Option.getOrUndefined)); const openProjectStack = Effect.fn("StackLocalDatabase.openProject")(function* () { const api = yield* StackApi; @@ -79,7 +72,10 @@ const databaseReady = Effect.fn("StackLocalDatabase.ready")(function* (stack: St return Option.none<{ readonly stack: Stack; readonly database: DatabaseInstance }>(); const observation = yield* database.status.pipe( Effect.map(Option.some), - Effect.catchTag("StackError", () => Effect.succeed(Option.none())), + Effect.catchIf( + (cause) => cause.reason === "owner-unavailable", + () => Effect.succeed(Option.none()), + ), ); if ( Option.isNone(observation) || @@ -101,12 +97,13 @@ export const stackOpenReadyProject = stackOpenProjectBy((cause) => notRunning(ca ), ); +/** The saved project stack's runtime as a hint; an absent or unreadable stack yields none. */ export const stackProjectRuntime = Effect.gen(function* () { const api = yield* StackApi; const settings = yield* CommandSettings; const path = yield* Path.Path; return (yield* stackForProject(api, settings, path))?.definition.runtime; -}); +}).pipe(Effect.orElseSucceed(() => undefined)); export class StackRuntimeUnavailableError extends Data.TaggedError("StackRuntimeUnavailableError")<{ readonly message: string; @@ -130,13 +127,6 @@ export const stackRequireProjectRuntime: Effect.Effect< Effect.flatMap((runtime) => runtime === undefined ? Effect.fail(RUNTIME_UNAVAILABLE) : Effect.succeed(runtime), ), - Effect.mapError( - (cause) => - new StackRuntimeUnavailableError({ - message: cause.message, - suggestion: RUNTIME_UNAVAILABLE.suggestion, - }), - ), ); /** Reads the configured database version from the saved database definition. */ @@ -157,7 +147,7 @@ export const stackProjectDatabaseVersion: Effect.Effect< if (database === undefined) return undefined; const status = yield* database.status.pipe(Effect.option, Effect.map(Option.getOrUndefined)); return status?.config.service === "database" ? status.config.config.version : undefined; -}); +}).pipe(Effect.scoped); export class StackNativeEngineError extends Data.TaggedError("StackNativeEngineError")<{ readonly message: string; @@ -194,10 +184,12 @@ const stackLocalDatabaseUrl: Effect.Effect< return yield* notRunning("The local stack primary service is not a database."); if (endpoint === undefined) return yield* notRunning("The local stack database SQL endpoint is unavailable."); - const password = Redacted.value(status.config.config.databasePassword); - const host = endpoint.host.includes(":") ? `[${endpoint.host}]` : endpoint.host; - return `postgresql://postgres:${encodeURIComponent(password)}@${host}:${endpoint.port}/postgres`; -}); + return toUserFacingDatabaseUrl({ + host: endpoint.host, + port: endpoint.port, + password: Redacted.value(status.config.config.databasePassword), + }); +}).pipe(Effect.scoped); export const stackLocalDatabaseConn: Effect.Effect< PgConnInput, @@ -220,25 +212,40 @@ export const stackEnsurePostgresOnlyStarted = Effect.fn( const api = yield* StackApi; const settings = yield* CommandSettings; const path = yield* Path.Path; - const runtime = yield* RuntimeInfo; const fs = yield* FileSystem.FileSystem; const output = yield* Output; const config = yield* loadStackConfig(settings.workdir).pipe(Effect.mapError(startFailed)); const toml = yield* readDbToml(fs, path, settings.workdir).pipe(Effect.mapError(startFailed)); const experimental = yield* resolveExperimentalWithProjectEnv({ ...toml.projectEnv }); const existing = yield* stackForProject(api, settings, path).pipe(Effect.mapError(startFailed)); - const identity = - existing === undefined ? yield* config.identity.pipe(Effect.mapError(startFailed)) : undefined; + const keys = + existing === undefined ? yield* config.keys.pipe(Effect.mapError(startFailed)) : undefined; + const createStack = Effect.gen(function* () { + const runtime = yield* selectStackRuntime(undefined).pipe( + Effect.mapError( + (error) => + new LocalDbRunningError({ + message: `failed to start local database: ${error.message}`, + daemonDown: true, + suggestion: error.suggestion, + }), + ), + ); + const created = yield* api + .create({ + projectRoot: settings.workdir, + stateRoot: stateRoot(settings, path), + cacheRoot: cacheRoot(settings, path), + runtime, + }) + .pipe(Effect.mapError(startFailed)); + const notice = automaticRuntimeNotice(undefined, runtime); + if (notice !== undefined) yield* output.info(notice); + return created; + }); const stack = existing === undefined - ? yield* api - .create({ - projectRoot: settings.workdir, - stateRoot: stateRoot(settings, path), - cacheRoot: cacheRoot(settings, path), - runtime: defaultStackRuntime(runtime), - }) - .pipe(Effect.mapError(startFailed)) + ? yield* createStack : yield* api .open({ id: existing.definition.id, @@ -255,11 +262,16 @@ export const stackEnsurePostgresOnlyStarted = Effect.fn( return yield* startFailed({ message: "database is disabled in the local configuration" }); const currentDatabase = yield* databaseFromStack(stack).pipe(Effect.mapError(startFailed)); if (currentDatabase !== undefined) { - const status = yield* currentDatabase.status.pipe(Effect.mapError(startFailed)); + const planned = yield* stack.composition + .plan([databaseCreation]) + .pipe(Effect.mapError(startFailed)); if ( - status.config.service === "database" && - postgresVersion(status.config.config.version) !== - postgresVersion(databaseCreation.config.version) + planned.some( + (entry) => + entry.id === currentDatabase.id && + entry.change === "incompatible" && + entry.paths.includes("config.version"), + ) ) return yield* startFailed({ message: "The requested database version does not match the saved stack binding", @@ -294,41 +306,23 @@ export const stackEnsurePostgresOnlyStarted = Effect.fn( message: "A standalone database exists outside the saved stack composition. Destroy the standalone database before starting this stack.", }); - const effectiveIdentity = identity ?? (yield* config.identity.pipe(Effect.mapError(startFailed))); + const effectiveKeys = keys ?? (yield* config.keys.pipe(Effect.mapError(startFailed))); const [database] = yield* stack.composition - .supabase([databaseCreation], { identity: effectiveIdentity }) + .supabase([databaseCreation], { keys: effectiveKeys }) .pipe(Effect.mapError(startFailed)); if (database === undefined || database.service !== "database") return yield* startFailed({ message: "stack did not create a database instance" }); return yield* Effect.gen(function* () { yield* database.start.pipe(Effect.mapError(startFailed)); yield* database.ready.pipe(Effect.mapError(startFailed)); - const catalog = yield* StackCatalogSetup; - const databaseServices = creations.flatMap((creation) => - creation.service === "auth" || - creation.service === "storage" || - creation.service === "realtime" - ? [creation.service] - : [], - ); const credentials = yield* stack.credentials.get.pipe(Effect.mapError(startFailed)); if (credentials === undefined) return yield* startFailed({ message: "stack credentials were not saved" }); - yield* catalog - .apply({ - target: { stack, database, databaseServices }, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, - workdir: settings.workdir, - }, - }) - .pipe(Effect.mapError(startFailed)); - yield* applyStackMigrateAndSeed(database, settings.workdir, toml, experimental).pipe( - Effect.mapError(startFailed), - ); + yield* initializeStackDatabase({ + target: { stack, database, databaseServices: catalogDatabaseServices(creations) }, + overlay: projectCatalogOverlay(toml, settings.workdir), + migrations: { workdir: settings.workdir, toml, experimental }, + }).pipe(Effect.mapError(startFailed)); return "started" as const; }).pipe( Effect.onError(() => diff --git a/apps/cli/src/command-internal/stack-runtime.ts b/apps/cli/src/command-internal/stack-runtime.ts index 05c4cb6c29..86aa22ef6b 100644 --- a/apps/cli/src/command-internal/stack-runtime.ts +++ b/apps/cli/src/command-internal/stack-runtime.ts @@ -1,9 +1,100 @@ -/** Selects native execution where the catalog publishes portable artifacts. */ -export const defaultStackRuntime = (runtime: { - readonly platform: string; - readonly arch: string; -}): "native" | "docker" => - (runtime.platform === "linux" && (runtime.arch === "x64" || runtime.arch === "arm64")) || - (runtime.platform === "darwin" && runtime.arch === "arm64") - ? "native" - : "docker"; +import { defaultRuntime } from "@supabase/stack/internal/artifacts"; +import { Data, Effect } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; + +/** Runtime that executes a local stack's services. */ +export type StackRuntime = "native" | "docker" | "podman"; + +/** 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"; + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.reason === "native-unsupported" + ? actionability.provideFlags + : actionability.dockerNotRunning; + } +} + +/** A cold Docker Desktop or Podman machine can take several seconds to answer its first request. */ +const PROBE_TIMEOUT = "10 seconds"; + +/** Each probe exits non-zero unless the engine's daemon or service answers. */ +const engineProbes = [ + { runtime: "docker", args: ["version", "--format", "{{.Server.Version}}"] }, + { runtime: "podman", args: ["info", "--format", "{{.Version.Version}}"] }, +] as const; + +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), + ); + +/** + * Notice for a new stack whose automatic selection skipped Docker, since that runtime is saved with + * the stack; `undefined` when the runtime was requested, saved, or Docker. + */ +export const automaticRuntimeNotice = ( + requested: StackRuntime | undefined, + selected: StackRuntime, +): string | undefined => + requested !== undefined || selected === "docker" + ? undefined + : `Docker didn't answer, so this new stack uses the ${selected === "podman" ? "Podman" : "native"} runtime, which is saved with the stack. To use Docker, start it, then run \`supabase stack destroy\` and start again, or choose a different --stack name with --runtime docker.`; + +/** + * Returns the requested or saved runtime unchanged; otherwise the first of Docker, Podman, or + * native that is usable on this host. + */ +export const selectStackRuntime = Effect.fn("StackRuntime.select")(function* ( + requested: StackRuntime | undefined, +) { + if (requested !== undefined) { + if (requested === "native") { + const { platform, arch } = yield* RuntimeInfo; + // Fail before a stack is created; the runtime's own check only runs mid-start. + if (defaultRuntime({ os: platform, arch }) !== "native") + return yield* new StackRuntimeSelectionError({ + reason: "native-unsupported", + message: `Native artifacts are unsupported on ${platform}/${arch}.`, + suggestion: + "Start Docker or Podman and rerun with --runtime docker or --runtime podman; if this stack already exists as native, run supabase stack destroy first.", + }); + } + return requested; + } + const spawner = yield* ChildProcessSpawner; + for (const probe of engineProbes) { + if (yield* engineReachable(spawner, probe)) return probe.runtime; + } + const { platform, arch } = yield* RuntimeInfo; + if (defaultRuntime({ os: platform, arch }) === "native") return "native"; + return yield* new StackRuntimeSelectionError({ + reason: "engine-unreachable", + 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.", + }); +}); diff --git a/apps/cli/src/command-internal/stack-shadow-cache.ts b/apps/cli/src/command-internal/stack-shadow-cache.ts index 7b267f4d5f..0249891d0d 100644 --- a/apps/cli/src/command-internal/stack-shadow-cache.ts +++ b/apps/cli/src/command-internal/stack-shadow-cache.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import { resolveArtifact, postgresVersion } from "@supabase/stack/internal/postgres-artifact"; +import { resolveArtifact, postgresVersion } from "@supabase/stack/internal/artifacts"; import type { ShadowSetupInput } from "./db-bootstrap/shadow-database.ts"; import { SHADOW_CACHE_ENV, 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 c67aa94faf..ca7dae1a4b 100644 --- a/apps/cli/src/command-internal/stack-shadow.integration.test.ts +++ b/apps/cli/src/command-internal/stack-shadow.integration.test.ts @@ -456,4 +456,63 @@ describe("stack shadow databases", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), 120_000, ); + + it.live( + "prints the cleanup commands when a shadow's destroy skips its engine", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-shadow-skipped-" }); + const output = mockOutput(); + const cleanupCommand = "docker rm --force $(docker ps --all --quiet)"; + // A real registration whose database creation fails fast and whose destroy reports skipped cleanup. + const api = Layer.effect( + StackApi, + Effect.gen(function* () { + const real = yield* StackApi; + return StackApi.of({ + ...real, + create: (options) => + real.create(options).pipe( + Effect.map((stack) => ({ + ...stack, + services: { + ...stack.services, + create: () => + Effect.fail(new StackError({ operation: "create", message: "injected" })), + }, + destroy: Effect.succeed({ + runtimeCleanup: "skipped", + engine: "docker", + cleanupCommands: [cleanupCommand], + } as const), + })), + ), + }); + }), + ).pipe(Layer.provide(stackApiLayer), Layer.provide(BunServices.layer)); + + yield* stackWithShadowDatabase(input(fs, path, root), () => Effect.void, { + runtime: "native", + }).pipe( + Effect.provide( + Layer.mergeAll( + api, + stackCatalogSetupLayer, + dbConnectionLayer, + runtimeInfoLayer, + mockCommandSettings({ workdir: root, supabaseHome: root }), + output.layer, + ), + ), + Effect.flip, + ); + + expect(output.stderrText).toMatch( + /Warning: Docker was unavailable, so Docker resources for shadow stack [0-9a-f]{64} were not removed\. Once it is running, remove them with:\n {2}docker rm --force \$\(docker ps --all --quiet\)\n/u, + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + 120_000, + ); }); diff --git a/apps/cli/src/command-internal/stack-shadow.ts b/apps/cli/src/command-internal/stack-shadow.ts index 1e6550d24d..86c2ee6276 100644 --- a/apps/cli/src/command-internal/stack-shadow.ts +++ b/apps/cli/src/command-internal/stack-shadow.ts @@ -3,10 +3,10 @@ import type { DatabaseInstance, Stack } from "@supabase/stack/effect"; import { CommandSettings } from "../config/command-settings.service.ts"; import { Output } from "../shared/output/output.service.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; -import { StackApi } from "./stack-api.ts"; -import { StackCatalogSetup } from "./stack-catalog-setup.ts"; +import { skippedRuntimeCleanupWarning, StackApi } from "./stack-api.ts"; +import { initializeStackDatabase } from "./stack-bootstrap.ts"; import { stackProjectRuntime } from "./stack-local-database.ts"; -import { defaultStackRuntime } from "./stack-runtime.ts"; +import { selectStackRuntime } from "./stack-runtime.ts"; import { parseConnectionString } from "./db-config.parse.ts"; import { toPostgresURL } from "./postgres-url.ts"; import { @@ -49,19 +49,32 @@ const shadowError = (cause: { readonly message: string }) => const causeMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); +/** Selects the shadow runtime: the project stack's saved runtime, otherwise automatic selection. */ +export const stackShadowRuntime = Effect.gen(function* () { + return yield* selectStackRuntime(yield* stackProjectRuntime).pipe( + Effect.mapError( + (error) => + new ShadowDbError({ + message: `${error.message} ${error.suggestion}`, + reason: "docker_daemon", + }), + ), + ); +}); + const acquireNamespace = Effect.fn("StackShadow.acquireNamespace")(function* (opts: ShadowOptions) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const api = yield* StackApi; const settings = yield* CommandSettings; - const runtimeInfo = yield* RuntimeInfo; - const runtime = opts.runtime ?? (yield* stackProjectRuntime) ?? defaultStackRuntime(runtimeInfo); + const runtime = opts.runtime ?? (yield* stackShadowRuntime); const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-shadow-" }); const stack = yield* api.create({ projectRoot: root, stateRoot: path.join(settings.supabaseHome, "stacks"), cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), runtime, + lifetime: "session", }); return { stack, runtime }; }); @@ -144,9 +157,8 @@ const initialize = Effect.fn("StackShadow.initialize")(function* ( } else { yield* startReady(database); } - const catalog = yield* StackCatalogSetup; if (!restored) - yield* catalog.apply({ + yield* initializeStackDatabase({ target: { stack, database, @@ -212,7 +224,10 @@ const initialize = Effect.fn("StackShadow.initialize")(function* ( } satisfies StackShadowAcquiredHandle; }); -/** Acquires a fresh shadow for callers whose enclosing scope owns its lifetime. */ +/** + * Acquires a fresh shadow for callers whose enclosing scope owns its lifetime. The shadow is a + * session stack, so its owner destroys it even when this process exits abruptly. + */ export const stackAcquireShadowDatabase = Effect.fn("StackShadow.acquire")(function* ( input: ShadowSetupInput, opts: ShadowOptions = {}, @@ -220,11 +235,16 @@ export const stackAcquireShadowDatabase = Effect.fn("StackShadow.acquire")(funct const output = yield* Output; const namespace = yield* Effect.acquireRelease(acquireNamespace(opts), ({ stack }) => stack.destroy.pipe( + Effect.flatMap((result) => + result.runtimeCleanup === "skipped" + ? output.raw( + `Warning: ${skippedRuntimeCleanupWarning(`shadow stack ${stack.id}`, result)}\n`, + "stderr", + ) + : Effect.void, + ), Effect.catch((cause) => - output.raw( - `Failed to destroy shadow stack ${stack.id}: ${cause.message}. Run supabase stack destroy --stack-id ${stack.id} to remove it.\n`, - "stderr", - ), + output.raw(`Failed to destroy shadow stack ${stack.id}: ${cause.message}.\n`, "stderr"), ), ), ); diff --git a/apps/cli/src/command-internal/stack-storage.ts b/apps/cli/src/command-internal/stack-storage.ts index 0e5fd41017..45b8fc9d84 100644 --- a/apps/cli/src/command-internal/stack-storage.ts +++ b/apps/cli/src/command-internal/stack-storage.ts @@ -214,7 +214,7 @@ export const stackStorageEndpoint: Effect.Effect< suggestion: "Run supabase start to create it.", }); return yield* stackStorageEndpointFor(opened.value); -}); +}).pipe(Effect.scoped); /** * Maps a stack-gateway activation failure into `StackStorageCapabilityError` guidance, only for diff --git a/apps/cli/src/command-internal/status-pretty.ts b/apps/cli/src/command-internal/status-pretty.ts index d6255023d0..62ab0232d5 100644 --- a/apps/cli/src/command-internal/status-pretty.ts +++ b/apps/cli/src/command-internal/status-pretty.ts @@ -1,4 +1,4 @@ -import { aqua, bold, green, yellow } from "./colors.ts"; +import { aqua, bold, gray, green, red, yellow } from "./colors.ts"; import type { StatusOutputNames } from "./status-values.ts"; /** @@ -10,7 +10,7 @@ import type { StatusOutputNames } from "./status-values.ts"; * `process.stderr` and would check the wrong stream's TTY status. */ -type OutputKind = "text" | "link" | "key"; +type OutputKind = "text" | "link" | "key" | "good" | "pending" | "bad" | "muted"; interface OutputItem { readonly label: string; @@ -18,7 +18,8 @@ interface OutputItem { readonly kind: OutputKind; } -interface OutputGroup { +/** One rounded-border table: a title row above label/value rows. */ +export interface StatusGroup { readonly name: string; readonly items: ReadonlyArray; } @@ -29,10 +30,10 @@ const COLUMN_0_MAX_WIDTH = 16; * Builds the 5 fixed display groups, looking up each label's value by its resolved output * key — `--override-name` remaps the key but never the group layout. */ -function buildGroups( +export function statusGroups( values: Readonly>, names: StatusOutputNames, -): ReadonlyArray { +): ReadonlyArray { const at = (key: string) => values[key] ?? ""; return [ { @@ -77,7 +78,7 @@ function buildGroups( /** * Display width for this command's inputs: URLs/keys/labels are always plain ASCII, so every - * rune is width 1. The 5 fixed group-title emoji are the only non-ASCII runes ever rendered, + * rune is width 1. The fixed group-title emoji are the only non-ASCII runes ever rendered, * and their widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} instead of computed * generically. */ @@ -92,11 +93,12 @@ const HEADER_DISPLAY_WIDTH: Readonly> = { "⛁ Database": 10, "🔑 Authentication Keys": 22, "📦 Storage (S3)": 15, + "🧩 Services": 11, }; /** * Exported only for direct unit coverage of the fallback branch — every call site in this - * file passes one of the 5 fixed titles in {@link HEADER_DISPLAY_WIDTH}. + * file passes one of the fixed titles in {@link HEADER_DISPLAY_WIDTH}. */ export function statusHeaderWidth(name: string): number { return HEADER_DISPLAY_WIDTH[name] ?? displayWidth(name); @@ -125,13 +127,19 @@ export function wrapStatusLabel(text: string, width: number): ReadonlyArray 0 ? lines : [text]; } -/** Value coloring: `link` → aqua, `key` → yellow, `text` → unstyled. */ function colorValue(kind: OutputKind, value: string): string { switch (kind) { case "link": return aqua(value, process.stdout); case "key": + case "pending": return yellow(value, process.stdout); + case "good": + return green(value, process.stdout); + case "bad": + return red(value, process.stdout); + case "muted": + return gray(value, process.stdout); case "text": return value; } @@ -173,7 +181,7 @@ export function statusColumnLayout( return { col0Padded, col1Padded, targetInner }; } -function renderGroupTable(group: OutputGroup): string | undefined { +function renderGroupTable(group: StatusGroup): string | undefined { const rows = group.items.filter((item) => item.value.length > 0); if (rows.length === 0) return undefined; @@ -229,7 +237,11 @@ export function renderStatusPretty( values: Readonly>, names: StatusOutputNames, ): string { - const groups = buildGroups(values, names); + return renderStatusGroups(statusGroups(values, names)); +} + +/** Renders groups as rounded-border tables, followed by one blank line per group. */ +export function renderStatusGroups(groups: ReadonlyArray): string { const lines: string[] = []; for (const group of groups) { const table = renderGroupTable(group); diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index fa3ad6a9f0..9c5c430a62 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -102,7 +102,7 @@ export interface StatusOutputNames { * Resolves each field's output key, applying `--override-name =` remaps over * the default names. `overrides` maps `fieldKey` (e.g. `"api.url"`) to the replacement name. */ -function resolveOutputNames(overrides: ReadonlyMap): StatusOutputNames { +export function resolveOutputNames(overrides: ReadonlyMap): StatusOutputNames { const nameFor = (field: StatusField) => overrides.get(field.fieldKey) ?? field.defaultName; return { apiUrl: nameFor(API_URL), diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 7769dc7ce1..f5010ec342 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -1,6 +1,7 @@ import * as nodePath from "node:path"; import { Effect, FileSystem, Option, Path } from "effect"; -import { postgres, type DatabaseInstance, type Stack } from "@supabase/stack/effect"; +import { type DatabaseInstance, type Stack } from "@supabase/stack/effect"; +import { postgres } from "@supabase/stack/commands"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; @@ -296,7 +297,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { : nodePath.extname(hostPath) !== "" ? nodePath.dirname(hostPath) : hostPath; - return yield* managedStack.stack.tools + return yield* managedStack.stack.commands .run(postgres.pgProve({ major: managedStack.major }), { args: args.cmd.slice(1), env: runEnv, diff --git a/apps/cli/src/command-internal/test-db.integration.test.ts b/apps/cli/src/command-internal/test-db.integration.test.ts index 4f69a6dadd..158e40ecc9 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -42,7 +42,7 @@ const stackApiStub = Layer.succeed(StackApi, { create: () => Effect.die("stack API unused"), open: () => Effect.die("stack API unused"), discover: () => Effect.die("stack API unused"), - resolveIdentity: () => Effect.die("stack API unused"), + find: () => Effect.die("stack API unused"), }); function mockResolver(opts: { conn?: PgConnInput; isLocal?: boolean } = {}) { 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 4049e4377f..d8f52694fb 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 @@ -23,7 +23,10 @@ import { testDb } from "./test-db.handler.ts"; import { runTestDbCommand } from "./test-db.command-handler.ts"; import { DockerRun } from "./docker-run.service.ts"; import { StackError } from "@supabase/stack/effect"; -import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; +import type { InitializationCommandOptions, PostgresCommandOptions } from "@supabase/stack/effect"; +import type { Stack } from "@supabase/stack/effect"; +import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; +import { destroyTestStack } from "../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); @@ -158,20 +161,48 @@ describe("managed test db pgTAP", { timeout: 180_000 }, () => { const partialTap = "not ok 1 - managed tool failed after streaming\n"; const jsonOutput = mockOutput({ format: "json" }); const processControl = mockProcessControl(); + const failingCommands = (commands: Stack["commands"]) => { + function run( + command: PostgresCommand, + options: PostgresCommandOptions, + ): Effect.Effect< + { readonly jobId: string; readonly exitCode: number }, + E | StackError, + R + >; + function run( + command: InitializationCommand, + options?: InitializationCommandOptions, + ): Effect.Effect< + { readonly jobId: string; readonly exitCode: number }, + E | StackError, + R + >; + function run( + command: PostgresCommand | InitializationCommand, + options?: PostgresCommandOptions | InitializationCommandOptions, + ) { + if ("type" in command) return commands.run(command); + if (options?.stdout === undefined) + return Effect.die("Postgres command requires a stdout sink"); + const stdout = options.stdout; + return Effect.gen(function* () { + yield* stdout(new TextEncoder().encode(partialTap)); + return yield* Effect.fail( + new StackError({ operation: "command", message: "pg_prove unavailable" }), + ); + }); + } + return run; + }; const failingStackApi = Layer.succeed(StackApi, { ...api, open: () => Effect.succeed({ ...stack, - tools: { - ...stack.tools, - run: (_tool, options) => - Effect.gen(function* () { - yield* options.stdout(new TextEncoder().encode(partialTap)); - return yield* Effect.fail( - new StackError({ operation: "tool", message: "pg_prove unavailable" }), - ); - }), + commands: { + ...stack.commands, + run: failingCommands(stack.commands), }, }), }); diff --git a/apps/cli/src/command-internal/upgrade-notice.ts b/apps/cli/src/command-internal/upgrade-notice.ts index b0e8825c4e..c9cdb271cc 100644 --- a/apps/cli/src/command-internal/upgrade-notice.ts +++ b/apps/cli/src/command-internal/upgrade-notice.ts @@ -18,7 +18,7 @@ import { lastGlobalFlagValue, rootFlagTokens, } from "../shared/cli/run.ts"; -import { CLI_UPGRADE_GUIDE_URL, CLI_VERSION } from "../shared/cli/version.ts"; +import { CLI_UPGRADE_GUIDE_URL, CLI_VERSION, parseSemver } from "../shared/cli/version.ts"; import { bold, yellow } from "./colors.ts"; import { parseDotEnv } from "./dotenv.ts"; import { candidateDotenvFilenames } from "./project-environment.ts"; @@ -61,33 +61,6 @@ function debugEnabled( const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); -interface ParsedSemver { - readonly nums: readonly [string, string, string]; - readonly prerelease: string; -} - -function parseSemver(version: string): ParsedSemver | undefined { - const match = - /^v(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)?(?![\s\S])/.exec( - version, - ); - if (match === null) return undefined; - const prerelease = match[4] ?? ""; - if ( - prerelease - .split(".") - .some( - (identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier[0] === "0", - ) - ) { - return undefined; - } - return { - nums: [match[1]!, match[2] ?? "0", match[3] ?? "0"], - prerelease, - }; -} - function compareNumericIdentifier(left: string, right: string): number { if (left.length !== right.length) return left.length < right.length ? -1 : 1; if (left === right) return 0; @@ -99,9 +72,9 @@ function compareNumericIdentifier(left: string, right: string): number { * suggests an upgrade; an invalid current version always does. */ export function isNewerCliVersion(latestTag: string, currentVersion: string): boolean { - const latest = parseSemver(latestTag); + const latest = latestTag.startsWith("v") ? parseSemver(latestTag.slice(1)) : undefined; if (latest === undefined) return false; - const current = parseSemver(`v${currentVersion}`); + const current = parseSemver(currentVersion); if (current === undefined) return true; for (let index = 0; index < 3; index++) { const comparison = compareNumericIdentifier(latest.nums[index]!, current.nums[index]!); diff --git a/apps/cli/src/commands/backups/list/list.handler.ts b/apps/cli/src/commands/backups/list/list.handler.ts index 3ef57c4426..7c6289d062 100644 --- a/apps/cli/src/commands/backups/list/list.handler.ts +++ b/apps/cli/src/commands/backups/list/list.handler.ts @@ -108,7 +108,7 @@ export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsLi Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index 991ba7ef20..de62569194 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -41,7 +41,7 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac Effect.tapError(() => restoring?.fail() ?? Effect.void), Effect.catch(mapRestoreError), ); - yield* restoring?.clear() ?? Effect.void; + yield* restoring?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 9d0387307e..44d8752802 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -149,7 +149,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ); if (entries.length > 0) { // `--yes`/`SUPABASE_YES` auto-confirms with a ` [Y/n] y` stderr echo; non-TTY - // stdin scans one piped line (100ms) before falling back to Yes. + // stdin scans one piped line (100ms): empty or EOF takes Yes, an unrecognised answer declines. const overwrite = yield* promptYesNo( output, yesFlag, diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index ed5e8684db..c736a4a9f5 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -52,8 +52,8 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra const gitBranch = yield* detectGitBranch(); if (Option.isSome(gitBranch) && gitBranch.value.length > 0) { // `--yes`/`SUPABASE_YES` auto-confirms with a `<title> [Y/n] y` stderr echo; non-TTY - // stdin scans one piped line (100ms) before falling back to Yes — `echo n | supabase - // branches create` cancels. + // stdin scans one piped line (100ms): empty or EOF takes Yes, anything but y/yes + // declines — `echo n | supabase branches create` cancels. const yes = yield* resolveYes; const confirmed = yield* promptYesNo( output, @@ -120,7 +120,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra ), ), ); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/branches/create/create.integration.test.ts b/apps/cli/src/commands/branches/create/create.integration.test.ts index e28ddd0ccb..e8e2b4dfe2 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -20,6 +20,7 @@ import { mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, + withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { branchesCreateCommand, type BranchesCreateFlags } from "./create.command.ts"; @@ -225,9 +226,8 @@ describe("branches create integration", () => { it.live("reports a missing name before contacting the API outside a git repository", () => { const { layer, api } = setup(); - return withEnvVar( - "GITHUB_HEAD_REF", - undefined, + return withConfigEnv( + { GITHUB_HEAD_REF: "" }, Effect.gen(function* () { const exit = yield* branchesCreate(baseFlags).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -246,7 +246,7 @@ describe("branches create integration", () => { // `GITHUB_HEAD_REF` drives `detectGitBranch` deterministically (its highest-priority source). const withGitBranch = <A, E, R>(effect: Effect.Effect<A, E, R>, branch = "feat-y") => - withEnvVar("GITHUB_HEAD_REF", branch, effect); + withConfigEnv({ GITHUB_HEAD_REF: branch }, effect); it.live("--yes auto-confirms the git-branch name with the [Y/n] y echo", () => { const { layer, out, api } = setup({ yes: true, stdinIsTty: true }); diff --git a/apps/cli/src/commands/branches/delete/delete.handler.ts b/apps/cli/src/commands/branches/delete/delete.handler.ts index c3422efe50..c9c22c378f 100644 --- a/apps/cli/src/commands/branches/delete/delete.handler.ts +++ b/apps/cli/src/commands/branches/delete/delete.handler.ts @@ -46,7 +46,7 @@ export const branchesDelete = Effect.fn("branches.delete")(function* (flags: Bra Effect.tapError(() => deleting?.fail() ?? Effect.void), Effect.catch(mapDeleteError), ); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; if (output.format === "json" || output.format === "stream-json") { yield* output.success("Deleted preview branch", { project_ref: branchRef }); diff --git a/apps/cli/src/commands/branches/disable/disable.handler.ts b/apps/cli/src/commands/branches/disable/disable.handler.ts index 2bd1903596..3ee6f6e07a 100644 --- a/apps/cli/src/commands/branches/disable/disable.handler.ts +++ b/apps/cli/src/commands/branches/disable/disable.handler.ts @@ -39,7 +39,7 @@ export const branchesDisable = Effect.fn("branches.disable")(function* ( Effect.tapError(() => disabling?.fail() ?? Effect.void), Effect.catch(mapDisableError), ); - yield* disabling?.clear() ?? Effect.void; + yield* disabling?.clear ?? Effect.void; // Established behavior: this message writes to STDOUT. if (output.format === "json" || output.format === "stream-json") { diff --git a/apps/cli/src/commands/branches/get/get.handler.ts b/apps/cli/src/commands/branches/get/get.handler.ts index 8de72c4661..8ba167a3fb 100644 --- a/apps/cli/src/commands/branches/get/get.handler.ts +++ b/apps/cli/src/commands/branches/get/get.handler.ts @@ -106,7 +106,7 @@ export const branchesGet = Effect.fn("branches.get")(function* (flags: BranchesG Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const detail: BranchDetail = { ...rawDetail, db_user: rawDetail.db_user ?? "******", diff --git a/apps/cli/src/commands/branches/list/list.handler.ts b/apps/cli/src/commands/branches/list/list.handler.ts index 6f8d6a2f2d..3e3ddbc0d5 100644 --- a/apps/cli/src/commands/branches/list/list.handler.ts +++ b/apps/cli/src/commands/branches/list/list.handler.ts @@ -48,7 +48,7 @@ export const branchesList = Effect.fn("branches.list")(function* (flags: Branche Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); - yield* fetching?.clear() ?? Effect.void; + 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 80ebca2423..3ad94a3b86 100644 --- a/apps/cli/src/commands/branches/pause/pause.handler.ts +++ b/apps/cli/src/commands/branches/pause/pause.handler.ts @@ -42,6 +42,6 @@ export const branchesPause = Effect.fn("branches.pause")(function* (flags: Branc Effect.tapError(() => pausing?.fail() ?? Effect.void), Effect.catch(mapPauseError), ); - yield* pausing?.clear() ?? Effect.void; + yield* pausing?.clear ?? Effect.void; }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/branches/unpause/unpause.handler.ts b/apps/cli/src/commands/branches/unpause/unpause.handler.ts index 98a183ae4c..3c030b4d5e 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.handler.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.handler.ts @@ -45,6 +45,6 @@ export const branchesUnpause = Effect.fn("branches.unpause")(function* ( Effect.tapError(() => restoring?.fail() ?? Effect.void), Effect.catch(mapUnpauseError), ); - yield* restoring?.clear() ?? Effect.void; + yield* restoring?.clear ?? Effect.void; }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/branches/update/update.handler.ts b/apps/cli/src/commands/branches/update/update.handler.ts index eae4f83dd8..28d3fa838e 100644 --- a/apps/cli/src/commands/branches/update/update.handler.ts +++ b/apps/cli/src/commands/branches/update/update.handler.ts @@ -83,7 +83,7 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra ), ), ); - yield* patching?.clear() ?? Effect.void; + yield* patching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index 7672a7bcc7..b5c3d29d4b 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -173,7 +173,7 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF }), ), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // configProjectConfigTry (ADR 0021) keeps a response the schema can't narrow as a typed // ProjectConfigParseError; anything else escaping it is a defect. 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 9a02b6508c..77f19458ce 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -202,6 +202,48 @@ describe("config diff integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("a project that never set up SMS diffs clean against a declared enabled = false", () => { + const { layer, out } = setup({ + toml: 'project_id = "test"\n[auth.sms.twilio]\nenabled = false\n', + }); + return Effect.gen(function* () { + yield* configDiff(noFlags); + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + + it.live("a configured provider still diffs clean", () => { + const { layer, out } = setup({ + toml: [ + 'project_id = "test"', + "[auth.sms.twilio]", + "enabled = true", + 'account_sid = "AC1"', + 'message_service_sid = "MG1"', + 'auth_token = "env(TWILIO_AUTH_TOKEN)"', + "", + ].join("\n"), + dotenv: "TWILIO_AUTH_TOKEN=token\n", + v2: { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { + ...(attributes["auth"] as Record<string, unknown>), + sms_twilio_account_sid: "AC1", + sms_twilio_message_service_sid: "MG1", + }, + }), + }), + }, + }); + return Effect.gen(function* () { + yield* configDiff(noFlags); + expect(out.stdoutText).toContain("No config differences found."); + }).pipe(Effect.provide(layer)); + }); + it.live("--exit-code sets exit 2 when differences are found", () => { const { layer, processControl } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', @@ -1158,7 +1200,7 @@ describe("config diff telemetry wiring", () => { const wiringLayer = (analytics: ReturnType<typeof mockContextualAnalytics>, projectRef: string) => Layer.mergeAll( setup({ toml: 'project_id = "test"\n', analytics }).layer, - commandRuntimeLayer(["config", "diff"]), + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed(["config", "diff", "--project-ref", projectRef]), }), @@ -1208,7 +1250,7 @@ describe("config diff -o/--output wrapper wiring", () => { Effect.provide( Layer.mergeAll( layer, - commandRuntimeLayer(["config", "diff"]), + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed(["config", "diff", "-o", "table"]) }), ), ), diff --git a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md index fccd2e8bd8..8aa9bb6b52 100644 --- a/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/pull/SIDE_EFFECTS.md @@ -6,8 +6,8 @@ resolution, fetch, and classification, `../diff/`). Prompts for confirmation bef interactive TTY, unless `--yes` is set; `--output-format json|stream-json` never prompts at all and returns the confirmation's default value without reading anything. A non-interactive TEXT run (no TTY on stdin) still prints the confirmation to stderr and reads a single line from piped stdin, -honoring an explicit `y`/`n` answer and falling back to the default otherwise. Never writes on -`--dry-run`, on a declined prompt, or on any error. +honoring an explicit `y`/`yes`/`n`/`no` answer, falling back to the default on an empty line, and +declining any other answer. Never writes on `--dry-run`, on a declined prompt, or on any error. ## Files Read diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index bbdd2882bb..991d3cf068 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -58,7 +58,7 @@ const configPullHandler = (flags: ConfigPullFlags) => export const configPullCommand = Command.make("pull", config).pipe( Command.withDescription( - "Writes configuration from a remote project or branch into supabase/config.toml. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/n honored, default otherwise) — use --dry-run to preview first.", + "Writes configuration from a remote project or branch into supabase/config.toml. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/yes/n/no honored, empty takes the default, anything else declines) — use --dry-run to preview first.", ), Command.withShortDescription("Pull remote config into supabase/config.toml"), Command.withExamples([ 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 1b5835b231..30f0a36ff4 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -2239,6 +2239,40 @@ describe("config pull integration", () => { }, ); + it.live("a declared provider is written back disabled by a remote that never set up SMS", () => { + const never = { + status: 200, + body: v2Response({ + attributes: (attributes) => ({ + ...attributes, + auth: { + ...(attributes["auth"] as Record<string, unknown>), + sms_provider: "twilio", + sms_twilio_account_sid: null, + }, + }), + }), + }; + const declared = TWILIO_BEFORE.replace("enabled = false", "enabled = true") + .replace('account_sid = ""', 'account_sid = "ACdeclared"') + .replace('message_service_sid = ""', 'message_service_sid = "MGdeclared"'); + const { layer } = setup({ toml: declared, yes: true, v2: never }); + return withEnvVar( + TWILIO_AUTH_TOKEN_VAR, + "a-real-secret-value", + Effect.gen(function* () { + yield* configPull(noFlags); + const after = yield* readConfig; + expect(after).toContain("enabled = false"); + expect(after).toContain('account_sid = "ACdeclared"'); + + const second = setup({ toml: after, yes: true, v2: never }); + yield* configPull(noFlags).pipe(Effect.provide(second.layer)); + expect(second.out.stdoutText).toContain("No config differences found."); + }), + ).pipe(Effect.provide(layer)); + }); + it.live( "twilio scenario B: the schema-validation gate drops the whole family when a sibling stays unwritable (remote silent on message_service_sid), and the written file still reloads", () => { diff --git a/apps/cli/src/commands/config/push/push.branch-target.ts b/apps/cli/src/commands/config/push/push.branch-target.ts index 3cc428de05..74097558b2 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -145,7 +145,7 @@ export function resolveConfigPushTarget( ); // A definitive answer (200 or 404) clears the task; an uncertain one marks it failed // since the diagnostic step didn't complete, though the push proceeds regardless. - yield* (probe.kind === "unknown" ? probing?.fail() : probing?.clear()) ?? Effect.void; + yield* (probe.kind === "unknown" ? probing?.fail() : probing?.clear) ?? Effect.void; if (probe.kind === "project") { return { kind: "project", ref, ...(probe.name === undefined ? {} : { name: probe.name }) }; 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 61edcf9bff..4cddc7ba2e 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -3600,7 +3600,7 @@ describe("config push telemetry wiring", () => { const wiringLayer = (analytics: ReturnType<typeof mockAnalytics>, projectRef: string) => Layer.mergeAll( setup({ toml: `project_id = "test"\n`, yes: true, analytics }).layer, - commandRuntimeLayer(["config", "push"]), + commandRuntimeLayer(["config", "push"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed(["config", "push", "--project-ref", projectRef]), }), diff --git a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md index 1529a7a36f..4eb11d7469 100644 --- a/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/diff/SIDE_EFFECTS.md @@ -10,7 +10,9 @@ pending port. When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, -then destroys its namespace when the Effect scope closes. Stack shadows use the stack baseline +then destroys its namespace when the Effect scope closes. If its container engine is unreachable +then, the namespace is still removed and stderr lists the commands that remove its engine +resources. Stack shadows use the stack baseline cache described below. Native artifacts are shared through `$SUPABASE_HOME/cache/stack`; shadow state and data use the normal stack registry, so `stack list` and `stack destroy` can find a shadow left by an abrupt CLI exit. Each shadow owns a unique temporary project root @@ -55,7 +57,10 @@ it, and JSON `null` disables formatting without disabling safe compaction. ## Docker -- Edge-runtime container (migra engine only). +- 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. - 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 @@ -75,7 +80,8 @@ it, and JSON `null` disables formatting without disabling safe compaction. (`dockerfileServiceImage("differ")`). One `docker run --rm` when no `--schema` is given; one run per `--schema` value, in flag order. Runs on the project's Docker network (`--network-id` or the generated `supabase_network_<projectId>` — never the host network, unlike the migra - bash fallback), with `--add-host host.docker.internal:host-gateway` on Linux only, and both + bash fallback), so loopback source and shadow hosts become `host.docker.internal` unless + `--network-id host` is set, with `--add-host host.docker.internal:host-gateway` on Linux only, and both `com.supabase.cli.project`/`com.docker.compose.project` labels — no env vars, bind mounts, or working-directory override. diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index e7832c6886..a92de2a624 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -7,7 +7,7 @@ import { NetworkIdFlag, } from "../../../command-internal/global-flags.ts"; import { GoProxy } from "../../../command-internal/go-proxy.service.ts"; -import { detectGitBranch } from "../../../shared/git/git-branch.ts"; +import { branchClause, detectGitBranch } from "../../../shared/git/git-branch.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; @@ -23,6 +23,10 @@ import type { DbConnType } from "../../../command-internal/db-target-flags.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; import { makeDir } from "../../../command-internal/make-dir.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import { + rewriteDumpHostForToolContainer, + toolContainerUsesHostNetwork, +} from "../../../command-internal/postgres-client.run.ts"; import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; import { schemaToCsvField } from "../../../command-internal/schema-flags.ts"; import { findDropStatements } from "../../../command-internal/sql-split.ts"; @@ -625,13 +629,20 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { 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: targetUrl, + 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@127.0.0.1:${shadowBase.shadowPort}/postgres`, + target: `postgresql://postgres:postgres@${differHost("127.0.0.1")}:${shadowBase.shadowPort}/postgres`, schema: flags.schema, projectEnvValues: cfg.projectEnv, projectId: shadowBase.projectId, @@ -730,9 +741,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // Detect the branch from the resolved workdir, not the caller's CWD, so // `supabase --workdir … db diff` reports the project's branch, not the // directory the command was invoked from. - const branch = Option.getOrElse(yield* detectGitBranch(cliSettings.workdir), () => "main"); + const branch = yield* detectGitBranch(cliSettings.workdir); yield* output.raw( - `Finished ${aqua("supabase db diff")} on branch ${aqua(branch)}.\n\n`, + `Finished ${aqua("supabase db diff")}${branchClause(branch, aqua)}.\n\n`, "stderr", ); } 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 9cf0c5dbae..d3a6ff0c3a 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -529,10 +529,10 @@ function pgadminEntry(overrides: Record<string, unknown> = {}) { const PGADMIN_DIFF_SQL = `${PGADMIN_DIFF_HEADER}\n\nALTER TABLE test;\n`; // Matches `setup()`'s default resolver/shadow-port fixtures (conn 127.0.0.1:54322, -// shadow port 54320). +// shadow port 54320), as seen from the differ's project network. const PGADMIN_SOURCE_URL = - "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10"; -const PGADMIN_TARGET_URL = "postgresql://postgres:postgres@127.0.0.1:54320/postgres"; + "postgresql://postgres:postgres@host.docker.internal:54322/postgres?connect_timeout=10"; +const PGADMIN_TARGET_URL = "postgresql://postgres:postgres@host.docker.internal:54320/postgres"; describe("db diff", () => { it.effect("diffs local with the default migra engine and prints SQL to stdout", () => { @@ -544,7 +544,9 @@ describe("db diff", () => { expect(stdout(s.out)).toBe("create table players ();\n\n"); expect(stderr(s.out)).toContain("Creating shadow database..."); expect(stderr(s.out)).toContain("Diffing schemas..."); - expect(stderr(s.out)).toContain("Finished supabase db diff on branch"); + // The temp workdir sits outside any git checkout, so the branch is unknown and + // the "Finished" line omits the clause instead of falsely claiming "main". + expect(stderr(s.out)).toContain("Finished supabase db diff.\n"); expect(s.telemetry.flushed).toBe(true); const expectedHost = FAKE_SHADOW_CONTAINER_ID.slice(0, 12); expect(s.shadowSetupJobCalls.length).toBeGreaterThan(0); @@ -562,7 +564,7 @@ describe("db diff", () => { } } expect(sawHost).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe(Effect.provide(s.layer), (body) => withEnvVar("GITHUB_HEAD_REF", undefined, body)); }); it.effect("forwards SUPABASE_SSL_DEBUG=TRUE to the migra script as true", () => { @@ -1936,6 +1938,24 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "the migra OOM fallback on a named network targets loopback databases via the host", + () => { + const s = setup(tmp.current, { + oom: true, + diffSql: "create table fb ();\n", + isLocal: true, + networkId: "my-net", + }); + return Effect.gen(function* () { + yield* dbDiff(flags({ schema: ["public"] })); + const env = (s.dockerCalls[0] as { env: Readonly<Record<string, string>> }).env; + expect(new URL(env["SOURCE"] ?? "").hostname).toBe("host.docker.internal"); + expect(new URL(env["TARGET"] ?? "").host).toBe("host.docker.internal:54322"); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.live( "removes the shadow container on a SIGINT-style interruption during the readiness wait, without waiting for the readiness timeout", () => { @@ -2076,7 +2096,7 @@ describe("db diff", () => { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); expect(s.differCalls).toHaveLength(1); const call = s.differCalls[0] as DockerRunOpts; - expect(call.image).toBe(dockerfileServiceImage("differ")); + expect(call.image).toBe(dockerfileServiceImage("differ", false)); expect(call.image).toBe("supabase/pgadmin-schema-diff:cli-0.0.5"); expect(call.cmd).toEqual(["--json-diff", PGADMIN_SOURCE_URL, PGADMIN_TARGET_URL]); expect(call.env).toEqual({}); 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 cc13b717ef..c0518adc45 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 @@ -191,6 +191,7 @@ describe("supabase db diff (stack shadow baseline cache)", () => { home, ); assertSuccess(second, `${runtime} warm db diff`); + expect(second.stderr).not.toMatch(/baseline (?:unusable|not cached)/iu); expect(second.stdout).toMatch(/stack_cache_second/iu); expect(second.stdout).not.toMatch(/stack_cache_first/iu); diff --git a/apps/cli/src/commands/db/diff/pgadmin-diff.ts b/apps/cli/src/commands/db/diff/pgadmin-diff.ts index 4fc272ca5c..35b3e23dde 100644 --- a/apps/cli/src/commands/db/diff/pgadmin-diff.ts +++ b/apps/cli/src/commands/db/diff/pgadmin-diff.ts @@ -22,7 +22,8 @@ import { trimGoSpace } from "../shared/go-string.ts"; import { INTERNAL_SCHEMAS } from "../../../command-internal/pg-dump.env.ts"; import { DbDiffPgAdminError } from "./diff.errors.ts"; -const DIFFER_IMAGE = dockerfileServiceImage("differ"); +// `differ` has no slim build, so the slim flag never applies to it. +const DIFFER_IMAGE = dockerfileServiceImage("differ", false); /** * Only the front of the buffer is trimmed, not every occurrence — a real pgAdmin4 diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 17265bf83c..ba27b252c2 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -105,7 +105,9 @@ shell inherits the suppressing variables and is missed. - **Stack backend host rewrite.** Stack dumps always use catalog `pg_dump` (native artifact or a one-shot of the same image). `--local` native rewrites loopback to `127.0.0.1`. Native `--linked` / `--db-url` keep - the resolved host. Container dumps use `isLocal` (config host+port match), not a - `connType` of local: a `--db-url` that matches `config.toml` is rewritten like a published - stack target (`host.docker.internal` / host network) and does not require a - project stack. Engine/runtime selection still uses `connType`. + the resolved host. Managed `--local` dumps run through the stack's own command runtime + against its endpoint without a rewrite. Other container dumps rewrite any loopback host + (whatever its port) to `host.docker.internal` unless the tool container shares the Linux + host network, and always run on the host network unless `--network-id` is set. Legacy + container dumps rewrite loopback only when `--network-id` / `SUPABASE_NETWORK_ID` puts + pg_dump on a named network. Engine/runtime selection still uses `connType`. diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index 6d068f9165..0d92fec4fa 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -220,12 +220,16 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { ? undefined : envNetworkId, ); - const stackPublishedTarget = backend.kind === "stack" && isLocal && managedStack === undefined; + // Loopback inside a bridge-networked tool container is the container itself; stack + // targets are published by a host-side proxy that the Docker VM loopback never sees. + // A managed stack runs pg_dump through its own command runtime against its own endpoint. + const rewriteLoopbackTarget = + backend.kind === "stack" ? managedStack === undefined : !dumpUsesHostNetwork; const dumpConn = useNativeClient ? connType === "local" ? dumpConnForHostClient(conn) : conn - : stackPublishedTarget + : rewriteLoopbackTarget ? { ...conn, host: rewriteDumpHostForToolContainer(conn.host, { @@ -373,7 +377,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), projectEnvValues: projectEnv, client: dumpClient, - forceHostNetwork: stackPublishedTarget, + forceHostNetwork: backend.kind === "stack", }); }), ), @@ -397,7 +401,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { : (chunk) => output.rawBytes(chunk), projectEnvValues: projectEnv, client: dumpClient, - forceHostNetwork: stackPublishedTarget, + forceHostNetwork: backend.kind === "stack", }); // 7b. IPv6 → IPv4-pooler retry, shared with `db pull`: a linked dump can reach the 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 795efa8de8..af9f38fc3b 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -1,7 +1,18 @@ import process from "node:process"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Stream } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, + Schema, + Stream, +} from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; @@ -33,7 +44,9 @@ import { DockerRunError } from "../../../command-internal/docker-run.errors.ts"; import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker-run.service.ts"; import { BundledPostgresClient } from "../../../command-internal/bundled-postgres-client.ts"; import type { RunPostgresClientOptions } from "../../../command-internal/bundled-postgres-client.ts"; -import type { DatabaseInstance, ServiceCreation, Stack } from "@supabase/stack/effect"; +import type { DatabaseInstance, ServiceCreation, Stack, StackError } from "@supabase/stack/effect"; +import type { InitializationCommand, PostgresCommand } from "@supabase/stack/commands"; +import type { InitializationCommandOptions, PostgresCommandOptions } from "@supabase/stack/effect"; import type { DbDumpFlags } from "./dump.command.ts"; import { dbDump } from "./dump.handler.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; @@ -66,6 +79,26 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { }, endpoints: { sql: { port: 54322 } }, }; + function runCommand<E, R>( + command: PostgresCommand, + options: PostgresCommandOptions<E, R>, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + function runCommand<E = never, R = never>( + command: InitializationCommand, + options?: InitializationCommandOptions<E, R>, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + function runCommand<E, R>( + command: PostgresCommand | InitializationCommand, + options?: PostgresCommandOptions<E, R> | InitializationCommandOptions<E, R>, + ) { + if ("type" in command) return Effect.die("initializer is unused"); + if (options?.stdout === undefined) return Effect.die("Postgres command requires a stdout sink"); + const stdout = options.stdout; + return Effect.gen(function* () { + yield* stdout(new TextEncoder().encode('CREATE TABLE "public" (id integer);\n')); + return { jobId: "job", exitCode: 0 }; + }); + } const database: DatabaseInstance = { id, service: "database", @@ -107,6 +140,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { }, credentials: { get: Effect.die("unused") }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed({ members: [{ id, activation: "eager" as const }], dependencies: [], @@ -118,40 +152,28 @@ const managedDumpStackApi = (runtime: "native" | "docker") => { restart: Effect.succeed([]), }, stop: Effect.void, - destroy: Effect.void, - tools: { - run: <E, R>( - _tool: { readonly command: string; readonly major: number }, - options: { - readonly stdout: (bytes: Uint8Array) => Effect.Effect<void, E, R>; - readonly stderr: (bytes: Uint8Array) => Effect.Effect<void, E, R>; - }, - ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E, R> => - Effect.gen(function* () { - yield* options.stdout(new TextEncoder().encode('CREATE TABLE "public" (id integer);\n')); - return { jobId: "job", exitCode: 0 }; - }), - }, + destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), + commands: { run: runCommand }, } satisfies Stack; return Layer.succeed(StackApi, { create: () => Effect.succeed(stack), open: () => Effect.succeed(stack), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id, identity: { projectRoot: "/work/project", branchContext: "main", stackName: "default" }, runtime, instances: [], composition: { members: [{ id, activation: "eager" as const }], dependencies: [] }, + lifetime: "detached" as const, ports: [], }, host: undefined, - }, - ]), - resolveIdentity: () => - Effect.succeed({ projectRoot: "/work/project", branchContext: "main", stackName: "default" }), + }), + ), }); }; @@ -159,7 +181,7 @@ const unusedStackApi = Layer.succeed(StackApi, { create: () => Effect.die("unused"), open: () => Effect.die("unused"), discover: () => Effect.die("unused"), - resolveIdentity: () => Effect.die("unused"), + find: () => Effect.die("unused"), }); function mockResolver(opts: { @@ -786,6 +808,46 @@ describe("db dump integration", () => { }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); }); + it.live("points a Windows tool container at the host for a loopback stack db-url", () => { + const conn = { + host: "127.0.0.1", + port: 55432, + user: "postgres", + password: "postgres", + database: "postgres", + }; + const { layer, bundled } = setup({ conn, isLocal: false, platform: "win32", stdout: "" }); + return Effect.gen(function* () { + yield* dbDump( + flags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:55432/postgres") }), + ); + expect(bundled.lastOpts?.runtime).toEqual({ kind: "container", engine: "docker" }); + expect(bundled.lastOpts?.network).toBe("host"); + expect(bundled.lastOpts?.env).toMatchObject({ + PGHOST: "host.docker.internal", + PGPORT: "55432", + }); + }).pipe(Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack")))); + }); + + it.live("points a named-network legacy tool container at the host for a loopback db-url", () => { + const { layer, docker } = setup({ + conn: { ...LOCAL_CONN, port: 55432 }, + isLocal: false, + networkId: "custom_net", + }); + return Effect.gen(function* () { + yield* dbDump( + flags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:55432/postgres") }), + ); + expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "custom_net" }); + expect(docker.lastOpts?.env).toMatchObject({ + PGHOST: "host.docker.internal", + PGPORT: "55432", + }); + }).pipe(Effect.provide(layer)); + }); + it.live("caches the linked project even when connection resolution fails (Go PostRun)", () => { // The project ref is resolved before the connection is built, and the // linked-project cache is refreshed unconditionally afterward. So an @@ -1084,13 +1146,24 @@ describe("db dump integration", () => { // A shell pipeline gets a genuine FIFO for the pipe probe below; Bun's spawnSync // "pipe" stdio is a socketpair, which fstats as a socket instead. - const PROBE = 'process.stdout.write(String(require("node:fs").fstatSync(1).isFIFO()));'; + const ttyProbe = Effect.gen(function* () { + const path = yield* Path.Path; + const module = (file: string) => + path + .fromFileUrl(new URL(`../../../shared/runtime/${file}`, import.meta.url)) + .pipe(Effect.flatMap(Schema.encodeEffect(Schema.fromJsonString(Schema.String)))); + const service = yield* module("tty.service.ts"); + const layer = yield* module("tty.layer.ts"); + return `import { Effect } from "effect"; import { Tty } from ${service}; import { ttyLayer } from ${layer}; Effect.runPromise(Tty.pipe(Effect.provide(ttyLayer))).then((tty) => process.stdout.write(String(tty.stdoutIsPipe)));`; + }); it.live.skipIf(process.platform === "win32")("classifies a real piped stdout as a pipe", () => Effect.gen(function* () { + const probe = yield* ttyProbe; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const child = yield* spawner.spawn( - ChildProcess.make("/bin/sh", ["-c", `"${process.execPath}" -e '${PROBE}' | cat`], { + ChildProcess.make("/bin/sh", ["-c", '"$1" -e "$2" | cat', "sh", process.execPath, probe], { + cwd: import.meta.dirname, stdin: "ignore", stderr: "ignore", }), @@ -1110,13 +1183,19 @@ describe("db dump integration", () => { Effect.gen(function* () { const path = yield* Path.Path; const file = path.join(tmp.current, "pipe-probe.txt"); + const probe = yield* ttyProbe; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const exitCode = yield* spawner.exitCode( - ChildProcess.make("/bin/sh", ["-c", `"${process.execPath}" -e '${PROBE}' > "${file}"`], { - stdin: "ignore", - stdout: "ignore", - stderr: "inherit", - }), + ChildProcess.make( + "/bin/sh", + ["-c", '"$1" -e "$2" > "$3"', "sh", process.execPath, probe, file], + { + cwd: import.meta.dirname, + stdin: "ignore", + stdout: "ignore", + stderr: "inherit", + }, + ), ); expect(exitCode).toBe(0); expect(yield* readUtf8(file)).toBe("false"); diff --git a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md index b159d490d2..07ae92efc8 100644 --- a/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/pull/SIDE_EFFECTS.md @@ -16,11 +16,16 @@ runs the same in-process declarative export (`supabase/schemas` plus When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, -then destroys its namespace when the Effect scope closes. Stack shadows use the stack baseline +then destroys its namespace when the Effect scope closes. If its container engine is unreachable +then, the namespace is still removed and stderr lists the commands that remove its engine +resources. Stack shadows use the stack baseline cache described below. Native artifacts are shared through `$SUPABASE_HOME/cache/stack`; shadow state and data use the normal stack registry, so `stack list` and `stack destroy` can find a shadow left by an abrupt CLI exit. Each shadow owns a unique temporary project root and uses an automatically assigned port; `db.shadow_port` applies only to the legacy backend. Migra (`--diff-engine migra`) is rejected in stack mode. +A `--db-url` whose host and port match the running project stack's SQL endpoint (the `DB_URL` +from `stack status --env`) is a local target and connects in plaintext, like `--local`; a remote +or multi-host `--db-url` without an explicit `sslmode` still requires TLS. Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them fatal, while `PGDELTA_DEBUG` writes diagnostic JSON under @@ -69,8 +74,10 @@ disables formatting without disabling safe compaction. below. Migration-style pulls only; `--declarative` provisions no shadow. - `supabase/migra` container — the migra OOM bash fallback only. - `pg_dump` container — the initial-migra pull's native remote-schema dump - (`streamPgDumpWithClient`, shared with `db dump`). Stack-backed pulls skip dump - seeding: they always use pg-delta and reject `--diff-engine migra`. + (`streamPgDumpWithClient`, shared with `db dump`). It runs on the host network unless + `--network-id` / `SUPABASE_NETWORK_ID` names another network; on a named network a loopback + target is rewritten to `host.docker.internal`. Stack-backed pulls skip dump seeding: they + always use pg-delta and reject `--diff-engine migra`. ### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default ON) @@ -122,7 +129,7 @@ at all, so nothing is cached for it. | `SUPABASE_DB_SHADOW_PORT` | shadow container's host port (`db.shadow_port`) — NOT `SUPABASE_DB_PORT`, which the shadow never reads | no | | `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | shadow container-config overrides, same as `db start`/`db reset` | no | | `SUPABASE_PROJECT_ID` | overrides the shadow container's project id/labels, same as `db start`/`db reset` (`utils.DbId`); ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow and initial-migra `pg_dump` containers onto an existing Docker network | no | | `SUPABASE_USE_SLIM_IMAGES` | resolves the current-pin shadow Postgres, `pg_dump`, PG15+ realtime/storage/auth migrate-job images (migration-style cold shadow), and (for migra) the edge-runtime image from the slim `ghcr.io/supabase/cli` builds (`true`/`1` enable); majors 13/15 use `15.14.1.167` when the flag is on; historical pins, PG14, OrioleDB, flag-off `15.8.1.085`, and `deno_version = 1` stay on docker.io | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline state is saved under a managed snapshot key and restored into the next run's fresh stack database (see Notes) | no | 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 5ef00703ed..e6f6b6b7b0 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -124,6 +124,8 @@ interface SetupOpts { // Raw argv seen by the handler (CliArgs). Only consulted when both `--declarative` // and `--use-pg-delta` are present, to replay pflag's last-occurrence-wins ordering. readonly args?: ReadonlyArray<string>; + readonly networkId?: string; + readonly platform?: NodeJS.Platform; // `CommandSettings.projectId`; defaults to `Option.some("test")`. Pass // `Option.none()` to exercise the config.toml/workdir-basename fallback // (`resolveLocalProjectId`). @@ -483,13 +485,13 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(ExperimentalFlag, opts.experimental ?? false), Layer.succeed(DebugFlag, false), Layer.succeed(DnsResolverFlag, "native"), - Layer.succeed(NetworkIdFlag, Option.none()), + Layer.succeed(NetworkIdFlag, Option.fromUndefinedOr(opts.networkId)), Layer.succeed(PgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), - mockRuntimeInfo(), + mockRuntimeInfo(opts.platform === undefined ? {} : { platform: opts.platform }), workdirFiles, ); return { @@ -1624,6 +1626,38 @@ describe("db pull", () => { }, ); + it.effect("points a named-network pg_dump container at the host for a loopback target", () => { + const s = setup(tmp.current, { + files: { + "supabase/.env": "SUPABASE_NETWORK_ID=dotenv-net\n", + }, + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "", + yes: true, + }); + return Effect.gen(function* () { + yield* dbPull(flags({ local: Option.some(true) })); + expect(s.dumpCalls[0]?.network).toEqual({ _tag: "named", name: "dotenv-net" }); + expect(s.dumpCalls[0]?.env["PGHOST"]).toBe("host.docker.internal"); + }).pipe(Effect.provide(s.layer), (body) => withEnvVar("SUPABASE_NETWORK_ID", undefined, body)); + }); + + it.effect("keeps a loopback target for a Linux pg_dump container on --network-id host", () => { + const s = setup(tmp.current, { + networkId: "host", + platform: "linux", + remoteVersions: [], + dumpStdout: "create table dumped ();\n", + edgeStdout: "", + yes: true, + }); + return Effect.gen(function* () { + yield* dbPull(flags({ local: Option.some(true) })); + expect(s.dumpCalls[0]?.env["PGHOST"]).toBe("127.0.0.1"); + }).pipe(Effect.provide(s.layer), (body) => withEnvVar("SUPABASE_NETWORK_ID", undefined, body)); + }); + it.effect("an explicit --yes=false overrides SUPABASE_YES and honors the piped answer", () => { // An explicit `--yes=false` wins over the SUPABASE_YES env — a piped `n` still // declines the history update rather than auto-confirming. diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 1e28ccd443..67ca69a486 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -155,6 +155,7 @@ function setup( files?: Readonly<Record<string, string>>; format?: OutputFormat; confirm?: ReadonlyArray<boolean>; + piped?: string; args?: ReadonlyArray<string>; yes?: boolean; isLocal?: boolean; @@ -233,10 +234,9 @@ function setup( }), BunServices.layer, // Prompts are answered through mockOutput's `promptConfirmResponses` (the - // TTY/clack path); Stdin is only used by promptYesNo's non-TTY branch (unreached - // here). - mockTty({ stdinIsTty: true }), - mockStdin(true), + // TTY/clack path) unless `piped` feeds promptYesNo's non-TTY branch. + mockTty({ stdinIsTty: opts.piped === undefined }), + mockStdin(opts.piped === undefined, opts.piped), Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), Layer.succeed(YesFlag, opts.yes ?? false), Layer.succeed(DnsResolverFlag, "native"), @@ -376,6 +376,37 @@ describe("db push", () => { }); }); + it.live.each(["u", "yess", "nope", " ", "n", "no"])( + "applies nothing when the piped answer is %j", + (answer) => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + piped: `${answer}\n`, + }); + return Effect.gen(function* () { + const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(conn.execs).not.toContain("BEGIN"); + }); + }, + ); + + it.live.each(["y\n", "yes\n", "\n", ""])( + "applies migrations when the piped answer is %j", + (piped) => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + piped, + }); + return Effect.gen(function* () { + yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(conn.execs).toContain("BEGIN"); + }); + }, + ); + it.live("prints the plan without applying in dry-run mode", () => { const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', 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 6966113319..4e960d99b1 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -28,6 +28,7 @@ import { import { VALID_REF, jsonResponse, + withConfigEnv, withEnvVar, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -61,13 +62,14 @@ import { StackCatalogSetup, type StackCatalogSetupInput, } from "../../../command-internal/stack-catalog-setup.ts"; -import type { - ServiceCreation, - ServiceInstance, - ServiceInstances, - StackCredentials, - Stack, - Observation, +import { + StackError, + type ServiceCreation, + type ServiceInstance, + type ServiceInstances, + type StackCredentials, + type Stack, + type Observation, } from "@supabase/stack/effect"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; @@ -544,12 +546,13 @@ const serviceCreation = ( const stackService = ( id: string, creation: Extract<ServiceCreation, { readonly service: ResetServiceKind }>, - observation: Effect.Effect<Observation>, + observation: Effect.Effect<Observation, StackError>, overrides: { readonly start?: Effect.Effect<void>; readonly ready?: Effect.Effect<void>; readonly stop?: Effect.Effect<void>; readonly resetData?: Effect.Effect<void>; + readonly credentials?: Effect.Effect<Readonly<Record<string, string>>, StackError>; } = {}, ): StackService => { const base = { @@ -571,6 +574,7 @@ const stackService = ( ...base, service: "database", credentials: () => + overrides.credentials ?? Effect.succeed({ databaseUrl: "postgresql://postgres:postgres@127.0.0.1:5432/postgres" }), saveSnapshot: () => Effect.die("unused"), restoreSnapshot: () => Effect.die("unused"), @@ -637,6 +641,10 @@ function mockResetStackApi(opts: { readonly apiEndpoint?: { readonly url: string; readonly port: number }; /** Models the `db start` overlay, whose composition holds only the database. */ readonly postgresOnly?: boolean; + /** Makes the database address unavailable once the reset has started it again. */ + readonly unavailableAfterReset?: boolean; + /** Fails every database status read, as an unreachable or mismatched owner does. */ + readonly statusFailure?: StackError; }) { let resetCalls = 0; let stopCalls = 0; @@ -704,13 +712,16 @@ function mockResetStackApi(opts: { }), ), ); - const dbStatus = Effect.sync(() => - makeStackObservation(DB_ID, database, { - lifecycle: databaseRunning ? "running" : "stopped", - health: databaseRunning ? "healthy" : undefined, - endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54329 }], - }), - ); + const dbStatus = + opts.statusFailure === undefined + ? Effect.sync(() => + makeStackObservation(DB_ID, database, { + lifecycle: databaseRunning ? "running" : "stopped", + health: databaseRunning ? "healthy" : undefined, + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54329 }], + }), + ) + : Effect.fail(opts.statusFailure); const db = stackService(DB_ID, database, dbStatus, { resetData: Effect.suspend(() => databaseRunning @@ -724,6 +735,13 @@ function mockResetStackApi(opts: { startCalls++; }), ready: Effect.void, + ...(opts.unavailableAfterReset === true + ? { + credentials: Effect.fail( + new StackError({ operation: "credentials", message: "owner unavailable" }), + ), + } + : {}), }); const composed: Array<StackService> = [ db, @@ -757,6 +775,7 @@ function mockResetStackApi(opts: { }, credentials: { get: Effect.succeed(RESET_STACK_CREDENTIALS) }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed({ members: members.map(({ id }, index) => ({ id, @@ -789,27 +808,30 @@ function mockResetStackApi(opts: { }, stop: Effect.die("unused"), destroy: Effect.die("unused"), - tools: { run: () => Effect.die("unused") }, + commands: { run: () => Effect.die("unused") }, }; return { layer: Layer.succeed(StackApi, { create: () => Effect.die("unused"), - resolveIdentity: () => - Effect.succeed({ projectRoot: opts.workdir, branchContext: "main", stackName: "default" }), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id: RESET_STACK_ID, identity: { projectRoot: opts.workdir, branchContext: "main", stackName: "default" }, runtime: "native" as const, - instances: members.map((member) => ({ id: member.id, creation: {} })), + instances: members.map((member) => ({ + id: member.id, + creation: { service: "mail" as const, config: {} }, + })), + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }, host: undefined, - }, - ]), + }), + ), open: () => Effect.succeed(stack), }), get resetCalls() { @@ -880,6 +902,8 @@ function setup( stackStorageError?: string; stackApiEndpoint?: { readonly url: string; readonly port: number }; stackPostgresOnly?: boolean; + stackUnavailableAfterReset?: boolean; + stackStatusFailure?: StackError; httpClient?: Layer.Layer<HttpClient.HttpClient>; }, ) { @@ -922,6 +946,8 @@ function setup( storageError: opts.stackStorageError, apiEndpoint: opts.stackApiEndpoint, postgresOnly: opts.stackPostgresOnly, + unavailableAfterReset: opts.stackUnavailableAfterReset, + statusFailure: opts.stackStatusFailure, }); const catalog = opts.stackBackend === true @@ -1038,31 +1064,36 @@ describe("db reset", () => { args: ["db", "reset", "--local"], isLocal: true, }); - return Effect.gen(function* () { - yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Resetting local database..."); - expect(out.stderrText).toContain("Recreating database...\n"); - expect(removedContainers(child.spawned)).toContain(DB_ID); - expect(removedVolumes(child.spawned)).toContain(DB_ID); - expect(createArgs(child.spawned)).not.toBeUndefined(); - // Default config: realtime, storage, and auth are all enabled (PG >= 15 default). - expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); - expect(out.stderrText).toContain("Restarting containers...\n"); - // Satellite restarts (storage/auth/realtime/pooler), then Kong reload. - expect(restartedContainers(child.spawned)).toEqual( - expect.arrayContaining([ - "supabase_storage_test", - "supabase_auth_test", - "supabase_realtime_test", - "supabase_pooler_test", - ]), - ); - expect(kongReloadCalls(child.spawned)).toHaveLength(1); - expect(out.stderrText).toContain("Finished "); - expect(out.stderrText).toContain("on branch "); - // Confirms the single `Effect.ensuring` finalizer still fires exactly once. - expect(telemetry.flushCount).toBe(1); - }); + return withEnvVar( + "GITHUB_HEAD_REF", + undefined, + Effect.gen(function* () { + yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting local database..."); + expect(out.stderrText).toContain("Recreating database...\n"); + expect(removedContainers(child.spawned)).toContain(DB_ID); + expect(removedVolumes(child.spawned)).toContain(DB_ID); + expect(createArgs(child.spawned)).not.toBeUndefined(); + // Default config: realtime, storage, and auth are all enabled (PG >= 15 default). + expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); + expect(out.stderrText).toContain("Restarting containers...\n"); + // Satellite restarts (storage/auth/realtime/pooler), then Kong reload. + expect(restartedContainers(child.spawned)).toEqual( + expect.arrayContaining([ + "supabase_storage_test", + "supabase_auth_test", + "supabase_realtime_test", + "supabase_pooler_test", + ]), + ); + expect(kongReloadCalls(child.spawned)).toHaveLength(1); + // The temp workdir sits outside any git checkout, so the branch is unknown and + // the "Finished" line omits the clause instead of falsely claiming "main". + expect(out.stderrText).toContain("Finished supabase db reset.\n"); + // Confirms the single `Effect.ensuring` finalizer still fires exactly once. + expect(telemetry.flushCount).toBe(1); + }), + ); }); it.live( @@ -1356,6 +1387,67 @@ describe("db reset", () => { }); }); + it.live("reports a stack whose owner is absent as not running", () => { + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackStatusFailure: new StackError({ + operation: "status", + message: "Stack owner is not running", + reason: "owner-unavailable", + }), + }); + return Effect.gen(function* () { + const error = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ + _tag: "ResetLocalDbNotRunningError", + message: "The local stack is not running.", + }); + expect(stackApi.resetCalls).toBe(0); + }); + }); + + it.live("surfaces a release mismatch instead of reporting the stack as not running", () => { + const message = + "Stack test is served by release 1.0.0+old, but this client is release 1.0.0+new; stop or destroy the stack (both work across releases) and retry"; + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackStatusFailure: new StackError({ + operation: "status", + message, + reason: "release-mismatch", + }), + }); + return Effect.gen(function* () { + const error = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ _tag: "StackError", message }); + expect(stackApi.resetCalls).toBe(0); + }); + }); + + it.live("reports a database that becomes unavailable after the reset as not running", () => { + const { layer, stackApi } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--local"], + isLocal: true, + stackBackend: true, + stackUnavailableAfterReset: true, + }); + return Effect.gen(function* () { + const error = yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.flip); + expect(error).toMatchObject({ + _tag: "ResetLocalDbNotRunningError", + message: "owner unavailable", + }); + expect(stackApi.resetCalls).toBe(1); + }); + }); + const BUCKET_TOML = ['project_id = "test"', "[storage.buckets.dogfood]", "public = true"].join( "\n", ); @@ -1831,9 +1923,8 @@ describe("db reset", () => { args: ["db", "reset", "--local"], isLocal: true, }); - return withEnvVar( - "GITHUB_HEAD_REF", - "feature-x", + return withConfigEnv( + { GITHUB_HEAD_REF: "feature-x" }, Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("on branch "); @@ -2897,9 +2988,48 @@ describe("db reset", () => { }, ); - const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + // Windows ignores POSIX modes and root bypasses them; both get an injected failure instead. + const posixModesEnforced = process.platform !== "win32" && process.getuid?.() !== 0; + + /** Denies `method` on `target` for the enclosing scope and returns the FileSystem to run with. */ + const denyAccess = Effect.fnUntraced(function* ( + target: string, + method: "readFileString" | "readDirectory", + restoreMode: number, + ) { + const fs = yield* FileSystem.FileSystem; + if (posixModesEnforced) { + yield* Effect.acquireRelease(fs.chmod(target, 0o000), () => + fs.chmod(target, restoreMode).pipe(Effect.orDie), + ); + return fs; + } + const denied = (p: string) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + pathOrDescriptor: p, + description: "permission denied", + }), + ); + const overridden: FileSystem.FileSystem = + method === "readFileString" + ? { + ...fs, + readFileString: (p, encoding) => + p === target ? denied(p) : fs.readFileString(p, encoding), + } + : { + ...fs, + readDirectory: (p, options) => + p === target ? denied(p) : fs.readDirectory(p, options), + }; + return overridden; + }); - it.live.skipIf(isRoot)( + it.live( "does not attach the schema-file suggestion when a schema file cannot be READ on an experimental remote reset", () => { const { layer, conn } = setup(tmp.current, { @@ -2913,35 +3043,31 @@ describe("db reset", () => { const schemaFile = path.join(tmp.current, "supabase", "schemas", "01_users.sql"); yield* fs.makeDirectory(path.dirname(schemaFile), { recursive: true }); yield* fs.writeFileString(schemaFile, "create table schema_users ();"); - yield* Effect.acquireUseRelease( - fs.chmod(schemaFile, 0o000), - () => - Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).not.toContain("See schema file"); - expect(Cause.findErrorOption(exit.cause)).not.toEqual( - Option.some( - expect.objectContaining({ - suggestion: expect.stringContaining("See schema file"), - }), - ), - ); - } - expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); - }), - () => fs.chmod(schemaFile, 0o644), + const deniedFs = yield* denyAccess(schemaFile, "readFileString", 0o644); + const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provideService(FileSystem.FileSystem, deniedFs), + Effect.provide(layer), + Effect.exit, ); - }).pipe(Effect.provide(BunServices.layer)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("failed to open migration file"); + expect(causeText).not.toContain("See schema file"); + expect(Cause.findErrorOption(exit.cause)).not.toEqual( + Option.some( + expect.objectContaining({ + suggestion: expect.stringContaining("See schema file"), + }), + ), + ); + } + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }, ); - it.live.skipIf(isRoot)( + it.live( "fails an experimental remote reset (without silently succeeding) when a matched schema_paths directory cannot be walked", () => { const { layer, conn } = setup(tmp.current, { @@ -2958,33 +3084,28 @@ describe("db reset", () => { path.join(schemasDir, "01_users.sql"), "create table schema_users ();", ); - yield* Effect.acquireUseRelease( - fs.chmod(schemasDir, 0o000), - () => - Effect.gen(function* () { - const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("failed to walk matched directory"); - expect(causeText).not.toContain("See schema file"); - expect(Cause.findErrorOption(exit.cause)).not.toEqual( - Option.some( - expect.objectContaining({ - suggestion: expect.stringContaining("See schema file"), - }), - ), - ); - } - expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); - expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); - }), - () => fs.chmod(schemasDir, 0o755), + const deniedFs = yield* denyAccess(schemasDir, "readDirectory", 0o755); + const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provideService(FileSystem.FileSystem, deniedFs), + Effect.provide(layer), + Effect.exit, ); - }).pipe(Effect.provide(BunServices.layer)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeText = Cause.pretty(exit.cause); + expect(causeText).toContain("failed to walk matched directory"); + expect(causeText).not.toContain("See schema file"); + expect(Cause.findErrorOption(exit.cause)).not.toEqual( + Option.some( + expect.objectContaining({ + suggestion: expect.stringContaining("See schema file"), + }), + ), + ); + } + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }, ); @@ -3218,7 +3339,10 @@ describe("db reset", () => { linked: true, sqlPaths: [absSeed], }).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + // Seed paths are reported forward-slashed on every platform, drive letter included. + expect(out.stderrText).toContain( + `Seeding data from ${absSeed.replaceAll(path.sep, "/")}...`, + ); }).pipe(Effect.provide(BunServices.layer)); }); diff --git a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts index fa8660e2a3..f86b7e44f8 100644 --- a/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.stack.e2e.test.ts @@ -7,7 +7,7 @@ import { create as createStack } from "@supabase/stack/effect"; import { tmpdir } from "node:os"; import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; -import { destroyTestStack } from "../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const COMMAND_TIMEOUT_MS = 8 * 60_000; const TEST_TIMEOUT_MS = COMMAND_TIMEOUT_MS + 2 * 60_000; @@ -136,7 +136,6 @@ const composeStack = Effect.fn("DbResetStackE2e.composeStack")(function* ( { service: "rest", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: JWT_SECRET, }, endpoints: { http: { port: "auto" } }, @@ -144,7 +143,6 @@ const composeStack = Effect.fn("DbResetStackE2e.composeStack")(function* ( { service: "auth", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: JWT_SECRET, }, endpoints: { http: { port: "auto" } }, diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts index e3b9301104..239c3c55fc 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts @@ -1,4 +1,8 @@ import { schemaToCsvField } from "../../../../command-internal/schema-flags.ts"; +import { + shellQuoteArgument, + type ShellPlatform, +} from "../../../../command-internal/shell-quote.ts"; import { declaredSqlExtensions, maskSqlComments, @@ -226,19 +230,10 @@ export function classifyDeclarativeLoadCompatibility(opts: { export const extensionDeclaration = (extension: string): string => `CREATE EXTENSION IF NOT EXISTS "${extension}" WITH SCHEMA "extensions";`; -/** - * Shell family the recovery commands are rendered for: POSIX gets `rm -rf`/`mv` with `&&` and - * backslash continuations; Windows gets single-line PowerShell (`Remove-Item`/`Move-Item` with - * `;`), since the staged-upgrade recipe must be runnable exactly as printed. - */ -export type ShellPlatform = "posix" | "windows"; - -export const currentShellPlatform = (): ShellPlatform => - process.platform === "win32" ? "windows" : "posix"; - export interface StagedExportContext { readonly declarativeDir: string; readonly schema: ReadonlyArray<string>; + /** POSIX gets `rm -rf`/`mv` chains; Windows gets single-line PowerShell, runnable as printed. */ readonly platform: ShellPlatform; } @@ -264,17 +259,6 @@ export const resolveStagedDeclarativeDir = (declarativeDir: string): string => { return `${trimmed === "" ? declarativeDir : trimmed}-next`; }; -const BARE_SAFE_ARGUMENT = /^[a-zA-Z0-9_./:@%+=,-]+$/; - -function shellQuoteArgument(value: string, platform: ShellPlatform): string { - if (BARE_SAFE_ARGUMENT.test(value)) return value; - // PowerShell single-quoted strings escape a quote by doubling it; POSIX - // shells need the classic '"'"' dance. - return platform === "windows" - ? `'${value.replaceAll("'", "''")}'` - : `'${value.replaceAll("'", `'"'"'`)}'`; -} - function schemaArguments(schema: ReadonlyArray<string>, platform: ShellPlatform): string { return schema .map((name) => ` --schema ${shellQuoteArgument(schemaToCsvField(name), platform)}`) 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 bd4ea52e14..5d2de28330 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts @@ -13,11 +13,11 @@ import { LoadPgDeltaSqlFiles, ReadPgDeltaExportManifest } from "../../shared/pgd import { DeclarativeCompatibilityError, DeclarativeDiffError } from "./declarative.errors.ts"; import { classifyDeclarativeLoadCompatibility, - currentShellPlatform, formatDeclarativeUpgradeGate, type DeclarativeLoadCompatibilityFinding, type DeclarativeUpgradeGateText, } from "./declarative.flow.ts"; +import { currentShellPlatform } from "../../../../command-internal/shell-quote.ts"; /** Ambient inputs shared by the orchestration steps. */ export interface DeclarativeRunContext { 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 d1dc4bab04..7344c9711b 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 @@ -154,6 +154,7 @@ function generateStackApi(workdir: string) { }, credentials: { get: unusedStack }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed(composition), supabase: unusedStackFn, configure: unusedStackFn, @@ -163,27 +164,28 @@ function generateStackApi(workdir: string) { }, stop: unusedStack, destroy: unusedStack, - tools: { run: unusedStackFn }, + commands: { run: unusedStackFn }, }; const identity = { projectRoot: workdir, branchContext: "main", stackName: "default" }; return Layer.succeed(StackApi, { create: unusedStackFn, open: () => Effect.succeed(stack), - resolveIdentity: () => Effect.succeed(identity), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id: stack.id, identity, runtime: "native", instances: [], composition, + lifetime: "detached" as const, ports: [], }, host: undefined, - }, - ]), + }), + ), }); } diff --git a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 0e04a63815..d9fb9bdfd1 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -6,7 +6,9 @@ as a new timestamped migration. When `[experimental].stack` is on, each shadow uses a fresh, invocation-owned stack namespace with an automatically assigned port. State and data live under `$SUPABASE_HOME/stacks`, and native artifacts are shared through `$SUPABASE_HOME/cache/stack`. The command destroys its -shadow namespaces when its Effect scope closes. An abrupt process exit can leave a namespace +shadow namespaces when its Effect scope closes; if their container engine is unreachable then, the +namespaces are still removed and stderr lists the commands that remove their engine resources. +An abrupt process exit can leave a namespace visible to `stack list` for manual `stack destroy` cleanup. Stack shadows use the stack baseline cache described below; `--no-cache` bypasses it as well as the legacy backend cache. 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 37a2a84c0c..cdbd06e22a 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 @@ -54,7 +54,6 @@ import { } from "../declarative.errors.ts"; import { classifyDeclarativeCompatibilityGap, - currentShellPlatform, formatDeclarativeGapEvidence, formatDeclarativeUpgradeGate, formatStagedExportAdoption, @@ -62,6 +61,7 @@ import { resolveDeclarativeMigrationName, resolveDeclarativeSyncApplyDecision, } from "../declarative.flow.ts"; +import { currentShellPlatform } from "../../../../../command-internal/shell-quote.ts"; import { warnFormerDeclarativeDefault } from "../declarative.former-default.ts"; import { appendExtensionDeclarations } from "../declarative.extension-repair.ts"; import { requirePgDelta } from "../declarative.gate.ts"; 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 c276ec3fe2..85ebc7eaaa 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 @@ -156,6 +156,7 @@ function syncStackApi(workdir: string, port: number) { }, credentials: { get: unusedSync }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed(composition), supabase: unusedSyncFn, configure: unusedSyncFn, @@ -165,27 +166,28 @@ function syncStackApi(workdir: string, port: number) { }, stop: unusedSync, destroy: unusedSync, - tools: { run: unusedSyncFn }, + commands: { run: unusedSyncFn }, }; const identity = { projectRoot: workdir, branchContext: "main", stackName: "default" }; return Layer.succeed(StackApi, { create: unusedSyncFn, open: () => Effect.succeed(stack), - resolveIdentity: () => Effect.succeed(identity), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id: stack.id, identity, runtime: "native", instances: [], composition, + lifetime: "detached" as const, ports: [], }, host: undefined, - }, - ]), + }), + ), }); } diff --git a/apps/cli/src/commands/db/shared/migra.deno-templates.ts b/apps/cli/src/commands/db/shared/migra.deno-templates.ts index 786acfe77d..bb3a80c851 100644 --- a/apps/cli/src/commands/db/shared/migra.deno-templates.ts +++ b/apps/cli/src/commands/db/shared/migra.deno-templates.ts @@ -1,11 +1,11 @@ // Embedded templates for the migra rollback path (`db diff --use-migra`, -// `db pull --diff-engine migra`). `migra.deno-templates.unit.test.ts` checks -// that `migraDiffScript` contains its error sentinel; neither template's -// content is pinned by a test. +// `db pull --diff-engine migra`). `migra.deno-templates.unit.test.ts` runs +// `migraDiffScript` against in-memory pools to pin its per-connection session +// settings and error sentinel; `migraDiffShellScript` is not pinned by a test. /** `templates/migra.ts` — diffs SOURCE→TARGET via @pgkit/migra inside Edge Runtime. */ export const migraDiffScript = - 'import { createClient, sql } from "npm:@pgkit/client";\nimport { Migration } from "npm:@pgkit/migra";\n\n// Avoids error on self-signed certificate\nconst ca = Deno.env.get("SSL_CA");\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\nconst sslDebug = Deno.env.get("SUPABASE_SSL_DEBUG")?.toLowerCase() === "true";\n\nfunction redactPostgresUrl(raw: string | undefined): string {\n if (!raw) return "<unset>";\n try {\n const u = new URL(raw);\n if (u.password) u.password = "xxxxx";\n return u.toString();\n } catch {\n return "<invalid-url>";\n }\n}\n\nif (sslDebug) {\n console.error(\n `[ssl-debug] migra.ts deno=${Deno.version.deno} v8=${Deno.version.v8} os=${Deno.build.os}`,\n );\n console.error(\n `[ssl-debug] migra.ts source=${redactPostgresUrl(source)} target=${redactPostgresUrl(target)}`,\n );\n console.error(\n `[ssl-debug] migra.ts ssl_ca_set=${ca != null} ssl_ca_len=${ca?.length ?? 0}`,\n );\n}\n\nconst clientBase = createClient(source);\nconst clientHead = createClient(target, {\n pgpOptions: { connect: { ssl: ca && { ca } } },\n});\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS")?.split(",") ?? [];\nconst excludedSchemas = Deno.env.get("EXCLUDED_SCHEMAS")?.split(",") ?? [];\n\nconst managedSchemas = ["auth", "realtime", "storage"];\nconst extensionSchemas = [\n "pg_catalog",\n "extensions",\n "pgmq",\n "tiger",\n "topology",\n];\n\ntry {\n // Step down from login role to postgres\n await clientHead.query(sql`set role postgres`);\n // Force schema qualified references for pg_get_expr\n await clientHead.query(sql`set search_path = \'\'`);\n await clientBase.query(sql`set search_path = \'\'`);\n const result: string[] = [];\n for (const schema of includedSchemas) {\n const m = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n if (managedSchemas.includes(schema)) {\n m.add(m.changes.triggers({ drops_only: true }));\n m.add(m.changes.rlspolicies({ drops_only: true }));\n m.add(m.changes.rlspolicies({ creations_only: true }));\n m.add(m.changes.triggers({ creations_only: true }));\n } else {\n m.add_all_changes(true);\n }\n result.push(m.sql);\n }\n if (includedSchemas.length === 0) {\n // Migra does not ignore custom types and triggers created by extensions, so we diff\n // them separately. This workaround only applies to a known list of managed schemas.\n for (const schema of extensionSchemas) {\n const e = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n e.set_safety(false);\n e.add(e.changes.schemas({ creations_only: true }));\n e.add_extension_changes();\n result.push(e.sql);\n }\n // Diff user defined entities in non-managed schemas, including extensions.\n const m = await Migration.create(clientBase, clientHead, {\n exclude_schema: [\n ...managedSchemas,\n ...extensionSchemas,\n ...excludedSchemas,\n ],\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n m.add_all_changes(true);\n result.push(m.sql);\n // For managed schemas, we want to include triggers and RLS policies only.\n for (const schema of managedSchemas) {\n const s = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n s.set_safety(false);\n s.add(s.changes.triggers({ drops_only: true }));\n s.add(s.changes.rlspolicies({ drops_only: true }));\n s.add(s.changes.rlspolicies({ creations_only: true }));\n s.add(s.changes.triggers({ creations_only: true }));\n result.push(s.sql);\n }\n }\n console.log(result.join(""));\n} catch (e) {\n if (sslDebug) {\n if (e instanceof Error) {\n console.error(\n `[ssl-debug] migra.ts error_name=${e.name} message=${e.message} stack=${e.stack ?? "<none>"}`,\n );\n } else {\n console.error(`[ssl-debug] migra.ts error=${String(e)}`);\n }\n }\n console.error(e);\n console.error("PGDELTA_SCRIPT_ERROR");\n} finally {\n await Promise.all([clientHead.end(), clientBase.end()]);\n}\n'; + 'import { createClient } from "npm:@pgkit/client";\nimport { Migration } from "npm:@pgkit/migra";\n\n// Avoids error on self-signed certificate\nconst ca = Deno.env.get("SSL_CA");\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\nconst sslDebug = Deno.env.get("SUPABASE_SSL_DEBUG")?.toLowerCase() === "true";\n\nfunction redactPostgresUrl(raw: string | undefined): string {\n if (!raw) return "<unset>";\n try {\n const u = new URL(raw);\n if (u.password) u.password = "xxxxx";\n return u.toString();\n } catch {\n return "<invalid-url>";\n }\n}\n\nif (sslDebug) {\n console.error(\n `[ssl-debug] migra.ts deno=${Deno.version.deno} v8=${Deno.version.v8} os=${Deno.build.os}`,\n );\n console.error(\n `[ssl-debug] migra.ts source=${redactPostgresUrl(source)} target=${redactPostgresUrl(target)}`,\n );\n console.error(\n `[ssl-debug] migra.ts ssl_ca_set=${ca != null} ssl_ca_len=${ca?.length ?? 0}`,\n );\n}\n\ntype PoolClient = {\n query: (stmt: string) => Promise<unknown>;\n on: (event: "error", listener: () => void) => void;\n off: (event: "error", listener: () => void) => void;\n};\n// Step down from login role to postgres and force schema qualified references for\n// pg_get_expr on every pooled connection, including one reopened after an idle timeout\nconst verify = (stmt: string) => (client: PoolClient, done: (err?: Error) => void) => {\n // pg-pool drops its error listener during verify, so a socket error must reject, not throw\n const ignore = () => {};\n client.on("error", ignore);\n client\n .query(stmt)\n .finally(() => client.off("error", ignore))\n .then(\n () => done(),\n (err: Error) => {\n err.message = `${stmt}: ${err.message}`;\n done(err);\n },\n );\n};\nconst clientBase = createClient(source, {\n pgpOptions: { connect: { verify: verify("set search_path = \'\'") } },\n});\nconst clientHead = createClient(target, {\n pgpOptions: {\n connect: {\n ssl: ca && { ca },\n verify: verify("set role postgres; set search_path = \'\'"),\n },\n },\n});\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS")?.split(",") ?? [];\nconst excludedSchemas = Deno.env.get("EXCLUDED_SCHEMAS")?.split(",") ?? [];\n\nconst managedSchemas = ["auth", "realtime", "storage"];\nconst extensionSchemas = [\n "pg_catalog",\n "extensions",\n "pgmq",\n "tiger",\n "topology",\n];\n\ntry {\n const result: string[] = [];\n for (const schema of includedSchemas) {\n const m = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n if (managedSchemas.includes(schema)) {\n m.add(m.changes.triggers({ drops_only: true }));\n m.add(m.changes.rlspolicies({ drops_only: true }));\n m.add(m.changes.rlspolicies({ creations_only: true }));\n m.add(m.changes.triggers({ creations_only: true }));\n } else {\n m.add_all_changes(true);\n }\n result.push(m.sql);\n }\n if (includedSchemas.length === 0) {\n // Migra does not ignore custom types and triggers created by extensions, so we diff\n // them separately. This workaround only applies to a known list of managed schemas.\n for (const schema of extensionSchemas) {\n const e = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n e.set_safety(false);\n e.add(e.changes.schemas({ creations_only: true }));\n e.add_extension_changes();\n result.push(e.sql);\n }\n // Diff user defined entities in non-managed schemas, including extensions.\n const m = await Migration.create(clientBase, clientHead, {\n exclude_schema: [\n ...managedSchemas,\n ...extensionSchemas,\n ...excludedSchemas,\n ],\n ignore_extension_versions: true,\n });\n m.set_safety(false);\n m.add_all_changes(true);\n result.push(m.sql);\n // For managed schemas, we want to include triggers and RLS policies only.\n for (const schema of managedSchemas) {\n const s = await Migration.create(clientBase, clientHead, {\n schema,\n ignore_extension_versions: true,\n });\n s.set_safety(false);\n s.add(s.changes.triggers({ drops_only: true }));\n s.add(s.changes.rlspolicies({ drops_only: true }));\n s.add(s.changes.rlspolicies({ creations_only: true }));\n s.add(s.changes.triggers({ creations_only: true }));\n result.push(s.sql);\n }\n }\n console.log(result.join(""));\n} catch (e) {\n if (sslDebug) {\n if (e instanceof Error) {\n console.error(\n `[ssl-debug] migra.ts error_name=${e.name} message=${e.message} stack=${e.stack ?? "<none>"}`,\n );\n } else {\n console.error(`[ssl-debug] migra.ts error=${String(e)}`);\n }\n }\n console.error(e);\n console.error("PGDELTA_SCRIPT_ERROR");\n} finally {\n await Promise.all([clientHead.end(), clientBase.end()]);\n}\n'; /** `templates/migra.sh` — OOM bash fallback executed in the `supabase/migra` image. */ export const migraDiffShellScript = diff --git a/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts b/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts index 18fe42d6f2..16a1371a94 100644 --- a/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts +++ b/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts @@ -1,14 +1,240 @@ -import { describe, expect, it } from "vitest"; +import { EventEmitter } from "node:events"; + +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect } from "effect"; import { dropObjectsSql } from "../../../command-internal/drop-objects.ts"; import { EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL } from "../../../command-internal/edge-runtime-script.service.ts"; import { migraDiffScript } from "./migra.deno-templates.ts"; import { listSchemasSql } from "./migra.ts"; +type Side = "source" | "target"; +type Verify = (connection: FakeConnection, done: (err?: Error) => void) => void; +type SetupFailure = "denyRole" | "dropConnection"; + +class SessionSetupError extends Data.TaggedError("SessionSetupError")<{ + readonly message: string; +}> {} + +class FakeConnection extends EventEmitter { + readonly session = { role: "cli_login_postgres", searchPath: '"$user", public' }; + readonly events: string[] = []; + readonly setupErrorListeners: number[] = []; + readonly dropErrorListeners: number[] = []; + + constructor( + readonly id: number, + private readonly failure: SetupFailure | undefined, + ) { + super(); + } + + query(stmt: string): Promise<void> { + this.events.push("query"); + this.setupErrorListeners.push(this.listenerCount("error")); + if (this.failure === "dropConnection") { + return Promise.resolve().then(() => { + const err = new Error("Connection terminated unexpectedly"); + this.dropErrorListeners.push(this.listenerCount("error")); + this.emit("error", err); + throw err; + }); + } + return Promise.resolve().then(() => { + for (const part of stmt.split(";").map((s) => s.trim())) { + if (part === "set role postgres") { + if (this.failure === "denyRole") { + throw new Error('permission denied to set role "postgres"'); + } + this.session.role = "postgres"; + } else if (part === "set search_path = ''") { + this.session.searchPath = ""; + } + } + this.events.push("settled"); + }); + } +} + +// Runs the embedded script against in-memory pools whose new connections start with server +// defaults; `replaceConnections` hands out a fresh connection on every checkout. +const runMigraScript = (opts: { replaceConnections?: boolean; failure?: SetupFailure } = {}) => + Effect.suspend(() => { + const inspections: Array< + { side: Side; id: number; errorListeners: number } & FakeConnection["session"] + > = []; + const connections: FakeConnection[] = []; + const stdout: string[] = []; + const stderr: string[] = []; + const ended: Side[] = []; + const env: Record<string, string> = { + SOURCE: "postgres://source", + TARGET: "postgres://target", + }; + + const createClient = ( + url: string, + options?: { pgpOptions?: { connect?: { verify?: Verify } } }, + ) => { + const side: Side = url === env.SOURCE ? "source" : "target"; + const verify = options?.pgpOptions?.connect?.verify; + let current: FakeConnection | undefined; + const openConnection = (): Effect.Effect<FakeConnection, SessionSetupError> => { + const fresh = new FakeConnection( + connections.length + 1, + side === "target" ? opts.failure : undefined, + ); + connections.push(fresh); + const setup = + verify === undefined + ? Effect.void + : Effect.callback<void, SessionSetupError>((resume) => + verify(fresh, (err) => { + fresh.events.push(err === undefined ? "ready" : "failed"); + resume( + err === undefined + ? Effect.void + : Effect.fail(new SessionSetupError({ message: err.message })), + ); + }), + ); + return Effect.as(setup, fresh); + }; + const checkout: Effect.Effect<FakeConnection, SessionSetupError> = Effect.suspend(() => + current !== undefined && !opts.replaceConnections + ? Effect.succeed(current) + : Effect.map(openConnection(), (fresh) => (current = fresh)), + ); + return { + side, + checkout, + end: () => { + ended.push(side); + return Promise.resolve(); + }, + }; + }; + type Client = ReturnType<typeof createClient>; + + const noop = () => ""; + const migration = { + sql: "", + set_safety: noop, + add: noop, + add_all_changes: noop, + add_extension_changes: noop, + changes: { triggers: noop, rlspolicies: noop, schemas: noop }, + }; + const Migration = { + create: (base: Client, head: Client) => + Effect.runPromise( + Effect.gen(function* () { + for (const client of [base, head]) { + const connection = yield* client.checkout; + inspections.push({ + side: client.side, + id: connection.id, + errorListeners: connection.listenerCount("error"), + ...connection.session, + }); + } + return migration; + }), + ), + }; + const format = (args: unknown[]) => + args.map((arg) => (arg instanceof Error ? arg.message : String(arg))).join(" "); + + const module = new Bun.Transpiler({ loader: "ts" }).transformSync( + `export default async (createClient, Migration, Deno, console) => {\n${migraDiffScript.replaceAll(/^import .* from "npm:.*";$/gmu, "")}\n};`, + ); + return Effect.gen(function* () { + const script: { default: (...args: unknown[]) => Promise<void> } = yield* Effect.promise( + () => import(`data:text/javascript;base64,${Buffer.from(module).toString("base64")}`), + ); + yield* Effect.promise(() => + script.default( + createClient, + Migration, + { env: { get: (key: string) => env[key] } }, + { + log: (...args: unknown[]) => stdout.push(format(args)), + error: (...args: unknown[]) => stderr.push(format(args)), + }, + ), + ); + return { inspections, connections, stdout, stderr, ended }; + }); + }); + describe("embedded migra templates", () => { it("emit the error sentinel from the diff script's failure path", () => { expect(migraDiffScript).toContain(EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL); }); + + it.effect( + "re-apply the session settings on every replacement connection (supabase/cli#6860)", + () => + Effect.gen(function* () { + const run = yield* runMigraScript({ replaceConnections: true }); + + expect(run.stderr).toEqual([]); + expect(run.stdout).toEqual([""]); + const sides: Side[] = ["source", "target"]; + for (const side of sides) { + expect( + run.inspections.filter((inspection) => inspection.side === side).length, + ).toBeGreaterThan(1); + } + for (const inspection of run.inspections) { + expect(inspection).toMatchObject({ + role: inspection.side === "target" ? "postgres" : "cli_login_postgres", + searchPath: "", + errorListeners: 0, + }); + } + for (const connection of run.connections) { + expect(connection.events).toEqual(["query", "settled", "ready"]); + expect(connection.setupErrorListeners).toEqual([1]); + } + }), + ); + + it.effect("report a failed session setup through the error sentinel", () => + Effect.gen(function* () { + const run = yield* runMigraScript({ failure: "denyRole" }); + + expect(run.stdout).toEqual([]); + expect(run.stderr).toEqual([ + `set role postgres; set search_path = '': permission denied to set role "postgres"`, + EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL, + ]); + expect(run.ended.toSorted()).toEqual(["source", "target"]); + }), + ); + + it.effect("report a connection dropped during session setup through the error sentinel", () => + Effect.gen(function* () { + const run = yield* runMigraScript({ failure: "dropConnection" }); + + expect(run.stdout).toEqual([]); + expect(run.stderr).toEqual([ + "set role postgres; set search_path = '': Connection terminated unexpectedly", + EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL, + ]); + expect(run.ended.toSorted()).toEqual(["source", "target"]); + const dropped = run.connections.filter( + (connection) => connection.dropErrorListeners.length > 0, + ); + expect(dropped).toHaveLength(1); + expect(dropped[0]).toMatchObject({ + events: ["query", "failed"], + setupErrorListeners: [1], + dropErrorListeners: [1], + }); + expect(dropped[0]?.listenerCount("error")).toBe(0); + }), + ); }); describe("embedded user-schema queries", () => { diff --git a/apps/cli/src/commands/db/shared/migra.ts b/apps/cli/src/commands/db/shared/migra.ts index 015a163def..0b7aa47d25 100644 --- a/apps/cli/src/commands/db/shared/migra.ts +++ b/apps/cli/src/commands/db/shared/migra.ts @@ -11,6 +11,11 @@ import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { EdgeRuntimeScript } from "../../../command-internal/edge-runtime-script.service.ts"; import { PG_DELTA_CA_BUNDLE } from "../../../command-internal/pgdelta-ssl.ts"; import { PgDeltaSslProbe } from "../../../command-internal/pgdelta-ssl-probe.service.ts"; +import { + rewriteDumpHostForToolContainer, + toolContainerUsesHostNetwork, +} from "../../../command-internal/postgres-client.run.ts"; +import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { migraDiffScript, migraDiffShellScript } from "./migra.deno-templates.ts"; import { MigraDiffError, MigraSchemaLoadError } from "./migra.errors.ts"; import { edgeRuntimeId, type PgDeltaContext } from "../../../command-internal/pgdelta.ts"; @@ -102,6 +107,26 @@ function shouldFallbackToBashMigra(message: string): boolean { ); } +/** + * Rewrites a loopback database URL for the migra container, which runs on the host network + * unless `--network-id` is set. Stack databases are published by a host-side proxy that + * Docker Desktop's host network does not share. + */ +const containerDatabaseUrl = Effect.fnUntraced(function* (url: string) { + const runtimeInfo = yield* RuntimeInfo; + const usesHostNetwork = toolContainerUsesHostNetwork(Option.getOrUndefined(yield* NetworkIdFlag)); + if (usesHostNetwork && (yield* currentStackBackend).kind !== "stack") return url; + const parsed = URL.parse(url); + if (parsed === null) return url; + const host = rewriteDumpHostForToolContainer(parsed.hostname, { + platform: runtimeInfo.platform, + usesHostNetwork, + }); + if (host === parsed.hostname) return url; + parsed.hostname = host; + return parsed.href; +}); + /** Builds the shared SOURCE/TARGET/SSL/schema env for both migra paths. */ const buildMigraEnv = Effect.fnUntraced(function* (params: { readonly source: string; @@ -110,8 +135,8 @@ const buildMigraEnv = Effect.fnUntraced(function* (params: { }) { const probe = yield* PgDeltaSslProbe; const env: Record<string, string> = { - SOURCE: params.source, - TARGET: params.target, + SOURCE: yield* containerDatabaseUrl(params.source), + TARGET: yield* containerDatabaseUrl(params.target), }; if (yield* isSslDebugEnabled) env["SUPABASE_SSL_DEBUG"] = "true"; // Probe the target for TLS; if it speaks TLS, inject the embedded CA bundle as SSL_CA. @@ -175,7 +200,10 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { params.schema.length > 0 ? params.schema : yield* loadTargetUserSchemas(params.target, params.connectOptions); - const env: Record<string, string> = { SOURCE: params.source, TARGET: params.target }; + const env: Record<string, string> = { + SOURCE: yield* containerDatabaseUrl(params.source), + TARGET: yield* containerDatabaseUrl(params.target), + }; if (yield* isSslDebugEnabled) env["SUPABASE_SSL_DEBUG"] = "true"; // The script runs as a string, so command-line args must be set manually via `set --` // for migra.sh's `"$@"` loop to see the schema list. 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 c0d2c3d33a..a0b19f1a3f 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 @@ -52,6 +52,7 @@ import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { stackAcquireShadowDatabase, + stackShadowRuntime, stackMigrateShadow, } from "../../../command-internal/stack-shadow.ts"; import { StackApi } from "../../../command-internal/stack-api.ts"; @@ -290,10 +291,15 @@ export const pgDeltaNextShadowLayer = Layer.effect( } satisfies ProvisionedDeclarativeShadow; }).pipe(Effect.provide(runtimeWith(outputService)), Effect.mapError(nextShadowError)); + // Both shadows of one plan share a runtime, so the engines are probed at most once. + const shadowRuntime = yield* Effect.cached(stackShadowRuntime); const stackAcquire = (input: NativeShadowInput, opts: ShadowCacheOpts) => - stackAcquireShadowDatabase(input.base, { - ...(opts.webhooks === undefined ? {} : { webhooks: opts.webhooks }), - ...(opts.bypassCache === true ? { bypassCache: true } : {}), + Effect.gen(function* () { + return yield* stackAcquireShadowDatabase(input.base, { + runtime: yield* shadowRuntime, + ...(opts.webhooks === undefined ? {} : { webhooks: opts.webhooks }), + ...(opts.bypassCache === true ? { bypassCache: true } : {}), + }); }); const stackProvisionMigrations = (input: NativeShadowInput, opts: ShadowCacheOpts) => 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 dbaa444078..8073f19f52 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 @@ -4,6 +4,7 @@ import { FetchHttpClient } from "effect/unstable/http"; import { Effect, FileSystem, Layer, Option } from "effect"; import { mockCommandSettings, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { containerEngineSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; @@ -91,6 +92,7 @@ describe("pg-delta next stack shadow provisioning", () => { ); const stateRoot = `${root}/stacks`; + const engines = containerEngineSpawner({ docker: "missing", podman: "missing" }); const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); const output = mockOutput().layer; const apiLayer = stackApiLayer.pipe( @@ -109,6 +111,8 @@ describe("pg-delta next stack shadow provisioning", () => { Layer.provide(Layer.succeed(ExperimentalFlag, false)), Layer.provide(Layer.succeed(NetworkIdFlag, Option.none())), Layer.provide(Layer.succeed(CliArgs, { args: [] })), + // Without a reachable container engine, automatic selection keeps the shadows native. + Layer.provide(engines.hidingLayer), Layer.provide(BunServices.layer), ); const services = Layer.mergeAll( @@ -151,6 +155,7 @@ describe("pg-delta next stack shadow provisioning", () => { ).toEqual([{ table_name: null }]); const api = yield* StackApi; expect(yield* api.discover({ stateRoot })).toHaveLength(2); + expect(engines.spawned.map(({ command }) => command)).toEqual(["docker", "podman"]); return plan; }).pipe(Effect.provide(services)), ); diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index 7d2a9ab96e..3cdd90f8a7 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -33,6 +33,48 @@ import { DeclarativeShadowDbError } from "./pgdelta.errors.ts"; import { declarativeSeamLayer } from "./pgdelta.seam.layer.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; +// This fixture catalog's pin must be keyed to the Dockerfile's own `pg` tag, or `toSlimImage` +// would find no match and fall back to the upstream (non-slim) image regardless of +// `SUPABASE_USE_SLIM_IMAGES` — masking the family-mismatch check below. `vi.hoisted` runs before +// every top-level `import` (including this file's own), so `dockerfileServiceImageRaw` isn't +// bound yet when this runs; `require` (CJS, unaffected by that ESM hoisting order) reads the +// Dockerfile directly instead, keeping this correct across every future Dockerfile bump. +const pgTag = vi.hoisted(() => { + const fs: typeof import("node:fs") = require("node:fs"); + const url: typeof import("node:url") = require("node:url"); + const dockerfilePath = url.fileURLToPath( + new URL("../../../shared/services/Dockerfile", import.meta.url), + ); + const match = /^FROM\s+supabase\/postgres:(\S+)\s+AS\s+pg$/m.exec( + fs.readFileSync(dockerfilePath, "utf8"), + ); + if (match?.[1] === undefined) throw new Error("pg tag not found in the Dockerfile"); + return match[1]; +}); + +vi.mock("@supabase/stack/internal/artifacts", () => { + const digest = "d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c"; + const nativePin = { archive: digest, manifest: digest }; + return { + catalogPins: () => [ + { + service: "database", + sourceService: "postgres", + pin: { + upstreamVersion: pgTag, + revision: 0, + image: `ghcr.io/supabase/cli/postgres:${pgTag}-r0@sha256:${digest}`, + natives: { + "darwin-arm64": nativePin, + "linux-amd64": nativePin, + "linux-arm64": nativePin, + }, + }, + }, + ], + }; +}); + /** * Integration coverage for the fully-native `declarativeSeamLayer`: `generate`/`sync`'s own * tests stub `DeclarativeSeam` entirely, so this file is the only place the real local-database @@ -207,6 +249,46 @@ describe("declarativeSeamLayer.ensureLocalPostgresImageCurrent", () => { }, ); + it.effect("flags a stale slim container when only its revision has drifted from a hotfix", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = tmp.current; + // Same upstream version and family as the fixture catalog's pin, but at a different + // revision (r1, a different digest) — the hotfix-drift case this model exists to catch, + // and the one a bare upstream-version comparison would mask. + const { layer } = setup(dir, { + dbInspectImage: `ghcr.io/supabase/cli/postgres:${pgTag}-r1@sha256:${"a".repeat(64)}`, + }); + return Effect.gen(function* () { + const seam = yield* DeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = failError(exit); + expect(error).toBeInstanceOf(DeclarativeShadowDbError); + expect((error as DeclarativeShadowDbError).message).toContain( + "local Postgres container image is stale", + ); + // Same family (slim vs slim): the generic remediation, not the family-mismatch wording. + expect((error as DeclarativeShadowDbError).message).not.toContain( + "same SUPABASE_USE_SLIM_IMAGES setting", + ); + expect((error as DeclarativeShadowDbError).message).toContain("--no-backup"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("passes when a slim container matches the expected image's release and digest", () => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const dir = tmp.current; + // The exact image (release version and digest) the fixture catalog pins at r0. + const { layer } = setup(dir, { + dbInspectImage: `ghcr.io/supabase/cli/postgres:${pgTag}-r0@sha256:d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c`, + }); + return Effect.gen(function* () { + const seam = yield* DeclarativeSeam; + const exit = yield* seam.ensureLocalPostgresImageCurrent.pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + it.effect("bails out when inspect succeeds but the image name is unparseable", () => { vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const dir = tmp.current; 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 7fb1f1f4c9..1223142205 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -9,7 +9,8 @@ import { resolveDbImage } from "../../../command-internal/db-image.ts"; import { readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { isDockerDaemonUnreachable } from "../../../command-internal/docker-suggest.ts"; -import { isSlimImageRef } from "../../../shared/services/slim-images.ts"; +import { imageDigest, imageTag, isSlimImageRef } from "../../../shared/services/slim-images.ts"; +import { upstreamVersionFromTag } from "../../../shared/services/services.shared.ts"; import { isLocalDbRunning } from "../../../command-internal/db-bootstrap/local-db-running.ts"; import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start-local-database.ts"; import { resolveLocalProjectId, localDbContainerId } from "../../../command-internal/docker-ids.ts"; @@ -87,6 +88,7 @@ export const declarativeSeamLayer = Layer.effect( if (backend.kind === "stack") { return yield* stackEnsurePostgresOnlyStarted().pipe( Effect.asVoid, + Effect.scoped, Effect.provideContext(context), Effect.provideService(StackApi, stackApi), Effect.provideService(ExperimentalFlag, experimentalFlag), @@ -249,20 +251,34 @@ export const declarativeSeamLayer = Layer.effect( ), Effect.map((value) => value.trim()), ); - const actualTag = dockerImageTag(actual); - const expectedTag = dockerImageTag(expected); - if (actual.length === 0 || actualTag.length === 0 || expectedTag.length === 0) { + const actualTag = imageTag(actual); + const expectedTag = imageTag(expected); + if (actual.length === 0 || actualTag === undefined || expectedTag === undefined) { return; } // Slim refs never go through a registry mirror, so a family mismatch // (e.g. a docker.io container satisfying a ghcr.io/supabase/cli - // expectation) is stale even when the tags happen to match. + // expectation) is stale even when the upstream versions happen to match. const familyMismatch = isSlimImageRef(expected) !== isSlimImageRef(actual); - if (!familyMismatch && actualTag === expectedTag) { - return; + if (!familyMismatch) { + // Same family: within slim, a `-r<N>` hotfix bump must still be caught, so compare + // the digest when both refs carry one (most precise), or the full tag otherwise — + // never the bare upstream version, which would mask a same-upstream revision drift. + const actualDigest = imageDigest(actual); + const expectedDigest = imageDigest(expected); + const current = + actualDigest !== undefined && expectedDigest !== undefined + ? actualDigest === expectedDigest + : actualTag === expectedTag; + if (current) return; } + // Across families, only the upstream version is comparable (a slim tag's `-r<N>` + // has no docker.io equivalent) — used to pick the remediation wording, not staleness: + // a family mismatch is always stale. + const upstreamMatches = + upstreamVersionFromTag(actualTag) === upstreamVersionFromTag(expectedTag); const remediation = - familyMismatch && actualTag === expectedTag + familyMismatch && upstreamMatches ? "The tags match but the image family does not (slim vs docker.io). Run supabase stop, then supabase start with the same SUPABASE_USE_SLIM_IMAGES setting before syncing declarative schemas." : "Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas."; return yield* new DeclarativeShadowDbError({ @@ -280,13 +296,6 @@ type StartLocalDatabaseDeps = ? R : never; -function dockerImageTag(image: string): string { - const trimmed = image.trim(); - const index = trimmed.lastIndexOf(":"); - if (index < 0 || index === trimmed.length - 1) return ""; - return trimmed.slice(index + 1); -} - export function isMissingContainerInspectError(stderr: string): boolean { return stderr.toLowerCase().includes("no such container"); } diff --git a/apps/cli/src/commands/db/shared/shadow-source.ts b/apps/cli/src/commands/db/shared/shadow-source.ts index f840e43a9c..7dcf6df614 100644 --- a/apps/cli/src/commands/db/shared/shadow-source.ts +++ b/apps/cli/src/commands/db/shared/shadow-source.ts @@ -97,10 +97,9 @@ export const prepareShadowSource = <E>( image: input.image, }); - // `handle` doubles as the baseline state: on a warm shadow-cache hit it already holds the - // platform baseline (so only the template database + user migrations run); on a - // cache-enabled cold provision it carries the snapshot step that runs between the two — see - // `shadow-cache.ts`/`ShadowBaselineState`. An uncached acquire is always-cold. + // `handle` is the baseline state. A warm hit already has the platform baseline. A + // cache-enabled cold provision snapshots after that baseline. The legacy engine then + // creates `contrib_regression`; pg-delta does not. An uncached acquire is always-cold. const migrateShadow = input.migrationMode === "pgdelta-next" ? migrateNextShadowDatabase : migrateShadowDatabase; yield* migrateShadow( diff --git a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md index dacc88ac69..05ade20e61 100644 --- a/apps/cli/src/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/start/SIDE_EFFECTS.md @@ -130,7 +130,7 @@ API request over the active context's local unix socket / named pipe: | `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | | `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | | `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | -| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | +| `SUPABASE_DB_ORIOLEDB_VERSION` | overrides `db.orioledb_version` (image + env) | no | | `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | | `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | | `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | 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 f0fccedeb2..debb4eae72 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -47,6 +47,7 @@ import type { DbStartFlags } from "./start.command.ts"; import { stackLocalDatabaseConn } from "../../../command-internal/stack-local-database.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { StackApi } from "../../../command-internal/stack-api.ts"; +import { postgresVersion } from "@supabase/stack/internal/artifacts"; import { StackCatalogSetup, StackCatalogSetupError, @@ -361,7 +362,7 @@ function setup(opts: SetupOpts = {}) { create: () => Effect.die("unused"), open: () => Effect.die("unused"), discover: () => Effect.die("unused"), - resolveIdentity: () => Effect.die("unused"), + find: () => Effect.die("unused"), }); const layer = Layer.mergeAll( @@ -1576,7 +1577,10 @@ describe("db start stack backend", () => { const databaseCreation: Extract<ServiceCreation, { service: "database" }> = { service: "database", config: { - version: "17.6.1.173", + // Derived from the real catalog's major-17 pin, not hardcoded: `dbStart`'s stack-backend + // path resolves the desired version through the same catalog, so a bump would otherwise + // make the "resumes the existing database" test below see a spurious version mismatch. + version: postgresVersion("17"), databasePassword: Redacted.make("secret"), jwtSecret: Redacted.make("secret"), jwtExpiry: 3600, @@ -1689,6 +1693,29 @@ describe("db start stack backend", () => { }), }, composition: { + plan: (creations: ReadonlyArray<ServiceCreationInput>) => + Effect.sync(() => + creations.flatMap((creation) => + creation.service === "database" && registered.includes(database) + ? [ + postgresVersion(creation.config.version) === postgresVersion(version) + ? { + id: database.id, + service: "database" as const, + member: members.includes(database), + change: "unchanged" as const, + } + : { + id: database.id, + service: "database" as const, + member: members.includes(database), + change: "incompatible" as const, + paths: ["config.version"], + }, + ] + : [], + ), + ), describe: Effect.sync(() => ({ members: members.map(({ id }) => ({ id, activation: "eager" as const })), dependencies: [], @@ -1705,8 +1732,8 @@ describe("db start stack backend", () => { restart: Effect.succeed([]), }, stop: Effect.void, - destroy: Effect.void, - tools: { run: () => Effect.die("unused") }, + destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), + commands: { run: () => Effect.die("unused") }, }; return { stack, state }; }; @@ -1715,26 +1742,24 @@ describe("db start stack backend", () => { Layer.succeed(StackApi, { create: () => Effect.succeed(fixture.stack), open: () => Effect.succeed(fixture.stack), - discover: () => + discover: () => Effect.die("unused"), + find: () => Effect.succeed( existing - ? [ - { - definition: { - id: stackId, - identity: { projectRoot: root, branchContext: "main", stackName: "default" }, - runtime: "native", - instances: [], - composition: { members: [], dependencies: [] }, - ports: [], - }, - host: undefined, + ? Option.some({ + definition: { + id: stackId, + identity: { projectRoot: root, branchContext: "main", stackName: "default" }, + runtime: "native" as const, + instances: [], + lifetime: "detached" as const, + composition: { members: [], dependencies: [] }, + ports: [], }, - ] - : [], + host: undefined, + }) + : Option.none(), ), - resolveIdentity: () => - Effect.succeed({ projectRoot: root, branchContext: "main", stackName: "default" }), }); it.live("creates and starts only the primary database", () => { diff --git a/apps/cli/src/commands/db/test/test.integration.test.ts b/apps/cli/src/commands/db/test/test.integration.test.ts index f3e5e33f01..a960da47f7 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -184,7 +184,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: [] }), Stdio.layerTest({ args: Effect.succeed(args) }), - commandRuntimeLayer(["db", "test"]), + commandRuntimeLayer(["db", "test"]).pipe(Layer.provide(BunServices.layer)), BunServices.layer, ); return { layer, out, analytics, processControl, connection, docker }; diff --git a/apps/cli/src/commands/domains/activate/activate.handler.ts b/apps/cli/src/commands/domains/activate/activate.handler.ts index 1271364b1e..cc267ad126 100644 --- a/apps/cli/src/commands/domains/activate/activate.handler.ts +++ b/apps/cli/src/commands/domains/activate/activate.handler.ts @@ -30,7 +30,7 @@ export const domainsActivate = Effect.fn("domains.activate")(function* ( Effect.tapError(() => activating?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapActivateError)), ); - yield* activating?.clear() ?? Effect.void; + yield* activating?.clear ?? Effect.void; yield* emitHostnameResult(response, flags.includeRawOutput); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/domains/create/create.handler.ts b/apps/cli/src/commands/domains/create/create.handler.ts index 2d8d6a1b20..f65d28f597 100644 --- a/apps/cli/src/commands/domains/create/create.handler.ts +++ b/apps/cli/src/commands/domains/create/create.handler.ts @@ -46,7 +46,7 @@ export const domainsCreate = Effect.fn("domains.create")(function* (flags: Domai Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapCreateError)), ); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; yield* emitHostnameResult(response, flags.includeRawOutput); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/domains/delete/delete.handler.ts b/apps/cli/src/commands/domains/delete/delete.handler.ts index 3cd8967534..0cab96bb18 100644 --- a/apps/cli/src/commands/domains/delete/delete.handler.ts +++ b/apps/cli/src/commands/domains/delete/delete.handler.ts @@ -35,7 +35,7 @@ export const domainsDelete = Effect.fn("domains.delete")(function* (flags: Domai Effect.tapError(() => deleting?.fail() ?? Effect.void), Effect.catch(mapDeleteError), ); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; if (output.format === "json" || output.format === "stream-json") { yield* output.success(DELETE_SUCCESS_MESSAGE, {}); diff --git a/apps/cli/src/commands/domains/get/get.handler.ts b/apps/cli/src/commands/domains/get/get.handler.ts index 2d2245ff24..e1c1faa711 100644 --- a/apps/cli/src/commands/domains/get/get.handler.ts +++ b/apps/cli/src/commands/domains/get/get.handler.ts @@ -32,7 +32,7 @@ export const domainsGet = Effect.fn("domains.get")(function* (flags: DomainsGetF Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapGetError)), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; yield* emitHostnameResult(response, flags.includeRawOutput); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/domains/reverify/reverify.handler.ts b/apps/cli/src/commands/domains/reverify/reverify.handler.ts index fbbc5ab956..6b2e47c58d 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.handler.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.handler.ts @@ -30,7 +30,7 @@ export const domainsReverify = Effect.fn("domains.reverify")(function* ( Effect.tapError(() => reverifying?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapReverifyError)), ); - yield* reverifying?.clear() ?? Effect.void; + yield* reverifying?.clear ?? Effect.void; yield* emitHostnameResult(response, flags.includeRawOutput); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts index 998e651d7e..c2fc3e1b0c 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.handler.ts @@ -30,7 +30,7 @@ export const encryptionGetRootKey = Effect.fn("encryption.get-root-key")(functio Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; if (output.format !== "text") { // json / stream-json — emit a structured result. diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts index 45ed1ba0d5..0a776e7914 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts @@ -47,7 +47,7 @@ export const encryptionUpdateRootKey = Effect.fn("encryption.update-root-key")(f Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.catch(mapUpdateError), ); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; if (output.format !== "text") { yield* output.success("", { root_key: response.root_key }); 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 5555f4226e..b86d13a8d0 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -83,7 +83,7 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu ), Effect.tapError(() => fetching.fail()), ); - yield* fetching.clear(); + yield* fetching.clear; const deployed = lookup.compute; const machineOutput = yield* computeMachineOutputRequested(); @@ -153,7 +153,7 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu if (deployed !== undefined || !lookup.readable) { const deleting = yield* output.task("Deleting compute..."); yield* deleteCompute(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); - yield* deleting.clear(); + yield* deleting.clear; } // A compute deployed from another checkout has neither a local entry nor a 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 604071f893..296e4ad1fb 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.handler.ts @@ -118,7 +118,7 @@ export const computeList = Effect.fn("compute.list")(function* (flags: ComputeLi const deployed = yield* listCompute(api, projectRef).pipe( Effect.tapError(() => fetching.fail()), ); - yield* fetching.clear(); + yield* fetching.clear; const byName = new Map(deployed.map((compute) => [compute.name, compute])); const configuredNames = Object.keys(project.section.compute); 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 13e084d1dd..cd26ce471b 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.handler.ts @@ -236,7 +236,7 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( tail: flags.tail, window: logWindow(yield* DateTime.now), }).pipe(Effect.tapError(() => fetching.fail())); - yield* fetching.clear(); + yield* fetching.clear; return rows; }); @@ -256,7 +256,7 @@ export const computeLogs = Effect.fn("compute.logs")(function* ( const deployed = yield* getCompute(api, projectRef, name).pipe( Effect.tapError(() => checking.fail()), ); - yield* checking.clear(); + yield* checking.clear; if (Option.isNone(deployed)) { return yield* new ComputeNotDeployedError({ detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, 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 08f71e1010..2d33bef76f 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.handler.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.handler.ts @@ -330,7 +330,7 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { const packaged = yield* packageComputeDirectory(compute.sourceDir, exclude).pipe( Effect.tapError(() => packaging.fail()), ); - yield* packaging.clear(); + yield* packaging.clear; // 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. @@ -366,7 +366,7 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { Effect.tapError(() => uploading.fail()), ); yield* uploadBuildContext(slot, packaged.archive).pipe(Effect.tapError(() => uploading.fail())); - yield* uploading.clear(); + yield* uploading.clear; yield* output.raw("Uploaded build context.\n", "stderr"); contextUploadId = slot.uploadId; } @@ -412,7 +412,7 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { // Checked regardless of whether the build was waited on: the verdict can // arrive on the deploy response as readily as on a poll. if (settled.buildState === "failed") { - yield* deploying.clear(); + yield* deploying.clear; return yield* new ComputeBuildFailedError({ detail: `The build for "${name}" failed${ settled.stateReason === undefined ? "" : `: ${settled.stateReason}` @@ -421,7 +421,7 @@ const deployOneCompute = Effect.fnUntraced(function* (input: { }); } - yield* deploying.clear(); + yield* deploying.clear; const url = settled.spec.exposure === "public" diff --git a/apps/cli/src/commands/experimental/compute/status/status.handler.ts b/apps/cli/src/commands/experimental/compute/status/status.handler.ts index 9d1acc1be7..c3c7648976 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.handler.ts @@ -60,7 +60,7 @@ export const computeStatus = Effect.fn("compute.status")(function* (flags: Compu const found = yield* getCompute(api, projectRef, name).pipe( Effect.tapError(() => fetching.fail()), ); - yield* fetching.clear(); + yield* fetching.clear; if (Option.isNone(found)) { return yield* new ComputeNotDeployedError({ 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 672f1c7f07..8f7f722738 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/destroy/SIDE_EFFECTS.md @@ -11,9 +11,11 @@ The selectors are mutually exclusive; a missing target fails. Explicit legacy `-o/--output` is rejected in favor of `--output-format`. Interactive text mode asks for confirmation and states that Storage uploads are -preserved. Non-interactive and machine-output runs require `--yes`. Rejection or -cancellation does not open or destroy a stack. Discovery may create/chmod the -registry directory to 0700 but does not launch an owner. +preserved. Non-interactive and machine-output runs require `--yes`. With `--yes` +the command prints no question; stderr states which stack and data are destroyed +and that Storage uploads are preserved. Rejection or cancellation does not open or +destroy a stack. Discovery may create/chmod the registry directory to 0700 but +does not launch an owner. After confirmation the command opens the selected handle and destroys its entire namespace. Destruction may start an owner to clean up a stopped namespace. @@ -21,6 +23,16 @@ Cleanup failures remain errors; the command does not claim success on failure. A failed destroy may leave its owner running; retry destruction or use `stack stop` to shut down that owner. +When no owner is running and the engine reports that its daemon cannot be +reached, destruction removes the local namespace and host data anyway, warns on +stderr that the stack's engine resources were not removed, and exits 0. The +warning lists the commands that remove them once the engine is running: one for +the stack's containers, and one per database whose data is kept in an engine +volume. Any other engine failure, an owner starting during destruction, or +host data the current user cannot delete (such as database files a container +wrote as its own user), fails the command before anything is removed and keeps +the stack registered; the last case asks to start the engine and retry. + ## Files and network Reads identity/state under `<SUPABASE_HOME or ~/.supabase>/stacks/<id>/` and, for @@ -32,8 +44,11 @@ routing/settings can read project configuration and profiles. ## Output, exit codes and telemetry -Text prints `Stack <id> destroyed.`; JSON and stream-json success data contain -`destroyed: true` and `id`. Exit 0 on destruction, 1 on invalid flags, missing +Text prints `Stack <id> destroyed.`, or, when engine cleanup was skipped, +`Stack <id> was removed locally; its <Engine> resources remain until the commands above are run.` +JSON and stream-json success data contain `destroyed: true`, `id`, and +`runtimeCleanup` (`complete` or `skipped`); a skipped cleanup also carries `engine` and +`cleanupCommands`. Exit 0 on destruction, 1 on invalid flags, missing selection, rejected/cancelled confirmation or cleanup failure, and 130 on interruption. Standard command telemetry is unchanged, with no custom events. Telemetry flushes on success and failure to 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 9bdae4642a..606b0da0f8 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.handler.ts @@ -10,6 +10,7 @@ import { StackApi, StackTargetError, rejectStackOutput, + skippedRuntimeCleanupWarning, StackTargetResolver, validateStackTarget, } from "../stack.shared.ts"; @@ -72,17 +73,22 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( message: "Destroying a stack requires confirmation; rerun with --yes.", suggestion: "Pass --yes when running non-interactively or in a machine-readable format.", }); - const confirmed = yield* promptYesNo( - output, - yes, - `Permanently destroy stack ${target.id} at ${target.projectRoot} and its owned data? Storage upload files will be preserved.`, - false, - ); - if (!confirmed) - return yield* new StackCommandDestroyError({ - reason: "cancelled", - message: "Stack destruction was not confirmed.", - }); + const scope = `stack ${target.id} at ${target.projectRoot} and its owned data`; + const preserved = "Storage upload files will be preserved."; + if (yes) yield* output.raw(`Permanently destroying ${scope}. ${preserved}\n`, "stderr"); + else { + const confirmed = yield* promptYesNo( + output, + false, + `Permanently destroy ${scope}? ${preserved}`, + false, + ); + if (!confirmed) + return yield* new StackCommandDestroyError({ + reason: "cancelled", + message: "Stack destruction was not confirmed.", + }); + } const stack = yield* api .open({ id: target.id, @@ -91,18 +97,26 @@ export const stackDestroy = Effect.fn("experimental.stack.destroy")(function* ( }) .pipe(Effect.mapError(destroyError)); const destroying = yield* output.task(`Destroying stack ${target.id}...`); - yield* stack.destroy.pipe( + const result = yield* stack.destroy.pipe( Effect.onExit((exit) => Exit.isSuccess(exit) - ? destroying.clear() + ? destroying.clear : Cause.hasInterruptsOnly(exit.cause) ? destroying.cancel() : destroying.fail(Option.getOrUndefined(Exit.findErrorOption(exit))?.message), ), Effect.mapError(destroyError), ); - if (output.format === "text") yield* output.raw(`Stack ${target.id} destroyed.\n`); - else yield* output.success("", { destroyed: true, id: target.id }); + if (result.runtimeCleanup === "skipped") + yield* output.warn(skippedRuntimeCleanupWarning(`stack ${target.id}`, result)); + if (output.format !== "text") + yield* output.success("", { destroyed: true, id: target.id, ...result }); + else if (result.runtimeCleanup === "complete") + yield* output.raw(`Stack ${target.id} destroyed.\n`); + else + yield* output.raw( + `Stack ${target.id} was removed locally; its ${result.engine === "docker" ? "Docker" : "Podman"} resources remain until the commands above are run.\n`, + ); }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); 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 70d10bc54c..402938083a 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 @@ -12,14 +12,22 @@ import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shar import { stackDestroy } from "./destroy.handler.ts"; const live = Layer.provideMerge(stackApiLayer, BunServices.layer); -const fixture = Effect.fn("StackDestroyTest.fixture")(function* (yes: boolean) { +const fixture = Effect.fn("StackDestroyTest.fixture")(function* ( + yes: boolean, + confirm?: { readonly answer: boolean }, +) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-" }); + const projectRoot = yield* fs.realPath(root); const api = yield* StackApi; const locations = { stateRoot: path.join(root, "stacks"), cacheRoot: path.join(root, "cache") }; const stack = yield* api.create({ ...locations, projectRoot: root, runtime: "native" }); - const output = mockOutput({ interactive: false }); + const output = mockOutput( + confirm === undefined + ? { interactive: false } + : { interactive: true, promptConfirmResponses: [confirm.answer] }, + ); const telemetry = mockTelemetryStateTracked(); const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); const layer = Layer.mergeAll( @@ -27,13 +35,14 @@ const fixture = Effect.fn("StackDestroyTest.fixture")(function* (yes: boolean) { telemetry.layer, settings, stackTargetResolverLayer.pipe(Layer.provide(settings)), - mockTty({ stdinIsTty: false }), + mockTty({ stdinIsTty: confirm !== undefined }), mockStdin(false), Layer.succeed(YesFlag, yes), Layer.succeed(CliArgs, { args: yes ? ["--yes"] : ["--yes=false"] }), ); return { root, + projectRoot, fs, path, api, @@ -59,6 +68,19 @@ describe("stack destroy", () => { }).pipe(Effect.provide(live)), ); + it.live("asks on an interactive terminal and keeps the stack when declined", () => + Effect.gen(function* () { + const f = yield* fixture(false, { answer: false }); + const error = yield* stackDestroy(f.flags).pipe(Effect.provide(f.layer), Effect.flip); + expect(error.reason).toBe("cancelled"); + expect(f.output.promptConfirmCalls.map(({ message }) => message)).toEqual([ + `Permanently destroy stack ${f.stack.id} at ${f.projectRoot} and its owned data? Storage upload files will be preserved.`, + ]); + const saved = yield* f.api.discover(f.locations); + expect(saved.map(({ definition }) => definition.id)).toEqual([f.stack.id]); + }).pipe(Effect.provide(live)), + ); + it.live("destroys an offline namespace without affecting a different stack", () => Effect.gen(function* () { const f = yield* fixture(true); @@ -87,6 +109,11 @@ describe("stack destroy", () => { expect((yield* f.api.discover(f.locations)).map(({ definition }) => definition.id)).toEqual([ other.id, ]); + expect(f.output.stderrText).toContain( + `Permanently destroying stack ${f.stack.id} at ${f.projectRoot} and its owned data. Storage upload files will be preserved.\n`, + ); + expect(f.output.stderrText).not.toContain("[y/N]"); + expect(f.output.promptConfirmCalls).toEqual([]); expect(f.output.stdoutText).toContain(`Stack ${f.stack.id} destroyed.`); expect(f.telemetry.flushed).toBe(true); }).pipe(Effect.provide(live)), @@ -104,7 +131,6 @@ describe("stack destroy", () => { f.stack.services.create({ service: "storage", config: { - databaseUrl: "postgresql://unused", jwtSecret: "test-secret", filePath: uploads, }, diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts new file mode 100644 index 0000000000..625b9339f4 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.runtime-unavailable.integration.test.ts @@ -0,0 +1,111 @@ +import { BunServices } from "@effect/platform-bun"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { YesFlag } from "../../../../command-internal/global-flags.ts"; +import { + mockCommandSettings, + mockTelemetryStateTracked, +} from "../../../../../tests/helpers/command-mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shared.ts"; +import { stackDestroy } from "./destroy.handler.ts"; + +const live = Layer.provideMerge(stackApiLayer, BunServices.layer); + +const fixture = Effect.fn("StackDestroyRuntimeUnavailableTest.fixture")(function* ( + format: "text" | "json", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-runtime-" }); + const binDir = path.join(root, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + const dockerShim = path.join(binDir, "docker"); + yield* fs.writeFileString( + dockerShim, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at tcp://127.0.0.1:1. Is the docker daemon running?' >&2\nexit 1\n", + ); + yield* fs.chmod(dockerShim, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + + const api = yield* StackApi; + const locations = { stateRoot: path.join(root, "stacks"), cacheRoot: path.join(root, "cache") }; + const stack = yield* api.create({ ...locations, projectRoot: root, runtime: "docker" }); + const output = mockOutput({ interactive: false, format }); + const telemetry = mockTelemetryStateTracked(); + const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); + const layer = Layer.mergeAll( + output.layer, + telemetry.layer, + settings, + stackTargetResolverLayer.pipe(Layer.provide(settings)), + mockTty({ stdinIsTty: false }), + mockStdin(false), + Layer.succeed(YesFlag, true), + Layer.succeed(CliArgs, { args: ["--yes"] }), + ); + return { + api, + locations, + stack, + output, + layer, + flags: { stack: Option.none<string>(), stackId: Option.some(stack.id) }, + }; +}); + +const containerCleanup = (id: string) => + `sh -c 'ids=\\$\\(docker ps --all --quiet --no-trunc --filter '\\\\''label=com\\.supabase\\.stack=${id}'\\\\'' --filter '\\\\''label=com\\.supabase\\.stack-root=[^']+/${id}/data'\\\\''\\) && \\{ \\[ -z "\\$ids" \\] \\|\\| docker rm --force \\$ids; \\}'`; + +it.live( + "removes a stack locally and lists its cleanup commands when its engine is unreachable", + () => + Effect.gen(function* () { + const f = yield* fixture("text"); + yield* stackDestroy(f.flags).pipe(Effect.provide(f.layer)); + expect(f.output.stdoutText).toBe( + `Stack ${f.stack.id} was removed locally; its Docker resources remain until the commands above are run.\n`, + ); + expect(f.output.messages).toContainEqual({ + type: "warn", + message: expect.stringMatching( + new RegExp( + `^Docker was unavailable, so Docker resources for stack ${f.stack.id} were not removed\\. Once it is running, remove them with:\\n ${containerCleanup(f.stack.id)}$`, + "u", + ), + ), + }); + expect(yield* f.api.discover(f.locations)).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(live)), +); + +it.live("reports skipped engine cleanup and its commands in the JSON result", () => + Effect.gen(function* () { + const f = yield* fixture("json"); + yield* stackDestroy(f.flags).pipe(Effect.provide(f.layer)); + expect(f.output.messages).toContainEqual( + expect.objectContaining({ + data: { + destroyed: true, + id: f.stack.id, + runtimeCleanup: "skipped", + engine: "docker", + cleanupCommands: [ + expect.stringMatching(new RegExp(`^${containerCleanup(f.stack.id)}$`, "u")), + ], + }, + }), + ); + expect(yield* f.api.discover(f.locations)).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(live)), +); 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 547285590d..d4ada831f3 100644 --- a/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/list/SIDE_EFFECTS.md @@ -23,10 +23,9 @@ 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. JSON emits `{ "stacks": [...], "message": "" }`; stream-json wraps that data in -one result event. Invalid state documents are skipped with a warning on stderr identifying each stack. -Other registry read failures still fail discovery. State is never repaired or deleted; -opening stacks and allocating ports still reject invalid documents. Target resolution for -other stack commands also remains strict. +one result event. State 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. State is never repaired or deleted. ## Flags and exit codes diff --git a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts index 40d0f41840..36e1c08a0c 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.integration.test.ts @@ -102,8 +102,8 @@ describe("stack list", () => { } expect(yield* fixture.fs.readFileString(file)).toBe("{broken"); expect(yield* fixture.fs.readFileString(mismatchFile)).toBe(healthyState); - const discoveryError = yield* fixture.api.discover(fixture.locations).pipe(Effect.flip); - expect(discoveryError.operation).toBe("discover"); + const discovered = yield* fixture.api.discover(fixture.locations); + expect(discovered.map(({ definition }) => definition.id)).toEqual([healthy.id]); const openError = yield* fixture.api .open({ ...fixture.locations, id: broken.id }) .pipe(Effect.flip); diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts index 262bb07549..f9127c4023 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.handler.ts @@ -63,10 +63,7 @@ export const stackLogs = Effect.fn("experimental.stack.logs")(function* (flags: stateRoot: path.join(settings.supabaseHome, "stacks"), cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), }; - const discovery = yield* api.discover(locations).pipe(Effect.mapError(logsError)); - if ( - !discovery.some(({ definition, host }) => definition.id === target.id && host !== undefined) - ) + if (!target.hostRunning) return yield* new StackCommandLogsError({ reason: "lifecycle", message: "No owner is reachable; live logs are unavailable.", diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts index 509be704b2..5caba8c782 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts @@ -9,7 +9,7 @@ import { } from "../../../../../tests/helpers/command-mocks.ts"; import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shared.ts"; import { stackLogs } from "./logs.handler.ts"; -import { destroyTestStack } from "../../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../../tests/helpers/stack-cleanup.ts"; const live = Layer.provideMerge(stackApiLayer, BunServices.layer); const fixture = Effect.fn("StackLogsTest.fixture")(function* ( diff --git a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md index 1a41ef9324..4d51539261 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/prepare/SIDE_EFFECTS.md @@ -34,8 +34,9 @@ caching, preparation, and cancellation; the CLI owns the temporary instance clea ## Flags and output -`--stack` and `--stack-id` are mutually exclusive. `--runtime` defaults to `auto` for new stacks; -existing stacks reuse their persisted runtime, and an explicit mismatch fails. With no +`--stack` and `--stack-id` are mutually exclusive. `--runtime` defaults to `auto` for new stacks, +which selects the same runtime as `stack start` (Docker, then Podman, then native); +existing stacks reuse their persisted runtime without probing, and an explicit mismatch fails. With no `--capability`, every enabled creation from the effective project configuration is prepared. Repeated `--capability` includes each capability's configured companion services (Storage/Imgproxy, Studio/Pgmeta, Analytics/Vector); requesting a disabled service fails before instance preparation. The legacy `-o/--output` flag is rejected; use diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts index 3c5ac534c5..0e728fb4e3 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -11,8 +11,10 @@ const config = { Flag.withDescription("Open an existing stack by id."), Flag.optional, ), - runtime: Flag.choice("runtime", ["auto", "docker", "native"] as const).pipe( - Flag.withDescription("Runtime to use for a new stack."), + runtime: Flag.choice("runtime", ["auto", "docker", "podman", "native"] as const).pipe( + Flag.withDescription( + "Runtime to use for a new stack. auto selects Docker, then Podman, then native, based on what is available.", + ), Flag.withDefault("auto" as const), ), capability: Flag.atMost( diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts index 7ceff0b000..8fea9be28b 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.errors.ts @@ -1,5 +1,5 @@ import { StackError } from "@supabase/stack/effect"; -import { Data } from "effect"; +import { Data, Schema } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, @@ -7,7 +7,7 @@ import { } from "../../../../shared/telemetry/error-actionability.ts"; export class StackCommandPrepareError extends Data.TaggedError("ExperimentalStackPrepareError")<{ - readonly reason: "flags" | "invalid-config" | "artifact" | "lifecycle" | "unknown"; + readonly reason: "flags" | "invalid-config" | "runtime" | "artifact" | "lifecycle" | "unknown"; readonly message: string; readonly suggestion?: string; readonly cause?: unknown; @@ -19,6 +19,8 @@ export class StackCommandPrepareError extends Data.TaggedError("ExperimentalStac case "invalid-config": case "lifecycle": return actionability.invalidConfig; + case "runtime": + return actionability.dockerNotRunning; case "artifact": return actionability.externalNetwork; case "unknown": @@ -37,12 +39,11 @@ export const stackPrepareError = (error: unknown): StackCommandPrepareError => { ? error.message : String(error); return new StackCommandPrepareError({ - reason: - error instanceof StackError - ? error.operation === "prepareService" - ? "artifact" - : "unknown" - : "unknown", + reason: Schema.is(StackError)(error) + ? error.operation === "prepareService" + ? "artifact" + : "unknown" + : "unknown", message, cause: error, }); diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts index 19f437d2d7..08f53cb662 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.handler.ts @@ -2,7 +2,6 @@ import { Cause, Effect, Exit, Option, Path } from "effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; import { @@ -15,7 +14,10 @@ import { } from "../stack.shared.ts"; import type { StackPrepareFlags } from "./prepare.command.ts"; import { StackCommandPrepareError, stackPrepareError } from "./prepare.errors.ts"; -import { defaultStackRuntime } from "../../../../command-internal/stack-runtime.ts"; +import { + automaticRuntimeNotice, + selectStackRuntime, +} from "../../../../command-internal/stack-runtime.ts"; type PreparedCapability = { readonly capability: string; @@ -52,7 +54,6 @@ export const stackPrepare = Effect.fn("experimental.stack.prepare")(function* ( const output = yield* Output; const settings = yield* CommandSettings; const path = yield* Path.Path; - const runtime = yield* RuntimeInfo; const resolver = yield* StackTargetResolver; const api = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); @@ -81,17 +82,34 @@ export const stackPrepare = Effect.fn("experimental.stack.prepare")(function* ( ); const stateRoot = path.join(settings.supabaseHome, "stacks"); const cacheRoot = path.join(settings.supabaseHome, "cache", "stack"); + const createStack = Effect.gen(function* () { + const runtime = yield* selectStackRuntime(target.runtime).pipe( + Effect.mapError( + (error) => + new StackCommandPrepareError({ + reason: error.reason === "native-unsupported" ? "flags" : "runtime", + message: error.message, + suggestion: error.suggestion, + cause: error, + }), + ), + ); + const created = yield* api + .create({ + projectRoot: target.projectRoot, + stateRoot, + cacheRoot, + runtime, + ...(target.name === undefined ? {} : { name: target.name }), + }) + .pipe(Effect.mapError(stackPrepareError)); + const notice = automaticRuntimeNotice(target.runtime, runtime); + if (notice !== undefined) yield* output.info(notice); + return created; + }); const stack = target.id === undefined - ? yield* api - .create({ - projectRoot: target.projectRoot, - stateRoot, - cacheRoot, - runtime: target.runtime ?? defaultStackRuntime(runtime), - ...(target.name === undefined ? {} : { name: target.name }), - }) - .pipe(Effect.mapError(stackPrepareError)) + ? yield* createStack : yield* api .open({ id: target.id, stateRoot, cacheRoot }) .pipe(Effect.mapError(stackPrepareError)); @@ -142,7 +160,7 @@ export const stackPrepare = Effect.fn("experimental.stack.prepare")(function* ( ).pipe( Effect.onExit((exit) => Exit.isSuccess(exit) - ? task.clear() + ? task.clear : Option.match(Cause.findErrorOption(exit.cause), { onNone: () => (Cause.hasInterruptsOnly(exit.cause) ? task.cancel() : task.fail()), onSome: (error) => task.fail(error.message), diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts index d2d5973673..32d6862aa3 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.integration.test.ts @@ -8,11 +8,20 @@ import { type ServiceInstances, } from "@supabase/stack/effect"; import { runtimeInfoLayer } from "../../../../shared/runtime/runtime-info.layer.ts"; +import type { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { + StackRuntimeSelectionError, + type StackRuntime, +} from "../../../../command-internal/stack-runtime.ts"; +import { + containerEngineSpawner, + type ContainerEngineState, +} from "../../../../../tests/helpers/child-process-spawner.ts"; import { mockCommandSettings, mockTelemetryStateTracked, } from "../../../../../tests/helpers/command-mocks.ts"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { StackApi, StackTargetResolver } from "../stack.shared.ts"; import { stackPrepare } from "./prepare.handler.ts"; import type { StackPrepareFlags } from "./prepare.command.ts"; @@ -37,7 +46,23 @@ const makeProject = (config: string) => return root; }); -const makeFixture = (root: string, failPreparation = false) => { +interface FixtureOptions { + readonly failPreparation?: boolean; + readonly savedRuntime?: StackRuntime; + readonly engines?: { + readonly docker: ContainerEngineState; + readonly podman: ContainerEngineState; + }; + readonly runtimeInfo?: Layer.Layer<RuntimeInfo>; +} + +const makeFixture = (root: string, options: FixtureOptions = {}) => { + const { failPreparation = false } = options; + const engines = containerEngineSpawner( + options.engines ?? { docker: "missing", podman: "missing" }, + ); + const createdRuntimes: Array<StackRuntime> = []; + let openCount = 0; let prepareCount = 0; let startCount = 0; let destroyCount = 0; @@ -119,6 +144,7 @@ const makeFixture = (root: string, failPreparation = false) => { }, credentials: { get: Effect.die("unused") }, composition: { + plan: () => Effect.succeed([]), supabase: () => Effect.die("prepare must not change the composition"), configure: () => Effect.void, describe: Effect.succeed({ @@ -130,29 +156,63 @@ const makeFixture = (root: string, failPreparation = false) => { restart: Effect.succeed([]), }, stop: Effect.void, - destroy: Effect.void, - tools: { run: () => Effect.die("unused") }, + destroy: Effect.succeed({ runtimeCleanup: "complete" as const }), + commands: { run: () => Effect.die("unused") }, }; const output = mockOutput(); const telemetry = mockTelemetryStateTracked(); const layer = Layer.mergeAll( BunServices.layer, - runtimeInfoLayer, + options.runtimeInfo ?? runtimeInfoLayer, + engines.layer, mockCommandSettings({ workdir: root, supabaseHome: root }), output.layer, telemetry.layer, Layer.succeed(StackTargetResolver, { - resolve: () => Effect.succeed({ projectRoot: root, hostRunning: false }), + resolve: (input) => + Effect.succeed({ + projectRoot: root, + hostRunning: false, + ...(options.savedRuntime === undefined + ? input.runtime === "auto" + ? {} + : { runtime: input.runtime } + : { id, runtime: options.savedRuntime }), + }), }), Layer.succeed(StackApi, { - create: () => Effect.succeed(stack), - open: () => Effect.succeed(stack), + create: (input) => + Effect.sync(() => { + createdRuntimes.push(input.runtime); + return stack; + }), + open: () => + Effect.sync(() => { + openCount += 1; + return stack; + }), discover: () => Effect.succeed([]), - resolveIdentity: () => Effect.die("unused"), + find: () => Effect.die("unused"), }), ); return { layer, + get createdRuntimes() { + return createdRuntimes; + }, + get openCount() { + return openCount; + }, + get runtimeNotices() { + return output.messages + .filter( + ({ type, message }) => type === "info" && message?.startsWith("Docker didn't answer"), + ) + .map(({ message }) => message); + }, + get probes() { + return engines.spawned.map(({ command }) => command); + }, get prepareCount() { return prepareCount; }, @@ -215,7 +275,7 @@ describe("stack prepare", () => { it.live("removes its temporary instance after a preparation failure", () => makeProject(databaseOnlyConfig).pipe( Effect.flatMap((root) => { - const fixture = makeFixture(root, true); + const fixture = makeFixture(root, { failPreparation: true }); return stackPrepare(flags()).pipe( Effect.provide(fixture.layer), Effect.flip, @@ -274,3 +334,130 @@ describe("stack prepare", () => { ), ); }); + +describe("stack prepare automatic runtime selection", () => { + const selectRuntime = (options: FixtureOptions) => + makeProject(databaseOnlyConfig).pipe( + Effect.flatMap((root) => { + const fixture = makeFixture(root, options); + return stackPrepare(flags({ runtime: "auto" })).pipe( + Effect.provide(fixture.layer), + Effect.as(fixture), + ); + }), + Effect.provide(BunServices.layer), + ); + + it.live("creates a Docker stack when the Docker engine answers", () => + selectRuntime({ engines: { docker: "running", podman: "running" } }).pipe( + Effect.tap((fixture) => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["docker"]); + expect(fixture.probes).toEqual(["docker"]); + expect(fixture.runtimeNotices).toEqual([]); + }), + ), + ), + ); + + it.live("creates a Podman stack when Docker is not installed and Podman answers", () => + selectRuntime({ + engines: { docker: "missing", podman: "running" }, + runtimeInfo: mockRuntimeInfo({ platform: "linux", arch: "x64" }), + }).pipe( + Effect.tap((fixture) => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["podman"]); + expect(fixture.probes).toEqual(["docker", "podman"]); + expect(fixture.runtimeNotices).toHaveLength(1); + expect(fixture.runtimeNotices[0]).toContain("uses the Podman runtime"); + expect(fixture.runtimeNotices[0]).toContain("supabase stack destroy"); + }), + ), + ), + ); + + it.live( + "creates a native stack on Linux when the Docker daemon is down and Podman is absent", + () => + selectRuntime({ + engines: { docker: "stopped", podman: "missing" }, + runtimeInfo: mockRuntimeInfo({ platform: "linux", arch: "x64" }), + }).pipe( + Effect.tap((fixture) => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["native"]); + expect(fixture.probes).toEqual(["docker", "podman"]); + expect(fixture.runtimeNotices).toHaveLength(1); + expect(fixture.runtimeNotices[0]).toContain("uses the native runtime"); + }), + ), + ), + ); + + for (const [platform, arch] of [ + ["win32", "x64"], + ["darwin", "x64"], + ] as const) { + it.live(`fails without creating a stack when no engine answers on ${platform}/${arch}`, () => + makeProject(databaseOnlyConfig).pipe( + Effect.flatMap((root) => { + const fixture = makeFixture(root, { + engines: { docker: "stopped", podman: "stopped" }, + runtimeInfo: mockRuntimeInfo({ platform, arch }), + }); + return stackPrepare(flags({ runtime: "auto" })).pipe( + Effect.provide(fixture.layer), + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toBeInstanceOf(StackCommandPrepareError); + expect(error.reason).toBe("runtime"); + expect(error.cause).toBeInstanceOf(StackRuntimeSelectionError); + expect(error.message).toContain(`not supported on ${platform}/${arch}`); + expect(error.suggestion).toBe("Start Docker or Podman, then rerun the command."); + expect(fixture.createdRuntimes).toEqual([]); + }), + ), + ); + }), + Effect.provide(BunServices.layer), + ), + ); + } + + it.live("reuses a saved runtime without probing any engine", () => + selectRuntime({ + savedRuntime: "podman", + engines: { docker: "running", podman: "running" }, + }).pipe( + Effect.tap((fixture) => + Effect.sync(() => { + expect(fixture.openCount).toBe(1); + expect(fixture.createdRuntimes).toEqual([]); + expect(fixture.probes).toEqual([]); + expect(fixture.runtimeNotices).toEqual([]); + }), + ), + ), + ); + + it.live("honors an explicit runtime without probing any engine", () => + makeProject(databaseOnlyConfig).pipe( + Effect.flatMap((root) => { + const fixture = makeFixture(root, { engines: { docker: "running", podman: "running" } }); + return stackPrepare(flags({ runtime: "podman" })).pipe( + Effect.provide(fixture.layer), + Effect.tap(() => + Effect.sync(() => { + expect(fixture.createdRuntimes).toEqual(["podman"]); + expect(fixture.probes).toEqual([]); + expect(fixture.runtimeNotices).toEqual([]); + }), + ), + ); + }), + Effect.provide(BunServices.layer), + ), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts index 26038645a9..898d1cf198 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.handler.ts @@ -84,7 +84,7 @@ export const stackRestart = Effect.fn("experimental.stack.restart")(function* ( const observations = yield* stack.composition.restart.pipe( Effect.onExit((exit) => Exit.isSuccess(exit) - ? task.clear() + ? task.clear : Cause.hasInterruptsOnly(exit.cause) ? task.cancel() : task.fail(Option.getOrUndefined(Exit.findErrorOption(exit))?.message), diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts index c38a8091b1..ab24e7622a 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.integration.test.ts @@ -10,7 +10,7 @@ import { } from "../../../../../tests/helpers/command-mocks.ts"; import { StackApi, stackApiLayer, stackTargetResolverLayer } from "../stack.shared.ts"; import { stackRestart } from "./restart.handler.ts"; -import { destroyTestStack } from "../../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../../tests/helpers/stack-cleanup.ts"; const live = Layer.provideMerge(stackApiLayer, BunServices.layer); const fixture = Effect.fn("StackRestartTest.fixture")(function* () { diff --git a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts index ed5108a55e..45a41ae98b 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config-environment.integration.test.ts @@ -137,14 +137,11 @@ enabled = false it.live("rejects the existing OrioleDB environment override", () => Effect.gen(function* () { const root = yield* project('project_id = "stack-config-env-orioledb"\n'); - const exit = yield* withEnvVar( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - "15.1.1.14", - load(root), - ).pipe(Effect.exit); + const exit = yield* withEnvVar("SUPABASE_DB_ORIOLEDB_VERSION", "15.1.1.14", load(root)).pipe( + Effect.exit, + ); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) - expect(String(exit.cause)).toContain("experimental.orioledb_version"); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("db.orioledb_version"); }), ); }); diff --git a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts index 62fe1e1e9b..e32f41ad53 100644 --- a/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-config.integration.test.ts @@ -5,6 +5,7 @@ import { Effect, Exit, FileSystem, Layer, Path, Schema } from "effect"; import { importJWK, jwtVerify } from "jose"; import { ServiceCreationInput } from "../../../../../../packages/stack/src/services/Catalog.ts"; import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; +import { CLI_VERSION } from "../../../shared/cli/version.ts"; import { renderCliConfigTemplate } from "../../../shared/init/project-init.templates.ts"; import { loadStackConfig } from "../../../command-internal/stack-config.ts"; @@ -54,11 +55,11 @@ enabled = true for (const service of services) yield* Schema.decodeEffect(ServiceCreationInput)(service); const recipes = byService(services); - const identity = yield* config.identity; - expect(identity.anonKey).toBeUndefined(); - expect(identity.serviceRoleKey).toBeUndefined(); - expect(identity.gotrueJwtKeys).toBeUndefined(); - expect(identity.publicSigningKeys).toBeUndefined(); + const keys = yield* config.keys; + expect(keys.anonKey).toBeUndefined(); + expect(keys.serviceRoleKey).toBeUndefined(); + expect(keys.gotrueJwtKeys).toBeUndefined(); + expect(keys.publicSigningKeys).toBeUndefined(); const database = recipes.get("database"); expect( database?.service === "database" ? database.config.rootKey : undefined, @@ -123,6 +124,26 @@ enabled = true }).pipe(Effect.provide(BunServices.layer)), ); + it.live("hands Studio the API settings it mirrors and its snippets folder", () => + Effect.gen(function* () { + const root = yield* project(`project_id = "stack-config-studio" +[api] +schemas = ["public", "storage"] +extra_search_path = ["public", "extensions"] +max_rows = 250 +`); + const config = yield* load(root); + const studio = byService(yield* config.creations("stack-studio")).get("studio"); + expect(studio?.service === "studio" ? studio.config : undefined).toMatchObject({ + snippetsRoot: `${root}/supabase/snippets`, + apiSchemas: "public,storage", + apiExtraSearchPath: "public,extensions", + apiMaxRows: 250, + cliVersion: CLI_VERSION, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("leaves stack-opt-in init listeners automatic except disabled pooler", () => Effect.gen(function* () { const root = yield* project(renderCliConfigTemplate("stack-config-init", false, true)); @@ -184,9 +205,9 @@ signing_keys_path = "./keys.json" `); yield* fs.writeFileString(path.join(enabled, "supabase", "keys.json"), "[]"); const enabledConfig = yield* load(enabled); - const identity = yield* enabledConfig.identity; - expect(identity.gotrueJwtKeys).toBe("[]"); - expect(identity.publicSigningKeys).toBe("[]"); + const keys = yield* enabledConfig.keys; + expect(keys.gotrueJwtKeys).toBe("[]"); + expect(keys.publicSigningKeys).toBe("[]"); const disabled = yield* project(`project_id = "stack-config-disabled-signing-keys" [auth] @@ -194,19 +215,19 @@ enabled = false signing_keys_path = "./missing-keys.json" `); const disabledConfig = yield* load(disabled); - const disabledIdentity = yield* disabledConfig.identity; + const disabledKeys = yield* disabledConfig.keys; const jwks = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Array(publicJwkSchema)))( - disabledIdentity.publicSigningKeys ?? "[]", + disabledKeys.publicSigningKeys ?? "[]", ); expect(jwks).toHaveLength(1); expect(jwks[0]?.kid).toBe(DEFAULT_SIGNING_KEY.kid); - expect(disabledIdentity.publicSigningKeys).not.toContain('"d"'); + expect(disabledKeys.publicSigningKeys).not.toContain('"d"'); const publicJwk = jwks[0]; if (publicJwk === undefined) return yield* Effect.die("The default public JWK is missing."); const publicKey = yield* Effect.promise(() => importJWK(publicJwk, "ES256")); for (const [token, role] of [ - [disabledIdentity.anonKey, "anon"], - [disabledIdentity.serviceRoleKey, "service_role"], + [disabledKeys.anonKey, "anon"], + [disabledKeys.serviceRoleKey, "service_role"], ] as const) { expect(token).toBeDefined(); const verified = yield* Effect.promise(() => @@ -219,10 +240,10 @@ signing_keys_path = "./missing-keys.json" `project_id = "stack-config-env-disabled-signing-keys"\n[auth]\nenabled = false\n`, { supabaseEnv: "SUPABASE_AUTH_SIGNING_KEYS_PATH=./missing-keys.json\n" }, ); - const envIdentity = yield* (yield* load(envDisabled)).identity; - expect(envIdentity.publicSigningKeys).toBe(disabledIdentity.publicSigningKeys); - expect(envIdentity.anonKey).toBeDefined(); - expect(envIdentity.serviceRoleKey).toBeDefined(); + const envKeys = yield* (yield* load(envDisabled)).keys; + expect(envKeys.publicSigningKeys).toBe(disabledKeys.publicSigningKeys); + expect(envKeys.anonKey).toBeDefined(); + expect(envKeys.serviceRoleKey).toBeDefined(); }).pipe(Effect.provide(BunServices.layer)), ); @@ -254,10 +275,10 @@ signing_keys_path = "./missing-keys.json" }, ); const config = yield* load(root); - const identity = yield* config.identity; + const keys = yield* config.keys; const remoteJwks = yield* Schema.decodeEffect( Schema.fromJsonString(Schema.Array(remoteJwkSchema)), - )(identity.remoteJwks ?? "[]"); + )(keys.remoteJwks ?? "[]"); expect(remoteJwks).toEqual([remoteKey]); expect(paths).toEqual(["/.well-known/openid-configuration", "/jwks"]); @@ -268,8 +289,8 @@ enabled = false enabled = true issuer_url = "" `); - const emptyIssuerIdentity = yield* (yield* load(emptyIssuer)).identity; - expect(emptyIssuerIdentity.remoteJwks).toBeUndefined(); + const emptyIssuerKeys = yield* (yield* load(emptyIssuer)).keys; + expect(emptyIssuerKeys.remoteJwks).toBeUndefined(); expect(paths).toEqual(["/.well-known/openid-configuration", "/jwks"]); }).pipe(Effect.provide(BunServices.layer)), ); @@ -327,7 +348,18 @@ orioledb_version = "15.1.1.14" const orioledbExit = yield* load(orioledb).pipe(Effect.exit); expect(Exit.isFailure(orioledbExit)).toBe(true); if (Exit.isFailure(orioledbExit)) - expect(String(orioledbExit.cause)).toContain("experimental.orioledb_version"); + expect(String(orioledbExit.cause)).toContain("db.orioledb_version"); + + // The same rejection applies to the canonical `[db]` location, not just the deprecated + // `[experimental]` alias. + const orioledbCanonical = yield* project(`project_id = "stack-config-orioledb-db" +[db] +orioledb_version = "15.1.1.14" +`); + const orioledbCanonicalExit = yield* load(orioledbCanonical).pipe(Effect.exit); + expect(Exit.isFailure(orioledbCanonicalExit)).toBe(true); + if (Exit.isFailure(orioledbCanonicalExit)) + expect(String(orioledbCanonicalExit.cause)).toContain("db.orioledb_version"); const s3 = yield* project(`project_id = "stack-config-experimental-s3" [experimental] diff --git a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts index 984b57cc34..79ad9f79c4 100644 --- a/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack-forwarding.integration.test.ts @@ -61,30 +61,40 @@ vector_port = 59001 realtime?.service === "realtime" ? realtime.config.secretKeyBase : undefined, ).toBeUndefined(); expect(services.find((s) => s.service === "vector")?.endpoints?.http?.port).toBe(59001); + // A composition binds each database URL before launch. + const bound = <C extends { readonly config: object }>(creation: C) => ({ + ...creation, + config: { + ...creation.config, + databaseUrl: "postgresql://supabase_admin@127.0.0.1/postgres", + }, + }); for (const container of [false, true]) { for (const service of services) { switch (service.service) { case "rest": - expect(yield* restSpec().env(service, new Map(), container)).toMatchObject({ + expect(yield* restSpec().env(bound(service), new Map(), container)).toMatchObject({ PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions,custom", }); break; case "pooler": - expect(yield* poolerSpec().env(service, new Map(), container)).toMatchObject({ + expect(yield* poolerSpec().env(bound(service), new Map(), container)).toMatchObject({ TENANT_ID: "pooler-dev", DEFAULT_POOL_SIZE: "7", MAX_CLIENT_CONN: "42", }); break; case "realtime": - expect(yield* realtimeSpec().env(service, new Map(), container)).toMatchObject({ - MAX_HEADER_LENGTH: "8192", - ERL_AFLAGS: "-proto_dist inet6_tcp", - DB_IP_VERSION: "ipv4", - }); + expect(yield* realtimeSpec().env(bound(service), new Map(), container)).toMatchObject( + { + MAX_HEADER_LENGTH: "8192", + ERL_AFLAGS: "-proto_dist inet6_tcp", + DB_IP_VERSION: "ipv4", + }, + ); break; case "storage": - expect(yield* storageSpec().env(service, new Map(), container)).toMatchObject({ + expect(yield* storageSpec().env(bound(service), new Map(), container)).toMatchObject({ S3_PROTOCOL_ENABLED: "false", VECTOR_ENABLED: "false", VECTOR_MAX_BUCKETS: "3", @@ -304,6 +314,7 @@ allow_dynamic_registration = true ...auth, config: { ...auth.config, + databaseUrl: "postgresql://supabase_auth_admin@127.0.0.1/postgres", smtpUrl: "smtp://127.0.0.1:1025", }, }, diff --git a/apps/cli/src/commands/experimental/stack/stack-summary.ts b/apps/cli/src/commands/experimental/stack/stack-summary.ts new file mode 100644 index 0000000000..3354c75b6a --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/stack-summary.ts @@ -0,0 +1,287 @@ +import { Effect, Redacted } from "effect"; +import { + apiRoute, + type Observation, + type ServiceCreation, + type StackCredentials, +} from "@supabase/stack/effect"; +import { red } from "../../../command-internal/colors.ts"; +import { toUserFacingDatabaseUrl } from "../../../command-internal/postgres-url.ts"; +import { + renderStatusGroups, + statusGroups, + type StatusGroup, +} from "../../../command-internal/status-pretty.ts"; +import { resolveOutputNames } from "../../../command-internal/status-values.ts"; +import { endpointReports } from "./stack-endpoints.format.ts"; + +type ServiceName = ServiceCreation["service"]; + +export type StackServiceState = + | "unavailable" + | "sleeping" + | "starting" + | "running" + | "stopping" + | "stopped" + | "unhealthy" + | "exited"; + +/** An observed service; `activation` is undefined for an instance outside the composition. */ +export interface StackServiceView { + readonly service: ServiceName; + readonly observation: Observation | undefined; + readonly activation: string | undefined; + readonly error?: string | undefined; +} + +/** S3 protocol endpoint and local access keys of the Storage member. */ +interface StackStorageS3 { + readonly url: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; + readonly region: string; +} + +/** Connection URLs derived from the observed composition members. */ +export interface StackConnections { + readonly api?: string; + readonly rest?: string; + readonly functions?: string; + readonly studio?: string; + readonly mcp?: string; + readonly mailpit?: string; + readonly database?: string; + readonly s3?: StackStorageS3; +} + +export const serviceState = (observation: Observation | undefined): StackServiceState => { + if (observation === undefined) return "unavailable"; + if (observation.health === "unhealthy") return "unhealthy"; + if (observation.lifecycle === "stopped") { + if (observation.error?.operation === "exit") return "exited"; + return observation.wakeEnabled ? "sleeping" : "stopped"; + } + return observation.lifecycle; +}; + +/** Reports raw endpoint observations keyed `service.endpoint`. */ +export const stackEndpoints = ( + services: ReadonlyArray<Pick<StackServiceView, "service" | "observation">>, +) => + Object.fromEntries( + services.flatMap(({ service, observation }) => + Object.entries(endpointReports(observation)).map( + ([name, endpoint]) => [`${service}.${name}`, endpoint] as const, + ), + ), + ); + +const stackDatabaseUrl = (observation: Observation | undefined): string | undefined => { + if (observation?.config.service !== "database") return undefined; + const sql = observation.endpoints.find(({ name }) => name === "sql"); + if (sql === undefined) return undefined; + return toUserFacingDatabaseUrl({ + host: sql.host, + port: sql.port, + password: Redacted.value(observation.config.config.databasePassword), + }); +}; + +const stackStorageS3 = ( + storageUrl: string | undefined, + observation: Observation | undefined, +): StackStorageS3 | undefined => { + if (storageUrl === undefined || observation?.config.service !== "storage") return undefined; + const { s3ProtocolEnabled, s3AccessKeyId, s3SecretAccessKey, s3Region } = + observation.config.config; + return s3ProtocolEnabled === false || + s3AccessKeyId === undefined || + s3SecretAccessKey === undefined || + s3Region === undefined + ? undefined + : { + url: `${storageUrl}/s3`, + accessKeyId: s3AccessKeyId, + secretAccessKey: s3SecretAccessKey, + region: s3Region, + }; +}; + +/** Derives connection URLs; callers pass composition members only. */ +export const stackConnections = ( + members: ReadonlyArray<Pick<StackServiceView, "service" | "observation">>, +): StackConnections => { + const http = (services: ReadonlyArray<ServiceName>) => + members + .filter(({ service }) => services.includes(service)) + .map(({ observation }) => endpointReports(observation).http?.url) + .find((url) => url !== undefined); + const api = http( + members.map(({ service }) => service).filter((service) => apiRoute(service) !== undefined), + ); + const routed = (service: ServiceName) => { + const route = apiRoute(service); + return api === undefined || route === undefined || http([service]) === undefined + ? undefined + : `${api}${route}`; + }; + const rest = routed("rest"); + const functions = routed("functions"); + const studio = http(["studio"]); + const mailpit = http(["mail"]); + const database = stackDatabaseUrl( + members.find(({ service }) => service === "database")?.observation, + ); + const s3 = stackStorageS3( + routed("storage"), + members.find(({ service }) => service === "storage")?.observation, + ); + return { + ...(api === undefined ? {} : { api }), + ...(rest === undefined ? {} : { rest }), + ...(functions === undefined ? {} : { functions }), + ...(studio === undefined ? {} : { studio }), + // The shared API listener serves `/mcp` only when Studio is a composition member with an + // HTTP endpoint. + ...(api !== undefined && studio !== undefined ? { mcp: `${api}/mcp` } : {}), + ...(mailpit === undefined ? {} : { mailpit }), + ...(database === undefined ? {} : { database }), + ...(s3 === undefined ? {} : { s3 }), + }; +}; + +/** The `status --env` variable map; shared by `stack start`'s JSON `env` and `stack status`. */ +export const connectionEnv = ( + connections: StackConnections, + credentials: + | Pick<StackCredentials, "publishableKey" | "secretKey" | "anonKey" | "serviceRoleKey"> + | undefined, +): Readonly<Record<string, string>> => { + const values: Record<string, string> = {}; + if (connections.api !== undefined) values.API_URL = connections.api; + if (connections.rest !== undefined) values.REST_URL = connections.rest; + if (connections.functions !== undefined) values.FUNCTIONS_URL = connections.functions; + if (connections.database !== undefined) values.DB_URL = connections.database; + if (connections.studio !== undefined) values.STUDIO_URL = connections.studio; + if (connections.mcp !== undefined) values.MCP_URL = connections.mcp; + if (connections.mailpit !== undefined) { + values.MAILPIT_URL = connections.mailpit; + // Deprecated alias of `MAILPIT_URL`, kept for parity with legacy `status --env`. + values.INBUCKET_URL = connections.mailpit; + } + if (credentials !== undefined) { + values.PUBLISHABLE_KEY = credentials.publishableKey; + values.SECRET_KEY = credentials.secretKey; + values.ANON_KEY = credentials.anonKey; + values.SERVICE_ROLE_KEY = credentials.serviceRoleKey; + } + if (connections.s3 !== undefined) { + values.STORAGE_S3_URL = connections.s3.url; + values.S3_PROTOCOL_ACCESS_KEY_ID = connections.s3.accessKeyId; + values.S3_PROTOCOL_ACCESS_KEY_SECRET = connections.s3.secretAccessKey; + values.S3_PROTOCOL_REGION = connections.s3.region; + } + return values; +}; + +const connectionValues = ( + connections: StackConnections, + credentials: Pick<StackCredentials, "publishableKey" | "secretKey"> | undefined, +) => { + const names = resolveOutputNames(new Map()); + const entries: ReadonlyArray<readonly [string, string | undefined]> = [ + [names.apiUrl, connections.api], + [names.restUrl, connections.rest], + [names.functionsUrl, connections.functions], + [names.studioUrl, connections.studio], + [names.mcpUrl, connections.mcp], + [names.mailpitUrl, connections.mailpit], + [names.dbUrl, connections.database], + [names.publishableKey, credentials?.publishableKey], + [names.secretKey, credentials?.secretKey], + [names.storageS3Url, connections.s3?.url], + [names.storageS3AccessKeyId, connections.s3?.accessKeyId], + [names.storageS3SecretAccessKey, connections.s3?.secretAccessKey], + [names.storageS3Region, connections.s3?.region], + ]; + return { + names, + values: Object.fromEntries( + entries.filter((entry): entry is readonly [string, string] => entry[1] !== undefined), + ), + }; +}; + +const serviceDetail = (view: StackServiceView, state: StackServiceState) => { + const health = view.observation?.health; + const parts: Array<string> = [state]; + if (state === "running" && health !== undefined) parts.push(health); + if (view.activation === undefined) parts.push("standalone"); + else if (view.activation === "lazy" && state === "sleeping") + parts.push("starts on first request"); + else parts.push(view.activation); + return parts.join(" · "); +}; + +const serviceKind = (view: StackServiceView, state: StackServiceState) => { + switch (state) { + case "running": + return view.observation?.health === "healthy" ? "good" : "pending"; + case "starting": + case "stopping": + return "pending"; + case "unhealthy": + case "exited": + return "bad"; + case "sleeping": + case "stopped": + case "unavailable": + return "muted"; + } +}; + +const servicesGroup = (services: ReadonlyArray<StackServiceView>): StatusGroup => ({ + name: "🧩 Services", + items: services.map((view) => { + const state = serviceState(view.observation); + return { + label: view.service, + value: serviceDetail(view, state), + kind: serviceKind(view, state), + }; + }), +}); + +/** Renders the member connections and service states in the legacy `status` table layout. */ +export const renderStackSummary = ( + services: ReadonlyArray<StackServiceView>, + credentials: Pick<StackCredentials, "publishableKey" | "secretKey"> | undefined, +): string => { + const { values, names } = connectionValues( + stackConnections(services.filter(({ activation }) => activation !== undefined)), + credentials, + ); + const errors = services.flatMap(({ service, error }) => + error === undefined ? [] : [red(`${service}: ${error}`, process.stdout)], + ); + const groups = renderStatusGroups( + [...statusGroups(values, names), servicesGroup(services)].filter(({ items }) => + items.some(({ value }) => value.length > 0), + ), + ); + return errors.length === 0 ? groups : `${groups}${errors.join("\n")}\n\n`; +}; + +/** Reads saved keys for display, warning instead of failing when they cannot be read. */ +export const summaryCredentials = ( + read: Effect.Effect<StackCredentials | undefined, { readonly message: string }>, + warn: (message: string) => Effect.Effect<void>, +) => + read.pipe( + Effect.catch((error) => + warn( + `The stack keys could not be read: ${error.message}. Run supabase status --env to retry.`, + ).pipe(Effect.as(undefined)), + ), + ); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts index 1a19d2f108..ed4e9742cb 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.integration.test.ts @@ -1,7 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; -import { resolveStackIdentity } from "@supabase/stack/internal/identity"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { StackApi, StackTargetError, @@ -15,69 +14,62 @@ type TargetInput = { readonly projectRoot: string; readonly name?: string; readonly id?: string; - readonly runtime: "auto" | "docker" | "native"; + readonly runtime: "auto" | "docker" | "podman" | "native"; }; -type DiscoveredStack = Effect.Success< - ReturnType<StackApi["Service"]["discover"]> ->[number]["definition"]; - -const stack = ( - id: string, - identity: DiscoveredStack["identity"], - runtime: DiscoveredStack["runtime"] = "native", -): DiscoveredStack => ({ - id, - identity, - runtime, - instances: [], - composition: { members: [], dependencies: [] }, - ports: [], +const workspace = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-" })); + const home = path.join(root, ".supabase"); + const register = (options: { + readonly projectRoot: string; + readonly name?: string; + readonly runtime?: "native" | "docker"; + }) => + Effect.gen(function* () { + const api = yield* StackApi; + const stack = yield* api.create({ + projectRoot: options.projectRoot, + ...(options.name === undefined ? {} : { name: options.name }), + stateRoot: path.join(home, "stacks"), + cacheRoot: path.join(home, "cache", "stack"), + runtime: options.runtime ?? "native", + }); + return stack.id; + }).pipe(Effect.scoped, Effect.provide(stackApiLayer)); + const resolve = (input: TargetInput) => + Effect.gen(function* () { + const resolver = yield* StackTargetResolver; + return yield* resolver.resolve(input); + }).pipe( + Effect.provide( + stackTargetResolverLayer.pipe( + Layer.provide(mockCommandSettings({ workdir: root, supabaseHome: home })), + Layer.provide(stackApiLayer), + ), + ), + ); + return { root, home, register, resolve }; }); -const resolverLayer = ( - discovered: ReadonlyArray<DiscoveredStack>, - workdir: string, - supabaseHome: string, -) => { - const api = Layer.succeed(StackApi, { - create: () => Effect.die("unused"), - open: () => Effect.die("unused"), - discover: () => - Effect.succeed(discovered.map((definition) => ({ definition, host: undefined }))), - resolveIdentity: (options) => - resolveStackIdentity(options).pipe(Effect.provide(BunServices.layer)), - }); - return stackTargetResolverLayer.pipe( - Layer.provide(mockCommandSettings({ workdir, supabaseHome })), - Layer.provide(api), - Layer.provide(BunServices.layer), - ); -}; - -const resolveTarget = (layer: Layer.Layer<StackTargetResolver>, input: TargetInput) => - Effect.gen(function* () { - const resolver = yield* StackTargetResolver; - return yield* resolver.resolve(input); - }).pipe(Effect.provide(layer)); - describe("stack target resolver", () => { it.live("resolves an explicit id without reading the current project identity", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-id-" }); + const { root, register, resolve } = yield* workspace; const storedRoot = path.join(root, "stored-project"); - const id = "a".repeat(64); - const target = yield* resolveTarget( - resolverLayer( - [stack(id, { projectRoot: storedRoot, branchContext: "main", stackName: "default" })], - root, - path.join(root, ".supabase"), - ), - { projectRoot: path.join(root, "missing-current-project"), id, runtime: "auto" }, - ); - expect(target).toEqual({ + yield* fs.makeDirectory(storedRoot); + const id = yield* register({ projectRoot: storedRoot }); + + const target = yield* resolve({ + projectRoot: path.join(root, "missing-current-project"), + id, + runtime: "auto", + }); + + expect(target).toMatchObject({ projectRoot: storedRoot, id, runtime: "native", @@ -87,56 +79,85 @@ describe("stack target resolver", () => { ); it.live("rejects an explicit id when its saved runtime differs", () => + Effect.gen(function* () { + const { root, register, resolve } = yield* workspace; + const id = yield* register({ projectRoot: root, runtime: "docker" }); + + const failure = yield* resolve({ projectRoot: root, id, runtime: "native" }).pipe( + Effect.flip, + ); + + expect(failure).toBeInstanceOf(StackTargetError); + expect(failure.reason).toBe("flags"); + expect(failure.message).toContain("Requested runtime native"); + expect(failure.message).toContain("existing stack runtime docker"); + expect(failure.suggestion).toContain("--runtime auto"); + expect(failure.suggestion).toContain("different --stack name"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("selects the saved stack whose identity the project and stack name derive", () => + Effect.gen(function* () { + const { root, register, resolve } = yield* workspace; + yield* register({ projectRoot: root }); + const id = yield* register({ projectRoot: root, name: "feature" }); + + const target = yield* resolve({ projectRoot: root, name: "feature", runtime: "auto" }); + + expect(target).toMatchObject({ projectRoot: root, id, name: "feature" }); + expect(target.definition?.identity.stackName).toBe("feature"); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("reports no id when the project has no saved stack", () => + Effect.gen(function* () { + const { root, resolve } = yield* workspace; + + const target = yield* resolve({ projectRoot: root, runtime: "docker" }); + + expect(target).toEqual({ projectRoot: root, runtime: "docker", hostRunning: false }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("carries an explicit Podman runtime onto a new stack target", () => + Effect.gen(function* () { + const { root, resolve } = yield* workspace; + + const target = yield* resolve({ projectRoot: root, runtime: "podman" }); + + expect(target).toMatchObject({ runtime: "podman", hostRunning: false }); + expect(target.id).toBeUndefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("returns the canonical project root for a new stack reached through a symlink", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-runtime-" }); - const id = "b".repeat(64); - const exit = yield* resolveTarget( - resolverLayer( - [stack(id, { projectRoot: root, branchContext: "main", stackName: "default" }, "docker")], - root, - path.join(root, ".supabase"), - ), - { projectRoot: root, id, runtime: "native" }, - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Exit.findErrorOption(exit); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(StackTargetError); - if (failure.value instanceof StackTargetError) { - expect(failure.value.reason).toBe("flags"); - expect(failure.value.message).toContain("Requested runtime native"); - expect(failure.value.message).toContain("existing stack runtime docker"); - expect(failure.value.suggestion).toContain("--runtime auto"); - expect(failure.value.suggestion).toContain("different --stack name"); - } - } - } + const { root, resolve } = yield* workspace; + const link = path.join(root, "link"); + yield* fs.symlink(root, link); + + const target = yield* resolve({ projectRoot: link, runtime: "auto" }); + + expect(target.projectRoot).toBe(root); + expect(target.id).toBeUndefined(); }).pipe(Effect.provide(BunServices.layer)), ); - it.live("selects a saved stack by the package identity tuple", () => + it.live("surfaces an unreadable saved stack instead of reporting it missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-target-identity-" }); - const canonicalRoot = yield* fs.realPath(root); - const identity = yield* resolveStackIdentity({ projectRoot: root, name: "feature" }).pipe( - Effect.provide(BunServices.layer), - ); - const id = "c".repeat(64); - const target = yield* resolveTarget( - resolverLayer( - [stack("d".repeat(64), { ...identity, branchContext: "other" }), stack(id, identity)], - root, - path.join(root, ".supabase"), - ), - { projectRoot: root, name: "feature", runtime: "auto" }, - ); - expect(target).toMatchObject({ projectRoot: canonicalRoot, id, name: "feature" }); + const { root, home, register, resolve } = yield* workspace; + const id = yield* register({ projectRoot: root }); + yield* fs.writeFileString(path.join(home, "stacks", id, "state.json"), "{broken"); + + const failure = yield* resolve({ projectRoot: root, runtime: "auto" }).pipe(Effect.flip); + + expect(failure).toBeInstanceOf(StackTargetError); + expect(failure.reason).toBe("invalid-config"); + expect(failure.message).toContain(id); }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 3556fcb13d..d7958bcc7c 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -1,24 +1,32 @@ -import type { ServiceCreation, StackError } from "@supabase/stack/effect"; -import { Context, Data, Effect, Layer, Option, Path } from "effect"; +import { + StackId, + type SavedStack, + type ServiceCreation, + type StackError, +} from "@supabase/stack/effect"; +import { Context, Data, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -import { StackApi, stackApiLayer } from "../../../command-internal/stack-api.ts"; - -export { StackApi, stackApiLayer }; +import { + skippedRuntimeCleanupWarning, + StackApi, + stackApiLayer, +} from "../../../command-internal/stack-api.ts"; +import type { StackRuntime } from "../../../command-internal/stack-runtime.ts"; -type StackId = string; -const isStackId = (id: string): boolean => /^[0-9a-f]{64}$/u.test(id); +export { skippedRuntimeCleanupWarning, StackApi, stackApiLayer }; /** The target selected by the CLI adapter for one stack command. */ export interface StackTarget { readonly projectRoot: string; - readonly id?: StackId; + readonly id?: string; readonly name?: string; - readonly runtime?: "native" | "docker" | "podman"; + readonly runtime?: StackRuntime; + readonly definition?: SavedStack; readonly hostRunning: boolean; } @@ -38,7 +46,7 @@ interface StackTargetResolverShape { readonly projectRoot: string; readonly name?: string; readonly id?: string; - readonly runtime: "auto" | "docker" | "native"; + readonly runtime: "auto" | StackRuntime; }) => Effect.Effect<StackTarget, StackTargetError>; } @@ -60,8 +68,8 @@ export const validateStackTarget = (input: { ) : Effect.void; -const validateStackId = (id: string): Effect.Effect<StackId, StackTargetError> => - isStackId(id) +const validateStackId = (id: string): Effect.Effect<string, StackTargetError> => + Schema.is(StackId)(id) ? Effect.succeed(id) : Effect.fail( new StackTargetError({ @@ -84,52 +92,43 @@ export const rejectStackOutput = ( ) : Effect.void; -const runtimeForFlag = ( - runtime: "auto" | "docker" | "native", -): StackTarget["runtime"] | undefined => - runtime === "auto" ? undefined : runtime === "docker" ? "docker" : "native"; +const runtimeForFlag = (runtime: "auto" | StackRuntime): StackTarget["runtime"] => + runtime === "auto" ? undefined : runtime; const runtimeMatches = ( saved: StackTarget["runtime"], requested: StackTarget["runtime"], ): boolean => requested === undefined || saved === requested; -/** Resolves an existing stack by its persisted canonical package identity. */ +/** Resolves an existing stack by id or by the package identity of the project and stack name. */ export const stackTargetResolverLayer = Layer.effect( StackTargetResolver, Effect.gen(function* () { const settings = yield* CommandSettings; const stackApi = yield* StackApi; + const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const resolve = Effect.fn("StackTargetResolver.resolve")(function* (input: { readonly projectRoot: string; readonly name?: string; readonly id?: string; - readonly runtime: "auto" | "docker" | "native"; + readonly runtime: "auto" | StackRuntime; }) { const id = input.id === undefined ? undefined : yield* validateStackId(input.id); const requestedRuntime = runtimeForFlag(input.runtime); - const identity = - id === undefined - ? yield* stackApi - .resolveIdentity({ + const stateRoot = path.join(settings.supabaseHome, "stacks"); + const found = yield* stackApi + .find( + id === undefined + ? { + stateRoot, projectRoot: input.projectRoot, ...(input.name === undefined ? {} : { name: input.name }), - }) - .pipe( - Effect.mapError( - (cause) => - new StackTargetError({ - message: cause.message, - reason: "invalid-config", - cause, - }), - ), - ) - : undefined; - const discovered = yield* stackApi - .discover({ stateRoot: path.join(settings.supabaseHome, "stacks") }) + } + : { stateRoot, id }, + ) .pipe( + Effect.map(Option.getOrUndefined), Effect.mapError( (cause) => new StackTargetError({ @@ -139,16 +138,6 @@ export const stackTargetResolverLayer = Layer.effect( }), ), ); - const found = - id === undefined - ? discovered.find( - ({ definition }) => - identity !== undefined && - definition.identity.projectRoot === identity.projectRoot && - definition.identity.branchContext === identity.branchContext && - definition.identity.stackName === identity.stackName, - ) - : discovered.find(({ definition }) => definition.id === id); if (id !== undefined && found === undefined) return yield* new StackTargetError({ message: `Stack ${id} was not found`, @@ -161,16 +150,23 @@ export const stackTargetResolverLayer = Layer.effect( "Use --runtime auto to reuse the saved runtime, or omit --stack-id and choose a different --stack name.", reason: "flags", }); + const runtime = found?.definition.runtime ?? requestedRuntime; + // A new stack saves paths under the canonical root its identity derives from. + const projectRoot = + found?.definition.identity.projectRoot ?? + (yield* fs + .realPath(input.projectRoot) + .pipe( + Effect.mapError( + (cause) => + new StackTargetError({ message: cause.message, reason: "invalid-config", cause }), + ), + )); return { - projectRoot: - found?.definition.identity.projectRoot ?? identity?.projectRoot ?? input.projectRoot, - ...(found === undefined ? {} : { id: found.definition.id }), + projectRoot, + ...(found === undefined ? {} : { id: found.definition.id, definition: found.definition }), ...(input.name === undefined ? {} : { name: input.name }), - ...(found === undefined && requestedRuntime === undefined - ? {} - : found === undefined - ? { runtime: requestedRuntime } - : { runtime: found.definition.runtime }), + ...(runtime === undefined ? {} : { runtime }), hostRunning: found?.host !== undefined, }; }); diff --git a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md index 8547236dbc..59b0bf2b38 100644 --- a/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md @@ -8,19 +8,27 @@ It creates or resumes the stack for the project and optional `--stack` name, or For a new stack or a stopped existing stack, the CLI loads the target project's `supabase/config.toml`, supported environment overrides, and project dotenv files. It validates the -supported configuration before creating service definitions. When the existing composition is -already running, start reports its current endpoints and returns without reading or applying project +supported configuration before creating service definitions. When every member of the existing +composition is running and healthy, or armed to wake and not already starting, start reports its +current endpoints and returns without reading or applying project configuration. When the database +is running and every other member is running with any health, starting, or armed to wake, start +notes that configuration changes apply after stop and start, starts the saved composition, and waits +until every member that was running or starting is ready, again without reading project configuration. -If the stack is in a partial lifecycle state, start fails with guidance to stop the stack and start -it again before applying configuration. +If the stack is otherwise in a partial lifecycle state, start fails with guidance to stop the stack +and start it again before applying configuration. Auth policies, OAuth providers, hooks, MFA, SMTP, email subjects and notification controls are forwarded to Auth. REST search paths, pooler limits, Realtime settings, Studio settings, Storage -S3 protocol/vector controls, and configured Vector ports are forwarded to their services. +S3 protocol/vector controls, and configured Vector ports are forwarded to their services. Storage +receives the local S3 access keys and region, and uses the gateway's `/storage/v1` prefix to verify +S3 signatures and to build resumable upload URLs. Encrypted JWT secrets are decrypted before shared credentials are derived. `db.health_timeout` controls database readiness; package JWT and PostgreSQL root-key defaults apply when omitted, and the effective root key is supplied through a stack-owned key file. -Studio receives the Functions management directory/URL and Analytics credentials when present. +Studio receives the database connection, the Functions management directory/URL, and Analytics +credentials when present. Starting with Studio creates `supabase/snippets/`, where Studio saves SQL +snippets. Email template `content_path` values and third-party identity providers remain unsupported: they require template serving and shared external JWKS verification respectively. @@ -29,20 +37,30 @@ Secrets needed by enabled services are passed to the runtime. State and service use `$SUPABASE_HOME/cache/stack`. Storage files use the caller-owned project directory `supabase/.temp/stack-uploads/<stack-id>/`. Functions preparation may build the project's source. -For a new stack, `--runtime auto` selects native on Linux x64/arm64 and macOS arm64, and Docker -elsewhere. An existing stack keeps its saved runtime. Explicit runtime selection has no fallback. +For a new stack, `--runtime auto` selects Docker when `docker version` reaches its daemon, then +Podman when `podman info` reaches its engine, then native on Linux x64/arm64 and macOS arm64. Each +probe is bounded by 10 seconds. Without a reachable engine on other platforms, the command fails and +asks the user to start Docker or Podman. When auto selection skips Docker, an info line names the +saved Podman or native runtime and how to switch to Docker. An existing stack keeps its saved +runtime and runs no probe. Explicit `--runtime docker`, `podman`, or `native` has no fallback. +When an explicit or saved Docker runtime is unreachable, the reported failure suggests starting +Docker, and `--runtime native` for a new stack on platforms that support native. Explicit +`--runtime native` on a platform with no native artifacts fails before creating a stack. + Native startup refuses root because PostgreSQL `initdb` cannot run as root, unless a Claude Code -sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs +or Modal Sandbox is detected or `SUPABASE_NATIVE_POSTGRES_USER` names a non-root user. PostgreSQL then runs as that user: the CLI chowns the instance data, root key, socket directory, and the cached bundle's `pgsodium_getkey.sh` to it, and adds traverse-only `o+x` to their parent directories, including root's home directory. Later commands that restrict the artifact cache and stack state roots to their owner keep that grant. Database is eager by default. Other services are lazy; traffic wakes them through their listeners. -Lazy services with idle policies stop after 60 seconds without traffic; Functions has no automatic -idle stop. `--eager` makes all selected services eager. Changes to activation policy take effect -after stopping and starting the stack, including when a later invocation omits an earlier `--eager` -flag. `--preparation` selects on-demand or background artifact preparation. +Lazy services with idle policies stop after 60 seconds without traffic, Studio after 5 minutes. A +service that a running service depends on, such as pg-meta for Studio, stays up until that +dependent stops. Functions has no automatic idle stop. `--eager` makes all selected services eager. +Changes to activation policy take effect after stopping and starting the stack, including when a +later invocation omits an earlier `--eager` flag. `--preparation` selects on-demand or background +artifact preparation. When Functions is selected, the CLI reads and validates `supabase/functions/.env`, ignoring reserved `SUPABASE_*` entries. `edge_runtime.secrets` overrides that file, while `functions.<name>.env` @@ -50,10 +68,8 @@ provides per-function values from project environment references. Per-function e entrypoints, import maps and static files are forwarded to the worker bootstrap. Configured paths are relative to `supabase/` and must remain within the project; Docker mounts that project read-only. The inspector port is retained as an endpoint intent and does not enable debugging by itself. -After an explicit stack stop, start applies changed Functions env values, per-function settings, -files root, and JWT verification when it updates the saved composition. Existing service identities, -endpoints, and lazy activation are retained. Running start calls do not refresh Functions from -changed project files; stop the stack and start it again to apply those changes. +Running start calls do not refresh Functions from changed project files; stop the stack and start +it again to apply those changes. ## Service selection @@ -64,11 +80,16 @@ Studio requires REST; excluding REST while keeping Studio fails before stopping Vector runs a stack-owned default configuration that enables its health API and forwards no service logs; log collection into Analytics is not implemented yet. -After an explicit stop, changed exclusions reuse existing service identities, data, and ports. -Removed services remain saved and stopped so including them again can reuse them. The -project configuration file is unchanged. Incompatible version, endpoint, or supported configuration -changes fail before modifying the stopped composition; they are not silently applied to saved -instances. +After an explicit stop, start compares the project configuration with the saved composition through +the stack package's composition plan, ignoring values the composition and stack credentials supply. +Changed service settings, including Functions env values, per-function settings, files root, and JWT +verification, replace the saved configuration of the existing instances; their identities, data, +and ports are retained. Changed exclusions reuse existing service identities, data, and ports. +Removed services remain saved and stopped so including them again can reuse them; a saved stopped +instance of a newly included service is reused when its endpoints and versions still match. The +project configuration file is unchanged. A changed endpoint, artifact version, or PostgreSQL major +version fails before modifying the stopped composition, naming the changed setting and suggesting +`supabase stack destroy` to recreate the stack. ## First startup and retries @@ -78,16 +99,19 @@ apply needed catalog and webhook setup without replaying project migrations or s composition reapplies the webhook setting before activation. When configured, initial Storage bucket seeding creates buckets and uploads their `objects_path` -files using the service-role JWT. Storage is started and made ready before those requests. A resumed +files using the service-role JWT, silently overwriting or pruning existing buckets without a +confirmation prompt. Storage is started and made ready before those requests. A resumed stack is not re-seeded. Projects without configured buckets make no bucket-seeding requests. -A failure or interruption during the first startup stops and unconfigures that initial composition, -then destroys only the service instances created by this invocation. When startup began without a -running owner, failure cleanup stops any owner launched during startup and waits for its exit. A -target with a running owner keeps it. Existing instances and their data are retained, and failed -resumes do not destroy existing data. Cleanup diagnostics name any instance that could not be removed -or owner that could not be stopped. After successful cleanup, fixing the cause and retrying starts -from an empty composition. +A new stack is registered by its owner once that owner starts; if the owner fails to start (for +example, Docker is unavailable) or the launch is interrupted, it removes that registration, and the +CLI reports the single launch failure with no separate stop diagnostic. Any other failure or interruption during the first startup stops and +unconfigures that initial composition, then destroys only the service instances created by this +invocation. When startup began without a running owner, failure cleanup stops any owner launched +during startup and waits for its exit. A target with a running owner keeps it. Existing instances and +their data are retained, and failed resumes do not destroy existing data. Cleanup diagnostics name any +instance that could not be removed or owner that could not be stopped. After successful cleanup, +fixing the cause and retrying starts from an empty composition. Successful startup leaves the owner available after the CLI exits. Abrupt process termination that bypasses finalizers requires manual inspection and, for an incomplete first bootstrap, destruction before retrying; there is no recovery journal. @@ -99,7 +123,17 @@ It remains available after the CLI exits. Preparation downloads native artifacts images. Catalog setup and project SQL connect to the primary database. Bucket seeding uses the local Storage HTTP endpoint. The CLI does not remove caller-owned Storage files during cleanup. -Text output reports progress and `Stack is ready.`. JSON output returns the stack `id`, assigned `endpoints` keyed by service and endpoint name -(for example `database.sql`), and an empty message. Endpoints contain protocol, address, port, -and URL, matching `stack status`; use status for service observations. Failures retain typed command -errors and package diagnostics. Telemetry state is flushed after success or failure. +Text output reports progress and `Stack is ready.`, then prints the connection summary shared with +`stack status` on stdout: API, REST, Functions, Studio, MCP, Mailpit, and database URLs for the +members that expose them, the publishable and secret keys, the Storage S3 URL, access keys, and +region when the S3 protocol is enabled, a services table, the runtime, and a +pointer to `supabase status --env` that repeats an explicit `--workdir` and any `--stack` or `--stack-id` selector, shell-quoted. Progress lines +and warnings written while the spinner is shown appear on their own rows. + +JSON output returns the stack `id`, its saved `runtime`, `endpoints` keyed by service and endpoint +name (protocol, address, port, and URL, matching `stack status`, with no synthetic entries), +`lazy_services` listing members that start on their first request (empty with `--eager`), `env` +(the same connection map `stack status --env` exports, present on every success path), and an +empty message. See [`docs/stack-commands.md`](../../../../../docs/stack-commands.md) for an +example. Failures retain typed command errors and package diagnostics. Telemetry state is flushed +after success or failure. diff --git a/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts new file mode 100644 index 0000000000..a15901b14b --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts @@ -0,0 +1,312 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { Effect, Layer, Option, Redacted, Schema, Stream } from "effect"; +import type { + Observation, + SavedStack, + ServiceCreation, + Stack, + StackCredentials, +} from "@supabase/stack/effect"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockStdin, + mockRuntimeInfo, + mockTelemetryRuntime, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { mockTelemetryStateTracked } from "../../../../../tests/helpers/command-mocks.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { GLOBAL_FLAGS, WorkdirFlag } from "../../../../command-internal/global-flags.ts"; +import { CommandSettings } from "../../../../config/command-settings.service.ts"; +import { StackApi, StackTargetResolver } from "../stack.shared.ts"; +import { stackStatusCommand } from "../status/status.command.ts"; +import { statusEnvPointer } from "./start-summary.format.ts"; + +/** Distinguishable credentials, keyed by a short tag, for one fake target stack. */ +const credentialsFor = (tag: string): StackCredentials => ({ + jwtSecret: `${tag}-secret`.padEnd(32, "0"), + postgresRootKey: `root-key-${tag}`, + databasePassword: `password-${tag}`, + publishableKey: `sb_publishable_${tag}`, + secretKey: `sb_secret_${tag}`, + anonKey: `anon-${tag}`, + serviceRoleKey: `service-${tag}`, + jwks: "{}", + gotrueJwtKeys: "[]", + remoteJwks: "[]", + anonKeyIsOverride: false, + serviceRoleKeyIsOverride: false, +}); + +const databaseCreation = (sqlPort: number, credentials: StackCredentials): ServiceCreation => ({ + service: "database", + config: { + version: "17", + databasePassword: Redacted.make(credentials.databasePassword), + jwtSecret: Redacted.make(credentials.jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: sqlPort } }, +}); + +/** A running, credentialed single-database stack distinguishable by its saved sql port. */ +function makeDatabaseStack(sqlPort: number, credentials: StackCredentials): Stack { + const creation = databaseCreation(sqlPort, credentials); + const observation: Observation = { + id: "database-id", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: sqlPort }], + config: creation, + lifecycle: "running", + health: "healthy", + error: undefined, + cleanupError: undefined, + exit: undefined, + currentOperation: undefined, + launchId: undefined, + intentRevision: 1, + wakeEnabled: false, + registered: true, + }; + const databaseInstance = { + id: "database-id", + service: "database" as const, + start: Effect.die("unused"), + ready: Effect.die("unused"), + stop: Effect.die("unused"), + restart: () => Effect.die("unused"), + destroy: Effect.die("unused"), + prepare: Effect.die("unused"), + status: Effect.succeed(observation), + followStatus: Stream.empty, + logs: Stream.empty, + credentials: () => Effect.succeed({}), + saveSnapshot: () => Effect.die("unused"), + restoreSnapshot: () => Effect.die("unused"), + resetData: Effect.die("unused"), + }; + return { + id: `stack-${sqlPort}`, + services: { + create: () => Effect.die("unused"), + get: () => Effect.succeed(databaseInstance), + list: Effect.succeed([databaseInstance]), + }, + credentials: { get: Effect.succeed(credentials) }, + composition: { + plan: () => Effect.die("unused"), + supabase: () => Effect.die("unused"), + configure: () => Effect.die("unused"), + describe: Effect.succeed({ + members: [{ id: "database-id", activation: "eager" as const }], + dependencies: [], + }), + start: Effect.die("unused"), + stop: Effect.die("unused"), + restart: Effect.die("unused"), + }, + stop: Effect.die("unused"), + destroy: Effect.die("unused"), + commands: { run: () => Effect.die("unused") }, + } satisfies Stack; +} + +const definitionFor = (id: string, projectRoot: string, creation: ServiceCreation): SavedStack => ({ + id, + lifetime: "detached", + identity: { projectRoot, branchContext: "pointer-test", stackName: id }, + runtime: "native", + instances: [{ id: "database-id", creation }], + composition: { members: [{ id: "database-id", activation: "eager" }], dependencies: [] }, + ports: [], +}); + +interface TargetSpec { + readonly projectRoot: string; + readonly name?: string; + readonly sqlPort: number; + readonly tag: string; +} + +/** The fake resolver's key, matching the real resolver's `(projectRoot, name)` lookup. */ +const targetKey = (projectRoot: string, name: string | undefined) => + `${projectRoot}\u0000${name ?? ""}`; + +/** + * Runs `stackStatusCommand` with the given literal argv (excluding the `supabase` program + * name) against a fake resolver stocked with `specs`, so a `--stack` that failed to reach the + * resolver resolves the project's default stack instead of the intended one — a distinguishable + * failure rather than a silent pass. + */ +const runStatusEnv = (input: { + readonly argv: ReadonlyArray<string>; + readonly ambientCwd: string; + readonly specs: ReadonlyArray<TargetSpec>; +}) => + Effect.gen(function* () { + const targets = new Map<string, { readonly id: string; readonly definition: SavedStack }>(); + const stacksById = new Map<string, Stack>(); + for (const spec of input.specs) { + const credentials = credentialsFor(spec.tag); + const stack = makeDatabaseStack(spec.sqlPort, credentials); + targets.set(targetKey(spec.projectRoot, spec.name), { + id: stack.id, + definition: definitionFor( + stack.id, + spec.projectRoot, + databaseCreation(spec.sqlPort, credentials), + ), + }); + stacksById.set(stack.id, stack); + } + + const resolver = Layer.succeed(StackTargetResolver, { + resolve: (resolveInput) => + Effect.sync(() => { + const target = targets.get(targetKey(resolveInput.projectRoot, resolveInput.name)); + return { + projectRoot: resolveInput.projectRoot, + ...(target === undefined ? {} : { id: target.id, definition: target.definition }), + hostRunning: target !== undefined, + }; + }), + }); + const api = Layer.succeed(StackApi, { + create: () => Effect.die("unused"), + open: ({ id }) => { + const stack = stacksById.get(id); + return stack === undefined ? Effect.die(`unknown stack ${id}`) : Effect.succeed(stack); + }, + discover: () => Effect.die("unused"), + find: () => Effect.die("unused"), + }); + // Only `--workdir` selects the project here; an absent flag falls back to the ambient cwd, + // which is registered under its own distinguishable stack so a dropped `--workdir` fails + // the test's assertions instead of passing unnoticed. + const commandSettings = Layer.effect( + CommandSettings, + Effect.gen(function* () { + const workdirFlag = yield* WorkdirFlag; + const workdir = Option.getOrElse(workdirFlag, () => input.ambientCwd); + return CommandSettings.of({ + profile: "supabase", + profileEnvValue: Option.none(), + supabaseHome: "/pointer-test/.supabase", + apiUrl: "https://api.supabase.com", + projectHost: "supabase.co", + poolerHost: "supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + accessToken: Option.none(), + dbPassword: Option.none(), + githubToken: Option.none(), + projectId: Option.none(), + workdir, + explicitWorkdir: Option.isSome(workdirFlag), + workdirEnvValue: Option.none(), + userAgent: "SupabaseCLI/pointer-test", + }); + }), + ); + + const output = mockOutput({ format: "json" }); + const analytics = mockAnalytics(); + const telemetry = mockTelemetryStateTracked(); + // `Command.provide` (not `Effect.provide` on the whole run) so `commandSettings`'s + // `WorkdirFlag` read resolves against this command node's own parsed flags, the same + // timing the production `stackRuntimeLayer` composition relies on. + const command = stackStatusCommand.pipe( + Command.provide(commandRuntimeLayer(["status"])), + Command.provide(Layer.mergeAll(resolver, api, commandSettings, telemetry.layer)), + ); + const testRoot = Command.make("supabase").pipe( + Command.withSubcommands([command]), + Command.withGlobalFlags(GLOBAL_FLAGS), + ); + + yield* Command.runWith(testRoot, { version: "0.0.0-test" })(input.argv).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + output.layer, + analytics.layer, + mockProcessControl().layer, + Layer.succeed(CliArgs, { args: input.argv }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + mockStdin(false), + mockRuntimeInfo({ cwd: input.ambientCwd, homeDir: "/pointer-test/home" }), + mockTelemetryRuntime({ + configDir: "/pointer-test/.supabase", + tracesDir: "/pointer-test/.supabase/traces", + }), + ), + ), + ); + + return yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), + )(output.rawChunks.map(({ text }) => text).join("")); + }).pipe(Effect.provide(BunServices.layer)); + +describe("stack start export pointer", () => { + it.live( + "the printed pointer's argv resolves --workdir and the named --stack to that stack, not the caller's cwd or the project's default stack", + () => + Effect.gen(function* () { + const projectRoot = "/pointer-test/project"; + const ambientCwd = "/pointer-test/project-b"; + const values = yield* runStatusEnv({ + argv: ["status", "--env", "--workdir", projectRoot, "--stack", "docker"], + ambientCwd, + specs: [ + { projectRoot, sqlPort: 40001, tag: "default" }, + { projectRoot, name: "docker", sqlPort: 40002, tag: "named" }, + { projectRoot: ambientCwd, sqlPort: 40003, tag: "cwd" }, + ], + }); + expect(values.DB_URL).toContain(":40002/"); + expect(values.PUBLISHABLE_KEY).toBe("sb_publishable_named"); + + // Pass `"posix"` explicitly so this assertion doesn't depend on the host running the + // test; `currentShellPlatform()` would render PowerShell quoting on win32. + const pointer = statusEnvPointer( + { explicitWorkdir: true, projectRoot, stack: "docker" }, + "posix", + ); + expect(pointer).toBe(`supabase status --env --workdir ${projectRoot} --stack docker`); + }), + ); + + it.live( + "quotes a workdir and stack name that need it, and the same raw values still resolve that stack", + () => + Effect.gen(function* () { + const projectRoot = "/pointer-test/project with space"; + const stackName = "feature one's box"; + const ambientCwd = "/pointer-test/project-b"; + const values = yield* runStatusEnv({ + argv: ["status", "--env", "--workdir", projectRoot, "--stack", stackName], + ambientCwd, + specs: [ + { projectRoot, sqlPort: 40004, tag: "default" }, + { projectRoot, name: stackName, sqlPort: 40005, tag: "named" }, + ], + }); + expect(values.DB_URL).toContain(":40005/"); + expect(values.PUBLISHABLE_KEY).toBe("sb_publishable_named"); + + const pointer = statusEnvPointer( + { explicitWorkdir: true, projectRoot, stack: stackName }, + "posix", + ); + expect(pointer).toBe( + `supabase status --env --workdir '${projectRoot}' --stack 'feature one'"'"'s box'`, + ); + }), + ); +}); diff --git a/apps/cli/src/commands/experimental/stack/start/start-summary.format.ts b/apps/cli/src/commands/experimental/stack/start/start-summary.format.ts new file mode 100644 index 0000000000..6717df047c --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start-summary.format.ts @@ -0,0 +1,23 @@ +import { + shellQuoteArgument, + type ShellPlatform, +} from "../../../../command-internal/shell-quote.ts"; + +export interface StatusEnvPointerInput { + readonly explicitWorkdir: boolean; + readonly projectRoot: string; + readonly stack?: string; + readonly stackId?: string; +} + +/** The `supabase status --env ...` pointer `start` prints to reproduce this stack's selection. */ +export const statusEnvPointer = (input: StatusEnvPointerInput, platform: ShellPlatform): string => { + const selector = [ + ...(input.explicitWorkdir ? ["--workdir", input.projectRoot] : []), + ...(input.stack === undefined ? [] : ["--stack", input.stack]), + ...(input.stackId === undefined ? [] : ["--stack-id", input.stackId]), + ] + .map((argument) => ` ${shellQuoteArgument(argument, platform)}`) + .join(""); + return `supabase status --env${selector}`; +}; diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts index 3b81180aed..4f9df6a258 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -20,8 +20,10 @@ const config = { Flag.withDescription("Open an existing stack by id."), Flag.optional, ), - runtime: Flag.choice("runtime", ["auto", "docker", "native"] as const).pipe( - Flag.withDescription("Runtime to use for a new stack."), + runtime: Flag.choice("runtime", ["auto", "docker", "podman", "native"] as const).pipe( + Flag.withDescription( + "Runtime to use for a new stack. auto selects Docker, then Podman, then native, based on what is available.", + ), Flag.withDefault("auto" as const), ), preparation: Flag.choice("preparation", ["background", "on-demand"] as const).pipe( @@ -42,7 +44,7 @@ export const stackStartCommand = Command.make("start", config).pipe( Command.withDescription( "Create or resume a managed local Supabase stack using supabase/config.toml when present. " + "Without a config file, default settings are used and no file is created. " + - "For a new stack, auto selects native on supported platforms and Docker elsewhere; the choice is persisted for that stack. " + + "For a new stack, auto selects Docker when its engine is reachable, then Podman, then native on supported platforms; the choice is persisted for that stack. " + "Explicit runtime choices are honored. Values support explicit env(NAME) references and automatic SUPABASE_* overrides.", ), Command.withShortDescription("Start a managed local stack"), diff --git a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts index 6e4aecec72..fca1046b9b 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -92,6 +92,7 @@ const FollowEventSchema = Schema.Struct({ const VariablesSchema = Schema.Record(Schema.String, Schema.String); const StartResultSchema = Schema.Struct({ id: Schema.String }); +const StackListSchema = Schema.Struct({ stacks: Schema.Array(Schema.Unknown) }); const minimalConfig = `project_id = "compiled-stack-start-e2e" @@ -424,11 +425,9 @@ describe("stack start (compiled e2e)", () => { exitTimeoutMs: CLEANUP_TIMEOUT_MS, }); expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); - expect(status.stdout).toContain(`(${idText})`); - expect(status.stdout).toContain("Owner: reachable"); - expect(status.stdout).toContain("Lifecycle: running"); - expect(status.stdout).toContain("Readiness: ready"); - expect(status.stdout).toMatch(/Config drift: (changed|unchanged)/u); + expect(status.stdout).toContain(" · ready · native · "); + expect(status.stdout).toMatch(/database +│ running · healthy · eager +│/u); + expect(status.stdout).not.toContain(idText); const topLevelStatus = yield* runSupabaseEffect(["status", "--stack-id", idText], { cwd: projectRoot, @@ -440,9 +439,7 @@ describe("stack start (compiled e2e)", () => { topLevelStatus.exitCode, `stdout:\n${topLevelStatus.stdout}\nstderr:\n${topLevelStatus.stderr}`, ).toBe(0); - expect(topLevelStatus.stdout).toContain(`(${idText})`); - expect(topLevelStatus.stdout).toContain("Owner: reachable"); - expect(topLevelStatus.stdout).toContain("Lifecycle: running"); + expect(topLevelStatus.stdout).toContain(" · ready · native · "); const env = yield* runSupabaseEffect( ["stack", "status", "--env", "--stack-id", idText, "--output-format", "json"], @@ -453,16 +450,15 @@ describe("stack start (compiled e2e)", () => { env.stdout, ); expect(Object.keys(variables)).toEqual([ + "API_URL", + "REST_URL", "DB_URL", - "ANON_KEY", - "SERVICE_ROLE_KEY", "PUBLISHABLE_KEY", "SECRET_KEY", - "API_URL", + "ANON_KEY", + "SERVICE_ROLE_KEY", ]); - expect(variables.DB_URL).toMatch( - /^postgresql:\/\/supabase_admin:.+@.+:\d+\/postgres(?:\?.*)?$/u, - ); + expect(variables.DB_URL).toMatch(/^postgresql:\/\/postgres:.+@.+:\d+\/postgres$/u); expect(variables.PUBLISHABLE_KEY).toMatch(/^sb_publishable_.+$/u); expect(variables.SECRET_KEY).toMatch(/^sb_secret_.+$/u); @@ -507,9 +503,8 @@ describe("stack start (compiled e2e)", () => { stoppedStatus.exitCode, `stdout:\n${stoppedStatus.stdout}\nstderr:\n${stoppedStatus.stderr}`, ).toBe(0); - expect(stoppedStatus.stdout).toContain("Owner: unavailable"); - expect(stoppedStatus.stdout).toContain("Lifecycle: unavailable"); - expect(stoppedStatus.stdout).toContain("Readiness: unavailable"); + expect(stoppedStatus.stdout).toContain(" · unavailable · native · "); + expect(stoppedStatus.stdout).toContain("The stack owner is not running."); const stoppedEnv = yield* runSupabaseEffect( ["stack", "status", "--env", "--stack-id", idText], @@ -559,4 +554,50 @@ describe("stack start (compiled e2e)", () => { }), ), ); + + test( + "removes a stack registration that fails to start because Docker is unreachable", + { timeout: CLEANUP_TIMEOUT_MS }, + () => + runNode( + Effect.gen(function* () { + home = makeTempHome(); + projectDir = yield* makeTempDirectory("/tmp/supabase-stack-start-docker-down-e2e-"); + yield* makeDirectory(join(projectDir, "supabase"), { recursive: true }); + yield* writeText(join(projectDir, "supabase", "config.toml"), minimalConfig); + // A `docker` first on PATH that reports an unreachable daemon, as the real CLI does. + const binDir = join(projectDir, "bin"); + yield* makeDirectory(binDir); + yield* writeText( + join(binDir, "docker"), + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at unix:///shim/docker.sock. Is the docker daemon running?' >&2\nexit 1\n", + ); + yield* withFs((fs) => fs.chmod(join(binDir, "docker"), 0o755)); + + const result = yield* runSupabaseEffect(["stack", "start", "--runtime", "docker"], { + cwd: projectDir, + home: home.dir, + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the CLI subprocess resolves `docker` from PATH. + env: { PATH: `${binDir}:${process.env.PATH ?? ""}` }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect(result.exitCode, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).not.toBe( + 0, + ); + expect(result.stderr).toContain("Cannot connect to the Docker daemon"); + expect(result.stderr).not.toContain("Failed to stop stack host"); + + const list = yield* runSupabaseEffect(["stack", "list", "--output-format", "json"], { + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }); + expect(list.exitCode, `stdout:\n${list.stdout}\nstderr:\n${list.stderr}`).toBe(0); + const listed = yield* Schema.decodeEffect(Schema.fromJsonString(StackListSchema))( + list.stdout.trim(), + ); + expect(listed.stacks).toEqual([]); + }), + ), + ); }); diff --git a/apps/cli/src/commands/experimental/stack/start/start.handler.ts b/apps/cli/src/commands/experimental/stack/start/start.handler.ts index e831574030..ad8262f771 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.handler.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.handler.ts @@ -1,15 +1,31 @@ -import { endpointReports } from "../stack-endpoints.format.ts"; -import { readStackFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; -import { defaultStackRuntime } from "../../../../command-internal/stack-runtime.ts"; -import { Effect, Equal, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; +import { defaultRuntime } from "@supabase/stack/internal/artifacts"; +import { + connectionEnv, + renderStackSummary, + stackConnections, + stackEndpoints, + summaryCredentials, + type StackServiceView, +} from "../stack-summary.ts"; +import { gray } from "../../../../command-internal/colors.ts"; +import { currentShellPlatform } from "../../../../command-internal/shell-quote.ts"; +import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; +import { statusEnvPointer } from "./start-summary.format.ts"; +import { + automaticRuntimeNotice, + selectStackRuntime, +} from "../../../../command-internal/stack-runtime.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { Effect, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect"; import { resolveNativePostgresUser, + type Observation, + type PlannedInstance, + type ServiceCreation, type ServiceCreationInput, type Stack, type StackError, - type StackIdentityInput, } from "@supabase/stack/effect"; -import { postgresVersion } from "@supabase/stack/internal/postgres-artifact"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag, @@ -17,12 +33,12 @@ import { } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { readDbToml } from "../../../../command-internal/db-config.toml-read.ts"; -import { StackCatalogSetup } from "../../../../command-internal/stack-catalog-setup.ts"; +import { catalogDatabaseServices } from "../../../../command-internal/stack-catalog-setup.ts"; import { - applyStackMigrateAndSeed, applyStackWebhooksOnly, + initializeStackDatabase, + projectCatalogOverlay, } from "../../../../command-internal/stack-bootstrap.ts"; import { stackStorageCredentialsFor } from "../../../../command-internal/stack-storage.ts"; import { @@ -93,12 +109,41 @@ const stackError = ( }); }; +// A saved stack keeps its runtime, so only a new stack can switch to native. +const dockerUnavailableSuggestion = ( + runtimeInfo: { readonly platform: string; readonly arch: string }, + creating: boolean, +) => + creating && defaultRuntime({ os: runtimeInfo.platform, arch: runtimeInfo.arch }) === "native" + ? "Docker CLI or daemon isn't reachable. Install or start Docker, or run with --runtime native." + : "Docker CLI or daemon isn't reachable. Install or start Docker."; + +const stackAcquireError = ( + cause: StackError, + runtimeContext: { + readonly selectedRuntime: "native" | "docker" | "podman"; + readonly runtime: { readonly platform: string; readonly arch: string }; + readonly creating: boolean; + }, +) => { + const base = stackError(cause); + if (cause.reason !== "runtime-unavailable" || runtimeContext.selectedRuntime !== "docker") + return base; + return new StackCommandStartError({ + reason: "runtime", + message: base.message, + ...(base.detail === undefined ? {} : { detail: base.detail }), + suggestion: dockerUnavailableSuggestion(runtimeContext.runtime, runtimeContext.creating), + cause: base.cause, + }); +}; + const loadStartConfig = (projectRoot: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.gen(function* () { const config = yield* loadStackConfig(projectRoot); - const identity = yield* config.identity; + const keys = yield* config.keys; const toml = yield* readDbToml(fs, path, projectRoot); - return { config, identity, toml }; + return { config, keys, toml }; }).pipe( Effect.mapError( (error) => @@ -119,92 +164,23 @@ const sameKinds = ( return leftKinds.size === rightKinds.size && [...leftKinds].every((kind) => rightKinds.has(kind)); }; -const sameBinding = ( - observed: { - readonly service: string; - readonly version?: string; - readonly config: unknown; - readonly endpoints?: unknown; - }, - requested: { - readonly service: string; - readonly version?: string; - readonly config: unknown; - readonly endpoints?: unknown; - }, -): boolean => - observed.service === requested.service && - observed.version === requested.version && - Equal.equals(observed.endpoints, requested.endpoints) && - Equal.equals( - comparableConfig(observed.service, observed.config), - comparableConfig(requested.service, requested.config), - ); - -const compositionManagedConfigKeys: Readonly<Record<string, ReadonlySet<string>>> = { - database: new Set(["databasePassword", "jwtSecret", "rootKey"]), - rest: new Set(["databaseUrl", "jwtSecret", "jwks"]), - auth: new Set(["databaseUrl", "jwtSecret", "gotrueJwtKeys", "externalApiUrl", "smtpUrl"]), - realtime: new Set(["databaseUrl", "jwtSecret", "jwks"]), - storage: new Set([ - "databaseUrl", - "filePath", - "jwtSecret", - "jwks", - "anonKey", - "serviceRoleKey", - "imgproxyUrl", - "vectorDatabaseUrl", - ]), - functions: new Set([ - "apiUrl", - "bootstrap", - "databaseUrl", - "env", - "filesRoot", - "functions", - "jwtSecret", - "jwks", - "anonKey", - "serviceRoleKey", - "publishableKey", - "secretKey", - "verifyJwt", - ]), - studio: new Set([ - "functionsRoot", - "pgmetaUrl", - "analyticsUrl", - "analyticsApiKey", - "functionsUrl", - "apiUrl", - "publicApiUrl", - "jwtSecret", - "jwks", - "anonKey", - "serviceRoleKey", - "publishableKey", - "secretKey", - ]), - analytics: new Set(["databaseUrl"]), - vector: new Set(["analyticsUrl"]), - pgmeta: new Set(["databaseUrl"]), - pooler: new Set(["databaseUrl", "jwtSecret"]), -}; - -const comparableConfig = (service: string, value: unknown): unknown => { - if (typeof value !== "object" || value === null || Array.isArray(value)) return value; - const ignored = compositionManagedConfigKeys[service] ?? new Set<string>(); - return Object.fromEntries( - Object.entries(value) - .filter(([key]) => !ignored.has(key)) - .map(([key, entry]) => - service === "database" && key === "version" && typeof entry === "string" - ? [key, postgresVersion(entry)] - : [key, entry], - ), - ); -}; +/** Rejects a saved instance whose endpoints or artifact versions the request would change. */ +const incompatibleChange = (planned: PlannedInstance) => + planned.change !== "incompatible" + ? undefined + : planned.service === "database" && planned.paths.includes("config.version") + ? new StackCommandStartError({ + reason: "invalid-config", + message: "The requested database version does not match the saved stack binding", + suggestion: + "Keep the saved database version, or run supabase stack destroy to recreate the stack.", + }) + : new StackCommandStartError({ + reason: "invalid-config", + message: `The requested ${planned.service} ${planned.paths.join(", ")} cannot change on the saved stack`, + suggestion: + "Keep the saved endpoint and version settings, or run supabase stack destroy to recreate the stack.", + }); const selectedCreations = ( creations: ReadonlyArray<ServiceCreationInput>, @@ -215,16 +191,31 @@ const selectedCreations = ( return !exclusions.includes(capability); }); -const compose = ( - stack: Stack, - creations: ReadonlyArray<ServiceCreationInput>, - reuseIds: ReadonlyArray<string>, - identity: StackIdentityInput, +const isServing = (status: Pick<Observation, "lifecycle" | "health">) => + status.lifecycle === "running" && status.health === "healthy"; + +const startReport = ( + stack: Pick<Stack, "composition">, + instances: ReadonlyArray<{ + readonly id: string; + readonly service: ServiceCreation["service"]; + readonly status: Effect.Effect<Observation, StackError>; + }>, ) => - stack.composition.supabase(creations, { - identity, - ...(reuseIds.length === 0 ? {} : { reuseIds }), - }); + Effect.gen(function* () { + const { members } = yield* stack.composition.describe; + const activation = new Map(members.map((member) => [member.id, member.activation])); + const views = yield* Effect.forEach(instances, (instance) => + instance.status.pipe( + Effect.map((observation): StackServiceView => ({ + service: instance.service, + observation, + activation: activation.get(instance.id), + })), + ), + ); + return { views, endpoints: stackEndpoints(views) }; + }).pipe(Effect.mapError(stackError)); /** Starts the selected managed stack and applies the local database overlays. */ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags: StackStartFlags) { @@ -232,7 +223,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const body = Effect.gen(function* () { const output = yield* Output; const settings = yield* CommandSettings; - const runtime = yield* RuntimeInfo; const resolver = yield* StackTargetResolver; const stackApi = yield* StackApi; const outputFlag = yield* Effect.serviceOption(OutputFlag); @@ -252,7 +242,18 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags runtime: flags.runtime, }) .pipe(Effect.mapError(mapTargetError)); - const selectedRuntime = target.runtime ?? defaultStackRuntime(runtime); + const runtime = yield* RuntimeInfo; + const selectedRuntime = yield* selectStackRuntime(target.runtime).pipe( + Effect.mapError( + (error) => + new StackCommandStartError({ + reason: error.reason === "native-unsupported" ? "flags" : "runtime", + message: error.message, + suggestion: error.suggestion, + cause: error, + }), + ), + ); const postgresUser = yield* resolveNativePostgresUser(selectedRuntime); const ensurePostgresUser = postgresUser._tag === "Unavailable" @@ -277,6 +278,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags stateRoot, cacheRoot, runtime: selectedRuntime, + startOwner: true, ...(target.name === undefined ? {} : { name: target.name }), }) : stackApi.open({ id: target.id, stateRoot, cacheRoot, startOwner: true }), @@ -295,7 +297,45 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ), ), - ).pipe(Effect.mapError(stackError)); + ).pipe( + Effect.mapError((cause) => + stackAcquireError(cause, { selectedRuntime, runtime, creating: target.id === undefined }), + ), + ); + const statusPointer = statusEnvPointer( + { + explicitWorkdir: settings.explicitWorkdir, + projectRoot: target.projectRoot, + ...(Option.isSome(flags.stack) ? { stack: flags.stack.value } : {}), + ...(Option.isSome(flags.stackId) ? { stackId: flags.stackId.value } : {}), + }, + currentShellPlatform(), + ); + const reportReady = (report: Effect.Success<ReturnType<typeof startReport>>, message: string) => + Effect.gen(function* () { + const credentials = yield* summaryCredentials(stack.credentials.get, output.warn); + const connections = stackConnections( + report.views.filter(({ activation }) => activation !== undefined), + ); + const env = connectionEnv(connections, credentials); + if (output.format !== "text") + return yield* output.success(message, { + id: stack.id, + runtime: selectedRuntime, + endpoints: report.endpoints, + lazy_services: report.views + .filter(({ activation }) => activation === "lazy") + .map(({ service }) => service), + env, + }); + if (message.length > 0) yield* output.success(message); + yield* output.raw( + `\n${renderStackSummary(report.views, credentials)}\n${gray(`Runtime: ${selectedRuntime}`, process.stdout)}\nRun ${statusPointer} to export these values as environment variables.\n`, + ); + }); + const runtimeNotice = + target.id === undefined ? automaticRuntimeNotice(target.runtime, selectedRuntime) : undefined; + if (runtimeNotice !== undefined) yield* output.info(runtimeNotice); const existingServices = yield* stack.services.list.pipe(Effect.mapError(stackError)); const composition = yield* stack.composition.describe.pipe(Effect.mapError(stackError)); const currentInstances = yield* Effect.forEach(composition.members, ({ id }) => @@ -307,26 +347,63 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags const primaryDatabase = currentInstances.find((instance) => instance.service === "database"); const databaseStatus = currentStatuses.find(({ id }) => id === primaryDatabase?.id); const fullyStarted = - databaseStatus?.lifecycle === "running" && - currentStatuses.every(({ lifecycle, wakeEnabled }) => lifecycle === "running" || wakeEnabled); + databaseStatus !== undefined && + isServing(databaseStatus) && + currentStatuses.every((status) => + status.lifecycle === "running" + ? isServing(status) + : status.lifecycle !== "starting" && status.wakeEnabled, + ); if (fullyStarted) { yield* Ref.set(startupComplete, true); - const endpoints = Object.fromEntries( - currentStatuses.flatMap((observation, index) => { - const instance = currentInstances[index]; - return instance === undefined - ? [] - : Object.entries(endpointReports(observation)).map( - ([name, endpoint]) => [`${instance.service}.${name}`, endpoint] as const, - ); - }), - ); - yield* output.success( + yield* reportReady( + yield* startReport(stack, currentInstances), "Stack is already running with its current services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", - { id: stack.id, endpoints }, ); return stack.id; } + const resumable = + primaryDatabase !== undefined && + databaseStatus?.lifecycle === "running" && + currentStatuses.every( + ({ lifecycle, wakeEnabled }) => + lifecycle === "running" || lifecycle === "starting" || wakeEnabled, + ); + if (resumable) { + yield* output.info( + "Resuming the saved stack services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", + ); + const starting = yield* output.task("Starting local Supabase stack..."); + yield* primaryDatabase.ready.pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.mapError(stackError), + ); + yield* stack.composition.start.pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.mapError((error) => stackError(error, currentInstances)), + ); + // Composition start awaits only eager members; lazy members that are up must be ready too. + const active = new Set( + currentStatuses + .filter(({ lifecycle }) => lifecycle === "running" || lifecycle === "starting") + .map(({ id }) => id), + ); + yield* Effect.forEach( + currentInstances.filter(({ id }) => active.has(id)), + (instance) => instance.ready, + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.mapError(stackError), + ); + yield* Ref.set(startupComplete, true); + const report = yield* startReport(stack, currentInstances).pipe( + Effect.tapError((error) => starting.fail(error.message)), + ); + yield* starting.succeed("Stack is ready."); + yield* reportReady(report, ""); + return stack.id; + } const fullyStopped = currentStatuses.every( ({ lifecycle, wakeEnabled }) => lifecycle === "stopped" && !wakeEnabled, ); @@ -347,7 +424,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags message: "A standalone database exists outside the saved stack composition", suggestion: "Destroy the standalone database before starting this stack.", }); - const { config, identity, toml } = + const { config, keys, toml } = configBeforeCreate ?? (yield* loadStartConfig(target.projectRoot, fs, path)); const creations = yield* config.creations(stack.id).pipe( Effect.mapError( @@ -359,23 +436,14 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); - const requested = yield* Effect.forEach(selectedCreations(creations, exclusions), (creation) => - creation.service === "functions" - ? readStackFunctionsEnv(`${creation.config.functionsRoot}/.env`, true).pipe( - Effect.map((env): ServiceCreationInput => ({ - ...creation, - config: { ...creation.config, env: { ...env, ...creation.config.env } }, - })), - Effect.mapError( - (cause) => - new StackCommandStartError({ - reason: "invalid-config", - message: cause.message, - cause, - }), - ), - ) - : Effect.succeed(creation), + const requested = yield* Effect.forEach( + selectedCreations(creations, exclusions), + withProjectFunctionsEnv, + ).pipe( + Effect.mapError( + (cause) => + new StackCommandStartError({ reason: "invalid-config", message: cause.message, cause }), + ), ); if ( requested.some(({ service }) => service === "studio") && @@ -427,66 +495,69 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags }), ), ); + if (requested.some(({ service }) => service === "studio")) + yield* fs + .makeDirectory(path.join(target.projectRoot, "supabase", "snippets"), { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new StackCommandStartError({ + reason: "invalid-config", + message: `Unable to create the Studio snippets directory: ${cause.message}`, + cause, + }), + ), + ); const initialComposition = composition.members.length === 0; const serviceKindsChanged = !sameKinds(currentInstances, requested); - for (const instance of currentInstances) { - const creation = requested.find(({ service }) => service === instance.service); - if (creation === undefined) continue; - const status = yield* instance.status.pipe(Effect.mapError(stackError)); - if ( - creation.service === "database" && - status.config.service === "database" && - postgresVersion(creation.config.version) !== postgresVersion(status.config.config.version) - ) - return yield* new StackCommandStartError({ - reason: "invalid-config", - message: "The requested database version does not match the saved stack binding", - suggestion: "Keep the saved database version, or destroy the stack.", - }); - if (!sameBinding(status.config, creation)) - return yield* new StackCommandStartError({ - reason: "invalid-config", - message: `The requested ${creation.service} configuration does not match the saved stack binding`, - suggestion: "Keep the saved endpoint and version settings, or destroy the stack.", - }); + const planned = yield* stack.composition.plan(requested).pipe(Effect.mapError(stackError)); + for (const entry of planned) { + const rejected = entry.member ? incompatibleChange(entry) : undefined; + if (rejected !== undefined) return yield* rejected; } - const reuseIds: Array<string> = currentInstances - .filter((instance) => requested.some((creation) => creation.service === instance.service)) - .map(({ id }) => id); - if (serviceKindsChanged) { - const currentKinds = new Set(currentInstances.map(({ service }) => service)); - for (const creation of requested) { - if (currentKinds.has(creation.service)) continue; - const candidates = []; - for (const candidate of existingServices) { - if (candidate.service !== creation.service || reuseIds.includes(candidate.id)) continue; - const status = yield* candidate.status.pipe( - Effect.map(Option.some), - Effect.catchTag("StackError", () => Effect.succeed(Option.none())), - ); - if ( - Option.isSome(status) && - status.value.lifecycle === "stopped" && - !status.value.wakeEnabled && - sameBinding(status.value.config, creation) - ) - candidates.push(candidate); - } - if (candidates.length > 1) - return yield* new StackCommandStartError({ - reason: "lifecycle", - message: `Multiple stopped ${creation.service} instances match this stack configuration`, - suggestion: "Remove the unused stack service, then retry.", - }); - const candidate = candidates[0]; - if (candidate !== undefined) reuseIds.push(candidate.id); + const reuseIds: Array<string> = planned.filter(({ member }) => member).map(({ id }) => id); + for (const creation of requested) { + if (currentInstances.some(({ service }) => service === creation.service)) continue; + const candidates = []; + for (const candidate of planned) { + if ( + candidate.member || + candidate.service !== creation.service || + candidate.change === "incompatible" + ) + continue; + const status = yield* stack.services.get(candidate.id).pipe( + Effect.flatMap((instance) => instance.status), + Effect.map(Option.some), + Effect.catchTag("StackError", () => Effect.succeed(Option.none())), + ); + if ( + Option.isSome(status) && + status.value.lifecycle === "stopped" && + !status.value.wakeEnabled + ) + candidates.push(candidate); } + if (candidates.length > 1) + return yield* new StackCommandStartError({ + reason: "lifecycle", + message: `Multiple stopped ${creation.service} instances match this stack configuration`, + suggestion: "Remove the unused stack service, then retry.", + }); + const candidate = candidates[0]; + if (candidate !== undefined) reuseIds.push(candidate.id); } const starting = yield* output.task("Starting local Supabase stack..."); - const members = yield* compose(stack, requested, reuseIds, identity).pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - ); + const members = yield* stack.composition + .supabase(requested, { + keys, + eager: flags.eager, + ...(reuseIds.length === 0 ? {} : { reuseIds }), + }) + .pipe( + Effect.tapError((error) => starting.fail(error.message)), + Effect.mapError(stackError), + ); if (initialComposition) { const existingIds = new Set(existingServices.map(({ id }) => id)); const owned = members.filter(({ id }) => !existingIds.has(id)); @@ -520,27 +591,6 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ), ); } - const configured = yield* stack.composition.describe.pipe(Effect.mapError(stackError)); - const desiredMembers = configured.members.map(({ id }) => { - const member = members.find((entry) => entry.id === id); - const eager = flags.eager || member?.service === "database"; - return { - id, - activation: eager ? ("eager" as const) : ("lazy" as const), - ...(eager || member?.service === "functions" ? {} : { idleMillis: 60_000 }), - }; - }); - const activationChanged = desiredMembers.some((desired) => { - const current = configured.members.find(({ id }) => id === desired.id); - return ( - current?.activation !== desired.activation || current?.idleMillis !== desired.idleMillis - ); - }); - if (activationChanged) { - yield* stack.composition - .configure({ ...configured, members: desiredMembers }) - .pipe(Effect.mapError(stackError)); - } const database = members.find((instance) => instance.service === "database"); if (database === undefined) return yield* new StackCommandStartError({ @@ -569,70 +619,28 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags reason: "invalid-config", message: "The stack has no saved credentials", }); - const databaseServices = requested - .filter( - (creation) => - creation.service === "auth" || - creation.service === "storage" || - creation.service === "realtime", - ) - .map((creation) => creation.service); - const catalog = yield* Effect.service(StackCatalogSetup); - if (initialComposition) { - yield* catalog - .apply({ - target: { - stack, - database, - databaseServices, - }, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, + if (initialComposition || serviceKindsChanged) { + const migrations = initialComposition + ? { workdir: target.projectRoot, - }, - }) - .pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - ); - const experimental = yield* resolveExperimentalWithProjectEnv({ ...toml.projectEnv }); - yield* applyStackMigrateAndSeed(database, target.projectRoot, toml, experimental).pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - ); - } else if (serviceKindsChanged) { - yield* catalog - .apply({ - target: { - stack, - database, - databaseServices, - }, - overlay: { - webhooks: "config", - webhooksEnabled: toml.webhooksEnabled, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, - workdir: target.projectRoot, - }, - }) - .pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - ); - yield* applyStackWebhooksOnly(database, toml.webhooksEnabled).pipe( + toml, + experimental: yield* resolveExperimentalWithProjectEnv({ ...toml.projectEnv }), + } + : undefined; + yield* initializeStackDatabase({ + target: { stack, database, databaseServices: catalogDatabaseServices(requested) }, + overlay: projectCatalogOverlay(toml, target.projectRoot), + ...(migrations === undefined ? {} : { migrations }), + }).pipe( Effect.tapError((error) => starting.fail(error.message)), Effect.mapError(stackError), ); - } else { + } + if (!initialComposition) yield* applyStackWebhooksOnly(database, toml.webhooksEnabled).pipe( Effect.tapError((error) => starting.fail(error.message)), Effect.mapError(stackError), ); - } if (initialComposition) { const storage = members.find((instance) => instance.service === "storage"); if (storage !== undefined) { @@ -658,6 +666,8 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags emitSummary: false, interactive: false, yes: true, + // Non-interactive prompts here would fake a `[Y/n]` question nobody answers. + promptless: true, credentials, resolvedConfig: { config: context.config, document: context.loaded?.document }, projectEnvValues: toml.projectEnv, @@ -672,21 +682,11 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags ); yield* Effect.forEach(preparation, (fiber) => Fiber.join(fiber)); yield* Ref.set(startupComplete, true); - const endpoints = Object.fromEntries( - (yield* Effect.forEach(members, (member) => - member.status.pipe( - Effect.tapError((error) => starting.fail(error.message)), - Effect.mapError(stackError), - Effect.map((observation) => - Object.entries(endpointReports(observation)).map( - ([name, endpoint]) => [`${member.service}.${name}`, endpoint] as const, - ), - ), - ), - )).flat(), + const report = yield* startReport(stack, members).pipe( + Effect.tapError((error) => starting.fail(error.message)), ); yield* starting.succeed("Stack is ready."); - yield* output.success("", { id: stack.id, endpoints }); + yield* reportReady(report, ""); return stack.id; }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts index 55226368fc..c1267d0a4f 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.integration.test.ts @@ -1,17 +1,28 @@ import { generateKeyPairSync } from "node:crypto"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Option, Redacted, Stream } from "effect"; +import { + Deferred, + Effect, + Equal, + Fiber, + FileSystem, + Layer, + Option, + Redacted, + Stream, +} from "effect"; import { DEFAULT_LOCAL_DATABASE_PASSWORD, DEFAULT_LOCAL_JWT_SECRET, DEFAULT_POSTGRES_ROOT_KEY, } from "@supabase/stack/defaults"; -import { postgresVersion } from "@supabase/stack/internal/postgres-artifact"; +import { postgresVersion } from "@supabase/stack/internal/artifacts"; import { StackError, type ServiceCreation, type ServiceCreationInput, + type PlannedInstance, type ServiceInstance, type ServiceInstances, type StackCredentials, @@ -20,8 +31,10 @@ import { import { mockCommandSettings, mockTelemetryStateTracked, + withEnvVar, } from "../../../../../tests/helpers/command-mocks.ts"; -import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { containerEngineSpawner } from "../../../../../tests/helpers/child-process-spawner.ts"; import { DbConnection, type DbSession, @@ -32,8 +45,7 @@ import { CommandPlatformApiFactory } from "../../../../auth/command-platform-api import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { runtimeInfoLayer } from "../../../../shared/runtime/runtime-info.layer.ts"; -import { StackApi, StackTargetResolver } from "../stack.shared.ts"; +import { StackApi, stackApiLayer, StackTargetResolver } from "../stack.shared.ts"; import { stackStart } from "./start.handler.ts"; import { StackCommandStartError } from "./start.errors.ts"; @@ -64,18 +76,30 @@ const session: DbSession = { const instance = ( creation: ServiceCreation, id: string, - lifecycle: () => "stopped" | "running", + lifecycle: () => "stopped" | "starting" | "running", wakeEnabled: () => boolean, + health: () => "starting" | "healthy" | "unhealthy" | undefined = () => + lifecycle() === "running" ? "healthy" : undefined, + ready: () => Effect.Effect<void, StackError> = () => Effect.void, ): ServiceInstances[ServiceCreation["service"]] => { const status = (config: ServiceCreation) => ({ id, endpoints: config.service === "database" ? [{ name: "sql", protocol: "tcp" as const, host: "127.0.0.1", port: 23456 }] - : [], + : config.service === "rest" || config.service === "studio" + ? [ + { + name: "http", + protocol: "http" as const, + host: "127.0.0.1", + port: config.service === "rest" ? 23457 : 23458, + }, + ] + : [], config, lifecycle: lifecycle(), - health: undefined, + health: health(), error: undefined, cleanupError: undefined, exit: undefined, @@ -88,7 +112,7 @@ const instance = ( const base = { id, start: Effect.void, - ready: Effect.void, + ready: Effect.suspend(ready), stop: Effect.void, restart: () => Effect.void, destroy: Effect.void, @@ -171,17 +195,24 @@ const requireConcreteCreation = (creation: ServiceCreationInput): ServiceCreatio }; }; +interface MemberStatus { + readonly lifecycle?: "stopped" | "starting" | "running"; + readonly wakeEnabled?: boolean; + readonly health?: "starting" | "healthy" | "unhealthy"; +} + const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { let members: Array<ServiceInstances[keyof ServiceInstances]> = []; let stopped = 0; + let hostStopped = 0; + let hostDestroyed = 0; let composed = 0; let catalogApplied = 0; + let compositionStarts = 0; let lifecycle: "stopped" | "running" = "stopped"; let activations = new Map<string, "eager" | "lazy">(); - const memberStatuses = new Map< - string, - { readonly lifecycle?: "stopped" | "running"; readonly wakeEnabled?: boolean } - >(); + const memberStatuses = new Map<string, MemberStatus>(); + const memberReadiness = new Map<string, Effect.Effect<void, StackError>>(); let savedCredentials: StackCredentials = { jwtSecret: DEFAULT_LOCAL_JWT_SECRET, postgresRootKey: DEFAULT_POSTGRES_ROOT_KEY, @@ -234,15 +265,15 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { database?.service === "database" && database.config.databasePassword !== undefined ? Redacted.value(database.config.databasePassword) : DEFAULT_LOCAL_DATABASE_PASSWORD, - publishableKey: options?.identity?.publishableKey ?? "sb_publishable_test", - secretKey: options?.identity?.secretKey ?? "sb_secret_test", - anonKey: options?.identity?.anonKey ?? "anon-token", - serviceRoleKey: options?.identity?.serviceRoleKey ?? "service-token", + publishableKey: options?.keys?.publishableKey ?? "sb_publishable_test", + secretKey: options?.keys?.secretKey ?? "sb_secret_test", + anonKey: options?.keys?.anonKey ?? "anon-token", + serviceRoleKey: options?.keys?.serviceRoleKey ?? "service-token", jwks: '{"keys":[]}', - gotrueJwtKeys: options?.identity?.gotrueJwtKeys ?? "[]", - remoteJwks: options?.identity?.remoteJwks ?? "[]", - anonKeyIsOverride: options?.identity?.anonKeyIsOverride ?? false, - serviceRoleKeyIsOverride: options?.identity?.serviceRoleKeyIsOverride ?? false, + gotrueJwtKeys: options?.keys?.gotrueJwtKeys ?? "[]", + remoteJwks: options?.keys?.remoteJwks ?? "[]", + anonKeyIsOverride: options?.keys?.anonKeyIsOverride ?? false, + serviceRoleKeyIsOverride: options?.keys?.serviceRoleKeyIsOverride ?? false, }; const previousMembers = members; members = creations.map((creation) => { @@ -258,11 +289,45 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { () => memberStatuses.get(id)?.wakeEnabled ?? (lifecycle === "running" && activations.get(id) === "lazy"), + () => { + const status = memberStatuses.get(id); + if (status?.health !== undefined) return status.health; + return (status?.lifecycle ?? lifecycle) === "running" ? "healthy" : undefined; + }, + () => memberReadiness.get(id) ?? Effect.void, ); }); - activations = new Map(members.map(({ id }) => [id, "eager"])); + activations = new Map( + members.map(({ id, service }) => [ + id, + options?.eager === true || service === "database" ? "eager" : "lazy", + ]), + ); return members; }), + plan: (creations) => + Effect.forEach(members, (member) => + member.status.pipe( + Effect.map(({ config }): ReadonlyArray<PlannedInstance> => { + const request = creations.find(({ service }) => service === member.service); + if (request === undefined) return []; + const base = { id: member.id, service: member.service, member: true }; + if (config.service === "database" && request.service === "database") + return [ + postgresVersion(config.config.version) === postgresVersion(request.config.version) + ? { ...base, change: "unchanged" } + : { ...base, change: "incompatible", paths: ["config.version"] }, + ]; + if (!Equal.equals(config.endpoints, request.endpoints)) + return [{ ...base, change: "incompatible", paths: ["endpoints"] }]; + return [ + Equal.equals(config.config, request.config) + ? { ...base, change: "unchanged" } + : { ...base, change: "changed", paths: ["config"] }, + ]; + }), + ), + ).pipe(Effect.map((planned) => planned.flat())), configure: ({ members: configured }) => Effect.sync(() => { activations = new Map(configured.map(({ id, activation }) => [id, activation])); @@ -270,7 +335,9 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { start: compositionStart ?? Effect.sync(() => { + compositionStarts += 1; lifecycle = "running"; + memberStatuses.clear(); return []; }), stop: Effect.sync(() => { @@ -280,9 +347,14 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { }), restart: Effect.succeed([]), }, - stop: Effect.void, - destroy: Effect.void, - tools: { run: () => Effect.die("tool not used") }, + stop: Effect.sync(() => { + hostStopped += 1; + }), + destroy: Effect.sync(() => { + hostDestroyed += 1; + return { runtimeCleanup: "complete" as const }; + }), + commands: { run: () => Effect.die("command not used") }, }; return { stack, @@ -292,24 +364,34 @@ const fakeStack = (compositionStart?: Stack["composition"]["start"]) => { get stopped() { return stopped; }, + get hostStopped() { + return hostStopped; + }, + get hostDestroyed() { + return hostDestroyed; + }, get composed() { return composed; }, get catalogApplied() { return catalogApplied; }, + get compositionStarts() { + return compositionStarts; + }, applyCatalog() { catalogApplied += 1; }, get savedCredentials() { return savedCredentials; }, - setMemberStatus( - id: string, - status: { readonly lifecycle?: "stopped" | "running"; readonly wakeEnabled?: boolean }, - ) { + setMemberStatus(id: string, status: MemberStatus) { memberStatuses.set(id, status); }, + /** Readiness survives composition start, which awaits only eager members. */ + setMemberReadiness(id: string, ready: Effect.Effect<void, StackError>) { + memberReadiness.set(id, ready); + }, }; }; @@ -318,6 +400,9 @@ const layers = ( fixture: ReturnType<typeof fakeStack>, output = mockOutput(), existing = true, + explicitWorkdir = false, + // Fixtures request the native runtime, so pin a host that ships native artifacts. + runtimeInfo = mockRuntimeInfo({ platform: "linux", arch: "x64" }), ) => { const telemetry = mockTelemetryStateTracked(); const target = Layer.succeed(StackTargetResolver, { @@ -333,14 +418,14 @@ const layers = ( create: () => Effect.succeed(fixture.stack), open: () => Effect.succeed(fixture.stack), discover: () => Effect.succeed([]), - resolveIdentity: () => Effect.die("identity not used"), + find: () => Effect.die("identity not used"), }); return Layer.mergeAll( BunServices.layer, - runtimeInfoLayer, + runtimeInfo, output.layer, telemetry.layer, - mockCommandSettings({ workdir: root }), + mockCommandSettings({ workdir: root, explicitWorkdir }), target, api, Layer.succeed(ExperimentalFlag, false), @@ -401,7 +486,7 @@ describe("experimental stack start", () => { }), open: () => Effect.succeed(fixture.stack), discover: () => Effect.succeed([]), - resolveIdentity: () => Effect.die("identity not used"), + find: () => Effect.die("identity not used"), }); const result = yield* stackStart(flags()).pipe( Effect.flip, @@ -412,6 +497,48 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live( + "rejects --runtime native on a platform with no native artifacts before creating a stack", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-start-native-unsupported-", + }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "unsupported"\n'); + let created = false; + const fixture = fakeStack(); + const base = layers( + root, + fixture, + mockOutput(), + false, + false, + mockRuntimeInfo({ platform: "win32", arch: "x64" }), + ); + const api = Layer.succeed(StackApi, { + create: () => + Effect.sync(() => { + created = true; + return fixture.stack; + }), + open: () => Effect.succeed(fixture.stack), + discover: () => Effect.succeed([]), + find: () => Effect.die("identity not used"), + }); + const result = yield* stackStart(flags()).pipe( + Effect.flip, + Effect.provide(Layer.merge(base, api)), + ); + expect(result).toMatchObject({ + reason: "flags", + message: expect.stringContaining("Native artifacts are unsupported on win32/x64"), + }); + expect(created).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("applies capability selection across repeated starts", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -439,6 +566,7 @@ describe("experimental stack start", () => { expect.objectContaining({ data: { id: fixture.stack.id, + runtime: "native", endpoints: { "database.sql": { protocol: "tcp", @@ -447,10 +575,19 @@ describe("experimental stack start", () => { url: "tcp://127.0.0.1:23456", }, }, + lazy_services: [], + env: { + DB_URL: "postgresql://postgres:postgres@127.0.0.1:23456/postgres", + PUBLISHABLE_KEY: "sb_publishable_test", + SECRET_KEY: "sb_secret_test", + ANON_KEY: "anon-token", + SERVICE_ROLE_KEY: "service-token", + }, }, }), ); expect(fixture.members.map(({ service }) => service)).toEqual(["database"]); + expect(yield* fs.exists(`${root}/supabase/snippets`)).toBe(false); expect(fixture.composed).toBe(1); const repeatedOutput = mockOutput(); yield* stackStart(flags(excluded)).pipe( @@ -468,6 +605,7 @@ describe("experimental stack start", () => { yield* fixture.stack.composition.stop; yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); expect(fixture.members.some(({ service }) => service === "rest")).toBe(true); + expect(yield* fs.exists(`${root}/supabase/snippets`)).toBe(true); expect(fixture.composed).toBe(2); yield* fixture.stack.composition.stop; yield* stackStart(flags(["studio"])).pipe(Effect.provide(layers(root, fixture))); @@ -482,6 +620,115 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("reports connection details and lazy services once the stack is ready", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-summary-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "start-summary"\n[edge_runtime]\nenabled = false\n', + ); + const excluded = ["auth", "realtime", "storage", "functions", "mail", "analytics", "pooler"]; + const fixture = fakeStack(); + const json = mockOutput({ format: "json" }); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); + const data = json.messages.find(({ data }) => data !== undefined)?.data as { + endpoints: Readonly<Record<string, unknown>>; + }; + expect(data).toMatchObject({ + runtime: "native", + endpoints: { + "rest.http": { url: "http://127.0.0.1:23457" }, + }, + lazy_services: ["pgmeta", "rest", "studio"], + env: { + API_URL: "http://127.0.0.1:23457", + REST_URL: "http://127.0.0.1:23457/rest/v1", + DB_URL: "postgresql://postgres:postgres@127.0.0.1:23456/postgres", + STUDIO_URL: "http://127.0.0.1:23458", + MCP_URL: "http://127.0.0.1:23457/mcp", + PUBLISHABLE_KEY: "sb_publishable_test", + SECRET_KEY: "sb_secret_test", + ANON_KEY: "anon-token", + SERVICE_ROLE_KEY: "service-token", + }, + }); + expect(data.endpoints).not.toHaveProperty("studio.mcp"); + + yield* fixture.stack.composition.stop; + const text = mockOutput(); + yield* stackStart({ ...flags(excluded), stack: Option.some("feature demo") }).pipe( + Effect.provide(layers(root, fixture, text, true, true)), + ); + expect(text.stdoutText).toMatch(/Project URL +│ http:\/\/127\.0\.0\.1:23457 +│/u); + expect(text.stdoutText).toMatch(/MCP +│ http:\/\/127\.0\.0\.1:23457\/mcp +│/u); + expect(text.stdoutText).not.toContain("GraphQL"); + expect(text.stdoutText).toMatch(/Secret +│ \S+ +│/u); + expect(text.stdoutText).toMatch(/rest +│ running · healthy · lazy +│/u); + // Windows temp paths contain `\` and `~`, so the PowerShell pointer quotes the workdir. + const workdir = process.platform === "win32" ? `'${root}'` : root; + expect(text.stdoutText).toContain( + `Runtime: native\nRun supabase status --env --workdir ${workdir} --stack 'feature demo' to export these values as environment variables.\n`, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("returns the connection env for an already-running stack in JSON", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-already-running-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "already-running"\n[edge_runtime]\nenabled = false\n', + ); + const excluded = ["auth", "realtime", "storage", "functions", "mail", "analytics", "pooler"]; + const fixture = fakeStack(); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture))); + const json = mockOutput({ format: "json" }); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); + expect(fixture.composed).toBe(1); + const data = json.messages.find(({ data }) => data !== undefined)?.data as { + env: Readonly<Record<string, string>>; + }; + expect(data.env).toMatchObject({ + API_URL: "http://127.0.0.1:23457", + MCP_URL: "http://127.0.0.1:23457/mcp", + DB_URL: "postgresql://postgres:postgres@127.0.0.1:23456/postgres", + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("returns the connection env for a resumed stack in JSON", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-resumed-env-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "resumed-env"\n[edge_runtime]\nenabled = false\n', + ); + const excluded = ["auth", "realtime", "storage", "functions", "mail", "analytics", "pooler"]; + const fixture = fakeStack(); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture))); + const rest = fixture.members.find(({ service }) => service === "rest"); + if (rest === undefined) return yield* Effect.die("Expected a REST member"); + // The database stays running; another member starting keeps the composition short of + // fully started, so the next start takes the resumed branch, not the already-running one. + fixture.setMemberStatus(rest.id, { lifecycle: "starting", health: "starting" }); + const json = mockOutput({ format: "json" }); + yield* stackStart(flags(excluded)).pipe(Effect.provide(layers(root, fixture, json))); + expect(fixture.compositionStarts).toBe(2); + expect(fixture.composed).toBe(1); + const data = json.messages.find(({ data }) => data !== undefined)?.data as { + env: Readonly<Record<string, string>>; + }; + expect(data.env.DB_URL).toBe("postgresql://postgres:postgres@127.0.0.1:23456/postgres"); + expect(data.env.API_URL).toBe("http://127.0.0.1:23457"); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("names the services that failed when the composition start fails", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -580,8 +827,6 @@ describe("experimental stack start", () => { const refreshedStatus = yield* refreshed.status; if (refreshedStatus.config.service === "functions") expect(refreshedStatus.config.config.env).toEqual({ CUSTOM_VALUE: "changed" }); - const configured = yield* fixture.stack.composition.describe; - expect(configured.members.find(({ id }) => id === functionsId)?.activation).toBe("lazy"); }).pipe(Effect.provide(BunServices.layer)), ); @@ -726,6 +971,133 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("rejects a stack whose database alone was started before reading changed config", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-db-only-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "db-only"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + for (const member of fixture.members) + if (member.service !== "database") + fixture.setMemberStatus(member.id, { lifecycle: "stopped", wakeEnabled: false }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "db-only"\n[auth]\nsigning_keys_path = "missing-keys.json"\n', + ); + + const error = yield* stackStart(flags(["studio"])).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "lifecycle", + message: "The stack is in a partial lifecycle state", + suggestion: "Run supabase stack stop, then supabase stack start to recover the stack.", + }); + expect(fixture.compositionStarts).toBe(1); + expect(fixture.composed).toBe(1); + expect(fixture.stopped).toBe(0); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live( + "reports a resumed stack ready only after its unhealthy and booting members are ready", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-resume-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "resume"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const rest = fixture.members.find(({ service }) => service === "rest"); + const auth = fixture.members.find(({ service }) => service === "auth"); + if (rest === undefined || auth === undefined) + return yield* Effect.die("Expected REST and Auth members"); + fixture.setMemberStatus(rest.id, { lifecycle: "running", health: "unhealthy" }); + fixture.setMemberStatus(auth.id, { lifecycle: "starting", health: "starting" }); + const gate = yield* Deferred.make<void>(); + const awaited = yield* Effect.forEach([rest.id, auth.id], (id) => + Deferred.make<void>().pipe( + Effect.tap((waiting) => + Effect.sync(() => + fixture.setMemberReadiness( + id, + Deferred.succeed(waiting, undefined).pipe(Effect.andThen(Deferred.await(gate))), + ), + ), + ), + ), + ); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "resume"\n[auth]\nsigning_keys_path = "missing-keys.json"\n', + ); + const output = mockOutput(); + + const resuming = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture, output)), + Effect.forkChild({ startImmediately: true }), + ); + const firstSettled = yield* Effect.raceFirst( + Fiber.join(resuming).pipe(Effect.as("reported" as const)), + Effect.forEach(awaited, Deferred.await, { discard: true }).pipe( + Effect.as("awaiting readiness" as const), + ), + ); + expect(firstSettled).toBe("awaiting readiness"); + expect(fixture.compositionStarts).toBe(2); + expect(output.messages).not.toContainEqual({ type: "success", message: "Stack is ready." }); + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(resuming); + + expect(fixture.composed).toBe(1); + expect(fixture.stopped).toBe(0); + expect(output.messages).toContainEqual({ + type: "info", + message: + "Resuming the saved stack services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", + }); + expect(output.messages).toContainEqual({ type: "success", message: "Stack is ready." }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("fails a start while a woken lazy member is still booting and never becomes ready", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-booting-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "booting"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const auth = fixture.members.find(({ service }) => service === "auth"); + if (auth === undefined) return yield* Effect.die("Expected an Auth member"); + fixture.setMemberStatus(auth.id, { lifecycle: "starting", health: "starting" }); + fixture.setMemberReadiness( + auth.id, + Effect.fail( + new StackError({ operation: "service.ready", message: "auth HTTP readiness timed out" }), + ), + ); + const output = mockOutput(); + + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture, output)), + Effect.flip, + ); + + expect(error).toBeInstanceOf(StackCommandStartError); + expect(error).toMatchObject({ message: "auth HTTP readiness timed out" }); + expect(output.messages.map(({ message }) => message)).not.toContain("Stack is ready."); + expect(output.messages.map(({ message }) => message)).not.toContain( + "Stack is already running with its current services. Run `supabase stack stop`, then `supabase stack start` to apply configuration or service-selection changes.", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("rejects changed Postgres root keys and database versions after stopping the stack", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -756,6 +1128,59 @@ describe("experimental stack start", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("applies changed service configuration after the stack is stopped", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-changed-config-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + const config = (maxRows: number) => + `project_id = "changed-config"\n[api]\nmax_rows = ${maxRows}\n[edge_runtime]\nenabled = false\n`; + yield* fs.writeFileString(`${root}/supabase/config.toml`, config(100)); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + const restId = fixture.members.find(({ service }) => service === "rest")?.id; + + yield* fs.writeFileString(`${root}/supabase/config.toml`, config(500)); + yield* fixture.stack.composition.stop; + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + const rest = fixture.members.find(({ service }) => service === "rest"); + if (rest === undefined) return yield* Effect.die("REST missing"); + expect(rest.id).toBe(restId); + const status = yield* rest.status; + expect(status.config.service === "rest" ? status.config.config.maxRows : undefined).toBe(500); + expect(fixture.composed).toBe(2); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("rejects a changed endpoint after the stack is stopped", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-changed-port-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "changed-port"\n'); + const fixture = fakeStack(); + yield* stackStart(flags()).pipe(Effect.provide(layers(root, fixture))); + + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "changed-port"\n[api]\nport = 54999\n', + ); + yield* fixture.stack.composition.stop; + const error = yield* stackStart(flags()).pipe( + Effect.provide(layers(root, fixture)), + Effect.flip, + ); + + expect(error).toMatchObject({ + reason: "invalid-config", + message: expect.stringContaining("cannot change on the saved stack"), + suggestion: expect.stringContaining("supabase stack destroy"), + }); + expect(fixture.composed).toBe(1); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("matches a Postgres major alias to the saved pinned database version", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -782,4 +1207,184 @@ describe("experimental stack start", () => { expect(fixture.composed).toBe(2); }).pipe(Effect.provide(BunServices.layer)), ); + + it.live("reports the saved runtime when automatic selection creates a Podman stack", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-auto-podman-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "auto-podman"\n[edge_runtime]\nenabled = false\n', + ); + const output = mockOutput(); + const engines = containerEngineSpawner({ docker: "stopped", podman: "running" }); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => Effect.succeed({ projectRoot: root, hostRunning: false }), + }); + yield* stackStart({ + ...flags([ + "rest", + "auth", + "realtime", + "storage", + "functions", + "studio", + "mail", + "analytics", + "pooler", + ]), + runtime: "auto", + }).pipe( + Effect.provide( + Layer.mergeAll(layers(root, fakeStack(), output, false), target, engines.layer), + ), + ); + expect(engines.spawned.map(({ command }) => command)).toEqual(["docker", "podman"]); + expect(output.messages).toContainEqual({ + type: "info", + message: expect.stringContaining( + "Docker didn't answer, so this new stack uses the Podman runtime", + ), + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + // The `#!/bin/sh` shim is never picked up on Windows, which only resolves `docker.exe` on PATH. + it.live.skipIf(process.platform === "win32")( + "leaves no stack registered when a new stack's owner cannot reach the Docker daemon", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-create-failure-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "create-failure"\n', + ); + yield* fs.makeDirectory(`${root}/bin`); + yield* fs.writeFileString( + `${root}/bin/docker`, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at unix:///shim/docker.sock. Is the docker daemon running?' >&2\nexit 1\n", + ); + yield* fs.chmod(`${root}/bin/docker`, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${root}/bin:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + const output = mockOutput(); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => + Effect.succeed({ projectRoot: root, runtime: "docker" as const, hostRunning: false }), + }); + const api = stackApiLayer.pipe(Layer.provide(BunServices.layer)); + + const error = yield* stackStart(flags()).pipe( + Effect.flip, + Effect.provide(Layer.mergeAll(layers(root, fakeStack(), output, false), target, api)), + ); + expect(error.message).toContain("Cannot connect to the Docker daemon"); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.reason).toBe("runtime"); + expect(error.suggestion).toContain("Docker CLI or daemon isn't reachable"); + } + expect(output.stderrText).not.toContain("Failed to stop"); + + const stacks = yield* StackApi.pipe( + Effect.flatMap((stackApi) => + stackApi.discover({ stateRoot: `${root}/.supabase/stacks` }), + ), + Effect.provide(api), + ); + expect(stacks).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("leaves no stack registered when a new stack's owner finds no Docker CLI", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-create-failure-" }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString(`${root}/supabase/config.toml`, 'project_id = "create-failure"\n'); + // An empty PATH hides any installed Docker CLI on every platform. + yield* fs.makeDirectory(`${root}/empty-bin`); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- Windows names the variable `Path`; the detached owner inherits it. + const envKeys = Object.keys(process.env); + const pathKey = envKeys.find((key) => key.toUpperCase() === "PATH") ?? "PATH"; + yield* withEnvVar( + pathKey, + `${root}/empty-bin`, + Effect.gen(function* () { + const output = mockOutput(); + const target = Layer.succeed(StackTargetResolver, { + resolve: () => + Effect.succeed({ projectRoot: root, runtime: "docker" as const, hostRunning: false }), + }); + const api = stackApiLayer.pipe(Layer.provide(BunServices.layer)); + + const error = yield* stackStart(flags()).pipe( + Effect.flip, + Effect.provide(Layer.mergeAll(layers(root, fakeStack(), output, false), target, api)), + ); + expect(error.message).toContain("docker ps"); + expect(error).toBeInstanceOf(StackCommandStartError); + if (error instanceof StackCommandStartError) { + expect(error.reason).toBe("runtime"); + expect(error.suggestion).toContain("Docker CLI or daemon isn't reachable"); + } + expect(output.stderrText).not.toContain("Failed to stop"); + + const stacks = yield* StackApi.pipe( + Effect.flatMap((stackApi) => + stackApi.discover({ stateRoot: `${root}/.supabase/stacks` }), + ), + Effect.provide(api), + ); + expect(stacks).toEqual([]); + }), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live( + "stops, without destroying, the owner when startup fails after the stack is acquired", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const isExisting of [false, true]) { + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-start-startup-failure-", + }); + yield* fs.makeDirectory(`${root}/supabase`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "startup-failure"\n', + ); + const fixture = fakeStack( + Effect.fail( + new StackError({ + operation: "composition.start", + message: "Composition start had failures", + }), + ), + ); + const error = yield* Effect.scoped( + stackStart(flags()).pipe( + Effect.flip, + Effect.provide(layers(root, fixture, mockOutput(), isExisting)), + ), + ); + expect(error).toBeInstanceOf(StackCommandStartError); + expect(fixture.hostStopped).toBe(1); + expect(fixture.hostDestroyed).toBe(0); + } + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts index dcd68524fc..b1ab5732b7 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.native.integration.test.ts @@ -145,8 +145,8 @@ enabled = false enabled = false `; -const makeLayers = (root: string, apiLayer = liveStackApi) => { - const settings = mockCommandSettings({ workdir: root, supabaseHome: root }); +const makeLayers = (root: string, apiLayer = liveStackApi, workdir = root) => { + const settings = mockCommandSettings({ workdir, supabaseHome: root }); const resolver = stackTargetResolverLayer.pipe( Layer.provide(Layer.mergeAll(BunServices.layer, settings, apiLayer)), ); @@ -378,8 +378,11 @@ describe("experimental stack start native lifecycle", () => { const failedStart = yield* Effect.scoped(Effect.exit(stackStart(flags([])))); expect(Exit.isFailure(failedStart)).toBe(true); if (!Exit.isFailure(failedStart)) return; - expect(Cause.pretty(failedStart.cause)).toContain( - `:${occupiedPort}: Cannot bind TCP listener`, + // Linux rejects the bind; platforms that allow overlapping binds reject the probe. + expect(Cause.pretty(failedStart.cause)).toMatch( + new RegExp( + `127\\.0\\.0\\.1:${occupiedPort}(: Cannot bind TCP listener| is already in use)`, + ), ); const ownerPid = ownerPids[attempt]; expect(ownerPid).toBeDefined(); @@ -433,4 +436,65 @@ describe("experimental stack start native lifecycle", () => { }).pipe(Effect.provide(BunServices.layer)), { timeout: 60_000 }, ); + + it.live.skipIf(process.platform === "win32")( + "restarts a stack created through a symlinked workdir after it is stopped", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "stack-start-symlink-" }), + ); + const project = path.join(root, "project"); + const workdir = path.join(root, "workdir"); + yield* fs.makeDirectory(path.join(project, "supabase"), { recursive: true }); + yield* fs.symlink(project, workdir); + yield* fs.writeFileString( + path.join(project, "supabase", "config.toml"), + projectConfig.replace( + "[edge_runtime]\nenabled = false", + "[edge_runtime]\nenabled = true", + ), + ); + const fixture = makeLayers(root, liveStackApi, workdir); + const onlyFunctions = excluded.filter((name) => name !== "functions"); + yield* Effect.ensuring( + Effect.gen(function* () { + const api = yield* StackApi; + const locations = { + stateRoot: path.join(root, "stacks"), + cacheRoot: path.join(root, "cache"), + }; + const functionsRoot = Effect.fn("functionsRoot")(function* (id: string) { + const stack = yield* api.open({ ...locations, id }); + const functions = (yield* stack.services.list).find( + (instance) => instance.service === "functions", + ); + if (functions === undefined) return yield* Effect.die("Functions missing"); + const status = yield* functions.status; + return { + id: functions.id, + root: + status.config.service === "functions" ? status.config.config.functionsRoot : "", + }; + }); + const stackId = yield* stackStart(flags(onlyFunctions)); + const first = yield* functionsRoot(stackId); + expect(first.root.startsWith(project)).toBe(true); + yield* (yield* api.open({ ...locations, id: stackId })).stop; + + const restartedId = yield* stackStart(flags(onlyFunctions)); + + expect(restartedId).toBe(stackId); + expect(yield* functionsRoot(restartedId)).toEqual(first); + }), + Effect.gen(function* () { + const api = yield* StackApi; + yield* destroyTestStacks(api, path.join(root, "stacks"), path.join(root, "cache")); + }), + ).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(BunServices.layer)), + { timeout: 180_000 }, + ); }); diff --git a/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts new file mode 100644 index 0000000000..c268717965 --- /dev/null +++ b/apps/cli/src/commands/experimental/stack/start/start.storage.e2e.test.ts @@ -0,0 +1,209 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit, FileSystem, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { tmpdir } from "node:os"; + +import { + makeTempCliProject, + makeTempHome, + runSupabaseEffect, +} from "../../../../../tests/helpers/cli.ts"; + +const COMMAND_TIMEOUT_MS = 10 * 60_000; +const CLEANUP_TIMEOUT_MS = 120_000; +const nativeSupported = + (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64")) || + (process.platform === "darwin" && process.arch === "arm64"); + +const projectConfig = `project_id = "stack-storage-e2e" + +[experimental] +stack = true + +[api] +enabled = true + +[storage] +enabled = true + +[storage.image_transformation] +enabled = false + +[auth] +enabled = false + +[db.pooler] +enabled = false + +[edge_runtime] +enabled = false + +[realtime] +enabled = false + +[studio] +enabled = false + +[analytics] +enabled = false + +[local_smtp] +enabled = false +`; + +class StackStorageE2eError extends Data.TaggedError("StackStorageE2eError")<{ + readonly message: string; +}> {} + +const StartResultSchema = Schema.Struct({ id: Schema.String }); +const StorageEnvSchema = Schema.Struct({ + API_URL: Schema.String, + SERVICE_ROLE_KEY: Schema.String, + STORAGE_S3_URL: Schema.String, + S3_PROTOCOL_ACCESS_KEY_ID: Schema.String, + S3_PROTOCOL_ACCESS_KEY_SECRET: Schema.String, + S3_PROTOCOL_REGION: Schema.String, +}); + +const s3Call = <A>(operation: string, call: () => Promise<A>) => + Effect.tryPromise({ + try: call, + catch: (cause) => + new StackStorageE2eError({ message: `S3 ${operation} failed: ${String(cause)}` }), + }); + +const tusMetadata = (entries: Readonly<Record<string, string>>) => + Object.entries(entries) + .map(([name, value]) => `${name} ${btoa(value)}`) + .join(","); + +describe("stack start Storage behind the API gateway (compiled e2e)", () => { + for (const runtime of ["native", "docker"] as const) { + if (runtime === "native" && !nativeSupported) continue; + it.live( + `serves S3 clients and resumable uploads from the ${runtime} status URLs`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const http = yield* HttpClient.HttpClient; + const home = makeTempHome(); + const project = yield* Effect.promise(() => + makeTempCliProject(`stack-storage-${runtime}-e2e-`), + ); + const artifacts = path.join(tmpdir(), "supabase-stack-artifacts"); + yield* fs.makeDirectory(path.join(home.dir, "cache"), { recursive: true }); + yield* fs.makeDirectory(artifacts, { recursive: true }); + yield* fs.symlink(artifacts, path.join(home.dir, "cache", "stack")); + yield* fs.makeDirectory(path.join(project.dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(project.dir, "supabase", "config.toml"), + projectConfig, + ); + const cli = (args: Array<string>, exitTimeoutMs = CLEANUP_TIMEOUT_MS) => + runSupabaseEffect(args, { + cwd: project.dir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs, + }); + + yield* Effect.addFinalizer((exit) => + cli(["stack", "destroy", "--yes"]).pipe( + Effect.flatMap((destroyed) => + Exit.isFailure(exit) || destroyed.exitCode === 0 + ? Effect.void + : new StackStorageE2eError({ + message: `stack destroy exited ${destroyed.exitCode}: ${destroyed.stderr}`, + }), + ), + Effect.orDie, + ), + ); + + const started = yield* cli( + ["stack", "start", "--runtime", runtime, "--eager", "--output-format", "json"], + COMMAND_TIMEOUT_MS, + ); + expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe( + 0, + ); + const { id } = yield* Schema.decodeEffect(Schema.fromJsonString(StartResultSchema))( + started.stdout.trim(), + ); + const status = yield* cli([ + "stack", + "status", + "--env", + "--stack-id", + id, + "--output-format", + "json", + ]); + expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); + const env = yield* Schema.decodeEffect(Schema.fromJsonString(StorageEnvSchema))( + status.stdout, + ); + const storageUrl = `${env.API_URL}/storage/v1`; + expect(env.STORAGE_S3_URL).toBe(`${storageUrl}/s3`); + const authorization = `Bearer ${env.SERVICE_ROLE_KEY}`; + const bucket = "e2e"; + + const createBucket = yield* http.execute( + HttpClientRequest.post(`${storageUrl}/bucket`).pipe( + HttpClientRequest.setHeaders({ authorization }), + HttpClientRequest.bodyJsonUnsafe({ name: bucket }), + ), + ); + expect(createBucket.status, yield* createBucket.text).toBe(200); + + const s3 = new Bun.S3Client({ + accessKeyId: env.S3_PROTOCOL_ACCESS_KEY_ID, + secretAccessKey: env.S3_PROTOCOL_ACCESS_KEY_SECRET, + region: env.S3_PROTOCOL_REGION, + endpoint: env.STORAGE_S3_URL, + bucket, + }); + yield* s3Call("write", () => s3.write("s3.txt", "written through S3")); + const listing = yield* s3Call("list", () => s3.list()); + expect(listing.contents?.map(({ key }) => key)).toEqual(["s3.txt"]); + expect(yield* s3Call("read", () => s3.file("s3.txt").text())).toBe("written through S3"); + + const body = new TextEncoder().encode("resumable upload"); + const create = yield* http.execute( + HttpClientRequest.post(`${storageUrl}/upload/resumable`).pipe( + HttpClientRequest.setHeaders({ + authorization, + "tus-resumable": "1.0.0", + "upload-length": String(body.length), + "upload-metadata": tusMetadata({ + bucketName: bucket, + objectName: "resumable.txt", + contentType: "text/plain", + }), + }), + ), + ); + expect(create.status, yield* create.text).toBe(201); + const location = create.headers.location ?? ""; + expect(location.startsWith(`${storageUrl}/upload/resumable/`), location).toBe(true); + const patch = yield* http.execute( + HttpClientRequest.patch(location).pipe( + HttpClientRequest.setHeaders({ + authorization, + "tus-resumable": "1.0.0", + "upload-offset": "0", + }), + HttpClientRequest.bodyUint8Array(body, "application/offset+octet-stream"), + ), + ); + expect(patch.status, yield* patch.text).toBe(204); + expect(yield* s3Call("read", () => s3.file("resumable.txt").text())).toBe( + "resumable upload", + ); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, FetchHttpClient.layer])), + { timeout: COMMAND_TIMEOUT_MS + 2 * CLEANUP_TIMEOUT_MS }, + ); + } +}); diff --git a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md index d22a0e8997..dd2200f2c3 100644 --- a/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/status/SIDE_EFFECTS.md @@ -21,35 +21,61 @@ members remain visible without live lifecycle or health values. Lifecycle, health, endpoint, and aggregate readiness values are reported separately so a stopped or unhealthy member is distinguishable from an unavailable owner. -The command compares the database version and explicit numeric endpoint ports with the saved -configuration of existing composition members. It does not compare other service settings or -changes to membership; an excluded service is not considered drift. Live listener availability -does not affect this comparison. It reports `config_drift.status` as `unchanged` or -`changed`, with changed paths, when the comparison is possible. A configuration -loading failure or unavailable database observation keeps the saved stack report -available and is shown as `config_drift.status: "unavailable"` with a message in -JSON. Status does not apply current configuration. The `services` list may include +The command compares the project configuration with the saved configuration of existing +composition members through the stack package's composition plan. It reads +`supabase/functions/.env` only when Functions is a saved composition member. +Values that the composition or stack credentials supply are ignored, as are changes to membership; +an excluded service is not considered drift. The comparison reads saved state only, so it is +available while the owner is unavailable. It reports `config_drift.status` as `unchanged` or +`changed`, with `services.<service>.<path>` paths, when the comparison is possible. A configuration +loading failure or unreadable saved state keeps the saved stack report available and is shown as +`config_drift.status: "unavailable"` with a message in JSON. Status does not apply current configuration. The `services` list may include saved standalone instances; composition members identify the services used for primary database, environment export, and drift comparisons. -The source checkout may bundle the default Functions bootstrap while translating -project configuration for drift; this is in-memory and writes no project files. -Text output includes identity, runtime, owner, lifecycle, readiness, services, -endpoints, and config drift. JSON nests only identity fields under `identity`; -runtime, lifecycle, readiness, composition, services, endpoints, and config -drift remain top-level fields. Stack identity, endpoints, and credentials are -never telemetry properties. +Text output starts with one line naming the stack, its readiness, runtime, and +project directory, noting when the owner is unavailable. It then prints the +connection summary shared with `stack start`: the API, REST, Functions, +Studio, MCP, Mailpit, and database URLs that the composition members expose, the +saved publishable and secret keys, the Storage S3 URL, access keys, and region +when the Storage member enables the S3 protocol, and a services table with each service's +state, health, and activation; sleeping lazy services are marked as starting on +first request. Service errors follow the tables, and config drift ends the +output as one muted line unless the configuration drifted. Stack and service IDs appear only +in JSON. JSON nests only identity fields under `identity`; runtime, lifecycle, +readiness, composition, services, endpoints, config drift, and `env` remain +top-level fields. `endpoints` reports the raw observations +(`service.endpoint`) of every observed instance, not only composition members; +it carries no synthetic entries. `env` is the member-scoped connection map +`--env` exports (see below), degrading to whatever is available when +credentials or the owner are unreachable; it never fails the command. Plain +`status` JSON `env` comes from saved bindings and can list values for stopped +or sleeping members, while `--env` requires a reachable owner and a running +primary database. Stack identity, endpoints, and credentials are never +telemetry properties. ## Exporting environment variables (`--env`) `--env` is the explicit environment-export operation. It requires a reachable -owner and a running primary database, then derives the database URL from the -observed SQL endpoint and saved database credentials, using the `supabase_admin` role. It does not request live -credentials or launch an owner, and it does not load or compare project -configuration. Text output emits dotenv assignments; +owner and a running primary database, then derives `DB_URL` from the observed +SQL endpoint and the saved database password, using the `postgres` role and no +query string. `API_URL` is the shared API listener of any member routed +through it, and `MCP_URL` is `<API_URL>/mcp`, present only when the shared API +listener is present and Studio is a composition member with an HTTP endpoint. +It does not request live credentials or launch an owner, and it does not load +or compare project configuration. Text output emits dotenv assignments; JSON and stream-JSON output emit a plain variable map under a successful result. -Unavailable optional credentials and endpoints are omitted. The derived -`ANON_KEY` and `SERVICE_ROLE_KEY` values are emitted from the required saved database JWT secret. +Unavailable optional credentials and endpoints are omitted. The exported +variable set is `API_URL`, `REST_URL`, `FUNCTIONS_URL`, `DB_URL`, `STUDIO_URL`, +`MCP_URL`, `MAILPIT_URL`, `PUBLISHABLE_KEY`, `SECRET_KEY`, `ANON_KEY`, +`SERVICE_ROLE_KEY`, `STORAGE_S3_URL`, `S3_PROTOCOL_ACCESS_KEY_ID`, +`S3_PROTOCOL_ACCESS_KEY_SECRET`, and `S3_PROTOCOL_REGION`; `REST_URL` and +`FUNCTIONS_URL` are `<API_URL>/rest/v1` and `<API_URL>/functions/v1`, present +only when their member is a composition member with an HTTP endpoint. +`STORAGE_S3_URL` is `<API_URL>/storage/v1/s3`, and it and the S3 access keys +and region come from the Storage member's saved configuration when its S3 +protocol is enabled. `ANON_KEY` and `SERVICE_ROLE_KEY` are emitted from the +required saved database JWT secret. `--override-name` renames an exported variable, accepting repeated flags or a comma-separated list of `EXPORTED_VARIABLE=VALID_ENV_NAME` entries. It requires diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.ts b/apps/cli/src/commands/experimental/stack/status/status.env.ts index 82bff7a0de..e751b24fb8 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.ts @@ -1,22 +1,30 @@ import { Effect } from "effect"; import type { StackCredentials } from "@supabase/stack/effect"; +import { connectionEnv, type StackConnections } from "../stack-summary.ts"; import { StackCommandStatusError } from "./status.errors.ts"; const variableNames = [ "API_URL", + "REST_URL", + "FUNCTIONS_URL", "DB_URL", - "ANON_KEY", - "SERVICE_ROLE_KEY", - "PUBLISHABLE_KEY", - "SECRET_KEY", "STUDIO_URL", + "MCP_URL", + "MAILPIT_URL", "INBUCKET_URL", + "PUBLISHABLE_KEY", + "SECRET_KEY", + "ANON_KEY", + "SERVICE_ROLE_KEY", + "STORAGE_S3_URL", "S3_PROTOCOL_ACCESS_KEY_ID", "S3_PROTOCOL_ACCESS_KEY_SECRET", "S3_PROTOCOL_REGION", - "S3_PROTOCOL_URL", ] as const; +/** Names the stack backend used to export, since removed; still worth naming in errors. */ +const removedVariableNames = new Set(["S3_PROTOCOL_URL"]); + export const stackEnvOverrides = (entries: ReadonlyArray<string>) => Effect.gen(function* () { const names = new Map(variableNames.map((name) => [String(name), String(name)])); @@ -25,15 +33,20 @@ export const stackEnvOverrides = (entries: ReadonlyArray<string>) => const [source, target, extra] = entry.split("="); if ( source === undefined || - !names.has(source) || target === undefined || extra !== undefined || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(target) ) return yield* new StackCommandStatusError({ reason: "flags", - message: - "--override-name must be EXPORTED_VARIABLE=VALID_ENV_NAME; for example API_URL=NEXT_PUBLIC_SUPABASE_URL.", + message: `--override-name entry "${entry}" must be EXPORTED_VARIABLE=VALID_ENV_NAME; for example API_URL=NEXT_PUBLIC_SUPABASE_URL.`, + }); + if (!names.has(source)) + return yield* new StackCommandStatusError({ + reason: "flags", + message: removedVariableNames.has(source) + ? `--override-name entry "${entry}" refers to ${source}, which is not exported by the stack backend.` + : `--override-name entry "${entry}" refers to ${source}, which is not an exported variable; valid variables are ${variableNames.join(", ")}.`, }); if (sources.has(source)) return yield* new StackCommandStatusError({ @@ -51,36 +64,20 @@ export const stackEnvOverrides = (entries: ReadonlyArray<string>) => return names; }); +/** The `status --env` variable map, with `--override-name` remapping applied. */ export const stackEnvValues = ( - status: { - readonly endpoints: Readonly<{ - readonly api?: { readonly url: string }; - readonly studio?: { readonly url: string }; - readonly mailUi?: { readonly url: string }; - }>; - readonly credentials?: Pick< - StackCredentials, - "publishableKey" | "secretKey" | "anonKey" | "serviceRoleKey" - >; - }, - credentials: Readonly<Record<string, string>>, + connections: StackConnections, + credentials: + | Pick<StackCredentials, "publishableKey" | "secretKey" | "anonKey" | "serviceRoleKey"> + | undefined, names: ReadonlyMap<string, string>, -): Readonly<Record<string, string>> => { - const values: Record<string, string> = {}; - if (credentials.databaseUrl !== undefined) values.DB_URL = credentials.databaseUrl; - if (status.credentials !== undefined) { - values.ANON_KEY = status.credentials.anonKey; - values.SERVICE_ROLE_KEY = status.credentials.serviceRoleKey; - values.PUBLISHABLE_KEY = status.credentials.publishableKey; - values.SECRET_KEY = status.credentials.secretKey; - } - if (status.endpoints.api !== undefined) values.API_URL = status.endpoints.api.url; - if (status.endpoints.studio !== undefined) values.STUDIO_URL = status.endpoints.studio.url; - if (status.endpoints.mailUi !== undefined) values.INBUCKET_URL = status.endpoints.mailUi.url; - return Object.fromEntries( - Object.entries(values).map(([key, value]) => [names.get(key) ?? key, value]), +): Readonly<Record<string, string>> => + Object.fromEntries( + Object.entries(connectionEnv(connections, credentials)).map(([key, value]) => [ + names.get(key) ?? key, + value, + ]), ); -}; const dotenvQuote = (value: string): string | undefined => { if (!value.includes("'")) return "'"; diff --git a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts index d1706e90e0..5b5dcf8ba3 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.env.unit.test.ts @@ -46,16 +46,13 @@ describe("stack environment overrides", () => { Effect.gen(function* () { const names = yield* stackEnvOverrides([]); const values = stackEnvValues( + {}, { - endpoints: {}, - credentials: { - publishableKey: "sb_publishable_saved", - secretKey: "sb_secret_saved", - anonKey: "asymmetric-anon-token", - serviceRoleKey: "asymmetric-service-token", - }, + publishableKey: "sb_publishable_saved", + secretKey: "sb_secret_saved", + anonKey: "asymmetric-anon-token", + serviceRoleKey: "asymmetric-service-token", }, - {}, names, ); expect(values).toEqual({ @@ -67,13 +64,23 @@ describe("stack environment overrides", () => { }), ); + it.effect("emits INBUCKET_URL as a deprecated alias of MAILPIT_URL", () => + Effect.gen(function* () { + const names = yield* stackEnvOverrides([]); + expect(stackEnvValues({ mailpit: "http://127.0.0.1:54324" }, undefined, names)).toEqual({ + MAILPIT_URL: "http://127.0.0.1:54324", + INBUCKET_URL: "http://127.0.0.1:54324", + }); + }), + ); + it.effect("accepts renames and rejects malformed or colliding destinations", () => Effect.gen(function* () { const names = yield* stackEnvOverrides(["API_URL=NEXT_PUBLIC_API_URL"]); expect(names.get("API_URL")).toBe("NEXT_PUBLIC_API_URL"); - expect( - stackEnvValues({ endpoints: { api: { url: "http://127.0.0.1:54321" } } }, {}, names), - ).toEqual({ NEXT_PUBLIC_API_URL: "http://127.0.0.1:54321" }); + expect(stackEnvValues({ api: "http://127.0.0.1:54321" }, undefined, names)).toEqual({ + NEXT_PUBLIC_API_URL: "http://127.0.0.1:54321", + }); for (const entries of [ ["UNKNOWN=value"], @@ -87,4 +94,51 @@ describe("stack environment overrides", () => { } }), ); + + it.effect("accepts every current variable name and rejects removed ones", () => + Effect.gen(function* () { + for (const name of [ + "API_URL", + "REST_URL", + "FUNCTIONS_URL", + "DB_URL", + "STUDIO_URL", + "MCP_URL", + "MAILPIT_URL", + "INBUCKET_URL", + "PUBLISHABLE_KEY", + "SECRET_KEY", + "ANON_KEY", + "SERVICE_ROLE_KEY", + "STORAGE_S3_URL", + "S3_PROTOCOL_ACCESS_KEY_ID", + "S3_PROTOCOL_ACCESS_KEY_SECRET", + "S3_PROTOCOL_REGION", + ]) { + const names = yield* stackEnvOverrides([`${name}=RENAMED_${name}`]); + expect(names.get(name)).toBe(`RENAMED_${name}`); + } + for (const removed of ["S3_PROTOCOL_URL", "JWT_SECRET"]) { + const error = yield* stackEnvOverrides([`${removed}=RENAMED`]).pipe(Effect.flip); + expect(error.reason).toBe("flags"); + } + }), + ); + + it.effect("names the offending entry in --override-name error messages", () => + Effect.gen(function* () { + const malformed = yield* stackEnvOverrides(["API_URL="]).pipe(Effect.flip); + expect(malformed.message).toContain('"API_URL="'); + + const removed = yield* stackEnvOverrides(["S3_PROTOCOL_URL=RENAMED"]).pipe(Effect.flip); + expect(removed.message).toContain("S3_PROTOCOL_URL"); + expect(removed.message).toContain("not exported by the stack backend"); + + const unknown = yield* stackEnvOverrides(["UNKNOWN=RENAMED"]).pipe(Effect.flip); + expect(unknown.message).toContain("UNKNOWN"); + expect(unknown.message).toContain("API_URL"); + expect(unknown.message).toContain("REST_URL"); + expect(unknown.message).toContain("FUNCTIONS_URL"); + }), + ); }); diff --git a/apps/cli/src/commands/experimental/stack/status/status.handler.ts b/apps/cli/src/commands/experimental/stack/status/status.handler.ts index 9301667248..e82f9ab753 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.handler.ts @@ -1,18 +1,23 @@ import { endpointReports } from "../stack-endpoints.format.ts"; -import { Effect, Option, Path, Redacted } from "effect"; -import type { - Observation, - ServiceCreation, - ServiceCreationInput, - Stack, -} from "@supabase/stack/effect"; -import type { StackError } from "@supabase/stack/effect"; +import { Effect, Option, Path } from "effect"; +import type { Observation, PlannedInstance, ServiceCreation, Stack } from "@supabase/stack/effect"; +import type { StackCredentials, StackError } from "@supabase/stack/effect"; import { Output } from "../../../../shared/output/output.service.ts"; import { OutputFlag } from "../../../../command-internal/global-flags.ts"; import { CommandSettings } from "../../../../config/command-settings.service.ts"; import { TelemetryState } from "../../../../telemetry/telemetry-state.service.ts"; import { loadStackConfig } from "../../../../command-internal/stack-config.ts"; -import { toPostgresURL } from "../../../../command-internal/postgres-url.ts"; +import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts"; +import { bold, gray, green, red, yellow } from "../../../../command-internal/colors.ts"; +import { + connectionEnv, + renderStackSummary, + serviceState, + stackConnections, + type StackServiceState, + stackEndpoints, + summaryCredentials, +} from "../stack-summary.ts"; import { StackApi, StackTargetError, @@ -34,15 +39,7 @@ type EndpointReport = { type ServiceReport = { readonly id: string; readonly service: ServiceCreation["service"]; - readonly state: - | "unavailable" - | "sleeping" - | "starting" - | "running" - | "stopping" - | "stopped" - | "unhealthy" - | "exited"; + readonly state: StackServiceState; readonly lifecycle: Observation["lifecycle"] | null; readonly health: Observation["health"] | null; readonly endpoints: Readonly<Record<string, EndpointReport>>; @@ -81,6 +78,8 @@ type StackReport = { readonly message: string; readonly paths?: ReadonlyArray<string>; }; + /** The `status --env` connection map, degrading to what's available when credentials or the owner are unreachable. */ + readonly env: Readonly<Record<string, string>>; }; const mapTargetError = (error: StackTargetError) => @@ -96,31 +95,19 @@ const mapStackError = (error: StackError) => reason: error.operation === "open" || error.operation === "discover" || - error.operation === "definition" || - error.operation === "getComposition" + error.operation === "definition" ? "invalid-config" : "runtime", message: error.message, suggestion: error.operation === "open" || error.operation === "discover" || - error.operation === "definition" || - error.operation === "getComposition" + error.operation === "definition" ? "Inspect the saved stack state under $SUPABASE_HOME/stacks." : "Retry the command and use --debug if the stack remains unavailable.", cause: error, }); -const serviceState = (observation: Observation | undefined): ServiceReport["state"] => { - if (observation === undefined) return "unavailable"; - if (observation.health === "unhealthy") return "unhealthy"; - if (observation.lifecycle === "stopped") { - if (observation.error?.operation === "exit") return "exited"; - return observation.wakeEnabled ? "sleeping" : "stopped"; - } - return observation.lifecycle; -}; - const serviceReport = ({ instance, observation, error }: ObservedService): ServiceReport => ({ id: instance.id, service: instance.service, @@ -161,18 +148,6 @@ const aggregateReadiness = ( : "starting"; }; -const endpointFor = ( - services: ReadonlyArray<ServiceReport>, - members: ReadonlyArray<{ readonly id: string }>, - service: ServiceCreation["service"], - endpoint: string, -) => { - const memberIds = new Set(members.map(({ id }) => id)); - return services.find((entry) => memberIds.has(entry.id) && entry.service === service)?.endpoints[ - endpoint - ]; -}; - const reportFor = ( definition: { readonly id: string; @@ -187,15 +162,11 @@ const reportFor = ( observed: ReadonlyArray<ObservedService>, members: ReadonlyArray<{ readonly id: string; readonly activation: string }>, configDrift: StackReport["config_drift"], + env: StackReport["env"], ): StackReport => { const services = observed.map(serviceReport); - const endpoints = Object.fromEntries( - services.flatMap((service) => - Object.entries(service.endpoints).map(([name, endpoint]) => [ - `${service.service}.${name}`, - endpoint, - ]), - ), + const endpoints = stackEndpoints( + observed.map(({ instance, observation }) => ({ service: instance.service, observation })), ); return { identity: { @@ -224,35 +195,59 @@ const reportFor = ( services, endpoints, config_drift: configDrift, + env, }; }; -const render = (report: StackReport): string => { - const lines = [ - `Stack ${report.identity.name} (${report.identity.id})`, - `Project: ${report.identity.project_root}`, - `Branch: ${report.identity.branch_context}`, - `Runtime: ${report.runtime}`, - `Owner: ${report.owner}`, - `Lifecycle: ${report.lifecycle ?? "unavailable"}`, - `Readiness: ${report.readiness}`, - "Services:", - ]; - const members = new Map(report.composition.members.map((member) => [member.id, member])); - for (const service of report.services) { - const details = [service.state, `lifecycle=${service.lifecycle ?? "unavailable"}`]; - const member = members.get(service.id); - details.push(member === undefined ? "standalone" : `activation=${member.activation}`); - if (service.health !== null) details.push(`health=${service.health}`); - lines.push(` ${service.service} (${service.id}): ${details.join(", ")}`); - for (const [name, endpoint] of Object.entries(service.endpoints)) - lines.push(` ${name}: ${endpoint.url}`); - if (service.error !== undefined) lines.push(` error: ${service.error}`); +const readinessColor = (readiness: StackReport["readiness"]) => { + switch (readiness) { + case "ready": + return green(readiness, process.stdout); + case "starting": + return yellow(readiness, process.stdout); + case "unhealthy": + return red(readiness, process.stdout); + case "sleeping": + case "stopped": + case "unavailable": + return gray(readiness, process.stdout); } - lines.push(`Config drift: ${report.config_drift.status}`); - lines.push(` ${report.config_drift.message}`); - for (const path of report.config_drift.paths ?? []) lines.push(` ${path}`); - return `${lines.join("\n")}\n`; +}; + +const renderDrift = (drift: StackReport["config_drift"]): ReadonlyArray<string> => + drift.status === "changed" + ? [ + yellow(drift.message, process.stdout), + ...(drift.paths ?? []).map((path) => ` ${path}`), + gray( + "Run supabase stack stop, then supabase stack start to apply the changes.", + process.stdout, + ), + ] + : [gray(drift.message, process.stdout)]; + +const render = ( + report: StackReport, + observed: ReadonlyArray<ObservedService>, + members: ReadonlyArray<{ readonly id: string; readonly activation: string }>, + credentials: StackCredentials | undefined, +): string => { + const activation = new Map(members.map((member) => [member.id, member.activation])); + const header = `${bold(`Stack ${report.identity.name}`, process.stdout)} · ${readinessColor(report.readiness)} · ${report.runtime} · ${gray(report.identity.project_root, process.stdout)}`; + const owner = + report.owner === "unavailable" + ? [gray("The stack owner is not running. Run supabase stack start.", process.stdout)] + : []; + const summary = renderStackSummary( + observed.map(({ instance, observation, error }) => ({ + service: instance.service, + observation, + activation: activation.get(instance.id), + error: error?.message ?? observation?.error?.message, + })), + credentials, + ); + return `${[header, ...owner].join("\n")}\n\n${summary}\n${renderDrift(report.config_drift).join("\n")}\n`; }; const findTarget = Effect.fn("experimental.stack.status.findTarget")(function* ( @@ -260,10 +255,7 @@ const findTarget = Effect.fn("experimental.stack.status.findTarget")(function* ( name: string | undefined, id: string | undefined, ) { - const settings = yield* CommandSettings; - const path = yield* Path.Path; const resolver = yield* StackTargetResolver; - const api = yield* StackApi; const target = yield* resolver .resolve({ projectRoot, @@ -272,27 +264,17 @@ const findTarget = Effect.fn("experimental.stack.status.findTarget")(function* ( runtime: "auto", }) .pipe(Effect.mapError(mapTargetError)); - if (target.id === undefined) + if (target.id === undefined || target.definition === undefined) return yield* new StackCommandStatusError({ reason: "not-found", message: "No managed stack exists for the selected project.", suggestion: "Run supabase stack start first.", }); - const discovered = yield* api - .discover({ stateRoot: path.join(settings.supabaseHome, "stacks") }) - .pipe(Effect.mapError(mapStackError)); - const found = discovered.find(({ definition }) => definition.id === target.id); - if (found === undefined) - return yield* new StackCommandStatusError({ - reason: "not-found", - message: `Stack ${target.id} was not found.`, - suggestion: "Choose an existing --stack-id, or run supabase stack start first.", - }); return { ...target, id: target.id, - definition: found.definition, - owner: found.host === undefined ? ("unavailable" as const) : ("reachable" as const), + definition: target.definition, + owner: target.hostRunning ? ("reachable" as const) : ("unavailable" as const), }; }); @@ -313,94 +295,53 @@ const observe = (stack: Stack, owner: StackReport["owner"]) => return { observed, members: composition.members }; }); -const compareConfig = ( - creations: ReadonlyArray<ServiceCreationInput>, - observed: ReadonlyArray<ObservedService>, - members: ReadonlyArray<{ readonly id: string }>, -): StackReport["config_drift"] => { - const paths: string[] = []; - const memberIds = new Set(members.map(({ id }) => id)); - for (const creation of creations) { - const service = observed.find( - ({ instance }) => memberIds.has(instance.id) && instance.service === creation.service, - ); - if (service === undefined) continue; - if (service.observation === undefined) { - if (service?.error !== undefined) - return { - status: "unavailable", - message: `Configuration could not be compared because ${creation.service} status failed: ${service.error.message}`, - }; - paths.push(`services.${creation.service}`); - continue; - } - if ( - creation.service === "database" && - service.observation.config.service === "database" && - creation.config.version !== service.observation.config.config.version - ) - paths.push(`services.database.config.version`); - if (creation.endpoints === undefined) continue; - for (const name of Object.keys(creation.endpoints)) { - const endpoint = Reflect.get(creation.endpoints, name); - if ( - typeof endpoint !== "object" || - endpoint === null || - !("port" in endpoint) || - typeof endpoint.port !== "number" - ) - continue; - const savedEndpoints = service.observation.config.endpoints; - const actual = savedEndpoints === undefined ? undefined : Reflect.get(savedEndpoints, name); - if ( - typeof actual !== "object" || - actual === null || - !("port" in actual) || - actual.port !== endpoint.port - ) - paths.push(`services.${creation.service}.endpoints.${name}`); - } - } +const driftFrom = (planned: ReadonlyArray<PlannedInstance>): StackReport["config_drift"] => { + const paths = planned.flatMap((entry) => + !entry.member || entry.change === "unchanged" + ? [] + : entry.paths.map((path) => `services.${entry.service}.${path}`), + ); return paths.length === 0 ? { status: "unchanged", - message: - "Configured database version and explicit endpoint ports match existing composition members.", + message: "Project configuration matches the saved composition members.", } : { status: "changed", - message: `${paths.length} configured service value${paths.length === 1 ? "" : "s"} differ from the saved stack.`, + message: `${paths.length} configured service ${paths.length === 1 ? "value differs" : "values differ"} from the saved stack.`, paths, }; }; -const configDrift = ( - projectRoot: string, - stackId: string, - observed: ReadonlyArray<ObservedService>, - members: ReadonlyArray<{ readonly id: string }>, -) => +const unavailableDrift = (message: string): StackReport["config_drift"] => ({ + status: "unavailable", + message, +}); + +const configDrift = (stack: Stack, projectRoot: string, functionsIsMember: boolean) => Effect.gen(function* () { - const memberIds = new Set(members.map(({ id }) => id)); - const database = observed.find( - ({ instance }) => memberIds.has(instance.id) && instance.service === "database", - ); - const databaseObservation = database?.observation; - if (databaseObservation?.config.service !== "database") - return { - status: "unavailable" as const, - message: "Configuration could not be compared without the saved database observation.", - }; const loaded = yield* loadStackConfig(projectRoot); - const creations = yield* loaded.creations(stackId); - return compareConfig(creations, observed, members); + const creations = yield* loaded.creations(stack.id); + // Drift ignores non-members, so a Functions dotenv only matters when Functions is a member. + const requested = functionsIsMember + ? yield* Effect.forEach(creations, withProjectFunctionsEnv) + : creations; + return driftFrom(yield* stack.composition.plan(requested)); }).pipe( - Effect.catchTag("StackConfigError", (error) => - Effect.succeed({ - status: "unavailable" as const, - message: `Project configuration could not be compared: ${error.message}`, - }), - ), + Effect.catchTags({ + StackConfigError: (error) => + Effect.succeed( + unavailableDrift(`Project configuration could not be compared: ${error.message}`), + ), + StackFunctionsEnvError: (error) => + Effect.succeed( + unavailableDrift(`Project configuration could not be compared: ${error.message}`), + ), + StackError: (error) => + Effect.succeed( + unavailableDrift(`Saved configuration could not be compared: ${error.message}`), + ), + }), ); export const stackStatus = Effect.fn("experimental.stack.status")(function* ( @@ -447,8 +388,11 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( .open({ ...locations, id: target.id }) .pipe(Effect.mapError(mapStackError)); const observed = yield* observe(stack, target.owner); + const memberIds = new Set(observed.members.map(({ id }) => id)); + const members = observed.observed.flatMap(({ instance, observation }) => + memberIds.has(instance.id) ? [{ service: instance.service, observation }] : [], + ); if (flags.env) { - const memberIds = new Set(observed.members.map(({ id }) => id)); const database = observed.observed.find( ({ instance }) => memberIds.has(instance.id) && instance.service === "database", ); @@ -464,8 +408,7 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( message: "The stack owner or primary database is unavailable for environment export.", suggestion: "Run supabase stack start first.", }); - const databaseConfig = databaseObservation.config; - if (databaseConfig.service !== "database") + if (databaseObservation.config.service !== "database") return yield* new StackCommandStatusError({ reason: "lifecycle", message: "The primary database configuration is unavailable for environment export.", @@ -478,52 +421,31 @@ export const stackStatus = Effect.fn("experimental.stack.status")(function* ( message: "The stack's active credentials are unavailable for environment export.", suggestion: "Run supabase stack start first.", }); - const sql = databaseObservation.endpoints.find(({ name }) => name === "sql"); - const databaseUrl = - sql === undefined - ? undefined - : toPostgresURL({ - host: sql.host, - port: sql.port, - user: "supabase_admin", - password: Redacted.value(databaseConfig.config.databasePassword), - database: "postgres", - }); - const services = observed.observed.map(serviceReport); - const endpoints = { - ...(endpointFor(services, observed.members, "rest", "http") === undefined - ? {} - : { api: endpointFor(services, observed.members, "rest", "http") }), - ...(endpointFor(services, observed.members, "studio", "http") === undefined - ? {} - : { studio: endpointFor(services, observed.members, "studio", "http") }), - ...(endpointFor(services, observed.members, "mail", "http") === undefined - ? {} - : { mailUi: endpointFor(services, observed.members, "mail", "http") }), - }; - const values = stackEnvValues( - { endpoints, credentials: identity }, - databaseUrl === undefined ? {} : { databaseUrl }, - envNames, - ); + const connections = stackConnections(members); + const values = stackEnvValues(connections, identity, envNames); if (output.format === "text") yield* output.raw(yield* encodeStackEnv(values)); else yield* output.result(values); return; } const config = yield* configDrift( + stack, target.definition.identity.projectRoot, - target.definition.id, - observed.observed, - observed.members, + observed.observed.some( + ({ instance }) => memberIds.has(instance.id) && instance.service === "functions", + ), ); + const credentials = yield* summaryCredentials(stack.credentials.get, output.warn); + const connections = stackConnections(members); const report = reportFor( target.definition, target.owner, observed.observed, observed.members, config, + connectionEnv(connections, credentials), ); - if (output.format === "text") yield* output.raw(render(report)); + if (output.format === "text") + yield* output.raw(render(report, observed.observed, observed.members, credentials)); else yield* output.success("", report); }); return yield* body.pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts index 3de8717b86..ffa4890839 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.integration.test.ts @@ -1,8 +1,9 @@ import { BunServices } from "@effect/platform-bun"; import { expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Stream } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Schema, Stream } from "effect"; import { type Observation, + type PlannedInstance, type ServiceCreation, type StackCredentials, StackError, @@ -48,9 +49,40 @@ const database: ServiceCreation = { }; const rest: ServiceCreation = { service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: 54321 } }, }; +const auth: ServiceCreation = { + service: "auth", + config: { jwtSecret }, + endpoints: { http: { port: 54321 } }, +}; +const studio: ServiceCreation = { + service: "studio", + config: {}, + endpoints: { http: { port: 54323 } }, +}; +const functions: ServiceCreation = { + service: "functions", + config: { + functionsRoot: "/project/supabase/functions", + bootstrap: "export default {};", + verifyJwt: true, + }, + endpoints: { http: { port: 54321 } }, +}; +const storage = (s3ProtocolEnabled: boolean): ServiceCreation => ({ + service: "storage", + config: { + filePath: "/project/supabase/.temp/stack-uploads", + jwtSecret, + s3ProtocolEnabled, + s3AccessKeyId: "local-access-key", + s3SecretAccessKey: "local-secret-key", + s3Region: "local", + }, + endpoints: { http: { port: 54321 } }, +}); const flags = (input?: Partial<StackStatusFlags>): StackStatusFlags => ({ stack: Option.none(), stackId: Option.none(), @@ -119,6 +151,8 @@ const makeStack = ( services: ReadonlyArray<StackInstance>, members: ReadonlyArray<{ readonly id: string; readonly activation: "eager" | "lazy" }>, credentials: StackCredentials = savedCredentials, + planned: ReadonlyArray<PlannedInstance> = [], + credentialsUnavailable = false, ): OpenedStack => ({ id: stackId, services: { @@ -126,8 +160,13 @@ const makeStack = ( get: (_id) => Effect.die("unused"), list: Effect.succeed([...services]), }, - credentials: { get: Effect.succeed(credentials) }, + credentials: { + get: credentialsUnavailable + ? Effect.fail(new StackError({ operation: "credentials", message: "owner unreachable" })) + : Effect.succeed(credentials), + }, composition: { + plan: () => Effect.succeed(planned), supabase: (_services, _options) => Effect.die("unused"), configure: (_config) => Effect.die("unused"), describe: Effect.succeed({ members: [...members], dependencies: [] }), @@ -137,7 +176,7 @@ const makeStack = ( }, stop: Effect.die("unused"), destroy: Effect.die("unused"), - tools: { + commands: { run: (_tool, _options) => Effect.die("unused"), }, }); @@ -147,8 +186,10 @@ const runStatus = (input: { readonly members?: ReadonlyArray<{ readonly id: string; readonly activation: "eager" | "lazy" }>; readonly reachable?: boolean; readonly outputFormat?: StatusOutputFormat; - readonly config?: "missing" | "invalid" | "explicit"; + readonly config?: "missing" | "invalid" | "explicit" | "multiline-functions-env"; readonly stackCredentials?: StackCredentials; + readonly credentialsUnavailable?: boolean; + readonly planned?: ReadonlyArray<PlannedInstance>; readonly flags?: StackStatusFlags; }) => Effect.gen(function* () { @@ -165,11 +206,24 @@ const runStatus = (input: { 'project_id = "status-test"\n[db]\nport = 54322\n', ); } + if (input.config === "multiline-functions-env") { + yield* fs.makeDirectory(`${root}/supabase/functions`, { recursive: true }); + yield* fs.writeFileString( + `${root}/supabase/config.toml`, + 'project_id = "status-test"\n[edge_runtime]\nenabled = true\n', + ); + yield* fs.writeFileString( + `${root}/supabase/functions/.env`, + 'PRIVATE_KEY="-----BEGIN KEY-----\nsecret\n-----END KEY-----"\n', + ); + } const projectRoot = root; const stack = makeStack( input.services, input.members ?? input.services.map(({ id }) => ({ id, activation: "lazy" as const })), input.stackCredentials, + input.planned, + input.credentialsUnavailable ?? false, ); const definition = { id: stackId, @@ -184,27 +238,14 @@ const runStatus = (input: { creation: service === "database" ? database : rest, })), composition: { members: input.members ?? [], dependencies: [] }, + lifetime: "detached" as const, ports: [], }; const api = Layer.succeed(StackApi, { create: () => Effect.die("create must not run"), open: () => Effect.succeed(stack), - discover: () => - Effect.succeed([ - { - definition, - host: - input.reachable === false - ? undefined - : { - stackId, - identity: definition.identity, - pid: 123, - port: 4567, - }, - }, - ]), - resolveIdentity: () => Effect.succeed(definition.identity), + discover: () => Effect.die("discover must not run"), + find: () => Effect.die("find must not run"), }); const resolver = Layer.succeed(StackTargetResolver, { resolve: (target) => @@ -212,7 +253,8 @@ const runStatus = (input: { projectRoot: target.projectRoot, id: stackId, runtime: "native" as const, - hostRunning: false, + definition, + hostRunning: input.reachable !== false, }), }); const out = mockOutput({ format: input.outputFormat ?? "text" }); @@ -230,16 +272,11 @@ const runStatus = (input: { return { effect, out, root }; }).pipe(Effect.provide(BunServices.layer)); -it.live("reports observed lifecycle and health without requesting credentials", () => +it.live("renders connections and a services summary without internal IDs", () => Effect.gen(function* () { const databaseCalls = { value: 0 }; const restCalls = { value: 0 }; const authCalls = { value: 0 }; - const auth: ServiceCreation = { - service: "auth", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, - endpoints: { http: { port: 54325 } }, - }; const services = [ makeService({ id: "database-id", @@ -270,14 +307,27 @@ it.live("reports observed lifecycle and health without requesting credentials", }), }), ]; - const run = yield* runStatus({ services, reachable: true }); + const run = yield* runStatus({ + services, + members: [ + { id: "database-id", activation: "eager" }, + { id: "rest-id", activation: "lazy" }, + { id: "auth-id", activation: "lazy" }, + ], + reachable: true, + }); yield* run.effect; - expect(run.out.stdoutText).toContain("Owner: reachable"); - expect(run.out.stdoutText).toContain("database (database-id): running"); - expect(run.out.stdoutText).toContain("rest (rest-id): sleeping"); - expect(run.out.stdoutText).toContain("auth (auth-id): unhealthy"); - expect(run.out.stdoutText).toContain("health=unhealthy"); - expect(run.out.stdoutText).toContain("Readiness: unhealthy"); + const text = run.out.stdoutText; + expect(text).toMatch(/^Stack status-stack · unhealthy · native · /u); + expect(text).toMatch(/Project URL │ http:\/\/127\.0\.0\.1:54321 +│/u); + expect(text).toContain("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + expect(text).toMatch(/Publishable │ saved-publishable-key +│/u); + expect(text).toMatch(/database +│ running · healthy · eager +│/u); + expect(text).toMatch(/rest +│ sleeping · starts on first request +│/u); + expect(text).toMatch(/auth +│ unhealthy · lazy +│/u); + expect(text).toContain("Project configuration matches the saved composition members."); + expect(text).not.toContain("database-id"); + expect(text).not.toContain(stackId); expect(databaseCalls.value).toBe(1); expect(restCalls.value).toBe(1); expect(authCalls.value).toBe(1); @@ -380,57 +430,281 @@ it.live("reports stopped readiness without treating unbound endpoints as drift", }), ); -it.live("does not report port drift when a stopped service has no live binding", () => +it.live("reports the planned differences of composition members as drift", () => Effect.gen(function* () { - const run = yield* runStatus({ - services: [ + const drifted = (outputFormat: StatusOutputFormat) => + runStatus({ + services: [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "stopped", + wakeEnabled: false, + }), + }), + ], + members: [{ id: "database-id", activation: "eager" }], + planned: [ + { + id: "database-id", + service: "database", + member: true, + change: "incompatible", + paths: ["endpoints.sql.port"], + }, + { + id: "standalone-rest", + service: "rest", + member: false, + change: "changed", + paths: ["config.maxRows"], + }, + ], + config: "explicit", + reachable: false, + outputFormat, + }); + const json = yield* drifted("json"); + yield* json.effect; + expect(json.out.messages.find((message) => message.type === "success")?.data).toMatchObject({ + config_drift: { status: "changed", paths: ["services.database.endpoints.sql.port"] }, + }); + const text = yield* drifted("text"); + yield* text.effect; + expect(text.out.stdoutText).toContain( + "1 configured service value differs from the saved stack.\n services.database.endpoints.sql.port\nRun supabase stack stop, then supabase stack start to apply the changes.\n", + ); + }), +); + +it.live( + "reports the gateway-served MCP endpoint at the API URL, with no synthetic endpoint entry", + () => + Effect.gen(function* () { + const services = [ makeService({ id: "database-id", creation: database, statusCalls: { value: 0 }, observation: makeObservation("database-id", database, { - lifecycle: "stopped", + lifecycle: "running", + health: "healthy", wakeEnabled: false, + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], }), }), - ], - config: "explicit", + makeService({ + id: "auth-id", + creation: auth, + statusCalls: { value: 0 }, + observation: makeObservation("auth-id", auth, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + makeService({ + id: "studio-id", + creation: studio, + statusCalls: { value: 0 }, + observation: makeObservation("studio-id", studio, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54323 }], + }), + }), + ]; + const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); + yield* report.effect; + const result = report.out.messages.find((message) => message.type === "success")?.data as { + endpoints: Readonly<Record<string, unknown>>; + env: Readonly<Record<string, string>>; + }; + expect(result).toMatchObject({ + endpoints: { "studio.http": { url: "http://127.0.0.1:54323" } }, + env: { MCP_URL: "http://127.0.0.1:54321/mcp" }, + }); + expect(result.endpoints).not.toHaveProperty("studio.mcp"); + const env = yield* runStatus({ services, reachable: true, flags: flags({ env: true }) }); + yield* env.effect; + expect(env.out.stdoutText).toContain("MCP_URL='http://127.0.0.1:54321/mcp'"); + expect(env.out.stdoutText).toContain("STUDIO_URL='http://127.0.0.1:54323'"); + expect(env.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); + }), +); + +it.live("omits MCP_URL when no member exposes the shared API listener", () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + wakeEnabled: false, + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "studio-id", + creation: studio, + statusCalls: { value: 0 }, + observation: makeObservation("studio-id", studio, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54323 }], + }), + }), + ]; + const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); + yield* report.effect; + const result = report.out.messages.find((message) => message.type === "success")?.data as { + env: Readonly<Record<string, string>>; + }; + expect(result.env).not.toHaveProperty("MCP_URL"); + }), +); + +it.live("omits MCP_URL when Studio is not a composition member", () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + wakeEnabled: false, + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "auth-id", + creation: auth, + statusCalls: { value: 0 }, + observation: makeObservation("auth-id", auth, { + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + ]; + const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); + yield* report.effect; + const result = report.out.messages.find((message) => message.type === "success")?.data as { + env: Readonly<Record<string, string>>; + }; + expect(result.env).not.toHaveProperty("MCP_URL"); + }), +); + +const storageServices = (creation: ServiceCreation) => { + return [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "storage-id", + creation, + statusCalls: { value: 0 }, + observation: makeObservation("storage-id", creation, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + ]; +}; + +it.live("reports the Storage S3 endpoint and access keys through the gateway", () => + Effect.gen(function* () { + const text = yield* runStatus({ services: storageServices(storage(true)), reachable: true }); + yield* text.effect; + expect(text.out.stdoutText).toMatch(/URL +│ http:\/\/127\.0\.0\.1:54321\/storage\/v1\/s3 +│/u); + expect(text.out.stdoutText).toMatch(/Access Key +│ local-access-key +│/u); + expect(text.out.stdoutText).toMatch(/Secret Key +│ local-secret-key +│/u); + expect(text.out.stdoutText).toMatch(/Region +│ local +│/u); + const env = yield* runStatus({ + services: storageServices(storage(true)), reachable: true, - outputFormat: "json", + flags: flags({ env: true }), }); - yield* run.effect; - const result = run.out.messages.find((message) => message.type === "success")?.data; - expect(result).toMatchObject({ config_drift: { status: "unchanged" } }); + yield* env.effect; + expect(env.out.stdoutText).toContain("STORAGE_S3_URL='http://127.0.0.1:54321/storage/v1/s3'"); + expect(env.out.stdoutText).toContain("S3_PROTOCOL_ACCESS_KEY_ID='local-access-key'"); + expect(env.out.stdoutText).toContain("S3_PROTOCOL_ACCESS_KEY_SECRET='local-secret-key'"); + expect(env.out.stdoutText).toContain("S3_PROTOCOL_REGION='local'"); }), ); -it.live("reports changed explicit ports even while a service is stopped", () => +it.live("omits Storage S3 details when the S3 protocol is disabled", () => Effect.gen(function* () { - const changed = { ...database, endpoints: { sql: { port: 54329 } } }; - const run = yield* runStatus({ - services: [ - makeService({ - id: "database-id", - creation: changed, - statusCalls: { value: 0 }, - observation: makeObservation("database-id", changed, { - lifecycle: "stopped", - wakeEnabled: false, - }), - }), - ], - config: "explicit", + const text = yield* runStatus({ services: storageServices(storage(false)), reachable: true }); + yield* text.effect; + expect(text.out.stdoutText).toMatch(/Project URL +│ http:\/\/127\.0\.0\.1:54321 +│/u); + expect(text.out.stdoutText).not.toContain("Storage (S3)"); + const env = yield* runStatus({ + services: storageServices(storage(false)), reachable: true, - outputFormat: "json", + flags: flags({ env: true }), }); - yield* run.effect; - const result = run.out.messages.find((message) => message.type === "success")?.data; - expect(result).toMatchObject({ - config_drift: { status: "changed", paths: ["services.database.endpoints.sql"] }, + yield* env.effect; + expect(env.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); + expect(env.out.stdoutText).not.toContain("S3_PROTOCOL_"); + expect(env.out.stdoutText).not.toContain("STORAGE_S3_URL"); + }), +); + +it.live("omits Storage S3 details for a Storage member saved without S3 keys", () => + Effect.gen(function* () { + const env = yield* runStatus({ + services: storageServices({ + service: "storage", + config: { filePath: "/project/supabase/.temp/stack-uploads", jwtSecret }, + endpoints: { http: { port: 54321 } }, + }), + reachable: true, + flags: flags({ env: true }), }); + yield* env.effect; + expect(env.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); + expect(env.out.stdoutText).not.toContain("S3_PROTOCOL_"); + expect(env.out.stdoutText).not.toContain("STORAGE_S3_URL"); }), ); +for (const { functionsMember, status } of [ + { functionsMember: false, status: "unchanged" }, + { functionsMember: true, status: "unavailable" }, +] as const) + it.live( + `reports ${status} drift for a multiline Functions dotenv when Functions is ${functionsMember ? "" : "not "}a member`, + () => + Effect.gen(function* () { + const services = [ + makeService({ id: "database-id", creation: database, statusCalls: { value: 0 } }), + makeService({ id: "functions-id", creation: functions, statusCalls: { value: 0 } }), + ]; + const run = yield* runStatus({ + services, + members: [ + { id: "database-id", activation: "eager" }, + ...(functionsMember ? [{ id: "functions-id", activation: "eager" as const }] : []), + ], + config: "multiline-functions-env", + reachable: false, + outputFormat: "json", + }); + yield* run.effect; + const result = run.out.messages.find((message) => message.type === "success")?.data; + expect(result).toMatchObject({ config_drift: { status } }); + }), + ); + it.live("reports unavailable owner and does not query service status", () => Effect.gen(function* () { const statusCalls = { value: 0 }; @@ -443,9 +717,9 @@ it.live("reports unavailable owner and does not query service status", () => ]; const run = yield* runStatus({ services, reachable: false }); yield* run.effect; - expect(run.out.stdoutText).toContain("Owner: unavailable"); - expect(run.out.stdoutText).toContain("Lifecycle: unavailable"); - expect(run.out.stdoutText).toContain("database (database-id): unavailable"); + expect(run.out.stdoutText).toMatch(/^Stack status-stack · unavailable · /u); + expect(run.out.stdoutText).toContain("The stack owner is not running."); + expect(run.out.stdoutText).toMatch(/database +│ unavailable · lazy +│/u); expect(statusCalls.value).toBe(0); }), ); @@ -503,7 +777,7 @@ it.live("exports saved credentials only for a running database", () => }); yield* run.effect; expect(run.out.stdoutText).toContain( - "DB_URL='postgresql://supabase_admin:postgres@127.0.0.1:54322/postgres?connect_timeout=10'", + "DB_URL='postgresql://postgres:postgres@127.0.0.1:54322/postgres'", ); expect(run.out.stdoutText).toContain("API_URL='http://127.0.0.1:54321'"); expect(run.out.stdoutText).toContain("ANON_KEY='saved-anon-token'"); @@ -513,6 +787,112 @@ it.live("exports saved credentials only for a running database", () => }), ); +it.live("derives DB_URL using the postgres role and a non-default saved password", () => + Effect.gen(function* () { + const password = "p@ss w/ord!"; + const customDatabase: ServiceCreation = { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make(password), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: 54322 } }, + }; + const services = [ + makeService({ + id: "database-id", + creation: customDatabase, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", customDatabase, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + ]; + const run = yield* runStatus({ services, reachable: true, flags: flags({ env: true }) }); + yield* run.effect; + expect(run.out.stdoutText).toContain( + `DB_URL='postgresql://postgres:${encodeURIComponent(password)}@127.0.0.1:54322/postgres'`, + ); + }), +); + +it.live("the status JSON env map equals the status --env export for the same stack", () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + makeService({ + id: "rest-id", + creation: rest, + statusCalls: { value: 0 }, + observation: makeObservation("rest-id", rest, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "http", protocol: "http", host: "127.0.0.1", port: 54321 }], + }), + }), + ]; + const report = yield* runStatus({ services, reachable: true, outputFormat: "json" }); + yield* report.effect; + const result = report.out.messages.find((message) => message.type === "success")?.data as { + env: Readonly<Record<string, string>>; + }; + const exported = yield* runStatus({ + services, + reachable: true, + outputFormat: "json", + flags: flags({ env: true }), + }); + yield* exported.effect; + const exportedValues = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), + )(exported.out.stdoutText); + expect(result.env).toEqual(exportedValues); + expect(result.env.REST_URL).toBe("http://127.0.0.1:54321/rest/v1"); + }), +); + +it.live("status JSON env degrades to what's available when credentials are unreachable", () => + Effect.gen(function* () { + const services = [ + makeService({ + id: "database-id", + creation: database, + statusCalls: { value: 0 }, + observation: makeObservation("database-id", database, { + lifecycle: "running", + health: "healthy", + endpoints: [{ name: "sql", protocol: "tcp", host: "127.0.0.1", port: 54322 }], + }), + }), + ]; + const run = yield* runStatus({ + services, + reachable: true, + outputFormat: "json", + credentialsUnavailable: true, + }); + yield* run.effect; + const result = run.out.messages.find((message) => message.type === "success")?.data as { + env: Readonly<Record<string, string>>; + }; + expect(result.env).not.toHaveProperty("PUBLISHABLE_KEY"); + expect(result.env.DB_URL).toContain("127.0.0.1:54322"); + }), +); + it.live("uses only the composition database for environment export", () => Effect.gen(function* () { const shadow = makeObservation("shadow-db", database, { diff --git a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md index df78baab24..a2bd679794 100644 --- a/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/stack/stop/SIDE_EFFECTS.md @@ -19,8 +19,12 @@ after that preflight, the shutdown error remains a failure. Text confirms each successful shutdown and identifies each unavailable owner. JSON and stream-json success data contain `stopped` and `unavailable` ID arrays. A missing default selection reports `found: false`; an unknown explicit name or -ID fails. `--all` attempts every selected reachable owner and reports failures -with their IDs. Corrupt registry state fails discovery without partial results. +ID fails. A single selection reads only the selected stack's state document; an +unreadable document fails the selection instead of being reported as missing. +`--all` attempts every selected reachable owner and reports failures with their +IDs. It skips state entries that cannot be read or decoded with a warning on +stderr identifying each stack; only a failure to read the stacks directory +itself fails discovery. ## Files and network diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts index c76b9730d1..93477c1335 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.handler.ts @@ -79,7 +79,31 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: }), ), ); - if (!all && target?.id === undefined) { + const locations = { + stateRoot: path.join(settings.supabaseHome, "stacks"), + cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), + }; + const selected = + target === undefined + ? yield* api + .discover({ + ...locations, + onInvalidState: (id, error) => + output.raw(`Warning: skipping invalid stack ${id}: ${error.message}\n`, "stderr"), + }) + .pipe( + Effect.map((discovered) => + discovered.map(({ definition, host }) => ({ + id: definition.id, + hostRunning: host !== undefined, + })), + ), + Effect.mapError(stopError), + ) + : target.id === undefined + ? undefined + : [{ id: target.id, hostRunning: target.hostRunning }]; + if (selected === undefined) { if (Option.isSome(flags.stack)) return yield* new StackCommandStopError({ reason: "flags", @@ -88,35 +112,23 @@ export const stackStop = Effect.fn("experimental.stack.stop")(function* (flags: yield* output.success("No managed stack found for this context.", { found: false }); return; } - const locations = { - stateRoot: path.join(settings.supabaseHome, "stacks"), - cacheRoot: path.join(settings.supabaseHome, "cache", "stack"), - }; - const discovered = yield* api.discover(locations).pipe(Effect.mapError(stopError)); - const selected = all - ? discovered - : discovered.filter(({ definition }) => definition.id === target?.id); - if (!all && selected.length === 0) - return yield* new StackCommandStopError({ - reason: "flags", - message: "The selected stack no longer exists.", - }); const results = yield* Effect.forEach( selected, ({ - definition, - host, + id, + hostRunning, }): Effect.Effect<{ readonly id: string; readonly result: Result.Result<"stopped" | "unavailable", StackError>; }> => - host === undefined - ? Effect.succeed({ id: definition.id, result: Result.succeed("unavailable" as const) }) - : api.open({ ...locations, id: definition.id }).pipe( + !hostRunning + ? Effect.succeed({ id, result: Result.succeed("unavailable" as const) }) + : api.open({ ...locations, id }).pipe( Effect.flatMap((stack) => stack.stop), + Effect.scoped, Effect.as("stopped" as const), Effect.result, - Effect.map((result) => ({ id: definition.id, result })), + Effect.map((result) => ({ id, result })), ), ); const failures = results.flatMap(({ id, result }) => diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts index d3a2ce6a7f..6648036cf2 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.integration.test.ts @@ -76,6 +76,31 @@ describe("stack stop", () => { }).pipe(Effect.provide(live)), ); + it.live.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "stop all continues past siblings whose state cannot be decoded or accessed and warns about them", + () => + Effect.gen(function* () { + const f = yield* fixture(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const broken = path.join(f.locations.stateRoot, "broken"); + yield* fs.makeDirectory(broken); + yield* fs.writeFileString(path.join(broken, "state.json"), "{broken"); + const locked = path.join(f.locations.stateRoot, "locked"); + yield* fs.makeDirectory(locked); + yield* Effect.acquireRelease(fs.chmod(locked, 0o000), () => + fs.chmod(locked, 0o700).pipe(Effect.orDie), + ); + yield* stackStop({ ...flags(), all: Option.some(true) }).pipe(Effect.provide(f.layer)); + expect(f.output.stdoutText.match(/workload state is unavailable/g)).toHaveLength(1); + expect(f.output.stderrText).toContain("Warning: skipping invalid stack broken"); + expect(f.output.stderrText).toContain("Warning: skipping invalid stack locked"); + expect(f.output.stdoutText).not.toContain("Warning"); + expect(yield* fs.readFileString(path.join(broken, "state.json"))).toBe("{broken"); + expect(f.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(live)), + ); + it.live("surfaces a shutdown failure after a successful owner preflight", () => Effect.gen(function* () { const f = yield* fixture(); @@ -85,13 +110,19 @@ describe("stack stop", () => { StackApi, StackApi.of({ ...f.api, - discover: () => - Effect.succeed([ - { + find: () => + Effect.succeed( + Option.some({ ...saved, - host: { stackId: f.stack.id, identity: saved.definition.identity, pid: 1, port: 1 }, - }, - ]), + host: { + stackId: f.stack.id, + identity: saved.definition.identity, + pid: 1, + port: 1, + release: "test", + }, + }), + ), open: () => Effect.succeed({ ...f.stack, diff --git a/apps/cli/src/commands/feedback/add/add.integration.test.ts b/apps/cli/src/commands/feedback/add/add.integration.test.ts index 018b56998c..9eb95a0151 100644 --- a/apps/cli/src/commands/feedback/add/add.integration.test.ts +++ b/apps/cli/src/commands/feedback/add/add.integration.test.ts @@ -171,7 +171,7 @@ function setupFeedbackHandler( base.layer, analytics.layer, processControl.layer, - commandRuntimeLayer(["feedback", "add"]), + commandRuntimeLayer(["feedback", "add"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed([...(opts.args ?? ["feedback", "add"])]) }), ); return { ...base, layer, analytics, processControl }; diff --git a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts index ffd821d53c..76caca4eaa 100644 --- a/apps/cli/src/commands/feedback/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/feedback/delete/delete.integration.test.ts @@ -152,7 +152,7 @@ function setupFeedbackDeleteHandler( base.layer, analytics.layer, processControl.layer, - commandRuntimeLayer(["feedback", "delete"]), + commandRuntimeLayer(["feedback", "delete"]).pipe(Layer.provide(BunServices.layer)), Stdio.layerTest({ args: Effect.succeed([...(opts.args ?? ["feedback", "delete", TOKEN])]) }), ); return { ...base, layer, analytics, processControl }; diff --git a/apps/cli/src/commands/feedback/feedback-task.ts b/apps/cli/src/commands/feedback/feedback-task.ts index d1222551a4..2858f70ef1 100644 --- a/apps/cli/src/commands/feedback/feedback-task.ts +++ b/apps/cli/src/commands/feedback/feedback-task.ts @@ -14,9 +14,9 @@ export const settleFeedbackTask = effect.pipe( Effect.onExit((exit) => Exit.isSuccess(exit) - ? task.clear() + ? task.clear : Cause.hasInterruptsOnly(exit.cause) - ? task.clear() + ? task.clear : task.fail(), ), ); diff --git a/apps/cli/src/commands/feedback/feedback-task.unit.test.ts b/apps/cli/src/commands/feedback/feedback-task.unit.test.ts index 10c7fed0db..508e2066aa 100644 --- a/apps/cli/src/commands/feedback/feedback-task.unit.test.ts +++ b/apps/cli/src/commands/feedback/feedback-task.unit.test.ts @@ -11,7 +11,7 @@ function recordingTask() { fail: () => Effect.sync(() => void settles.push("fail")), info: () => Effect.void, cancel: () => Effect.void, - clear: () => Effect.sync(() => void settles.push("clear")), + clear: Effect.sync(() => void settles.push("clear")), }; return { task, settles }; } diff --git a/apps/cli/src/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/commands/functions/delete/delete.integration.test.ts index 8cf9331f33..cad2932b70 100644 --- a/apps/cli/src/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Effect, Layer, Option, Stdio } from "effect"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -85,7 +86,7 @@ describe("functions delete", () => { cliSettings: mockCommandSettings({ workdir: tempRoot.current }), analytics, }), - commandRuntimeLayer(["functions", "delete"]), + commandRuntimeLayer(["functions", "delete"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed([ "functions", diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index 91f6a68894..7c9b82c61b 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { Config, ConfigProvider, @@ -595,7 +597,7 @@ describe("functions download", () => { // The unbundle tail is always the last 6 args regardless of whether // `--add-host` (Linux-only) was inserted before it. expect(runCommand?.args.slice(-6)).toEqual([ - `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, + `public.ecr.aws/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, "unbundle", "--eszip", "/root/eszips/output_hello-world.eszip", @@ -1724,7 +1726,7 @@ describe("functions download", () => { analytics, }), proxy.layer, - commandRuntimeLayer(["functions", "download"]), + commandRuntimeLayer(["functions", "download"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed([ "functions", @@ -2043,7 +2045,7 @@ describe("functions download", () => { const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( - `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, + `ghcr.io/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, ); expect( child.spawned.filter( @@ -2122,7 +2124,7 @@ describe("functions download", () => { ).toHaveLength(2); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( - `ghcr.io/${dockerfileServiceImage("edgeruntime")}`, + `ghcr.io/${dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)}`, ); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/list/list.handler.ts b/apps/cli/src/commands/functions/list/list.handler.ts index b48a2426a9..a2711b6ac5 100644 --- a/apps/cli/src/commands/functions/list/list.handler.ts +++ b/apps/cli/src/commands/functions/list/list.handler.ts @@ -86,7 +86,7 @@ export const functionsList = Effect.fn("functions.list")(function* (flags: Funct decode: true, }); } - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const { functions, isNil } = decodedFunctions.value; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts new file mode 100644 index 0000000000..d3c24be7c5 --- /dev/null +++ b/apps/cli/src/commands/functions/new/new.stack.e2e.test.ts @@ -0,0 +1,170 @@ +// Golden path for a stack-mode project: `functions new hello` scaffolds the template, `stack +// start` boots it, and a real request proves Edge Runtime resolves the template's import map. +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { homedir } from "node:os"; + +import { makeTempHome, runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; + +const nativeSupported = + (process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64")) || + (process.platform === "darwin" && process.arch === "arm64"); + +const NEW_TIMEOUT_MS = 60_000; +const START_TIMEOUT_MS = 15 * 60_000; +const CLEANUP_TIMEOUT_MS = 120_000; +// The template's first request resolves `jsr:@supabase/functions-js` and `npm:@supabase/server` +// over the network, on top of waking the lazily-started Functions member. +const INVOKE_TIMEOUT_MS = 5 * 60_000; +const SETUP_MARGIN_MS = 60_000; + +const minimalConfig = `project_id = "functions-new-stack-e2e" + +[experimental] +stack = true + +[api] +enabled = true + +[auth] +enabled = false + +[db.pooler] +enabled = false + +[edge_runtime] +enabled = true + +[realtime] +enabled = false + +[storage] +enabled = false + +[studio] +enabled = false + +[analytics] +enabled = false + +[local_smtp] +enabled = false +`; + +const StartResultSchema = Schema.Struct({ id: Schema.String }); +const StackEnvSchema = Schema.Struct({ API_URL: Schema.String, PUBLISHABLE_KEY: Schema.String }); +const HelloResponseSchema = Schema.Struct({ message: Schema.String }); + +const layer = Layer.mergeAll(BunServices.layer, FetchHttpClient.layer); + +describe("functions new (stack e2e)", () => { + for (const runtime of ["native", "docker"] as const) { + if (runtime === "native" && !nativeSupported) continue; + + it.live( + `serves the generated hello Function through ${runtime} stack start`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Linux Edge Runtime overlays /tmp with a private worker filesystem, so native functions need a visible host path. + const projectDir = yield* fs.makeTempDirectoryScoped({ + ...(runtime === "native" && process.platform === "linux" + ? { directory: homedir() } + : {}), + prefix: `functions-new-stack-${runtime}-`, + }); + const home = makeTempHome(); + yield* Effect.addFinalizer(() => Effect.sync(() => home[Symbol.dispose]())); + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectDir, "supabase", "config.toml"), + minimalConfig, + ); + + const created = yield* runSupabaseEffect( + ["functions", "new", "hello", "--output-format", "json"], + { cwd: projectDir, home: home.dir, exitTimeoutMs: NEW_TIMEOUT_MS }, + ); + expect(created.exitCode, `stdout:\n${created.stdout}\nstderr:\n${created.stderr}`).toBe( + 0, + ); + + let stackId: string | undefined; + yield* Effect.addFinalizer(() => + stackId === undefined + ? Effect.void + : runSupabaseEffect(["stack", "destroy", "--stack-id", stackId, "--yes"], { + cwd: projectDir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }).pipe( + Effect.orDie, + Effect.flatMap((destroyed) => + destroyed.exitCode === 0 + ? Effect.void + : Effect.die( + `stack destroy exited ${destroyed.exitCode}\nstdout:\n${destroyed.stdout}\nstderr:\n${destroyed.stderr}`, + ), + ), + ), + ); + + const started = yield* runSupabaseEffect( + ["stack", "start", "--runtime", runtime, "--output-format", "json"], + { + cwd: projectDir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: START_TIMEOUT_MS, + }, + ); + expect(started.exitCode, `stdout:\n${started.stdout}\nstderr:\n${started.stderr}`).toBe( + 0, + ); + const startResult = yield* Schema.decodeEffect(Schema.fromJsonString(StartResultSchema))( + started.stdout.trim(), + ); + stackId = startResult.id; + + const status = yield* runSupabaseEffect( + ["stack", "status", "--env", "--stack-id", stackId, "--output-format", "json"], + { + cwd: projectDir, + home: home.dir, + env: { SUPABASE_EXPERIMENTAL_STACK: "1" }, + exitTimeoutMs: CLEANUP_TIMEOUT_MS, + }, + ); + expect(status.exitCode, `stdout:\n${status.stdout}\nstderr:\n${status.stderr}`).toBe(0); + const env = yield* Schema.decodeEffect(Schema.fromJsonString(StackEnvSchema))( + status.stdout, + ); + + const http = yield* HttpClient.HttpClient; + const request = yield* HttpClientRequest.post(`${env.API_URL}/functions/v1/hello`, { + headers: { apikey: env.PUBLISHABLE_KEY }, + }).pipe(HttpClientRequest.bodyJson({ name: "e2e" })); + const response = yield* http.execute(request).pipe(Effect.timeout(INVOKE_TIMEOUT_MS)); + const text = yield* response.text; + expect(response.status, `response body:\n${text}`).toBe(200); + const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(HelloResponseSchema))( + text, + ); + expect(decoded).toEqual({ message: "Hello e2e!" }); + }).pipe(Effect.provide(layer)), + { + timeout: + NEW_TIMEOUT_MS + + START_TIMEOUT_MS + + CLEANUP_TIMEOUT_MS + + INVOKE_TIMEOUT_MS + + CLEANUP_TIMEOUT_MS + + SETUP_MARGIN_MS, + }, + ); + } +}); diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 6b5215fb2a..275063814f 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -50,6 +50,7 @@ import { } from "../../../shared/runtime/process-control.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { @@ -349,6 +350,8 @@ function mockFileWatcher(expectedPaths: ReadonlyArray<string> = []) { function mockDockerLogSpawner(behaviors: ReadonlyArray<LogProcessBehavior>) { const spawned: Array<{ command: string; args: ReadonlyArray<string> }> = []; let index = 0; + let liveHandles = 0; + let maxLiveHandles = 0; return { layer: Layer.succeed( @@ -364,6 +367,16 @@ function mockDockerLogSpawner(behaviors: ReadonlyArray<LogProcessBehavior>) { args: [...command.args], }; spawned.push(record); + yield* Effect.acquireRelease( + Effect.sync(() => { + liveHandles += 1; + maxLiveHandles = Math.max(maxLiveHandles, liveHandles); + }), + () => + Effect.sync(() => { + liveHandles -= 1; + }), + ); const behavior = behaviors[Math.min(index, behaviors.length - 1)] ?? {}; index += 1; if (behavior.onSpawn !== undefined) yield* behavior.onSpawn(); @@ -396,6 +409,9 @@ function mockDockerLogSpawner(behaviors: ReadonlyArray<LogProcessBehavior>) { get spawned() { return spawned; }, + get maxLiveHandles() { + return maxLiveHandles; + }, }; } @@ -1016,7 +1032,7 @@ describe("functions serve integration", () => { } expect(dockerRun.args).toContain( - yield* getRegistryImageUrl(dockerfileServiceImage("edgeruntime")), + yield* getRegistryImageUrl(dockerfileServiceImage("edgeruntime", yield* slimImagesEnabled)), ); expect(dockerRun.args.join(" ")).not.toContain(multilineValue); expect(dockerRun.args.join(" ")).not.toContain("EOF_ENV_0"); @@ -2629,6 +2645,7 @@ describe("functions serve integration", () => { expect(error.message).toContain("supabase_edge_runtime_test-project"); expect(error.message).toContain("5 times"); } + expect(childSpawner.maxLiveHandles).toBe(1); }); }, ); diff --git a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts index 8712a8089b..5bc41babe2 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.e2e.test.ts @@ -6,9 +6,8 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { homedir, tmpdir } from "node:os"; import { spawnSupabase } from "../../../../tests/helpers/cli.ts"; -import { bundleStackFunctionsServeMainTemplate } from "../../../command-internal/stack-functions-bundler.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; -import { destroyTestStack } from "../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const jwtSecret = "functions-serve-stack-e2e-secret-at-least-32-characters"; const nativeSupported = @@ -66,7 +65,6 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( runtime, }); yield* Effect.addFinalizer(() => destroyTestStack(stack)); - const bootstrap = yield* bundleStackFunctionsServeMainTemplate(); yield* stack.composition.supabase([ { service: "database", @@ -80,7 +78,7 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( }, { service: "rest", - config: { databaseUrl: "postgresql://placeholder", jwtSecret }, + config: { jwtSecret }, endpoints: { http: { port: "auto" } }, }, ...(included @@ -89,7 +87,6 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( service: "functions" as const, config: { functionsRoot, - bootstrap, jwtSecret, verifyJwt: true, env: { CUSTOM_VALUE: "original" }, @@ -116,7 +113,6 @@ const fixture = Effect.fn("FunctionsServeE2e.fixture")(function* ( service: "functions", config: { functionsRoot, - bootstrap, jwtSecret, verifyJwt: true, env: { CUSTOM_VALUE: "excluded" }, diff --git a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts index b4712285ee..45f486e1cd 100644 --- a/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.stack.integration.test.ts @@ -266,6 +266,7 @@ const fixture = ( }, credentials: { get: Effect.succeed(stackCredentials) }, composition: { + plan: () => Effect.succeed([]), describe: Effect.succeed({ members: options.standaloneProjectRoot === undefined @@ -284,27 +285,28 @@ const fixture = ( }, stop: Effect.die("unused"), destroy: Effect.die("unused"), - tools: { run: () => Effect.die("unused") }, + commands: { run: () => Effect.die("unused") }, } satisfies Stack; const identity = { projectRoot: "/project", branchContext: "main", stackName: "default" }; const apiService = { create: () => Effect.die("unused"), open: () => Effect.succeed(stack), - discover: () => - Effect.succeed([ - { + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + Option.some({ definition: { id: stack.id, identity, - runtime: "native", + runtime: "native" as const, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }, host: undefined, - }, - ]), - resolveIdentity: () => Effect.succeed(identity), + }), + ), } satisfies StackApi["Service"]; const api = Layer.succeed(StackApi, apiService); const layer = Layer.mergeAll( diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index 4fdb4e5b37..0db11b30dc 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -45,7 +45,7 @@ const consolePromptText = Effect.fnUntraced(function* (label: string) { const line = yield* stdin.readLine( tty.stdinIsTty ? GO_CONSOLE_TTY_TIMEOUT_MILLIS : GO_CONSOLE_NON_TTY_TIMEOUT_MILLIS, ); - const input = Option.getOrElse(line, () => ""); + const input = Option.getOrElse(line, () => "").trim(); if (!tty.stdinIsTty) { yield* output.raw(`${input}\n`, "stderr"); } diff --git a/apps/cli/src/commands/gen/signing-key/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/signing-key/SIDE_EFFECTS.md index 2fc24569dd..d485373215 100644 --- a/apps/cli/src/commands/gen/signing-key/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/signing-key/SIDE_EFFECTS.md @@ -58,7 +58,7 @@ Same as `--output-format json` above. - `--algorithm` accepts `ES256` (default, recommended) or `RS256`. - `--append` appends the new key to an existing keys file instead of overwriting. -- The overwrite prompt honors `SUPABASE_YES` (shell env or the project `.env`/`.env.local`/`.env.<env>[.local]` files, shell wins) and an explicit `--yes=false` override (flag wins over env; an omitted flag falls back to the env var, resolved after the project env loads — CLI-1878). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`) — a piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) also falls back to the default. +- The overwrite prompt honors `SUPABASE_YES` (shell env or the project `.env`/`.env.local`/`.env.<env>[.local]` files, shell wins) and an explicit `--yes=false` override (flag wins over env; an omitted flag falls back to the env var, resolved after the project env loads — CLI-1878). On non-TTY stdin, a piped `y`/`n` line is read within a 100ms timeout and honored before falling back to the default (`y`) — a non-empty piped answer other than an exact `y`/`yes`/`n`/`no` (case-insensitive) declines. - `auth.signing_keys_path` is resolved relative to the active `supabase/config.toml` or `supabase/config.json`. - Generated keys are JWKs, not PEM files. - No network or Management API calls are involved. diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts index 2a6b1278d5..840df06425 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts @@ -177,10 +177,11 @@ const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const isGitIgnored = Effect.fnUntraced(function* (filePath: string, searchFrom: string) { const path = yield* Path.Path; - const gitRoot = yield* Effect.tryPromise(() => findGitRootPath(searchFrom)).pipe(Effect.orDie); - if (gitRoot === undefined) { + const gitRootOption = yield* findGitRootPath(searchFrom); + if (Option.isNone(gitRootOption)) { return Option.none<boolean>(); } + const gitRoot = gitRootOption.value; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const relative = path.relative(gitRoot, filePath).replaceAll("\\", "/"); diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 24447d7f86..5db2ed9e28 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -1,7 +1,13 @@ # `supabase gen types` -Generates PostgREST client types in-process via `@supabase/postgrest-typegen` -against a direct PostgreSQL connection. `--linked`/`--project-id` TypeScript +Generates PostgREST client types against a direct PostgreSQL connection. +Introspection runs in-process via `@supabase/postgrest-typegen`; the language +comes from the `@supabase/typegen` registry, which lists every `--lang` value and +either calls that language's generator in-process (TypeScript, Go, Python, +Swift today) or runs the language's own tool in the directory the command was run from, with the +introspected document on stdin (`--lang dart` runs `dart run supabase_typegen +--output -`). A bump of that dependency can add a `--lang` value or a language +flag; the CLI names no language itself. `--linked`/`--project-id` TypeScript output still comes from the Management API; every other language and target (including `--local` and `--db-url`) connects to the database and introspects it directly — no pg-meta container is involved. When `[experimental].stack` @@ -54,14 +60,19 @@ workdir). `--local` and `--db-url` do not call the Management API. ## Subprocesses -| Command | When | Purpose | -| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ---------------------------------- | -| `docker`/`podman container inspect supabase_db_<project_id>` | `--local`, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running | +| Command | When | Purpose | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `docker`/`podman container inspect supabase_db_<project_id>` | `--local`, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running | +| `dart run supabase_typegen --output -` | `--lang dart` on every target, in the directory the command was run from | generate the types from the `GeneratorMetadata` JSON on stdin | -Generation itself runs in-process and never shells out. On a native or -Docker-based managed stack (`[experimental].stack` on), `--local` never -inspects a container; it resolves the stack's database connection through -`DbConfigResolver` the same way `--db-url` does. +Any other `--lang` whose registry entry is out-of-process runs its own tool +the same way: stdout is the output, stderr is folded into the error on +failure. The tool inherits the CLI's entire environment, so a `PUB_CACHE` or +proxy setting reaches it unchanged. Introspection and the in-process +languages never shell out. On a native or Docker-based managed stack +(`[experimental].stack` on), `--local` never inspects a container; it +resolves the stack's database connection through `DbConfigResolver` the same +way `--db-url` does. ## Environment Variables @@ -78,6 +89,7 @@ inspects a container; it resolves the stack's database connection through | `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | | `SUPABASE_SERVICES_HOSTNAME` | host used to reach the local database on the legacy Docker Compose stack | no (defaults to `127.0.0.1`) | | `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | +| `PATH`, `PATHEXT`, `ComSpec` | find the tool of an out-of-process `--lang`; `PATHEXT` and `ComSpec` matter on Windows only | no (`PATHEXT` defaults to `.COM;.EXE;.BAT;.CMD`, `ComSpec` to `cmd.exe`; without `PATH` the tool is reported as not installed) | ## Exit Codes @@ -96,6 +108,8 @@ inspects a container; it resolves the stack's database connection through | `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config on a schema-selecting path (`GenTypesMissingProjectConfigError`) — a DEFAULTED workdir keeps the embedded-default fallback instead | | `1` | a resolved preview branch config has no `db_user`/`db_pass` (`GenTypesBranchCredentialsUnavailableError`) | | `1` | API error or database connection/introspection/generation failure (`GenTypesGenerationError`) | +| `1` | an out-of-process language's toolchain or package is missing from the current directory; the message carries the registry's install hint (`GenTypesToolNotInstalledError`) | +| `1` | an out-of-process language's tool exited unsuccessfully or rejected the metadata document; the message carries its stderr (`GenTypesToolFailedError`) | ## Output @@ -170,15 +184,24 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only Management API and is unaffected by this change, so it can differ from local output on these jsonb cases until the hosted service adopts the same generator. - `--schema` / `-s` accepts a comma-separated list of schemas to include. -- `--swift-access-control` accepts `internal` (default) or `public`. It is - mutually exclusive with an _explicit_ `--linked`/`--project-id`; on the `--local`, - `--db-url`, and implicit-linked-fallback paths it is always applied - regardless of `--lang`. -- `--postgrest-v9-compat` generates types compatible with PostgREST v9 and below. - It must be used together with `--db-url` (error: +- Language flags are rendered from the registry's user-facing option specs; today that is + `--swift-access-control`, which accepts `internal` (default), `public`, `private` or + `package`. Every such flag is mutually exclusive with an _explicit_ + `--linked`/`--project-id`; on the `--local`, `--db-url`, and implicit-linked-fallback + paths the flags are parsed regardless of `--lang`, and only the values the chosen + language declares reach its generator. +- `--postgrest-v9-compat` is deprecated and hidden from help and docs. It still turns + one-to-one relationship detection off (the registry's `detect-one-to-one-relationships` + consumer option), must be used together with `--db-url` (error: `--postgrest-v9-compat must used together with --db-url` — note the typo, preserved - intentionally). `--local` still forces v9 compat when the local PostgREST image tag - contains `v9`. + intentionally), and prints this line on stderr when passed: + + ``` + Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection. + ``` + + `--local` still forces detection off when the local PostgREST image tag contains `v9`. + - `--query-timeout` sets the maximum time allowed for introspection (default 15s), applied both as the connection's server-side `statement_timeout` and as a connect timeout. It is mutually exclusive with an _explicit_ `--linked`/`--project-id`; on diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 6ab72759ce..db87d011f7 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -3,12 +3,15 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; +import { GLOBAL_FLAGS } from "../../../command-internal/global-flags.ts"; +import { + PERSISTENT_VALUE_FLAG_NAMES, + PERSISTENT_VALUE_FLAG_SHORTHANDS, +} from "../../../shared/cli/cobra-flag-groups.ts"; import { genTypes } from "./types.handler.ts"; +import { GEN_TYPES_LANGUAGES, genTypesLanguageFlags } from "./types.languages.ts"; import { genTypesRuntimeLayer } from "./types.layers.ts"; -const LANG_VALUES = ["typescript", "go", "swift", "python"] as const; -const SWIFT_ACCESS_CONTROL_VALUES = ["internal", "public"] as const; - const config = { local: Flag.boolean("local").pipe( Flag.withDescription("Generate types from the local dev database."), @@ -26,7 +29,7 @@ const config = { Flag.withDescription("Generate types from a project ID."), Flag.optional, ), - lang: Flag.choice("lang", LANG_VALUES).pipe( + lang: Flag.choice("lang", GEN_TYPES_LANGUAGES).pipe( Flag.withDescription("Output language of the generated types. (default typescript)"), Flag.withDefault("typescript"), ), @@ -39,12 +42,10 @@ const config = { (err) => (err instanceof Error ? err.message : String(err)), ), ), - swiftAccessControl: Flag.choice("swift-access-control", SWIFT_ACCESS_CONTROL_VALUES).pipe( - Flag.withDescription("Access control for Swift generated types. (default internal)"), - Flag.withDefault("internal"), - ), + // Hidden: Effect V4 has no `Flag.withDeprecated`; the handler prints cobra's deprecation line. postgrestV9Compat: Flag.boolean("postgrest-v9-compat").pipe( Flag.withDescription("Generate types compatible with PostgREST v9 and below."), + Flag.withHidden, Flag.withDefault(false), ), queryTimeout: Flag.string("query-timeout").pipe( @@ -53,12 +54,39 @@ const config = { ), } as const; +/** Every flag name `gen types` already answers to: its own, the globals, and Effect's built-ins. */ +const GEN_TYPES_RESERVED_FLAG_NAMES: ReadonlyArray<string> = [ + "local", + "linked", + "db-url", + "project-id", + "lang", + "schema", + "s", + "postgrest-v9-compat", + "query-timeout", + ...GLOBAL_FLAGS.map((flag) => flag.id), + ...PERSISTENT_VALUE_FLAG_NAMES, + ...PERSISTENT_VALUE_FLAG_SHORTHANDS.keys(), + "help", + "h", + "version", + "v", + "wizard", + "completions", + "log-level", +]; + +const flagsConfig = { ...config, ...genTypesLanguageFlags(GEN_TYPES_RESERVED_FLAG_NAMES) }; + const commandConfig = { - ...config, + ...flagsConfig, language: Argument.string("language").pipe(Argument.optional, Param.withHidden), } as const; -export type GenTypesFlags = CliCommand.Command.Config.Infer<typeof config>; +/** The registry's language flags are only known at runtime; read them through `languageOptionValues`. */ +export type GenTypesFlags = CliCommand.Command.Config.Infer<typeof flagsConfig> & + Readonly<Record<string, unknown>>; export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.withDescription("Generate types from Postgres schema."), @@ -83,7 +111,7 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( ]), Command.withHandler((flags) => genTypes(flags).pipe( - withCommandTelemetry({ flags, safeFlags: ["project-id"], config }), + withCommandTelemetry({ flags, safeFlags: ["project-id"], config: flagsConfig }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index 48e1d652ad..505f1b65fd 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -1,3 +1,4 @@ +import { languages } from "@supabase/typegen"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Clock, Config, Data, Effect, FileSystem, Option, Path, Schedule } from "effect"; @@ -8,6 +9,7 @@ import { withTempHome, } from "../../../../tests/helpers/cli.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { localDbContainerId } from "../../../command-internal/docker-ids.ts"; import { RESOLVE_BUDGET_MS, @@ -15,10 +17,11 @@ import { resolveDeadline, } from "../../../../tests/helpers/docker-image.ts"; -const TYPEGEN_LANGS = ["typescript", "go", "swift", "python"] as const; -type TypegenLang = (typeof TYPEGEN_LANGS)[number]; +const TYPEGEN_LANGS: ReadonlyArray<string> = languages + .filter((language) => language.inProcess) + .map((language) => language.name); +type TypegenLang = string; -const LOCAL_POSTGRES_IMAGE = dockerfileServiceImage("pg"); const LOCAL_POSTGRES_TIMEOUT_MS = 120_000; const TYPEGEN_TIMEOUT_MS = 90_000; // Image resolution runs inside the test body, so its timeout must add on top of the @@ -211,8 +214,9 @@ const startLocalPostgres = Effect.fnUntraced(function* (input: { }) { const containerName = localDbContainerId(input.projectId); const imageDeadline = resolveDeadline(LOCAL_IMAGE_BUDGET_MS); + const localPostgresImage = dockerfileServiceImage("pg", yield* slimImagesEnabled); const postgresImage = yield* Effect.tryPromise({ - try: () => ensureImage(LOCAL_POSTGRES_IMAGE, imageDeadline - RESOLVE_BUDGET_MS), + try: () => ensureImage(localPostgresImage, imageDeadline - RESOLVE_BUDGET_MS), catch: (cause) => new TypegenE2eSetupError({ message: "failed to ensure Docker image", cause }), }); @@ -297,6 +301,8 @@ function expectLanguageShape(lang: TypegenLang, stdout: string) { case "python": expect(stdout).toContain("from __future__ import annotations"); break; + default: + throw new Error(`no shape assertion for the registry language ${lang}`); } } diff --git a/apps/cli/src/commands/gen/types/types.errors.unit.test.ts b/apps/cli/src/commands/gen/types/types.errors.unit.test.ts index b34c8171cd..dc6a9f5496 100644 --- a/apps/cli/src/commands/gen/types/types.errors.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.errors.unit.test.ts @@ -8,6 +8,38 @@ import { GenTypesLocalDbInspectError, GenTypesLocalDbNotRunningError, } from "./types.errors.ts"; +import { + GenTypesToolFailedError, + GenTypesToolNotInstalledError, +} from "./types.generator.service.ts"; + +describe("GenTypesToolNotInstalledError actionability", () => { + it("classifies a missing language toolchain as user-actionable without a canonical remedy", () => { + const error = new GenTypesToolNotInstalledError({ + message: + "Generating dart types needs `dart`, which was not found on PATH. Install the Dart SDK.", + }); + + const result = classifyCliErrorActionability(error); + expect(result.error_kind).toBe(actionability.toolNotInstalled.error_kind); + expect(result.error_category).toBe(actionability.toolNotInstalled.error_category); + expect(result.has_suggestion).toBe(false); + expect(result.error_fingerprint).toBe("tag:GenTypesToolNotInstalledError"); + }); +}); + +describe("GenTypesToolFailedError actionability", () => { + it("classifies a failing language tool as unknown with the rerun-debug suggestion", () => { + const error = new GenTypesToolFailedError({ + message: "`dart run supabase_typegen --output -` exited with code 78.", + }); + + const result = classifyCliErrorActionability(error); + expect(result.error_kind).toBe(actionability.toolFailed.error_kind); + expect(result.suggestion_type).toBe(actionability.toolFailed.suggestion_type); + expect(result.error_fingerprint).toBe("tag:GenTypesToolFailedError"); + }); +}); describe("GenTypesBranchCredentialsUnavailableError actionability", () => { it("classifies a branch config without credentials as an API response problem", () => { diff --git a/apps/cli/src/commands/gen/types/types.generator.integration.test.ts b/apps/cli/src/commands/gen/types/types.generator.integration.test.ts new file mode 100644 index 0000000000..4209a07517 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.generator.integration.test.ts @@ -0,0 +1,161 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect"; +import type { DbSession } from "../../../command-internal/db-connection.service.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { genTypesGeneratorLayer } from "./types.generator.layer.ts"; +import { + type GenTypesGenerateInput, + GenTypesGenerator, + GenTypesToolFailedError, + GenTypesToolNotInstalledError, +} from "./types.generator.service.ts"; + +const unused = (what: string) => () => Effect.die(`${what} is not part of this test`); + +/** A connection whose every introspection query returns no rows: a valid, empty database. */ +const emptyDatabase = Layer.succeed(DbConnection, { + connect: () => + Effect.succeed<DbSession>({ + exec: unused("exec"), + execBatch: unused("execBatch"), + query: () => Effect.succeed([]), + queryRaw: unused("queryRaw"), + extensionExists: unused("extensionExists"), + copyToCsv: unused("copyToCsv"), + }), +}); + +const runtimeIn = (cwd: string) => + Layer.succeed(RuntimeInfo, { + cwd, + platform: process.platform, + arch: process.arch, + homeDir: cwd, + execPath: process.execPath, + pid: process.pid, + }); + +/** The generator layer as the command wires it, with the environment the layer reads through Config. */ +const layerIn = (cwd: string, env: Record<string, string>) => + genTypesGeneratorLayer.pipe( + Layer.provide(emptyDatabase), + Layer.provide(runtimeIn(cwd)), + Layer.provide(BunServices.layer), + Layer.provide(Layer.succeed(ConfigProvider.ConfigProvider, ConfigProvider.fromEnvRecord(env))), + ); + +const input = (lang: string): GenTypesGenerateInput => ({ + conn: { + host: "127.0.0.1", + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }, + isLocal: true, + dnsResolver: "native", + lang, + includedSchemas: ["public"], + options: {}, +}); + +const generate = (lang: string) => + Effect.gen(function* () { + const generator = yield* GenTypesGenerator; + return yield* Effect.scoped(generator.generate(input(lang))); + }); + +/** A stand-in `dart`: fails like the real tool when `.fail` sits in its cwd, else echoes its stdin. */ +const FAKE_DART = `#!/bin/sh +if [ -f "$(pwd)/.fail" ]; then + echo "The project is on Dart 3.0.0, but the generated code needs Dart 3.8.0 or newer." >&2 + exit 78 +fi +printf 'args: %s\\n' "$*" +printf 'cwd: %s\\n' "$(pwd)" +cat +`; + +const withFakeDart = <A, E, R>( + body: (context: { readonly cwd: string; readonly bin: string }) => Effect.Effect<A, E, R>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "gen-types-dart-" }), + ); + const bin = path.join(cwd, "bin"); + yield* fs.makeDirectory(bin); + yield* fs.writeFileString(path.join(bin, "dart"), FAKE_DART); + yield* fs.chmod(path.join(bin, "dart"), 0o755); + return yield* body({ cwd, bin }); + }).pipe(Effect.provide(BunServices.layer)); + +describe.skipIf(process.platform === "win32")( + "genTypesGeneratorLayer with a real subprocess", + () => { + it.effect("runs the language tool in the invocation directory with the document on stdin", () => + withFakeDart(({ cwd, bin }) => + generate("dart").pipe( + Effect.tap((output) => + Effect.sync(() => { + expect(output).toContain("args: run supabase_typegen --output -\n"); + expect(output).toContain(`cwd: ${cwd}\n`); + expect(output).toContain('"schemas":[]'); + expect(output).toContain('"version":1'); + }), + ), + Effect.provide(layerIn(cwd, { PATH: `${bin}:/usr/bin:/bin` })), + ), + ), + ); + + it.effect("reports a missing toolchain with the registry's install hint", () => + withFakeDart(({ cwd }) => + Effect.flip(generate("dart")).pipe( + Effect.tap((error) => + Effect.sync(() => { + expect(error).toBeInstanceOf(GenTypesToolNotInstalledError); + expect(error.message).toContain("Install the Dart SDK"); + }), + ), + Effect.provide(layerIn(cwd, { PATH: `${cwd}/nowhere:/usr/bin:/bin` })), + ), + ), + ); + + it.effect("surfaces the tool's stderr when it exits unsuccessfully", () => + withFakeDart(({ cwd, bin }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(cwd, ".fail"), ""); + const error = yield* Effect.flip(generate("dart")); + expect(error).toBeInstanceOf(GenTypesToolFailedError); + expect(error.message).toContain("exited with code 78"); + expect(error.message).toContain("needs Dart 3.8.0 or newer"); + }).pipe( + Effect.provide( + Layer.merge(layerIn(cwd, { PATH: `${bin}:/usr/bin:/bin` }), BunServices.layer), + ), + ), + ), + ); + + it.effect("still generates in-process languages without spawning anything", () => + withFakeDart(({ cwd }) => + generate("typescript").pipe( + Effect.tap((output) => + Effect.sync(() => { + expect(output).toContain("export type Database"); + }), + ), + Effect.provide(layerIn(cwd, { PATH: "/usr/bin:/bin" })), + ), + ), + ); + }, +); diff --git a/apps/cli/src/commands/gen/types/types.generator.layer.ts b/apps/cli/src/commands/gen/types/types.generator.layer.ts index d9f660f0db..f94fceec4f 100644 --- a/apps/cli/src/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/commands/gen/types/types.generator.layer.ts @@ -1,98 +1,131 @@ -import { Effect, Layer } from "effect"; import { - generateGo, - generatePython, - generateSwift, - generateTypescript, + findLanguage, + InvalidOptionError, introspect, - sortGeneratorMetadata, - type GeneratorMetadata, + type OptionValue, + type OptionValues, type Queryable, -} from "@supabase/postgrest-typegen"; + ToolFailedError, + ToolNotInstalledError, + type TypegenLanguage, +} from "@supabase/typegen"; +import { Config, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; -import { GenTypesGenerationError, GenTypesGenerator } from "./types.generator.service.ts"; +import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { + type GenTypesGenerateError, + GenTypesGenerationError, + GenTypesGenerator, + GenTypesToolFailedError, + GenTypesToolNotInstalledError, +} from "./types.generator.service.ts"; +import { makeTypegenHost } from "./types.typegen-host.ts"; -/** - * pg-meta printed generated output through `console.log`, which appends a newline regardless of - * what the generator already emitted — the TypeScript generator ends with one, so its output - * gained a blank final line. Appending unconditionally keeps every language byte-identical. - */ -function withTrailingNewline(code: string): string { - return `${code}\n`; -} +/** Only the values the language declares; the registry rejects names it does not know. */ +export const declaredOptions = (language: TypegenLanguage, values: OptionValues): OptionValues => { + const declared: Record<string, OptionValue> = {}; + for (const spec of language.options) { + const value = values[spec.name]; + if (value !== undefined) declared[spec.name] = value; + } + return declared; +}; + +const generationError = (lang: string, cause: unknown): GenTypesGenerationError => + new GenTypesGenerationError({ + message: `failed to generate ${lang} types: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }); + +/** The CLI error for whatever `TypegenLanguage.generate` rejected with. */ +export const mapRegistryError = (lang: string, cause: unknown): GenTypesGenerateError => { + if (cause instanceof ToolNotInstalledError) { + return new GenTypesToolNotInstalledError({ message: cause.message }); + } + if (cause instanceof ToolFailedError) { + return new GenTypesToolFailedError({ message: cause.message, cause }); + } + if (cause instanceof InvalidOptionError) { + return new GenTypesGenerationError({ message: cause.message, cause }); + } + return generationError(lang, cause); +}; + +const optionalEnv = (name: string) => + Config.option(Config.string(name)).pipe(Effect.map(Option.getOrUndefined)); /** - * Live `GenTypesGenerator`: opens a `DbConnection` session, adapts it to the generator's - * `Queryable` contract, and runs introspection and code generation in-process, replacing the - * pg-meta Docker container `gen types` previously shelled out to. + * Live `GenTypesGenerator`: opens a `DbConnection` session, adapts it to typegen's `Queryable` + * contract, introspects in-process, then hands the metadata to the language's registry entry, + * which calls its generator in-process or runs the language's own tool in the working directory. */ export const genTypesGeneratorLayer = Layer.effect( GenTypesGenerator, Effect.gen(function* () { const dbConn = yield* DbConnection; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* RuntimeInfo; + const lookupEnv = { + PATH: yield* optionalEnv("PATH"), + PATHEXT: yield* optionalEnv("PATHEXT"), + ComSpec: yield* optionalEnv("ComSpec"), + }; return GenTypesGenerator.of({ generate: (input) => Effect.gen(function* () { - const session = yield* dbConn.connect(input.conn, { - isLocal: input.isLocal, - dnsResolver: input.dnsResolver, - }); - // `session.query` needs no services, but running it through the current fiber's - // context (rather than a bare detached `Effect.runPromise`) keeps the Promise bridge - // anchored to this generator effect instead of a disconnected top-level runtime. - const runQuery = Effect.runPromiseWith(yield* Effect.context<never>()); - const toGenerationError = (cause: unknown) => - new GenTypesGenerationError({ - message: `failed to generate ${input.lang} types: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - cause, + const language = findLanguage(input.lang); + if (language === undefined) { + return yield* new GenTypesGenerationError({ + message: `failed to generate ${input.lang} types: unknown language`, }); - const generateSource = (metadata: GeneratorMetadata) => { - switch (input.lang) { - case "typescript": - return Effect.tryPromise({ - try: () => - generateTypescript(metadata, { - detectOneToOneRelationships: input.detectOneToOneRelationships, - // TypeScript is emitted as the generator wrote it. oxfmt is not bundled. - format: (code) => Promise.resolve(code), - }), - catch: toGenerationError, - }); - case "go": - return Effect.try({ try: () => generateGo(metadata), catch: toGenerationError }); - case "python": - return Effect.try({ - try: () => generatePython(metadata), - catch: toGenerationError, - }); - case "swift": - return Effect.try({ - try: () => generateSwift(metadata, { accessControl: input.swiftAccessControl }), - catch: toGenerationError, - }); - } - }; - const metadata = yield* Effect.tryPromise({ - try: (signal) => { - // `signal` aborts when this generate call is interrupted, so forwarding it to - // every `runQuery` stops an in-flight introspection query instead of leaving it - // detached from the fiber that started it. - const queryable: Queryable = { - query: (sql) => - runQuery(session.query(sql), { signal }).then((rows) => ({ rows: [...rows] })), - }; - return introspect(queryable, { includedSchemas: [...input.includedSchemas] }); - }, - catch: toGenerationError, - }).pipe( - Effect.flatMap((raw) => - Effect.try({ try: () => sortGeneratorMetadata(raw), catch: toGenerationError }), - ), + } + // Both Promise bridges run through the current fiber's context (rather than a bare + // detached `Effect.runPromise`) so they stay anchored to this generator effect instead + // of a disconnected top-level runtime. + const runPromise = Effect.runPromiseWith(yield* Effect.context<never>()); + const toGenerationError = (cause: unknown) => generationError(input.lang, cause); + // The session closes before an out-of-process tool starts, so no connection idles + // while, say, `dart run` compiles. + const metadata = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(input.conn, { + isLocal: input.isLocal, + dnsResolver: input.dnsResolver, + }); + return yield* Effect.tryPromise({ + try: (signal) => { + // `signal` aborts when this generate call is interrupted, so forwarding it to + // every query stops an in-flight introspection query instead of leaving it + // detached from the fiber that started it. + const queryable: Queryable = { + query: (sql) => + runPromise(session.query(sql), { signal }).then((rows) => ({ + rows: [...rows], + })), + }; + return introspect(queryable, { includedSchemas: [...input.includedSchemas] }); + }, + catch: toGenerationError, + }); + }), ); - return withTrailingNewline(yield* generateSource(metadata)); + const host = makeTypegenHost({ + cwd: runtime.cwd, + env: lookupEnv, + platform: runtime.platform, + spawner, + runPromise, + }); + return yield* Effect.tryPromise({ + try: (signal) => + language.generate(metadata, declaredOptions(language, input.options), { + ...host, + signal, + }), + catch: (cause) => mapRegistryError(input.lang, cause), + }); }), }); }), diff --git a/apps/cli/src/commands/gen/types/types.generator.service.ts b/apps/cli/src/commands/gen/types/types.generator.service.ts index 14e04ea579..8fcb069039 100644 --- a/apps/cli/src/commands/gen/types/types.generator.service.ts +++ b/apps/cli/src/commands/gen/types/types.generator.service.ts @@ -1,3 +1,4 @@ +import type { OptionValues } from "@supabase/typegen"; import { Context, Data, type Effect, type Scope } from "effect"; import { actionability, @@ -10,30 +11,27 @@ import type { PgConnInput, } from "../../../command-internal/db-connection.service.ts"; -/** Output language `gen types` can produce, mirroring the `@supabase/postgrest-typegen` generators. */ -type GenTypesLanguage = "typescript" | "go" | "python" | "swift"; - -/** - * Swift access-control levels `gen types` exposes. The underlying generator also accepts - * `"private"`/`"package"`, which this command does not surface. - */ -type GenTypesSwiftAccessControl = "internal" | "public"; - export interface GenTypesGenerateInput { readonly conn: PgConnInput; readonly isLocal: boolean; readonly dnsResolver: DbConnectOptions["dnsResolver"]; - readonly lang: GenTypesLanguage; + /** A `--lang` value: the name of one of the registry's `languages`. */ + readonly lang: string; readonly includedSchemas: ReadonlyArray<string>; - readonly detectOneToOneRelationships: boolean; - readonly swiftAccessControl: GenTypesSwiftAccessControl; + /** Registry option values by name; ones the language does not declare are dropped. */ + readonly options: OptionValues; } +export type GenTypesGenerateError = + | GenTypesGenerationError + | GenTypesToolNotInstalledError + | GenTypesToolFailedError; + interface GenTypesGeneratorShape { /** Connects to `input.conn`, introspects it, and generates `input.lang` source. */ readonly generate: ( input: GenTypesGenerateInput, - ) => Effect.Effect<string, GenTypesGenerationError | DbConnectError, Scope.Scope>; + ) => Effect.Effect<string, GenTypesGenerateError | DbConnectError, Scope.Scope>; } /** Introspection or code generation failed against the target database's schema. */ @@ -46,10 +44,28 @@ export class GenTypesGenerationError extends Data.TaggedError("GenTypesGeneratio } } -/** - * Generates PostgREST client types in-process via `@supabase/postgrest-typegen`, replacing the - * pg-meta Docker container `gen types` previously shelled out to. - */ +/** An out-of-process language's toolchain is missing; the message carries the install hint. */ +export class GenTypesToolNotInstalledError extends Data.TaggedError( + "GenTypesToolNotInstalledError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.toolNotInstalled; + } +} + +/** An out-of-process language's tool failed; the message carries its stderr. */ +export class GenTypesToolFailedError extends Data.TaggedError("GenTypesToolFailedError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.toolFailed; + } +} + +/** Introspects the target database and generates `lang` through the `@supabase/typegen` registry. */ export class GenTypesGenerator extends Context.Service<GenTypesGenerator, GenTypesGeneratorShape>()( "supabase/cli/GenTypesGenerator", ) {} diff --git a/apps/cli/src/commands/gen/types/types.generator.unit.test.ts b/apps/cli/src/commands/gen/types/types.generator.unit.test.ts index d91d9742f3..67b5105d15 100644 --- a/apps/cli/src/commands/gen/types/types.generator.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.generator.unit.test.ts @@ -1,56 +1,79 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; import { - generateGo, - generatePython, - generateSwift, - generateTypescript, - introspect, - sortGeneratorMetadata, - type GeneratorMetadata, -} from "@supabase/postgrest-typegen"; + findLanguage, + InvalidOptionError, + ToolFailedError, + ToolNotInstalledError, +} from "@supabase/typegen"; +import { declaredOptions, mapRegistryError } from "./types.generator.layer.ts"; +import { + GenTypesGenerationError, + GenTypesToolFailedError, + GenTypesToolNotInstalledError, +} from "./types.generator.service.ts"; -const emptyMetadata: GeneratorMetadata = { - version: 1, - schemas: [{ id: 1, name: "public", owner: "postgres" }], - tables: [], - views: [], - materializedViews: [], - foreignTables: [], - columns: [], - primaryKeys: [], - relationships: [], - functions: [], - types: [], -}; +describe("registry error mapping", () => { + it("keeps the registry's message, install hint included, for a missing toolchain", () => { + const hint = "Install the Dart SDK."; + const mapped = mapRegistryError( + "dart", + new ToolNotInstalledError({ + language: "dart", + tool: "dart", + installHint: hint, + message: `Generating dart types needs \`dart\`, which was not found on PATH. ${hint}`, + }), + ); + expect(mapped).toBeInstanceOf(GenTypesToolNotInstalledError); + expect(mapped.message).toBe( + `Generating dart types needs \`dart\`, which was not found on PATH. ${hint}`, + ); + }); -/** - * `tsconfig.types.json` type-checks this package against its published `dist/*.d.ts`, while Bun - * resolves its `bun` exports condition to `src/*.ts` at runtime. These assertions run against the - * Bun-resolved module, so a drift between the two views fails here rather than at generation time. - */ -describe("postgrest-typegen runtime contract", () => { - it("exposes the introspection and generation entry points the generator layer calls", () => { - expect(typeof introspect).toBe("function"); - expect(typeof sortGeneratorMetadata).toBe("function"); - expect(typeof generateTypescript).toBe("function"); - expect(typeof generateGo).toBe("function"); - expect(typeof generatePython).toBe("function"); - expect(typeof generateSwift).toBe("function"); + it("keeps the tool's stderr when it fails", () => { + const mapped = mapRegistryError( + "dart", + new ToolFailedError({ + language: "dart", + command: ["dart", "run"], + exitCode: 78, + stderr: "needs Dart 3.8", + message: "`dart run` exited with code 78.\nneeds Dart 3.8", + }), + ); + expect(mapped).toBeInstanceOf(GenTypesToolFailedError); + expect(mapped.message).toContain("needs Dart 3.8"); }); - it.effect("renders every supported language from metadata alone", () => - Effect.gen(function* () { - const metadata = sortGeneratorMetadata(emptyMetadata); + it("reports a rejected option and any other failure as a generation error", () => { + const invalid = mapRegistryError( + "swift", + new InvalidOptionError({ language: "swift", option: "x", message: "no option x" }), + ); + expect(invalid).toBeInstanceOf(GenTypesGenerationError); + expect(invalid.message).toBe("no option x"); + const other = mapRegistryError("go", new Error("boom")); + expect(other).toBeInstanceOf(GenTypesGenerationError); + expect(other.message).toBe("failed to generate go types: boom"); + }); +}); - expect( - yield* Effect.promise(() => - generateTypescript(metadata, { format: (code) => Promise.resolve(code) }), - ), - ).toContain("public"); - expect(generateGo(metadata)).toContain("package"); - expect(generatePython(metadata)).toContain("import"); - expect(generateSwift(metadata, { accessControl: "internal" })).toContain("import Supabase"); - }), - ); +describe("declaredOptions", () => { + it("forwards only the options the language declares", () => { + const swift = findLanguage("swift")!; + expect( + declaredOptions(swift, { + "swift-access-control": "public", + "detect-one-to-one-relationships": false, + unknown: true, + }), + ).toEqual({ "swift-access-control": "public" }); + expect( + declaredOptions(findLanguage("typescript")!, { + "swift-access-control": "public", + "detect-one-to-one-relationships": false, + }), + ).toEqual({ "detect-one-to-one-relationships": false }); + expect(declaredOptions(findLanguage("go")!, { "swift-access-control": "public" })).toEqual({}); + }); }); diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index addb2381f1..3885284cec 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,7 +1,7 @@ import type { LoadedCliConfig } from "@supabase/config/effect"; import { loadCliConfig } from "@supabase/config/internal"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from "effect"; +import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; import { getDomain } from "tldts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -44,6 +44,11 @@ import { runWithPoolerFallback, } from "../../../command-internal/pooler-fallback.ts"; import type { GenTypesFlags } from "./types.command.ts"; +import { + GEN_TYPES_LANGUAGE_FLAG_NAMES, + GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, + languageOptionValues, +} from "./types.languages.ts"; import { GenTypesBranchCredentialsUnavailableError, GenTypesFlagUsageError, @@ -56,7 +61,7 @@ import { GenTypesUnexpectedStatusError, GenTypesWorkdirError, } from "./types.errors.ts"; -import { type GenTypesGenerationError, GenTypesGenerator } from "./types.generator.service.ts"; +import { type GenTypesGenerateError, GenTypesGenerator } from "./types.generator.service.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { @@ -109,25 +114,19 @@ function isPoolerHost(host: string, poolerHost: string): boolean { const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; -type GenTypesMutexFlag = - | "local" - | "linked" - | "project-id" - | "db-url" - | "postgrest-v9-compat" - | "swift-access-control" - | "query-timeout"; - // Validation reports only the first violated group, in this listed order — e.g. `--db-url X // --postgrest-v9-compat --project-id Y` reports the postgrest group, not the // local/linked/project-id/db-url group. -const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray<ReadonlyArray<GenTypesMutexFlag>> = [ +const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray<ReadonlyArray<string>> = [ ["linked", "project-id", "postgrest-v9-compat"], ["linked", "project-id", "query-timeout"], - ["linked", "project-id", "swift-access-control"], + ...GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => ["linked", "project-id", name]), ["local", "linked", "project-id", "db-url"], ]; +const POSTGREST_V9_COMPAT_DEPRECATION_LINE = + "Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection."; + /** * Every value-taking flag `gen types` parses, telling `pflagArgvScan` which bare tokens * consume the next argv token as their value. Boolean flags (`--local`, `--linked`, @@ -139,7 +138,7 @@ const GEN_TYPES_SCAN_SPEC = { "project-id", "lang", "schema", - "swift-access-control", + ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, "query-timeout", ...PERSISTENT_VALUE_FLAG_NAMES, ]), @@ -163,7 +162,7 @@ const LONG_FLAGS_WITH_VALUES = new Set([ "project-id", "lang", "schema", - "swift-access-control", + ...GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES, "query-timeout", "profile", "workdir", @@ -248,7 +247,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const schemas = flags.schema; const lang = flags.lang; - const swiftAccessControl = flags.swiftAccessControl; + const languageOptions = languageOptionValues(flags); const toRelativeConfigPath = (path: string) => relativeConfigPath(cliSettings.workdir, path); @@ -311,11 +310,21 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) * `toConnectError` classifies the IPv6-unreachable dial failure at the connection boundary and * exposes it via `DbConnectError.ipv6Unreachable`, so a `DbConnectError` no longer carries the * raw driver cause `isIPv6ConnectivityErrorCause` needs; fall back to it for any other error. + * An out-of-process tool runs after the connection succeeded, and its errors carry the tool's + * stderr in the message, which could name a host the tool itself failed to reach; they are + * never a database connectivity failure, so they never earn the pooler retry. */ - const classifyGenerateError = (error: DbConnectError | GenTypesGenerationError): boolean => - Predicate.isTagged(error, "DbConnectError") - ? (error.ipv6Unreachable ?? false) - : isIPv6ConnectivityErrorCause(error); + const classifyGenerateError = (error: DbConnectError | GenTypesGenerateError): boolean => { + switch (error._tag) { + case "DbConnectError": + return error.ipv6Unreachable ?? false; + case "GenTypesToolNotInstalledError": + case "GenTypesToolFailedError": + return false; + case "GenTypesGenerationError": + return isIPv6ConnectivityErrorCause(error); + } + }; const runGenerate = (input: { readonly conn: PgConnInput; @@ -340,8 +349,10 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) dnsResolver, lang, includedSchemas: input.includedSchemas, - detectOneToOneRelationships: input.detectOneToOneRelationships, - swiftAccessControl, + options: { + ...languageOptions, + "detect-one-to-one-relationships": input.detectOneToOneRelationships, + }, }); }), ); @@ -535,6 +546,10 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }); } + if (occurrences.has("postgrest-v9-compat")) { + yield* output.raw(`${POSTGREST_V9_COMPAT_DEPRECATION_LINE}\n`, "stderr"); + } + // This guard runs before flag-group validation, so its error wins when both apply. Both // run after the telemetry context is installed, so every return here must stay inside the // `Effect.ensuring(telemetryState.flush)` below. @@ -559,14 +574,16 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) // A flag counts as set once passed explicitly, regardless of value (`--linked=false` // still trips its group). `project-id`/`db-url` are read straight off parsed flags since // they have no boolean-vs-default ambiguity. - const changedMutexFlags: Record<GenTypesMutexFlag, boolean> = { + const changedMutexFlags: Record<string, boolean> = { local: occurrences.has("local"), linked: occurrences.has("linked"), "project-id": Option.isSome(flags.projectId), "db-url": Option.isSome(flags.dbUrl), "postgrest-v9-compat": occurrences.has("postgrest-v9-compat"), - "swift-access-control": occurrences.has("swift-access-control"), "query-timeout": occurrences.has("query-timeout"), + ...Object.fromEntries( + GEN_TYPES_LANGUAGE_FLAG_NAMES.map((name) => [name, occurrences.has(name)]), + ), }; for (const group of GEN_TYPES_MUTEX_GROUPS) { const set = group.filter((flagName) => changedMutexFlags[flagName]); diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index dc9d030e0f..d494a07a5e 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -52,9 +52,11 @@ import { genTypes } from "./types.handler.ts"; import { localDbContainerId, parseQueryTimeoutMillis, rootCaBundle } from "./types.shared.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { + type GenTypesGenerateError, GenTypesGenerationError, GenTypesGenerator, type GenTypesGenerateInput, + GenTypesToolFailedError, } from "./types.generator.service.ts"; const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); @@ -113,7 +115,7 @@ function defaultFlags(overrides: Partial<GenTypesFlags> = {}): GenTypesFlags { projectId: Option.none(), lang: "typescript" as const, schema: [], - swiftAccessControl: "internal" as const, + "swift-access-control": Option.none(), postgrestV9Compat: false, queryTimeout: "15s", ...overrides, @@ -185,7 +187,7 @@ function mockGenTypesGenerator( readonly generate?: ( input: GenTypesGenerateInput, callIndex: number, - ) => Effect.Effect<string, GenTypesGenerationError | DbConnectError>; + ) => Effect.Effect<string, GenTypesGenerateError | DbConnectError>; readonly output?: string; } = {}, ) { @@ -209,7 +211,7 @@ function mockGenTypesGenerator( /** One `GenTypesGenerator.generate` outcome per attempt — models a failing then a retried call. */ function sequentialGenerator( - steps: ReadonlyArray<() => Effect.Effect<string, GenTypesGenerationError | DbConnectError>>, + steps: ReadonlyArray<() => Effect.Effect<string, GenTypesGenerateError | DbConnectError>>, ) { return mockGenTypesGenerator({ generate: (_input, index) => @@ -263,6 +265,18 @@ function nonIpv6Failure(lang = "go") { }); } +/** + * A tool failure whose stderr reads like a DNS miss. The message-text fallback of + * `isIPv6ConnectivityErrorCause` would classify it as IPv6-retryable, but the tool ran after the + * database connection had already succeeded. + */ +function toolFailureNamingAHost() { + return new GenTypesToolFailedError({ + message: + "failed to generate dart types: dart run supabase_typegen exited with code 1: Failed host lookup: 'pub.dev' (OS Error: No address associated with hostname, errno = 7)", + }); +} + /** * A single `container inspect` spawn — the only subprocess `gen types --local` still shells out * to (via `assertLocalDbRunning`) now that generation itself runs in-process. `dockerMissing` @@ -941,7 +955,11 @@ describe("gen types", () => { args: ["gen", "types", "--linked", "--swift-access-control", "public", "--lang", "swift"], }); const exit = yield* genTypes( - defaultFlags({ linked: true, lang: "swift", swiftAccessControl: "public" }), + defaultFlags({ + linked: true, + lang: "swift", + "swift-access-control": Option.some("public"), + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -971,7 +989,7 @@ describe("gen types", () => { defaultFlags({ projectId: Option.some(VALID_REF), lang: "swift", - swiftAccessControl: "public", + "swift-access-control": Option.some("public"), }), ).pipe(Effect.provide(layer), Effect.exit); @@ -1441,12 +1459,16 @@ describe("gen types", () => { projectId: Option.some(VALID_REF), }); yield* genTypes( - defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), + defaultFlags({ + lang: "go", + queryTimeout: "20s", + "swift-access-control": Option.some("public"), + }), ).pipe(Effect.provide(layer)); expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); const call = generator.calls[0]; - expect(call?.swiftAccessControl).toBe("public"); + expect(call?.options["swift-access-control"]).toBe("public"); expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); expect(call?.conn.connectTimeoutSeconds).toBe(20); }).pipe(Effect.provide(BunServices.layer)), @@ -1850,6 +1872,41 @@ describe("gen types", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.live("does not retry through the pooler when an out-of-process tool fails", () => + Effect.gen(function* () { + const generator = sequentialGenerator([() => Effect.fail(toolFailureNamingAHost())]); + const { layer, out, dbConfig } = yield* setup({ + args: ["gen", "types", "--lang", "dart", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "dart" }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.live("preserves the original generation error when pooler fallback resolution fails", () => Effect.gen(function* () { const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); @@ -2188,7 +2245,7 @@ describe("gen types", () => { }); expect(call?.isLocal).toBe(true); expect(call?.includedSchemas).toEqual(["public", "custom"]); - expect(call?.detectOneToOneRelationships).toBe(true); + expect(call?.options["detect-one-to-one-relationships"]).toBe(true); expect(call?.conn.sslmode).toBeUndefined(); expect(call?.conn.sslrootcertInline).toBeUndefined(); expect(linkedProjectCache.cached).toBe(false); @@ -2277,7 +2334,7 @@ describe("gen types", () => { const { layer, generator } = yield* setup({ workdir }); yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); + expect(generator.calls[0]?.options["detect-one-to-one-relationships"]).toBe(false); }).pipe(Effect.provide(BunServices.layer)), ); @@ -2301,7 +2358,7 @@ describe("gen types", () => { const { layer, generator } = yield* setup({ workdir }); yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(true); + expect(generator.calls[0]?.options["detect-one-to-one-relationships"]).toBe(true); }).pipe(Effect.provide(BunServices.layer)), ); @@ -2349,11 +2406,15 @@ describe("gen types", () => { args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], }); yield* genTypes( - defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), + defaultFlags({ + local: true, + lang: "python", + "swift-access-control": Option.some("public"), + }), ).pipe(Effect.provide(layer)); expect(generator.calls[0]?.lang).toBe("python"); - expect(generator.calls[0]?.swiftAccessControl).toBe("public"); + expect(generator.calls[0]?.options["swift-access-control"]).toBe("public"); }).pipe(Effect.provide(BunServices.layer)), ); @@ -2774,7 +2835,7 @@ describe("gen types", () => { dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), lang: "swift", schema: ["public"], - swiftAccessControl: "public", + "swift-access-control": Option.some("public"), postgrestV9Compat: true, queryTimeout: "20s", }), @@ -2782,25 +2843,32 @@ describe("gen types", () => { const call = generator.calls[0]; expect(call?.lang).toBe("swift"); - expect(call?.swiftAccessControl).toBe("public"); - expect(call?.detectOneToOneRelationships).toBe(false); + expect(call?.options["swift-access-control"]).toBe("public"); + expect(call?.options["detect-one-to-one-relationships"]).toBe(false); expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); expect(call?.conn.connectTimeoutSeconds).toBe(20); }).pipe(Effect.provide(BunServices.layer)), ); - it.live("allows --postgrest-v9-compat together with --db-url", () => - Effect.gen(function* () { - const { layer, generator } = yield* setup(); - yield* genTypes( - defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - postgrestV9Compat: true, - }), - ).pipe(Effect.provide(layer)); + it.live( + "allows --postgrest-v9-compat together with --db-url and prints its deprecation line", + () => + Effect.gen(function* () { + const { layer, generator, out } = yield* setup({ + args: ["gen", "types", "--db-url", "postgresql://x", "--postgrest-v9-compat"], + }); + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + postgrestV9Compat: true, + }), + ).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), + expect(generator.calls[0]?.options["detect-one-to-one-relationships"]).toBe(false); + expect(out.stderrText).toContain( + "Flag --postgrest-v9-compat has been deprecated, PostgREST 9 reached end of life; the flag still disables one-to-one relationship detection.", + ); + }).pipe(Effect.provide(BunServices.layer)), ); it.live("allows legacy positional non-typescript when --lang is explicitly set", () => diff --git a/apps/cli/src/commands/gen/types/types.languages.ts b/apps/cli/src/commands/gen/types/types.languages.ts new file mode 100644 index 0000000000..77e45da8fd --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.languages.ts @@ -0,0 +1,138 @@ +import { languages, type OptionSpec, type OptionValue, type OptionValues } from "@supabase/typegen"; +import { Option } from "effect"; +import { Flag } from "effect/unstable/cli"; + +/** A user-facing registry option as one CLI flag, merged across the languages that declare it. */ +export interface LanguageFlagSpec { + readonly name: string; + readonly kind: OptionSpec["kind"]; + readonly help: string; + /** Every declaring language's choices; `undefined` for other kinds. */ + readonly choices?: ReadonlyArray<string>; + /** Shown in help and docs only when every declaring language agrees; never sent. */ + readonly default?: OptionValue; +} + +export type GenTypesLanguageFlagValue = Option.Option<string | boolean>; + +/** + * One flag per option name. The flag carries no default: the registry applies each language's + * own default to the values the user did not set. + */ +export const mergeUserOptions = ( + specs: ReadonlyArray<OptionSpec>, +): ReadonlyArray<LanguageFlagSpec> => { + const byName = new Map<string, LanguageFlagSpec>(); + for (const spec of specs) { + if (spec.audience !== "user") continue; + const existing = byName.get(spec.name); + if (existing === undefined) { + byName.set(spec.name, { + name: spec.name, + kind: spec.kind, + help: spec.help, + choices: spec.kind === "choice" ? [...spec.choices] : undefined, + default: spec.default, + }); + continue; + } + if (existing.kind !== spec.kind) { + throw new Error( + `@supabase/typegen declares --${spec.name} as both ${existing.kind} and ${spec.kind}`, + ); + } + byName.set(spec.name, { + ...existing, + choices: + existing.choices !== undefined && spec.kind === "choice" + ? [...new Set([...existing.choices, ...spec.choices])] + : existing.choices, + default: existing.default === spec.default ? existing.default : undefined, + }); + } + return [...byName.values()]; +}; + +const languageFlag = (spec: LanguageFlagSpec): Flag.Flag<GenTypesLanguageFlagValue> => { + const help = + spec.default === undefined ? spec.help : `${spec.help} (default ${String(spec.default)})`; + switch (spec.kind) { + case "boolean": + return Flag.boolean(spec.name).pipe(Flag.withDescription(help), Flag.optional); + case "choice": + return Flag.choice(spec.name, spec.choices ?? []).pipe( + Flag.withDescription(help), + Flag.optional, + ); + case "string": + return Flag.string(spec.name).pipe(Flag.withDescription(help), Flag.optional); + } +}; + +/** Throws when a registry option reuses a flag name the command or the CLI already defines. */ +export const languageFlagsFor = ( + specs: ReadonlyArray<LanguageFlagSpec>, + reservedFlagNames: Iterable<string>, +): Readonly<Record<string, Flag.Flag<GenTypesLanguageFlagValue>>> => { + const reserved = new Set(reservedFlagNames); + const collisions = specs.map((spec) => spec.name).filter((name) => reserved.has(name)); + if (collisions.length > 0) { + throw new Error( + `@supabase/typegen declares language flags that collide with CLI flags: ${collisions.join(", ")}`, + ); + } + return Object.fromEntries(specs.map((spec) => [spec.name, languageFlag(spec)])); +}; + +/** The values the user set, keyed by option name; unset flags are absent. */ +export const optionValuesFor = ( + specs: ReadonlyArray<LanguageFlagSpec>, + flags: Readonly<Record<string, unknown>>, +): OptionValues => { + const values: Record<string, OptionValue> = {}; + for (const spec of specs) { + const value = flags[spec.name]; + if (!Option.isOption(value) || Option.isNone(value)) continue; + if (typeof value.value === "string" || typeof value.value === "boolean") { + values[spec.name] = value.value; + } + } + return values; +}; + +/** Documented defaults keyed the way `DOCS_DEFAULT_OVERRIDES` expects. */ +export const flagDefaultsFor = ( + specs: ReadonlyArray<LanguageFlagSpec>, +): Readonly<Record<string, string>> => + Object.fromEntries( + specs.flatMap((spec) => + spec.default === undefined ? [] : [[`supabase-gen-types ${spec.name}`, String(spec.default)]], + ), + ); + +export const GEN_TYPES_LANGUAGES: ReadonlyArray<string> = languages.map( + (language) => language.name, +); + +const GEN_TYPES_LANGUAGE_OPTIONS = mergeUserOptions( + languages.flatMap((language) => language.options), +); + +export const GEN_TYPES_LANGUAGE_FLAG_NAMES: ReadonlyArray<string> = GEN_TYPES_LANGUAGE_OPTIONS.map( + (option) => option.name, +); + +/** Language flags that consume the next argv token, for the pflag-style scans in the handler. */ +export const GEN_TYPES_LANGUAGE_VALUE_FLAG_NAMES: ReadonlyArray<string> = + GEN_TYPES_LANGUAGE_OPTIONS.filter((option) => option.kind !== "boolean").map( + (option) => option.name, + ); + +export const genTypesLanguageFlags = (reservedFlagNames: Iterable<string>) => + languageFlagsFor(GEN_TYPES_LANGUAGE_OPTIONS, reservedFlagNames); + +export const genTypesLanguageFlagDefaults = (): Readonly<Record<string, string>> => + flagDefaultsFor(GEN_TYPES_LANGUAGE_OPTIONS); + +export const languageOptionValues = (flags: Readonly<Record<string, unknown>>): OptionValues => + optionValuesFor(GEN_TYPES_LANGUAGE_OPTIONS, flags); diff --git a/apps/cli/src/commands/gen/types/types.languages.unit.test.ts b/apps/cli/src/commands/gen/types/types.languages.unit.test.ts new file mode 100644 index 0000000000..2018e97091 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.languages.unit.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { OptionSpec } from "@supabase/typegen"; +import { Option } from "effect"; +import { + flagDefaultsFor, + languageFlagsFor, + mergeUserOptions, + optionValuesFor, +} from "./types.languages.ts"; + +const level = (choices: ReadonlyArray<string>, fallback: string): OptionSpec => ({ + name: "level", + audience: "user", + kind: "choice", + choices, + default: fallback, + help: "Access level.", +}); + +describe("mergeUserOptions", () => { + it("skips consumer options and keeps one flag per name", () => { + const merged = mergeUserOptions([ + level(["a", "b"], "a"), + { name: "version", audience: "consumer", kind: "string", help: "" }, + level(["b", "c"], "a"), + ]); + expect(merged).toEqual([ + { + name: "level", + kind: "choice", + help: "Access level.", + choices: ["a", "b", "c"], + default: "a", + }, + ]); + }); + + it("drops the default when the declaring languages disagree", () => { + const [merged] = mergeUserOptions([level(["a", "b"], "a"), level(["a", "b"], "b")]); + expect(merged?.default).toBeUndefined(); + }); + + it("refuses one name declared with two kinds", () => { + expect(() => + mergeUserOptions([ + level(["a"], "a"), + { name: "level", audience: "user", kind: "boolean", default: false, help: "" }, + ]), + ).toThrow(/--level as both choice and boolean/); + }); +}); + +describe("languageFlagsFor", () => { + const specs = mergeUserOptions([level(["a", "b"], "a")]); + + it("refuses a registry flag that reuses a reserved name", () => { + expect(() => languageFlagsFor(specs, ["lang", "level"])).toThrow( + /collide with CLI flags: level/, + ); + expect(Object.keys(languageFlagsFor(specs, ["lang"]))).toEqual(["level"]); + }); +}); + +describe("optionValuesFor", () => { + const specs = mergeUserOptions([ + level(["a", "b"], "a"), + { name: "verbose", audience: "user", kind: "boolean", default: false, help: "" }, + ]); + + it("forwards only the flags the user set, so each language applies its own default", () => { + expect(optionValuesFor(specs, { level: Option.some("b"), verbose: Option.none() })).toEqual({ + level: "b", + }); + expect(optionValuesFor(specs, { level: Option.none(), verbose: Option.some(true) })).toEqual({ + verbose: true, + }); + expect(optionValuesFor(specs, { lang: "swift" })).toEqual({}); + }); +}); + +describe("flagDefaultsFor", () => { + it("documents a default only when it is unambiguous", () => { + expect(flagDefaultsFor(mergeUserOptions([level(["a"], "a")]))).toEqual({ + "supabase-gen-types level": "a", + }); + expect(flagDefaultsFor(mergeUserOptions([level(["a"], "a"), level(["b"], "b")]))).toEqual({}); + }); +}); diff --git a/apps/cli/src/commands/gen/types/types.typegen-host.integration.test.ts b/apps/cli/src/commands/gen/types/types.typegen-host.integration.test.ts new file mode 100644 index 0000000000..991dedfb47 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.typegen-host.integration.test.ts @@ -0,0 +1,42 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { makeTypegenHost } from "./types.typegen-host.ts"; + +describe.skipIf(process.platform === "win32")("makeTypegenHost with the real spawner", () => { + const spawnInto = (tool: string, stdin: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runPromise = Effect.runPromiseWith(yield* Effect.context<never>()); + const host = makeTypegenHost({ + cwd: process.cwd(), + env: {}, + platform: process.platform, + spawner, + runPromise, + }); + if (host.spawn === undefined) throw new Error("the typegen host must supply spawn"); + return yield* Effect.promise(() => + host.spawn!({ command: "sh", args: ["-c", tool], cwd: process.cwd(), env: {}, stdin }), + ); + }).pipe(Effect.provide(BunServices.layer)); + + // A write this small completes before the tool exits, so the pipe's EPIPE arrives after the + // sink has stopped listening; only the patched stdin error listener keeps it from crashing + // the process (seen on Linux, never on macOS). + it.effect("survives a tool that exits before reading a small stdin", () => + Effect.gen(function* () { + const result = yield* spawnInto("exit 3", "x".repeat(1024)); + expect(result.exitCode).toBe(3); + }), + ); + + // Far more than a pipe buffer holds, so the write itself meets the closed pipe for certain. + it.effect("survives a tool that exits before reading a large stdin", () => + Effect.gen(function* () { + const result = yield* spawnInto("exit 3", "x".repeat(4 * 1024 * 1024)); + expect(result.exitCode).toBe(3); + }), + ); +}); diff --git a/apps/cli/src/commands/gen/types/types.typegen-host.ts b/apps/cli/src/commands/gen/types/types.typegen-host.ts new file mode 100644 index 0000000000..2c7cf0d2ef --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.typegen-host.ts @@ -0,0 +1,121 @@ +import { + type Host, + isWindowsScript, + quoteForCmd, + resolveWindowsCommand, + type SpawnRequest, + type SpawnResult, +} from "@supabase/typegen"; +import { Effect, Predicate, Stream } from "effect"; +import { ChildProcess, type ChildProcessSpawner } from "effect/unstable/process"; + +import { collectText } from "../../../command-internal/container-cli.ts"; + +export interface TypegenHostOptions { + /** Where out-of-process tools run: the directory the command was invoked from. */ + readonly cwd: string; + /** What the registry's Windows lookup reads; the child inherits the full environment. */ + readonly env: Readonly<Record<string, string | undefined>>; + readonly platform: NodeJS.Platform; + readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + /** `Effect.runPromiseWith(context)` from the generator fiber, so it owns the spawned tool. */ + readonly runPromise: <A>( + effect: Effect.Effect<A>, + options?: { readonly signal?: AbortSignal | undefined }, + ) => Promise<A>; +} + +type TypegenSpawnOutcome = + | { readonly _tag: "Exited"; readonly result: SpawnResult } + | { readonly _tag: "NotFound" } + | { readonly _tag: "Failed"; readonly error: unknown }; + +const isNotFound = (error: unknown): boolean => + Predicate.hasProperty(error, "reason") && Predicate.isTagged(error.reason, "NotFound"); + +const spawnForTypegen = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + request: SpawnRequest, + platform: NodeJS.Platform, +): Effect.Effect<TypegenSpawnOutcome> => + Effect.scoped( + Effect.gen(function* () { + let command = request.command; + let args: ReadonlyArray<string> = request.args; + let shell = false; + if (platform === "win32") { + // `spawn` cannot start the `.bat` Flutter ships `dart` as without a shell. + const resolved = resolveWindowsCommand(request.command, request.env); + if (resolved === undefined) return { _tag: "NotFound" } as const; + shell = isWindowsScript(resolved); + command = shell ? quoteForCmd(resolved) : resolved; + if (shell) args = request.args.map(quoteForCmd); + } + const child = yield* spawner.spawn( + ChildProcess.make(command, [...args], { + cwd: request.cwd, + env: request.env, + extendEnv: true, + shell, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ); + // Written here rather than handed to the spawner, so a tool that exits before reading its + // input fails the write in this fiber, where it is expected, instead of in a forked one. + const feedStdin = Stream.run( + Stream.make(new TextEncoder().encode(request.stdin)), + child.stdin, + ).pipe(Effect.ignore); + const [, stdout, stderr, exitCode] = yield* Effect.all( + [ + feedStdin, + collectText(child.stdout), + collectText(child.stderr), + // A signal-ended process fails `exitCode`; the registry's contract wants `null`. + child.exitCode.pipe( + Effect.map(Number), + Effect.orElseSucceed((): number | null => null), + ), + ], + { concurrency: "unbounded" }, + ); + return { _tag: "Exited", result: { exitCode, stdout, stderr } } as const; + }), + ).pipe( + Effect.catch((error) => + Effect.succeed<TypegenSpawnOutcome>( + isNotFound(error) ? { _tag: "NotFound" } : { _tag: "Failed", error }, + ), + ), + ); + +const commandNotFound = (command: string): Error => + Object.assign(new Error(`spawn ${command} ENOENT`), { + code: "ENOENT", + syscall: `spawn ${command}`, + path: command, + }); + +/** The registry `Host` for `gen types`; TypeScript is handed back unformatted, `oxfmt` stays out. */ +export const makeTypegenHost = (options: TypegenHostOptions): Host => ({ + cwd: options.cwd, + env: options.env, + spawn: (request) => + options + .runPromise(spawnForTypegen(options.spawner, request, options.platform), { + signal: request.signal, + }) + .then((outcome) => { + switch (outcome._tag) { + case "Exited": + return outcome.result; + case "NotFound": + throw commandNotFound(request.command); + case "Failed": + throw outcome.error; + } + }), + format: (code) => Promise.resolve(code), +}); diff --git a/apps/cli/src/commands/gen/types/types.typegen-host.unit.test.ts b/apps/cli/src/commands/gen/types/types.typegen-host.unit.test.ts new file mode 100644 index 0000000000..8be1e919e3 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.typegen-host.unit.test.ts @@ -0,0 +1,196 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import type { SpawnRequest, SpawnResult } from "@supabase/typegen"; +import { Data, Effect, FileSystem, Path, PlatformError, Sink, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { makeTypegenHost } from "./types.typegen-host.ts"; + +interface SpawnCall { + readonly command: string; + readonly args: ReadonlyArray<string>; + readonly cwd: string | undefined; + readonly env: Readonly<Record<string, string | undefined>> | undefined; + readonly extendEnv: boolean | undefined; + readonly shell: boolean | string | undefined; + stdin: string; +} + +/** Records what `spawnForTypegen` asks for and answers like a finished process. */ +function fakeSpawner( + opts: { + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; + readonly notFound?: boolean; + } = {}, +) { + const calls: Array<SpawnCall> = []; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("unexpected command shape"); + } + const call: SpawnCall = { + command: command.command, + args: command.args, + cwd: command.options.cwd, + env: command.options.env, + extendEnv: command.options.extendEnv, + shell: command.options.shell, + stdin: "", + }; + calls.push(call); + if (opts.notFound === true) { + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${command.command} not found`, + }); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(4242), + stdout: Stream.make(encoder.encode(opts.stdout ?? "")), + stderr: Stream.make(encoder.encode(opts.stderr ?? "")), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)), + isRunning: Effect.succeed(false), + stdin: Sink.forEach((chunk: Uint8Array) => + Effect.sync(() => { + call.stdin += decoder.decode(chunk, { stream: true }); + }), + ), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { spawner, calls }; +} + +const request = (overrides: Partial<SpawnRequest> = {}): SpawnRequest => ({ + command: "dart", + args: ["run", "supabase_typegen", "--output", "-"], + cwd: "/projects/app", + env: { PATH: "/usr/bin" }, + stdin: '{"version":1}', + ...overrides, +}); + +const host = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + platform: NodeJS.Platform = "darwin", + env: Readonly<Record<string, string | undefined>> = { PATH: "/usr/bin" }, +) => + makeTypegenHost({ + cwd: "/projects/app", + env, + platform, + spawner, + runPromise: (effect, options) => Effect.runPromise(effect, options), + }); + +describe("makeTypegenHost", () => { + const spawnOf = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + platform: NodeJS.Platform = "darwin", + env: Readonly<Record<string, string | undefined>> = { PATH: "/usr/bin" }, + ) => { + const { spawn } = host(spawner, platform, env); + if (spawn === undefined) throw new Error("the typegen host must supply spawn"); + return (req: SpawnRequest) => Effect.promise(() => spawn(req)); + }; + + class SpawnRejected extends Data.TaggedError("SpawnRejected")<{ readonly reason: unknown }> {} + + /** The rejection of a spawn, for the contract cases where the promise must fail. */ + const rejectionOf = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + platform: NodeJS.Platform, + env: Readonly<Record<string, string | undefined>>, + req: SpawnRequest, + ) => { + const { spawn } = host(spawner, platform, env); + if (spawn === undefined) throw new Error("the typegen host must supply spawn"); + return Effect.tryPromise({ + try: () => spawn(req), + catch: (reason) => new SpawnRejected({ reason }), + }).pipe(Effect.flip); + }; + + it.effect("returns what the tool wrote and feeds it the document on stdin", () => + Effect.gen(function* () { + const { spawner, calls } = fakeSpawner({ + stdout: "class Tickets {}\n", + stderr: "summary\n", + }); + const result: SpawnResult = yield* spawnOf(spawner)(request()); + + expect(result).toEqual({ exitCode: 0, stdout: "class Tickets {}\n", stderr: "summary\n" }); + expect(calls[0]?.stdin).toBe('{"version":1}'); + }), + ); + + it.effect("reports a non-zero exit as a result rather than a failure", () => + Effect.gen(function* () { + const { spawner } = fakeSpawner({ exitCode: 65, stderr: "Could not parse the document\n" }); + const result = yield* spawnOf(spawner)(request()); + expect(result.exitCode).toBe(65); + expect(result.stderr).toBe("Could not parse the document\n"); + }), + ); + + it.effect("rejects a missing executable with ENOENT, as the registry's Host contract asks", () => + Effect.gen(function* () { + const { spawner } = fakeSpawner({ notFound: true }); + const error = yield* rejectionOf(spawner, "darwin", { PATH: "/usr/bin" }, request()); + expect(error.reason).toMatchObject({ code: "ENOENT" }); + }), + ); + + it.effect("hands TypeScript back unformatted", () => + Effect.gen(function* () { + const { spawner } = fakeSpawner(); + const { format } = host(spawner); + if (format === undefined) throw new Error("the typegen host must supply format"); + const code = "export type Database = {}"; + expect(yield* Effect.promise(() => format(code, "output.ts"))).toBe(code); + }), + ); + + it.effect("on Windows, rejects with ENOENT without spawning when PATH holds no candidate", () => + Effect.gen(function* () { + const { spawner, calls } = fakeSpawner(); + const env = { PATH: "C:\\nowhere", PATHEXT: ".EXE;.BAT" }; + const error = yield* rejectionOf(spawner, "win32", env, request({ env })); + expect(error.reason).toMatchObject({ code: "ENOENT" }); + expect(calls).toEqual([]); + }), + ); + + // The registry's lookup joins Windows paths, which only exist on a Windows filesystem. + describe.skipIf(process.platform !== "win32")("on a Windows filesystem", () => { + it.effect("runs a .bat found through PATH and PATHEXT through the command interpreter", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "typegen-host-" }), + ); + const flutter = path.join(dir, "flutter"); + yield* fs.makeDirectory(flutter); + yield* fs.writeFileString(path.join(flutter, "dart.BAT"), "@echo off\r\n"); + + const { spawner, calls } = fakeSpawner({ stdout: "ok" }); + const env = { PATH: flutter, PATHEXT: ".EXE;.BAT" }; + yield* spawnOf(spawner, "win32", env)(request({ env })); + expect(calls[0]?.command).toBe(path.join(flutter, "dart.BAT")); + expect(calls[0]?.shell).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + }); +}); diff --git a/apps/cli/src/commands/init/SIDE_EFFECTS.md b/apps/cli/src/commands/init/SIDE_EFFECTS.md index c9cdca403a..d42d518a7c 100644 --- a/apps/cli/src/commands/init/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/init/SIDE_EFFECTS.md @@ -73,13 +73,6 @@ failed to create config file: open supabase\config.toml: The file exists. Run supabase init --force to overwrite existing config file. ``` -When `--use-orioledb` is passed without `--experimental` (stderr; the second line is the generic debug hint appended on error): - -``` -required flag(s) "experimental" not set -Try rerunning the command with --debug to troubleshoot the error. -``` - When `SUPABASE_EXPERIMENTAL_STACK` is a non-empty value other than `0` or `1` (stderr; the second line is the generic debug hint appended on error): ``` @@ -91,7 +84,7 @@ Try rerunning the command with --debug to troubleshoot the error. - Uses the invocation cwd directly and does not recurse upward looking for an existing project. - The `--force` flag overwrites an existing `supabase/config.toml`. -- The `--use-orioledb` flag sets `UseOrioleDB` in init params; requires `--experimental` flag. +- The `--use-orioledb` flag writes the OrioleDB version to `db.orioledb_version`; it does not require `--experimental`. - `SUPABASE_EXPERIMENTAL_STACK=1` opts the new project into the experimental stack backend: the written config includes `[experimental] stack = true` and omits the Docker-era default ports (API, database, shadow, pooler, Studio, mail UI, Functions inspector, and Analytics). diff --git a/apps/cli/src/commands/init/init.errors.ts b/apps/cli/src/commands/init/init.errors.ts index c9d7b03fef..0e2583b225 100644 --- a/apps/cli/src/commands/init/init.errors.ts +++ b/apps/cli/src/commands/init/init.errors.ts @@ -18,18 +18,3 @@ export class InitConfigExistsError extends Data.TaggedError("InitConfigExistsErr return actionability.provideFlags; } } - -/** - * `--use-orioledb` without `--experimental`. Reproduces the established required-flag error - * text verbatim: `required flag(s) "experimental" not set`. No suggestion — the text output - * layer's `fail` already appends the generic `--debug` hint when unset. - */ -export class InitExperimentalRequiredError extends Data.TaggedError( - "InitExperimentalRequiredError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} diff --git a/apps/cli/src/commands/init/init.handler.ts b/apps/cli/src/commands/init/init.handler.ts index a10d0d2518..49fa0ca9a2 100644 --- a/apps/cli/src/commands/init/init.handler.ts +++ b/apps/cli/src/commands/init/init.handler.ts @@ -6,23 +6,16 @@ import { experimentalFeatureEnv, resolveExperimentalFeature, } from "../../command-internal/experimental-feature.ts"; -import { ExperimentalFlag, WorkdirFlag, resolveYes } from "../../command-internal/global-flags.ts"; -import { InitConfigExistsError, InitExperimentalRequiredError } from "./init.errors.ts"; +import { WorkdirFlag, resolveYes } from "../../command-internal/global-flags.ts"; +import { InitConfigExistsError } from "./init.errors.ts"; import type { InitFlags } from "./init.command.ts"; export const init = Effect.fn("init")(function* (flags: InitFlags) { const output = yield* Output; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; - const experimental = yield* ExperimentalFlag; const workdir = yield* WorkdirFlag; - if (flags.useOrioledb && !experimental) { - return yield* new InitExperimentalRequiredError({ - message: `required flag(s) "experimental" not set`, - }); - } - const experimentalStack = yield* resolveExperimentalFeature({ feature: "stack", configValue: Effect.succeed(false), diff --git a/apps/cli/src/commands/init/init.integration.test.ts b/apps/cli/src/commands/init/init.integration.test.ts index bf9f451fca..0f7a5e2b17 100644 --- a/apps/cli/src/commands/init/init.integration.test.ts +++ b/apps/cli/src/commands/init/init.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; +import * as SmolToml from "smol-toml"; import { Cause, ConfigProvider, @@ -139,30 +140,29 @@ describe("init", () => { }); }); - it.live("requires --experimental when --use-orioledb is set, with cobra's exact wording", () => { + it.live.each([ + { experimental: false, label: "without --experimental" }, + { experimental: true, label: "with --experimental" }, + ])("writes OrioleDB config $label", ({ experimental }) => { const tempDir = tempRoot.current; return Effect.gen(function* () { - const { layer } = setup(tempDir, { experimental: false }); + const { layer, out } = setup(tempDir, { experimental }); - const exit = yield* init({ + yield* init({ interactive: false, useOrioledb: true, force: false, withVscodeWorkspace: false, withVscodeSettings: false, withIntellijSettings: false, - }).pipe(Effect.provide(layer), Effect.exit); - - const error = findFailure(exit); - expect(error["_tag"]).toBe("InitExperimentalRequiredError"); - expect(error["message"]).toBe(`required flag(s) "experimental" not set`); - expect(error["suggestion"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); - expect(yield* renderFailureToStderr(exit)).toEqual([ - `required flag(s) "experimental" not set\n`, - "Try rerunning the command with --debug to troubleshoot the error.\n", - ]); + const content = yield* readTextFile(tempDir, "supabase", "config.toml"); + expect(SmolToml.parse(content)).toMatchObject({ + db: { major_version: 17, orioledb_version: "17.11.0.002" }, + }); + expect(out.stdoutText).toBe("Finished supabase init.\n"); }); }); diff --git a/apps/cli/src/commands/issue/issue.handler.ts b/apps/cli/src/commands/issue/issue.handler.ts index 9828a818bf..46d9b6dae2 100644 --- a/apps/cli/src/commands/issue/issue.handler.ts +++ b/apps/cli/src/commands/issue/issue.handler.ts @@ -34,7 +34,7 @@ export const issueBug = Effect.fn("issue.bug")(function* (flags: IssueBugFlags) "affected-area": readIssueFlagValue(flags.area), "cli-version": telemetryRuntime.cliVersion, os: `${runtimeInfo.platform} ${runtimeInfo.arch}`, - "install-method": inferIssueInstallMethod(runtimeInfo), + "install-method": yield* inferIssueInstallMethod(runtimeInfo), command: readIssueFlagValue(flags.command), "actual-output": readIssueFlagValue(flags.actualOutput), "expected-behavior": readIssueFlagValue(flags.expectedBehavior), diff --git a/apps/cli/src/commands/issue/issue.integration.test.ts b/apps/cli/src/commands/issue/issue.integration.test.ts index b41027eef6..8034a1d8ed 100644 --- a/apps/cli/src/commands/issue/issue.integration.test.ts +++ b/apps/cli/src/commands/issue/issue.integration.test.ts @@ -39,7 +39,7 @@ function issueMockOutput(opts: { readonly format?: OutputFormat } = {}) { fail: () => Effect.void, info: () => Effect.void, cancel: () => Effect.void, - clear: () => Effect.void, + clear: Effect.void, }), promptText: () => Effect.succeed(""), promptPassword: () => Effect.succeed(""), diff --git a/apps/cli/src/commands/link/link.handler.ts b/apps/cli/src/commands/link/link.handler.ts index ea7c90fdaf..5a56ff300f 100644 --- a/apps/cli/src/commands/link/link.handler.ts +++ b/apps/cli/src/commands/link/link.handler.ts @@ -167,7 +167,7 @@ const resolveLinkBranchRef = Effect.fnUntraced(function* (value: string) { Effect.tapError(() => task?.fail() ?? Effect.void), Effect.catch(mapBranchListError), ); - yield* task?.clear() ?? Effect.void; + yield* task?.clear ?? Effect.void; const found: LinkBranch | undefined = branches.find( // UUID matching is case-insensitive (canonical ids are lowercase hex, but uppercase input @@ -393,7 +393,7 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { Effect.timeout(LINK_CACHE_CORRELATION_TIMEOUT), Effect.map((branches) => branches.some((branch) => branch.project_ref === ref)), Effect.orElseSucceed(() => false), - Effect.ensuring(correlating?.clear() ?? Effect.void), + Effect.ensuring(correlating?.clear ?? Effect.void), ); if (!verified) { yield* fs.remove(paths.linkedProjectCache, { force: true }).pipe(Effect.ignore); diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 61c6f7d427..1ce4cae955 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -1459,7 +1460,7 @@ describe("link integration", () => { cliSettings, analytics, }), - commandRuntimeLayer(["link"]), + commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed(["link", "--project-ref", VALID_REF]), }), @@ -1489,7 +1490,7 @@ describe("link integration", () => { cliSettings, analytics, }), - commandRuntimeLayer(["link"]), + commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed(["link", "--project-ref", "my-branch"]), }), diff --git a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md index 3f8a18dc3e..7b9d25165c 100644 --- a/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/fetch/SIDE_EFFECTS.md @@ -60,7 +60,7 @@ Same structured `files` result delivered as an NDJSON `result` event. `Do you want to overwrite existing files in supabase/migrations directory?` (default **YES**). Declining exits non-zero (`context canceled`). `--yes` or `SUPABASE_YES` (shell env or project `.env`) auto-confirms; a non-interactive / - machine-output run takes the default (YES). + machine-output run takes the default (YES). An unrecognised answer declines. ## Notes diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index b54f72c94e..a512dece4a 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -254,6 +254,45 @@ describe("migration fetch", () => { }).pipe(Effect.provide(layer)); }); + it.live.each( + ["u", "yess", "nope", " "].flatMap((answer) => [ + { answer, isTTY: false }, + { answer, isTTY: true }, + ]), + )( + "cancels the overwrite on the unrecognised answer $answer (isTTY $isTTY)", + ({ answer, isTTY }) => { + const { layer } = setup(tmp.current, { + isTTY, + pipedInput: `${answer}\n`, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); + const exit = yield* migrationFetch(flags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && failure.value._tag).toBe("OperationCanceledError"); + } + expect(yield* listMigrations(tmp.current)).toEqual(["existing.sql"]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live.each(["\n", "", " YES \n"])("overwrites on the piped answer %j", (pipedInput) => { + const { layer } = setup(tmp.current, { + isTTY: false, + pipedInput, + rows: [{ version: "20240101000000", name: "init", statements: ["create table a"] }], + }); + return Effect.gen(function* () { + yield* seedExistingMigration(tmp.current); + yield* migrationFetch(flags()); + expect(yield* listMigrations(tmp.current)).toContain("20240101000000_init.sql"); + }).pipe(Effect.provide(layer)); + }); + it.live("bypasses the overwrite prompt with --yes (echoes the auto-answer)", () => { const { layer, out } = setup(tmp.current, { yes: true, diff --git a/apps/cli/src/commands/migration/migration.prompt.ts b/apps/cli/src/commands/migration/migration.prompt.ts index ab4bf3e628..bd74d62990 100644 --- a/apps/cli/src/commands/migration/migration.prompt.ts +++ b/apps/cli/src/commands/migration/migration.prompt.ts @@ -13,7 +13,8 @@ const NON_TTY_TIMEOUT_MILLIS = 100; * general `promptYesNo`: it writes the label to stderr and reads one stdin line * regardless of `--output` format (rather than auto-defaulting in json/stream-json), * and on a real TTY it reads a raw stdin line with a 10-minute timeout instead of a - * clack confirm UI. `--yes` short-circuits to `true`, echoing `<label> y`. + * clack confirm UI. `--yes` short-circuits to `true`, echoing `<label> y`. A parsed + * answer wins; an empty line, EOF, or timeout takes the default; any other line declines. */ export const migrationConfirm = ( title: string, @@ -33,6 +34,7 @@ export const migrationConfirm = ( yield* output.raw(label, "stderr"); const line = yield* stdin.readLine(stdin.isTTY ? TTY_TIMEOUT_MILLIS : NON_TTY_TIMEOUT_MILLIS); const input = Option.getOrElse(line, () => ""); - if (!stdin.isTTY) yield* output.raw(`${input}\n`, "stderr"); - return parseYesNo(input) ?? options.defaultValue; + if (!stdin.isTTY) yield* output.raw(`${input.trim()}\n`, "stderr"); + // An unrecognised answer is never consent. + return parseYesNo(input) ?? (input.length > 0 ? false : options.defaultValue); }); diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 63c1ff31e0..6c7a10d777 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -9,7 +9,9 @@ migration-history table to match. When `[experimental].stack` is on, each shadow is a fresh database in an invocation-owned, unique temporary stack namespace. The command applies the catalog and project migrations as needed, -then destroys its namespace when the Effect scope closes. Stack shadows use the stack baseline +then destroys its namespace when the Effect scope closes. If its container engine is unreachable +then, the namespace is still removed and stderr lists the commands that remove its engine +resources. Stack shadows use the stack baseline cache described below. Native artifacts are shared through `$SUPABASE_HOME/cache/stack`; shadow state and data use the normal stack registry, so `stack list` and `stack destroy` can find a shadow left by an abrupt CLI exit. Each shadow owns a unique temporary project root @@ -156,7 +158,8 @@ code or the rest of the payload. baseline to target the surviving **older** version, not the original squash target. - A failed full-schema dump leaves the target migration truncated (not recoverable — the file was already truncated before the dump began). -- A declined "Update remote migration history table?" prompt is a **success** path (exit 0, +- A declined "Update remote migration history table?" prompt (`n`, or any unrecognised + answer) is a **success** path (exit 0, no baseline query, `Finished …` still prints) — the opposite of `migration repair`/`fetch`/ `down`, which treat a decline as a cancellation. - **Atomicity note:** the old Go CLI sent the baseline `DELETE`/`INSERT` via a batched pipeline diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index 31f99f4aa5..64969f7aed 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -36,6 +36,7 @@ import { mockTty, } from "../../../../tests/helpers/mocks.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../../shared/services/slim-images.ts"; import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { @@ -750,7 +751,9 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* seedHappyPathMigrations(tmp.current); yield* migrationSquash(flags()); - const expectedImage = yield* getRegistryImageUrl(dockerfileServiceImage("pg")); + const expectedImage = yield* getRegistryImageUrl( + dockerfileServiceImage("pg", yield* slimImagesEnabled), + ); expect(s.dumpCalls).toHaveLength(3); for (const call of s.dumpCalls) { expect(call.env["PGPORT"]).toBe("54320"); @@ -1286,6 +1289,16 @@ describe("migration squash", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("an unrecognised piped answer leaves the remote migration history alone", () => { + const s = setupRemote({ isTTY: false, pipedInput: "nope\n" }); + return Effect.gen(function* () { + yield* seedMigration(tmp.current, "0_init.sql"); + yield* migrationSquash(flags()); + expect(s.execs).not.toContain("BEGIN"); + expect(s.queries).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "--version 0 baselines exactly version 0 even though a newer migration survives", () => { diff --git a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts index d14f20e6be..db17d0494a 100644 --- a/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.native.integration.test.ts @@ -28,7 +28,7 @@ import { BundledPostgresClient } from "../../../command-internal/bundled-postgre import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { migrationSquash } from "./squash.handler.ts"; import type { MigrationSquashFlags } from "./squash.command.ts"; -import { destroyTestStack } from "../../../../../../packages/stack/tests/stack-cleanup.ts"; +import { destroyTestStack } from "../../../../tests/helpers/stack-cleanup.ts"; const runtimes = ["native", "docker"] as const; const liveStackApi = stackApiLayer.pipe(Layer.provide(BunServices.layer)); diff --git a/apps/cli/src/commands/network-bans/get/get.handler.ts b/apps/cli/src/commands/network-bans/get/get.handler.ts index 30e7fce6c6..97e101fd99 100644 --- a/apps/cli/src/commands/network-bans/get/get.handler.ts +++ b/apps/cli/src/commands/network-bans/get/get.handler.ts @@ -41,7 +41,7 @@ export const networkBansGet = Effect.fn("network-bans.get")(function* (flags: Ne Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goOutput = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/network-restrictions/get/get.handler.ts b/apps/cli/src/commands/network-restrictions/get/get.handler.ts index 827772f9cc..5b207123df 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.handler.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.handler.ts @@ -51,7 +51,7 @@ export const networkRestrictionsGet = Effect.fn("network-restrictions.get")(func Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/network-restrictions/update/update.handler.ts b/apps/cli/src/commands/network-restrictions/update/update.handler.ts index aff28d7ea4..4617ed42b6 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.handler.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.handler.ts @@ -84,7 +84,7 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.catch(mapUpdateError), ); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; // The PATCH response always renders as `&[]`/`&[...]`, never `<nil>`; partition // returns concrete arrays to match, even when a type has no items. const partitioned = partitionPatchedCidrs(response.config.dbAllowedCidrs); @@ -103,7 +103,7 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.catch(mapUpdateError), ); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; // POST /apply prints the response field directly; an omitted array renders as `<nil>`. v4Out = response.config.dbAllowedCidrs; v6Out = response.config.dbAllowedCidrsV6; diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index 5c5652b064..2744d9026c 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -341,7 +341,7 @@ const withNotebookTask = return yield* Effect.acquireUseRelease( output.task(`${sanitizeInlineName(subject)}...`), () => self, - (task, exit) => (Exit.isFailure(exit) ? task.fail() : task.clear()), + (task, exit) => (Exit.isFailure(exit) ? task.fail() : task.clear), ); }); diff --git a/apps/cli/src/commands/orgs/create/create.handler.ts b/apps/cli/src/commands/orgs/create/create.handler.ts index b7a83a57da..bbcc52c849 100644 --- a/apps/cli/src/commands/orgs/create/create.handler.ts +++ b/apps/cli/src/commands/orgs/create/create.handler.ts @@ -40,7 +40,7 @@ export const orgsCreate = Effect.fn("orgs.create")(function* (flags: OrgsCreateF Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.catch(mapCreateError), ); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/orgs/list/list.handler.ts b/apps/cli/src/commands/orgs/list/list.handler.ts index 685081a4e9..3e3a9f7bd9 100644 --- a/apps/cli/src/commands/orgs/list/list.handler.ts +++ b/apps/cli/src/commands/orgs/list/list.handler.ts @@ -42,7 +42,7 @@ export const orgsList = Effect.fn("orgs.list")(function* (_flags: OrgsListFlags) Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts index a63ae60692..800524b161 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts @@ -54,7 +54,7 @@ export const postgresConfigDelete = Effect.fn("postgres-config.delete")(function unmarshalMessage: (description) => `failed to unmarshal delete response: ${description}`, }).pipe(Effect.tapError(() => deleting?.fail() ?? Effect.void)); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; yield* writePostgresConfigOutput(updated); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/postgres-config/get/get.handler.ts b/apps/cli/src/commands/postgres-config/get/get.handler.ts index 96e52515af..db64d5f380 100644 --- a/apps/cli/src/commands/postgres-config/get/get.handler.ts +++ b/apps/cli/src/commands/postgres-config/get/get.handler.ts @@ -27,7 +27,7 @@ export const postgresConfigGet = Effect.fn("postgres-config.get")(function* ( const config = yield* fetchCurrentPostgresConfig(ref).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; yield* writePostgresConfigOutput(config); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/postgres-config/update/update.handler.ts b/apps/cli/src/commands/postgres-config/update/update.handler.ts index 32d96b42fc..b9f11be4d9 100644 --- a/apps/cli/src/commands/postgres-config/update/update.handler.ts +++ b/apps/cli/src/commands/postgres-config/update/update.handler.ts @@ -71,7 +71,7 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function unmarshalMessage: (description) => `failed to unmarshal update response: ${description}`, }).pipe(Effect.tapError(() => updating?.fail() ?? Effect.void)); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; yield* writePostgresConfigOutput(updated); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts index 2bf078e0c7..b5767a9814 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts @@ -66,7 +66,7 @@ export const projectsApiKeys = Effect.fn("projects.api-keys")(function* ( const keys: ApiKeys = yield* getProjectApiKeys(ref, flags.reveal).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/projects/delete/delete.handler.ts b/apps/cli/src/commands/projects/delete/delete.handler.ts index 63444cf179..f6292a615e 100644 --- a/apps/cli/src/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/commands/projects/delete/delete.handler.ts @@ -101,7 +101,7 @@ export const projectsDelete = Effect.fn("projects.delete")(function* (flags: Pro }), ), ); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; // No per-ref keyring credential delete: the access token is stored under the profile // name, not the ref, so there is nothing to remove here. diff --git a/apps/cli/src/commands/projects/list/list.handler.ts b/apps/cli/src/commands/projects/list/list.handler.ts index fd1611ed9d..0dd0831a48 100644 --- a/apps/cli/src/commands/projects/list/list.handler.ts +++ b/apps/cli/src/commands/projects/list/list.handler.ts @@ -118,7 +118,7 @@ export const projectsList = Effect.fn("projects.list")(function* (_flags: Projec decode: true, }); } - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // Prints the not-linked message to stderr but still renders the table below. if (Option.isNone(linkedRef)) { diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 829bcad068..21880b7ee5 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -65,7 +65,7 @@ export const pullHandler = (flags: PullFlags) => export const pullCommand = Command.make("pull", config).pipe( Command.withDescription( - "Refreshes local project state from a linked Supabase project or branch in one step: pulls config into supabase/config.toml, optionally fetches the remote migration history table, pulls the database schema into supabase/migrations (also updating that database's migration history), and downloads every Edge Function's source. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/n honored, default otherwise) — use --dry-run to preview first.", + "Refreshes local project state from a linked Supabase project or branch in one step: pulls config into supabase/config.toml, optionally fetches the remote migration history table, pulls the database schema into supabase/migrations (also updating that database's migration history), and downloads every Edge Function's source. Prompts for confirmation before writing on an interactive TTY, unless --yes is set; --output-format json|stream-json skips the prompt entirely and takes its default answer, while a non-interactive text run still prints the prompt to stderr and reads one line from piped stdin (y/yes/n/no honored, empty takes the default, anything else declines) — use --dry-run to preview first.", ), Command.withShortDescription("Pull remote project state into the local checkout"), Command.withExamples([ diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 634f7649b1..f3eba9ba8c 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -664,7 +664,7 @@ function setup(opts: SetupOpts = {}) { goOutput: opts.goOutput ?? Option.none(), }), machineErrorContextLayer, - commandRuntimeLayer(["pull"]), + commandRuntimeLayer(["pull"]).pipe(Layer.provide(BunServices.layer)), capturingStdio?.layer ?? Stdio.layerTest({ args: Effect.succeed(["pull"]) }), dbConfig.layer, pgDelta.layer, diff --git a/apps/cli/src/commands/secrets/list/list.handler.ts b/apps/cli/src/commands/secrets/list/list.handler.ts index 225202e8c3..c148a71acc 100644 --- a/apps/cli/src/commands/secrets/list/list.handler.ts +++ b/apps/cli/src/commands/secrets/list/list.handler.ts @@ -67,7 +67,7 @@ export const secretsList = Effect.fn("secrets.list")(function* (flags: SecretsLi Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapListError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const sorted = sortSecrets(response); const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/secrets/set/set.handler.ts b/apps/cli/src/commands/secrets/set/set.handler.ts index 26df74dbdd..690690bed3 100644 --- a/apps/cli/src/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/commands/secrets/set/set.handler.ts @@ -285,7 +285,7 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF Effect.tapError(() => setting?.fail() ?? Effect.void), Effect.catch(mapSetError), ); - yield* setting?.clear() ?? Effect.void; + yield* setting?.clear ?? Effect.void; if (output.format === "json" || output.format === "stream-json") { yield* output.success("Finished supabase secrets set.", { diff --git a/apps/cli/src/commands/secrets/unset/unset.handler.ts b/apps/cli/src/commands/secrets/unset/unset.handler.ts index bf411983f7..e39fe9d3cc 100644 --- a/apps/cli/src/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/commands/secrets/unset/unset.handler.ts @@ -80,7 +80,7 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets Effect.tapError(() => unsetting?.fail() ?? Effect.void), Effect.catch(mapUnsetError), ); - yield* unsetting?.clear() ?? Effect.void; + yield* unsetting?.clear ?? Effect.void; if (output.format === "json" || output.format === "stream-json") { yield* output.success("Finished supabase secrets unset.", { diff --git a/apps/cli/src/commands/services/SIDE_EFFECTS.md b/apps/cli/src/commands/services/SIDE_EFFECTS.md index 646d88e098..63b4d68c7b 100644 --- a/apps/cli/src/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/services/SIDE_EFFECTS.md @@ -86,7 +86,7 @@ TS-only NDJSON success event with the same `{ services: [...] }` payload. ## Notes - Backend selection follows canonical experimental-feature routing: `SUPABASE_EXPERIMENTAL_STACK=1|0` takes precedence over `experimental.stack`; unset or empty uses project config, and an invalid environment value fails. Output fields and serializers stay the same. -- The legacy backend uses its baked-in service matrix and honors its existing config/version overrides. The stack backend lists services from the installed CLI artifact catalog using canonical `ghcr.io/supabase/cli/...` image names and catalog versions. Native and Docker runtimes use the same catalog versions. Since the catalog belongs to the installed CLI, an older launched CLI or a newer/mirrored stack image may differ from this inventory. The command does not inspect running containers, image pulls, service health, or live stack state. +- The legacy backend uses its baked-in service matrix and honors its existing config/version overrides. The stack backend lists services from the installed CLI artifact catalog using canonical `ghcr.io/supabase/cli/...` image names and catalog versions, reported as the upstream version without a slim revision suffix (`-rN`). Native and Docker runtimes use the same catalog versions. Since the catalog belongs to the installed CLI, an older launched CLI or a newer/mirrored stack image may differ from this inventory. The command does not inspect running containers, image pulls, service health, or live stack state. - For stack mode, PostgreSQL uses the configured major version or `SUPABASE_DB_MAJOR_VERSION` (15 or 17). Invalid configuration or an unsupported PostgreSQL major warns with the cause and falls back to default catalog versions; absent config uses defaults. Legacy image pins, slim-image rewriting, and remote image overrides do not affect stack results. - Linked-version checks are best-effort. Remote lookup failures do not change the exit code; they only leave the `LINKED` column empty for unavailable services. - A malformed linked ref is the one lookup failure that prints an explicit stderr warning (see API Routes above); every other remote failure (network error, expired token, etc.) still fails silently and just leaves `LINKED` empty. Most real-world malformed refs come from an untrimmed `SUPABASE_PROJECT_ID` env var (e.g. a trailing newline from a secrets manager or `.env` file) rather than actual file tampering — the env var is read raw and unlike the on-disk `project-ref` file is never trimmed. diff --git a/apps/cli/src/commands/services/services-local-stack.ts b/apps/cli/src/commands/services/services-local-stack.ts index 85907a4470..7a37515ff9 100644 --- a/apps/cli/src/commands/services/services-local-stack.ts +++ b/apps/cli/src/commands/services/services-local-stack.ts @@ -2,10 +2,11 @@ import { artifactServiceKinds, postgresVersion, resolveArtifact, -} from "@supabase/stack/internal/service-catalog"; +} from "@supabase/stack/internal/artifacts"; import { Effect, Result } from "effect"; import { loadLocalProjectContext } from "../../command-internal/local-project-context.ts"; import { envOverrideMajorVersion } from "../../command-internal/local-config-values.ts"; +import { upstreamVersionFromTag } from "../../shared/services/services.shared.ts"; import type { ServiceVersionRow } from "../../shared/services/services.shared.ts"; import type { RemoteServiceName } from "../../shared/services/services.shared.ts"; @@ -52,7 +53,8 @@ export const stackServiceVersions = Effect.fn("services.stackServiceVersions")(f const remoteName = remoteNames[service]; return { name, - local: artifact.version, + // A slim revision suffix (`-rN`) repackages the same upstream release. + local: upstreamVersionFromTag(artifact.version), remote: remoteName === undefined ? "" : (remote[remoteName] ?? ""), } satisfies ServiceVersionRow; }), diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index dbbb151ed6..4cee002ec2 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -33,6 +33,7 @@ import { renderServicesWarning, type ServiceVersionRow, } from "../../shared/services/services.shared.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import type { ServicesFlags } from "./services.command.ts"; import { ServicesEnvNotSupportedError } from "./services.errors.ts"; import { stackServiceVersions } from "./services-local-stack.ts"; @@ -176,6 +177,7 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) imageOverrides["edge-runtime"] = edgeRuntimeImage; } const localImageOptions = { + slim: yield* slimImagesEnabled, imageOverrides, normalizeVersionTags: false, serviceVersions, diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index c22e86f41a..839a972d36 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { catalogPins } from "@supabase/stack/internal/artifacts"; import { BunServices } from "@effect/platform-bun"; import { CliOutput, Command } from "effect/unstable/cli"; import { @@ -42,6 +43,25 @@ import { services } from "./services.handler.ts"; const LOCAL_POSTGRES_VERSION = dockerfileServiceImageRaw("pg").split(":")[1] ?? ""; +/** + * The real stack catalog's pinned upstream version for `sourceService` — the default pin, or + * (for postgres) the additional 15.x line when `additional` is set. Derived rather than + * hardcoded, so a catalog bump never makes these "stack backend" assertions go stale. + */ +function catalogUpstreamVersion( + sourceService: string, + options: { readonly additional?: boolean } = {}, +): string { + const wantDefault = !(options.additional ?? false); + const entry = catalogPins().find( + (candidate) => candidate.sourceService === sourceService && candidate.isDefault === wantDefault, + ); + if (entry === undefined) { + throw new Error(`No catalog pin for '${sourceService}' (default=${wantDefault}).`); + } + return entry.pin.upstreamVersion; +} + /** Shape of one row in the `--output json` services array. */ const ServiceRows = Schema.Array( Schema.Struct({ @@ -188,6 +208,10 @@ const writeTempFile = Effect.fnUntraced(function* (workdir: string, name: string yield* fs.writeFileString(path.join(tempDir, name), content); }); +// `postgresImageForDbMajorVersion` always returns the raw docker.io reference (slim translation +// is a downstream concern); its tag is the Dockerfile-generated one, which always matches the +// catalog's pinned upstream version, so this is the same version the CLI reports whether or not +// the slim flag is on. function postgresVersionForDbMajorVersion(majorVersion: number): string { const image = postgresImageForDbMajorVersion(majorVersion); if (image === undefined) { @@ -333,13 +357,25 @@ describe("services", () => { expect(out.stderrText).toBe(""); expect(rows).toHaveLength(13); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "15.14.1.173" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: catalogUpstreamVersion("postgres", { additional: true }), + }), ); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/mailpit", local: "v1.30.2" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/mailpit", + local: catalogUpstreamVersion("mailpit"), + }), ); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/vector", local: "0.53.0" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/vector", + local: catalogUpstreamVersion("vector"), + }), + ); + expect(rows).toContainEqual( + expect.objectContaining({ name: "ghcr.io/supabase/cli/storage", local: "v1.79.28" }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -359,7 +395,10 @@ describe("services", () => { const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "15.14.1.173" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: catalogUpstreamVersion("postgres", { additional: true }), + }), ); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -377,10 +416,16 @@ describe("services", () => { const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "17.6.1.173" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: catalogUpstreamVersion("postgres"), + }), ); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/auth", local: "v2.196.0" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/auth", + local: catalogUpstreamVersion("auth"), + }), ); expect(out.stderrText).toContain("unsupported PostgreSQL major version: 16"); expect(out.stderrText).toContain("using default stack catalog versions"); @@ -396,7 +441,10 @@ describe("services", () => { const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( - expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", local: "17.6.1.173" }), + expect.objectContaining({ + name: "ghcr.io/supabase/cli/postgres", + local: catalogUpstreamVersion("postgres"), + }), ); expect(out.stderrText).toMatch(/^failed to read config:/); expect(out.stderrText).toContain("using default stack catalog versions"); @@ -580,7 +628,7 @@ major_version = 15 expect(stackRows).toContainEqual( expect.objectContaining({ name: "ghcr.io/supabase/cli/postgres", - local: "17.6.1.173", + local: catalogUpstreamVersion("postgres"), remote: "17.6.1.200", }), ); diff --git a/apps/cli/src/commands/snippets/download/download.handler.ts b/apps/cli/src/commands/snippets/download/download.handler.ts index b77d959c3f..9d1b3233e7 100644 --- a/apps/cli/src/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/commands/snippets/download/download.handler.ts @@ -185,7 +185,7 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( }), ), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // Exposes the full payload (id, name, owner, ... alongside content.sql) // for scripted callers — see SIDE_EFFECTS.md; matches the shape diff --git a/apps/cli/src/commands/snippets/list/list.handler.ts b/apps/cli/src/commands/snippets/list/list.handler.ts index 29a97a74bb..0f56049338 100644 --- a/apps/cli/src/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/commands/snippets/list/list.handler.ts @@ -184,7 +184,7 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet }), ), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const parsed = parseSnippetsResponse(rawBody); const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts b/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts index 293b53dec3..0bf9a7f158 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.handler.ts @@ -49,7 +49,7 @@ export const sslEnforcementGet = Effect.fn("ssl-enforcement.get")(function* ( Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapGetError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts index 5dac34f54a..cd92a3f378 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts @@ -66,7 +66,7 @@ export const sslEnforcementUpdate = Effect.fn("ssl-enforcement.update")(function Effect.tapError(() => updating?.fail() ?? Effect.void), Effect.catch(mapUpdateError), ); - yield* updating?.clear() ?? Effect.void; + yield* updating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index d4584ebc80..39519f7a1d 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -298,7 +298,7 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { } const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); - yield* creating?.clear() ?? Effect.void; + yield* creating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/sso/list/list.handler.ts b/apps/cli/src/commands/sso/list/list.handler.ts index 4a8217318e..06e9e826e7 100644 --- a/apps/cli/src/commands/sso/list/list.handler.ts +++ b/apps/cli/src/commands/sso/list/list.handler.ts @@ -75,7 +75,7 @@ export const ssoList = Effect.fn("sso.list")(function* (flags: SsoListFlags) { Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch((cause) => handleListError(ref, cause)), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); const payload = { providers: response.items }; diff --git a/apps/cli/src/commands/sso/remove/remove.handler.ts b/apps/cli/src/commands/sso/remove/remove.handler.ts index 2f553d65ca..d35d06df9b 100644 --- a/apps/cli/src/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/commands/sso/remove/remove.handler.ts @@ -76,7 +76,7 @@ export const ssoRemove = Effect.fn("sso.remove")(function* (flags: SsoRemoveFlag Effect.tapError(() => removing?.fail() ?? Effect.void), Effect.catch((cause) => handleRemoveError(ref, providerId, cause)), ); - yield* removing?.clear() ?? Effect.void; + yield* removing?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/sso/show/show.handler.ts b/apps/cli/src/commands/sso/show/show.handler.ts index e56906f563..268af5b796 100644 --- a/apps/cli/src/commands/sso/show/show.handler.ts +++ b/apps/cli/src/commands/sso/show/show.handler.ts @@ -62,7 +62,7 @@ export const ssoShow = Effect.fn("sso.show")(function* (flags: SsoShowFlags) { Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch((cause) => handleShowError(providerId, cause)), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; // `--metadata` short-circuits regardless of `--output`. if (flags.metadata) { diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index 51cf524d22..6d9f91b37b 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -484,7 +484,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag } const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); diff --git a/apps/cli/src/commands/start/services/gotrue.service.ts b/apps/cli/src/commands/start/services/gotrue.service.ts index 8de0ff43f1..dce09c4b3a 100644 --- a/apps/cli/src/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/commands/start/services/gotrue.service.ts @@ -553,6 +553,8 @@ export function buildGotrueEnv(input: BuildGotrueEnvInput): Record<string, strin } export interface GotrueContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The already-resolved `config.auth.image`. Not part of the decoded `@supabase/config` schema; resolution is the caller's responsibility. */ readonly image: string; /** The sanitized project id, used to derive this container's own name/the `db` container's own name via {@link serviceContainerName}. */ @@ -581,7 +583,7 @@ export function buildGotrueContainerSpec(input: GotrueContainerSpecInput): Start env, binds: [], exposedPorts: [{ containerPort: GOTRUE_PORT }], - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck(`http://127.0.0.1:${GOTRUE_PORT}/health`) : { test: [ diff --git a/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts b/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts index 06dba5223c..a0bce99ffb 100644 --- a/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { buildGotrueContainerSpec, @@ -11,10 +11,6 @@ import { type GotrueWebauthnInput, } from "./gotrue.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - // Every field not asserted by a specific subtest below reflects the // default config's own values. const baseEnvInput: BuildGotrueEnvInput = { @@ -674,6 +670,7 @@ describe("buildGotrueEnv", () => { describe("buildGotrueContainerSpec", () => { test("assembles the full container spec, deriving dbHost/dbPassword from projectId/dbUrl", () => { const spec = buildGotrueContainerSpec({ + slim: false, image: "supabase/gotrue:v2.180.0", projectId: "proj", networkId: "supabase_network_proj", @@ -710,8 +707,8 @@ describe("buildGotrueContainerSpec", () => { }); test("uses BusyBox wget flags on a slim auth image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildGotrueContainerSpec({ + slim: true, image: "ghcr.io/supabase/cli/auth:v2.196.0", projectId: "proj", networkId: "supabase_network_proj", diff --git a/apps/cli/src/commands/start/services/logflare.service.ts b/apps/cli/src/commands/start/services/logflare.service.ts index 8ae024e704..a4b39aa6a7 100644 --- a/apps/cli/src/commands/start/services/logflare.service.ts +++ b/apps/cli/src/commands/start/services/logflare.service.ts @@ -49,6 +49,8 @@ const LOGFLARE_ENTRYPOINT_SCRIPT = "EOF\n"; export interface LogflareContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** * The already-resolved `config.analytics.image`. Not part of the decoded * `@supabase/config` schema; resolution is the caller's responsibility. @@ -111,7 +113,7 @@ export function buildLogflareContainerSpec( }; const binds: Array<string> = []; - const slim = usesSlimImageRuntime(input.image); + const slim = usesSlimImageRuntime(input.image, input.slim); if (input.backend === "bigquery") { const hostJwtPath = path.join(input.workdir, input.gcpJwtPath); diff --git a/apps/cli/src/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/commands/start/services/logflare.service.unit.test.ts index dda73d1fad..2555d56798 100644 --- a/apps/cli/src/commands/start/services/logflare.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/logflare.service.unit.test.ts @@ -2,15 +2,12 @@ import { BunPath } from "@effect/platform-bun"; import { Effect, Path } from "effect"; import { it } from "@effect/vitest"; -import { afterEach, describe, expect, vi } from "vitest"; +import { describe, expect } from "vitest"; import { buildLogflareContainerSpec, type LogflareContainerSpecInput } from "./logflare.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const base: LogflareContainerSpecInput = { + slim: false, image: "supabase/logflare:1.0.0", projectId: "proj", networkId: "supabase_network_proj", @@ -172,10 +169,10 @@ describe("buildLogflareContainerSpec", () => { () => { return Effect.gen(function* () { const path = yield* Path.Path; - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildLogflareContainerSpec( { ...base, + slim: true, image: "ghcr.io/supabase/cli/analytics:v1.50.6", backend: "bigquery", gcpProjectId: "my-project", @@ -195,15 +192,15 @@ describe("buildLogflareContainerSpec", () => { it.effect("overrides the entrypoint and uses wget on a slim analytics image", () => { return Effect.gen(function* () { const path = yield* Path.Path; - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const slim = buildLogflareContainerSpec( { ...base, + slim: true, image: "ghcr.io/supabase/cli/analytics:v1.50.6", }, path, ); - const dockerIo = buildLogflareContainerSpec(base, path); + const dockerIo = buildLogflareContainerSpec({ ...base, slim: true }, path); expect(slim.entrypoint).toBe(dockerIo.entrypoint); expect(slim.cmd).toEqual(dockerIo.cmd); expect(slim.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/realtime.service.ts b/apps/cli/src/commands/start/services/realtime.service.ts index 8dafa26358..ea7ca439c4 100644 --- a/apps/cli/src/commands/start/services/realtime.service.ts +++ b/apps/cli/src/commands/start/services/realtime.service.ts @@ -16,6 +16,8 @@ import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; import { startInternalDbPassword } from "../../../command-internal/db-bootstrap/internal-db-connection.ts"; export interface RealtimeContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The sanitized project id. */ readonly projectId: string; /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ @@ -51,7 +53,7 @@ export function buildRealtimeContainerSpec(input: RealtimeContainerSpecInput): S env, binds: [], exposedPorts: [{ containerPort: "4000" }], - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:4000/api/ping", { header: `Host:${REALTIME_TENANT_ID}`, }) diff --git a/apps/cli/src/commands/start/services/realtime.service.unit.test.ts b/apps/cli/src/commands/start/services/realtime.service.unit.test.ts index f6b5812a48..4feba183ed 100644 --- a/apps/cli/src/commands/start/services/realtime.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/realtime.service.unit.test.ts @@ -1,13 +1,10 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { buildRealtimeContainerSpec, type RealtimeContainerSpecInput } from "./realtime.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - describe("buildRealtimeContainerSpec", () => { const input: RealtimeContainerSpecInput = { + slim: false, projectId: "proj", networkId: "supabase_network_proj", image: "supabase/realtime:v2", @@ -68,9 +65,9 @@ describe("buildRealtimeContainerSpec", () => { }); test("uses wget for the healthcheck on a slim realtime image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildRealtimeContainerSpec({ ...input, + slim: true, image: "ghcr.io/supabase/cli/realtime:v2.130.0", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/storage.service.ts b/apps/cli/src/commands/start/services/storage.service.ts index 8a4e6ccf88..b38fe1edd5 100644 --- a/apps/cli/src/commands/start/services/storage.service.ts +++ b/apps/cli/src/commands/start/services/storage.service.ts @@ -135,6 +135,8 @@ export function buildStorageEnv(input: StorageEnvInput): Record<string, string> } export interface StorageContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The sanitized project id. */ readonly projectId: string; /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ @@ -190,7 +192,7 @@ export function buildStorageContainerSpec(input: StorageContainerSpecInput): Sta env, binds: [`${containerName}:${STORAGE_DOCKER_PATH}`], // IPv4 loopback: localhost can resolve to IPv6 on GitPod and miss the listener. - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:5000/status") : { test: [ diff --git a/apps/cli/src/commands/start/services/storage.service.unit.test.ts b/apps/cli/src/commands/start/services/storage.service.unit.test.ts index d2ab97f05a..7cd8bd0d35 100644 --- a/apps/cli/src/commands/start/services/storage.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/storage.service.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { appendStorageVectorEnv, @@ -8,10 +8,6 @@ import { type StorageEnvInput, } from "./storage.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const baseEnvInput: StorageEnvInput = { targetMigration: "", anonKey: "anon-key", @@ -178,6 +174,7 @@ describe("appendStorageVectorEnv", () => { describe("buildStorageContainerSpec", () => { const input: StorageContainerSpecInput = { + slim: false, projectId: "proj", networkId: "supabase_network_proj", image: "supabase/storage-api:v1", @@ -230,9 +227,9 @@ describe("buildStorageContainerSpec", () => { }); test("uses BusyBox wget flags on a slim storage image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildStorageContainerSpec({ ...input, + slim: true, image: "ghcr.io/supabase/cli/storage:v1.72.1", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/supavisor.service.ts b/apps/cli/src/commands/start/services/supavisor.service.ts index dbf7f7d3f9..db2c8b317f 100644 --- a/apps/cli/src/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/commands/start/services/supavisor.service.ts @@ -56,6 +56,8 @@ export function buildSupavisorStartCmd(): ReadonlyArray<string> { } export interface SupavisorContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** The already-resolved `config.db.pooler.image`; resolution is the caller's responsibility. */ readonly image: string; /** The project id, used to derive this container's own name via {@link serviceContainerName}. */ @@ -130,7 +132,7 @@ export function buildSupavisorContainerSpec( ], ports: [{ hostPort: String(input.port), containerPort: dockerPort }], // Slim pooler ships wget, not curl. - healthcheck: usesSlimImageRuntime(input.image) + healthcheck: usesSlimImageRuntime(input.image, input.slim) ? slimWgetHealthcheck("http://127.0.0.1:4000/api/health") : { test: [ diff --git a/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts b/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts index 26839a34ee..b6052078cd 100644 --- a/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/supavisor.service.unit.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { buildSupavisorContainerSpec, @@ -6,11 +6,8 @@ import { type SupavisorContainerSpecInput, } from "./supavisor.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - const base: SupavisorContainerSpecInput = { + slim: false, image: "supabase/supavisor:2.0.0", projectId: "proj", networkId: "supabase_network_proj", @@ -133,9 +130,9 @@ describe("buildSupavisorContainerSpec", () => { }); test("uses wget for the healthcheck on a slim pooler image", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildSupavisorContainerSpec({ ...base, + slim: true, image: "ghcr.io/supabase/cli/pooler:v2.9.12", }); expect(spec.healthcheck?.test).toEqual([ diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts index c82690c315..ec49dab78b 100644 --- a/apps/cli/src/commands/start/services/vector.service.ts +++ b/apps/cli/src/commands/start/services/vector.service.ts @@ -222,11 +222,14 @@ const VECTOR_HEALTHCHECK = { } as const; /** - * Writes the rendered `vector.yaml` via a `cat <<'EOF'` heredoc, waits on Logflare's `/health` - * (sinks would otherwise start too early), then `exec`s Vector so it stays PID 1. A TERM trap - * covers the wait so `docker stop` does not burn 10s if Logflare is still down; `-T 2` bounds each - * probe so a hung health endpoint can't defer the trap. Slim Vector ships BusyBox wget, so the - * wait uses `-q --spider` instead of GNU's `--no-verbose --tries`. + * Creates `/etc/vector` (absent from Vector 0.58's images, both slim and upstream), writes the + * rendered `vector.yaml` via a `cat <<'EOF'` heredoc, waits on Logflare's `/health` (sinks would + * otherwise start too early), then `exec`s Vector so it stays PID 1. A TERM trap covers the wait + * so `docker stop` does not burn 10s if Logflare is still down; `-T 2` bounds each probe so a hung + * health endpoint can't defer the trap. Slim Vector ships BusyBox wget, so the wait uses + * `-q --spider` instead of GNU's `--no-verbose --tries`. Both images run as root, so `mkdir -p` + * needs no separate ownership handling, and `/var/lib/vector` (the `docker_logs` source's + * checkpoint `data_dir`) already exists in both. */ export function buildVectorEntrypointScript( vectorYaml: string, @@ -237,7 +240,7 @@ export function buildVectorEntrypointScript( ? slimWgetWaitCommand(`http://${logflareId}:4000/health`) : `wget --no-verbose --tries=1 -T 2 --spider http://${logflareId}:4000/health`; return ( - "cat <<'EOF' > /etc/vector/vector.yaml\n" + + "mkdir -p /etc/vector\ncat <<'EOF' > /etc/vector/vector.yaml\n" + vectorYaml + "\nEOF\ntrap 'exit 143' TERM\nuntil " + wget + @@ -246,6 +249,8 @@ export function buildVectorEntrypointScript( } export interface VectorContainerSpecInput { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; /** `config.analytics.vector_image`, already resolved/pulled by the caller. */ readonly image: string; /** `serviceContainerName("vector", projectId)`, also used as the `vector.yaml` template's `vectorId` field. */ @@ -276,7 +281,7 @@ export interface VectorContainerSpecInput { /** Builds Vector's {@link StartContainerSpec}. */ export function buildVectorContainerSpec(input: VectorContainerSpecInput): StartContainerSpec { - const slim = usesSlimImageRuntime(input.image); + const slim = usesSlimImageRuntime(input.image, input.slim); const vectorYaml = renderStartVectorYaml({ apiKey: input.apiKey, vectorId: input.containerName, diff --git a/apps/cli/src/commands/start/services/vector.service.unit.test.ts b/apps/cli/src/commands/start/services/vector.service.unit.test.ts index 0f991a8666..10c149fc10 100644 --- a/apps/cli/src/commands/start/services/vector.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/vector.service.unit.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, test } from "@effect/vitest"; -import { afterEach, vi } from "vitest"; import { Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -15,10 +14,6 @@ import { type VectorDockerSocketPlan, } from "./vector.service.ts"; -afterEach(() => { - vi.unstubAllEnvs(); -}); - /** Matches the standing `mockSpawner` shape in `image-prepull.unit.test.ts`. */ function mockSpawner( handler: (args: ReadonlyArray<string>) => { exitCode: number; stdout?: string; stderr?: string }, @@ -213,7 +208,7 @@ describe("resolveVectorDockerSocketPlan", () => { describe("buildVectorEntrypointScript", () => { test("writes vector.yaml then waits on Logflare's health endpoint before exec'ing vector (start.go:449-454)", () => { expect(buildVectorEntrypointScript("VECTOR_YAML", "supabase_analytics_proj")).toBe( - "cat <<'EOF' > /etc/vector/vector.yaml\n" + + "mkdir -p /etc/vector\ncat <<'EOF' > /etc/vector/vector.yaml\n" + "VECTOR_YAML" + "\nEOF\ntrap 'exit 143' TERM\nuntil wget --no-verbose --tries=1 -T 2 --spider http://" + "supabase_analytics_proj" + @@ -223,6 +218,7 @@ describe("buildVectorEntrypointScript", () => { }); const base: VectorContainerSpecInput = { + slim: false, image: "supabase/vector:0.28.1", containerName: "supabase_vector_proj", networkId: "supabase_network_proj", @@ -287,9 +283,9 @@ describe("buildVectorContainerSpec", () => { }); test("slim image waits on Logflare with BusyBox wget flags", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); const spec = buildVectorContainerSpec({ ...base, + slim: true, image: "ghcr.io/supabase/cli/vector:0.53.0", }); expect(spec.entrypoint).toBe("sh"); diff --git a/apps/cli/src/commands/start/start.gates.ts b/apps/cli/src/commands/start/start.gates.ts index eda523cb2e..3c5b21aecd 100644 --- a/apps/cli/src/commands/start/start.gates.ts +++ b/apps/cli/src/commands/start/start.gates.ts @@ -212,6 +212,7 @@ export interface StartImagePlanEntry { */ export function resolveStartImagePlan( gates: StartGates, + slim: boolean, serviceVersions: LocalServiceVersionOverrides = {}, ): ReadonlyArray<StartImagePlanEntry> { const plan: Array<StartImagePlanEntry> = []; @@ -223,8 +224,8 @@ export function resolveStartImagePlan( const localServiceName = START_SERVICE_TO_LOCAL_VERSION_NAME[entry.service]; const image = localServiceName === undefined - ? dockerfileServiceImage(alias) - : resolvePinnedImage(alias, localServiceName, serviceVersions); + ? dockerfileServiceImage(alias, slim) + : resolvePinnedImage(alias, localServiceName, serviceVersions, slim); plan.push({ service: entry.service, image }); } return plan; diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 3dd0155ed9..6c2e52d394 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -134,6 +134,7 @@ import { START_WAITING_FOR_HEALTH_CHECKS_MESSAGE, } from "./start.format.ts"; import { resolveStartGates, resolveStartImagePlan } from "./start.gates.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; import { isUnhealthyStartError, rollbackStart, @@ -755,7 +756,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); // 7. Resolve every image that will actually be pulled before any container is created. - const imagePlan = resolveStartImagePlan(gates, serviceVersionOverrides); + const slim = yield* slimImagesEnabled; + const imagePlan = resolveStartImagePlan(gates, slim, serviceVersionOverrides); // Edge Runtime doesn't go through `resolveStartImagePlan` (see `start.gates.ts`'s header), // so its default image is resolved independently, pre-pulled when enabled and not excluded. const edgeRuntimeDefaultImage = gates.edgeRuntime @@ -1099,6 +1101,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { spec: buildLogflareContainerSpec( { image, + slim, projectId, networkId, port: analyticsPort, @@ -1131,6 +1134,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildVectorContainerSpec({ image, + slim, containerName: vectorContainerName, networkId, apiKey: ANALYTICS_API_KEY, @@ -1189,6 +1193,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildGotrueContainerSpec({ image, + slim, projectId, networkId, dbUrl: values.dbUrl, @@ -1221,6 +1226,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectId, networkId, image, + slim, ipVersion: realtimeIpVersion, maxHeaderLength: realtimeMaxHeaderLength, dbUrl: values.dbUrl, @@ -1249,6 +1255,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectId, networkId, image, + slim, targetMigration: storageTargetMigration, fileSizeLimit: storageFileSizeLimit, s3Region: values.storageS3Region, @@ -1334,6 +1341,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return { spec: buildSupavisorContainerSpec({ image, + slim, projectId, networkId, port: poolerPort, @@ -1395,14 +1403,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ...config.db, port: values.dbPort, major_version: majorVersion, + // Matches the already env-overridden value used to select `postgresImage` above; + // `postgresExtraEnv` reads this and its sibling S3 fields for its + // `POSTGRES_INITDB_ARGS` branch. + orioledb_version: orioledbVersion, settings: resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), }, - // Matches the already env-overridden value used to select `postgresImage` above; - // `postgresExtraEnv` reads this and its sibling S3 fields for its - // `POSTGRES_INITDB_ARGS` branch. experimental: { ...config.experimental, - orioledb_version: orioledbVersion, s3_host: s3Host, s3_region: s3Region, s3_access_key: s3AccessKey, diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index e193f10c95..208203cb30 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -47,6 +47,7 @@ import { } from "../../command-internal/global-flags.ts"; import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; import { serviceContainerIds, serviceContainerName } from "../../command-internal/docker-ids.ts"; +import { HealthCheckTimeoutSeconds } from "../../command-internal/db-bootstrap/health-check.ts"; import { DbConnection, type DbSession } from "../../command-internal/db-connection.service.ts"; import { dockerRunLayer } from "../../command-internal/docker-run.layer.ts"; import { START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; @@ -3278,8 +3279,7 @@ content_path = "./supabase/templates/custom_notice.html" "fails and rolls back when Postgres itself never becomes healthy within its configured health_timeout", () => Effect.gen(function* () { - // `db.health_timeout` is config.toml-configurable, unlike the generic 30s - // `serviceTimeout` other services wait on, so this keeps the scenario fast. + // A zero `db.health_timeout` limits Postgres's wait to a single probe. const neverHealthy = new Set<string>(); const base = defaultRoute({ neverHealthy }); const route = (args: ReadonlyArray<string>): RouteResult => { @@ -3290,7 +3290,7 @@ content_path = "./supabase/templates/custom_notice.html" return base(args); }; const { layer, child } = yield* setup({ - configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "2s"\n', + configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "0s"\n', route, }); @@ -3328,7 +3328,7 @@ content_path = "./supabase/templates/custom_notice.html" return base(args); }; const { layer, out, child, analytics } = yield* setup({ - configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "2s"\n', + configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "0s"\n', route, }); @@ -3352,9 +3352,8 @@ content_path = "./supabase/templates/custom_notice.html" ); // Real time, not `it.effect`/`TestClock`: `start` performs genuine async I/O that never - // resolves under a virtualized clock. `waitForHealthyServices` has no config-configurable - // timeout seam here (it falls back to the hardcoded 30s default), hence the generous - // real-time budget. + // resolves under a virtualized clock. A zero `HealthCheckTimeoutSeconds` keeps the single + // probe that finds auth unhealthy and skips the default retry budget. it.live( "fails and rolls back when a non-Postgres service never becomes healthy within the timeout (no --ignore-health-check)", () => @@ -3383,16 +3382,18 @@ content_path = "./supabase/templates/custom_notice.html" } expect(out.stderrText).not.toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); }); // Real time, not `it.effect`/`TestClock`: genuine async I/O deep inside the forked effect // (HTTP requests and `resolveDbImage`'s file read) needs real Node // event-loop turns to settle, so a virtualized clock would never let the fiber reach the - // health-check phase. Exercises the real 30s `serviceTimeout` bulk health-check wait, hence - // the generous timeout. + // health-check phase. it.live( "exits 0 on --ignore-health-check when a non-Postgres container never turns healthy, without rolling back", () => @@ -3431,8 +3432,11 @@ content_path = "./supabase/templates/custom_notice.html" // `cli_stack_started` never fires on the ignored-unhealthy fallthrough — only a genuine // bulk health-check success reaches that capture. expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); describe("--ignore-health-check storage-only recheck and seed on a fresh volume", () => { @@ -3463,8 +3467,11 @@ content_path = "./supabase/templates/custom_notice.html" expect(out.stderrText).toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(false); expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); it.live( @@ -3507,8 +3514,11 @@ content_path = "./supabase/templates/custom_notice.html" expect(out.stderrText).not.toContain("Do you want to prune it?"); }).pipe(Effect.provide(layer)), ); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); it.live( @@ -3573,13 +3583,13 @@ content_path = "./supabase/templates/custom_notice.html" } expect(rollbackWasAttempted(child.spawned)).toBe(true); expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - 45_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); - // Both the main bulk health check (auth) and this storage-only recheck run out their own - // full ~30s real-time retry budget here, hence the doubled timeout relative to every other - // real-time health-check test in this file. it.live( "falls through to the original warning without attempting to seed when the storage recheck itself never turns healthy", () => @@ -3609,8 +3619,11 @@ content_path = "./supabase/templates/custom_notice.html" expect(out.stderrText).toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(false); expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - 90_000, + }).pipe( + Effect.provideService(HealthCheckTimeoutSeconds, 0), + Effect.provide(BunServices.layer), + ), + 10_000, ); }); @@ -4440,12 +4453,12 @@ content_path = "./supabase/templates/custom_notice.html" ); }); - describe("SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION override", () => { + describe("SUPABASE_DB_ORIOLEDB_VERSION override", () => { it.live( "selects the OrioleDB Postgres image and enables the container's S3 env when set only via env", () => withEnvVar( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + "SUPABASE_DB_ORIOLEDB_VERSION", "16.0.0.1", Effect.gen(function* () { const { layer, child } = yield* setup(); @@ -4469,7 +4482,7 @@ content_path = "./supabase/templates/custom_notice.html" it.live("honors SUPABASE_EXPERIMENTAL_S3_HOST/_REGION/_ACCESS_KEY/_SECRET_KEY", () => withEnvVar( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + "SUPABASE_DB_ORIOLEDB_VERSION", "16.0.0.1", withEnvVar( "SUPABASE_EXPERIMENTAL_S3_HOST", @@ -5001,7 +5014,7 @@ content_path = "./supabase/templates/custom_notice.html" () => withEnvVar( "SUPABASE_DB_HEALTH_TIMEOUT", - "2s", + "0s", Effect.gen(function* () { const neverHealthy = new Set<string>(); const base = defaultRoute({ neverHealthy }); diff --git a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts index 179a7e3ae1..59b3468509 100644 --- a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts @@ -17,6 +17,7 @@ import { import { getRegistryImageUrl } from "../../command-internal/docker-registry.ts"; import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; +import { slimImagesEnabled } from "../../shared/services/slim-images.ts"; class StartE2eSetupError extends Data.TaggedError("StartE2eSetupError")<{ readonly message: string; @@ -286,7 +287,9 @@ describe("supabase start (e2e)", () => { const mailpitContainer = serviceContainerName("inbucket", projectId); // The exact tag `start` resolves for Mailpit, so its already-cached check finds this // broken build without reaching a registry. - const mailpitImage = yield* getRegistryImageUrl(dockerfileServiceImage("mailpit")); + const mailpitImage = yield* getRegistryImageUrl( + dockerfileServiceImage("mailpit", yield* slimImagesEnabled), + ); const init = yield* runSupabaseEffect(["init"], { cwd: projectDir, @@ -335,4 +338,55 @@ describe("supabase start (e2e)", () => { }).pipe(Effect.provide(BunServices.layer)), START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, ); + + // Only Postgres, Logflare, and Vector run. Every other scenario here excludes Logflare and Vector, + // so this is the one that exercises the Vector image's entrypoint end to end. + it.live( + "starts Vector against a real Logflare and reaches healthy", + () => + Effect.gen(function* () { + const projectDir = yield* makeProject("sb-start-e2e-vector-"); + const path = yield* Path.Path; + const projectId = sanitizeProjectId(path.basename(projectDir)); + const vectorContainer = serviceContainerName("vector", projectId); + + const init = yield* runSupabaseEffect(["init"], { + cwd: projectDir, + exitTimeoutMs: SHORT_E2E_TIMEOUT_MS, + }); + requireCliSuccess(init, "init setup"); + yield* overridePorts(projectDir); + + const excludeArgs = SERVICE_CATALOG.flatMap((entry) => + entry.excludeKey === undefined || + entry.excludeKey === "logflare" || + entry.excludeKey === "vector" + ? [] + : ["--exclude", entry.excludeKey], + ); + const start = yield* runSupabaseEffect(["start", ...excludeArgs], { + cwd: projectDir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + + if (start.exitCode !== 0) { + const logs = yield* runDockerEffect(["logs", vectorContainer]).pipe( + Effect.map(({ stdout, stderr }) => `${stdout}${stderr}`.trim() || "<empty>"), + Effect.catch((error) => Effect.succeed(`<unavailable: ${error.message}>`)), + ); + throw new Error( + `start failed (exit ${start.exitCode})\nstdout:\n${start.stdout}\nstderr:\n${start.stderr}\n${vectorContainer} logs:\n${logs}`, + ); + } + + const health = yield* runDockerEffect([ + "inspect", + vectorContainer, + "--format", + "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}", + ]); + expect(health.stdout.trim()).toBe("healthy"); + }).pipe(Effect.provide(BunServices.layer)), + START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS + CLEANUP_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/commands/start/start.services.unit.test.ts b/apps/cli/src/commands/start/start.services.unit.test.ts index 73f6921233..b34feac5c3 100644 --- a/apps/cli/src/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/commands/start/start.services.unit.test.ts @@ -1,10 +1,13 @@ import { CliConfigSchema, type CliConfig } from "@supabase/config"; import { Schema } from "effect"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; -import { toSlimImage } from "../../shared/services/slim-images.ts"; +import { + expectedPinnedImage, + GHCR_SLIM_IMAGE_PATTERN, +} from "../../../tests/helpers/slim-images.ts"; import { serviceContainerIds, localDbContainerId } from "../../command-internal/docker-ids.ts"; import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; import { resolveStartGates, resolveStartImagePlan, type StartGates } from "./start.gates.ts"; @@ -216,10 +219,6 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => }); describe("resolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - const allGatesOpen: StartGates = { kong: true, gotrue: true, @@ -236,26 +235,36 @@ describe("resolveStartImagePlan under SUPABASE_USE_SLIM_IMAGES", () => { edgeRuntime: true, }; - const imageFor = (service: string, serviceVersions: LocalServiceVersionOverrides = {}) => - resolveStartImagePlan(allGatesOpen, serviceVersions).find((entry) => entry.service === service) - ?.image; + const imageFor = ( + service: string, + slim: boolean, + serviceVersions: LocalServiceVersionOverrides = {}, + ) => + resolveStartImagePlan(allGatesOpen, slim, serviceVersions).find( + (entry) => entry.service === service, + )?.image; it("plans docker.io images while the flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - expect(imageFor("gotrue")).toBe(currentGotrue); - expect(imageFor("vector")).toBe(currentVector); - expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + expect(imageFor("gotrue", false)).toBe(currentGotrue); + expect(imageFor("vector", false)).toBe(currentVector); + expect(imageFor("supavisor", false, { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); }); it("plans slim images when the flag is on, keeping unmapped services on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(imageFor("gotrue")).toBe(toSlimImage("gotrue", currentGotrue)); - expect(imageFor("logflare")).toBe(toSlimImage("logflare", currentLogflare)); - expect(imageFor("vector")).toBe(toSlimImage("vector", currentVector)); - expect(imageFor("supavisor", { pooler: currentPoolerTag })).toBe( - toSlimImage("supavisor", currentPooler), - ); - expect(imageFor("supavisor", { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); - expect(imageFor("kong")).toBe("library/kong:2.8.1"); + const gotruePinned = expectedPinnedImage("gotrue", currentGotrue); + const logflarePinned = expectedPinnedImage("logflare", currentLogflare); + const vectorPinned = expectedPinnedImage("vector", currentVector); + const poolerPinned = expectedPinnedImage("supavisor", currentPooler); + for (const pinned of [gotruePinned, logflarePinned, vectorPinned, poolerPinned]) { + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); + } + expect(imageFor("gotrue", true)).toBe(gotruePinned); + expect(imageFor("logflare", true)).toBe(logflarePinned); + expect(imageFor("vector", true)).toBe(vectorPinned); + expect(imageFor("supavisor", true, { pooler: currentPoolerTag })).toBe(poolerPinned); + // Deliberate fallback: a historical pin the catalog doesn't carry stays on docker.io. + expect(imageFor("supavisor", true, { pooler: "2.0.0" })).toBe("supabase/supavisor:2.0.0"); + // Deliberate fallback: kong has no slim build at all. + expect(imageFor("kong", true)).toBe("library/kong:2.8.1"); }); }); diff --git a/apps/cli/src/commands/start/start.slim-images.e2e.test.ts b/apps/cli/src/commands/start/start.slim-images.e2e.test.ts index 65dae6a808..2a44aacfb3 100644 --- a/apps/cli/src/commands/start/start.slim-images.e2e.test.ts +++ b/apps/cli/src/commands/start/start.slim-images.e2e.test.ts @@ -2,9 +2,14 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { BunServices } from "@effect/platform-bun"; import { Clock, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { beforeAll, describe, expect, it } from "@effect/vitest"; +import { catalogPins, type ServiceKind } from "@supabase/stack/internal/artifacts"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../../shared/services/slim-images.ts"; +import { isSlimImageRef, toSlimImage } from "../../shared/services/slim-images.ts"; +import { + expectedPinnedImage, + GHCR_SLIM_IMAGE_PATTERN, +} from "../../../tests/helpers/slim-images.ts"; import { buildHealthCmdArg } from "../../command-internal/db-bootstrap/docker-create-args.ts"; import { slimWgetHealthcheck, @@ -63,20 +68,29 @@ const PULL_ALIASES = [ "mailpit", "kong", ] as const; -/** Slim images whose in-container probe is BusyBox wget. */ -const WGET_PROBE_ALIASES = [ - "gotrue", +/** + * Catalog services whose slim image ships BusyBox wget as its in-container probe (not the + * Dockerfile-derived image: whether the Dockerfile's tag currently matches that catalog pin is + * unrelated to whether the pinned slim image itself accepts the BusyBox argv). + */ +const WGET_PROBE_SERVICES: ReadonlyArray<ServiceKind> = [ + "auth", "realtime", "storage", - "logflare", - "supavisor", + "analytics", "vector", -] as const; + "pooler", +]; + +function wgetProbeCatalogImages(): ReadonlyArray<string> { + return catalogPins() + .filter((entry) => WGET_PROBE_SERVICES.includes(entry.service)) + .map((entry) => entry.pin.image); +} function latestImagesToPull(): ReadonlyArray<string> { - return [...new Set([...PULL_ALIASES, ...WGET_PROBE_ALIASES])].map((alias) => - toSlimImage(alias, dockerfileServiceImageRaw(alias)), - ); + const dockerfileImages = PULL_ALIASES.map((alias) => expectedSlimImage(alias)); + return [...new Set([...dockerfileImages, ...wgetProbeCatalogImages()])]; } function readSectionPort(config: string, section: string): number { @@ -92,8 +106,73 @@ const containerImage = Effect.fnUntraced(function* (name: string) { return stdout.trim(); }); +/** `kong` has no slim build at all, so this stays fallback-tolerant for the pull-ahead list. */ function expectedSlimImage(alias: string): string { - return toSlimImage(alias, dockerfileServiceImageRaw(alias)); + const raw = dockerfileServiceImageRaw(alias); + return toSlimImage(alias, raw) ?? raw; +} + +/** + * The Dockerfile aliases whose spec builder switches its healthcheck on `usesSlimImageRuntime` + * (`*.service.ts`). Every one of them is slim today (`expectedPinnedImage`), but the assertions + * below still derive the expected healthcheck from the image actually resolved, not from that + * assumption directly. + */ +type HealthcheckedAlias = "gotrue" | "storage" | "realtime"; + +/** Mirrors each service's own upstream (non-slim) `healthcheck.test`, `CMD` prefix included. */ +function upstreamHealthcheckTest(alias: HealthcheckedAlias): ReadonlyArray<string> { + switch (alias) { + case "gotrue": + return [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:9999/health", + ]; + case "storage": + return [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://127.0.0.1:5000/status", + ]; + case "realtime": + return [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + `Host:${REALTIME_TENANT_ID}`, + "http://127.0.0.1:4000/api/ping", + ]; + } +} + +/** Mirrors each service's own slim (BusyBox wget) `healthcheck.test`, `CMD` prefix included. */ +function slimHealthcheckTest(alias: HealthcheckedAlias): ReadonlyArray<string> { + switch (alias) { + case "gotrue": + return slimWgetHealthcheck("http://127.0.0.1:9999/health").test; + case "storage": + return slimWgetHealthcheck("http://127.0.0.1:5000/status").test; + case "realtime": + return slimWgetHealthcheck("http://127.0.0.1:4000/api/ping", { + header: `Host:${REALTIME_TENANT_ID}`, + }).test; + } +} + +/** The `healthcheck.test` a container running `image` must have — matched to its family. */ +function expectedHealthcheckTest(alias: HealthcheckedAlias, image: string): ReadonlyArray<string> { + return isSlimImageRef(image) ? slimHealthcheckTest(alias) : upstreamHealthcheckTest(alias); } const containerHealthcheckTest = Effect.fnUntraced(function* (name: string) { @@ -185,19 +264,20 @@ describe("supabase start slim images (e2e)", () => { ); it.live( - "every slim wget image accepts the BusyBox healthcheck argv", + "every pinned slim wget image accepts the BusyBox healthcheck argv", () => Effect.gen(function* () { - for (const alias of WGET_PROBE_ALIASES) { - const image = expectedSlimImage(alias); + for (const entry of catalogPins()) { + if (!WGET_PROBE_SERVICES.includes(entry.service)) continue; + const image = entry.pin.image; const probe = - alias === "realtime" + entry.service === "realtime" ? slimWgetHealthcheck("http://127.0.0.1:9/", { header: `Host:${REALTIME_TENANT_ID}`, }) : slimWgetHealthcheck("http://127.0.0.1:9/"); expectBusyBoxAccepted(yield* runWgetInImage(image, probe.test.slice(2)), image); - if (alias === "vector") { + if (entry.service === "vector") { const waitArgs = slimWgetWaitCommand("http://127.0.0.1:9/").split(" ").slice(1); expectBusyBoxAccepted(yield* runWgetInImage(image, waitArgs), `${image} wait`); } @@ -207,7 +287,7 @@ describe("supabase start slim images (e2e)", () => { ); it.live( - "starts the latest slim images, serves a function without a version pin, and keeps the Dockerfile tag", + "starts each service on its resolved image (slim or upstream), matching healthchecks to family, and serves a function without a version pin", () => Effect.gen(function* () { const projectDir = yield* makeProject("sb-slim-start-e2e-"); @@ -244,25 +324,40 @@ describe("supabase start slim images (e2e)", () => { }); expect(start.exitCode, `stdout:\n${start.stdout}\nstderr:\n${start.stderr}`).toBe(0); - expect(yield* containerImage(dbContainer)).toBe(expectedSlimImage("pg")); - expect(yield* containerImage(storageContainer)).toBe(expectedSlimImage("storage")); - expect(yield* containerImage(edgeRuntimeContainer)).toBe(expectedSlimImage("edgeruntime")); + // Every alias here is slim-capable, and its default Dockerfile tag is generated from the + // catalog, so it matches a catalog pin — so each expected image is read straight from the catalog + // (`expectedPinnedImage`), independent of `toSlimImage`. + const authImage = expectedPinnedImage("gotrue", dockerfileServiceImageRaw("gotrue")); + const realtimeImage = expectedPinnedImage( + "realtime", + dockerfileServiceImageRaw("realtime"), + ); + const storageImage = expectedPinnedImage("storage", dockerfileServiceImageRaw("storage")); + for (const image of [authImage, realtimeImage, storageImage]) { + expect(image).toMatch(GHCR_SLIM_IMAGE_PATTERN); + } + + expect(yield* containerImage(dbContainer)).toBe( + expectedPinnedImage("pg", dockerfileServiceImageRaw("pg")), + ); + expect(yield* containerImage(storageContainer)).toBe(storageImage); + expect(yield* containerImage(edgeRuntimeContainer)).toBe( + expectedPinnedImage("edgeruntime", dockerfileServiceImageRaw("edgeruntime")), + ); + expect(yield* containerImage(authContainer)).toBe(authImage); + expect(yield* containerImage(realtimeContainer)).toBe(realtimeImage); expect(yield* containerHealthcheckTest(authContainer)).toEqual([ "CMD-SHELL", - buildHealthCmdArg(slimWgetHealthcheck("http://127.0.0.1:9999/health").test), + buildHealthCmdArg(expectedHealthcheckTest("gotrue", authImage)), ]); expect(yield* containerHealthcheckTest(realtimeContainer)).toEqual([ "CMD-SHELL", - buildHealthCmdArg( - slimWgetHealthcheck("http://127.0.0.1:4000/api/ping", { - header: `Host:${REALTIME_TENANT_ID}`, - }).test, - ), + buildHealthCmdArg(expectedHealthcheckTest("realtime", realtimeImage)), ]); expect(yield* containerHealthcheckTest(storageContainer)).toEqual([ "CMD-SHELL", - buildHealthCmdArg(slimWgetHealthcheck("http://127.0.0.1:5000/status").test), + buildHealthCmdArg(expectedHealthcheckTest("storage", storageImage)), ]); expect(yield* containerHealthStatus(authContainer)).toBe("healthy"); expect(yield* containerHealthStatus(realtimeContainer)).toBe("healthy"); diff --git a/apps/cli/src/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/commands/storage/rm/rm.integration.test.ts index 2ae7abf059..e60e86ce35 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -207,7 +207,7 @@ describe("storage rm", () => { }); }); - it.live("falls back to the default (no) on an unparseable piped answer", () => { + it.live("declines on an unparseable piped answer", () => { const { layer, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, diff --git a/apps/cli/src/commands/telemetry/telemetry.integration.test.ts b/apps/cli/src/commands/telemetry/telemetry.integration.test.ts index 6eb6d94c75..96d5fdd7ee 100644 --- a/apps/cli/src/commands/telemetry/telemetry.integration.test.ts +++ b/apps/cli/src/commands/telemetry/telemetry.integration.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import { Command } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; import { mockAnalytics, @@ -74,12 +75,14 @@ function setupWithRealAnalytics(dir: string) { Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(envLayer), + Layer.provide(BunServices.layer), ); const analytics = analyticsLayer.pipe( Layer.provide(configLayer), Layer.provide(runtimeInfoLayer), Layer.provide(ttyLayer), Layer.provide(BunServices.layer), + Layer.provide(FetchHttpClient.layer), Layer.provide(envLayer), ); const layer = Layer.mergeAll( diff --git a/apps/cli/src/commands/test/new/SIDE_EFFECTS.md b/apps/cli/src/commands/test/new/SIDE_EFFECTS.md index 0f7baec520..a196f4a04c 100644 --- a/apps/cli/src/commands/test/new/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/new/SIDE_EFFECTS.md @@ -8,11 +8,14 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------ | ------ | -------------------------------------- | -| `<workdir>/supabase/tests/<name>_test.sql` | SQL | always, unless the file already exists | +| Path | Format | When | +| ------------------------------------------ | ------ | ---------------------------------------------------------------------------- | +| `<workdir>/supabase/tests/<name>_test.sql` | SQL | if the name is valid, the file does not already exist, and creation succeeds | -The parent directory `<workdir>/supabase/tests/` is created if missing. +The parent directory `<workdir>/supabase/tests/` is created if missing. A name whose path, +with `..` segments collapsed, lands outside that directory is rejected before any +directory or file is created. The check is on the path text: an existing symlink under +`supabase/tests` is followed on purpose, so shared test folders keep working. ## API Routes @@ -28,11 +31,12 @@ The parent directory `<workdir>/supabase/tests/` is created if missing. ## Exit Codes -| Code | Condition | -| ---- | -------------------------------------- | -| `0` | success | -| `1` | test file already exists | -| `1` | write failure (e.g. permission denied) | +| Code | Condition | +| ---- | -------------------------------------------- | +| `0` | success | +| `1` | invalid test name (escapes `supabase/tests`) | +| `1` | test file already exists | +| `1` | write failure (e.g. permission denied) | ## Output @@ -55,3 +59,9 @@ Emits the same success payload as a final NDJSON `result` event. byte-identical to the original Go template). - `--template` / `-t` selects the template framework (only `pgtap` is supported; default `pgtap`). - Native TypeScript port (Phase 1+); no Go proxy. +- **Path-traversal hardening (TS-only):** the name is rejected before any write if + `<workdir>/supabase/tests/<name>_test.sql` lands outside the tests directory once + `..` segments are collapsed. Nothing is created — no file and no parent directory. + Existing symlinks under `supabase/tests` are followed on purpose (shared test + folders), so the check blocks `..` traversal only. Names that stay inside + `supabase/tests`, optionally with subdirectories, are unaffected. diff --git a/apps/cli/src/commands/test/new/new.e2e.test.ts b/apps/cli/src/commands/test/new/new.e2e.test.ts index 089a4abc9a..725cd15c46 100644 --- a/apps/cli/src/commands/test/new/new.e2e.test.ts +++ b/apps/cli/src/commands/test/new/new.e2e.test.ts @@ -6,12 +6,6 @@ import { runSupabaseEffect } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -/** - * Golden-path e2e: `test new` writes a real file through the compiled-binary - * boundary. Validates `Command.provide` + the runtime layer + FileSystem wiring. - * Branch detail (json/stream-json, exists/write errors) is covered by the - * integration suite. - */ describe("supabase test new", () => { it.live( "scaffolds supabase/tests/<name>_test.sql and prints the created path", @@ -39,4 +33,33 @@ describe("supabase test new", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), E2E_TIMEOUT_MS, ); + + it.live( + "rejects traversal without writing files or terminal controls", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectDir = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-test-new-rejected-e2e-", + }); + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectDir, "supabase", "config.toml"), + 'project_id = "test-new-rejected-e2e"\n', + ); + + const { exitCode, stdout, stderr } = yield* runSupabaseEffect( + ["test", "new", "../../nested/\u001b[2Jx", "--output-format", "text"], + { cwd: projectDir, env: { NO_COLOR: "1", FORCE_COLOR: undefined } }, + ); + expect(exitCode, stderr).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain('invalid test name: "../../nested/[2Jx"'); + expect(stderr).not.toContain("\u001b"); + expect(yield* fs.exists(path.join(projectDir, "nested"))).toBe(false); + expect(yield* fs.exists(path.join(projectDir, "supabase", "tests"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + E2E_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/commands/test/new/new.errors.ts b/apps/cli/src/commands/test/new/new.errors.ts index 407b387acb..74f8003288 100644 --- a/apps/cli/src/commands/test/new/new.errors.ts +++ b/apps/cli/src/commands/test/new/new.errors.ts @@ -19,6 +19,16 @@ export class TestNewFileExistsError extends Data.TaggedError("TestNewFileExistsE } } +/** The test name resolves outside `supabase/tests`. */ +export class TestNewInvalidNameError extends Data.TaggedError("TestNewInvalidNameError")<{ + readonly path: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + /** Writing the test file failed (e.g. permission denied). */ export class TestNewWriteError extends Data.TaggedError("TestNewWriteError")<{ readonly path: string; diff --git a/apps/cli/src/commands/test/new/new.handler.ts b/apps/cli/src/commands/test/new/new.handler.ts index 05cc51eae8..339f7cf117 100644 --- a/apps/cli/src/commands/test/new/new.handler.ts +++ b/apps/cli/src/commands/test/new/new.handler.ts @@ -4,8 +4,13 @@ import { CommandSettings } from "../../../config/command-settings.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { bold } from "../../../command-internal/colors.ts"; +import { sanitizeInlineName } from "../../../command-internal/http-errors.ts"; import type { TestNewFlags } from "./new.command.ts"; -import { TestNewFileExistsError, TestNewWriteError } from "./new.errors.ts"; +import { + TestNewFileExistsError, + TestNewInvalidNameError, + TestNewWriteError, +} from "./new.errors.ts"; import { PGTAP_TEMPLATE } from "./new.template.ts"; const TEMPLATE_CONTENT: Record<"pgtap", string> = { @@ -27,6 +32,16 @@ export const testNew = Effect.fn("test.new")(function* (flags: TestNewFlags) { const relPath = path.join("supabase", "tests", `${flags.name}_test.sql`); const target = path.join(cliSettings.workdir, relPath); + // `path.join` collapses "..", so check the normalized target: names may include + // subdirectories as long as they resolve inside supabase/tests. + const testsDir = path.join(cliSettings.workdir, "supabase", "tests"); + if (!target.startsWith(testsDir + path.sep)) { + return yield* new TestNewInvalidNameError({ + path: relPath, + message: `invalid test name: "${sanitizeInlineName(flags.name)}" must not escape the ${path.join("supabase", "tests")} directory`, + }); + } + const exists = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); if (exists) { return yield* new TestNewFileExistsError({ diff --git a/apps/cli/src/commands/test/new/new.integration.test.ts b/apps/cli/src/commands/test/new/new.integration.test.ts index fba10382e2..98c885f832 100644 --- a/apps/cli/src/commands/test/new/new.integration.test.ts +++ b/apps/cli/src/commands/test/new/new.integration.test.ts @@ -9,6 +9,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; +import { TestNewInvalidNameError } from "./new.errors.ts"; import { PGTAP_TEMPLATE } from "./new.template.ts"; import { testNew } from "./new.handler.ts"; @@ -189,6 +191,88 @@ describe("test new integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("creates test files under subdirectories that stay inside the tests directory", () => { + const { layer, out, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* testNew(flags("sub/foo")); + yield* testNew(flags("sub/../foo")); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests", "sub", "foo_test.sql"))).toBe( + true, + ); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests", "foo_test.sql"))).toBe(true); + expect(out.stdoutText).toContain("supabase/tests/sub/foo_test.sql"); + expect(out.stdoutText).toContain("supabase/tests/foo_test.sql"); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a name that escapes the tests directory and writes nothing", () => { + const { layer, telemetry, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Escapes into a fresh directory inside this test's own temp root, so a guard + // that ran after makeDirectory would leave `nested/` behind. + const exit = yield* Effect.exit(testNew(flags("../../nested/x"))); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toBeInstanceOf(TestNewInvalidNameError); + expect(failure.value.message).toContain("must not escape the supabase/tests directory"); + } + expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ + error_category: "invalid_input", + suggestion_type: "provide_flags", + }); + } + expect(yield* fs.exists(path.join(workdir, "nested"))).toBe(false); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests"))).toBe(false); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("follows an existing symlink to a shared test directory", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const testsDir = path.join(workdir, "supabase", "tests"); + const sharedDir = path.join(workdir, "shared-tests"); + yield* fs.makeDirectory(testsDir, { recursive: true }); + yield* fs.makeDirectory(sharedDir); + yield* fs.symlink(sharedDir, path.join(testsDir, "shared")); + + yield* testNew(flags("shared/pet")); + + expect(yield* fs.readFileString(path.join(sharedDir, "pet_test.sql"))).toBe(PGTAP_TEMPLATE); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a name that escapes into a sibling directory sharing the tests prefix", () => { + const { layer, workdir } = setup(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* Effect.exit(testNew(flags("../tests2/x"))); + + expect(Exit.isFailure(exit)).toBe(true); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests2"))).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("sanitizes control characters in an invalid-name diagnostic", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const error = yield* testNew(flags("../../\u001b[2Jbad\r\n\t\u009bname")).pipe(Effect.flip); + expect(error).toBeInstanceOf(TestNewInvalidNameError); + expect(error.message).toContain('invalid test name: "../../[2Jbad name"'); + }).pipe(Effect.provide(layer)); + }); + it.live("flushes telemetry via ensuring", () => { const { layer, telemetry } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts index b652353041..c8a860db7a 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts @@ -87,7 +87,7 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")( }), ), ); - yield* activating?.clear() ?? Effect.void; + yield* activating?.clear ?? Effect.void; const goOutput = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts index cd58a72015..4a3f176363 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -91,7 +91,7 @@ export const vanitySubdomainsCheckAvailability = Effect.fn("vanity-subdomains.ch }), ), ); - yield* checking?.clear() ?? Effect.void; + yield* checking?.clear ?? Effect.void; const goOutput = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts b/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts index e7113972af..a3c012d68c 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.handler.ts @@ -40,7 +40,7 @@ export const vanitySubdomainsDelete = Effect.fn("vanity-subdomains.delete")(func Effect.tapError(() => deleting?.fail() ?? Effect.void), Effect.catch(mapDeleteError), ); - yield* deleting?.clear() ?? Effect.void; + yield* deleting?.clear ?? Effect.void; // `--output` is ignored entirely (stderr-only success). We still read // the legacy flag so that an explicit --output suppresses the TS json/stream-json diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts index bd20347120..26974c69f7 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.handler.ts @@ -55,7 +55,7 @@ export const vanitySubdomainsGet = Effect.fn("vanity-subdomains.get")(function* Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(gateMapError({ projectRef: ref }, mapGetError)), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; const goOutput = Option.getOrUndefined(outputFlag); diff --git a/apps/cli/src/commands/whoami/whoami.handler.ts b/apps/cli/src/commands/whoami/whoami.handler.ts index bab4125ba7..097240084a 100644 --- a/apps/cli/src/commands/whoami/whoami.handler.ts +++ b/apps/cli/src/commands/whoami/whoami.handler.ts @@ -64,7 +64,7 @@ export const whoami = Effect.fn("whoami")(function* (_flags: WhoamiFlags) { Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch(mapProfileError), ); - yield* fetching?.clear() ?? Effect.void; + yield* fetching?.clear ?? Effect.void; if (output.format !== "text") { yield* emitMachineProfile(profile); diff --git a/apps/cli/src/commands/whoami/whoami.integration.test.ts b/apps/cli/src/commands/whoami/whoami.integration.test.ts index 4de8aebb0f..0b0c38d299 100644 --- a/apps/cli/src/commands/whoami/whoami.integration.test.ts +++ b/apps/cli/src/commands/whoami/whoami.integration.test.ts @@ -1,5 +1,6 @@ import type { V1GetProfileOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Cause, Effect, Exit, Layer, Option, Schema, Stdio } from "effect"; import { GLOBAL_OUTPUT_FORMATS } from "../../command-internal/global-flags.ts"; @@ -227,7 +228,7 @@ describe("whoami integration", () => { Effect.provide( Layer.mergeAll( layer, - commandRuntimeLayer(["whoami"]), + commandRuntimeLayer(["whoami"]).pipe(Layer.provide(BunCrypto.layer)), Stdio.layerTest({ args: Effect.succeed(["whoami", "-o", goOutput]) }), ), ), diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index 8c0be720c1..9cfecd1851 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -116,7 +116,11 @@ export const commandSettingsLayer = Layer.unwrap( const read = <A>(config: Config.Config<A>) => config.parse(provider); const profileEnvValue = yield* read(Config.option(Config.string("SUPABASE_PROFILE"))); const supabaseHome = yield* read(Config.option(Config.string("SUPABASE_HOME"))); - const resolvedSupabaseHome = resolveSupabaseHomeValue(supabaseHome, runtimeInfo.homeDir); + const resolvedSupabaseHome = resolveSupabaseHomeValue( + path, + supabaseHome, + runtimeInfo.homeDir, + ); // Optional service: tests without argv default to "not explicit". An empty command // path scans all of argv up to `--`, matching pflag. diff --git a/apps/cli/src/config/profile-file.ts b/apps/cli/src/config/profile-file.ts index aeba0faa1a..b96d90a1e0 100644 --- a/apps/cli/src/config/profile-file.ts +++ b/apps/cli/src/config/profile-file.ts @@ -21,10 +21,11 @@ import { * directly, so `env` defaults to it. */ export function supabaseHome( + path: Path.Path, homeDir: string, env: Readonly<Record<string, string | undefined>> = process.env, ): string { - return resolveSupabaseHome(env, homeDir); + return resolveSupabaseHome(path, env, homeDir); } /** Raised when persisting the profile name fails — fails `login` outright, @@ -42,7 +43,7 @@ export function profileFilePath( homeDir: string, env?: Readonly<Record<string, string | undefined>>, ): string { - return path.join(supabaseHome(homeDir, env), "profile"); + return path.join(supabaseHome(path, homeDir, env), "profile"); } /** Writes the profile name to the resolved profile path. Fatal on failure. */ diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 760bf0c1ce..edd7bc02d4 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -1,4 +1,5 @@ import type { DocsFlag } from "./docs-spec.ts"; +import { genTypesLanguageFlagDefaults } from "../commands/gen/types/types.languages.ts"; /** * Static data for the docs spec generator — information the Effect command tree cannot @@ -121,6 +122,7 @@ export const DOCS_EXCLUDED: ReadonlySet<string> = new Set([ * flags add entries by hand. */ export const DOCS_DEFAULT_OVERRIDES: Readonly<Record<string, string>> = { + ...genTypesLanguageFlagDefaults(), "supabase agent": "auto", "supabase dns-resolver": "native", "supabase output": "pretty", @@ -150,7 +152,6 @@ export const DOCS_DEFAULT_OVERRIDES: Readonly<Record<string, string>> = { "supabase-gen-signing-key algorithm": "ES256", "supabase-gen-types lang": "typescript", "supabase-gen-types query-timeout": "15s", - "supabase-gen-types swift-access-control": "internal", "supabase-inspect-db-bloat linked": "true", "supabase-inspect-db-blocking linked": "true", "supabase-inspect-db-calls linked": "true", diff --git a/apps/cli/src/output/quiet-progress-text-output.layer.ts b/apps/cli/src/output/quiet-progress-text-output.layer.ts index 0d1da13fe5..3e867e3653 100644 --- a/apps/cli/src/output/quiet-progress-text-output.layer.ts +++ b/apps/cli/src/output/quiet-progress-text-output.layer.ts @@ -23,7 +23,7 @@ export const quietProgressTextOutputLayer = Layer.effect( fail: () => Effect.void, info: () => Effect.void, cancel: () => Effect.void, - clear: () => Effect.void, + clear: Effect.void, }), progress: () => Effect.succeed({ diff --git a/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts b/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts index e1f39746c4..7cf78c14ba 100644 --- a/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts +++ b/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { beforeEach, vi } from "vitest"; import { Effect, Layer, Stdio } from "effect"; +import { TestClock } from "effect/testing"; import { mockTty } from "../../tests/helpers/mocks.ts"; import { Output } from "../shared/output/output.service.ts"; @@ -58,7 +59,6 @@ vi.mock("@clack/prompts", () => ({ beforeEach(() => { vi.resetAllMocks(); - vi.useRealTimers(); mockClack.isCancel.mockReturnValue(false); mockClack.spinnerFactory.mockReturnValue(mockClack.spinnerHandle); }); @@ -70,13 +70,12 @@ describe("quietProgressTextOutputLayer", () => { it.effect("never starts a spinner, even after the spinner delay elapses", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Fetching branches..."); yield* task.message("Still fetching..."); // TASK_SPINNER_DELAY_MS is 200ms; the text layer would show a spinner by now. - vi.advanceTimersByTime(500); - yield* task.clear(); + yield* TestClock.adjust(500); + yield* task.clear; expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); expect(mockClack.spinnerHandle.start).not.toHaveBeenCalled(); diff --git a/apps/cli/src/shared/auth/credentials.layer.unit.test.ts b/apps/cli/src/shared/auth/credentials.layer.unit.test.ts index a75921c995..30e2c2928d 100644 --- a/apps/cli/src/shared/auth/credentials.layer.unit.test.ts +++ b/apps/cli/src/shared/auth/credentials.layer.unit.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { afterEach, beforeEach, vi } from "vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, PlatformError, Redacted } from "effect"; +import { beforeEach, vi } from "vitest"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + PlatformError, + Redacted, +} from "effect"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { mockCliProjectContext, mockRuntimeInfo, @@ -79,6 +86,7 @@ function makeLayer( Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(envLayer), + Layer.provide(BunServices.layer), ), ); return credentialsLayer.pipe( @@ -88,7 +96,16 @@ function makeLayer( ); } -let tempHome: string; +const tempHome = useTempWorkdir("supabase-creds-test-"); + +const writeFallbackToken = (content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supaDir = path.join(tempHome.current, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), content, { mode: 0o600 }); + }); beforeEach(() => { passwords.clear(); @@ -96,11 +113,6 @@ beforeEach(() => { throwOnGetPasswordAccounts.clear(); returnNullForAccounts.clear(); throwOnDeletePasswordAccounts.clear(); - tempHome = mkdtempSync(join(tmpdir(), "supabase-creds-test-")); -}); - -afterEach(() => { - rmSync(tempHome, { recursive: true, force: true }); }); describe("Credentials", () => { @@ -118,7 +130,7 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("decodes Go keyring base64 values from current account", () => { @@ -127,7 +139,7 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("falls back to legacy account when current is missing", () => { @@ -136,7 +148,7 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "legacy-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("prefers current account over legacy", () => { @@ -146,7 +158,7 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("returns none when no token found anywhere", () => { @@ -154,84 +166,79 @@ describe("Credentials", () => { const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("falls back to filesystem when keyring throws", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token-123", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken("fs-token-123"); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "fs-token-123"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("returns Some from filesystem in no-keyring mode", () => { - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-only-token", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken("fs-only-token"); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "fs-only-token"); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); + }).pipe( + Effect.provide( + Layer.mergeAll( + makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }), + BunServices.layer, + ), + ), + ); }); it.effect("returns None when filesystem file is empty", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken(""); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("returns None when filesystem file has only whitespace", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), " \n \t ", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken(" \n \t "); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("falls through when keyring returns null for both accounts", () => { returnNullForAccounts.add("Supabase CLI/access-token"); returnNullForAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-fallback-token", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken("fs-fallback-token"); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; // keyring returns null (falsy) for both → falls through to filesystem expectSomeToken(token, "fs-fallback-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("falls through when keyring returns empty passwords for both accounts", () => { passwords.set("Supabase CLI/access-token", ""); passwords.set("Supabase CLI/supabase", ""); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-empty-keyring-fallback", { mode: 0o600 }); return Effect.gen(function* () { + yield* writeFallbackToken("fs-empty-keyring-fallback"); const { getAccessToken } = yield* Credentials; const token = yield* getAccessToken; expectSomeToken(token, "fs-empty-keyring-fallback"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(Layer.mergeAll(makeLayer(tempHome.current), BunServices.layer))); }); it.effect("surfaces a filesystem read failure instead of treating it as no token", () => { @@ -260,9 +267,9 @@ describe("Credentials", () => { ), }), ); - const runtimeInfoLayer = mockRuntimeInfo({ homeDir: tempHome }); + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: tempHome.current }); const cliProjectContextLayer = mockCliProjectContext(); - const envLayer = processEnvLayer({ HOME: tempHome, SUPABASE_NO_KEYRING: "1" }); + const envLayer = processEnvLayer({ HOME: tempHome.current, SUPABASE_NO_KEYRING: "1" }); const layer = credentialsLayer.pipe( Layer.provide(failingFs), Layer.provide(BunServices.layer), @@ -273,6 +280,7 @@ describe("Credentials", () => { Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(envLayer), + Layer.provide(BunServices.layer), ), ), ); @@ -314,7 +322,7 @@ describe("Credentials", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) expect(error.value).toBeInstanceOf(PlatformError.PlatformError); } - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }, failingFs))); + }).pipe(Effect.provide(makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }, failingFs))); }); it.effect("saves to keyring when available", () => { @@ -322,36 +330,52 @@ describe("Credentials", () => { const { saveAccessToken } = yield* Credentials; yield* saveAccessToken("new-token"); expect(passwords.get("Supabase CLI/access-token")).toBe("new-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("falls back to filesystem when setPassword throws", () => { throwOnSetPassword = true; return Effect.gen(function* () { - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("fallback-token"); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("fallback-token"); + }).pipe(Effect.provide(makeLayer(tempHome.current))); + const content = yield* fs.readFileString( + path.join(tempHome.current, ".supabase", "access-token"), + ); expect(content).toBe("fallback-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("saves to filesystem in no-keyring mode", () => { return Effect.gen(function* () { - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("no-keyring-token"); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("no-keyring-token"); + }).pipe(Effect.provide(makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }))); + const content = yield* fs.readFileString( + path.join(tempHome.current, ".supabase", "access-token"), + ); expect(content).toBe("no-keyring-token"); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("creates .supabase directory if missing", () => { throwOnSetPassword = true; return Effect.gen(function* () { - expect(existsSync(join(tempHome, ".supabase"))).toBe(false); - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("create-dir-token"); - expect(existsSync(join(tempHome, ".supabase"))).toBe(true); - }).pipe(Effect.provide(makeLayer(tempHome))); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.exists(path.join(tempHome.current, ".supabase"))).toBe(false); + yield* Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("create-dir-token"); + }).pipe(Effect.provide(makeLayer(tempHome.current))); + expect(yield* fs.exists(path.join(tempHome.current, ".supabase"))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)); }); }); @@ -381,7 +405,7 @@ describe("Credentials", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) expect(error.value).toBeInstanceOf(PlatformError.PlatformError); } - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }, failingFs))); + }).pipe(Effect.provide(makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }, failingFs))); }); it.effect("returns false when no token exists anywhere", () => { @@ -389,7 +413,7 @@ describe("Credentials", () => { const { deleteAccessToken } = yield* Credentials; const deleted = yield* deleteAccessToken; expect(deleted).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("deletes current keyring account and returns true", () => { @@ -399,7 +423,7 @@ describe("Credentials", () => { const deleted = yield* deleteAccessToken; expect(deleted).toBe(true); expect(passwords.has("Supabase CLI/access-token")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("deletes legacy keyring account when current is absent", () => { @@ -409,7 +433,7 @@ describe("Credentials", () => { const deleted = yield* deleteAccessToken; expect(deleted).toBe(true); expect(passwords.has("Supabase CLI/supabase")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("deletes both keyring accounts when both exist", () => { @@ -421,33 +445,41 @@ describe("Credentials", () => { expect(deleted).toBe(true); expect(passwords.has("Supabase CLI/access-token")).toBe(false); expect(passwords.has("Supabase CLI/supabase")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + }).pipe(Effect.provide(makeLayer(tempHome.current))); }); it.effect("deletes filesystem token and returns true", () => { throwOnDeletePasswordAccounts.add("Supabase CLI/access-token"); throwOnDeletePasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token", { mode: 0o600 }); return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeFallbackToken("fs-token"); + const deleted = yield* Effect.gen(function* () { + const { deleteAccessToken } = yield* Credentials; + return yield* deleteAccessToken; + }).pipe(Effect.provide(makeLayer(tempHome.current))); expect(deleted).toBe(true); - expect(existsSync(join(supaDir, "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + expect(yield* fs.exists(path.join(tempHome.current, ".supabase", "access-token"))).toBe( + false, + ); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("deletes filesystem token in no-keyring mode", () => { - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token", { mode: 0o600 }); return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeFallbackToken("fs-token"); + const deleted = yield* Effect.gen(function* () { + const { deleteAccessToken } = yield* Credentials; + return yield* deleteAccessToken; + }).pipe(Effect.provide(makeLayer(tempHome.current, { SUPABASE_NO_KEYRING: "1" }))); expect(deleted).toBe(true); - expect(existsSync(join(supaDir, "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); + expect(yield* fs.exists(path.join(tempHome.current, ".supabase", "access-token"))).toBe( + false, + ); + }).pipe(Effect.provide(BunServices.layer)); }); }); }); diff --git a/apps/cli/src/shared/auth/crypto.layer.ts b/apps/cli/src/shared/auth/crypto.layer.ts index 7bf8e2db6f..32a1f40ab6 100644 --- a/apps/cli/src/shared/auth/crypto.layer.ts +++ b/apps/cli/src/shared/auth/crypto.layer.ts @@ -1,7 +1,7 @@ import { Buffer } from "node:buffer"; import { createDecipheriv, createECDH, randomUUID, type ECDH } from "node:crypto"; import { hostname, userInfo } from "node:os"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer } from "effect"; import { Crypto, type EncryptedPayload } from "./crypto.service.ts"; @@ -13,17 +13,18 @@ export const cryptoLayer = Layer.sync(Crypto, () => return { ecdh, publicKeyHex: ecdh.getPublicKey("hex", "uncompressed") }; }), generateSessionId: Effect.sync(() => randomUUID()), - defaultTokenName: Effect.sync(() => { - const ts = Date.now(); - try { - const user = userInfo().username; - const host = hostname(); - if (user && host) return `cli_${user}@${host}_${ts}`; - } catch { - /* fall through */ - } - return `cli_${ts}`; - }), + defaultTokenName: Clock.currentTimeMillis.pipe( + Effect.map((ts) => { + try { + const user = userInfo().username; + const host = hostname(); + if (user && host) return `cli_${user}@${host}_${ts}`; + } catch { + /* fall through */ + } + return `cli_${ts}`; + }), + ), decryptToken: (ecdh: ECDH, payload: EncryptedPayload) => Effect.sync(() => { const sharedSecret = ecdh.computeSecret(Buffer.from(payload.publicKey, "hex")); diff --git a/apps/cli/src/shared/auth/crypto.layer.unit.test.ts b/apps/cli/src/shared/auth/crypto.layer.unit.test.ts index 4021000bff..314b78b996 100644 --- a/apps/cli/src/shared/auth/crypto.layer.unit.test.ts +++ b/apps/cli/src/shared/auth/crypto.layer.unit.test.ts @@ -2,7 +2,7 @@ import { Buffer } from "node:buffer"; import { describe, expect, it } from "@effect/vitest"; import { createCipheriv, createECDH, randomBytes } from "node:crypto"; import { vi } from "vitest"; -import { Cause, Effect, Exit } from "effect"; +import { Cause, Clock, Effect, Exit } from "effect"; import { Crypto } from "./crypto.service.ts"; import { cryptoLayer } from "./crypto.layer.ts"; @@ -11,17 +11,16 @@ const mockOs = vi.hoisted(() => ({ userInfoReturnEmptyUsername: false, })); -vi.mock("node:os", async (importOriginal) => { - const actual = await importOriginal<typeof import("node:os")>(); - return { +vi.mock("node:os", (importOriginal) => + importOriginal<typeof import("node:os")>().then((actual) => ({ ...actual, userInfo: (...args: Parameters<typeof actual.userInfo>) => { if (mockOs.userInfoShouldThrow) throw new Error("userInfo unavailable"); if (mockOs.userInfoReturnEmptyUsername) return { ...actual.userInfo(...args), username: "" }; return actual.userInfo(...args); }, - }; -}); + })), +); const testLayer = cryptoLayer; @@ -110,12 +109,12 @@ describe("Crypto", () => { }).pipe(Effect.provide(testLayer)); }); - it.effect("contains a numeric timestamp", () => { - const before = Date.now(); - return Effect.gen(function* () { + it.live("contains a numeric timestamp", () => + Effect.gen(function* () { + const before = yield* Clock.currentTimeMillis; const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; - const after = Date.now(); + const after = yield* Clock.currentTimeMillis; // Token names end with _<timestamp>: cli_<ts> or cli_<user>@<host>_<ts>. const match = name.match(/_(\d+)$/); @@ -123,8 +122,8 @@ describe("Crypto", () => { const ts = Number(match![1]); expect(ts).toBeGreaterThanOrEqual(before); expect(ts).toBeLessThanOrEqual(after); - }).pipe(Effect.provide(testLayer)); - }); + }).pipe(Effect.provide(testLayer)), + ); it.effect("falls back to cli_<ts> when userInfo throws", () => { mockOs.userInfoShouldThrow = true; @@ -134,15 +133,14 @@ describe("Crypto", () => { const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; expect(name).toMatch(/^cli_\d+$/); - }) - .pipe(Effect.provide(testLayer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - mockOs.userInfoShouldThrow = false; - }), - ), - ); + }).pipe( + Effect.provide(testLayer), + Effect.ensuring( + Effect.sync(() => { + mockOs.userInfoShouldThrow = false; + }), + ), + ); }); it.effect("falls back to cli_<ts> when username is empty (if-branch false path)", () => { @@ -151,15 +149,14 @@ describe("Crypto", () => { const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; expect(name).toMatch(/^cli_\d+$/); - }) - .pipe(Effect.provide(testLayer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - mockOs.userInfoReturnEmptyUsername = false; - }), - ), - ); + }).pipe( + Effect.provide(testLayer), + Effect.ensuring( + Effect.sync(() => { + mockOs.userInfoReturnEmptyUsername = false; + }), + ), + ); }); }); diff --git a/apps/cli/src/shared/auth/platform-api.layer.ts b/apps/cli/src/shared/auth/platform-api.layer.ts index 63b205856b..55aa052ecc 100644 --- a/apps/cli/src/shared/auth/platform-api.layer.ts +++ b/apps/cli/src/shared/auth/platform-api.layer.ts @@ -17,13 +17,11 @@ export const makePlatformApiServices = Effect.gen(function* () { const token = Option.isSome(configuredToken) ? configuredToken : storedToken; if (Option.isNone(token)) { - return yield* Effect.fail( - new PlatformAuthRequiredError({ - message: "You are not logged in to Supabase.", - detail: "Platform commands require a management API access token.", - suggestion: "Run `supabase login` or set SUPABASE_ACCESS_TOKEN before retrying.", - }), - ); + return yield* new PlatformAuthRequiredError({ + message: "You are not logged in to Supabase.", + detail: "Platform commands require a management API access token.", + suggestion: "Run `supabase login` or set SUPABASE_ACCESS_TOKEN before retrying.", + }); } const config = { diff --git a/apps/cli/src/shared/auth/platform-api.layer.unit.test.ts b/apps/cli/src/shared/auth/platform-api.layer.unit.test.ts index a1803a9e0c..c4a9f5d132 100644 --- a/apps/cli/src/shared/auth/platform-api.layer.unit.test.ts +++ b/apps/cli/src/shared/auth/platform-api.layer.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Redacted, Stdio } from "effect"; +import { Effect, Exit, Layer, Option, Redacted, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -132,11 +132,9 @@ describe("platformApiLayer", () => { ); return Effect.gen(function* () { - const exit = yield* Effect.gen(function* () { - return yield* PlatformApi; - }).pipe(Effect.provide(layer), Effect.exit); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { + const exit = yield* PlatformApi.pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain("PlatformAuthRequiredError"); } }); @@ -241,14 +239,16 @@ describe("platformApiLayer", () => { yield* api.v1.listAllBranches({ ref: "abcdefghijklmnopqrst" }); }).pipe( withCommandInstrumentation(), - Effect.provide(layer), - Effect.provide(runtimeLayer), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["branches", "list"]), - }), + Layer.mergeAll( + layer, + runtimeLayer, + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["branches", "list"]), + }), + ), ), Effect.tap(() => Effect.sync(() => { diff --git a/apps/cli/src/shared/auth/token.unit.test.ts b/apps/cli/src/shared/auth/token.unit.test.ts index 86400c21a8..b420e388df 100644 --- a/apps/cli/src/shared/auth/token.unit.test.ts +++ b/apps/cli/src/shared/auth/token.unit.test.ts @@ -17,21 +17,15 @@ function expectInvalidTokenError(exit: Exit.Exit<unknown, unknown>) { describe("validateToken", () => { describe("valid tokens", () => { it.live("accepts sbp_ prefix with 40 lowercase hex chars", () => - Effect.gen(function* () { - yield* validateToken(`sbp_${VALID_HEX_40}`); - }), + validateToken(`sbp_${VALID_HEX_40}`), ); it.live("accepts sbp_oauth_ prefix with 40 lowercase hex chars", () => - Effect.gen(function* () { - yield* validateToken(`sbp_oauth_${VALID_HEX_40}`); - }), + validateToken(`sbp_oauth_${VALID_HEX_40}`), ); it.live("accepts all valid hex characters (a-f, 0-9)", () => - Effect.gen(function* () { - yield* validateToken("sbp_abcdef0123456789abcdef0123456789abcdef01"); - }), + validateToken("sbp_abcdef0123456789abcdef0123456789abcdef01"), ); }); diff --git a/apps/cli/src/shared/cli/run.integration.test.ts b/apps/cli/src/shared/cli/run.integration.test.ts index 04cd71dd8d..9aa9075f95 100644 --- a/apps/cli/src/shared/cli/run.integration.test.ts +++ b/apps/cli/src/shared/cli/run.integration.test.ts @@ -4,7 +4,7 @@ import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli"; import { branchesCommand } from "../../commands/branches/branches.command.ts"; import { GLOBAL_FLAGS } from "../../command-internal/global-flags.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; -import { emptyEnv, mockOutput } from "../../../tests/helpers/mocks.ts"; +import { emptyEnv, fakeConsole, mockOutput } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "./cli-args.service.ts"; import { OutputFormatFlag } from "./global-flags.ts"; import { exitCodeForFailure, withoutParseErrorHelpDump } from "./run.ts"; @@ -13,44 +13,6 @@ const testBranchesCommand = branchesCommand.pipe( Command.withGlobalFlags([OutputFormatFlag, ...GLOBAL_FLAGS]), ); -/** - * A `Console.Console` test double that records `log`/`error` calls into `calls` instead of - * writing anywhere. Not `vi.spyOn`-based: spying on `console.log` and `console.error` in the same - * test unreliably breaks call detection under this repo's Bun + Vitest combination. - */ -function fakeConsole(): { readonly console: Console.Console; readonly calls: Array<string> } { - const calls: Array<string> = []; - const unused = () => {}; - return { - calls, - console: { - assert: unused, - clear: unused, - count: unused, - countReset: unused, - debug: unused, - dir: unused, - dirxml: unused, - error: (...args: ReadonlyArray<unknown>) => { - calls.push(`error:${args.join(" ")}`); - }, - group: unused, - groupCollapsed: unused, - groupEnd: unused, - info: unused, - log: (...args: ReadonlyArray<unknown>) => { - calls.push(`log:${args.join(" ")}`); - }, - table: unused, - time: unused, - timeEnd: unused, - timeLog: unused, - trace: unused, - warn: unused, - }, - }; -} - /** * Runs the real `branchesCommand` definition directly (not nested under `rootCommand`) through * `Command.runWith`, so the `ShowHelp` cause shape is the one the real CLI produces. Bypassing @@ -97,8 +59,8 @@ describe("group command exit codes (CLI-1906)", () => { /** * Runs commands through `Command.runWith`, wrapped in `withoutParseErrorHelpDump`, and asserts on - * the calls recorded by a fake `Console.Console` (see `fakeConsole` above) substituted for the - * real one — the exact service `withoutParseErrorHelpDump` overrides and replays through. + * the calls recorded by `fakeConsole()` substituted for the real `Console.Console` — the exact + * service `withoutParseErrorHelpDump` overrides and replays through. * * `branchesCommand` covers the `UnrecognizedOption` shape end to end. `MissingOption`/ * `InvalidValue` need a genuinely required flag or `Flag.choice`, which every shipped command diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 1e54c8e767..f5766aaf30 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -522,6 +522,7 @@ function cliSettingsLayerFor(runtimeLayer: Layer.Layer<never>) { return cliSettingsLayer.pipe( Layer.provide(cliProjectContextLayerFor(runtimeLayer)), Layer.provide(runtimeLayer), + Layer.provide(BunServices.layer), ); } diff --git a/apps/cli/src/shared/cli/version.integration.test.ts b/apps/cli/src/shared/cli/version.integration.test.ts index b3ee0e4f01..77edf2dadb 100644 --- a/apps/cli/src/shared/cli/version.integration.test.ts +++ b/apps/cli/src/shared/cli/version.integration.test.ts @@ -7,44 +7,70 @@ import { vi } from "vitest"; import { rootCommand } from "../../cli/root.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; import { CliArgs } from "./cli-args.service.ts"; +import { CLI_VERSION } from "./version.ts"; const formatLogArg = (value: unknown): string => typeof value === "object" && value !== null ? JSON.stringify(value) : String(value); -describe("CLI --version (text)", () => { - const versionLayer = (args: ReadonlyArray<string>) => - Layer.mergeAll( - CliOutput.layer(textCliOutputFormatter()), - Layer.succeed(CliArgs, { args }), - BunServices.layer, +const builtinLayer = (args: ReadonlyArray<string>) => + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + BunServices.layer, + ); + +/** + * Captures `console.log` while `run` executes. Spying on `console.log` alone is reliable here; + * `run.integration.test.ts` explains why pairing it with a `console.error` spy is not. + */ +async function captureLogs(run: () => Promise<void>): Promise<Array<string>> { + const logs: string[] = []; + const spy = vi.spyOn(console, "log").mockImplementation((first?: unknown, ...rest: unknown[]) => { + const line = + rest.length === 0 + ? first === undefined + ? "" + : formatLogArg(first) + : [first, ...rest].map(formatLogArg).join(" "); + logs.push(line); + }); + try { + await run(); + } finally { + spy.mockRestore(); + } + return logs; +} + +describe("CLI --help (text)", () => { + test("source runs describe themselves as a development build", async () => { + // `Command.runWith` keeps handler/global-flag services in the effect type even when the + // built-in `--help`/`--version` exits early; only BunServices + CliOutput are needed here. + const logs = await captureLogs(() => + Effect.runPromise( + Command.runWith(rootCommand, { version: CLI_VERSION })(["--help"]).pipe( + Effect.provide(builtinLayer(["--help"])), + ) as Effect.Effect<void>, + ), ); + const help = logs.join("\n"); + expect(help).toContain("Supabase CLI (development build)."); + expect(help).not.toContain("stable channel"); + }); +}); +describe("CLI --version (text)", () => { test("CLI prints bare semver on stdout", async () => { - const logs: string[] = []; - const spy = vi - .spyOn(console, "log") - .mockImplementation((first?: unknown, ...rest: unknown[]) => { - const line = - rest.length === 0 - ? first === undefined - ? "" - : formatLogArg(first) - : [first, ...rest].map(formatLogArg).join(" "); - logs.push(line); - }); - try { - // `Command.runWith` keeps handler/global-flag services in the effect type even when - // `--version` exits early; only BunServices + CliOutput are needed at runtime here. - await Effect.runPromise( - Command.runWith(rootCommand, { version: "2.99.0-beta.1" })(["--version"]).pipe( - Effect.provide(versionLayer(["--version"])), + const version = "2.99.0-beta.1"; + const logs = await captureLogs(() => + Effect.runPromise( + Command.runWith(rootCommand, { version })(["--version"]).pipe( + Effect.provide(builtinLayer(["--version"])), ) as Effect.Effect<void>, - ); - } finally { - spy.mockRestore(); - } + ), + ); expect(logs.length).toBeGreaterThanOrEqual(1); - expect(logs[0]).toMatch(/^\d+\.\d+\.\d+/); + expect(logs[0]).toBe(version); expect(logs[0]).not.toMatch(/supabase\s+v/i); }); diff --git a/apps/cli/src/shared/cli/version.ts b/apps/cli/src/shared/cli/version.ts index 04c39c621f..e7f5d99d40 100644 --- a/apps/cli/src/shared/cli/version.ts +++ b/apps/cli/src/shared/cli/version.ts @@ -13,3 +13,51 @@ export const CLI_VERSION = */ export const CLI_UPGRADE_GUIDE_URL = "https://supabase.com/docs/guides/cli/getting-started#updating-the-supabase-cli"; + +export interface ParsedSemver { + readonly nums: readonly [string, string, string]; + readonly prerelease: string; +} + +/** + * Parses a bare semver string (no leading `v`); minor and patch may be omitted and build + * metadata is ignored. Returns `undefined` for anything the semver grammar rejects. + */ +export function parseSemver(version: string): ParsedSemver | undefined { + const match = + /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)?(?![\s\S])/.exec( + version, + ); + if (match === null) return undefined; + const prerelease = match[4] ?? ""; + if ( + prerelease + .split(".") + .some( + (identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier[0] === "0", + ) + ) { + return undefined; + } + return { + nums: [match[1]!, match[2] ?? "0", match[3] ?? "0"], + prerelease, + }; +} + +/** + * Which kind of build a CLI version string identifies. `beta` is the `develop` release train, + * `preview` is a pull-request package, and `development` covers source runs, local builds, and + * any prerelease identifier the release pipeline does not produce. + */ +export type CliBuildChannel = "stable" | "beta" | "preview" | "development"; + +export function cliBuildChannel(version: string): CliBuildChannel { + const parsed = parseSemver(version); + if (parsed === undefined) return "development"; + if (parsed.prerelease === "") return "stable"; + const identifier = parsed.prerelease.split(".")[0]; + if (identifier === "beta") return "beta"; + if (identifier === "pr") return "preview"; + return "development"; +} diff --git a/apps/cli/src/shared/cli/version.unit.test.ts b/apps/cli/src/shared/cli/version.unit.test.ts new file mode 100644 index 0000000000..a7903de40b --- /dev/null +++ b/apps/cli/src/shared/cli/version.unit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { cliBuildChannel, parseSemver } from "./version.ts"; + +describe("parseSemver", () => { + it.each([ + ["3.0.0", { nums: ["3", "0", "0"], prerelease: "" }], + ["3.0.0-beta.12", { nums: ["3", "0", "0"], prerelease: "beta.12" }], + ["0.0.0-pr.1234", { nums: ["0", "0", "0"], prerelease: "pr.1234" }], + ["2.114", { nums: ["2", "114", "0"], prerelease: "" }], + ["2.114.0+build.7", { nums: ["2", "114", "0"], prerelease: "" }], + ])("parses %j", (version, expected) => { + expect(parseSemver(version)).toEqual(expected); + }); + + it.each([ + ["v3.0.0", "a leading v"], + ["", "an empty string"], + ["not-a-version", "non-numeric parts"], + ["02.1.0", "a leading zero in the major"], + ["2.1.0-01", "a leading zero in a numeric prerelease id"], + ["2.1.0-alpha..1", "an empty prerelease id"], + ])("rejects %j (%s)", (version) => { + expect(parseSemver(version)).toBeUndefined(); + }); +}); + +describe("cliBuildChannel", () => { + it.each([ + ["3.0.0", "stable"], + ["3.0.0-beta.12", "beta"], + ["0.0.0-pr.1234", "preview"], + ["0.0.0-dev", "development"], + ["0.0.0-automated", "development"], + ["3.1.0-alpha.1", "development"], + ["not-a-version", "development"], + ])("%j is a %s build", (version, expected) => { + expect(cliBuildChannel(version)).toBe(expected); + }); +}); diff --git a/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts index c853d54073..f10c95ee46 100644 --- a/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts @@ -1,22 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; import { CliProjectContext } from "./cli-project-context.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-context-")); -} +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-project-context-" }), +); -function buildLayer(opts: { cwd: string; env?: Record<string, string> }) { +function buildLayer(path: Path.Path, opts: { cwd: string; env?: Record<string, string> }) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: join(opts.cwd, ".home"), + homeDir: path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); return cliProjectContextLayer.pipe( @@ -27,36 +23,34 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string> }) { } describe("cliProjectContextLayer", () => { - it.live("loads when supabase/config.toml uses env() on numeric fields (CLI-1489)", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); + it.live("loads when supabase/config.toml uses env() on numeric fields (CLI-1489)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile( - join(projectRoot, "supabase", "config.toml"), - [ - 'project_id = "with-env-ports"', - "", - "[api]", - 'port = "env(SUPABASE_API_PORT)"', - "", - "[db]", - 'port = "env(SUPABASE_DB_PORT)"', - "", - "[analytics]", - 'port = "env(SUPABASE_ANALYTICS_PORT)"', - "", - ].join("\n"), - ), + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + [ + 'project_id = "with-env-ports"', + "", + "[api]", + 'port = "env(SUPABASE_API_PORT)"', + "", + "[db]", + 'port = "env(SUPABASE_DB_PORT)"', + "", + "[analytics]", + 'port = "env(SUPABASE_ANALYTICS_PORT)"', + "", + ].join("\n"), ); - const cliProjectContext = yield* Effect.gen(function* () { - return yield* CliProjectContext; - }).pipe( + const cliProjectContext = yield* CliProjectContext.pipe( Effect.provide( - buildLayer({ + buildLayer(path, { cwd: projectRoot, env: { SUPABASE_API_PORT: "54321", @@ -72,23 +66,20 @@ describe("cliProjectContextLayer", () => { expect(cliProjectContext.paths.value.projectRoot).toBe(projectRoot); } expect(Option.isSome(cliProjectContext.projectEnv)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("returns empty context when no supabase project is found", () => { - const tempDir = makeTempDir(); + it.live("returns empty context when no supabase project is found", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; - return Effect.gen(function* () { - const cliProjectContext = yield* Effect.gen(function* () { - return yield* CliProjectContext; - }).pipe(Effect.provide(buildLayer({ cwd: tempDir }))); + const cliProjectContext = yield* CliProjectContext.pipe( + Effect.provide(buildLayer(path, { cwd: tempDir })), + ); expect(Option.isNone(cliProjectContext.paths)).toBe(true); expect(Option.isNone(cliProjectContext.projectEnv)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts index a3862c51af..e2843f0400 100644 --- a/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliSettingsLayer } from "./cli-settings.layer.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; @@ -12,14 +8,17 @@ import { cliProjectHomeLayer } from "./cli-project-home.layer.ts"; import { CliProjectContext } from "./cli-project-context.service.ts"; import { CliProjectHome, CliProjectHomeNotDirectoryError } from "./cli-project-home.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-home-")); -} +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-project-home-" }), +); -function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: string }) { +function buildLayer( + path: Path.Path, + opts: { cwd: string; env?: Record<string, string>; homeDir?: string }, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir ?? path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredCliProjectContextLayer = cliProjectContextLayer.pipe( @@ -28,6 +27,7 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: Layer.provide(envLayer), ); const discoveredCliSettingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), Layer.provide(discoveredCliProjectContextLayer), ); @@ -49,18 +49,21 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: } describe("cliProjectHomeLayer", () => { - it.live("resolves a repo-local project home from the nearest discovered config root", () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const cwd = join(packageRoot, "src"); - const supabaseHome = join(tempDir, "supabase-home"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(packageRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(packageRoot, "supabase", "config.toml"), 'project_id = "web"\n'), + it.live("resolves a repo-local project home from the nearest discovered config root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const repoRoot = path.join(tempDir, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(packageRoot, "src"); + const supabaseHome = path.join(tempDir, "supabase-home"); + + yield* fs.makeDirectory(path.join(packageRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", "config.toml"), + 'project_id = "web"\n', ); const { cliProjectHome, cliProjectContext } = yield* Effect.gen(function* () { @@ -68,110 +71,102 @@ describe("cliProjectHomeLayer", () => { cliProjectHome: yield* CliProjectHome, cliProjectContext: yield* CliProjectContext, }; - }).pipe(Effect.provide(buildLayer({ cwd, env: { SUPABASE_HOME: supabaseHome } }))); + }).pipe(Effect.provide(buildLayer(path, { cwd, env: { SUPABASE_HOME: supabaseHome } }))); expect(Option.isSome(cliProjectContext.paths)).toBe(true); expect(cliProjectHome.projectRoot).toBe(packageRoot); - expect(cliProjectHome.supabaseDir).toBe(join(packageRoot, "supabase")); - expect(cliProjectHome.projectHomeDir).toBe(join(packageRoot, ".supabase")); + expect(cliProjectHome.supabaseDir).toBe(path.join(packageRoot, "supabase")); + expect(cliProjectHome.projectHomeDir).toBe(path.join(packageRoot, ".supabase")); expect(cliProjectHome.projectLocalVersionsPath).toBe( - join(packageRoot, ".supabase", "local-versions.json"), + path.join(packageRoot, ".supabase", "local-versions.json"), ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("falls back to the nearest linked project root when no project config exists", () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const projectRoot = join(repoRoot, "apps", "web"); - const cwd = join(projectRoot, "src", "feature"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, ".supabase", "project.json"), "{}\n"), - ); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - const layer = buildLayer({ cwd, env: { SUPABASE_HOME: join(tempDir, "supabase-home") } }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + it.live("falls back to the nearest linked project root when no project config exists", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const repoRoot = path.join(tempDir, "repo"); + const projectRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(projectRoot, "src", "feature"); + + yield* fs.makeDirectory(path.join(projectRoot, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, ".supabase", "project.json"), "{}\n"); + yield* fs.makeDirectory(cwd, { recursive: true }); + + const layer = buildLayer(path, { + cwd, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); expect(cliProjectHome.projectRoot).toBe(projectRoot); - expect(cliProjectHome.projectHomeDir).toBe(join(projectRoot, ".supabase")); - expect(cliProjectHome.supabaseDir).toBe(join(projectRoot, "supabase")); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + expect(cliProjectHome.projectHomeDir).toBe(path.join(projectRoot, ".supabase")); + expect(cliProjectHome.supabaseDir).toBe(path.join(projectRoot, "supabase")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("does not let a bare ancestor .supabase directory capture a nested checkout", () => { - const tempDir = makeTempDir(); - const parentRoot = join(tempDir, "workspace"); - const cwd = join(parentRoot, "test-cli-v3"); + it.live("does not let a bare ancestor .supabase directory capture a nested checkout", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const parentRoot = path.join(tempDir, "workspace"); + const cwd = path.join(parentRoot, "test-cli-v3"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(parentRoot, ".supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); + yield* fs.makeDirectory(path.join(parentRoot, ".supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); - const layer = buildLayer({ cwd, env: { SUPABASE_HOME: join(tempDir, "supabase-home") } }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { + cwd, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); expect(cliProjectHome.projectRoot).toBe(cwd); - expect(cliProjectHome.projectHomeDir).toBe(join(cwd, ".supabase")); - expect(cliProjectHome.projectLinkPath).toBe(join(cwd, ".supabase", "project.json")); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + expect(cliProjectHome.projectHomeDir).toBe(path.join(cwd, ".supabase")); + expect(cliProjectHome.projectLinkPath).toBe(path.join(cwd, ".supabase", "project.json")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("creates the repo-local .supabase directory lazily", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); + it.live("creates the repo-local .supabase directory lazily", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); - return Effect.gen(function* () { - const layer = buildLayer({ + const layer = buildLayer(path, { cwd: projectRoot, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); yield* cliProjectHome.ensureCliProjectHomeDir; - yield* Effect.tryPromise(() => writeFile(cliProjectHome.projectLinkPath, "{}\n")); - expect(yield* Effect.tryPromise(() => readFile(cliProjectHome.projectLinkPath, "utf8"))).toBe( - "{}\n", - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + yield* fs.writeFileString(cliProjectHome.projectLinkPath, "{}\n"); + expect(yield* fs.readFileString(cliProjectHome.projectLinkPath)).toBe("{}\n"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.live( "fails with CliProjectHomeNotDirectoryError when a FILE occupies the .supabase path", - () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(projectRoot, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, ".supabase"), "not a directory\n"), - ); + yield* fs.makeDirectory(projectRoot, { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, ".supabase"), "not a directory\n"); - const layer = buildLayer({ + const layer = buildLayer(path, { cwd: projectRoot, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); const exit = yield* cliProjectHome.ensureCliProjectHomeDir.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -184,32 +179,29 @@ describe("cliProjectHomeLayer", () => { expect(error.value.message).toContain("could not be created"); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); it.live( "fails with CliProjectHomeNotDirectoryError (BadResource) when a FILE occupies an ancestor of the project home path", - () => { + () => // Distinct from the AlreadyExists case above: here `.supabase` doesn't exist, but a file // sits on one of its own parent directories, so `mkdir` fails with ENOTDIR while // traversing, not EEXIST on the leaf itself. - const tempDir = makeTempDir(); - const fileAsDir = join(tempDir, "proj"); - const cwd = join(fileAsDir, "child"); + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const fileAsDir = path.join(tempDir, "proj"); + const cwd = path.join(fileAsDir, "child"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeFile(fileAsDir, "not a directory\n")); + yield* fs.writeFileString(fileAsDir, "not a directory\n"); - const layer = buildLayer({ + const layer = buildLayer(path, { cwd, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); const exit = yield* cliProjectHome.ensureCliProjectHomeDir.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -222,9 +214,6 @@ describe("cliProjectHomeLayer", () => { expect(error.value.message).toContain("could not be created"); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/shared/config/cli-project-local-service-versions.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-local-service-versions.layer.unit.test.ts index 007ab35322..4f422e9147 100644 --- a/apps/cli/src/shared/config/cli-project-local-service-versions.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-local-service-versions.layer.unit.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, PlatformError } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + PlatformError, + Schema, +} from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliSettingsLayer } from "./cli-settings.layer.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; @@ -13,19 +19,24 @@ import { cliProjectLocalServiceVersionsLayer } from "./cli-project-local-service import { CliProjectHome } from "./cli-project-home.service.ts"; import { CliProjectLocalServiceVersions } from "./cli-project-local-service-versions.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-local-versions-")); -} - -function buildLayer(opts: { - cwd: string; - env?: Record<string, string>; - homeDir?: string; - fs?: Layer.Layer<FileSystem.FileSystem>; -}) { +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-project-local-versions-" }), +); + +const PrettyJsonString = Schema.fromJsonString(Schema.Unknown, { space: 2 }); + +function buildLayer( + path: Path.Path, + opts: { + cwd: string; + env?: Record<string, string>; + homeDir?: string; + fs?: Layer.Layer<FileSystem.FileSystem>; + }, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir ?? path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredCliProjectContextLayer = cliProjectContextLayer.pipe( @@ -34,6 +45,7 @@ function buildLayer(opts: { Layer.provide(envLayer), ); const discoveredCliSettingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), Layer.provide(discoveredCliProjectContextLayer), ); @@ -61,8 +73,6 @@ function buildLayer(opts: { describe("cliProjectLocalServiceVersionsLayer", () => { it.live("surfaces a filesystem read permission failure", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); const fsLayer = Layer.succeed( FileSystem.FileSystem, FileSystem.makeNoop({ @@ -80,10 +90,14 @@ describe("cliProjectLocalServiceVersionsLayer", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); - - const layer = buildLayer({ cwd: projectRoot, fs: fsLayer }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); + + const layer = buildLayer(path, { cwd: projectRoot, fs: fsLayer }); const localVersions = yield* CliProjectLocalServiceVersions.pipe(Effect.provide(layer)); const exit = yield* Effect.exit(localVersions.load); @@ -95,20 +109,19 @@ describe("cliProjectLocalServiceVersionsLayer", () => { expect(error.value).toBeInstanceOf(PlatformError.PlatformError); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)); }); - it.live("fails with a tagged error when local service versions are malformed", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + it.live("fails with a tagged error when local service versions are malformed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); - const layer = buildLayer({ cwd: projectRoot }); + const layer = buildLayer(path, { cwd: projectRoot }); const { cliProjectHome, localVersions } = yield* Effect.gen(function* () { return { cliProjectHome: yield* CliProjectHome, @@ -117,9 +130,7 @@ describe("cliProjectLocalServiceVersionsLayer", () => { }).pipe(Effect.provide(layer)); yield* cliProjectHome.ensureCliProjectHomeDir; - yield* Effect.tryPromise(() => - writeFile(cliProjectHome.projectLocalVersionsPath, "{not-json"), - ); + yield* fs.writeFileString(cliProjectHome.projectLocalVersionsPath, "{not-json"); const exit = yield* Effect.exit(localVersions.load); expect(Exit.isFailure(exit)).toBe(true); @@ -130,21 +141,20 @@ describe("cliProjectLocalServiceVersionsLayer", () => { expect(error.value).toMatchObject({ _tag: "InvalidLocalServiceVersionsStateError" }); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("loads local service version overrides from repo-local state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + it.live("loads local service version overrides from repo-local state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); + + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); const { cliProjectHome, localVersions } = yield* Effect.gen(function* () { return { cliProjectHome: yield* CliProjectHome, @@ -153,21 +163,15 @@ describe("cliProjectLocalServiceVersionsLayer", () => { }).pipe(Effect.provide(layer)); yield* cliProjectHome.ensureCliProjectHomeDir; - yield* Effect.tryPromise(() => - writeFile( - cliProjectHome.projectLocalVersionsPath, - JSON.stringify( - { - updatedAt: "2026-03-21T12:00:00.000Z", - versions: { - auth: "v2.180.0", - storage: "1.40.0", - }, - }, - null, - 2, - ), - ), + yield* fs.writeFileString( + cliProjectHome.projectLocalVersionsPath, + yield* Schema.encodeEffect(PrettyJsonString)({ + updatedAt: "2026-03-21T12:00:00.000Z", + versions: { + auth: "v2.180.0", + storage: "1.40.0", + }, + }), ); const loaded = yield* localVersions.load; @@ -178,29 +182,24 @@ describe("cliProjectLocalServiceVersionsLayer", () => { storage: "1.40.0", }); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("returns none when no local override file exists", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const localVersions = yield* Effect.gen(function* () { - return yield* CliProjectLocalServiceVersions; - }).pipe(Effect.provide(layer)); + it.live("returns none when no local override file exists", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); + + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const localVersions = yield* CliProjectLocalServiceVersions.pipe(Effect.provide(layer)); const loaded = yield* localVersions.load; expect(Option.isNone(loaded)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/config/cli-settings.layer.ts b/apps/cli/src/shared/config/cli-settings.layer.ts index 22110d5c63..f5316c4896 100644 --- a/apps/cli/src/shared/config/cli-settings.layer.ts +++ b/apps/cli/src/shared/config/cli-settings.layer.ts @@ -1,4 +1,4 @@ -import { Config, ConfigProvider, Effect, Layer, Option, Redacted } from "effect"; +import { Config, ConfigProvider, Effect, Layer, Option, Path, Redacted } from "effect"; import { resolveSupabaseHomeValue } from "./supabase-home.ts"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import { resolvePosthogConfig } from "../telemetry/posthog-config.ts"; @@ -10,6 +10,7 @@ const SUPABASE_DASHBOARD_URL = "https://supabase.com/dashboard"; const SUPABASE_PROJECT_HOST = "supabase.co"; const makeCliSettings = Effect.gen(function* () { + const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const cliProjectContext = yield* CliProjectContext; const ambientProvider = yield* ConfigProvider.ConfigProvider; @@ -41,7 +42,7 @@ const makeCliSettings = Effect.gen(function* () { (token) => Redacted.make(token, { label: "SUPABASE_ACCESS_TOKEN" }), ), noKeyring: yield* read(Config.option(Config.string("SUPABASE_NO_KEYRING"))), - supabaseHome: resolveSupabaseHomeValue(supabaseHome, runtimeInfo.homeDir), + supabaseHome: resolveSupabaseHomeValue(path, supabaseHome, runtimeInfo.homeDir), debug: yield* read(Config.option(Config.string("SUPABASE_DEBUG"))), telemetryDebug: yield* read(Config.option(Config.string("SUPABASE_TELEMETRY_DEBUG"))), telemetryDisabled: yield* read(Config.option(Config.string("SUPABASE_TELEMETRY_DISABLED"))), diff --git a/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts b/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts index e57e201c20..62e409f354 100644 --- a/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { ConfigProvider, Effect, Layer, Option, Redacted } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { mockCliProjectContext, mockRuntimeInfo, @@ -16,19 +12,22 @@ import { cliSettingsLayer } from "./cli-settings.layer.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; import { CliProjectContext } from "./cli-project-context.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-cli-settings-")); -} +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-cli-settings-" }), +); -function buildLayer(opts: { - cwd: string; - env?: Record<string, string>; - providerEnv?: Record<string, string>; - homeDir?: string; -}) { +function buildLayer( + path: Path.Path, + opts: { + cwd: string; + env?: Record<string, string>; + providerEnv?: Record<string, string>; + homeDir?: string; + }, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir ?? path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredCliProjectContextLayer = cliProjectContextLayer.pipe( @@ -37,6 +36,7 @@ function buildLayer(opts: { Layer.provide(envLayer), ); const discoveredCliSettingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), Layer.provide(discoveredCliProjectContextLayer), Layer.provide( @@ -59,82 +59,86 @@ function buildLayer(opts: { describe("cliSettingsLayer", () => { for (const optOut of ["SUPABASE_TELEMETRY_DISABLED", "DO_NOT_TRACK"]) { - it.live(`honors injected ${optOut} alongside discovered project settings`, () => { - const cwd = makeTempDir(); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"))); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "demo"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "SUPABASE_DEBUG=\n"), + it.live(`honors injected ${optOut} alongside discovered project settings`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fs.makeDirectory(path.join(cwd, "supabase")); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + 'project_id = "demo"\n', ); + yield* fs.writeFileString(path.join(cwd, "supabase", ".env"), "SUPABASE_DEBUG=\n"); yield* Effect.gen(function* () { const settings = yield* CliSettings; expect(settings.debug).toEqual(Option.some("")); expect(yield* getEffectiveConsent(Option.none())).toBe("denied"); }).pipe( Effect.provide( - buildLayer({ cwd, providerEnv: { [optOut]: "1", SUPABASE_DEBUG: "true" } }), + buildLayer(path, { cwd, providerEnv: { [optOut]: "1", SUPABASE_DEBUG: "true" } }), ), ); - }).pipe(Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true })))); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); } - it.live("falls back to ambient env when no Supabase project is found", () => { - const tempDir = makeTempDir(); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; - const cliProjectContext = yield* CliProjectContext; + it.live("falls back to ambient env when no Supabase project is found", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + yield* Effect.gen(function* () { + const cliSettings = yield* CliSettings; + const cliProjectContext = yield* CliProjectContext; - expect(cliSettings.apiUrl).toBe("https://ambient.example"); - expect(Option.isNone(cliProjectContext.paths)).toBe(true); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_API_URL: "https://ambient.example", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + expect(cliSettings.apiUrl).toBe("https://ambient.example"); + expect(Option.isNone(cliProjectContext.paths)).toBe(true); + }).pipe( + Effect.provide( + buildLayer(path, { + cwd: tempDir, + env: { + SUPABASE_API_URL: "https://ambient.example", + }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.live( "uses the nearest discovered project and loads supabase/.env.local over supabase/.env", - () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const cwd = join(packageRoot, "src"); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const repoRoot = path.join(tempDir, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(packageRoot, "src"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(repoRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(join(packageRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), + yield* fs.makeDirectory(path.join(repoRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(path.join(packageRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); + yield* fs.writeFileString( + path.join(repoRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', ); - yield* Effect.tryPromise(() => - writeFile(join(repoRoot, "supabase", ".env"), "SUPABASE_API_URL=https://repo.example\n"), + yield* fs.writeFileString( + path.join(repoRoot, "supabase", ".env"), + "SUPABASE_API_URL=https://repo.example\n", ); - yield* Effect.tryPromise(() => - writeFile(join(packageRoot, "supabase", "config.toml"), 'project_id = "web"\n'), + yield* fs.writeFileString( + path.join(packageRoot, "supabase", "config.toml"), + 'project_id = "web"\n', ); - yield* Effect.tryPromise(() => - writeFile( - join(packageRoot, "supabase", ".env"), - "SUPABASE_API_URL=https://shared.example\nSUPABASE_DASHBOARD_URL=https://dashboard.example\n", - ), + yield* fs.writeFileString( + path.join(packageRoot, "supabase", ".env"), + "SUPABASE_API_URL=https://shared.example\nSUPABASE_DASHBOARD_URL=https://dashboard.example\n", ); - yield* Effect.tryPromise(() => - writeFile( - join(packageRoot, "supabase", ".env.local"), - "SUPABASE_API_URL=https://local.example\n", - ), + yield* fs.writeFileString( + path.join(packageRoot, "supabase", ".env.local"), + "SUPABASE_API_URL=https://local.example\n", ); const { cliSettings, cliProjectContext } = yield* Effect.gen(function* () { @@ -142,7 +146,7 @@ describe("cliSettingsLayer", () => { cliSettings: yield* CliSettings, cliProjectContext: yield* CliProjectContext, }; - }).pipe(Effect.provide(buildLayer({ cwd }))); + }).pipe(Effect.provide(buildLayer(path, { cwd }))); expect(cliSettings.apiUrl).toBe("https://local.example"); expect(cliSettings.dashboardUrl).toBe("https://dashboard.example"); @@ -150,36 +154,33 @@ describe("cliSettingsLayer", () => { if (Option.isSome(cliProjectContext.paths)) { expect(cliProjectContext.paths.value.projectRoot).toBe(packageRoot); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("lets ambient env override discovered project env", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); + it.live("lets ambient env override discovered project env", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', ); - yield* Effect.tryPromise(() => - writeFile( - join(projectRoot, "supabase", ".env"), - "SUPABASE_API_URL=https://from-dotenv.example\nSUPABASE_ACCESS_TOKEN=sbp_dotenv\n", - ), + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env"), + "SUPABASE_API_URL=https://from-dotenv.example\nSUPABASE_ACCESS_TOKEN=sbp_dotenv\n", ); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", ".env.local"), "SUPABASE_ACCESS_TOKEN=sbp_local\n"), + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env.local"), + "SUPABASE_ACCESS_TOKEN=sbp_local\n", ); - const cliSettings = yield* Effect.gen(function* () { - return yield* CliSettings; - }).pipe( + const cliSettings = yield* CliSettings.pipe( Effect.provide( - buildLayer({ + buildLayer(path, { cwd: projectRoot, env: { SUPABASE_API_URL: "https://from-ambient.example", @@ -194,25 +195,24 @@ describe("cliSettingsLayer", () => { if (Option.isSome(cliSettings.accessToken)) { expect(Redacted.value(cliSettings.accessToken.value)).toBe("sbp_ambient"); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("has no PostHog key when nothing is injected or overridden", () => { - const tempDir = makeTempDir(); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + it.live("has no PostHog key when nothing is injected or overridden", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const cliSettings = yield* CliSettings.pipe( + Effect.provide(buildLayer(path, { cwd: tempDir })), + ); expect(Option.isNone(cliSettings.telemetryPosthogKey)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.effect("preserves empty runtime settings as present options", () => { const settingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(mockRuntimeInfo({ cwd: "/test/cwd", homeDir: "/test/home" })), Layer.provide(mockCliProjectContext()), Layer.provide( @@ -237,74 +237,77 @@ describe("cliSettingsLayer", () => { }).pipe(Effect.provide(settingsLayer)); }); - it.live("prefers SUPABASE_TELEMETRY_POSTHOG_KEY over the shipped default", () => { - const tempDir = makeTempDir(); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + it.live("prefers SUPABASE_TELEMETRY_POSTHOG_KEY over the shipped default", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const cliSettings = yield* CliSettings.pipe( + Effect.provide( + buildLayer(path, { + cwd: tempDir, + env: { + SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_env_override", + }, + }), + ), + ); expect(cliSettings.telemetryPosthogKey).toEqual(Option.some("phc_env_override")); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_env_override", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("uses SUPABASE_HOME (trimmed) when configured", () => { - const tempDir = makeTempDir(); - const supabaseHome = join(tempDir, "custom-supabase-home"); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + it.live("uses SUPABASE_HOME (trimmed) when configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const supabaseHome = path.join(tempDir, "custom-supabase-home"); + const cliSettings = yield* CliSettings.pipe( + Effect.provide( + buildLayer(path, { cwd: tempDir, env: { SUPABASE_HOME: ` ${supabaseHome} ` } }), + ), + ); expect(cliSettings.supabaseHome).toBe(supabaseHome); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir, env: { SUPABASE_HOME: ` ${supabaseHome} ` } })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); for (const value of ["", " "]) { it.live( `falls back to <homeDir>/.supabase when SUPABASE_HOME is ${JSON.stringify(value)}`, - () => { - const tempDir = makeTempDir(); - const homeDir = join(tempDir, "home"); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const homeDir = path.join(tempDir, "home"); + const cliSettings = yield* CliSettings.pipe( + Effect.provide( + buildLayer(path, { cwd: tempDir, homeDir, env: { SUPABASE_HOME: value } }), + ), + ); - expect(cliSettings.supabaseHome).toBe(join(homeDir, ".supabase")); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir, homeDir, env: { SUPABASE_HOME: value } })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + expect(cliSettings.supabaseHome).toBe(path.join(homeDir, ".supabase")); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); } - it.live("uses the build-injected PostHog key and host when no runtime override is set", () => { - const tempDir = makeTempDir(); - return Effect.gen(function* () { - const cliSettings = yield* CliSettings; + it.live("uses the build-injected PostHog key and host when no runtime override is set", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const cliSettings = yield* CliSettings.pipe( + Effect.provide( + buildLayer(path, { + cwd: tempDir, + env: { + SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", + SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", + }, + }), + ), + ); expect(cliSettings.telemetryPosthogHost).toBe("https://build-posthog.example"); expect(cliSettings.telemetryPosthogKey).toEqual(Option.some("phc_build_key")); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", - SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts b/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts index 66fa7b3c0f..3e2a497c00 100644 --- a/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts +++ b/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, FileSystem, Exit, Layer, Option, PlatformError } from "effect"; +import { + Cause, + Effect, + FileSystem, + Exit, + Layer, + Option, + Path, + PlatformError, + Schema, +} from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliSettingsLayer } from "./cli-settings.layer.ts"; import { cliProjectContextLayer } from "./cli-project-context.layer.ts"; @@ -17,19 +23,22 @@ import { ProjectNotLinkedError, } from "./project-link-state.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-link-state-")); -} - -function buildLayer(opts: { - cwd: string; - env?: Record<string, string>; - homeDir?: string; - fs?: Layer.Layer<FileSystem.FileSystem>; -}) { +const makeTempDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-project-link-state-" }), +); + +function buildLayer( + path: Path.Path, + opts: { + cwd: string; + env?: Record<string, string>; + homeDir?: string; + fs?: Layer.Layer<FileSystem.FileSystem>; + }, +) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir ?? path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredCliProjectContextLayer = cliProjectContextLayer.pipe( @@ -38,6 +47,7 @@ function buildLayer(opts: { Layer.provide(envLayer), ); const discoveredCliSettingsLayer = cliSettingsLayer.pipe( + Layer.provide(BunServices.layer), Layer.provide(runtimeInfoLayer), Layer.provide(discoveredCliProjectContextLayer), ); @@ -85,32 +95,32 @@ const SAMPLE_STATE = { } as const; describe("projectLinkStateLayer", () => { - it.live("surfaces a clear permission failure", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const linkPath = join(projectRoot, ".supabase", "project.json"); - const fsLayer = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - remove: () => - Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "remove", - description: "permission denied", - pathOrDescriptor: linkPath, - }), - ), - }), - ); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - const layer = buildLayer({ cwd: projectRoot, fs: fsLayer }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + it.live("surfaces a clear permission failure", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const linkPath = path.join(projectRoot, ".supabase", "project.json"); + const fsLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + remove: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + description: "permission denied", + pathOrDescriptor: linkPath, + }), + ), + }), + ); + + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + const layer = buildLayer(path, { cwd: projectRoot, fs: fsLayer }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const exit = yield* Effect.exit(linkState.clear); expect(Exit.isFailure(exit)).toBe(true); @@ -119,29 +129,26 @@ describe("projectLinkStateLayer", () => { expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) expect(error.value).toBeInstanceOf(PlatformError.PlatformError); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("saves and loads repo-local project link state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), + it.live("saves and loads repo-local project link state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', ); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save(SAMPLE_STATE); const loaded = yield* linkState.load; @@ -151,36 +158,31 @@ describe("projectLinkStateLayer", () => { expect(loaded.value).toEqual(SAMPLE_STATE); } - const rawFile = yield* Effect.tryPromise(() => - readFile(cliProjectHome.projectLinkPath, "utf8"), - ); + const rawFile = yield* fs.readFileString(cliProjectHome.projectLinkPath); expect(rawFile).toContain('"project":'); expect(rawFile).toContain('"active_branch":'); - const raw = JSON.parse(rawFile) as typeof SAMPLE_STATE; + const raw = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(rawFile); expect(raw).toEqual(SAMPLE_STATE); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live("clears repo-local link state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), + it.live("clears repo-local link state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', ); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const cliProjectHome = yield* Effect.gen(function* () { - return yield* CliProjectHome; - }).pipe(Effect.provide(layer)); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const cliProjectHome = yield* CliProjectHome.pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save(SAMPLE_STATE); yield* linkState.clear; @@ -188,24 +190,21 @@ describe("projectLinkStateLayer", () => { const loaded = yield* linkState.load; expect(Option.isNone(loaded)).toBe(true); - yield* Effect.tryPromise(() => readFile(cliProjectHome.projectLinkPath, "utf8")).pipe( - Effect.flip, - Effect.asVoid, - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + yield* fs.readFileString(cliProjectHome.projectLinkPath).pipe(Effect.flip, Effect.asVoid); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("fails with a tagged error when repo-local link state is malformed", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + it.live("fails with a tagged error when repo-local link state is malformed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".supabase"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".supabase"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); const { cliProjectHome, linkState } = yield* Effect.gen(function* () { return { cliProjectHome: yield* CliProjectHome, @@ -213,7 +212,7 @@ describe("projectLinkStateLayer", () => { }; }).pipe(Effect.provide(layer)); - yield* Effect.tryPromise(() => writeFile(cliProjectHome.projectLinkPath, "{not-json")); + yield* fs.writeFileString(cliProjectHome.projectLinkPath, "{not-json"); const exit = yield* linkState.load.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -228,43 +227,39 @@ describe("projectLinkStateLayer", () => { }); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("getActiveBranch returns none when not linked", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + it.live("getActiveBranch returns none when not linked", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const activeBranch = yield* linkState.getActiveBranch; expect(Option.isNone(activeBranch)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); - it.live("getActiveBranch returns the persisted active_branch", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + it.live("getActiveBranch returns the persisted active_branch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save(SAMPLE_STATE); @@ -277,25 +272,23 @@ describe("projectLinkStateLayer", () => { is_default: true, }); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); it.live( "setActiveBranch updates only active_branch, leaving project and versions unchanged", - () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save(SAMPLE_STATE); @@ -310,24 +303,21 @@ describe("projectLinkStateLayer", () => { expect(loaded.value.versions).toEqual(SAMPLE_STATE.versions); expect(loaded.value.fetchedAt).toBe(SAMPLE_STATE.fetchedAt); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }, + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("setActiveBranch fails with ProjectNotLinkedError when project is not linked", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); + it.live("setActiveBranch fails with ProjectNotLinkedError when project is not linked", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* makeTempDir; + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const layer = buildLayer(path, { cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const exit = yield* linkState .setActiveBranch({ ref: "branchrefabcdefghijk", name: "feature-x", is_default: false }) @@ -345,8 +335,6 @@ describe("projectLinkStateLayer", () => { }); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/config/supabase-home.ts b/apps/cli/src/shared/config/supabase-home.ts index 67be36cea4..7095246ead 100644 --- a/apps/cli/src/shared/config/supabase-home.ts +++ b/apps/cli/src/shared/config/supabase-home.ts @@ -1,5 +1,4 @@ -import { join } from "node:path"; -import { Option } from "effect"; +import { Option, type Path } from "effect"; /** * Resolves the global Supabase CLI state root. @@ -9,18 +8,24 @@ import { Option } from "effect"; * defaults to `<homeDir>/.supabase`. A pure function, so every caller resolves through it with * its own environment and home directory, keeping the contract in one place. */ -export const resolveSupabaseHomeValue = (value: Option.Option<string>, homeDir: string): string => { +export const resolveSupabaseHomeValue = ( + path: Path.Path, + value: Option.Option<string>, + homeDir: string, +): string => { const configured = Option.isSome(value) ? value.value.trim() : undefined; return configured !== undefined && configured.length > 0 ? configured - : join(homeDir, ".supabase"); + : path.join(homeDir, ".supabase"); }; export const resolveSupabaseHome = ( + path: Path.Path, env: Readonly<Record<string, string | undefined>>, homeDir: string, ): string => resolveSupabaseHomeValue( + path, env["SUPABASE_HOME"] === undefined ? Option.none() : Option.some(env["SUPABASE_HOME"]), homeDir, ); diff --git a/apps/cli/src/shared/config/supabase-home.unit.test.ts b/apps/cli/src/shared/config/supabase-home.unit.test.ts index 2302e42cf3..8a6ca37b9e 100644 --- a/apps/cli/src/shared/config/supabase-home.unit.test.ts +++ b/apps/cli/src/shared/config/supabase-home.unit.test.ts @@ -1,31 +1,54 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { expect, layer } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Path } from "effect"; import { resolveSupabaseHome } from "./supabase-home.ts"; -const HOME = join("/home", "test"); +layer(BunServices.layer)("resolveSupabaseHome", (it) => { + it.effect("returns SUPABASE_HOME when set to a non-empty value", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, { SUPABASE_HOME: "/custom/supabase" }, home)).toBe( + "/custom/supabase", + ); + }), + ); -describe("resolveSupabaseHome", () => { - it("returns SUPABASE_HOME when set to a non-empty value", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: "/custom/supabase" }, HOME)).toBe( - "/custom/supabase", - ); - }); + it.effect("trims surrounding whitespace from SUPABASE_HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, { SUPABASE_HOME: " /custom/supabase " }, home)).toBe( + "/custom/supabase", + ); + }), + ); - it("trims surrounding whitespace from SUPABASE_HOME", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: " /custom/supabase " }, HOME)).toBe( - "/custom/supabase", - ); - }); + it.effect("falls back to <homeDir>/.supabase when SUPABASE_HOME is unset", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, {}, home)).toBe(path.join(home, ".supabase")); + }), + ); - it("falls back to <homeDir>/.supabase when SUPABASE_HOME is unset", () => { - expect(resolveSupabaseHome({}, HOME)).toBe(join(HOME, ".supabase")); - }); + it.effect("falls back to <homeDir>/.supabase when SUPABASE_HOME is empty", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, { SUPABASE_HOME: "" }, home)).toBe( + path.join(home, ".supabase"), + ); + }), + ); - it("falls back to <homeDir>/.supabase when SUPABASE_HOME is empty", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: "" }, HOME)).toBe(join(HOME, ".supabase")); - }); - - it("falls back to <homeDir>/.supabase when SUPABASE_HOME is whitespace only", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: " " }, HOME)).toBe(join(HOME, ".supabase")); - }); + it.effect("falls back to <homeDir>/.supabase when SUPABASE_HOME is whitespace only", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = path.join("/home", "test"); + expect(resolveSupabaseHome(path, { SUPABASE_HOME: " " }, home)).toBe( + path.join(home, ".supabase"), + ); + }), + ); }); diff --git a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts index a16778e794..ed0c55277b 100644 --- a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Fiber } from "effect"; +import { Deferred, Effect, Exit, Fiber, Schema } from "effect"; import { feedbackClientLayer } from "./feedback-client.layer.ts"; import type { FeedbackSubmission } from "./feedback-client.service.ts"; import { FeedbackClient } from "./feedback-client.service.ts"; @@ -39,11 +39,16 @@ function recordingFetch( ) { const requests: Array<{ request: Request; bodyText: string }> = []; const fetch: typeof globalThis.fetch = Object.assign( - async (input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit): Promise<Response> => { const request = input instanceof Request ? new Request(input, init) : new Request(String(input), init); - requests.push({ request, bodyText: await request.clone().text() }); - return respond(request); + return request + .clone() + .text() + .then((bodyText) => { + requests.push({ request, bodyText }); + return respond(request); + }); }, { preconnect: () => Promise.resolve() }, ); @@ -57,6 +62,10 @@ function jsonResponse(body: unknown, status = 200) { }); } +const decodeJsonBody = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + function layerWith(transport: ReturnType<typeof recordingFetch>) { return feedbackClientLayer({ environment: TEST_ENV, fetch: transport.fetch }); } @@ -75,7 +84,7 @@ describe("feedbackClientLayer", () => { expect(request.method).toBe("POST"); expect(request.url).toBe(`${TEST_ENV.url}/rest/v1/rpc/submit_interfaces_feedback`); expect(request.headers.get("apikey")).toBe(TEST_ENV.key); - expect(JSON.parse(bodyText)).toEqual({ + expect(yield* decodeJsonBody(bodyText)).toEqual({ feedback: "port conflicts when running two stacks", user_agent: "SupabaseCLI/9.9.9", project_ref: PROJECT_REF, @@ -107,12 +116,12 @@ describe("feedbackClientLayer", () => { }, }); - const body = JSON.parse(transport.requests[0]!.bodyText); + const body = yield* decodeJsonBody(transport.requests[0]!.bodyText); // The RPC's `project_ref` parameter defaults to null server-side; the // CLI simply leaves it (and `user_id`) out of the call. expect(body).not.toHaveProperty("project_ref"); expect(body).not.toHaveProperty("user_id"); - expect(body.metadata).toEqual({ + expect(body["metadata"]).toEqual({ cli_version: "9.9.9", source: "cli", os: "linux", @@ -172,20 +181,18 @@ describe("feedbackClientLayer", () => { // insert commit after the command was cancelled. The fake fetch behaves // like a real one: it settles only when its abort signal fires. let capturedSignal: AbortSignal | undefined; - const inFlight = Promise.withResolvers<void>(); + const inFlight = Deferred.makeUnsafe<void>(); const transport = recordingFetch((request) => { capturedSignal = request.signal; - inFlight.resolve(); - return new Promise<Response>((_, reject) => { - request.signal.addEventListener("abort", () => reject(request.signal.reason)); - }); + Deferred.doneUnsafe(inFlight, Exit.void); + return Effect.runPromise(Effect.never, { signal: request.signal }); }); return Effect.gen(function* () { const client = yield* FeedbackClient; const fiber = yield* client .submit(SUBMISSION) .pipe(Effect.forkChild({ startImmediately: true })); - yield* Effect.promise(() => inFlight.promise); + yield* Deferred.await(inFlight); yield* Fiber.interrupt(fiber); expect(capturedSignal?.aborted).toBe(true); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 323929f198..2282b7b6ef 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -34,6 +34,7 @@ import { invalidFunctionSlugDetail, validateFunctionSlugMessage, } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { ConflictingFunctionDeployFlagsError, FunctionDeployCancelledError, @@ -405,9 +406,8 @@ async function realpathIfExists(pathname: string) { } } -async function resolveFunctionsSourceRoot(projectRoot: string) { - return (await findGitRootPath(projectRoot)) ?? resolve(projectRoot); -} +const resolveFunctionsSourceRoot = (projectRoot: string) => + findGitRootPath(projectRoot).pipe(Effect.map(Option.getOrElse(() => resolve(projectRoot)))); function humanSize(bytes: number) { if (bytes < 1000) { @@ -1207,23 +1207,39 @@ function sanitizeDockerBinds( return result; } -export async function buildDockerBinds( +type DockerBindsOptions = { + readonly additionalModuleRoots?: ReadonlyArray<string>; + readonly onWarning?: (message: string) => Promise<void>; + readonly skipMissingImportMapTargets?: boolean; + /** Resolved marker presence, including an explicitly empty project value. */ + readonly bitbucketCloneDirDefined?: boolean; +}; + +export const buildDockerBinds = ( + projectId: string, + functionsDir: string, + outputDir: string, + config: ResolvedDeployFunctionConfig, + options: DockerBindsOptions = {}, +) => + resolveFunctionsSourceRoot(resolve(functionsDir, "..", "..")).pipe( + Effect.flatMap((sourceRoot) => + Effect.promise(() => + buildDockerBindsWithin(sourceRoot, projectId, functionsDir, outputDir, config, options), + ), + ), + ); + +async function buildDockerBindsWithin( + sourceRoot: string, projectId: string, functionsDir: string, outputDir: string, config: ResolvedDeployFunctionConfig, - options: { - readonly additionalModuleRoots?: ReadonlyArray<string>; - readonly onWarning?: (message: string) => Promise<void>; - readonly skipMissingImportMapTargets?: boolean; - /** Resolved marker presence, including an explicitly empty project value. */ - readonly bitbucketCloneDirDefined?: boolean; - } = {}, + options: DockerBindsOptions, ): Promise<ReadonlyArray<DockerBind>> { const hostFunctionsDir = resolve(functionsDir); const hostOutputDir = resolve(outputDir); - const projectRoot = resolve(functionsDir, "..", ".."); - const sourceRoot = await resolveFunctionsSourceRoot(projectRoot); const realSourceRoot = await realpath(sourceRoot); const moduleRoots = [ realSourceRoot, @@ -1442,13 +1458,11 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( const outputPath = join(outputDir, "output.eszip"); // `edgeRuntimeImage` applies the tag verbatim — a `.temp/edge-runtime-version` pin flows // through unmodified, `v` prefix or not (see the helper's doc in `functions.shared.ts`). - const rawImage = edgeRuntimeImage(edgeRuntimeVersion); - const binds = yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, outputDir, config, { - bitbucketCloneDirDefined, - onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), - }), - ); + const rawImage = edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled); + const binds = yield* buildDockerBinds(projectId, functionsDir, outputDir, config, { + bitbucketCloneDirDefined, + onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), + }); // Resolved per function rather than hoisted out of the loop (unlike `download.ts`'s // `PulledEdgeRuntimeImage`): the first resolve failure aborts the loop, and the only added // cost is one cached `docker image inspect` per function. @@ -2083,10 +2097,7 @@ const deployViaApi = Effect.fnUntraced(function* ( // (`projectRoot`), not at `sourceRoot`. The import-walk boundary (which files may be uploaded // at all) is intentionally wider, extending to the nearest git root, so files outside the // workdir but inside a monorepo can still deploy — those upload with `../`-relative names. - const sourceRoot = yield* Effect.tryPromise({ - try: () => resolveFunctionsSourceRoot(projectRoot), - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }); + const sourceRoot = yield* resolveFunctionsSourceRoot(projectRoot); const enabled = configs.filter((config) => config.enabled); for (const skipped of configs.filter((config) => !config.enabled)) { yield* output.raw(`Skipping disabled Function: ${skipped.slug}\n`, "stderr"); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index ec46dc9965..eff5003774 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -2,6 +2,8 @@ import { mkdir, mkdtemp, realpath, rename, rm, symlink, writeFile } from "node:f import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -12,6 +14,9 @@ import { } from "./deploy.ts"; import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; +const runBuildDockerBinds = (...args: Parameters<typeof buildDockerBinds>) => + Effect.runPromise(buildDockerBinds(...args).pipe(Effect.provide(BunServices.layer))); + /** * `../../` from `<root>/supabase/functions/hello/deno.json`'s directory lands at * `<root>/supabase/_vendor/package/dist/index.mjs`, outside `functionsDir` @@ -114,7 +119,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -140,7 +145,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { let caught: unknown; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async () => {}, }); } catch (error) { @@ -170,7 +175,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -195,7 +200,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -217,7 +222,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -249,7 +254,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -274,7 +279,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeVendorIndexFile(root); try { - await buildDockerBinds("test-project", functionsDir, outputDir, config); + await runBuildDockerBinds("test-project", functionsDir, outputDir, config); } finally { await rm(root, { recursive: true, force: true }); } @@ -292,7 +297,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -318,14 +323,14 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil try { let threwWithoutOption = false; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config); + await runBuildDockerBinds("test-project", functionsDir, outputDir, config); } catch { threwWithoutOption = true; } expect(threwWithoutOption).toBe(true); const warnings: Array<string> = []; - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -353,7 +358,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -386,7 +391,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeFile(scopeDependency, 'export const dependency = "scope";\n'); try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config); + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config); const hostPaths = binds.map((bind) => bind.hostPath); expect(hostPaths).toContain(scopeEntrypoint); @@ -414,7 +419,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil await writeFile(scopeEntrypoint, 'export { util } from "./util.ts";\n'); await writeFile(scopeDependency, 'export const util = "thing";\n'); - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -455,7 +460,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -489,7 +494,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -515,7 +520,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -545,7 +550,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { + const binds = await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -576,7 +581,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, @@ -611,7 +616,7 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil const warnings: Array<string> = []; try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { + await runBuildDockerBinds("test-project", functionsDir, outputDir, config, { onWarning: async (message) => { warnings.push(message); }, diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 82aaf569f5..46f8bce592 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -35,6 +35,7 @@ import { invalidFunctionSlugDetail, validateFunctionSlugMessage, } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { ConflictingFunctionDownloadFlagsError, FunctionDownloadNotFoundError, @@ -899,7 +900,7 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( // `edgeRuntimeImage` applies the tag verbatim; a `.temp/edge-runtime-version` // pin flows through unmodified. Registry mapping + pull-with-retry happens // per-container, right before `ensureDockerNetwork` (see the caller). - rawImage: edgeRuntimeImage(edgeRuntimeVersion), + rawImage: edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled), projectEnvValues: context.projectEnvValues, }; }); diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 0c6ebabb3c..2f691ac4ff 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -32,11 +32,11 @@ export const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; * is substituted verbatim into the (possibly slim-rewritten) default image, * with no `v` prefix synthesized. */ -export function edgeRuntimeImage(tag: string): string { +export function edgeRuntimeImage(tag: string, slim: boolean): string { if (tag === DENO1_EDGE_RUNTIME_VERSION) { return `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; } - return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag); + return slimImageForCurrentPin("edgeruntime", dockerfileServiceImageRaw("edgeruntime"), tag, slim); } /** diff --git a/apps/cli/src/shared/functions/functions.shared.unit.test.ts b/apps/cli/src/shared/functions/functions.shared.unit.test.ts index 5eb8806232..57e310bf51 100644 --- a/apps/cli/src/shared/functions/functions.shared.unit.test.ts +++ b/apps/cli/src/shared/functions/functions.shared.unit.test.ts @@ -2,6 +2,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Effect } from "effect"; import { dockerfileServiceImageRaw } from "../services/dockerfile-images.ts"; +import { + expectedPinnedImage, + GHCR_SLIM_IMAGE_PATTERN, +} from "../../../tests/helpers/slim-images.ts"; import { DENO1_EDGE_RUNTIME_VERSION, edgeRuntimeImage, @@ -17,22 +21,19 @@ afterEach(() => { describe("edgeRuntimeImage", () => { it("keeps the deno1 tag on the docker.io image even when the slim flag is on", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION)).toBe( + expect(edgeRuntimeImage(DENO1_EDGE_RUNTIME_VERSION, true)).toBe( `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`, ); }); - it("rewrites the current Dockerfile tag onto the slim ghcr.io image when the flag is on", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(edgeRuntimeImage(currentEdgeRuntimeTag)).toBe( - `ghcr.io/supabase/cli/edge-runtime:${currentEdgeRuntimeTag}`, - ); + it("rewrites the current Dockerfile tag onto the catalog-pinned slim ghcr.io image when the flag is on", () => { + const pinned = expectedPinnedImage("edgeruntime", rawEdgeRuntimeImage); + expect(pinned).toMatch(GHCR_SLIM_IMAGE_PATTERN); + expect(edgeRuntimeImage(currentEdgeRuntimeTag, true)).toBe(pinned); }); it("keeps a historical pin on docker.io when the flag is on", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(edgeRuntimeImage("v1.73.0")).toBe("supabase/edge-runtime:v1.73.0"); + expect(edgeRuntimeImage("v1.73.0", true)).toBe("supabase/edge-runtime:v1.73.0"); }); }); diff --git a/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts b/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts index 342e83b1a3..bef3c5392a 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.integration.test.ts @@ -79,6 +79,7 @@ const load = async ( AbortController, AbortSignal, Headers, + ReadableStream, Request, Response, URL, @@ -330,6 +331,110 @@ describe("CLI functions bootstrap bundle", () => { }); }); + describe("request body ownership", () => { + const upload = (chunks = 4) => { + const progress = { readToEnd: false, cancelled: false }; + let sent = 0; + const body = new ReadableStream<Uint8Array>({ + pull: (controller) => { + if (sent === chunks) { + progress.readToEnd = true; + controller.close(); + return; + } + sent += 1; + controller.enqueue(new Uint8Array(1024)); + }, + cancel: () => { + progress.cancelled = true; + }, + }); + return { body, progress }; + }; + const functionConfig = (verifyJWT: boolean) => + JSON.stringify({ + hello: { entrypointPath: "hello/index.ts", importMapPath: "", staticFiles: [], verifyJWT }, + }); + + it("reads the rest of a request body the worker abandons", async () => { + const loaded = await load(await bundleServeMainTemplate(), baseEnv(functionConfig(false)), { + fetch: async (request: Request) => { + const reader = request.body?.getReader(); + await reader?.read(); + // Not awaited: if the body were shared with the incoming request again, Bun + // would never settle this cancel and the test would hang instead of failing. + void reader?.cancel(); + return new Response("rejected", { status: 400 }); + }, + }); + const { body, progress } = upload(); + + const response = await loaded.options.handler( + new Request("http://localhost/hello", { method: "POST", body, duplex: "half" }), + ); + + expect(progress).toEqual({ readToEnd: true, cancelled: false }); + expect(response.status).toBe(400); + expect(await response.text()).toBe("rejected"); + }); + + it("settles an aborted request while the abandoned body read is pending", async () => { + const { promise: readPending, resolve: markReadPending } = Promise.withResolvers<void>(); + const pendingRead = Promise.withResolvers<void>().promise; + let pulls = 0; + const body = new ReadableStream<Uint8Array>({ + pull: (streamController) => { + pulls += 1; + if (pulls === 1) { + streamController.enqueue(new Uint8Array([1])); + return; + } + markReadPending(); + return pendingRead; + }, + }); + const loaded = await load(await bundleServeMainTemplate(), baseEnv(functionConfig(false)), { + fetch: async () => new Response("worker should not run"), + }); + const controller = new AbortController(); + const pending = loaded.options.handler( + new Request("http://localhost/missing", { + method: "POST", + body, + duplex: "half", + signal: controller.signal, + }), + ); + + await readPending; + controller.abort(); + + await expect(pending).resolves.toMatchObject({ status: 499 }); + }); + + it.each([ + ["an invalid token", "/hello", 401], + ["an unknown function", "/missing", 404], + ] as const)("reads the whole upload before rejecting %s", async (_, path, status) => { + const loaded = await load(await bundleServeMainTemplate(), baseEnv(functionConfig(true)), { + fetch: async () => new Response("should not run"), + }); + const { body, progress } = upload(); + + const response = await loaded.options.handler( + new Request(`http://localhost${path}`, { + method: "POST", + body, + duplex: "half", + headers: { Authorization: "Bearer invalid" }, + }), + ); + + expect(progress).toEqual({ readToEnd: true, cancelled: false }); + expect(response.status).toBe(status); + }); + }); + it("does not fetch after an aborted pending worker creation", async () => { const bundle = await bundleServeMainTemplate(); const config = JSON.stringify({ diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index a081624c56..b83d3ae5e0 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; +import { Effect } from "effect"; import { describe, expect, test } from "vitest"; import { START_KONG_YML_TEMPLATE } from "../../commands/start/templates/kong.yml.ts"; @@ -274,7 +275,7 @@ describe("functions serve runtime template (offline)", () => { "boots under edge-runtime with networking disabled and fetches nothing remote", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(edgeRuntimeDockerfileImage()); + const runtimeImage = await ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage)); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; try { @@ -336,7 +337,7 @@ describe("functions serve runtime template (offline)", () => { "returns canonical JWT auth failures", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, async () => { - const runtimeImage = await ensureImage(edgeRuntimeDockerfileImage()); + const runtimeImage = await ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage)); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; try { @@ -431,8 +432,8 @@ describe("functions serve runtime template (offline)", () => { async () => { const imageDeadline = resolveDeadline(); const [runtimeImage, kongImage] = await Promise.all([ - ensureImage(edgeRuntimeDockerfileImage(), imageDeadline), - ensureImage(dockerfileServiceImage("kong"), imageDeadline), + ensureImage(await Effect.runPromise(edgeRuntimeDockerfileImage), imageDeadline), + ensureImage(dockerfileServiceImage("kong", false), imageDeadline), ]); const dir = await mkdtemp(join(tmpdir(), "supabase-serve-kong-e2e-")); const network = `supabase-serve-kong-e2e-${process.pid.toString()}`; @@ -593,7 +594,7 @@ describe("functions serve runtime template (offline)", () => { { method: "POST", headers: { "x-reject-before-body": "true" }, - body: new Uint8Array(128 * 1024), + body: new Uint8Array(1024 * 1024), signal: AbortSignal.timeout(5_000), }, ); @@ -606,7 +607,10 @@ describe("functions serve runtime template (offline)", () => { expect(runtimeLogs).not.toContain("must-not-appear-in-debug-logs"); const authResponse = await fetch(authUrl, { + method: "POST", headers: { Origin: "http://localhost:3000" }, + body: new Uint8Array(1024 * 1024), + signal: AbortSignal.timeout(5_000), }); expect(authResponse.status).toBe(401); expect(authResponse.headers.get("sb-error-code")).toBe("UNAUTHORIZED_NO_AUTH_HEADER"); diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index cbca57da80..bfcd1fd5c7 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -1,4 +1,15 @@ -import { Cause, Config, ConfigProvider, Console, Data, Effect, Exit, Option, Schema } from "effect"; +import { + Cause, + Config, + ConfigProvider, + Console, + Data, + Effect, + Exit, + Option, + Schema, + Stream, +} from "effect"; import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; import * as jose from "jose"; @@ -383,12 +394,33 @@ function shouldUsePackageJsonDiscovery({ ); } -export function prepareUserRequest(req: Request): Request { +interface RequestBodyReader { + read(): Promise<{ done: true; value?: undefined } | { done: false; value: Uint8Array }>; +} + +const requestBodyChunks = (body: RequestBodyReader) => + Stream.fromEffectRepeat( + foreign(() => body.read()).pipe( + Effect.flatMap((chunk) => (chunk.done ? Cause.done() : Effect.succeed(chunk.value))), + ), + ); + +const drainRequestBody = (body: RequestBodyReader | undefined) => + body === undefined ? Effect.void : Stream.runDrain(requestBodyChunks(body)).pipe(Effect.ignore); + +export function prepareUserRequest(req: Request, body: RequestBodyReader | undefined): Request { const clonedURL = new URL(req.url); const forwardedHost = req.headers.get("x-forwarded-host"); clonedURL.hostname = forwardedHost ?? clonedURL.hostname; - // Cloning tees the body, so an unread branch can stall early worker responses. - const forwardedReq = new Request(clonedURL.href, req); + // The runtime closes a connection whose request body is unread, which gateways report as 502, so + // the worker gets its own stream and cancelling it leaves the body for `drainRequestBody`. + const forwardedReq = new Request(clonedURL.href, { + method: req.method, + headers: req.headers, + body: body === undefined ? null : Stream.toReadableStream(requestBodyChunks(body)), + signal: req.signal, + duplex: "half", + }); forwardedReq.headers.delete("sb-api-key"); EdgeRuntime.applySupabaseTag(req, forwardedReq); @@ -400,6 +432,12 @@ Deno.serve({ handler: (req: Request) => Effect.runPromiseExit( Effect.gen(function* () { + // `worker.fetch` settles its body pipe before resolving, so the drain only reads what the + // worker abandoned. + const body = yield* Effect.acquireRelease( + Effect.sync(() => req.body?.getReader()), + (body) => Effect.interruptible(drainRequestBody(body)), + ); const url = new URL(req.url); const { pathname } = url; @@ -510,7 +548,7 @@ Deno.serve({ }), ); - const userReq = prepareUserRequest(req); + const userReq = prepareUserRequest(req, body); return yield* foreign(() => worker.fetch(userReq)); }); @@ -527,7 +565,7 @@ Deno.serve({ ), ), ); - }), + }).pipe(Effect.scoped), { signal: req.signal }, ).then((exit) => { if (Exit.isSuccess(exit)) return exit.value; diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 24af7444a4..e55ba8ddec 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -89,6 +89,7 @@ import { } from "./functions-docker.ts"; import { loadFunctionsCliConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; +import { slimImagesEnabled } from "../services/slim-images.ts"; import { DockerLogsStreamError, EdgeRuntimeContainerCrashedError, @@ -1676,17 +1677,15 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin } const bindWarnings: string[] = []; - for (const bind of yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, functionsDir, fnConfig, { - bitbucketCloneDirDefined, - additionalModuleRoots: [flagCwd], - skipMissingImportMapTargets: true, - onWarning: (message) => { - bindWarnings.push(message); - return Promise.resolve(); - }, - }), - )) { + for (const bind of yield* buildDockerBinds(projectId, functionsDir, functionsDir, fnConfig, { + bitbucketCloneDirDefined, + additionalModuleRoots: [flagCwd], + skipMissingImportMapTargets: true, + onWarning: (message) => { + bindWarnings.push(message); + return Promise.resolve(); + }, + })) { binds.add(formatDockerBind(bind)); } const missingSourceWarning = bindWarnings.find((warning) => @@ -1771,17 +1770,15 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo } const bindWarnings: string[] = []; - for (const bind of yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, functionsDir, config, { - bitbucketCloneDirDefined, - additionalModuleRoots: [input.flagCwd], - skipMissingImportMapTargets: true, - onWarning: (message) => { - bindWarnings.push(message); - return Promise.resolve(); - }, - }), - )) { + for (const bind of yield* buildDockerBinds(projectId, functionsDir, functionsDir, config, { + bitbucketCloneDirDefined, + additionalModuleRoots: [input.flagCwd], + skipMissingImportMapTargets: true, + onWarning: (message) => { + bindWarnings.push(message); + return Promise.resolve(); + }, + })) { const key = formatDockerBind(bind); functionBinds.set(key, bind); if (!bind.externalScope) { @@ -2035,7 +2032,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { // `docker pull` on cold cache instead of immediately — left open since // fixing it risks `start`'s shared, more critical bring-up path. const image = yield* resolveFunctionsDockerImage( - edgeRuntimeImage(edgeRuntimeVersion), + edgeRuntimeImage(edgeRuntimeVersion, yield* slimImagesEnabled), resolved.projectEnvValues, ); diff --git a/apps/cli/src/shared/git/git-branch.ts b/apps/cli/src/shared/git/git-branch.ts index 114f210797..46a5f62b2f 100644 --- a/apps/cli/src/shared/git/git-branch.ts +++ b/apps/cli/src/shared/git/git-branch.ts @@ -1,39 +1,71 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Config, Effect, FileSystem, Option, Path } from "effect"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; /** * Detects the current git branch: `$GITHUB_HEAD_REF` when set (CI - * pull-request workflows), otherwise the nearest `.git/HEAD` walking up from - * `startDir` (default: the runtime CWD), parsed as `ref: refs/heads/<name>`. - * Returns `Option.none()` when no git repository is found; callers substitute - * their own default. + * pull-request workflows), otherwise the nearest `.git` walking up from + * `startDir` (default: the runtime CWD). Returns `Option.none()` when no git + * repository is found, its HEAD is detached, or its branch can't otherwise be + * determined; callers decide how to handle an unknown branch. * * Pass `startDir` explicitly for a resolved `--workdir` so the branch * reflects the project directory, not the process's CWD. */ export const detectGitBranch = ( startDir?: string, -): Effect.Effect<Option.Option<string>, never, RuntimeInfo | FileSystem.FileSystem | Path.Path> => +): Effect.Effect< + Option.Option<string>, + Config.ConfigError, + RuntimeInfo | FileSystem.FileSystem | Path.Path +> => Effect.gen(function* () { - const githubHeadRef = process.env["GITHUB_HEAD_REF"]; - if (githubHeadRef !== undefined && githubHeadRef.length > 0) { - return Option.some(githubHeadRef); + const githubHeadRef = yield* Config.option(Config.string("GITHUB_HEAD_REF")); + if (Option.isSome(githubHeadRef) && githubHeadRef.value.length > 0) { + return githubHeadRef; } const runtimeInfo = yield* RuntimeInfo; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const branchFromHead = (content: string): Option.Option<string> => { + const match = content.trim().match(/^ref: refs\/heads\/(.+)$/); + return match?.[1] !== undefined ? Option.some(match[1]) : Option.none<string>(); + }; + + // Reads `HEAD` in `gitDir` and parses it — `Option.none()` covers both a + // missing/unreadable file and a detached HEAD (a raw commit SHA). + const readBranch = (gitDir: string) => + fs + .readFileString(path.join(gitDir, "HEAD")) + .pipe(Effect.option, Effect.map(Option.flatMap(branchFromHead))); + let dir = path.resolve(startDir ?? runtimeInfo.cwd); const root = path.parse(dir).root; while (true) { - const headPath = path.join(dir, ".git", "HEAD"); - const content = yield* fs.readFileString(headPath).pipe(Effect.option); - if (Option.isSome(content)) { - const match = content.value.trim().match(/^ref: refs\/heads\/(.+)$/); - return match?.[1] !== undefined ? Option.some(match[1]) : Option.none<string>(); + const gitPath = path.join(dir, ".git"); + const info = yield* fs.stat(gitPath).pipe(Effect.option); + if (Option.isSome(info)) { + // Found the repository root — resolve its branch here and stop, even on + // failure, rather than walking into an unrelated parent checkout. + if (info.value.type === "Directory") { + return yield* readBranch(gitPath); + } + // A `.git` FILE is a worktree/submodule gitlink: `gitdir: <path>`, the + // path resolved relative to `dir` when it isn't already absolute. + const gitlink = yield* fs.readFileString(gitPath).pipe(Effect.option); + const target = gitlink.pipe( + Option.flatMap((raw) => Option.fromNullishOr(raw.trim().match(/^gitdir:\s*(.+)$/)?.[1])), + ); + if (Option.isNone(target)) { + return Option.none<string>(); + } + const gitDir = path.isAbsolute(target.value) + ? target.value + : path.resolve(dir, target.value); + return yield* readBranch(gitDir); } if (dir === root) { return Option.none<string>(); @@ -41,3 +73,10 @@ export const detectGitBranch = ( dir = path.dirname(dir); } }); + +/** + * Renders " on branch <name>" for a "Finished …" summary line, or an empty + * string when the branch is unknown — never guess a default like `main`. + */ +export const branchClause = (branch: Option.Option<string>, format: (name: string) => string) => + Option.match(branch, { onNone: () => "", onSome: (name) => ` on branch ${format(name)}` }); diff --git a/apps/cli/src/shared/git/git-branch.unit.test.ts b/apps/cli/src/shared/git/git-branch.unit.test.ts index 1c8640f8ca..511e107fe6 100644 --- a/apps/cli/src/shared/git/git-branch.unit.test.ts +++ b/apps/cli/src/shared/git/git-branch.unit.test.ts @@ -1,136 +1,152 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import { detectGitBranch } from "./git-branch.ts"; -function withCwd(cwd: string) { +function withCwd(cwd: string, env: Record<string, string> = {}) { return Layer.mergeAll( BunServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), Layer.succeed(RuntimeInfo, { cwd, platform: process.platform, arch: process.arch, - homeDir: tmpdir(), + homeDir: cwd, execPath: process.execPath, pid: process.pid, }), ); } +const makeTempDir = (prefix: string) => + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.makeTempDirectoryScoped({ prefix })); + +const writeHead = Effect.fnUntraced(function* (root: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(root, ".git")); + yield* fs.writeFileString(path.join(root, ".git", "HEAD"), contents); +}); + describe("detectGitBranch", () => { - let original: string | undefined; + it.live("returns $GITHUB_HEAD_REF when set", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-ci-"); + const got = yield* detectGitBranch().pipe( + Effect.provide(withCwd(root, { GITHUB_HEAD_REF: "ci-branch" })), + ); + expect(got).toEqual(Option.some("ci-branch")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("returns $GITHUB_HEAD_REF when set", () => { - original = process.env["GITHUB_HEAD_REF"]; - process.env["GITHUB_HEAD_REF"] = "ci-branch"; - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("ci-branch"); - } finally { - if (original === undefined) delete process.env["GITHUB_HEAD_REF"]; - else process.env["GITHUB_HEAD_REF"] = original; - } - }).pipe(Effect.provide(withCwd(tmpdir()))); - }); + it.live("ignores an empty $GITHUB_HEAD_REF and falls back to .git/HEAD", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-empty-ref-"); + yield* writeHead(root, "ref: refs/heads/feature-x\n"); + const got = yield* detectGitBranch().pipe( + Effect.provide(withCwd(root, { GITHUB_HEAD_REF: "" })), + ); + expect(got).toEqual(Option.some("feature-x")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("parses ref: refs/heads/<name> from .git/HEAD in CWD", () => { - const original2 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-")); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/feature-x\n"); - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("feature-x"); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original2 !== undefined) process.env["GITHUB_HEAD_REF"] = original2; - } - }).pipe(Effect.provide(withCwd(root))); - }); + it.live("parses ref: refs/heads/<name> from .git/HEAD in CWD", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-"); + yield* writeHead(root, "ref: refs/heads/feature-x\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); + expect(got).toEqual(Option.some("feature-x")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("walks up parent directories until .git/HEAD is found", () => { - const original3 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-walk-")); - const nested = join(root, "a", "b", "c"); - mkdirSync(nested, { recursive: true }); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/main\n"); - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("main"); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original3 !== undefined) process.env["GITHUB_HEAD_REF"] = original3; - } - }).pipe(Effect.provide(withCwd(nested))); - }); + it.live("walks up parent directories until .git/HEAD is found", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir("git-branch-walk-"); + const nested = path.join(root, "a", "b", "c"); + yield* fs.makeDirectory(nested, { recursive: true }); + yield* writeHead(root, "ref: refs/heads/main\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(nested))); + expect(got).toEqual(Option.some("main")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("returns none when no .git/HEAD is ever found", () => { - const original4 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-empty-")); - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isNone(got)).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original4 !== undefined) process.env["GITHUB_HEAD_REF"] = original4; - } - }).pipe(Effect.provide(withCwd(root))); - }); + it.live("returns none when no .git/HEAD is ever found", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-none-"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); + expect(Option.isNone(got)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("returns none when .git/HEAD points at a detached commit (no ref: line)", () => { - const original5 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-detached-")); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "deadbeef\n"); - return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isNone(got)).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original5 !== undefined) process.env["GITHUB_HEAD_REF"] = original5; - } - }).pipe(Effect.provide(withCwd(root))); - }); + it.live("returns none when .git/HEAD points at a detached commit (no ref: line)", () => + Effect.gen(function* () { + const root = yield* makeTempDir("git-branch-detached-"); + yield* writeHead(root, "deadbeef\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); + expect(Option.isNone(got)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("walks from an explicit startDir instead of the runtime CWD", () => + Effect.gen(function* () { + // The project repo (with .git/HEAD) is the startDir; the runtime CWD is an + // unrelated dir with no repo, mirroring `supabase --workdir <project>` run + // from elsewhere. + const project = yield* makeTempDir("git-branch-workdir-"); + yield* writeHead(project, "ref: refs/heads/project-branch\n"); + const elsewhere = yield* makeTempDir("git-branch-cwd-"); + const got = yield* detectGitBranch(project).pipe(Effect.provide(withCwd(elsewhere))); + expect(got).toEqual(Option.some("project-branch")); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("resolves a worktree's `.git` gitlink file to its own HEAD", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Mirrors `git worktree add`: the worktree's `.git` is a file pointing at the + // real gitdir under the main checkout's `.git/worktrees/<name>`. + const main = yield* makeTempDir("git-branch-main-"); + yield* writeHead(main, "ref: refs/heads/develop\n"); + const worktreeGitDir = path.join(main, ".git", "worktrees", "feature"); + yield* fs.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fs.writeFileString(path.join(worktreeGitDir, "HEAD"), "ref: refs/heads/feature-x\n"); + const worktree = yield* makeTempDir("git-branch-worktree-"); + yield* fs.writeFileString(path.join(worktree, ".git"), `gitdir: ${worktreeGitDir}\n`); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(worktree))); + expect(got).toEqual(Option.some("feature-x")); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.live("resolves a relative gitdir in a `.git` gitlink against its directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir("git-branch-relative-"); + const gitDir = path.join(root, "actual-gitdir"); + yield* fs.makeDirectory(gitDir, { recursive: true }); + yield* fs.writeFileString(path.join(gitDir, "HEAD"), "ref: refs/heads/relative-branch\n"); + yield* fs.writeFileString(path.join(root, ".git"), "gitdir: ./actual-gitdir\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); + expect(got).toEqual(Option.some("relative-branch")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("walks from an explicit startDir instead of the runtime CWD", () => { - const original6 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - // The project repo (with .git/HEAD) is the startDir; the runtime CWD is an - // unrelated dir with no repo, mirroring `supabase --workdir <project>` run - // from elsewhere. - const project = mkdtempSync(join(tmpdir(), "git-branch-workdir-")); - mkdirSync(join(project, ".git")); - writeFileSync(join(project, ".git", "HEAD"), "ref: refs/heads/project-branch\n"); - const elsewhere = mkdtempSync(join(tmpdir(), "git-branch-cwd-")); - return Effect.gen(function* () { - const got = yield* detectGitBranch(project); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("project-branch"); - } finally { - rmSync(project, { recursive: true, force: true }); - rmSync(elsewhere, { recursive: true, force: true }); - if (original6 !== undefined) process.env["GITHUB_HEAD_REF"] = original6; - } - }).pipe(Effect.provide(withCwd(elsewhere))); - }); + it.live("stops at the nearest .git instead of a detached HEAD's parent checkout", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* makeTempDir("git-branch-detached-nested-"); + yield* writeHead(root, "ref: refs/heads/main\n"); + const nested = path.join(root, "nested"); + yield* fs.makeDirectory(nested); + yield* writeHead(nested, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n"); + const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(nested))); + expect(Option.isNone(got)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/git/git-root.ts b/apps/cli/src/shared/git/git-root.ts index 062fb14c4d..91cf9cd1f9 100644 --- a/apps/cli/src/shared/git/git-root.ts +++ b/apps/cli/src/shared/git/git-root.ts @@ -1,21 +1,20 @@ -import { stat } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { Effect, FileSystem, Option, Path } from "effect"; -export async function findGitRootPath(startPath: string) { - let current = resolve(startPath); +export const findGitRootPath = Effect.fnUntraced(function* (startPath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(startPath); - for (;;) { - try { - await stat(resolve(current, ".git")); - return current; - } catch { - // Keep walking until we hit the filesystem root. + while (true) { + // A failed stat means no `.git` here: keep walking until we hit the filesystem root. + if (Option.isSome(yield* fs.stat(path.resolve(current, ".git")).pipe(Effect.option))) { + return Option.some(current); } - const parent = dirname(current); + const parent = path.dirname(current); if (parent === current) { - return undefined; + return Option.none<string>(); } current = parent; } -} +}); diff --git a/apps/cli/src/shared/init/project-init.modes.integration.test.ts b/apps/cli/src/shared/init/project-init.modes.integration.test.ts index 58ac00eb09..b89df561fd 100644 --- a/apps/cli/src/shared/init/project-init.modes.integration.test.ts +++ b/apps/cli/src/shared/init/project-init.modes.integration.test.ts @@ -1,17 +1,21 @@ -import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../tests/helpers/mocks.ts"; import { initProject } from "./project-init.ts"; -function makeTempProjectDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-init-modes-")); -} +const makeTempProjectDir = Effect.flatMap(FileSystem.FileSystem, (fs) => + fs.makeTempDirectoryScoped({ prefix: "supabase-init-modes-" }), +); + +// Pin the process umask to 0 to prove the modes below are pinned explicitly, +// not incidental to Node's own umask-masked defaults (which coincide under +// the common 022). +const zeroUmask = Effect.acquireRelease( + Effect.sync(() => process.umask(0)), + (prevUmask) => Effect.sync(() => process.umask(prevUmask)), +); function runInit(cwd: string) { const out = mockOutput({ format: "text", interactive: false }); @@ -29,54 +33,39 @@ function runInit(cwd: string) { }).pipe(Effect.provide(layer)); } -// Pin the process umask to 0 to prove the modes below are pinned explicitly, -// not incidental to Node's own umask-masked defaults (which coincide under -// the common 022). +const fileMode = Effect.fnUntraced(function* (pathname: string) { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.stat(pathname)).mode & 0o777; +}); + describe("initProject file modes (Go parity: 0755 dirs, 0644 files)", () => { - it.live("pins the supabase dir and config.toml to Go's exact modes", () => { - const cwd = makeTempProjectDir(); - const prevUmask = process.umask(0); + it.live("pins the supabase dir and config.toml to Go's exact modes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const cwd = yield* makeTempProjectDir; + yield* zeroUmask; - return runInit(cwd).pipe( - Effect.andThen( - Effect.sync(() => { - const supabaseDir = join(cwd, "supabase"); - const configTomlPath = join(supabaseDir, "config.toml"); + yield* runInit(cwd); - expect(statSync(supabaseDir).mode & 0o777).toBe(0o755); - expect(statSync(configTomlPath).mode & 0o777).toBe(0o644); - }), - ), - Effect.ensuring( - Effect.sync(() => { - process.umask(prevUmask); - rmSync(cwd, { recursive: true, force: true }); - }), - ), - ); - }); + const supabaseDir = path.join(cwd, "supabase"); + expect(yield* fileMode(supabaseDir)).toBe(0o755); + expect(yield* fileMode(path.join(supabaseDir, "config.toml"))).toBe(0o644); + }).pipe(Effect.provide(BunServices.layer)), + ); it.live( "pins a freshly created supabase/.gitignore to Go's exact file mode inside a git repo", - () => { - const cwd = makeTempProjectDir(); - mkdirSync(join(cwd, ".git")); - const prevUmask = process.umask(0); + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempProjectDir; + yield* fs.makeDirectory(path.join(cwd, ".git")); + yield* zeroUmask; + + yield* runInit(cwd); - return runInit(cwd).pipe( - Effect.andThen( - Effect.sync(() => { - const gitignorePath = join(cwd, "supabase", ".gitignore"); - expect(statSync(gitignorePath).mode & 0o777).toBe(0o644); - }), - ), - Effect.ensuring( - Effect.sync(() => { - process.umask(prevUmask); - rmSync(cwd, { recursive: true, force: true }); - }), - ), - ); - }, + expect(yield* fileMode(path.join(cwd, "supabase", ".gitignore"))).toBe(0o644); + }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts index 7d9a266f2c..ef4dd89a5f 100644 --- a/apps/cli/src/shared/init/project-init.templates.ts +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -39,6 +39,8 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run \`SHOW # server_version;\` on the remote database to check. major_version = 17 +# OrioleDB version to use as the Postgres storage engine. Leave empty to use the default engine. +orioledb_version = "__ORIOLEDB_VERSION__" [db.pooler] enabled = false @@ -392,8 +394,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "__ORIOLEDB_VERSION__" # Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com s3_host = "env(S3_HOST)" # Configures S3 bucket region, eg. us-east-1 @@ -464,7 +464,7 @@ export const INTELLIJ_DENO_TEMPLATE = `<?xml version="1.0" encoding="UTF-8"?> </project> `; -const ORIOLE_DB_VERSION = "15.1.0.150"; +const ORIOLE_DB_VERSION = "17.11.0.002"; const EXPERIMENTAL_STACK_INIT_FLAG = `# Use the new local stack backend for start, stop, and status, and for --local targets of db, migration, test db, gen types, inspect, and pull. stack = true diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index 6dbb7073d6..fefb42dbd3 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -1,7 +1,6 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { applyConfigEdits, type ConfigEdit } from "@supabase/config/internal"; import { INIT_GITIGNORE_TEMPLATE, @@ -11,18 +10,20 @@ import { renderCliConfigTemplate, } from "./project-init.templates.ts"; -const here = dirname(fileURLToPath(import.meta.url)); -// Vendored copies of the Go CLI's init-template scaffold files. Dotted file -// names are de-dotted so git/tooling don't interpret the fixtures themselves. -const goTemplatesFixtureDir = join(here, "testdata/go-templates"); - function normalizeNewlines(text: string): string { return text.replace(/\r\n/g, "\n"); } -function readVendoredTemplate(name: string): string { - return normalizeNewlines(readFileSync(join(goTemplatesFixtureDir, name), "utf8")); -} +// Vendored copies of the Go CLI's init-template scaffold files. Dotted file +// names are de-dotted so git/tooling don't interpret the fixtures themselves. +const readVendoredTemplate = Effect.fnUntraced(function* (name: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const contents = yield* fs.readFileString( + path.join(import.meta.dirname, "testdata/go-templates", name), + ); + return normalizeNewlines(contents); +}); // Go's config.toml scaffold renders through text/template (`config.Eject`), so // an action wrapping a backtick raw string — {{ `{{ .Code }}` }} in the @@ -34,16 +35,16 @@ function resolveGoTemplateEscapes(template: string): string { } // Emulates what Go's config.Eject writes to disk for a fresh `supabase init` project. -function renderExpectedGoEject(): string { - return ( - resolveGoTemplateEscapes(readVendoredTemplate("config.toml")) +const renderExpectedGoEject = readVendoredTemplate("config.toml").pipe( + Effect.map((template) => + resolveGoTemplateEscapes(template) .replace("{{ .ProjectId }}", "demo-project") - .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") + .replace("{{ .Db.OrioleDBVersion }}", "17.11.0.002") // supabase init always opts new projects into pg-delta; the Go template // renders this from a flag only set on the init path. - .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true") - ); -} + .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true"), + ), +); // The Go scaffold still describes `auto_expose_new_tables` as unset-means- // revoked and deprecated; the native template documents unset-means-exposed @@ -57,29 +58,37 @@ const NATIVE_AUTO_EXPOSE_COMMENT = `# without explicit GRANTs, matching the clou # instead. Left unset, a fresh project falls back to \`true\`. # auto_expose_new_tables = true`; -function renderExpectedNativeEject(): string { - return renderExpectedGoEject() - .replace( - '# content_path = "./templates/password_changed_notification.html"', - '# content_path = "./supabase/templates/password_changed_notification.html"', - ) - .replace(GO_AUTO_EXPOSE_COMMENT, NATIVE_AUTO_EXPOSE_COMMENT); -} +const renderExpectedNativeEject = renderExpectedGoEject.pipe( + Effect.map((eject) => + eject + .replace( + '# content_path = "./templates/password_changed_notification.html"', + '# content_path = "./supabase/templates/password_changed_notification.html"', + ) + .replace(GO_AUTO_EXPOSE_COMMENT, NATIVE_AUTO_EXPOSE_COMMENT), + ), +); describe("project init templates", () => { - it("renders config.toml with the native notification content_path base", () => { - expect(normalizeNewlines(renderCliConfigTemplate("demo-project", true))).toBe( - renderExpectedNativeEject(), - ); - }); + it.effect("renders config.toml with the native notification content_path base", () => + Effect.gen(function* () { + expect(normalizeNewlines(renderCliConfigTemplate("demo-project", true))).toBe( + yield* renderExpectedNativeEject, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("models every template action in the Go scaffold, so parity cannot silently drift", () => { - // Anything beyond the GoTrue OTP placeholder means the Go template gained - // a construct this suite doesn't emulate; update `resolveGoTemplateEscapes` - // to match before shipping. - const unresolvedActions = renderExpectedGoEject().match(/\{\{[^}]*\}\}/g) ?? []; - expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); - }); + it.effect( + "models every template action in the Go scaffold, so parity cannot silently drift", + () => + Effect.gen(function* () { + // Anything beyond the GoTrue OTP placeholder means the Go template gained + // a construct this suite doesn't emulate; update `resolveGoTemplateEscapes` + // to match before shipping. + const unresolvedActions = (yield* renderExpectedGoEject).match(/\{\{[^}]*\}\}/g) ?? []; + expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); + }).pipe(Effect.provide(BunServices.layer)), + ); it("renders the SMS and MFA phone OTP templates as GoTrue templates, not raw Go escapes", () => { const rendered = renderCliConfigTemplate("demo-project", false); @@ -112,21 +121,33 @@ describe("project init templates", () => { expect(rendered).not.toMatch(/^port = 54327$/m); }); - it("matches the Go .gitignore scaffold", () => { - expect(INIT_GITIGNORE_TEMPLATE).toBe(readVendoredTemplate("gitignore")); - }); + it.effect("matches the Go .gitignore scaffold", () => + Effect.gen(function* () { + expect(INIT_GITIGNORE_TEMPLATE).toBe(yield* readVendoredTemplate("gitignore")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go VS Code extensions scaffold", () => { - expect(VSCODE_EXTENSIONS_TEMPLATE).toBe(readVendoredTemplate("vscode-extensions.json.golden")); - }); + it.effect("matches the Go VS Code extensions scaffold", () => + Effect.gen(function* () { + expect(VSCODE_EXTENSIONS_TEMPLATE).toBe( + yield* readVendoredTemplate("vscode-extensions.json.golden"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go VS Code settings scaffold", () => { - expect(VSCODE_SETTINGS_TEMPLATE).toBe(readVendoredTemplate("vscode-settings.json.golden")); - }); + it.effect("matches the Go VS Code settings scaffold", () => + Effect.gen(function* () { + expect(VSCODE_SETTINGS_TEMPLATE).toBe( + yield* readVendoredTemplate("vscode-settings.json.golden"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go IntelliJ scaffold", () => { - expect(INTELLIJ_DENO_TEMPLATE).toBe(readVendoredTemplate("idea-deno.xml")); - }); + it.effect("matches the Go IntelliJ scaffold", () => + Effect.gen(function* () { + expect(INTELLIJ_DENO_TEMPLATE).toBe(yield* readVendoredTemplate("idea-deno.xml")); + }).pipe(Effect.provide(BunServices.layer)), + ); }); // `applyConfigEdits` must edit the scaffold exactly as intended and nothing diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index a5d8e82b30..33675d242d 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -106,6 +106,8 @@ const decodeJsonObject = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); +const encodePrettyJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + // Parses a settings file through a Schema boundary so malformed JSON surfaces // as a typed `InitParseSettingsError` (never a fiber defect) and a // non-object document is rejected. @@ -147,9 +149,9 @@ const INIT_DIR_MODE = 0o755; function writeJsonFile(pathname: string, contents: Record<string, unknown>) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(pathname, `${JSON.stringify(contents, null, 2)}\n`, { - mode: INIT_FILE_MODE, - }); + // `contents` was just decoded from JSON, so encoding it back cannot fail. + const json = yield* encodePrettyJson(contents).pipe(Effect.orDie); + yield* fs.writeFileString(pathname, `${json}\n`, { mode: INIT_FILE_MODE }); }); } diff --git a/apps/cli/src/shared/init/testdata/go-templates/config.toml b/apps/cli/src/shared/init/testdata/go-templates/config.toml index 07bacc0ade..c26043d5c9 100644 --- a/apps/cli/src/shared/init/testdata/go-templates/config.toml +++ b/apps/cli/src/shared/init/testdata/go-templates/config.toml @@ -40,6 +40,8 @@ health_timeout = "2m" # The database major version to use. This has to be the same as your remote database's. Run `SHOW # server_version;` on the remote database to check. major_version = 17 +# OrioleDB version to use as the Postgres storage engine. Leave empty to use the default engine. +orioledb_version = "{{ .Db.OrioleDBVersion }}" [db.pooler] enabled = false @@ -393,8 +395,6 @@ backend = "postgres" # Experimental features may be deprecated any time [experimental] -# Configures Postgres storage engine to use OrioleDB (S3) -orioledb_version = "{{ .Experimental.OrioleDBVersion }}" # Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com s3_host = "env(S3_HOST)" # Configures S3 bucket region, eg. us-east-1 diff --git a/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts b/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts index d227e3b71a..9555b1dd3f 100644 --- a/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts +++ b/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, FileSystem, Path } from "effect"; import { parse } from "yaml"; import { buildIssueUrl, @@ -34,16 +34,17 @@ function isBodyItem(value: unknown): value is IssueFormBodyItem { return isRecord(value); } -function issueTemplateDir() { - return resolve(import.meta.dirname, "../../../../../.github/ISSUE_TEMPLATE"); -} +const issueTemplatePath = Effect.fnUntraced(function* (template: string) { + const path = yield* Path.Path; + return path.resolve(import.meta.dirname, "../../../../../.github/ISSUE_TEMPLATE", template); +}); -function readTemplate(template: string): ReadonlyArray<IssueFormBodyItem> { - const path = resolve(issueTemplateDir(), template); - const parsed = parse(readFileSync(path, "utf8")); +const readTemplate = Effect.fnUntraced(function* (template: string) { + const fs = yield* FileSystem.FileSystem; + const parsed: unknown = parse(yield* fs.readFileString(yield* issueTemplatePath(template))); if (!isRecord(parsed) || !Array.isArray(parsed.body)) return []; return parsed.body.filter(isBodyItem); -} +}); function fieldIds(body: ReadonlyArray<IssueFormBodyItem>) { return body.flatMap((item) => (typeof item.id === "string" ? [item.id] : [])); @@ -75,68 +76,79 @@ function requiredFields(body: ReadonlyArray<IssueFormBodyItem>) { } describe("issue template contract", () => { - it("points to issue form templates that exist", () => { - for (const form of Object.values(issueTemplateContract)) { - expect(existsSync(resolve(issueTemplateDir(), form.template))).toBe(true); - } - }); - - it("keeps issue command field ids aligned with the GitHub issue forms", () => { - for (const form of Object.values(issueTemplateContract)) { - const ids = fieldIds(readTemplate(form.template)); - expect(ids).toEqual(expect.arrayContaining([...form.fields])); - expect(form.fields).toEqual(expect.arrayContaining(ids)); - } - }); - - it("keeps issue command prefilled option values valid for their fields", () => { - for (const form of Object.values(issueTemplateContract)) { - const body = readTemplate(form.template); - for (const [fieldId, values] of Object.entries(form.optionValues)) { - const item = body.find((entry) => entry.id === fieldId); - expect(item, `${form.template} should include field ${fieldId}`).toBeDefined(); - expect(optionLabels(item!)).toEqual(expect.arrayContaining([...values])); + it.effect("points to issue form templates that exist", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const form of Object.values(issueTemplateContract)) { + expect(yield* fs.exists(yield* issueTemplatePath(form.template))).toBe(true); } - } - }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps issue command field ids aligned with the GitHub issue forms", () => + Effect.gen(function* () { + for (const form of Object.values(issueTemplateContract)) { + const ids = fieldIds(yield* readTemplate(form.template)); + expect(ids).toEqual(expect.arrayContaining([...form.fields])); + expect(form.fields).toEqual(expect.arrayContaining(ids)); + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps issue command prefilled option values valid for their fields", () => + Effect.gen(function* () { + for (const form of Object.values(issueTemplateContract)) { + const body = yield* readTemplate(form.template); + for (const [fieldId, values] of Object.entries(form.optionValues)) { + const item = body.find((entry) => entry.id === fieldId); + expect(item, `${form.template} should include field ${fieldId}`).toBeDefined(); + expect(optionLabels(item!)).toEqual(expect.arrayContaining([...values])); + } + } + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps inferred install methods compatible with the template dropdown", () => + Effect.gen(function* () { + const infer = (execPath: string, env: Record<string, string>) => + inferIssueInstallMethod({ execPath }).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), + ), + ); + const cases = [ + { userAgent: "pnpm/10.0.0", execPath: "/usr/local/bin/supabase", expected: "pnpm" }, + { userAgent: "npm/11.0.0", execPath: "/usr/local/bin/supabase", expected: "npm" }, + { userAgent: "yarn/4.0.0", execPath: "/usr/local/bin/supabase", expected: "yarn" }, + { userAgent: "bun/1.2.0", execPath: "/usr/local/bin/supabase", expected: "bun" }, + { userAgent: undefined, execPath: "/opt/homebrew/bin/supabase", expected: "brew" }, + { userAgent: undefined, execPath: "/usr/local/bin/supabase", expected: "Other" }, + ] as const; - it("keeps inferred install methods compatible with the template dropdown", () => { - const originalUserAgent = process.env["npm_config_user_agent"]; - const originalInstallMethod = process.env["SUPABASE_INSTALL_METHOD"]; - const cases = [ - { userAgent: "pnpm/10.0.0", execPath: "/usr/local/bin/supabase", expected: "pnpm" }, - { userAgent: "npm/11.0.0", execPath: "/usr/local/bin/supabase", expected: "npm" }, - { userAgent: "yarn/4.0.0", execPath: "/usr/local/bin/supabase", expected: "yarn" }, - { userAgent: "bun/1.2.0", execPath: "/usr/local/bin/supabase", expected: "bun" }, - { userAgent: undefined, execPath: "/opt/homebrew/bin/supabase", expected: "brew" }, - { userAgent: undefined, execPath: "/usr/local/bin/supabase", expected: "Other" }, - ] as const; - - try { - delete process.env["SUPABASE_INSTALL_METHOD"]; for (const testcase of cases) { - if (testcase.userAgent === undefined) { - delete process.env["npm_config_user_agent"]; - } else { - process.env["npm_config_user_agent"] = testcase.userAgent; - } - const value = inferIssueInstallMethod({ execPath: testcase.execPath }); + const value = yield* infer( + testcase.execPath, + testcase.userAgent === undefined ? {} : { npm_config_user_agent: testcase.userAgent }, + ); expect(value).toBe(testcase.expected); expect(issueInstallMethodValues).toContain(value); } - process.env["SUPABASE_INSTALL_METHOD"] = "Docker image"; - expect(inferIssueInstallMethod({ execPath: "/usr/local/bin/supabase" })).toBe("Docker image"); - - process.env["SUPABASE_INSTALL_METHOD"] = "asdf"; - expect(inferIssueInstallMethod({ execPath: "/usr/local/bin/supabase" })).toBe("Other"); - } finally { - if (originalUserAgent === undefined) delete process.env["npm_config_user_agent"]; - else process.env["npm_config_user_agent"] = originalUserAgent; - if (originalInstallMethod === undefined) delete process.env["SUPABASE_INSTALL_METHOD"]; - else process.env["SUPABASE_INSTALL_METHOD"] = originalInstallMethod; - } - }); + expect( + yield* infer("/usr/local/bin/supabase", { SUPABASE_INSTALL_METHOD: "Docker image" }), + ).toBe("Docker image"); + expect(yield* infer("/usr/local/bin/supabase", { SUPABASE_INSTALL_METHOD: "asdf" })).toBe( + "Other", + ); + // A blank override falls through to the user agent, like an unset one. + expect( + yield* infer("/usr/local/bin/supabase", { + SUPABASE_INSTALL_METHOD: " ", + npm_config_user_agent: "pnpm/10.0.0", + }), + ).toBe("pnpm"); + }), + ); it("keeps generated issue URLs under the browser-friendly limit", () => { const longField = "x".repeat(4_000); @@ -150,9 +162,13 @@ describe("issue template contract", () => { expect(url.length).toBeLessThanOrEqual(8_000); }); - it("keeps issue form required fields aligned with the command contract", () => { - for (const form of Object.values(issueTemplateContract)) { - expect(requiredFields(readTemplate(form.template))).toEqual([...form.requiredFields]); - } - }); + it.effect("keeps issue form required fields aligned with the command contract", () => + Effect.gen(function* () { + for (const form of Object.values(issueTemplateContract)) { + expect(requiredFields(yield* readTemplate(form.template))).toEqual([ + ...form.requiredFields, + ]); + } + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/issue/issue-url.ts b/apps/cli/src/shared/issue/issue-url.ts index 2c24d5ca6c..ed99976e0c 100644 --- a/apps/cli/src/shared/issue/issue-url.ts +++ b/apps/cli/src/shared/issue/issue-url.ts @@ -1,4 +1,4 @@ -import { Option } from "effect"; +import { Config, Effect, Option } from "effect"; const ISSUE_NEW_URL = "https://github.com/supabase/cli/issues/new"; const MAX_FIELD_LENGTH = 1_500; @@ -134,11 +134,18 @@ function validInstallMethod(value: string): string { return issueInstallMethodValueSet.has(value) ? value : "Other"; } -export function inferIssueInstallMethod(runtimeInfo: { readonly execPath: string }): string { - const explicit = process.env["SUPABASE_INSTALL_METHOD"]?.trim(); - if (explicit) return validInstallMethod(explicit); - - const userAgent = process.env["npm_config_user_agent"]?.toLowerCase(); +export const inferIssueInstallMethod = Effect.fnUntraced(function* (runtimeInfo: { + readonly execPath: string; +}) { + const explicit = (yield* Config.option(Config.string("SUPABASE_INSTALL_METHOD"))).pipe( + Option.map((value) => value.trim()), + Option.filter((value) => value.length > 0), + ); + if (Option.isSome(explicit)) return validInstallMethod(explicit.value); + + const userAgent = Option.getOrUndefined( + yield* Config.option(Config.string("npm_config_user_agent")), + )?.toLowerCase(); if (userAgent?.startsWith("pnpm/")) return "pnpm"; if (userAgent?.startsWith("npm/")) return "npm"; if (userAgent?.startsWith("yarn/")) return "yarn"; @@ -149,4 +156,4 @@ export function inferIssueInstallMethod(runtimeInfo: { readonly execPath: string if (execPath.includes("/node_modules/") || execPath.includes("\\node_modules\\")) return "npm"; return "Other"; -} +}); diff --git a/apps/cli/src/shared/output/json-error-handling.unit.test.ts b/apps/cli/src/shared/output/json-error-handling.unit.test.ts index bd050ca0e6..5e26e0d017 100644 --- a/apps/cli/src/shared/output/json-error-handling.unit.test.ts +++ b/apps/cli/src/shared/output/json-error-handling.unit.test.ts @@ -48,7 +48,7 @@ function mockOutput(format: "text" | "json" | "stream-json" = "text") { fail: (_nextMessage?: string) => Effect.void, info: (_nextMessage?: string) => Effect.void, cancel: (_nextMessage?: string) => Effect.void, - clear: () => Effect.void, + clear: Effect.void, }), result: (_data: unknown) => Effect.void, success: (_message: string, _data?: Record<string, unknown>) => Effect.void, @@ -80,7 +80,7 @@ function mockOutput(format: "text" | "json" | "stream-json" = "text") { describe("withJsonErrorHandling", () => { describe("text format", () => { - it.live("re-raises the original error in text format", () => { + it.effect("re-raises the original error in text format", () => { const processControl = mockProcessControl(); return Effect.gen(function* () { const out = mockOutput("text"); @@ -106,7 +106,7 @@ describe("withJsonErrorHandling", () => { }); describe("json format", () => { - it.live("calls output.fail() with structured error and sets process.exitCode", () => { + it.effect("calls output.fail() with structured error and sets process.exitCode", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -125,10 +125,10 @@ describe("withJsonErrorHandling", () => { suggestion: "try again", }); expect(processControl.exitCode).toBe(1); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); - it.live("includes detail and suggestion when present on error", () => { + it.effect("includes detail and suggestion when present on error", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -142,10 +142,10 @@ describe("withJsonErrorHandling", () => { detail: "in-depth explanation", suggestion: "do this instead", }); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); - it.live("omits detail and suggestion when absent on error", () => { + it.effect("omits detail and suggestion when absent on error", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -157,10 +157,10 @@ describe("withJsonErrorHandling", () => { expect(call.message).toBe("minimal error"); expect("detail" in call).toBe(false); expect("suggestion" in call).toBe(false); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); - it.live("uses UnknownError code when error has no _tag", () => { + it.effect("uses UnknownError code when error has no _tag", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -169,10 +169,10 @@ describe("withJsonErrorHandling", () => { expect(out.failCalls).toHaveLength(1); expect(out.failCalls[0]?.code).toBe("UnknownError"); expect(out.failCalls[0]?.message).toBe("plain error message"); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); - it.live("sets the exact exit code for a GoChildExitError, not a generic 1", () => { + it.effect("sets the exact exit code for a GoChildExitError, not a generic 1", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); return Effect.gen(function* () { @@ -184,7 +184,7 @@ describe("withJsonErrorHandling", () => { expect(out.failCalls).toHaveLength(1); expect(out.failCalls[0]?.code).toBe("GoChildExitError"); expect(processControl.exitCode).toBe(130); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.merge(out.layer, processControl.layer))); }); }); }); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 6054c9a3a7..5440a67521 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -14,7 +14,7 @@ import { text, } from "@clack/prompts"; import { styleText } from "node:util"; -import { Effect, Layer, Option, Stdio, Stream } from "effect"; +import { DateTime, Effect, Fiber, Layer, Option, Schema, Stdio, Stream } from "effect"; import { Tty } from "../runtime/tty.service.ts"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; @@ -24,6 +24,13 @@ import type { OutputFormat, StreamEvent } from "./types.ts"; const TASK_SPINNER_DELAY_MS = 200; +const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeJson = (value: unknown) => + encodeJsonString(value).pipe( + Effect.mapError((error) => new TypeError(error.message, { cause: error })), + Effect.orDie, + ); + // Reads the opt-in `MachineErrorContext` cell, if any command in this run // provided it — see that service's doc comment for the envelope contract. const readMachineErrorContext = Effect.fnUntraced(function* () { @@ -64,6 +71,7 @@ export const textOutputLayer = Layer.effect( Effect.gen(function* () { const tty = yield* Tty; const write = stdioWriter(yield* Stdio.Stdio); + const scope = yield* Effect.scope; const DEFAULT_AUTOCOMPLETE_THRESHOLD = 10; const buildSelectOptions = ( @@ -198,48 +206,123 @@ export const textOutputLayer = Layer.effect( return value; }); + // Writes pause the shown task spinner so they never share its row; the task's final + // line waits for the last in-flight write. + interface ShownSpinner { + handle: ReturnType<typeof spinner>; + message: string; + pauses: number; + settle?: () => void; + } + let activeSpinner: ShownSpinner | undefined; + + const pauseSpinner = (): ShownSpinner | undefined => { + if (activeSpinner === undefined) return undefined; + if (activeSpinner.pauses === 0) activeSpinner.handle.clear(); + activeSpinner.pauses += 1; + return activeSpinner; + }; + + // Without the guide, a resume adds no extra `│` line. + const resumeSpinner = (paused: ShownSpinner | undefined) => { + if (paused === undefined || paused.pauses === 0) return; + paused.pauses -= 1; + if (paused.pauses > 0) return; + paused.handle = spinner({ withGuide: false }); + paused.handle.start(formatTaskMessage(paused.message)); + paused.settle?.(); + }; + + const logAround = ( + emit: (message: string, opts?: { spacing?: number }) => void, + message: string, + ) => { + const paused = pauseSpinner(); + if (paused === undefined) emit(message); + else emit(message, { spacing: 0 }); + resumeSpinner(paused); + }; + + // A spinner due to appear during an unpaused write waits until such writes finish. + let unpausedWrites = 0; + let showWhenIdle: (() => void) | undefined; + + const withSpinnerPaused = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => + Effect.suspend(() => { + const paused = pauseSpinner(); + if (paused !== undefined) + return effect.pipe(Effect.ensuring(Effect.sync(() => resumeSpinner(paused)))); + unpausedWrites += 1; + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + unpausedWrites -= 1; + if (unpausedWrites > 0 || showWhenIdle === undefined) return; + const show = showWhenIdle; + showWhenIdle = undefined; + show(); + }), + ), + ); + }); + return Output.of({ format: "text" as const, interactive: tty.stdoutIsTty, intro: (title: string) => Effect.sync(() => intro(title)), outro: (message: string) => Effect.sync(() => outro(message)), - info: (message: string) => Effect.sync(() => log.info(message)), - warn: (message: string) => Effect.sync(() => log.warn(message)), - error: (message: string) => Effect.sync(() => log.error(message)), + info: (message: string) => Effect.sync(() => logAround(log.info, message)), + warn: (message: string) => Effect.sync(() => logAround(log.warn, message)), + error: (message: string) => Effect.sync(() => logAround(log.error, message)), event: (event: StreamEvent) => - event.type === "log-entry" - ? Effect.sync(() => log.info(`[${event.service}] ${event.line}`)) - : Effect.sync(() => log.info(JSON.stringify(event))), + (event.type === "log-entry" + ? Effect.succeed(`[${event.service}] ${event.line}`) + : encodeJson(event) + ).pipe(Effect.flatMap((message) => Effect.sync(() => logAround(log.info, message)))), task: (message: string) => - Effect.sync(() => { + Effect.gen(function* () { let shown = false; let settled = false; let currentMessage = message; - let task: ReturnType<typeof spinner> | undefined; - let timeout: ReturnType<typeof setTimeout> | undefined; - - const cancelPendingStart = () => { - if (timeout !== undefined) { - clearTimeout(timeout); - timeout = undefined; - } - }; + let shownSpinner: ShownSpinner | undefined; - const finish = (render: () => void) => { - settled = true; - cancelPendingStart(); - render(); - }; - - timeout = setTimeout(() => { + // clack's spinner writes cursor/animation escape codes, so non-TTY stdout + // gets plain progress lines instead. + let lastLogged: string | undefined; + const show = () => { if (settled) { return; } - task = spinner(); + if (!tty.stdoutIsTty) { + lastLogged = currentMessage; + log.step(currentMessage); + return; + } + shownSpinner = { handle: spinner(), message: currentMessage, pauses: 0 }; shown = true; - task.start(currentMessage); - timeout = undefined; - }, TASK_SPINNER_DELAY_MS); + shownSpinner.handle.start(currentMessage); + activeSpinner = shownSpinner; + }; + + const pendingStart = yield* Effect.sync(() => { + if (unpausedWrites > 0) showWhenIdle = show; + else show(); + }).pipe( + Effect.delay(TASK_SPINNER_DELAY_MS), + Effect.forkIn(scope, { startImmediately: true }), + ); + + const finish = (render: () => void) => + Effect.sync(() => { + settled = true; + const settle = () => { + render(); + if (activeSpinner === shownSpinner) activeSpinner = undefined; + }; + if (shownSpinner !== undefined && shownSpinner.pauses > 0) + shownSpinner.settle = settle; + else settle(); + }).pipe(Effect.andThen(Fiber.interrupt(pendingStart))); return { message: (nextMessage: string) => @@ -248,65 +331,60 @@ export const textOutputLayer = Layer.effect( return; } currentMessage = nextMessage; - if (shown) { - task?.message(formatTaskMessage(nextMessage)); + if (shownSpinner !== undefined) { + shownSpinner.message = nextMessage; + if (shownSpinner.pauses === 0) + shownSpinner.handle.message(formatTaskMessage(nextMessage)); + } else if (lastLogged !== undefined && lastLogged !== nextMessage) { + // Polling tasks repeat the same message; log only changes. + lastLogged = nextMessage; + log.step(nextMessage); } }), succeed: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - task?.stop(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - log.success(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + shownSpinner?.handle.stop(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + log.success(nextMessage); + } + }), fail: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - task?.error(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - log.error(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + shownSpinner?.handle.error(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + log.error(nextMessage); + } + }), info: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - task?.clear(); - } - if (nextMessage !== undefined) { - log.info(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + shownSpinner?.handle.clear(); + } + if (nextMessage !== undefined) { + log.info(nextMessage); + } + }), cancel: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - task?.cancel(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - cancel(nextMessage); - } - }), - ), - clear: () => - Effect.sync(() => - finish(() => { - if (shown) { - task?.clear(); - } - }), - ), + finish(() => { + if (shown) { + shownSpinner?.handle.cancel(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + cancel(nextMessage); + } + }), + clear: finish(() => { + if (shown) { + shownSpinner?.handle.clear(); + } + }), }; }), promptText: ( @@ -365,9 +443,15 @@ export const textOutputLayer = Layer.effect( }; }), result: () => Effect.void, - success: (message: string) => Effect.sync(() => log.success(message)), + success: (message: string) => Effect.sync(() => logAround(log.success, message)), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.sync(() => { + // A command failure is terminal, so a still-shown task spinner is dropped. + if (activeSpinner !== undefined) { + activeSpinner.handle.clear(); + activeSpinner.pauses = 0; + activeSpinner = undefined; + } // Bypasses clack's `log.error` framing (`│` guide + `■` icon): a // red-styled message on stderr, optionally followed by a suggestion. process.stderr.write(styleText("red", err.message) + "\n"); @@ -387,8 +471,10 @@ export const textOutputLayer = Layer.effect( ); } }), - raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), - rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => write(bytes, stream), + raw: (text: string, stream: "stdout" | "stderr" = "stdout") => + withSpinnerPaused(write(text, stream)), + rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => + withSpinnerPaused(write(bytes, stream)), }); }), ); @@ -418,7 +504,8 @@ export const jsonOutputLayer = Layer.effect( info: (message: string) => writeStderr(`${message}\n`), warn: (message: string) => writeStderr(`${message}\n`), error: (message: string) => writeStderr(`${message}\n`), - event: (event: StreamEvent) => writeStderr(`${JSON.stringify(event)}\n`), + event: (event: StreamEvent) => + encodeJson(event).pipe(Effect.flatMap((json) => writeStderr(`${json}\n`))), task: (message: string) => Effect.sync(() => ({ message: (nextMessage: string) => writeStderr(`[task] ${nextMessage}\n`), @@ -430,7 +517,7 @@ export const jsonOutputLayer = Layer.effect( nextMessage ? writeStderr(`${nextMessage}\n`) : Effect.void, cancel: (nextMessage?: string) => nextMessage ? writeStderr(`[task] cancelled: ${nextMessage}\n`) : Effect.void, - clear: () => Effect.void, + clear: Effect.void, })).pipe(Effect.tap(() => writeStderr(`[task] start: ${message}\n`))), promptText: () => nonInteractive("prompt for input"), promptPassword: () => nonInteractive("prompt for password"), @@ -457,7 +544,8 @@ export const jsonOutputLayer = Layer.effect( const extra = yield* readMachineErrorContext(); // `extra` spreads first so the envelope's own `_tag`/`error` can't // be clobbered by a same-named context field. - yield* writeStdout(JSON.stringify({ ...extra, _tag: "Error", error: err }) + "\n"); + const json = yield* encodeJson({ ...extra, _tag: "Error", error: err }); + yield* writeStdout(json + "\n"); }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => write(bytes, stream), @@ -471,15 +559,13 @@ export const streamJsonOutputLayer = Layer.effect( Effect.gen(function* () { const write = stdioWriter(yield* Stdio.Stdio); const writeStdout = (s: string) => write(s, "stdout"); - const emitLog = (level: "info" | "warn" | "success" | "error", message: string) => { - const event: StreamEvent = { - type: "log", - level, - message, - timestamp: new Date().toISOString(), - }; - return writeStdout(JSON.stringify(event) + "\n"); - }; + const emitEvent = (event: (timestamp: string) => StreamEvent, extra: object = {}) => + DateTime.now.pipe( + Effect.flatMap((now) => encodeJson({ ...extra, ...event(DateTime.formatIso(now)) })), + Effect.flatMap((json) => writeStdout(json + "\n")), + ); + const emitLog = (level: "info" | "warn" | "success" | "error", message: string) => + emitEvent((timestamp) => ({ type: "log", level, message, timestamp })); const nonInteractive = (action: string) => Effect.fail( @@ -488,14 +574,8 @@ export const streamJsonOutputLayer = Layer.effect( suggestion: "Provide all required values via flags", }), ); - const result = (data: unknown) => { - const event: StreamEvent = { - type: "result", - data, - timestamp: new Date().toISOString(), - }; - return writeStdout(`${JSON.stringify(event)}\n`); - }; + const result = (data: unknown) => + emitEvent((timestamp) => ({ type: "result", data, timestamp })); return Output.of({ format: "stream-json" as const, @@ -505,7 +585,8 @@ export const streamJsonOutputLayer = Layer.effect( info: (message: string) => emitLog("info", message), warn: (message: string) => emitLog("warn", message), error: (message: string) => emitLog("error", message), - event: (event: StreamEvent) => writeStdout(JSON.stringify(event) + "\n"), + event: (event: StreamEvent) => + encodeJson(event).pipe(Effect.flatMap((json) => writeStdout(json + "\n"))), task: (message: string) => Effect.sync(() => ({ message: (nextMessage: string) => emitLog("info", nextMessage), @@ -513,7 +594,7 @@ export const streamJsonOutputLayer = Layer.effect( fail: (nextMessage?: string) => emitLog("error", nextMessage ?? "Task failed."), info: (nextMessage?: string) => emitLog("info", nextMessage ?? "Task completed."), cancel: (nextMessage?: string) => emitLog("warn", nextMessage ?? "Task cancelled."), - clear: () => Effect.void, + clear: Effect.void, })).pipe(Effect.tap(() => emitLog("info", message))), promptText: () => nonInteractive("prompt for input"), promptPassword: () => nonInteractive("prompt for password"), @@ -524,15 +605,15 @@ export const streamJsonOutputLayer = Layer.effect( Effect.sync(() => { let current = 0; const emit = (status: "start" | "active" | "done", message: string) => { - const event: StreamEvent = { + const at = current; + return emitEvent((timestamp) => ({ type: "progress", status, - current, + current: at, max: opts.max, message, - timestamp: new Date().toISOString(), - }; - return writeStdout(JSON.stringify(event) + "\n"); + timestamp, + })); }; return { @@ -550,14 +631,9 @@ export const streamJsonOutputLayer = Layer.effect( fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.gen(function* () { const extra = yield* readMachineErrorContext(); - const event: StreamEvent = { - type: "error", - error: err, - timestamp: new Date().toISOString(), - }; // `extra` spreads first so the event's own `type`/`error`/`timestamp` // can't be clobbered by a same-named context field. - yield* writeStdout(JSON.stringify({ ...extra, ...event }) + "\n"); + yield* emitEvent((timestamp) => ({ type: "error", error: err, timestamp }), extra); }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => write(bytes, stream), diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index cb75d76811..24a1e4b23a 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { afterEach, beforeEach, vi } from "vitest"; -import { Cause, Effect, Exit, Layer, Sink, Stdio, Stream } from "effect"; +import { beforeEach, vi } from "vitest"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Sink, Stdio, Stream } from "effect"; +import { TestClock } from "effect/testing"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; import { machineErrorContextLayer } from "./machine-error-context.layer.ts"; @@ -50,7 +51,7 @@ vi.mock("@clack/prompts", () => ({ outro: (a: unknown) => mockClack.outro(a), note: (a: unknown, b?: unknown, c?: unknown) => mockClack.note(a, b, c), log: mockClack.log, - spinner: () => mockClack.spinnerFactory(), + spinner: (opts?: unknown) => mockClack.spinnerFactory(opts), text: (a: unknown) => mockClack.text(a), password: (a: unknown) => mockClack.password(a), confirm: (a: unknown) => mockClack.confirm(a), @@ -63,14 +64,13 @@ vi.mock("@clack/prompts", () => ({ beforeEach(() => { vi.resetAllMocks(); - vi.useRealTimers(); mockClack.isCancel.mockReturnValue(false); mockClack.spinnerFactory.mockReturnValue(mockClack.spinnerHandle); }); -afterEach(() => { - vi.useRealTimers(); -}); +const decodeJson = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); function mockStdio() { const stdout: string[] = []; @@ -112,11 +112,10 @@ describe("Output", () => { it.effect("task uses clack spinner and can resolve into info", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.message("Still loading..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); yield* task.info("Loaded organizations."); expect(mockClack.spinnerFactory).toHaveBeenCalledTimes(1); @@ -129,11 +128,10 @@ describe("Output", () => { it.effect("task skips the spinner when it completes quickly", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.succeed("Loaded organizations."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); expect(mockClack.spinnerHandle.start).not.toHaveBeenCalled(); @@ -145,11 +143,10 @@ describe("Output", () => { "task keeps raw multiline formatting when it completes before the spinner shows", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.succeed("- name: Supabase\n- name: Supabase Dev"); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); expect(mockClack.log.success).toHaveBeenCalledWith( @@ -160,10 +157,9 @@ describe("Output", () => { it.effect("task prefixes continuation lines for multiline completions", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust(200); yield* task.succeed("- name: Supabase\n- name: Supabase Dev"); expect(mockClack.spinnerHandle.stop).toHaveBeenCalledWith( @@ -338,6 +334,211 @@ describe("Output", () => { }).pipe(Effect.provide(sunk)); }); + it.effect("never shows the spinner of a task left pending when the layer closes", () => + Effect.gen(function* () { + yield* Effect.gen(function* () { + const out = yield* Output; + yield* out.task("Loading organizations..."); + }).pipe(Effect.provide(layer, { local: true })); + yield* TestClock.adjust(200); + + expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); + }), + ); + + it.effect("pauses and resumes the task spinner around a log while it is shown", () => + Effect.gen(function* () { + const out = yield* Output; + yield* out.task("Loading organizations..."); + yield* TestClock.adjust(200); + + yield* out.warn("no files matched pattern: missing.sql"); + + expect(mockClack.spinnerFactory).toHaveBeenNthCalledWith(2, { withGuide: false }); + expect(mockClack.log.warn).toHaveBeenCalledWith("no files matched pattern: missing.sql", { + spacing: 0, + }); + const [clear] = mockClack.spinnerHandle.clear.mock.invocationCallOrder; + const [warn] = mockClack.log.warn.mock.invocationCallOrder; + const [, resume] = mockClack.spinnerHandle.start.mock.invocationCallOrder; + expect(clear).toBeLessThan(warn!); + expect(warn).toBeLessThan(resume!); + }).pipe(Effect.provide(layer)), + ); + + it.effect( + "pauses and resumes the task spinner around a stderr raw write while it is shown", + () => { + const order: string[] = []; + const stderr: string[] = []; + const stdioLayer = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => Sink.forEach((_item: string | Uint8Array) => Effect.void), + stderr: () => + Sink.forEach((item: string | Uint8Array) => + Effect.sync(() => { + order.push("write"); + stderr.push(typeof item === "string" ? item : new TextDecoder().decode(item)); + }), + ), + }), + ); + const sunk = textOutputLayer.pipe( + Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: true }), stdioLayer)), + ); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.task("Loading organizations..."); + yield* TestClock.adjust(200); + + mockClack.spinnerHandle.clear.mockImplementation(() => order.push("clear")); + mockClack.spinnerHandle.start.mockImplementation((msg?: string) => + order.push(`start:${msg}`), + ); + + yield* out.raw("raw line\n", "stderr"); + + expect(stderr).toEqual(["raw line\n"]); + expect(order).toEqual(["clear", "write", "start:Loading organizations..."]); + }).pipe(Effect.provide(sunk)); + }, + ); + + it.effect("resumes and settles the spinner only after overlapping raw writes finish", () => + Effect.gen(function* () { + const gate = (name: string) => + Effect.all({ entered: Deferred.make<void>(), release: Deferred.make<void>() }).pipe( + Effect.map((deferreds) => ({ name, ...deferreds })), + ); + const first = yield* gate("first\n"); + const second = yield* gate("second\n"); + const stdioLayer = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => Sink.forEach((_item: string | Uint8Array) => Effect.void), + stderr: () => + Sink.forEach((item: string | Uint8Array) => { + const write = [first, second].find(({ name }) => name === item); + return write === undefined + ? Effect.void + : Deferred.succeed(write.entered, undefined).pipe( + Effect.andThen(Deferred.await(write.release)), + ); + }), + }), + ); + const sunk = textOutputLayer.pipe( + Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: true }), stdioLayer)), + ); + yield* Effect.gen(function* () { + const out = yield* Output; + const task = yield* out.task("Loading organizations..."); + yield* TestClock.adjust(200); + + const firstWrite = yield* Effect.forkChild(out.raw(first.name, "stderr")); + yield* Deferred.await(first.entered); + const secondWrite = yield* Effect.forkChild(out.raw(second.name, "stderr")); + yield* Deferred.await(second.entered); + + yield* Deferred.succeed(first.release, undefined); + yield* Fiber.join(firstWrite); + yield* task.succeed("Loaded."); + expect(mockClack.spinnerFactory).toHaveBeenCalledTimes(1); + expect(mockClack.spinnerHandle.stop).not.toHaveBeenCalled(); + + yield* Deferred.succeed(second.release, undefined); + yield* Fiber.join(secondWrite); + expect(mockClack.spinnerHandle.clear).toHaveBeenCalledTimes(1); + expect(mockClack.spinnerFactory).toHaveBeenCalledTimes(2); + expect(mockClack.spinnerHandle.stop).toHaveBeenCalledWith("Loaded."); + }).pipe(Effect.provide(sunk)); + }), + ); + + it.effect("delays a due spinner until a raw write already in flight finishes", () => + Effect.gen(function* () { + const entered = yield* Deferred.make<void>(); + const release = yield* Deferred.make<void>(); + const stdioLayer = Layer.succeed( + Stdio.Stdio, + Stdio.make({ + args: Effect.succeed([]), + stdin: Stream.empty, + stdout: () => Sink.forEach((_item: string | Uint8Array) => Effect.void), + stderr: () => + Sink.forEach((_item: string | Uint8Array) => + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))), + ), + }), + ); + const sunk = textOutputLayer.pipe( + Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: true }), stdioLayer)), + ); + yield* Effect.gen(function* () { + const out = yield* Output; + yield* out.task("Loading organizations..."); + const write = yield* Effect.forkChild(out.raw("slow\n", "stderr")); + yield* Deferred.await(entered); + + yield* TestClock.adjust(200); + expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); + + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(write); + expect(mockClack.spinnerHandle.start).toHaveBeenCalledWith("Loading organizations..."); + }).pipe(Effect.provide(sunk)); + }), + ); + + it.effect("settles the task through the spinner resumed after a log", () => + Effect.gen(function* () { + const handle = () => ({ + start: vi.fn(), + stop: vi.fn(), + cancel: vi.fn(), + error: vi.fn(), + message: vi.fn(), + clear: vi.fn(), + isCancelled: false, + }); + const first = handle(); + const resumed = handle(); + mockClack.spinnerFactory.mockReturnValueOnce(first).mockReturnValueOnce(resumed); + const out = yield* Output; + const task = yield* out.task("Starting local Supabase stack..."); + yield* TestClock.adjust(200); + + yield* out.info("Seeding globals from roles.sql..."); + yield* task.succeed("Stack is ready."); + + expect(first.clear).toHaveBeenCalledTimes(1); + expect(first.stop).not.toHaveBeenCalled(); + expect(resumed.start).toHaveBeenCalledWith("Starting local Supabase stack..."); + expect(resumed.stop).toHaveBeenCalledWith("Stack is ready."); + }).pipe(Effect.provide(layer)), + ); + + it.effect("clears a shown task spinner before rendering a command failure", () => + Effect.gen(function* () { + const writes = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const out = yield* Output; + yield* out.task("Starting local Supabase stack..."); + yield* TestClock.adjust(200); + + yield* out.fail({ code: "E_TEST", message: "no database", suggestion: "retry" }); + const [clear] = mockClack.spinnerHandle.clear.mock.invocationCallOrder; + const [firstWrite] = writes.mock.invocationCallOrder; + writes.mockRestore(); + + expect(clear).toBeLessThan(firstWrite!); + }).pipe(Effect.provide(layer)), + ); + it.effect("promptText interrupts on cancel", () => { mockClack.text.mockResolvedValue(Symbol.for("clack:cancel")); mockClack.isCancel.mockReturnValue(true); @@ -536,6 +737,30 @@ describe("Output", () => { }); }); + describe("text layer on a non-TTY stdout", () => { + const layer = textOutputLayer.pipe( + Layer.provide(Layer.mergeAll(mockTty({ stdoutIsTty: false }), mockStdio().layer)), + ); + + it.effect("task logs each distinct progress message as a plain line instead of a spinner", () => + Effect.gen(function* () { + const out = yield* Output; + const task = yield* out.task("Loading organizations..."); + yield* TestClock.adjust(200); + yield* task.message("Loading projects..."); + yield* task.message("Loading projects..."); + yield* task.succeed("Loaded organizations."); + + expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); + expect(mockClack.log.step.mock.calls).toEqual([ + ["Loading organizations..."], + ["Loading projects..."], + ]); + expect(mockClack.log.success).toHaveBeenCalledWith("Loaded organizations."); + }).pipe(Effect.provide(layer)), + ); + }); + describe("json layer", () => { it.effect("interactive is false", () => { const mock = mockStdio(); @@ -691,7 +916,7 @@ describe("Output", () => { const out = yield* Output; yield* out.success("ok", { id: 42 }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ id: 42, message: "ok" }); }).pipe(Effect.provide(layer)); }); @@ -713,7 +938,7 @@ describe("Output", () => { const out = yield* Output; yield* out.fail({ code: "E_TEST", message: "failed", detail: "details" }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed", detail: "details" }, @@ -737,7 +962,7 @@ describe("Output", () => { yield* context.set({ linked_project: { project_ref: "abc" } }); yield* out.fail({ code: "E_TEST", message: "failed" }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -753,7 +978,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_TEST", message: "failed" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -776,7 +1001,7 @@ describe("Output", () => { const context = yield* MachineErrorContext; yield* context.set({ _tag: "Hacked", error: "Hacked", safe_field: "ok" }); yield* out.fail({ code: "E_TEST", message: "failed" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -804,7 +1029,7 @@ describe("Output", () => { const out = yield* Output; yield* out.intro("Starting up"); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("info"); expect(parsed.message).toBe("Starting up"); @@ -819,7 +1044,7 @@ describe("Output", () => { const out = yield* Output; yield* out.outro("All done"); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("info"); expect(parsed.message).toBe("All done"); @@ -833,12 +1058,9 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.info("stream info"); - expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); - expect(parsed.type).toBe("log"); - expect(parsed.level).toBe("info"); - expect(parsed.message).toBe("stream info"); - expect(parsed.timestamp).toBeDefined(); + expect(mock.stdout).toEqual([ + '{"type":"log","level":"info","message":"stream info","timestamp":"1970-01-01T00:00:00.000Z"}\n', + ]); }).pipe(Effect.provide(layer)); }); @@ -848,7 +1070,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.warn("stream warn"); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("warn"); expect(parsed.message).toBe("stream warn"); @@ -861,7 +1083,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.error("stream error"); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("error"); expect(parsed.message).toBe("stream error"); @@ -881,7 +1103,7 @@ describe("Output", () => { line: "checkpoint complete", source: "live", }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed).toEqual({ type: "log-entry", timestamp: "2026-03-11T00:00:00.000Z", @@ -902,8 +1124,8 @@ describe("Output", () => { yield* task.succeed("Loaded organizations."); expect(mock.stdout).toHaveLength(2); - const started = JSON.parse(mock.stdout[0]!); - const finished = JSON.parse(mock.stdout[1]!); + const started = yield* decodeJson(mock.stdout[0]!); + const finished = yield* decodeJson(mock.stdout[1]!); expect(started).toEqual( expect.objectContaining({ type: "log", @@ -975,13 +1197,30 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); + it.effect("progress emits NDJSON progress events", () => { + const mock = mockStdio(); + const layer = streamJsonOutputLayer.pipe(Layer.provide(mock.layer)); + return Effect.gen(function* () { + const out = yield* Output; + const bar = yield* out.progress({ max: 3 }); + yield* bar.start("Working..."); + yield* bar.advance(2, "Halfway"); + yield* bar.stop("Done."); + expect(mock.stdout).toEqual([ + '{"type":"progress","status":"start","current":0,"max":3,"message":"Working...","timestamp":"1970-01-01T00:00:00.000Z"}\n', + '{"type":"progress","status":"active","current":2,"max":3,"message":"Halfway","timestamp":"1970-01-01T00:00:00.000Z"}\n', + '{"type":"progress","status":"done","current":2,"max":3,"message":"Done.","timestamp":"1970-01-01T00:00:00.000Z"}\n', + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect("success emits result event", () => { const mock = mockStdio(); const layer = streamJsonOutputLayer.pipe(Layer.provide(mock.layer)); return Effect.gen(function* () { const out = yield* Output; yield* out.success("done", { key: "value" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("result"); expect(parsed.data).toEqual({ key: "value", message: "done" }); expect(parsed.timestamp).toBeDefined(); @@ -994,13 +1233,20 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.result({ id: 42 }); - const parsed = JSON.parse(mock.stdout[0]!); - expect(parsed).toEqual({ - type: "result", - data: { id: 42 }, - timestamp: expect.any(String), - }); - expect(parsed.data).not.toHaveProperty("message"); + expect(mock.stdout).toEqual([ + '{"type":"result","data":{"id":42},"timestamp":"1970-01-01T00:00:00.000Z"}\n', + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("result dies with a TypeError for an unserializable payload", () => { + const mock = mockStdio(); + const layer = streamJsonOutputLayer.pipe(Layer.provide(mock.layer)); + return Effect.gen(function* () { + const out = yield* Output; + const exit = yield* out.result({ id: 42n }).pipe(Effect.exit); + expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBeInstanceOf(TypeError); + expect(mock.stdout).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -1010,14 +1256,9 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_FAIL", message: "boom", suggestion: "try again" }); - const parsed = JSON.parse(mock.stdout[0]!); - expect(parsed.type).toBe("error"); - expect(parsed.error).toEqual({ - code: "E_FAIL", - message: "boom", - suggestion: "try again", - }); - expect(parsed.timestamp).toBeDefined(); + expect(mock.stdout).toEqual([ + '{"type":"error","error":{"code":"E_FAIL","message":"boom","suggestion":"try again"},"timestamp":"1970-01-01T00:00:00.000Z"}\n', + ]); }).pipe(Effect.provide(layer)); }); @@ -1032,7 +1273,7 @@ describe("Output", () => { const context = yield* MachineErrorContext; yield* context.set({ linked_project: { project_ref: "abc" } }); yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("error"); expect(parsed.error).toEqual({ code: "E_FAIL", message: "boom" }); expect(parsed.linked_project).toEqual({ project_ref: "abc" }); @@ -1051,7 +1292,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(Object.keys(parsed).sort()).toEqual(["error", "timestamp", "type"]); }).pipe(Effect.provide(layer)); }); @@ -1075,7 +1316,7 @@ describe("Output", () => { safe_field: "ok", }); yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = yield* decodeJson(mock.stdout[0]!); expect(parsed.type).toBe("error"); expect(parsed.error).toEqual({ code: "E_FAIL", message: "boom" }); expect(typeof parsed.timestamp).toBe("string"); diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index c9a2435d77..eb0668a11d 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -10,7 +10,7 @@ export interface OutputTask { readonly fail: (message?: string) => Effect.Effect<void>; readonly info: (message?: string) => Effect.Effect<void>; readonly cancel: (message?: string) => Effect.Effect<void>; - readonly clear: () => Effect.Effect<void>; + readonly clear: Effect.Effect<void>; } interface OutputSelectOption { diff --git a/apps/cli/src/shared/output/table.unit.test.ts b/apps/cli/src/shared/output/table.unit.test.ts index 389d2aa46e..ee4c846474 100644 --- a/apps/cli/src/shared/output/table.unit.test.ts +++ b/apps/cli/src/shared/output/table.unit.test.ts @@ -1,5 +1,5 @@ +import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { describe, expect, it } from "vitest"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { columnWidths, formatTableRow, outputTable } from "./table.ts"; @@ -32,36 +32,34 @@ describe("formatTableRow", () => { }); describe("outputTable", () => { - it("emits a header row then one info message per data item", async () => { + it.effect("emits a header row then one info message per data item", () => { const out = mockOutput(); - await Effect.runPromise( - outputTable(["ID", "NAME"], [{ id: "1", name: "main" }], (r) => [r.id, r.name]).pipe( - Effect.provide(out.layer), - ), - ); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos).toEqual(["ID NAME", "1 main"]); + return Effect.gen(function* () { + yield* outputTable(["ID", "NAME"], [{ id: "1", name: "main" }], (r) => [r.id, r.name]); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos).toEqual(["ID NAME", "1 main"]); + }).pipe(Effect.provide(out.layer)); }); - it("uses header width when wider than any cell", async () => { + it.effect("uses header width when wider than any cell", () => { const out = mockOutput(); - await Effect.runPromise( - outputTable(["STATUS"], [{ s: "OK" }], (r) => [r.s]).pipe(Effect.provide(out.layer)), - ); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos[0]).toBe("STATUS"); - expect(infos[1]).toBe("OK "); + return Effect.gen(function* () { + yield* outputTable(["STATUS"], [{ s: "OK" }], (r) => [r.s]); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos[0]).toBe("STATUS"); + expect(infos[1]).toBe("OK "); + }).pipe(Effect.provide(out.layer)); }); - it("calls formatRow with cells, widths, and original item to produce row string", async () => { + it.effect("calls formatRow with cells, widths, and original item to produce row string", () => { const out = mockOutput(); const captured: Array<{ cells: ReadonlyArray<string>; widths: ReadonlyArray<number>; item: { name: string }; }> = []; - await Effect.runPromise( - outputTable( + return Effect.gen(function* () { + yield* outputTable( ["NAME"], [{ name: "alice" }], (r) => [r.name], @@ -69,13 +67,13 @@ describe("outputTable", () => { captured.push({ cells, widths, item }); return formatTableRow(cells, widths) + " [custom]"; }, - ).pipe(Effect.provide(out.layer)), - ); - expect(captured).toHaveLength(1); - expect(captured[0]!.cells).toEqual(["alice"]); - expect(captured[0]!.widths).toEqual([5]); - expect(captured[0]!.item).toEqual({ name: "alice" }); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos[1]).toBe("alice [custom]"); + ); + expect(captured).toHaveLength(1); + expect(captured[0]!.cells).toEqual(["alice"]); + expect(captured[0]!.widths).toEqual([5]); + expect(captured[0]!.item).toEqual({ name: "alice" }); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos[1]).toBe("alice [custom]"); + }).pipe(Effect.provide(out.layer)); }); }); diff --git a/apps/cli/src/shared/runtime/browser.layer.unit.test.ts b/apps/cli/src/shared/runtime/browser.layer.unit.test.ts index a782f5a1ef..fe5456620d 100644 --- a/apps/cli/src/shared/runtime/browser.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/browser.layer.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Layer, Sink, Stream } from "effect"; +import { ConfigProvider, Effect, Layer, PlatformError, Sink, Stream } from "effect"; import { FileSystem } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; @@ -142,7 +142,16 @@ describe("Browser", () => { it.effect("errors are ignored when spawner fails", () => { const failingLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.fail(new Error("spawn failed") as any)), + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ), + ), ); const configLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); const layer = Layer.mergeAll( diff --git a/apps/cli/src/shared/runtime/command-runtime.layer.ts b/apps/cli/src/shared/runtime/command-runtime.layer.ts index 021770dd2c..5969006dde 100644 --- a/apps/cli/src/shared/runtime/command-runtime.layer.ts +++ b/apps/cli/src/shared/runtime/command-runtime.layer.ts @@ -1,13 +1,14 @@ -import { Effect, Layer } from "effect"; +import { Crypto, Effect, Layer } from "effect"; import { CommandRuntime } from "./command-runtime.service.ts"; export const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => Layer.effect( CommandRuntime, - Effect.sync(() => - CommandRuntime.of({ + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return CommandRuntime.of({ commandPath: [...commandPath], - commandRunId: crypto.randomUUID(), - }), - ), + commandRunId: yield* crypto.randomUUIDv4, + }); + }), ); diff --git a/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts b/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts index cfbb024fa1..9b459fd24c 100644 --- a/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Effect } from "effect"; import { commandRuntimeLayer } from "./command-runtime.layer.ts"; @@ -8,21 +9,21 @@ import { getCommandRuntimeSpanName, } from "./command-runtime.service.ts"; +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + describe("commandRuntimeLayer", () => { it.effect("generates a fresh command run id for each invocation", () => Effect.gen(function* () { - const first = yield* Effect.gen(function* () { - return yield* CommandRuntime; - }).pipe(Effect.provide(commandRuntimeLayer(["status"]))); - const second = yield* Effect.gen(function* () { - return yield* CommandRuntime; - }).pipe(Effect.provide(commandRuntimeLayer(["status"]))); + const first = yield* CommandRuntime.pipe(Effect.provide(commandRuntimeLayer(["status"]))); + const second = yield* CommandRuntime.pipe(Effect.provide(commandRuntimeLayer(["status"]))); expect(first.commandPath).toEqual(["status"]); expect(second.commandPath).toEqual(["status"]); expect(getCommandRuntimeCommand(first)).toBe("status"); expect(getCommandRuntimeSpanName(first)).toBe("command.status"); + expect(first.commandRunId).toMatch(UUID_V4); + expect(second.commandRunId).toMatch(UUID_V4); expect(first.commandRunId).not.toBe(second.commandRunId); - }), + }).pipe(Effect.provide(BunCrypto.layer)), ); }); diff --git a/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts b/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts index 452ba363ad..475fd170d4 100644 --- a/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts @@ -95,7 +95,7 @@ describe("ProcessControl", () => { Effect.gen(function* () { yield* processControl.holdSignals(["SIGINT", "SIGTERM"]); yield* Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)); - yield* Effect.never; + return yield* Effect.never; }), ).pipe(Effect.forkChild({ startImmediately: true })); diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts index 691615937a..db726d37e9 100644 --- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts +++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts @@ -1,7 +1,6 @@ +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it, vi } from "@effect/vitest"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; import { createStackE2eCleanupManager } from "../../../tests/helpers/stack-e2e-cleanup.ts"; import { CliHomeDisposeError } from "../../../tests/helpers/cli.ts"; @@ -9,14 +8,17 @@ function permissionError(message = "permission denied") { return Object.assign(new Error(message), { code: "EACCES" }); } +const drain = (manager: ReturnType<typeof createStackE2eCleanupManager>) => + Effect.promise(() => manager.drain()); + function cleanupEnvironment( calls: Array<string>, overrides: Partial<Parameters<typeof createStackE2eCleanupManager>[0]> = {}, ): Parameters<typeof createStackE2eCleanupManager>[0] { return { - stopStack: async (projectDir, homeDir) => { + stopStack: (projectDir, homeDir) => { calls.push(`stop:${projectDir}:${homeDir}`); - return { exitCode: 0 }; + return Promise.resolve({ exitCode: 0 }); }, captureSnapshot: () => ({ managedStacksRootExists: false, @@ -24,13 +26,14 @@ function cleanupEnvironment( stackDirs: [], trackedPids: [], }), - waitForCleanup: async () => true, - forceCleanup: async () => { + waitForCleanup: () => Promise.resolve(true), + forceCleanup: () => { calls.push("force"); + return Promise.resolve(); }, - removeProjectWithDocker: async () => { + removeProjectWithDocker: () => { calls.push("docker-remove"); - return false; + return Promise.resolve(false); }, repairProjectPermissions: () => { calls.push("chmod"); @@ -41,79 +44,82 @@ function cleanupEnvironment( } describe("stack e2e cleanup manager", () => { - it("cleans a registered stack project and associated home once", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - captureSnapshot: () => ({ - managedStacksRootExists: true, - documentFiles: ["/tmp/stack.json"], - stackDirs: ["/tmp/stack"], - trackedPids: [], + it.effect("cleans a registered stack project and associated home once", () => + Effect.gen(function* () { + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + captureSnapshot: () => ({ + managedStacksRootExists: true, + documentFiles: ["/tmp/stack.json"], + stackDirs: ["/tmp/stack"], + trackedPids: [], + }), }), - }), - ); - - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); + ); - await manager.drain(); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); - }); + yield* drain(manager); - it("canonicalizes symlinked project and home paths before matching stack state", async () => { - const root = mkdtempSync(join(tmpdir(), "stack-e2e-cleanup-")); - const project = join(root, "project"); - const projectLink = join(root, "project-link"); - const home = join(root, "home"); - const homeLink = join(root, "home-link"); - mkdirSync(project); - mkdirSync(home); - symlinkSync(project, projectLink); - symlinkSync(home, homeLink); - - const snapshots: Array<{ readonly projectDir: string; readonly homeDir?: string }> = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment([], { - captureSnapshot: (projectDir, homeDir) => { - snapshots.push({ projectDir, homeDir }); - return { - managedStacksRootExists: false, - documentFiles: [], - stackDirs: [], - trackedPids: [], - }; - }, - }), - ); + expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); + }), + ); + + it.effect("canonicalizes symlinked project and home paths before matching stack state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-e2e-cleanup-" }); + const project = path.join(root, "project"); + const projectLink = path.join(root, "project-link"); + const home = path.join(root, "home"); + const homeLink = path.join(root, "home-link"); + yield* fs.makeDirectory(project); + yield* fs.makeDirectory(home); + yield* fs.symlink(project, projectLink); + yield* fs.symlink(home, homeLink); + + const snapshots: Array<{ readonly projectDir: string; readonly homeDir?: string }> = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment([], { + captureSnapshot: (projectDir, homeDir) => { + snapshots.push({ projectDir, homeDir }); + return { + managedStacksRootExists: false, + documentFiles: [], + stackDirs: [], + trackedPids: [], + }; + }, + }), + ); - try { manager.registerHome({ dir: homeLink, dispose: () => {} }); - manager.registerStackProject({ dir: projectLink, cleanup: async () => {} }); + manager.registerStackProject({ dir: projectLink, cleanup: () => Promise.resolve() }); manager.associateHome(projectLink, homeLink); - await manager.drain(); + yield* drain(manager); expect(snapshots).toEqual([ - { projectDir: realpathSync(project), homeDir: realpathSync(home) }, + { projectDir: yield* fs.realPath(project), homeDir: yield* fs.realPath(home) }, ]); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("preserves project and home cleanup receivers", async () => { + it.effect("preserves project and home cleanup receivers", () => { class ReceiverHome { readonly dir = "/tmp/home"; disposed = false; @@ -127,304 +133,332 @@ describe("stack e2e cleanup manager", () => { readonly dir = "/tmp/project"; cleaned = false; - async cleanup() { + cleanup() { this.cleaned = true; + return Promise.resolve(); } } - const home = new ReceiverHome(); - const project = new ReceiverProject(); - const manager = createStackE2eCleanupManager(cleanupEnvironment([])); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { + return Effect.gen(function* () { + const warn = yield* Effect.acquireRelease( + Effect.sync(() => vi.spyOn(console, "warn").mockImplementation(() => {})), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const home = new ReceiverHome(); + const project = new ReceiverProject(); + const manager = createStackE2eCleanupManager(cleanupEnvironment([])); manager.registerHome(home); manager.registerStackProject(project); manager.associateHome(project.dir, home.dir); - await manager.drain(); + yield* drain(manager); expect(project.cleaned).toBe(true); expect(home.disposed).toBe(true); expect(warn).not.toHaveBeenCalled(); - } finally { - warn.mockRestore(); - } + }); }); - it("ignores non-stack homes", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); - - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); + it.effect("ignores non-stack homes", () => + Effect.gen(function* () { + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); - await manager.drain(); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); - expect(calls).toEqual([]); - }); + yield* drain(manager); - it("warns when graceful cleanup leaves leaked resources behind", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - stopStack: async () => { - calls.push("stop"); - return { exitCode: 0 }; - }, - captureSnapshot: () => ({ - managedStacksRootExists: true, - documentFiles: ["/tmp/stack.json"], - stackDirs: ["/tmp/stack"], - trackedPids: [123], + expect(calls).toEqual([]); + }), + ); + + it.effect("warns when graceful cleanup leaves leaked resources behind", () => + Effect.gen(function* () { + const warn = yield* Effect.acquireRelease( + Effect.sync(() => vi.spyOn(console, "warn").mockImplementation(() => {})), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + stopStack: () => { + calls.push("stop"); + return Promise.resolve({ exitCode: 0 }); + }, + captureSnapshot: () => ({ + managedStacksRootExists: true, + documentFiles: ["/tmp/stack.json"], + stackDirs: ["/tmp/stack"], + trackedPids: [123], + }), + waitForCleanup: () => Promise.resolve(false), }), - waitForCleanup: async () => false, - }), - ); + ); - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(manager.drain()).resolves.toBeUndefined(); + expect(yield* drain(manager)).toBeUndefined(); expect(warn).toHaveBeenCalledWith(expect.stringContaining("leaked stack resources")); - } finally { - warn.mockRestore(); - } - expect(calls).toEqual(["stop", "force", "cleanup-project", "dispose-home"]); - }); - - it("stops persisted stack directories even when no live runtime artifacts remain", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - captureSnapshot: () => ({ - managedStacksRootExists: true, - documentFiles: [], - stackDirs: ["/tmp/home/stacks/stack-id"], - trackedPids: [], + expect(calls).toEqual(["stop", "force", "cleanup-project", "dispose-home"]); + }), + ); + + it.effect("stops persisted stack directories even when no live runtime artifacts remain", () => + Effect.gen(function* () { + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + captureSnapshot: () => ({ + managedStacksRootExists: true, + documentFiles: [], + stackDirs: ["/tmp/home/stacks/stack-id"], + trackedPids: [], + }), }), - }), - ); + ); - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - await manager.drain(); + yield* drain(manager); - expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); - }); + expect(calls).toEqual(["stop:/tmp/project:/tmp/home", "cleanup-project", "dispose-home"]); + }), + ); + + it.effect("removes permission-blocked projects with the Docker root fallback", () => + Effect.gen(function* () { + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + removeProjectWithDocker: () => { + calls.push("docker-remove"); + return Promise.resolve(true); + }, + }), + ); - it("removes permission-blocked projects with the Docker root fallback", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - removeProjectWithDocker: async () => { - calls.push("docker-remove"); - return true; + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.reject(permissionError()); }, - }), - ); - - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - throw permissionError(); - }, - }); + }); - await manager.drain(); + yield* drain(manager); - expect(calls).toEqual(["cleanup-project", "docker-remove"]); - }); + expect(calls).toEqual(["cleanup-project", "docker-remove"]); + }), + ); + + it.effect("classifies a permission errno wrapped in a typed error's cause chain", () => + Effect.gen(function* () { + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + removeProjectWithDocker: () => { + calls.push("docker-remove"); + return Promise.resolve(true); + }, + }), + ); - it("classifies a permission errno wrapped in a typed error's cause chain", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - removeProjectWithDocker: async () => { - calls.push("docker-remove"); - return true; + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.reject(new CliHomeDisposeError({ cause: permissionError() })); }, - }), - ); - - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - throw new CliHomeDisposeError({ cause: permissionError() }); - }, - }); + }); - await manager.drain(); + yield* drain(manager); - expect(calls).toEqual(["cleanup-project", "docker-remove"]); - }); + expect(calls).toEqual(["cleanup-project", "docker-remove"]); + }), + ); + + it.effect("removes permission-blocked associated homes with the Docker root fallback", () => + Effect.gen(function* () { + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager( + cleanupEnvironment(calls, { + removeProjectWithDocker: () => { + calls.push("docker-remove"); + return Promise.resolve(true); + }, + }), + ); - it("removes permission-blocked associated homes with the Docker root fallback", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager( - cleanupEnvironment(calls, { - removeProjectWithDocker: async () => { - calls.push("docker-remove"); - return true; + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + throw permissionError(); }, - }), - ); - - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - throw permissionError(); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); - - await expect(manager.drain()).resolves.toBeUndefined(); - - expect(calls).toEqual(["cleanup-project", "dispose-home", "docker-remove"]); - }); + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - it("warns when an associated home remains after permission fallback", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + expect(yield* drain(manager)).toBeUndefined(); - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - throw permissionError(); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - }, - }); - manager.associateHome("/tmp/project", "/tmp/home"); + expect(calls).toEqual(["cleanup-project", "dispose-home", "docker-remove"]); + }), + ); + + it.effect("warns when an associated home remains after permission fallback", () => + Effect.gen(function* () { + const warn = yield* Effect.acquireRelease( + Effect.sync(() => vi.spyOn(console, "warn").mockImplementation(() => {})), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + throw permissionError(); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project", "/tmp/home"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(manager.drain()).resolves.toBeUndefined(); + expect(yield* drain(manager)).toBeUndefined(); expect(warn).toHaveBeenCalledWith(expect.stringContaining("Failed to remove temp home")); - } finally { - warn.mockRestore(); - } - expect(calls).toEqual([ - "cleanup-project", - "dispose-home", - "docker-remove", - "chmod", - "dispose-home", - ]); - }); - - it("disposes an associated home once after all projects sharing it are cleaned", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); - - manager.registerHome({ - dir: "/tmp/home", - dispose: () => { - calls.push("dispose-home"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project-one", - cleanup: async () => { - calls.push("cleanup-project-one"); - }, - }); - manager.registerStackProject({ - dir: "/tmp/project-two", - cleanup: async () => { - calls.push("cleanup-project-two"); - }, - }); - manager.associateHome("/tmp/project-one", "/tmp/home"); - manager.associateHome("/tmp/project-two", "/tmp/home"); - - await manager.drain(); - - expect(calls).toEqual(["cleanup-project-one", "cleanup-project-two", "dispose-home"]); - }); + expect(calls).toEqual([ + "cleanup-project", + "dispose-home", + "docker-remove", + "chmod", + "dispose-home", + ]); + }), + ); - it("falls back to chmod and retries cleanup when Docker cannot remove the project", async () => { - const calls: Array<string> = []; - let attempts = 0; - const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); - - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - attempts += 1; - calls.push(`cleanup-project:${attempts}`); - if (attempts === 1) { - throw permissionError(); - } - }, - }); + it.effect("disposes an associated home once after all projects sharing it are cleaned", () => + Effect.gen(function* () { + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); - await manager.drain(); + manager.registerHome({ + dir: "/tmp/home", + dispose: () => { + calls.push("dispose-home"); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project-one", + cleanup: () => { + calls.push("cleanup-project-one"); + return Promise.resolve(); + }, + }); + manager.registerStackProject({ + dir: "/tmp/project-two", + cleanup: () => { + calls.push("cleanup-project-two"); + return Promise.resolve(); + }, + }); + manager.associateHome("/tmp/project-one", "/tmp/home"); + manager.associateHome("/tmp/project-two", "/tmp/home"); - expect(calls).toEqual(["cleanup-project:1", "docker-remove", "chmod", "cleanup-project:2"]); - }); + yield* drain(manager); - it("warns with permission diagnostics when fallback cleanup still cannot remove the project", async () => { - const calls: Array<string> = []; - const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + expect(calls).toEqual(["cleanup-project-one", "cleanup-project-two", "dispose-home"]); + }), + ); + + it.effect("falls back to chmod and retries cleanup when Docker cannot remove the project", () => + Effect.gen(function* () { + const calls: Array<string> = []; + let attempts = 0; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + attempts += 1; + calls.push(`cleanup-project:${attempts}`); + if (attempts === 1) { + return Promise.reject(permissionError()); + } + return Promise.resolve(); + }, + }); - manager.registerStackProject({ - dir: "/tmp/project", - cleanup: async () => { - calls.push("cleanup-project"); - throw permissionError(); - }, - }); + yield* drain(manager); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - await expect(manager.drain()).resolves.toBeUndefined(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("Permission diagnostics:")); - } finally { - warn.mockRestore(); - } - expect(calls).toEqual(["cleanup-project", "docker-remove", "chmod", "cleanup-project"]); - }); + expect(calls).toEqual(["cleanup-project:1", "docker-remove", "chmod", "cleanup-project:2"]); + }), + ); + + it.effect( + "warns with permission diagnostics when fallback cleanup still cannot remove the project", + () => + Effect.gen(function* () { + const warn = yield* Effect.acquireRelease( + Effect.sync(() => vi.spyOn(console, "warn").mockImplementation(() => {})), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + const calls: Array<string> = []; + const manager = createStackE2eCleanupManager(cleanupEnvironment(calls)); + + manager.registerStackProject({ + dir: "/tmp/project", + cleanup: () => { + calls.push("cleanup-project"); + return Promise.reject(permissionError()); + }, + }); + + expect(yield* drain(manager)).toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Permission diagnostics:")); + expect(calls).toEqual(["cleanup-project", "docker-remove", "chmod", "cleanup-project"]); + }), + ); }); diff --git a/apps/cli/src/shared/runtime/stdin.integration.test.ts b/apps/cli/src/shared/runtime/stdin.integration.test.ts index d72e072247..35f611b2fe 100644 --- a/apps/cli/src/shared/runtime/stdin.integration.test.ts +++ b/apps/cli/src/shared/runtime/stdin.integration.test.ts @@ -1,7 +1,9 @@ import { fileURLToPath } from "node:url"; +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Duration, Effect, Fiber, Layer, Option, Queue, Ref, Stream } from "effect"; +import { Cause, Duration, Effect, Fiber, Layer, Option, Queue, Ref, Stream } from "effect"; import { systemError, type PlatformError } from "effect/PlatformError"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { TestClock } from "effect/testing"; import { mockTty } from "../../../tests/helpers/mocks.ts"; @@ -270,27 +272,49 @@ describe("stdinLayer", () => { }); }); +const killOnTimeout = + (child: ChildProcessSpawner.ChildProcessHandle, stderr: Fiber.Fiber<string, PlatformError>) => + <A, E, R>(self: Effect.Effect<A, E, R>) => + self.pipe( + Effect.timeout("20 seconds"), + Effect.catchTag("TimeoutError", () => + child.kill().pipe( + Effect.andThen(Fiber.join(stderr)), + Effect.flatMap((text) => Effect.die(new Error(`child timed out: ${text}`))), + ), + ), + ); + describe("stdinLayer over fd 0", () => { - it("waits out a non-blocking fd 0 until the answer lands", async () => { - // perl flips `O_NONBLOCK` on stdin (Bun cannot) and execs into the reader - // so fd 0 stays non-blocking. The first prompt must run its window out to - // None rather than treat the empty read as a dead descriptor; the second - // reads the answer once that window closes. - const bun = Bun.which("bun"); - const perl = Bun.which("perl"); - if (!bun || !perl) throw new Error("bun and perl executables not found"); - const here = (file: string) => JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); - const child = Bun.spawn( - [ - perl, - "-e", - `use Fcntl; + it.live( + "waits out a non-blocking fd 0 until the answer lands", + () => + Effect.gen(function* () { + // perl flips `O_NONBLOCK` on stdin (Bun cannot) and execs into the reader + // so fd 0 stays non-blocking. The first prompt must run its window out to + // None rather than treat the empty read as a dead descriptor; the second + // reads the answer once that window closes. + const bun = Bun.which("bun"); + const perl = Bun.which("perl"); + if (!bun || !perl) { + return yield* Effect.die(new Error("bun and perl executables not found")); + } + const here = (file: string) => + JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); + const input = yield* Queue.unbounded<Uint8Array, Cause.Done>(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make( + perl, + [ + "-e", + `use Fcntl; fcntl(STDIN, F_SETFL, O_NONBLOCK) or die "fcntl: $!"; print STDERR ((fcntl(STDIN, F_GETFL, 0) & O_NONBLOCK) ? "nonblock\\n" : "block\\n"); exec @ARGV or die "exec: $!";`, - bun, - "-e", - `import { Effect, Layer, Option } from "effect"; + bun, + "-e", + `import { Effect, Layer, Option } from "effect"; import { Stdin } from ${here("./stdin.service.ts")}; import { stdinLayer } from ${here("./stdin.layer.ts")}; import { ttyLayer } from ${here("./tty.layer.ts")}; @@ -302,52 +326,70 @@ describe("stdinLayer over fd 0", () => { Effect.runPromise(program.pipe(Effect.provide(stdinLayer.pipe(Layer.provide(ttyLayer))))).then( () => process.exit(0), );`, - ], - { cwd: import.meta.dirname, stdin: "pipe", stdout: "pipe", stderr: "pipe", timeout: 20_000 }, - ); - const stdout = child.stdout.pipeThrough(new TextDecoderStream()).getReader(); - let buffered = ""; - const nextLine = async () => { - while (!buffered.includes("\n")) { - const { value, done } = await stdout.read(); - if (done) throw new Error(`child exited early: ${await new Response(child.stderr).text()}`); - buffered += value; - } - const [line, ...rest] = buffered.split("\n"); - buffered = rest.join("\n"); - return line; - }; - try { - expect(await nextLine()).toBe("<none>"); - await child.stdin.write("y\n"); - await child.stdin.flush(); - expect(await nextLine()).toBe("y"); - await child.stdin.end(); - const [exitCode, stderr] = await Promise.all([ - child.exited, - new Response(child.stderr).text(), - ]); - expect(exitCode, stderr).toBe(0); - expect(stderr).toContain("nonblock"); - } finally { - // A failed assertion must not leave the child waiting on its second prompt. - child.kill(); - } - }, 30_000); + ], + { + cwd: import.meta.dirname, + stdin: Stream.fromQueue(input), + stdout: "pipe", + stderr: "pipe", + }, + ), + ); + const lines = yield* Stream.toQueue(Stream.splitLines(Stream.decodeText(child.stdout)), { + capacity: "unbounded", + }); + const stderrFiber = yield* Effect.forkChild( + Stream.mkString(Stream.decodeText(child.stderr)), + ); + const nextLine = Queue.take(lines).pipe( + Effect.catchTag("Done", () => + Fiber.join(stderrFiber).pipe( + Effect.flatMap((stderr) => Effect.die(new Error(`child exited early: ${stderr}`))), + ), + ), + ); + yield* Effect.gen(function* () { + expect(yield* nextLine).toBe("<none>"); + yield* Queue.offer(input, enc("y\n")); + expect(yield* nextLine).toBe("y"); + yield* Queue.end(input); + const [exitCode, stderr] = yield* Effect.all([child.exitCode, Fiber.join(stderrFiber)], { + concurrency: "unbounded", + }); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("nonblock"); + }).pipe(killOnTimeout(child, stderrFiber)); + }).pipe( + // A failed assertion must not leave the child waiting on its second prompt. + Effect.scoped, + Effect.provide(BunServices.layer), + ), + 30_000, + ); - it("answers prompts from a flooded pipe and leaves the rest for a child inheriting fd 0", async () => { - // 2 MiB of lines piped in; three prompts take the first three, then a - // child inheriting fd 0 counts what's left. A reader that fully drained - // stdin would leave it nothing. - const bun = Bun.which("bun"); - if (!bun) throw new Error("Bun executable not found"); - const here = (file: string) => JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); - const payload = enc(Array.from({ length: 200_000 }, (_, index) => `line-${index}\n`).join("")); - const child = Bun.spawn( - [ - bun, - "-e", - `import { Effect, Layer, Option } from "effect"; + it.live( + "answers prompts from a flooded pipe and leaves the rest for a child inheriting fd 0", + () => + Effect.gen(function* () { + // 2 MiB of lines piped in; three prompts take the first three, then a + // child inheriting fd 0 counts what's left. A reader that fully drained + // stdin would leave it nothing. + const bun = Bun.which("bun"); + if (!bun) { + return yield* Effect.die(new Error("Bun executable not found")); + } + const here = (file: string) => + JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); + const payload = enc( + Array.from({ length: 200_000 }, (_, index) => `line-${index}\n`).join(""), + ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make( + bun, + [ + "-e", + `import { Effect, Layer, Option } from "effect"; import { Stdin } from ${here("./stdin.service.ts")}; import { stdinLayer } from ${here("./stdin.layer.ts")}; import { ttyLayer } from ${here("./tty.layer.ts")}; @@ -367,19 +409,33 @@ describe("stdinLayer over fd 0", () => { Effect.runPromise(program.pipe(Effect.provide(stdinLayer.pipe(Layer.provide(ttyLayer))))).then( () => process.exit(0), );`, - ], - // Prompts give up after 3 x 5 s; a child that hangs anyway is killed at 20 s, ahead of - // vitest's 30 s guard, so the failure still carries its stderr. - { cwd: import.meta.dirname, stdin: payload, stdout: "pipe", stderr: "pipe", timeout: 20_000 }, - ); - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - expect(exitCode, stderr).toBe(0); - const [answers, left] = stdout.trim().split("\n"); - expect(answers).toBe("line-0 line-1 line-2"); - expect(payload.length - Number(left)).toBeLessThanOrEqual(256 * 1024); - }, 30_000); + ], + { + cwd: import.meta.dirname, + stdin: Stream.make(payload), + stdout: "pipe", + stderr: "pipe", + }, + ), + ); + const stderrFiber = yield* Effect.forkChild( + Stream.mkString(Stream.decodeText(child.stderr)), + ); + // Prompts give up after 3 x 5 s; a child that hangs anyway is killed at 20 s, ahead of + // vitest's 30 s guard, so the failure still carries its stderr. + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + child.exitCode, + Stream.mkString(Stream.decodeText(child.stdout)), + Fiber.join(stderrFiber), + ], + { concurrency: "unbounded" }, + ).pipe(killOnTimeout(child, stderrFiber)); + expect(exitCode, stderr).toBe(0); + const [answers, left] = stdout.trim().split("\n"); + expect(answers).toBe("line-0 line-1 line-2"); + expect(payload.length - Number(left)).toBeLessThanOrEqual(256 * 1024); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + 30_000, + ); }); diff --git a/apps/cli/src/shared/runtime/stdin.layer.ts b/apps/cli/src/shared/runtime/stdin.layer.ts index 9371deb579..d8f70ff3ad 100644 --- a/apps/cli/src/shared/runtime/stdin.layer.ts +++ b/apps/cli/src/shared/runtime/stdin.layer.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- no effect or platform-bun stream reads fd 0 with backpressure and non-blocking EAGAIN handling (#6287). import { createReadStream } from "node:fs"; import { BunStream } from "@effect/platform-bun"; import { @@ -84,19 +85,19 @@ const makeStdin = Effect.fnUntraced(function* (stdin: Stream.Stream<Uint8Array, const scope = yield* Effect.scope; const lineStream = source.pipe(boundPendingLine, Stream.decodeText(), Stream.splitLines); - const lineReader = Effect.gen(function* () { - // One line per pull: `flattenArray` holds the rest of a multi-line chunk for the next - // pull, and `toPull` serializes pulls, so prompts running at once still take turns. - const pull = yield* Channel.toPull(Channel.flattenArray(Stream.toChannel(lineStream))).pipe( - Scope.provide(scope), - ); + // One line per pull: `flattenArray` holds the rest of a multi-line chunk for the next + // pull, and `toPull` serializes pulls, so prompts running at once still take turns. + const lineReader = Channel.toPull(Channel.flattenArray(Stream.toChannel(lineStream))).pipe( + Scope.provide(scope), // EOF, read errors and the line bound arrive as typed failures and become the prompt's // default; a defect or an interrupt propagates rather than silently answering a prompt. - return pull.pipe( - Effect.map(Option.some), - Effect.orElseSucceed(() => Option.none<string>()), - ); - }); + Effect.map((pull) => + pull.pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none<string>()), + ), + ), + ); // Persistent, lazily-opened (via `Effect.cached`) line reader shared by every `readLine` // call, so successive prompts read successive piped lines instead of restarting the pipe. @@ -135,7 +136,7 @@ const makeStdin = Effect.fnUntraced(function* (stdin: Stream.Stream<Uint8Array, // Outer `None` = timed out; inner `None` = EOF / read error; either way the // prompt takes its default. const line = yield* take.pipe(Effect.timeoutOption(Duration.millis(timeoutMillis))); - return Option.map(Option.flatten(line), (value) => value.trim()); + return Option.flatten(line); }); // Streams piped stdin without collecting it. Unlike `readPipedBytes`, read errors PROPAGATE diff --git a/apps/cli/src/shared/runtime/stdin.layer.unit.test.ts b/apps/cli/src/shared/runtime/stdin.layer.unit.test.ts index 3b0d35246b..a88f78ebe9 100644 --- a/apps/cli/src/shared/runtime/stdin.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/stdin.layer.unit.test.ts @@ -152,6 +152,16 @@ describe("Stdin", () => { }); describe("readLine", () => { + it.effect("returns each physical line untrimmed", () => { + const layer = withStdin(Stream.fromIterable([encoder.encode(" \n y \n\n")])); + return Effect.gen(function* () { + const { readLine } = yield* Stdin; + expect(yield* readLine(10_000)).toEqual(Option.some(" ")); + expect(yield* readLine(10_000)).toEqual(Option.some(" y ")); + expect(yield* readLine(10_000)).toEqual(Option.some("")); + }).pipe(Effect.provide(layer)); + }); + it.effect("returns None at EOF", () => { const layer = withStdin(Stream.empty); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/runtime/stdin.service.ts b/apps/cli/src/shared/runtime/stdin.service.ts index 5445a9974a..83619a9bb5 100644 --- a/apps/cli/src/shared/runtime/stdin.service.ts +++ b/apps/cli/src/shared/runtime/stdin.service.ts @@ -20,7 +20,8 @@ interface StdinShape { readonly pipedBytesStream: Stream.Stream<Uint8Array, PlatformError>; readonly readPipedText: Effect.Effect<Option.Option<string>>; /** - * Reads the *next* line from stdin (trimmed), bounded by `timeoutMillis` — callers pass 10 + * Reads the *next* line from stdin (untrimmed, so a whitespace-only answer stays + * distinguishable from an empty one), bounded by `timeoutMillis` — callers pass 10 * minutes on a TTY and 100 ms otherwise. Backed by a single persistent, lazily-opened * reader, so successive calls return successive lines and a command that only prompts on a * TTY never grabs stdin before it needs to. A timeout, EOF, or a read error all return diff --git a/apps/cli/src/shared/runtime/tty.layer.ts b/apps/cli/src/shared/runtime/tty.layer.ts index f174015e70..febaca7061 100644 --- a/apps/cli/src/shared/runtime/tty.layer.ts +++ b/apps/cli/src/shared/runtime/tty.layer.ts @@ -1,19 +1,19 @@ -import { fstatSync } from "node:fs"; import process from "node:process"; -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; import { Tty } from "./tty.service.ts"; -export const ttyLayer = Layer.sync(Tty, () => - Tty.of({ - stdinIsTty: !!process.stdin.isTTY, - stdoutIsTty: !!process.stdout.isTTY, - stdoutIsPipe: (() => { - try { - return fstatSync(1).isFIFO(); - } catch { - return false; - } - })(), - }), +export const ttyLayer = Layer.effect( + Tty, + Effect.tryPromise(() => Bun.file(1).stat()).pipe( + Effect.map((stats) => stats.isFIFO()), + Effect.orElseSucceed(() => false), + Effect.map((stdoutIsPipe) => + Tty.of({ + stdinIsTty: !!process.stdin.isTTY, + stdoutIsTty: !!process.stdout.isTTY, + stdoutIsPipe, + }), + ), + ), ); diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index fa87292ca5..aee8c87f83 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -1,21 +1,29 @@ -# Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.171 AS pg +# The tag pinned below is generated from packages/stack/src/Artifacts.ts by +# apps/cli/scripts/render-service-dockerfile.ts, for these aliases only: pg, pg15, mailpit, +# 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 +# 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.30.2 AS mailpit -FROM postgrest/postgrest:v16.3 AS postgrest +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.14-sha-4dd8a95 AS studio -FROM darthsim/imgproxy:v3.8.0 AS imgproxy -FROM supabase/edge-runtime:v1.76.2 AS edgeruntime -FROM timberio/vector:0.53.0-alpine AS vector +FROM supabase/studio:2026.09.28-sha-5e59b60 AS studio +FROM darthsim/imgproxy:v3.26.0 AS imgproxy +FROM supabase/edge-runtime:v1.77.1 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.135.3 AS realtime -FROM supabase/storage-api:v1.77.0 AS storage -FROM supabase/logflare:1.50.12 AS logflare +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 # 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 diff --git a/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts b/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts deleted file mode 100644 index d8d058c146..0000000000 --- a/apps/cli/src/shared/services/dockerfile-go-sync.unit.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, test } from "vitest"; -import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; - -// The Go tree still `go:embed`s its own copy for a dependency that hasn't been removed -// yet; this keeps the two copies in sync until apps/cli-go is deleted, at which point -// this test should be deleted alongside it. -const GO_DOCKERFILE_PATH = fileURLToPath( - new URL("../../../../cli-go/pkg/config/templates/Dockerfile", import.meta.url), -); - -describe("Go Dockerfile sync guard", () => { - test("keeps the Go tree's embedded Dockerfile byte-identical to the TS-owned copy", () => { - const goDockerfile = readFileSync(GO_DOCKERFILE_PATH, "utf8"); - expect(goDockerfile).toBe(serviceImagesDockerfile); - }); -}); diff --git a/apps/cli/src/shared/services/dockerfile-images.ts b/apps/cli/src/shared/services/dockerfile-images.ts index 70ca17a9b2..377f78a034 100644 --- a/apps/cli/src/shared/services/dockerfile-images.ts +++ b/apps/cli/src/shared/services/dockerfile-images.ts @@ -25,6 +25,6 @@ export function dockerfileServiceImageRaw(alias: string): string { * point for default service images; use `dockerfileServiceImageRaw` where the * docker.io identity itself is the contract (user-facing short names). */ -export function dockerfileServiceImage(alias: string): string { - return slimImageForAlias(alias, dockerfileServiceImageRaw(alias)); +export function dockerfileServiceImage(alias: string, slim: boolean): string { + return slimImageForAlias(alias, dockerfileServiceImageRaw(alias), slim); } diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 1496757576..3f0f553586 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -10,11 +10,18 @@ import { ErrorActionabilityId, } from "../telemetry/error-actionability.ts"; import { + dockerfileServiceImageRaw, dockerfileServiceImages, parseDockerfileServiceImages, type DockerfileImageSpec, } from "./dockerfile-images.ts"; -import { slimImageForAlias, slimImageForCurrentPin, slimImagesEnabled } from "./slim-images.ts"; +import { + imageRepository, + imageTag, + replaceImageTag, + slimImageForAlias, + slimImageForCurrentPin, +} from "./slim-images.ts"; export { parseDockerfileServiceImages } from "./dockerfile-images.ts"; @@ -36,6 +43,8 @@ export type LocalServiceVersionOverrides = Partial<Record<LocalServiceVersionNam export type LocalServiceImageOverrides = Partial<Record<LocalServiceVersionName, string>>; export interface LocalServiceImageOptions { + /** The resolved `SUPABASE_USE_SLIM_IMAGES` flag. */ + readonly slim: boolean; readonly imageOverrides?: LocalServiceImageOverrides; readonly normalizeVersionTags?: boolean; readonly serviceVersions?: LocalServiceVersionOverrides; @@ -114,32 +123,25 @@ export function localServiceImagesFromDockerfile( const LOCAL_SERVICE_IMAGES = localServiceImagesFromSpecs(dockerfileServiceImages); -export const POSTGRES_FALLBACK_IMAGE_PG14 = "supabase/postgres:14.1.0.89"; -/** Flag-off PG13/15 docker.io pin. */ -export const POSTGRES_FALLBACK_IMAGE_PG15 = "supabase/postgres:15.8.1.085"; -/** Published slim PG15 pin; flag-on majors 13/15 slim-translate this, not 15.8. */ -export const POSTGRES_FALLBACK_IMAGE_PG15_SLIM = "supabase/postgres:15.14.1.167"; - +/** + * Resolves PG13/14/15 against the Dockerfile's `pg15`/`pg14` stages — the single version table, + * generated (`pg15`) or hand-pinned (`pg14`, no slim build) from the stack catalog. Always the + * raw docker.io reference; slim translation happens downstream via the same `toSlimImage("pg", + * …)` path every other slim-capable service uses, since a slim-capable service's Dockerfile tag + * always matches a catalog upstream version by construction. + */ export function postgresImageForDbMajorVersion(majorVersion: number): string | undefined { switch (majorVersion) { case 13: case 15: - return slimImagesEnabled() ? POSTGRES_FALLBACK_IMAGE_PG15_SLIM : POSTGRES_FALLBACK_IMAGE_PG15; + return dockerfileServiceImageRaw("pg15"); case 14: - return POSTGRES_FALLBACK_IMAGE_PG14; + return dockerfileServiceImageRaw("pg14"); default: return undefined; } } -function replaceImageTag(image: string, tag: string): string { - const index = image.lastIndexOf(":"); - if (index === -1) { - return image; - } - return `${image.slice(0, index + 1)}${tag.trim()}`; -} - /** Applies that service's image-tag prefix when the version does not already start with it. */ export function tagForServiceVersion(service: LocalServiceVersionName, version: string): string { const trimmed = version.trim(); @@ -166,27 +168,29 @@ export function isUsableServiceVersionTag( } function localServiceImagesForOptions( - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray<ServiceImageSpec> { const normalizeVersionTags = options.normalizeVersionTags ?? true; - const slim = slimImagesEnabled(); + const slim = options.slim; return LOCAL_SERVICE_IMAGES.map((service) => { // Explicit overrides are used verbatim; the caller decides slim vs docker.io. const override = options.imageOverrides?.[service.localService]; - const baseImage = override ?? slimImageForAlias(service.alias, service.image); + const baseImage = override ?? slimImageForAlias(service.alias, service.image, slim); const version = options.serviceVersions?.[service.localService]; if (version === undefined || version.trim().length === 0) { return baseImage === service.image ? service : { ...service, image: baseImage }; } + // `slim-images.ts`'s `replaceImageTag` doesn't trim (its own callers already do), so this + // path — the only one that skips `tagForServiceVersion`'s trim — trims here. const pin = normalizeVersionTags ? tagForServiceVersion(service.localService, version) - : version; + : version.trim(); if (override === undefined && slim) { return { ...service, image: options.slimCurrentPinOnly - ? slimImageForCurrentPin(service.alias, service.image, pin) - : slimImageForAlias(service.alias, replaceImageTag(service.image, pin)), + ? slimImageForCurrentPin(service.alias, service.image, pin, slim) + : slimImageForAlias(service.alias, replaceImageTag(service.image, pin), slim), }; } return { @@ -211,17 +215,26 @@ export interface ServiceVersionRow { readonly remote: string; } +/** A release tag's `-r<N>` suffix, matching the slim-services revision grammar. */ +const RELEASE_REVISION_SUFFIX = /^(?<upstream>.+)-r(?:0|[1-9][0-9]*)$/; + +/** Strips a slim release tag's `-r<N>` suffix, if any, back to its upstream version. */ +export function upstreamVersionFromTag(tag: string): string { + return RELEASE_REVISION_SUFFIX.exec(tag)?.groups?.upstream ?? tag; +} + function toServiceVersionRow( service: ServiceImageSpec, remote: Partial<Record<RemoteServiceName, string>> = {}, ): ServiceVersionRow { - const tagSeparator = service.image.lastIndexOf(":"); - if (tagSeparator === -1) { + // `@`-aware (via `imageTag`/`imageRepository`): a slim catalog pin's image carries a + // `@sha256:…` digest after the tag. + const name = imageRepository(service.image); + const tag = imageTag(service.image); + if (name === undefined || tag === undefined) { throw new Error(`Invalid service image entry: ${service.image}`); } - - const name = service.image.slice(0, tagSeparator); - const local = service.image.slice(tagSeparator + 1); + const local = upstreamVersionFromTag(tag); return { name, @@ -364,7 +377,7 @@ const fetchPostgrestVersion = Effect.fnUntraced(function* ( const normalized = version?.trim().split(/\s+/)[0]; if (normalized === undefined || normalized.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "postgrest" })); + return yield* new ServiceVersionNotFoundError({ service: "postgrest" }); } return tagForServiceVersion("postgrest", normalized); @@ -379,7 +392,7 @@ const fetchAuthVersion = Effect.fnUntraced(function* ( const version = stringField(body, "version")?.trim(); if (version === undefined || version.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "auth" })); + return yield* new ServiceVersionNotFoundError({ service: "auth" }); } return version; @@ -392,15 +405,15 @@ const fetchStorageVersion = Effect.fnUntraced(function* ( ) { const version = (yield* fetchText(client, `${baseUrl}/storage/v1/version`, accessKey)).trim(); if (version.length === 0 || version === "0.0.0") { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "storage" })); + return yield* new ServiceVersionNotFoundError({ service: "storage" }); } return tagForServiceVersion("storage", version); }); -const fetchOptionalVersion = ( +const fetchOptionalVersion = <E>( service: OptionalRemoteServiceName, - effect: Effect.Effect<string, unknown>, + effect: Effect.Effect<string, E>, ) => effect.pipe( Effect.exit, @@ -420,14 +433,14 @@ const makeConfiguredApiClient = Effect.fnUntraced(function* (input: ServiceFetch }); export function listLocalServiceVersions( - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray<ServiceVersionRow> { return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service)); } export function mergeRemoteServiceVersions( remote: Partial<Record<RemoteServiceName, string>>, - options: LocalServiceImageOptions = {}, + options: LocalServiceImageOptions, ): ReadonlyArray<ServiceVersionRow> { return localServiceImagesForOptions(options).map((service) => toServiceVersionRow(service, remote), diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index c22fb49b29..ff9e180f5c 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,34 +1,66 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, it, test } from "@effect/vitest"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; +import { vi } from "vitest"; import serviceImagesDockerfile from "./Dockerfile" with { type: "text" }; +import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { fetchLinkedServiceVersions, listLocalServiceVersions, localServiceImagesFromDockerfile, + mergeRemoteServiceVersions, parseDockerfileServiceImages, postgresImageForDbMajorVersion, renderServicesTable, renderServicesWarning, } from "./services.shared.ts"; +// Only `auth` is pinned in this fixture catalog, at the current Dockerfile-independent version +// `v2.197.0-r0`, with a realistic (non-placeholder) fixture digest built to the real +// `ArtifactPin`/`NativePin` shape from `@supabase/stack/internal/artifacts`. Every other service +// is deliberately absent, so `toSlimImage` falls through to the upstream image for them — the +// permanent state for a non-slim-capable alias (kong, `pg14`, the job images): the Dockerfile's +// slim-capable lines are generated from the catalog now, so they never disagree with it. +// `vi.mock` factories are hoisted above every other top-level statement, so the fixture is +// inlined rather than referencing an outer const. +vi.mock("@supabase/stack/internal/artifacts", () => { + const digest = "260e94edb8d402555791146fcf70b8e90efdc6a81877a04e5aa26f0f416a5dd7"; + const nativePin = { archive: digest, manifest: digest }; + return { + catalogPins: () => [ + { + service: "auth", + sourceService: "auth", + pin: { + upstreamVersion: "v2.197.0", + revision: 0, + image: `ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:${digest}`, + natives: { + "darwin-arm64": nativePin, + "linux-amd64": nativePin, + "linux-arm64": nativePin, + }, + }, + }, + ], + }; +}); + const ACCESS_TOKEN = Redacted.make(`sbp_${"a".repeat(40)}`); const PROJECT_REF = "abcdefghijklmnopqrst"; // `fetchLinkedServiceVersions` reads the ambient HttpClient from context instead // of self-provisioning one, so each invocation needs a concrete transport. const runLinkedFetch = (input: Parameters<typeof fetchLinkedServiceVersions>[0]) => - Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); - -describe("services shared", () => { - beforeEach(() => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); - }); + fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); - afterEach(() => { - vi.unstubAllEnvs(); - }); +const serve = (options: Parameters<typeof Bun.serve>[0]) => + Effect.acquireRelease( + Effect.try(() => Bun.serve(options)), + (server) => Effect.promise(() => server.stop(true)), + ); +describe("services shared", () => { test("parses service images from Dockerfile FROM aliases", () => { expect( parseDockerfileServiceImages(` @@ -51,7 +83,7 @@ describe("services shared", () => { }); test("derives local service versions from the Dockerfile manifest", () => { - const rows = listLocalServiceVersions(); + const rows = listLocalServiceVersions({ slim: false }); const dockerfileImages = localServiceImagesFromDockerfile(serviceImagesDockerfile); const expectedRows = dockerfileImages.map((service) => { const tagSeparator = service.image.lastIndexOf(":"); @@ -77,34 +109,45 @@ describe("services shared", () => { ]); }); - test("keeps the established PG13/15 fallback unless the slim flag is on", () => { - expect(postgresImageForDbMajorVersion(13)).toBe("supabase/postgres:15.8.1.085"); - expect(postgresImageForDbMajorVersion(15)).toBe("supabase/postgres:15.8.1.085"); - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(postgresImageForDbMajorVersion(13)).toBe("supabase/postgres:15.14.1.167"); - expect(postgresImageForDbMajorVersion(15)).toBe("supabase/postgres:15.14.1.167"); + test("resolves PG13/14/15 from the Dockerfile's pg15/pg14 stages, regardless of the slim flag", () => { + const pg15 = dockerfileServiceImageRaw("pg15"); + const pg14 = dockerfileServiceImageRaw("pg14"); + expect(postgresImageForDbMajorVersion(13)).toBe(pg15); + expect(postgresImageForDbMajorVersion(15)).toBe(pg15); + expect(postgresImageForDbMajorVersion(14)).toBe(pg14); + // Always the raw docker.io reference; slim translation is a separate, downstream concern + // (`toSlimImage`/`slimImageForCurrentPin`), so this function itself doesn't read the flag. + expect(postgresImageForDbMajorVersion(13)).toBe(pg15); + expect(postgresImageForDbMajorVersion(15)).toBe(pg15); + expect(postgresImageForDbMajorVersion(14)).toBe(pg14); }); - test("lists slim images when SUPABASE_USE_SLIM_IMAGES is set", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(listLocalServiceVersions().map((row) => row.name)).toEqual([ - "ghcr.io/supabase/cli/postgres", - "ghcr.io/supabase/cli/auth", - "ghcr.io/supabase/cli/postgrest", - "ghcr.io/supabase/cli/realtime", - "ghcr.io/supabase/cli/storage", - "ghcr.io/supabase/cli/edge-runtime", - "ghcr.io/supabase/cli/studio", - "ghcr.io/supabase/cli/pgmeta", - "ghcr.io/supabase/cli/analytics", - "ghcr.io/supabase/cli/pooler", - ]); + test("slim-translates a version override that matches a catalog pin", () => { + expect( + listLocalServiceVersions({ slim: true, serviceVersions: { auth: "v2.197.0" } }), + ).toContainEqual({ + name: "ghcr.io/supabase/cli/auth", + // The catalog's release version (`v2.197.0-r0`) is a Dockerfile/manifest tag; the row shows + // the upstream version so a `supabase services` mismatch check compares upstream to upstream. + local: "v2.197.0", + remote: "", + }); + }); + + test("keeps a version override that isn't in the catalog on docker.io", () => { + expect( + listLocalServiceVersions({ slim: true, serviceVersions: { storage: "v1.70.3" } }), + ).toContainEqual({ + name: "supabase/storage-api", + local: "v1.70.3", + remote: "", + }); }); test("keeps historical pins on docker.io when slimCurrentPinOnly is set", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); expect( listLocalServiceVersions({ + slim: true, slimCurrentPinOnly: true, serviceVersions: { pooler: "2.0.0", analytics: "1.4.0" }, }), @@ -117,28 +160,19 @@ describe("services shared", () => { }); test("normalizes historical pins before slimCurrentPinOnly", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); expect( listLocalServiceVersions({ + slim: true, slimCurrentPinOnly: true, serviceVersions: { auth: "2.151.0" }, }), ).toContainEqual({ name: "supabase/gotrue", local: "v2.151.0", remote: "" }); }); - test("slim-translates catalog version overrides that are not the Dockerfile pin", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(listLocalServiceVersions({ serviceVersions: { storage: "v1.70.3" } })).toContainEqual({ - name: "ghcr.io/supabase/cli/storage", - local: "v1.70.3", - remote: "", - }); - }); - // Explicit overrides keep their registry; a serviceVersions pin still rewrites the tag. test("leaves explicit image overrides on docker.io when SUPABASE_USE_SLIM_IMAGES is set", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); const rows = listLocalServiceVersions({ + slim: true, imageOverrides: { postgres: "supabase/postgres:15.8.1.085", "edge-runtime": "supabase/edge-runtime:v1.68.4", @@ -155,9 +189,31 @@ describe("services shared", () => { ); }); + // A digest-carrying override paired with a serviceVersions pin exercises the same + // `replaceImageTag` used for the plain-tag case above; it must drop the stale digest + // rather than splice the new tag into it (`…-r0@sha256:<pin>`). + test("rewrites the tag on a digest-carrying image override, dropping the stale digest", () => { + const rows = listLocalServiceVersions({ + slim: true, + imageOverrides: { + postgres: + "ghcr.io/supabase/cli/postgres:17.6.1.173-r0@sha256:24e96b8d5daf90f67a62b5593d3008446744007e0a57e302d269c02a4e459e8f", + }, + normalizeVersionTags: false, + serviceVersions: { postgres: "17.6.1.200" }, + }); + + expect(rows).toContainEqual({ + name: "ghcr.io/supabase/cli/postgres", + local: "17.6.1.200", + remote: "", + }); + }); + test("can preserve raw local service version overrides", () => { expect( listLocalServiceVersions({ + slim: false, normalizeVersionTags: false, serviceVersions: { auth: "2.151.0", @@ -171,58 +227,58 @@ describe("services shared", () => { ); }); - test("returns postgres only when no service-role key is available", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return Response.json({ - id: PROJECT_REF, - ref: PROJECT_REF, - organization_id: "org-id", - organization_slug: "org", - name: "Linked Project", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.6.1.200", - postgres_engine: "17", - release_channel: "ga", - }, - }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "anon", - id: "publishable-id", - type: "publishable", - api_key: "publishable-key", - description: null, - }, - ]); - } - - if ( - url.pathname === "/auth/v1/health" || - url.pathname === "/rest/v1/" || - url.pathname === "/storage/v1/version" - ) { - throw new Error( - `tenant endpoint should not be called without a service-role key: ${url.pathname}`, - ); - } - - return new Response("not found", { status: 404 }); - }, - }); + it.live("returns postgres only when no service-role key is available", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return Response.json({ + id: PROJECT_REF, + ref: PROJECT_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "anon", + id: "publishable-id", + type: "publishable", + api_key: "publishable-key", + description: null, + }, + ]); + } + + if ( + url.pathname === "/auth/v1/health" || + url.pathname === "/rest/v1/" || + url.pathname === "/storage/v1/version" + ) { + throw new Error( + `tenant endpoint should not be called without a service-role key: ${url.pathname}`, + ); + } + + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -232,45 +288,43 @@ describe("services shared", () => { }); expect(result).toEqual({ postgres: "17.6.1.200" }); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("returns no linked versions when project api keys cannot be loaded", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return Response.json({ + id: PROJECT_REF, + ref: PROJECT_REF, + organization_id: "org-id", + organization_slug: "org", + name: "Linked Project", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.6.1.200", + postgres_engine: "17", + release_channel: "ga", + }, + }); + } - test("returns no linked versions when project api keys cannot be loaded", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return Response.json({ - id: PROJECT_REF, - ref: PROJECT_REF, - organization_id: "org-id", - organization_slug: "org", - name: "Linked Project", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.6.1.200", - postgres_engine: "17", - release_channel: "ga", - }, - }); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -280,51 +334,49 @@ describe("services shared", () => { }); expect(result).toEqual({}); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("still returns tenant service versions when project version lookup fails", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "service-role-key", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === "/auth/v1/health") { + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/") { + return Response.json({ info: { version: "14.13" } }); + } + + if (url.pathname === "/storage/v1/version") { + return new Response("1.61.0"); + } - test("still returns tenant service versions when project version lookup fails", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "service-role-key", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === "/auth/v1/health") { - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/") { - return Response.json({ info: { version: "14.13" } }); - } - - if (url.pathname === "/storage/v1/version") { - return new Response("1.61.0"); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -338,51 +390,49 @@ describe("services shared", () => { postgrest: "v14.13", storage: "v1.61.0", }); - } finally { - await server.stop(true); - } - }); + }), + ); + + it.live("keeps an already-prefixed tenant version, including uppercase V", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "service-role-key", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === "/auth/v1/health") { + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/") { + return Response.json({ info: { version: "V14.13" } }); + } + + if (url.pathname === "/storage/v1/version") { + return new Response("v1.77.1-versions"); + } - test("keeps an already-prefixed tenant version, including uppercase V", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "service-role-key", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === "/auth/v1/health") { - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/") { - return Response.json({ info: { version: "V14.13" } }); - } - - if (url.pathname === "/storage/v1/version") { - return new Response("v1.77.1-versions"); - } - - return new Response("not found", { status: 404 }); - }, - }); + return new Response("not found", { status: 404 }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -396,62 +446,62 @@ describe("services shared", () => { postgrest: "V14.13", storage: "v1.77.1-versions", }); - } finally { - await server.stop(true); - } - }); + }), + ); - test("falls back to empty linked versions when the linked fetch fails", async () => { - const result = await runLinkedFetch({ - apiUrl: "http://127.0.0.1:1", - projectHost: "supabase.co", - projectRef: PROJECT_REF, - accessToken: ACCESS_TOKEN, - userAgent: "supabase", - }); + it.live("falls back to empty linked versions when the linked fetch fails", () => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: "http://127.0.0.1:1", + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + }); - expect(result).toEqual({}); - }); + expect(result).toEqual({}); + }), + ); + + it.live("authenticates tenant probes with apikey only for sb_ keys", () => + Effect.gen(function* () { + const authHeaders: Record<string, string | null> = {}; + const server = yield* serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { + return Response.json([ + { + name: "service_role", + id: "key-id", + type: "secret", + api_key: "sb_secret_servicerolekey", + description: null, + secret_jwt_template: { role: "service_role" }, + }, + ]); + } + + if (url.pathname === `/v1/projects/${PROJECT_REF}`) { + return new Response("boom", { status: 500 }); + } + + if (url.pathname === "/auth/v1/health") { + authHeaders.apikey = request.headers.get("apikey"); + authHeaders.authorization = request.headers.get("authorization"); + return Response.json({ version: "v2.190.0" }); + } + + if (url.pathname === "/rest/v1/" || url.pathname === "/storage/v1/version") { + return new Response("not found", { status: 404 }); + } - test("authenticates tenant probes with apikey only for sb_ keys", async () => { - const authHeaders: Record<string, string | null> = {}; - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { - return Response.json([ - { - name: "service_role", - id: "key-id", - type: "secret", - api_key: "sb_secret_servicerolekey", - description: null, - secret_jwt_template: { role: "service_role" }, - }, - ]); - } - - if (url.pathname === `/v1/projects/${PROJECT_REF}`) { - return new Response("boom", { status: 500 }); - } - - if (url.pathname === "/auth/v1/health") { - authHeaders.apikey = request.headers.get("apikey"); - authHeaders.authorization = request.headers.get("authorization"); - return Response.json({ version: "v2.190.0" }); - } - - if (url.pathname === "/rest/v1/" || url.pathname === "/storage/v1/version") { return new Response("not found", { status: 404 }); - } - - return new Response("not found", { status: 404 }); - }, - }); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: PROJECT_REF, @@ -463,21 +513,19 @@ describe("services shared", () => { expect(result).toEqual({ auth: "v2.190.0" }); expect(authHeaders.apikey).toBe("sb_secret_servicerolekey"); expect(authHeaders.authorization).toBeNull(); - } finally { - await server.stop(true); - } - }); - - test("skips remote lookups for a malformed project ref", async () => { - const server = Bun.serve({ - port: 0, - fetch() { - throw new Error("no request should be made for a malformed project ref"); - }, - }); + }), + ); + + it.live("skips remote lookups for a malformed project ref", () => + Effect.gen(function* () { + const server = yield* serve({ + port: 0, + fetch() { + throw new Error("no request should be made for a malformed project ref"); + }, + }); - try { - const result = await runLinkedFetch({ + const result = yield* runLinkedFetch({ apiUrl: server.url.origin, projectHost: "supabase.co", projectRef: "not-a-valid-ref", @@ -486,13 +534,11 @@ describe("services shared", () => { }); expect(result).toEqual({}); - } finally { - await server.stop(true); - } - }); + }), + ); test("renders the local services table with expected headers and rows", () => { - const rows = listLocalServiceVersions(); + const rows = listLocalServiceVersions({ slim: false }); const table = renderServicesTable(rows); expect(table).toContain("SERVICE IMAGE"); @@ -513,4 +559,20 @@ describe("services shared", () => { ]), ).toContain("supabase/postgres:17.6.1.132 => 17.6.1.200"); }); + + test("compares upstream versions for a slim catalog pin, not the release tag", () => { + const rows = mergeRemoteServiceVersions( + { auth: "v2.197.0" }, + { slim: true, serviceVersions: { auth: "v2.197.0" } }, + ); + + expect(rows).toContainEqual({ + name: "ghcr.io/supabase/cli/auth", + local: "v2.197.0", + remote: "v2.197.0", + }); + // The pinned image's release tag (`v2.197.0-r0@sha256:…`) never surfaces as a mismatch + // against the upstream-only remote version. + expect(renderServicesWarning(rows)).toBeUndefined(); + }); }); diff --git a/apps/cli/src/shared/services/slim-images.catalog-alignment.unit.test.ts b/apps/cli/src/shared/services/slim-images.catalog-alignment.unit.test.ts new file mode 100644 index 0000000000..ea5fd0dae4 --- /dev/null +++ b/apps/cli/src/shared/services/slim-images.catalog-alignment.unit.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; +import { slimCatalogPin, toSlimImage } from "./slim-images.ts"; + +/** + * Every slim-capable Dockerfile alias, against the real (unmocked) catalog. The Dockerfile is + * generated from that same catalog (`render-service-dockerfile.ts`), so each alias's raw tag is + * always one of the catalog's own upstream versions by construction — no fixture, no + * `vi.mock`, exercising the Dockerfile/catalog alignment end to end. + */ +const SLIM_CAPABLE_ALIASES = [ + "pg", + "pg15", + "gotrue", + "postgrest", + "realtime", + "storage", + "edgeruntime", + "studio", + "pgmeta", + "logflare", + "supavisor", + "vector", + "imgproxy", + "mailpit", +] as const; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("slim mode against the real catalog", () => { + it.each(SLIM_CAPABLE_ALIASES)("resolves the %s alias to its pinned catalog image", (alias) => { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); + const raw = dockerfileServiceImageRaw(alias); + const pin = slimCatalogPin(alias, raw); + expect(pin).toBeDefined(); + const resolved = toSlimImage(alias, raw); + expect(resolved).toBeDefined(); + expect(resolved).toMatch(/^ghcr\.io\/supabase\/cli\//); + }); + + it("has no slim entry for aliases with no slim build", () => { + for (const alias of ["kong", "pg14", "differ", "migra", "pgprove"]) { + const raw = dockerfileServiceImageRaw(alias); + expect(toSlimImage(alias, raw)).toBeUndefined(); + } + }); +}); diff --git a/apps/cli/src/shared/services/slim-images.ts b/apps/cli/src/shared/services/slim-images.ts index af0635fc11..440e3a5284 100644 --- a/apps/cli/src/shared/services/slim-images.ts +++ b/apps/cli/src/shared/services/slim-images.ts @@ -1,13 +1,18 @@ +import { catalogPins } from "@supabase/stack/internal/artifacts"; +import { Config, ConfigProvider, Effect, Option } from "effect"; + const SLIM_IMAGES_ENV = "SUPABASE_USE_SLIM_IMAGES"; const SLIM_IMAGE_PREFIX = "ghcr.io/supabase/cli/"; /** * Maps embedded-Dockerfile aliases onto the slim service catalog. Aliases with - * no slim build (kong, the `differ`/`migra`/`pgprove` job images) are absent and - * keep their docker.io reference. OrioleDB tags are excluded in `slimCatalogPin`. + * no slim build (kong, `pg14`, the `differ`/`migra`/`pgprove` job images) are + * absent and keep their docker.io reference. OrioleDB tags are excluded in + * `slimCatalogPin`. */ const SLIM_SERVICE_BY_ALIAS = { pg: "postgres", + pg15: "postgres", gotrue: "auth", postgrest: "postgrest", realtime: "realtime", @@ -41,11 +46,13 @@ const V_PREFIXED_SERVICES: ReadonlySet<SlimServiceName> = new Set([ "pooler", ]); -/** Reads the ambient slim-image flag for callers without an explicit project value. */ -export function slimImagesEnabled(): boolean { - const value = process.env[SLIM_IMAGES_ENV]; - return value === "true" || value === "1"; -} +/** + * Reads the ambient slim-image flag for callers without an explicit project value: always the + * process environment, never the active `ConfigProvider`. That lookup cannot fail. + */ +export const slimImagesEnabled = Effect.suspend(() => + Config.option(Config.string(SLIM_IMAGES_ENV)).parse(ConfigProvider.fromEnv()), +).pipe(Effect.map(Option.exists((value) => value === "true" || value === "1")), Effect.orDie); /** * Catalog-normalized slim tag under `ghcr.io/supabase/cli/<service>`. The @@ -66,7 +73,7 @@ export interface SlimCatalogPin { } /** OrioleDB tags are docker.io-only; slim-services does not publish them. */ -export function isOrioleImage(image: string): boolean { +function isOrioleImage(image: string): boolean { const tag = imageTag(image); return tag !== undefined && tag.toLowerCase().includes("orioledb"); } @@ -94,35 +101,71 @@ export function slimCatalogPin(alias: string, image: string): SlimCatalogPin | u } /** - * Rewrites a docker.io image reference to its `ghcr.io/supabase/cli` slim - * equivalent, keeping the pin's version. This helper owns tag normalization - * (`v`-prefixing, `tagPrefix`), so pins that differ only in prefix between the - * two registries (`supavisor`, `logflare`) land on the right slim tag. Vector's - * docker.io tags carry an `-alpine` variant suffix that the slim build does - * not publish, so the strip is scoped to `vector` only — an `-alpine`-suffixed - * pin on any other service is a real tag, not a variant marker. + * Looks up the pinned catalog image (with its published `@sha256` digest) for + * `service` whose `upstreamVersion` equals `version`. Reads the same catalog + * `apps/cli`'s stack-independent clients use, keyed by the slim-services + * `sourceService` name (which matches this module's `SlimServiceName`). + */ +function catalogImageFor(service: SlimServiceName, upstreamVersion: string): string | undefined { + for (const entry of catalogPins()) { + if (entry.sourceService === service && entry.pin.upstreamVersion === upstreamVersion) { + return entry.pin.image; + } + } + return undefined; +} + +/** + * Resolves the catalog's pinned slim image (repository, release version and + * digest) whose `upstreamVersion` normalizes to `image`'s tag for `alias`. + * This owns tag normalization (`v`-prefixing, `tagPrefix`, vector's `-alpine` + * strip) via {@link slimCatalogPin}, so pins that differ only in prefix + * between the two registries (`supavisor`, `logflare`) still match. Returns `undefined` whenever + * no catalog pin matches `alias` and `image`'s tag — callers then keep the upstream (non-slim) + * image instead of guessing a slim tag. That covers more than "no slim build": an alias with no + * slim build at all (kong, `pg14`, the one-shot job images); an excluded tag on an alias that + * does have one (an OrioleDB `pg` tag, which {@link slimCatalogPin} always excludes); and a tag + * the catalog simply doesn't pin (an upstream version the catalog hasn't caught up to yet, or a + * hosted-project override from `supabase link` that doesn't match the pinned upstream version). */ -export function toSlimImage(alias: string, image: string): string { +export function toSlimImage(alias: string, image: string): string | undefined { const pin = slimCatalogPin(alias, image); if (pin === undefined) { - return image; + return undefined; } - return `${SLIM_IMAGE_PREFIX}${pin.service}:${pin.version}`; + return catalogImageFor(pin.service, pin.version); } /** `toSlimImage` behind the feature flag; a no-op while the flag is off. */ -export function slimImageForAlias(alias: string, image: string): string { - return slimImagesEnabled() ? toSlimImage(alias, image) : image; +export function slimImageForAlias(alias: string, image: string, enabled: boolean): string { + return enabled ? (toSlimImage(alias, image) ?? image) : image; } +/** The tag portion of `image`, ignoring any `@sha256:…` digest suffix. */ export function imageTag(image: string): string | undefined { - const tagSeparator = image.lastIndexOf(":"); - return tagSeparator === -1 ? undefined : image.slice(tagSeparator + 1); + const withoutDigest = image.split("@")[0] ?? image; + const tagSeparator = withoutDigest.lastIndexOf(":"); + return tagSeparator === -1 ? undefined : withoutDigest.slice(tagSeparator + 1); +} + +/** The repository portion of `image` (before the tag), the other half of `imageTag`'s split. */ +export function imageRepository(image: string): string | undefined { + const withoutDigest = image.split("@")[0] ?? image; + const tagSeparator = withoutDigest.lastIndexOf(":"); + return tagSeparator === -1 ? undefined : withoutDigest.slice(0, tagSeparator); +} + +/** The `@sha256:…` digest suffix of `image`, if it carries one. */ +export function imageDigest(image: string): string | undefined { + const at = image.indexOf("@"); + return at === -1 ? undefined : image.slice(at + 1); } -function replaceImageTag(image: string, tag: string): string { - const tagSeparator = image.lastIndexOf(":"); - return tagSeparator === -1 ? image : `${image.slice(0, tagSeparator + 1)}${tag}`; +/** Replaces `image`'s tag with `tag`, dropping any `@sha256:…` digest — a new tag invalidates it. */ +export function replaceImageTag(image: string, tag: string): string { + const withoutDigest = image.split("@")[0] ?? image; + const tagSeparator = withoutDigest.lastIndexOf(":"); + return tagSeparator === -1 ? image : `${withoutDigest.slice(0, tagSeparator + 1)}${tag}`; } /** @@ -152,8 +195,8 @@ export function pinMatchesCurrentImage( export function slimImageForCurrentPin( alias: string, currentRawImage: string, - pin?: string, - enabled = slimImagesEnabled(), + pin: string | undefined, + enabled: boolean, ): string { const trimmed = pin?.trim() ?? ""; const tagged = trimmed.length > 0 ? replaceImageTag(currentRawImage, trimmed) : currentRawImage; @@ -163,7 +206,7 @@ export function slimImageForCurrentPin( if (trimmed.length > 0 && !pinMatchesCurrentImage(alias, trimmed, currentRawImage)) { return tagged; } - return toSlimImage(alias, tagged); + return toSlimImage(alias, tagged) ?? tagged; } /** Slim images are published only under this prefix; single home for the check. */ @@ -176,6 +219,6 @@ export function isSlimImageRef(image: string): boolean { * one-shot jobs use this so a ghcr-shaped override with the flag off stays on * the docker.io contract. */ -export function usesSlimImageRuntime(image: string): boolean { - return slimImagesEnabled() && isSlimImageRef(image); +export function usesSlimImageRuntime(image: string, enabled: boolean): boolean { + return enabled && isSlimImageRef(image); } diff --git a/apps/cli/src/shared/services/slim-images.unit.test.ts b/apps/cli/src/shared/services/slim-images.unit.test.ts index 4e082a3b7c..5064bd026a 100644 --- a/apps/cli/src/shared/services/slim-images.unit.test.ts +++ b/apps/cli/src/shared/services/slim-images.unit.test.ts @@ -1,7 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect } from "effect"; +import { vi } from "vitest"; import { dockerfileServiceImageRaw } from "./dockerfile-images.ts"; import { + imageTag, pinMatchesCurrentImage, slimCatalogPin, slimImageForAlias, @@ -11,141 +14,241 @@ import { usesSlimImageRuntime, } from "./slim-images.ts"; +// Only `auth` is pinned in this fixture catalog, at `v2.197.0-r0`, with a realistic +// (non-placeholder) fixture digest built to the real `ArtifactPin`/`NativePin` shape from +// `@supabase/stack/internal/artifacts`. Every other service is deliberately absent, so +// `toSlimImage` falls through to the upstream image for them — the permanent state for a +// non-slim-capable alias (kong, `pg14`, the job images): the Dockerfile's slim-capable lines +// are generated from the catalog now, so they never disagree with it. `vi.mock` factories are +// hoisted above every other top-level statement, so the fixture is inlined rather than +// referencing an outer const. +vi.mock("@supabase/stack/internal/artifacts", () => { + const digest = "d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c"; + const nativePin = { archive: digest, manifest: digest }; + return { + catalogPins: () => [ + { + service: "auth", + sourceService: "auth", + pin: { + upstreamVersion: "v2.197.0", + revision: 0, + image: `ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:${digest}`, + natives: { + "darwin-arm64": nativePin, + "linux-amd64": nativePin, + "linux-arm64": nativePin, + }, + }, + }, + ], + }; +}); + +const AUTH_FIXTURE_PIN_IMAGE = + "ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c"; + afterEach(() => { vi.unstubAllEnvs(); }); -describe("toSlimImage", () => { +describe("slimCatalogPin", () => { it.each([ - ["pg", "ghcr.io/supabase/cli/postgres"], - ["gotrue", "ghcr.io/supabase/cli/auth"], - ["postgrest", "ghcr.io/supabase/cli/postgrest"], - ["realtime", "ghcr.io/supabase/cli/realtime"], - ["storage", "ghcr.io/supabase/cli/storage"], - ["edgeruntime", "ghcr.io/supabase/cli/edge-runtime"], - ["studio", "ghcr.io/supabase/cli/studio"], - ["pgmeta", "ghcr.io/supabase/cli/pgmeta"], - ["logflare", "ghcr.io/supabase/cli/analytics"], - ["supavisor", "ghcr.io/supabase/cli/pooler"], - ["vector", "ghcr.io/supabase/cli/vector"], - ["imgproxy", "ghcr.io/supabase/cli/imgproxy"], - ["mailpit", "ghcr.io/supabase/cli/mailpit"], - ])("maps the %s manifest pin onto %s", (alias, repository) => { - const translated = toSlimImage(alias, dockerfileServiceImageRaw(alias)); - expect(translated.slice(0, translated.lastIndexOf(":"))).toBe(repository); - }); - - it("keeps a non-current pin instead of the catalog default", () => { - expect(toSlimImage("pg", "supabase/postgres:17.6.1.164")).toBe( - "ghcr.io/supabase/cli/postgres:17.6.1.164", - ); - expect(toSlimImage("studio", "supabase/studio:2026.08.17-sha-0c1da8f")).toBe( - "ghcr.io/supabase/cli/studio:2026.08.17-sha-0c1da8f", - ); + ["pg", "postgres"], + ["pg15", "postgres"], + ["gotrue", "auth"], + ["postgrest", "postgrest"], + ["realtime", "realtime"], + ["storage", "storage"], + ["edgeruntime", "edge-runtime"], + ["studio", "studio"], + ["pgmeta", "pgmeta"], + ["logflare", "analytics"], + ["supavisor", "pooler"], + ["vector", "vector"], + ["imgproxy", "imgproxy"], + ["mailpit", "mailpit"], + ])("maps the %s alias onto the %s slim service", (alias, service) => { + const pin = slimCatalogPin(alias, dockerfileServiceImageRaw(alias)); + expect(pin?.service).toBe(service); + }); + + it("keeps a non-current pin's version verbatim", () => { + expect(slimCatalogPin("pg", "supabase/postgres:17.6.1.164")).toEqual({ + service: "postgres", + version: "17.6.1.164", + }); + expect(slimCatalogPin("studio", "supabase/studio:2026.08.17-sha-0c1da8f")).toEqual({ + service: "studio", + version: "2026.08.17-sha-0c1da8f", + }); }); - // Fixed pins, not manifest pins: dependabot bumps the manifest, so spelling - // out a current pin here would fail on every bump. The `it.each` above covers - // the part that must track it (the repository each alias maps to). it("keeps a single v on pins already prefixed on docker.io", () => { - expect(toSlimImage("realtime", "supabase/realtime:v2.130.0")).toBe( - "ghcr.io/supabase/cli/realtime:v2.130.0", - ); - expect(toSlimImage("storage", "supabase/storage-api:v1.72.1")).toBe( - "ghcr.io/supabase/cli/storage:v1.72.1", - ); - expect(toSlimImage("gotrue", "supabase/gotrue:V2.196.0")).toBe( - "ghcr.io/supabase/cli/auth:v2.196.0", - ); + expect(slimCatalogPin("realtime", "supabase/realtime:v2.130.0")).toEqual({ + service: "realtime", + version: "v2.130.0", + }); + expect(slimCatalogPin("storage", "supabase/storage-api:v1.72.1")).toEqual({ + service: "storage", + version: "v1.72.1", + }); + expect(slimCatalogPin("gotrue", "supabase/gotrue:V2.196.0")).toEqual({ + service: "auth", + version: "v2.196.0", + }); }); it("v-prefixes pins whose slim tag scheme differs from docker.io's", () => { - expect(toSlimImage("supavisor", "supabase/supavisor:2.9.10")).toBe( - "ghcr.io/supabase/cli/pooler:v2.9.10", - ); - expect(toSlimImage("logflare", "supabase/logflare:1.50.4")).toBe( - "ghcr.io/supabase/cli/analytics:v1.50.4", - ); - expect(toSlimImage("pgmeta", "supabase/postgres-meta:v0.98.0")).toBe( - "ghcr.io/supabase/cli/pgmeta:v0.98.0", - ); + expect(slimCatalogPin("supavisor", "supabase/supavisor:2.9.10")).toEqual({ + service: "pooler", + version: "v2.9.10", + }); + expect(slimCatalogPin("logflare", "supabase/logflare:1.50.4")).toEqual({ + service: "analytics", + version: "v1.50.4", + }); + expect(slimCatalogPin("pgmeta", "supabase/postgres-meta:v0.98.0")).toEqual({ + service: "pgmeta", + version: "v0.98.0", + }); }); - it("keeps OrioleDB tags on docker.io", () => { - expect(toSlimImage("pg", "supabase/postgres:16.0.0.1-orioledb")).toBe( - "supabase/postgres:16.0.0.1-orioledb", - ); - expect(toSlimImage("pg", "supabase/postgres:orioledb-15.1.0.55")).toBe( - "supabase/postgres:orioledb-15.1.0.55", - ); + it("excludes OrioleDB tags", () => { expect(slimCatalogPin("pg", "supabase/postgres:16.0.0.1-orioledb")).toBeUndefined(); + expect(slimCatalogPin("pg", "supabase/postgres:orioledb-15.1.0.55")).toBeUndefined(); }); it("strips vector's docker.io -alpine variant suffix", () => { - expect(toSlimImage("vector", "timberio/vector:0.53.0-alpine")).toBe( - "ghcr.io/supabase/cli/vector:0.53.0", - ); + expect(slimCatalogPin("vector", "timberio/vector:0.53.0-alpine")).toEqual({ + service: "vector", + version: "0.53.0", + }); }); it("does not strip -alpine from a non-vector service's tag", () => { - expect(toSlimImage("studio", "supabase/studio:2026.08.17-alpine")).toBe( - "ghcr.io/supabase/cli/studio:2026.08.17-alpine", - ); + expect(slimCatalogPin("studio", "supabase/studio:2026.08.17-alpine")).toEqual({ + service: "studio", + version: "2026.08.17-alpine", + }); }); - it("passes through aliases with no slim build", () => { - for (const alias of ["kong", "differ", "migra", "pgprove"]) { - const image = dockerfileServiceImageRaw(alias); - expect(toSlimImage(alias, image)).toBe(image); + it("is absent for aliases with no slim build", () => { + for (const alias of ["kong", "pg14", "differ", "migra", "pgprove"]) { + expect(slimCatalogPin(alias, dockerfileServiceImageRaw(alias))).toBeUndefined(); } }); - it("passes through an untagged reference", () => { - expect(toSlimImage("pg", "supabase/postgres")).toBe("supabase/postgres"); + it("is absent for an untagged reference", () => { + expect(slimCatalogPin("pg", "supabase/postgres")).toBeUndefined(); + }); +}); + +describe("toSlimImage", () => { + it("returns the catalog's pinned image (with its digest) when the tag matches a catalog upstream version", () => { + expect(toSlimImage("gotrue", "supabase/gotrue:v2.197.0")).toBe(AUTH_FIXTURE_PIN_IMAGE); + // The alias-normalization prefix (V -> v) still applies before the catalog match. + expect(toSlimImage("gotrue", "supabase/gotrue:V2.197.0")).toBe(AUTH_FIXTURE_PIN_IMAGE); + }); + + it("returns undefined, keeping the upstream image, when the tag isn't in the catalog", () => { + expect(toSlimImage("gotrue", "supabase/gotrue:v2.100.0")).toBeUndefined(); + // `pg` has no entry at all in this fixture catalog. + expect(toSlimImage("pg", "supabase/postgres:17.6.1.164")).toBeUndefined(); + }); + + it("returns undefined for aliases and tags slimCatalogPin already excludes", () => { + expect(toSlimImage("pg", "supabase/postgres:16.0.0.1-orioledb")).toBeUndefined(); + expect(toSlimImage("kong", dockerfileServiceImageRaw("kong"))).toBeUndefined(); + expect(toSlimImage("pg", "supabase/postgres")).toBeUndefined(); + }); +}); + +describe("imageTag", () => { + it("returns the release tag, ignoring an @sha256 digest", () => { + expect( + imageTag( + "ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:d348483ad1141c54bfb4eaae801f5385fe1c2970fc106f95f531b5247092d52c", + ), + ).toBe("v2.197.0-r0"); + }); + + it("returns the tag on a plain (digest-less) reference", () => { + expect(imageTag("supabase/gotrue:v2.197.0")).toBe("v2.197.0"); + }); + + it("returns undefined on an untagged reference", () => { + expect(imageTag("supabase/postgres")).toBeUndefined(); }); }); describe("slimImagesEnabled", () => { - it.each([ - ["true", true], - ["1", true], - ["false", false], - ["0", false], - ["yes", false], - ["TRUE", false], - ["", false], - ])("reads %j as %s", (value, expected) => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", value); - expect(slimImagesEnabled()).toBe(expected); + afterEach(() => { + vi.unstubAllEnvs(); }); + + it.effect.each([ + { value: "true", expected: true }, + { value: "1", expected: true }, + { value: "false", expected: false }, + { value: "0", expected: false }, + { value: "yes", expected: false }, + { value: "TRUE", expected: false }, + { value: "", expected: false }, + { value: undefined, expected: false }, + ])("reads $value as $expected", ({ value, expected }) => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", value); + expect(yield* slimImagesEnabled).toBe(expected); + }), + ); + + it.effect("reads the process environment, not the active ConfigProvider", () => + Effect.gen(function* () { + vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", undefined); + const pinned = ConfigProvider.fromEnvRecord({ SUPABASE_USE_SLIM_IMAGES: "true" }); + const failing = ConfigProvider.make(() => + Effect.fail(new ConfigProvider.SourceError({ message: "injected" })), + ); + for (const provider of [pinned, failing]) { + expect( + yield* slimImagesEnabled.pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + ), + ).toBe(false); + } + }), + ); }); describe("slimImageForAlias", () => { it("is a no-op while the flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165")).toBe( - "supabase/postgres:17.6.1.165", + expect(slimImageForAlias("gotrue", "supabase/gotrue:v2.197.0", false)).toBe( + "supabase/gotrue:v2.197.0", ); }); - it("translates when the flag is on", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165")).toBe( - "ghcr.io/supabase/cli/postgres:17.6.1.165", + it("translates when the flag is on and the tag matches the catalog", () => { + expect(slimImageForAlias("gotrue", "supabase/gotrue:v2.197.0", true)).toBe( + AUTH_FIXTURE_PIN_IMAGE, + ); + }); + + it("keeps the upstream image when the flag is on but the tag isn't in the catalog", () => { + expect(slimImageForAlias("pg", "supabase/postgres:17.6.1.165", true)).toBe( + "supabase/postgres:17.6.1.165", ); }); }); describe("usesSlimImageRuntime", () => { it("is false while the flag is off even for a ghcr ref", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - expect(usesSlimImageRuntime("ghcr.io/supabase/cli/postgres:17.6.1.165")).toBe(false); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/postgres:17.6.1.165", false)).toBe(false); }); it("is true only when the flag is on and the ref is slim", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "1"); - expect(usesSlimImageRuntime("ghcr.io/supabase/cli/auth:v2.196.0")).toBe(true); - expect(usesSlimImageRuntime("supabase/gotrue:v2.196.0")).toBe(false); + expect(usesSlimImageRuntime("ghcr.io/supabase/cli/auth:v2.196.0", true)).toBe(true); + expect(usesSlimImageRuntime("supabase/gotrue:v2.196.0", true)).toBe(false); }); }); @@ -161,24 +264,30 @@ describe("pinMatchesCurrentImage", () => { }); describe("slimImageForCurrentPin", () => { - it("slim-translates the current pin and leaves a historical pin on docker.io", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", "true"); - const current = dockerfileServiceImageRaw("storage"); - const currentTag = current.split(":")[1] ?? ""; - expect(slimImageForCurrentPin("storage", current)).toBe(toSlimImage("storage", current)); - expect(slimImageForCurrentPin("storage", current, currentTag)).toBe( - toSlimImage("storage", current), + it("slim-translates the current pin using the catalog's pinned digest", () => { + const current = "supabase/gotrue:v2.197.0"; + expect(slimImageForCurrentPin("gotrue", current, undefined, true)).toBe(AUTH_FIXTURE_PIN_IMAGE); + expect(slimImageForCurrentPin("gotrue", current, "v2.197.0", true)).toBe( + AUTH_FIXTURE_PIN_IMAGE, ); - expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( - "supabase/storage-api:v1.67.0", + expect(slimImageForCurrentPin("gotrue", current, "v1.67.0", true)).toBe( + "supabase/gotrue:v1.67.0", ); }); it("is a no-op while the flag is off", () => { - vi.stubEnv("SUPABASE_USE_SLIM_IMAGES", ""); - const current = dockerfileServiceImageRaw("storage"); - expect(slimImageForCurrentPin("storage", current, "v1.67.0")).toBe( - "supabase/storage-api:v1.67.0", + const current = "supabase/gotrue:v2.197.0"; + expect(slimImageForCurrentPin("gotrue", current, "v1.67.0", false)).toBe( + "supabase/gotrue:v1.67.0", + ); + }); + + it("falls back to the upstream image on the linked-pin fallback path (a hosted version that doesn't match the pin)", () => { + const current = "supabase/gotrue:v2.197.0"; + // The linked project's hosted version (v1.67.0) differs from the catalog's pinned + // upstream version (v2.197.0): stay on the upstream (non-slim) image instead of guessing. + expect(slimImageForCurrentPin("gotrue", current, "v1.67.0", true)).toBe( + "supabase/gotrue:v1.67.0", ); }); }); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 821039993d..c89c548f1b 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -292,6 +292,8 @@ GenTypesMissingProjectConfigError GenTypesNetworkError GenTypesNetworkIdUnsupportedError GenTypesParseConfigError +GenTypesToolFailedError +GenTypesToolNotInstalledError GenTypesUnexpectedStatusError GenTypesWorkdirError GoChildExitError @@ -300,7 +302,6 @@ HealthCheckTimeoutError HostPostgresClientError ImagePrepullError InitConfigExistsError -InitExperimentalRequiredError InitParseSettingsError InspectCsvqError InspectMutuallyExclusiveFlagsError @@ -524,10 +525,10 @@ SsoUpdateUnexpectedStatusError StackBootstrapError StackCatalogSetupError StackConfigError -StackFunctionsBundleError StackFunctionsEnvError StackNativeEngineError StackRoutingError +StackRuntimeSelectionError StackRuntimeUnavailableError StackStorageCapabilityError StackStorageUnavailableError @@ -578,6 +579,7 @@ TestDbMutuallyExclusiveFlagsError TestDbNoTestsError TestDbRunError TestNewFileExistsError +TestNewInvalidNameError TestNewWriteError UnknownComputeExposureError UnknownComputeRuntimeError diff --git a/apps/cli/src/shared/telemetry/ai-tool.layer.ts b/apps/cli/src/shared/telemetry/ai-tool.layer.ts index ba45285bb0..989968df42 100644 --- a/apps/cli/src/shared/telemetry/ai-tool.layer.ts +++ b/apps/cli/src/shared/telemetry/ai-tool.layer.ts @@ -8,18 +8,16 @@ function normalizeAgentName(name: string): string { export const aiToolLayer = Layer.effect( AiTool, - Effect.promise(() => determineAgent()).pipe( + Effect.tryPromise(() => determineAgent()).pipe( Effect.map((result) => AiTool.of({ name: result.isAgent ? Option.some(normalizeAgentName(result.agent.name)) : Option.none(), }), ), - Effect.catch(() => - Effect.succeed( - AiTool.of({ - name: Option.none(), - }), - ), + Effect.orElseSucceed(() => + AiTool.of({ + name: Option.none(), + }), ), ), ); diff --git a/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts b/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts index 8576e6e19c..8a53c9c779 100644 --- a/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, Layer, Option } from "effect"; import { processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { aiToolLayer } from "./ai-tool.layer.ts"; import { AiTool } from "./ai-tool.service.ts"; @@ -9,7 +9,9 @@ describe("aiToolLayer", () => { Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.some("codex")); - }).pipe(Effect.provide(aiToolLayer), Effect.provide(processEnvLayer({ CODEX_SANDBOX: "1" }))), + }).pipe( + Effect.provide(aiToolLayer.pipe(Layer.provide(processEnvLayer({ CODEX_SANDBOX: "1" })))), + ), ); it.live("normalizes known agent names for analytics properties", () => @@ -17,8 +19,9 @@ describe("aiToolLayer", () => { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.some("github_copilot")); }).pipe( - Effect.provide(aiToolLayer), - Effect.provide(processEnvLayer({ AI_AGENT: "github-copilot-cli" })), + Effect.provide( + aiToolLayer.pipe(Layer.provide(processEnvLayer({ AI_AGENT: "github-copilot-cli" }))), + ), ), ); @@ -26,6 +29,6 @@ describe("aiToolLayer", () => { Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.none()); - }).pipe(Effect.provide(aiToolLayer), Effect.provide(processEnvLayer({}))), + }).pipe(Effect.provide(aiToolLayer.pipe(Layer.provide(processEnvLayer({}))))), ); }); diff --git a/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts b/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts index ed81a5fb20..f51280a769 100644 --- a/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts +++ b/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts @@ -43,11 +43,7 @@ describe("withAnalyticsContext", () => { it.live("is inherited by child fibers", () => Effect.gen(function* () { const child = yield* Effect.gen(function* () { - const fiber = yield* Effect.forkChild( - Effect.gen(function* () { - return yield* CurrentAnalyticsContext; - }), - ); + const fiber = yield* Effect.forkChild(CurrentAnalyticsContext); return yield* Fiber.join(fiber); }).pipe( withAnalyticsContext({ diff --git a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts index 10cb1dd66f..7434fb22ea 100644 --- a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts +++ b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Data, Effect, Exit, Layer, Option, Stdio } from "effect"; +import { BunCrypto } from "@effect/platform-bun"; +import { Cause, Data, Effect, Exit, Layer, Option, Schema, Stdio } from "effect"; import { commandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; import { CurrentAnalyticsContext } from "./analytics-context.ts"; import { Analytics } from "./analytics.service.ts"; @@ -30,6 +31,8 @@ const FAILURE_PROPERTY_NAMES = [ PropWorkflow, ] as const; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + class InstrumentationAuthError extends Data.TaggedError("InstrumentationAuthError")<{ readonly message: string; readonly path: string; @@ -107,14 +110,14 @@ describe("withCommandInstrumentation", () => { expect(typeof span.attributes.get("command_run_id")).toBe("string"); }).pipe( withCommandInstrumentation({ analytics: false }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["branches", "list"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) }), + commandRuntimeLayer(["branches", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["branches", "list"])), ); }); @@ -130,14 +133,14 @@ describe("withCommandInstrumentation", () => { }); }).pipe( withCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["start", "--detach", "--exclude=auth"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["start", "--detach", "--exclude=auth"]) }), + commandRuntimeLayer(["start"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["start"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(2); @@ -174,17 +177,17 @@ describe("withCommandInstrumentation", () => { const failure = new InstrumentationAuthError(secrets); const program = withCommandInstrumentation()(Effect.fail(failure)).pipe( - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["login"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["login"]) }), + commandRuntimeLayer(["login"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["login"])), Effect.exit, Effect.tap((exit) => - Effect.sync(() => { + Effect.gen(function* () { expect(analytics.captured).toHaveLength(1); const event = analytics.captured[0]; expect(event?.event).toBe("cli_command_executed"); @@ -198,7 +201,7 @@ describe("withCommandInstrumentation", () => { suggested_command: "supabase login", }); expect(event?.properties).not.toHaveProperty(PropWorkflow); - const encoded = JSON.stringify(event); + const encoded = yield* encodeJson(event); for (const secret of Object.values(secrets)) expect(encoded).not.toContain(secret); expect(Exit.isFailure(exit)).toBe(true); @@ -218,13 +221,17 @@ describe("withCommandInstrumentation", () => { return Effect.die(new TypeError(secret)).pipe( withCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) })), - Effect.provide(commandRuntimeLayer(["branches", "list"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) }), + commandRuntimeLayer(["branches", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap(() => - Effect.sync(() => { + Effect.gen(function* () { expect(analytics.captured[0]?.properties).toMatchObject({ exit_code: 1, error_kind: "internal_bug", @@ -233,7 +240,7 @@ describe("withCommandInstrumentation", () => { has_suggestion: true, suggestion_type: "rerun_debug", }); - expect(JSON.stringify(analytics.captured[0])).not.toContain(secret); + expect(yield* encodeJson(analytics.captured[0])).not.toContain(secret); }), ), Effect.asVoid, @@ -252,10 +259,14 @@ describe("withCommandInstrumentation", () => { return Effect.fail(failure).pipe( withCommandInstrumentation(), - Effect.provide(failingAnalytics(new Error("telemetry defect"))), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["login"]) })), - Effect.provide(commandRuntimeLayer(["login"])), + Effect.provide( + Layer.mergeAll( + failingAnalytics(new Error("telemetry defect")), + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["login"]) }), + commandRuntimeLayer(["login"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -276,10 +287,14 @@ describe("withCommandInstrumentation", () => { // is being cancelled and swallowing would fight the cancellation. return Effect.void.pipe( withCommandInstrumentation(), - Effect.provide(interruptingAnalytics()), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["login"]) })), - Effect.provide(commandRuntimeLayer(["login"])), + Effect.provide( + Layer.mergeAll( + interruptingAnalytics(), + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["login"]) }), + commandRuntimeLayer(["login"]).pipe(Layer.provide(BunCrypto.layer)), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -307,22 +322,24 @@ describe("withCommandInstrumentation", () => { }, allowedFlagValues: ["exclude", "mode", "stack"], }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "start", - "--detach", - "--mode=docker", - "--exclude", - "auth", - "--exclude", - "storage", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "start", + "--detach", + "--mode=docker", + "--exclude", + "auth", + "--exclude", + "storage", + ]), + }), + commandRuntimeLayer(["start"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["start"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -352,14 +369,16 @@ describe("withCommandInstrumentation", () => { }, allowedFlagValues: ["token", "name", "noBrowser"], }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["login", "--name", "my-machine", "--no-browser"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["login", "--name", "my-machine", "--no-browser"]), + }), + commandRuntimeLayer(["login"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["login"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -376,16 +395,16 @@ describe("withCommandInstrumentation", () => { it.live("skips analytics capture when analytics are disabled", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "ok").pipe( + return Effect.succeed("ok").pipe( withCommandInstrumentation({ analytics: false }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["telemetry", "enable"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) }), + commandRuntimeLayer(["telemetry", "enable"]).pipe(Layer.provide(BunCrypto.layer)), + ), ), - Effect.provide(commandRuntimeLayer(["telemetry", "enable"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toEqual([]); diff --git a/apps/cli/src/shared/telemetry/consent.integration.test.ts b/apps/cli/src/shared/telemetry/consent.integration.test.ts index ead37bef33..38e39b2b12 100644 --- a/apps/cli/src/shared/telemetry/consent.integration.test.ts +++ b/apps/cli/src/shared/telemetry/consent.integration.test.ts @@ -7,14 +7,14 @@ import { Exit, FileSystem, Fiber, + Path, PlatformError, Ref, Result, + Schema, } from "effect"; -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { TestClock } from "effect/testing"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { writeTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; @@ -25,9 +25,22 @@ const config: TelemetryConfig = { session_last_active: 1, }; -function makeDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-consent-publish-")); -} +const tempRoot = useTempWorkdir("supabase-consent-publish-"); + +const seedPreviousConfig = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = tempRoot.current; + const configPath = path.join(dir, "telemetry.json"); + yield* fs.writeFileString(configPath, "previous config"); + return { dir, configPath }; +}); + +const temporaryFiles = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.readDirectory(dir)).filter((name) => name.includes(".tmp.")); + }); function injectedFailure(pathOrDescriptor: string, code: string, cause?: unknown) { return PlatformError.systemError({ @@ -41,12 +54,9 @@ function injectedFailure(pathOrDescriptor: string, code: string, cause?: unknown } describe("writeTelemetryConfig", () => { - it.effect("retries Windows replacement failures, then publishes the config", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); - - return Effect.gen(function* () { + it.effect("retries Windows replacement failures, then publishes the config", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; const calls = yield* Ref.make(0); const remainingFailures = yield* Ref.make(2); @@ -63,7 +73,7 @@ describe("writeTelemetryConfig", () => { yield* Ref.set(remainingFailures, remaining - 1); yield* Ref.update(temporaryPaths, (paths) => [...paths, from]); yield* Deferred.succeed(firstFailure, undefined); - return yield* Effect.fail(injectedFailure(to, call === 1 ? "EPERM" : "EACCES")); + return yield* injectedFailure(to, call === 1 ? "EPERM" : "EACCES"); } return yield* fs.rename(from, to); }), @@ -82,20 +92,18 @@ describe("writeTelemetryConfig", () => { expect(retries).toHaveLength(2); expect(retries[0]).toContain(".tmp."); expect(retries[1]).toBe(retries[0]); - expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual(config); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.effect("cleans the temporary file when cancelled during replacement backoff", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* fs.readFileString(configPath), + ), + ).toEqual(config); + expect(yield* temporaryFiles(dir)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { + it.effect("cleans the temporary file when cancelled during replacement backoff", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; const firstFailure = yield* Deferred.make<void>(); const publishing = yield* writeTelemetryConfig(config, dir, "win32").pipe( @@ -110,20 +118,14 @@ describe("writeTelemetryConfig", () => { ); yield* Deferred.await(firstFailure); yield* Fiber.interrupt(publishing); - expect(readFileSync(configPath, "utf8")).toBe("previous config"); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.effect("preserves the normalized platform error when retries exhaust", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); + expect(yield* fs.readFileString(configPath)).toBe("previous config"); + expect(yield* temporaryFiles(dir)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { + it.effect("preserves the normalized platform error when retries exhaust", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; const calls = yield* Ref.make(0); const firstFailure = yield* Deferred.make<void>(); @@ -167,20 +169,14 @@ describe("writeTelemetryConfig", () => { } } expect(yield* Ref.get(calls)).toBe(13); - expect(readFileSync(configPath, "utf8")).toBe("previous config"); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.effect("does not retry unrelated errors or failures on another platform", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); + expect(yield* fs.readFileString(configPath)).toBe("previous config"); + expect(yield* temporaryFiles(dir)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { + it.effect("does not retry unrelated errors or failures on another platform", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; for (const [platform, code] of [ ["win32", "EINVAL"], @@ -199,21 +195,15 @@ describe("writeTelemetryConfig", () => { ); expect(Exit.isFailure(result)).toBe(true); expect(yield* Ref.get(calls)).toBe(1); - expect(readFileSync(configPath, "utf8")).toBe("previous config"); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); + expect(yield* fs.readFileString(configPath)).toBe("previous config"); + expect(yield* temporaryFiles(dir)).toEqual([]); } - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.effect("removes a partially written temporary file when writing fails", () => { - const dir = makeDir(); - const configPath = path.join(dir, "telemetry.json"); - writeFileSync(configPath, "previous config"); + }).pipe(Effect.provide(BunServices.layer)), + ); - return Effect.gen(function* () { + it.effect("removes a partially written temporary file when writing fails", () => + Effect.gen(function* () { + const { dir, configPath } = yield* seedPreviousConfig; const fs = yield* FileSystem.FileSystem; const result = yield* writeTelemetryConfig(config, dir).pipe( Effect.provideService(FileSystem.FileSystem, { @@ -228,11 +218,8 @@ describe("writeTelemetryConfig", () => { Effect.exit, ); expect(Exit.isFailure(result)).toBe(true); - expect(readFileSync(configPath, "utf8")).toBe("previous config"); - expect(readdirSync(dir).filter((name) => name.includes(".tmp."))).toEqual([]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + expect(yield* fs.readFileString(configPath)).toBe("previous config"); + expect(yield* temporaryFiles(dir)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index 0b34e84085..870d58d70a 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -1,4 +1,5 @@ import { + Crypto, Duration, Effect, FileSystem, @@ -10,7 +11,12 @@ import { Schema, } from "effect"; import { CliSettings } from "../config/cli-settings.service.ts"; -import { type ConsentState, TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; +import { + type ConsentState, + PersistedNumberSchema, + TelemetryConfigSchema, + type TelemetryConfig, +} from "./types.ts"; export const getConfigDir = CliSettings.useSync((cliSettings) => cliSettings.supabaseHome); @@ -21,7 +27,7 @@ const LegacyTelemetryConfigSchema = Schema.Struct({ session_id: Schema.String, session_last_active: Schema.String, distinct_id: Schema.optionalKey(Schema.String), - schema_version: Schema.optionalKey(Schema.Number), + schema_version: Schema.optionalKey(PersistedNumberSchema), }); type LegacyTelemetryConfig = Schema.Schema.Type<typeof LegacyTelemetryConfigSchema>; @@ -58,11 +64,7 @@ const decodeTelemetryConfigFile = Effect.fnUntraced(function* (content: string) Effect.catch(() => Effect.gen(function* () { const legacyConfig = yield* decodeLegacyTelemetryConfigFile(content); - const config = legacyConfigToTelemetryConfig(legacyConfig); - if (config === undefined) { - return yield* Effect.fail(new Error("invalid legacy telemetry state")); - } - return config; + return yield* Effect.fromNullishOr(legacyConfigToTelemetryConfig(legacyConfig)); }), ), ); @@ -87,6 +89,7 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* ( configDir: string, platform: NodeJS.Platform = process.platform, ) { + const crypto = yield* Crypto.Crypto; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(configDir, { recursive: true, mode: 0o700 }); @@ -94,7 +97,7 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* ( // Random suffix, not a timestamp: concurrent writers (parallel test files, // two CLI processes) in the same millisecond would otherwise share a tmp // path and race the rename into ENOENT. - const tmpPath = `${configPath}.tmp.${crypto.randomUUID()}`; + const tmpPath = `${configPath}.tmp.${yield* crypto.randomUUIDv4}`; const retrySchedule = Schedule.exponential("10 millis", 2).pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.millis(100))), diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index 43bbc0e1cd..b606585446 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -1,22 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { ConfigProvider, Effect, Layer, Option } from "effect"; +import { Clock, ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import { cliSettingsLayer } from "../config/cli-settings.layer.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { mockCliProjectContext, mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; import { getEffectiveConsent, readTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; -function makeConfig(consent: TelemetryConfig["consent"]): TelemetryConfig { - return { +const makeConfig = (consent: TelemetryConfig["consent"]) => + Effect.map(Clock.currentTimeMillis, (now): TelemetryConfig => ({ consent, device_id: "test-device", session_id: "test-session", - session_last_active: Date.now(), - }; -} + session_last_active: now, + })); function withEnv(env: Record<string, string>) { return cliSettingsLayer.pipe( @@ -25,6 +22,7 @@ function withEnv(env: Record<string, string>) { Layer.provide( ConfigProvider.layer(ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true })), ), + Layer.provide(BunServices.layer), ); } @@ -32,58 +30,59 @@ function emptyEnv() { return withEnv({}); } -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-consent-test-")); -} +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -function writeTelemetryFile(dir: string, content: string): void { - writeFileSync(path.join(dir, "telemetry.json"), content); -} +const writeTelemetryFile = (dir: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(dir, "telemetry.json"), content); + }); describe("getEffectiveConsent", () => { - it.live("returns denied when DO_NOT_TRACK=1", () => + it.effect("returns denied when DO_NOT_TRACK=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ DO_NOT_TRACK: "1" }))), ); - it.live("returns denied when SUPABASE_TELEMETRY_DISABLED=1", () => + it.effect("returns denied when SUPABASE_TELEMETRY_DISABLED=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1" }))), ); - it.live("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over persisted granted consent", () => + it.effect("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over persisted granted consent", () => Effect.gen(function* () { const consent = yield* getEffectiveConsent(Option.none()); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1" }))), ); - it.live("DO_NOT_TRACK=1 takes precedence over persisted granted consent", () => + it.effect("DO_NOT_TRACK=1 takes precedence over persisted granted consent", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ DO_NOT_TRACK: "1" }))), ); - it.live("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over DO_NOT_TRACK=1", () => + it.effect("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over DO_NOT_TRACK=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1", DO_NOT_TRACK: "1" }))), ); - it.live("returns config consent value when set", () => + it.effect("returns config consent value when set", () => Effect.gen(function* () { - expect(yield* getEffectiveConsent(Option.some(makeConfig("granted")))).toBe("granted"); - expect(yield* getEffectiveConsent(Option.some(makeConfig("denied")))).toBe("denied"); + expect(yield* getEffectiveConsent(Option.some(yield* makeConfig("granted")))).toBe("granted"); + expect(yield* getEffectiveConsent(Option.some(yield* makeConfig("denied")))).toBe("denied"); }).pipe(Effect.provide(emptyEnv())), ); - it.live("defaults to granted when no config (opt-out model)", () => + it.effect("defaults to granted when no config (opt-out model)", () => Effect.gen(function* () { const consent = yield* getEffectiveConsent(Option.none()); expect(consent).toBe("granted"); @@ -92,34 +91,33 @@ describe("getEffectiveConsent", () => { }); describe("readTelemetryConfig", () => { - it.live("decodes a valid telemetry config", () => { - const dir = makeTempDir(); - const expected = makeConfig("denied"); - writeTelemetryFile(dir, JSON.stringify(expected)); + const tempRoot = useTempWorkdir("supabase-consent-test-"); + + it.effect("decodes a valid telemetry config", () => + Effect.gen(function* () { + const dir = tempRoot.current; + const expected = yield* makeConfig("denied"); + yield* writeTelemetryFile(dir, yield* encodeJson(expected)); - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual(Option.some(expected)); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("decodes a legacy disabled telemetry state as denied consent", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile( + dir, + yield* encodeJson({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); - it.live("decodes a legacy disabled telemetry state as denied consent", () => { - const dir = makeTempDir(); - writeTelemetryFile( - dir, - JSON.stringify({ - enabled: false, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual( Option.some({ @@ -129,27 +127,24 @@ describe("readTelemetryConfig", () => { session_last_active: Date.parse("2026-04-01T12:00:00Z"), }), ); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("decodes a legacy enabled telemetry state as granted consent", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile( + dir, + yield* encodeJson({ + enabled: true, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); - it.live("decodes a legacy enabled telemetry state as granted consent", () => { - const dir = makeTempDir(); - writeTelemetryFile( - dir, - JSON.stringify({ - enabled: true, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - distinct_id: "user-123", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual( Option.some({ @@ -160,35 +155,66 @@ describe("readTelemetryConfig", () => { distinct_id: "user-123", }), ); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("keeps an overflowed session_last_active and the persisted consent", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile( + dir, + '{"consent":"denied","device_id":"device","session_id":"session","session_last_active":1e999}', + ); - it.live("returns none for malformed JSON instead of throwing", () => { - const dir = makeTempDir(); - writeTelemetryFile(dir, ""); + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "denied", + device_id: "device", + session_id: "session", + session_last_active: Infinity, + }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("decodes a legacy state whose schema_version overflowed", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile( + dir, + '{"enabled":false,"device_id":"legacy-device","session_id":"legacy-session","session_last_active":"2026-04-01T12:00:00Z","schema_version":1e999}', + ); + + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "denied", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + }), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("returns none for malformed JSON instead of throwing", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile(dir, ""); - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual(Option.none()); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("returns none for structurally invalid telemetry config", () => { - const dir = makeTempDir(); - writeTelemetryFile(dir, JSON.stringify({ consent: "granted" })); + it.effect("returns none for structurally invalid telemetry config", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeTelemetryFile(dir, yield* encodeJson({ consent: "granted" })); - return Effect.gen(function* () { const config = yield* readTelemetryConfig(dir); expect(config).toEqual(Option.none()); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts b/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts index f1fc502b85..80b9c8549e 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts @@ -1,38 +1,48 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path, Schema } from "effect"; +import { randomUUID } from "node:crypto"; import { pathToFileURL } from "node:url"; -import { describe, expect, test } from "vitest"; + +const encodeSpecifier = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); describe("release-minified error fingerprints", () => { - test("keeps a declared tagged error's source identifier", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "supabase-error-actionability-")); - const bundlePath = join(tempDir, "fixture.mjs"); - const errorModule = resolve(import.meta.dirname, "../functions/delete.errors.ts"); - const plainErrorModule = resolve( - import.meta.dirname, - "../../command-internal/config-validate.ts", - ); - const classifierModule = resolve(import.meta.dirname, "error-actionability.ts"); + it.live("keeps a declared tagged error's source identifier", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-error-actionability-", + }); + const bundlePath = path.join(tempDir, "fixture.mjs"); + const errorModule = yield* encodeSpecifier( + path.resolve(import.meta.dirname, "../functions/delete.errors.ts"), + ); + const plainErrorModule = yield* encodeSpecifier( + path.resolve(import.meta.dirname, "../../command-internal/config-validate.ts"), + ); + const classifierModule = yield* encodeSpecifier( + path.resolve(import.meta.dirname, "error-actionability.ts"), + ); - try { - const build = await Bun.build({ - entrypoints: ["actionability-fixture"], - target: "bun", - minify: true, - plugins: [ - { - name: "actionability-fixture", - setup(builder) { - builder.onResolve({ filter: /^actionability-fixture$/ }, () => ({ - path: "actionability-fixture", - namespace: "actionability-fixture", - })); - builder.onLoad({ filter: /.*/, namespace: "actionability-fixture" }, () => ({ - contents: ` - import { InvalidFunctionSlugError } from ${JSON.stringify(errorModule)}; - import { ConfigValidateError } from ${JSON.stringify(plainErrorModule)}; - import { classifyCliErrorActionability } from ${JSON.stringify(classifierModule)}; + const build = yield* Effect.tryPromise(() => + Bun.build({ + entrypoints: ["actionability-fixture"], + target: "bun", + minify: true, + plugins: [ + { + name: "actionability-fixture", + setup(builder) { + builder.onResolve({ filter: /^actionability-fixture$/ }, () => ({ + path: "actionability-fixture", + namespace: "actionability-fixture", + })); + builder.onLoad({ filter: /.*/, namespace: "actionability-fixture" }, () => ({ + contents: ` + import { InvalidFunctionSlugError } from ${errorModule}; + import { ConfigValidateError } from ${plainErrorModule}; + import { classifyCliErrorActionability } from ${classifierModule}; export const taggedConstructorName = InvalidFunctionSlugError.name; export const taggedClassification = classifyCliErrorActionability( new InvalidFunctionSlugError({ message: "private user input" }), @@ -42,12 +52,13 @@ describe("release-minified error fingerprints", () => { new ConfigValidateError("private user input"), ); `, - loader: "ts", - })); + loader: "ts", + })); + }, }, - }, - ], - }); + ], + }), + ); expect(build.success, build.logs.map(String).join("\n")).toBe(true); expect(build.outputs).toHaveLength(1); @@ -55,8 +66,11 @@ describe("release-minified error fingerprints", () => { expect(output).toBeDefined(); if (output === undefined) return; - await Bun.write(bundlePath, output); - const fixture = await import(`${pathToFileURL(bundlePath).href}?run=${crypto.randomUUID()}`); + const bundle = yield* Effect.tryPromise(() => output.arrayBuffer()); + yield* fs.writeFile(bundlePath, new Uint8Array(bundle)); + const fixture = yield* Effect.tryPromise( + () => import(`${pathToFileURL(bundlePath).href}?run=${randomUUID()}`), + ); expect(Reflect.get(fixture, "taggedConstructorName")).not.toBe("InvalidFunctionSlugError"); expect(Reflect.get(fixture, "taggedClassification")).toEqual({ error_kind: "user_actionable", @@ -73,8 +87,6 @@ describe("release-minified error fingerprints", () => { has_suggestion: true, suggestion_type: "update_config", }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 45c0526a22..552e6bcc25 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -387,6 +387,20 @@ export const actionability = { has_suggestion: true, suggestion_type: CliSuggestionType.RerunDebug, }, + /** A toolchain the command shells out to is missing; the remedy varies per tool. */ + toolNotInstalled: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + /** A tool the command shells out to exited unsuccessfully; its stderr is in the message. */ + toolFailed: { + error_kind: CliErrorKind.Unknown, + error_category: CliErrorCategory.Unknown, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, apiStatus: { error_kind: CliErrorKind.ExternalService, error_category: CliErrorCategory.ApiStatus, diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index b6d773b839..e0508fd99f 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -32,7 +32,7 @@ class DeclaredStatusError extends Data.TaggedError("DeclaredStatusError")<{ } } -class PlainDeclaredError extends Error { +class PlainDeclaredError extends Data.Error<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "PlainDeclaredError"; get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -90,9 +90,10 @@ describe("classifyCliErrorActionability", () => { suggestion_type: "login", suggested_command: "supabase login", }); - expect(classifyCliErrorActionability(new PlainDeclaredError("private")).error_fingerprint).toBe( - "error:PlainDeclaredError", - ); + expect( + classifyCliErrorActionability(new PlainDeclaredError({ message: "private" })) + .error_fingerprint, + ).toBe("error:PlainDeclaredError"); }); it("preserves native Error subclass identifiers after minification", () => { @@ -141,7 +142,7 @@ describe("classifyCliErrorActionability", () => { }); it("rejects malformed declarations and arbitrary remediation text", () => { - class InvalidDeclaration extends Error { + class InvalidDeclaration extends Data.Error { get [ErrorActionabilityId]() { return { error_kind: "user_actionable", @@ -159,7 +160,7 @@ describe("classifyCliErrorActionability", () => { it("handles hostile declarations and unknown failures safely", () => { const secret = "customer-project-ref"; - class HostileError extends Error { + class HostileError extends Data.Error { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { throw new Error(secret); } diff --git a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts index 5eefce133c..5b4a4e3620 100644 --- a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts @@ -1,7 +1,7 @@ -import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { beforeAll, describe, expect, it } from "@effect/vitest"; +import { Config, Effect, FileSystem, Option, Path, PlatformError } from "effect"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { beforeAll, describe, expect, it } from "vitest"; /** * Guards the telemetry identity of every CLI error: the string passed to @@ -21,9 +21,10 @@ import { beforeAll, describe, expect, it } from "vitest"; const cliSrcDir = fileURLToPath(new URL("../..", import.meta.url)); const repoRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); -const externalConfigSrcDir = path.join(repoRoot, "packages/config/src"); -const fixturesDir = fileURLToPath(new URL("./__fixtures__", import.meta.url)); -const fixturePath = path.join(fixturesDir, "error-tags.txt"); +const externalConfigSrcDir = fileURLToPath( + new URL("../../../../../packages/config/src", import.meta.url), +); +const fixturePath = fileURLToPath(new URL("./__fixtures__/error-tags.txt", import.meta.url)); // Matches both the inline declaration form and the formatter-wrapped // multi-line form, where the string literal lands on its own line before the @@ -59,15 +60,24 @@ interface TaggedErrorDeclaration { readonly file: string; } -function walk(dir: string): Array<string> { - return readdirSync(dir).flatMap((entry) => { - if (entry === "__fixtures__") return []; - const fullPath = path.join(dir, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) return walk(fullPath); - return fullPath.endsWith(".ts") && !fullPath.endsWith(".d.ts") ? [fullPath] : []; +const walk = ( + dir: string, +): Effect.Effect<Array<string>, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(dir); + const nested = yield* Effect.forEach(entries, (entry) => + Effect.gen(function* () { + if (entry === "__fixtures__") return []; + const fullPath = path.join(dir, entry); + const stats = yield* fs.stat(fullPath); + if (stats.type === "Directory") return yield* walk(fullPath); + return fullPath.endsWith(".ts") && !fullPath.endsWith(".d.ts") ? [fullPath] : []; + }), + ); + return nested.flat(); }); -} function isProductionSourceFile(filePath: string): boolean { return !TEST_FILE_SUFFIXES.some((suffix) => filePath.endsWith(suffix)); @@ -78,25 +88,25 @@ function isProductionSourceFile(filePath: string): boolean { * given production files, skipping `COMPUTED_TAG_SOURCE_FILES` (those come from * {@link collectComputedTagDeclarations} instead) so a file never contributes twice. */ -function collectStaticDeclarations( - filePaths: ReadonlyArray<string>, - rootDir: string, -): Array<TaggedErrorDeclaration> { - const declarations: Array<TaggedErrorDeclaration> = []; - for (const filePath of filePaths) { - const relativeToRoot = path.relative(rootDir, filePath); - if (rootDir === cliSrcDir && computedTagSourceFileSet.has(relativeToRoot)) continue; - const source = readFileSync(filePath, "utf8"); - for (const match of source.matchAll(TAGGED_ERROR_PATTERN)) { - declarations.push({ - className: match[1]!, - tag: match[2]!, - file: path.relative(repoRoot, filePath), - }); +const collectStaticDeclarations = (filePaths: ReadonlyArray<string>, rootDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const declarations: Array<TaggedErrorDeclaration> = []; + for (const filePath of filePaths) { + const relativeToRoot = path.relative(rootDir, filePath); + if (rootDir === cliSrcDir && computedTagSourceFileSet.has(relativeToRoot)) continue; + const source = yield* fs.readFileString(filePath); + for (const match of source.matchAll(TAGGED_ERROR_PATTERN)) { + declarations.push({ + className: match[1]!, + tag: match[2]!, + file: path.relative(repoRoot, filePath), + }); + } } - } - return declarations; -} + return declarations; + }); /** * Runtime half of the computed-tag guard: imports each `COMPUTED_TAG_SOURCE_FILES` module, @@ -106,26 +116,26 @@ function collectStaticDeclarations( * `new Export({})` is safe because every export here is a `Data.TaggedError`-derived class * whose generated constructor assigns whatever properties are present without validating them. */ -async function collectComputedTagDeclarations(): Promise<Array<TaggedErrorDeclaration>> { +const collectComputedTagDeclarations = Effect.gen(function* () { + const path = yield* Path.Path; const declarations: Array<TaggedErrorDeclaration> = []; for (const relativeFile of COMPUTED_TAG_SOURCE_FILES) { const moduleUrl = pathToFileURL(path.join(cliSrcDir, relativeFile)).href; - const module: Record<string, unknown> = await import(moduleUrl); + const module: Record<string, unknown> = yield* Effect.tryPromise(() => import(moduleUrl)); for (const [exportName, exportValue] of Object.entries(module)) { if (typeof exportValue !== "function") continue; - let tag: unknown; - try { + const constructed = yield* Effect.try(() => { const Ctor = exportValue as new (args: Record<string, unknown>) => { _tag?: unknown }; - tag = new Ctor({})._tag; - } catch { - continue; // Not a constructible Data.TaggedError-shaped export. - } + return new Ctor({})._tag; + }).pipe(Effect.option); + if (Option.isNone(constructed)) continue; // Not a constructible Data.TaggedError-shaped export. + const tag = constructed.value; if (typeof tag !== "string") continue; declarations.push({ className: exportName, tag, file: `apps/cli/src/${relativeFile}` }); } } return declarations; -} +}); /** Shared comparator for the fixture and the live scan, so a `localeCompare`-vs-default-sort * mismatch can't make the comparison lie. */ @@ -133,23 +143,22 @@ function compareTags(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } -function readSnapshotTags(): Array<string> { - return readFileSync(fixturePath, "utf8") - .split(/\r?\n/) - .filter((line) => line.length > 0); -} +const readSnapshotTags = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return (yield* fs.readFileString(fixturePath)).split(/\r?\n/).filter((line) => line.length > 0); +}); /** Every production `Data.TaggedError` declaration, static and computed alike. */ -async function collectProductionDeclarations(): Promise<Array<TaggedErrorDeclaration>> { - const cliFiles = walk(cliSrcDir).filter(isProductionSourceFile); - const externalFiles = walk(externalConfigSrcDir).filter(isProductionSourceFile); +const collectProductionDeclarations = Effect.gen(function* () { + const cliFiles = (yield* walk(cliSrcDir)).filter(isProductionSourceFile); + const externalFiles = (yield* walk(externalConfigSrcDir)).filter(isProductionSourceFile); const staticDeclarations = [ - ...collectStaticDeclarations(cliFiles, cliSrcDir), - ...collectStaticDeclarations(externalFiles, externalConfigSrcDir), + ...(yield* collectStaticDeclarations(cliFiles, cliSrcDir)), + ...(yield* collectStaticDeclarations(externalFiles, externalConfigSrcDir)), ]; - const computedDeclarations = await collectComputedTagDeclarations(); + const computedDeclarations = yield* collectComputedTagDeclarations; return [...staticDeclarations, ...computedDeclarations]; -} +}); /** * Regenerates `__fixtures__/error-tags.txt` from the current, live tag set. @@ -160,44 +169,53 @@ async function collectProductionDeclarations(): Promise<Array<TaggedErrorDeclara * Only ever do this after confirming the `added`/`removed` diff below is an * intentional, reviewed identity change -- not an accidental rename. */ -function writeFixture(tags: ReadonlySet<string>): void { - const sorted = [...tags].sort(compareTags); - writeFileSync(fixturePath, sorted.map((tag) => `${tag}\n`).join("")); -} +const writeFixture = (tags: ReadonlySet<string>) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sorted = [...tags].sort(compareTags); + yield* fs.writeFileString(fixturePath, sorted.map((tag) => `${tag}\n`).join("")); + }); describe("error tag stability", () => { let productionDeclarations: Array<TaggedErrorDeclaration>; - beforeAll(async () => { - productionDeclarations = await collectProductionDeclarations(); - }); - - it("keeps every Data.TaggedError tag literal identical to the committed snapshot", () => { - const currentTagSet = new Set(productionDeclarations.map((declaration) => declaration.tag)); - - if (process.env["UPDATE_ERROR_TAGS_FIXTURE"] === "1") { - writeFixture(currentTagSet); - } - - const currentTags = [...currentTagSet].sort(compareTags); - const snapshotTags = readSnapshotTags(); - - const added = currentTags.filter((tag) => !snapshotTags.includes(tag)); - const removed = snapshotTags.filter((tag) => !currentTags.includes(tag)); + beforeAll(() => + Effect.runPromise(collectProductionDeclarations.pipe(Effect.provide(BunServices.layer))).then( + (declarations) => { + productionDeclarations = declarations; + }, + ), + ); + + it.live("keeps every Data.TaggedError tag literal identical to the committed snapshot", () => + Effect.gen(function* () { + const currentTagSet = new Set(productionDeclarations.map((declaration) => declaration.tag)); + + const updateFixture = yield* Config.option(Config.string("UPDATE_ERROR_TAGS_FIXTURE")); + if (Option.isSome(updateFixture) && updateFixture.value === "1") { + yield* writeFixture(currentTagSet); + } - expect( - { added, removed }, - 'A Data.TaggedError("...") tag literal was added, removed, or changed. That string is the ' + - "error's telemetry identity in PostHog -- it flows into error_fingerprint as `tag:<TagName>` on " + - "the cli_command_executed event. Changing it silently splits one error's history into two " + - "fingerprints, with no error and no warning, breaking repeat-rate and trend continuity. " + - "Renaming the error CLASS is fine; do NOT change the string literal passed to " + - "Data.TaggedError(...) when you do it. If this change is an intentional, reviewed identity " + - "change (not an accidental rename), regenerate the snapshot with " + - '`UPDATE_ERROR_TAGS_FIXTURE=1 bun --bun vitest run --project unit -t "error tag stability"` ' + - "and commit the resulting apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt.", - ).toEqual({ added: [], removed: [] }); - }); + const currentTags = [...currentTagSet].sort(compareTags); + const snapshotTags = yield* readSnapshotTags; + + const added = currentTags.filter((tag) => !snapshotTags.includes(tag)); + const removed = snapshotTags.filter((tag) => !currentTags.includes(tag)); + + expect( + { added, removed }, + 'A Data.TaggedError("...") tag literal was added, removed, or changed. That string is the ' + + "error's telemetry identity in PostHog -- it flows into error_fingerprint as `tag:<TagName>` on " + + "the cli_command_executed event. Changing it silently splits one error's history into two " + + "fingerprints, with no error and no warning, breaking repeat-rate and trend continuity. " + + "Renaming the error CLASS is fine; do NOT change the string literal passed to " + + "Data.TaggedError(...) when you do it. If this change is an intentional, reviewed identity " + + "change (not an accidental rename), regenerate the snapshot with " + + '`UPDATE_ERROR_TAGS_FIXTURE=1 bun --bun vitest run --project unit -t "error tag stability"` ' + + "and commit the resulting apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt.", + ).toEqual({ added: [], removed: [] }); + }).pipe(Effect.provide(BunServices.layer)), + ); it("never lets two unexpectedly-different error classes share the same tag literal", () => { /** diff --git a/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts b/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts index 644774b0e6..6d5482dffd 100644 --- a/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts +++ b/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts @@ -1,77 +1,86 @@ -import { describe, expect, test } from "vitest"; -import { Cause, Context, Effect, Exit, Option, Schema, Tracer } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Clock, Context, Effect, Exit, Option, Schema, Tracer } from "effect"; import { formatSpanForDebugConsole, makeDebugConsoleExporter } from "./debug-console.ts"; -function makeEndedSpan(name: string, attrs: Record<string, unknown> = {}): Tracer.Span { - const startTime = BigInt(Date.now()) * BigInt(1_000_000); - const endTime = startTime + BigInt(50_000_000); // 50ms later - const attributes = new Map(Object.entries(attrs)); - return { - _tag: "Span", - name, - spanId: "abc123", - traceId: "def456", - parent: Option.none(), - annotations: Context.empty(), - links: [], - sampled: true, - kind: "internal", - status: { - _tag: "Ended", - startTime, - endTime, - exit: { _tag: "Success", value: undefined } as any, - }, - attributes, - end: () => {}, - attribute: () => {}, - event: () => {}, - addLinks: () => {}, - }; -} +const makeEndedSpan = (name: string, attrs: Record<string, unknown> = {}) => + Effect.map(Clock.currentTimeMillis, (now): Tracer.Span => { + const startTime = BigInt(now) * BigInt(1_000_000); + const endTime = startTime + BigInt(50_000_000); // 50ms later + const attributes = new Map(Object.entries(attrs)); + return { + _tag: "Span", + name, + spanId: "abc123", + traceId: "def456", + parent: Option.none(), + annotations: Context.empty(), + links: [], + sampled: true, + kind: "internal", + status: { + _tag: "Ended", + startTime, + endTime, + exit: { _tag: "Success", value: undefined } as any, + }, + attributes, + end: () => {}, + attribute: () => {}, + event: () => {}, + addLinks: () => {}, + }; + }); describe("debug-console exporter", () => { - test("formats and writes ended span info", () => { - let stderrOutput = ""; - const span = makeEndedSpan("test-span", { command: "login" }); - const exportSpanToDebugConsole = makeDebugConsoleExporter((line) => - Effect.sync(() => { - stderrOutput += line; - }), - ); + it.effect("formats and writes ended span info", () => + Effect.gen(function* () { + let stderrOutput = ""; + const span = yield* makeEndedSpan("test-span", { command: "login" }); + const exportSpanToDebugConsole = makeDebugConsoleExporter((line) => + Effect.sync(() => { + stderrOutput += line; + }), + ); - Effect.runSync(exportSpanToDebugConsole(span)); + yield* exportSpanToDebugConsole(span); - expect(stderrOutput).toContain("test-span"); - expect(stderrOutput).toContain("50ms"); - expect(stderrOutput).toContain("login"); - expect(stderrOutput).toContain("\n"); - }); + expect(stderrOutput).toContain("test-span"); + expect(stderrOutput).toContain("50ms"); + expect(stderrOutput).toContain("login"); + expect(stderrOutput).toContain("\n"); + }), + ); - test("returns undefined for spans that have not ended", () => { - const span = { - ...makeEndedSpan("pending-span"), - status: { - _tag: "Started", - startTime: BigInt(Date.now()) * BigInt(1_000_000), - } as Tracer.SpanStatus, - }; + it.effect("returns undefined for spans that have not ended", () => + Effect.gen(function* () { + const span = { + ...(yield* makeEndedSpan("pending-span")), + status: { + _tag: "Started", + startTime: BigInt(yield* Clock.currentTimeMillis) * BigInt(1_000_000), + } as Tracer.SpanStatus, + }; - expect(Effect.runSync(formatSpanForDebugConsole(span))).toEqual(Option.none()); - }); + expect(yield* formatSpanForDebugConsole(span)).toEqual(Option.none()); + }), + ); - test("returns a typed failure for unserializable attributes", () => { - const cyclic: Record<string, unknown> = {}; - cyclic.self = cyclic; - const result = Effect.runSyncExit(formatSpanForDebugConsole(makeEndedSpan("cyclic", cyclic))); + it.effect("returns a typed failure for unserializable attributes", () => + Effect.gen(function* () { + const cyclic: Record<string, unknown> = {}; + cyclic.self = cyclic; + const result = yield* Effect.exit( + formatSpanForDebugConsole(yield* makeEndedSpan("cyclic", cyclic)), + ); - expect(Exit.isFailure(result)).toBe(true); - if (Exit.isFailure(result)) { - const error = Cause.findErrorOption(result.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(Schema.isSchemaError(error.value)).toBe(true); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = Cause.findErrorOption(result.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(Schema.isSchemaError(error.value)).toBe(true); + } } - } - }); + }), + ); }); diff --git a/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts b/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts index deb9649327..ef739c80ed 100644 --- a/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts +++ b/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts @@ -1,22 +1,18 @@ import { describe, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect } from "effect"; +import { Effect, Path } from "effect"; +import { useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { initNdjsonExporter } from "./ndjson.ts"; const fsLayer = BunServices.layer; describe("initNdjsonExporter", () => { - it.live("does not fail when traces directory does not exist", () => { - const dir = mkdtempSync(path.join(tmpdir(), "supabase-ndjson-test-")); - const tracesDir = path.join(dir, "traces"); - return Effect.gen(function* () { - yield* initNdjsonExporter(tracesDir); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + const tempRoot = useTempWorkdir("supabase-ndjson-test-"); + + it.live("does not fail when traces directory does not exist", () => + Effect.gen(function* () { + const path = yield* Path.Path; + yield* initNdjsonExporter(path.join(tempRoot.current, "traces")); + }).pipe(Effect.provide(fsLayer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts index e3f2a246cc..43018bf949 100644 --- a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts @@ -1,55 +1,21 @@ -import { createServer, type Server } from "node:http"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Schema } from "effect"; import { gunzipSync } from "node:zlib"; -import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; -import { runSupabase } from "../../../tests/helpers/cli.ts"; +import { runSupabaseEffect } from "../../../tests/helpers/cli.ts"; type CapturedEvent = { readonly event: unknown; readonly properties: unknown; }; -describe("failed command telemetry", () => { - let server: Server; - let host: string; - const capturedEvents: CapturedEvent[] = []; - - beforeAll(async () => { - server = createServer((request, response) => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - const body = Buffer.concat(chunks); - const decoded = request.headers["content-encoding"] === "gzip" ? gunzipSync(body) : body; - const payload: unknown = JSON.parse(decoded.toString()); - if (typeof payload === "object" && payload !== null) { - const batch = Reflect.get(payload, "batch"); - if (Array.isArray(batch)) capturedEvents.push(...batch); - } - response.writeHead(200, { "content-type": "application/json" }); - response.end("{}"); - }); - }); - await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Failed to allocate a telemetry receiver port"); - } - host = `http://127.0.0.1:${address.port}`; - }); - - afterAll(async () => { - await new Promise<void>((resolve, reject) => { - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); - }); - - beforeEach(() => { - capturedEvents.length = 0; - }); +class TelemetryReceiverError extends Data.TaggedError("TelemetryReceiverError")<{ + readonly cause: unknown; +}> {} +describe("failed command telemetry", () => { // `branches list` needs a syntactically valid access token so the auth gate builds and the // failure happens during project-ref resolution instead of at the auth gate itself. - test.each([ + it.live.each([ { args: ["branches", "list"], command: "branches list", @@ -83,27 +49,55 @@ describe("failed command telemetry", () => { }, rawErrors: ["failed to connect", "127.0.0.1", "select 1"], }, - ])("emits sanitized metadata from the compiled CLI ($command)", async (testCase) => { - const result = await runSupabase(testCase.args, { - env: { - SUPABASE_ACCESS_TOKEN: testCase.accessToken, - SUPABASE_TELEMETRY_DISABLED: "0", - DO_NOT_TRACK: "0", - SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_failure_metadata_e2e", - SUPABASE_TELEMETRY_POSTHOG_HOST: host, - }, - }); + ])("emits sanitized metadata from the compiled CLI ($command)", (testCase) => + Effect.gen(function* () { + const capturedEvents: CapturedEvent[] = []; + const server = yield* Effect.acquireRelease( + Effect.try({ + try: () => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + return request.arrayBuffer().then((buffer) => { + const body = new Uint8Array(buffer); + const decoded = + request.headers.get("content-encoding") === "gzip" ? gunzipSync(body) : body; + const payload: unknown = JSON.parse(new TextDecoder().decode(decoded)); + if (typeof payload === "object" && payload !== null) { + const batch = Reflect.get(payload, "batch"); + if (Array.isArray(batch)) capturedEvents.push(...batch); + } + return Response.json({}); + }); + }, + }), + catch: (cause) => new TelemetryReceiverError({ cause }), + }), + (running) => Effect.promise(() => running.stop(true)), + ); - expect(result.exitCode).toBe(1); - const event = capturedEvents.find((candidate) => candidate.event === "cli_command_executed"); - expect(event).toBeDefined(); - expect(event?.properties).toMatchObject({ - command: testCase.command, - exit_code: 1, - ...testCase.expected, - }); - expect(event?.properties).not.toHaveProperty("workflow"); - const encoded = JSON.stringify(event); - for (const rawError of testCase.rawErrors) expect(encoded).not.toContain(rawError); - }); + const result = yield* runSupabaseEffect(testCase.args, { + env: { + SUPABASE_ACCESS_TOKEN: testCase.accessToken, + SUPABASE_TELEMETRY_DISABLED: "0", + DO_NOT_TRACK: "0", + SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_failure_metadata_e2e", + SUPABASE_TELEMETRY_POSTHOG_HOST: server.url.origin, + }, + }); + + expect(result.exitCode).toBe(1); + const event = capturedEvents.find((candidate) => candidate.event === "cli_command_executed"); + expect(event).toBeDefined(); + expect(event?.properties).toMatchObject({ + command: testCase.command, + exit_code: 1, + ...testCase.expected, + }); + expect(event?.properties).not.toHaveProperty("workflow"); + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(event); + for (const rawError of testCase.rawErrors) expect(encoded).not.toContain(rawError); + }), + ); }); diff --git a/apps/cli/src/shared/telemetry/identity.ts b/apps/cli/src/shared/telemetry/identity.ts index 92d30cd199..592ce0944d 100644 --- a/apps/cli/src/shared/telemetry/identity.ts +++ b/apps/cli/src/shared/telemetry/identity.ts @@ -1,18 +1,19 @@ -import { Effect, Option } from "effect"; +import { Clock, Crypto, Effect, Option } from "effect"; import { readTelemetryConfig, writeTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; const SESSION_TIMEOUT_MS = 30 * 60 * 1000; export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { + const crypto = yield* Crypto.Crypto; const config = yield* readTelemetryConfig(configDir); - const now = Date.now(); + const now = yield* Clock.currentTimeMillis; if (Option.isNone(config)) { const newConfig: TelemetryConfig = { consent: "granted", - device_id: crypto.randomUUID(), - session_id: crypto.randomUUID(), + device_id: yield* crypto.randomUUIDv4, + session_id: yield* crypto.randomUUIDv4, session_last_active: now, }; yield* writeTelemetryConfig(newConfig, configDir); @@ -26,7 +27,7 @@ export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { const currentConfig = config.value; const isSessionExpired = now - currentConfig.session_last_active > SESSION_TIMEOUT_MS; - const sessionId = isSessionExpired ? crypto.randomUUID() : currentConfig.session_id; + const sessionId = isSessionExpired ? yield* crypto.randomUUIDv4 : currentConfig.session_id; yield* writeTelemetryConfig( { ...currentConfig, session_id: sessionId, session_last_active: now }, @@ -86,6 +87,7 @@ export function makeTelemetryIdentity(persisted: string | undefined): TelemetryI * aliases a fresh device. */ export const resetIdentity = Effect.fnUntraced(function* (configDir: string) { + const crypto = yield* Crypto.Crypto; const identity = yield* resolveIdentity(configDir); const config = yield* readTelemetryConfig(configDir); const nextConfig: TelemetryConfig = { @@ -93,9 +95,9 @@ export const resetIdentity = Effect.fnUntraced(function* (configDir: string) { onNone: () => "granted", onSome: (value) => value.consent, }), - device_id: crypto.randomUUID(), + device_id: yield* crypto.randomUUIDv4, session_id: identity.sessionId, - session_last_active: Date.now(), + session_last_active: yield* Clock.currentTimeMillis, }; yield* writeTelemetryConfig(nextConfig, configDir); }); diff --git a/apps/cli/src/shared/telemetry/identity.unit.test.ts b/apps/cli/src/shared/telemetry/identity.unit.test.ts index cdb8dbe672..0a4889301e 100644 --- a/apps/cli/src/shared/telemetry/identity.unit.test.ts +++ b/apps/cli/src/shared/telemetry/identity.unit.test.ts @@ -1,187 +1,162 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect } from "effect"; +import { Clock, Effect, FileSystem, Path, Schema } from "effect"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { makeTelemetryIdentity, resetIdentity, resolveIdentity } from "./identity.ts"; -import type { TelemetryConfig } from "./types.ts"; +import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-identity-test-")); -} +const TelemetryConfigJson = Schema.fromJsonString(TelemetryConfigSchema); -function writeConfig(dir: string, config: TelemetryConfig): void { - mkdirSync(dir, { recursive: true }); - writeFileSync(path.join(dir, "telemetry.json"), JSON.stringify(config)); -} +const writeConfig = (dir: string, config: TelemetryConfig) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "telemetry.json"), + yield* Schema.encodeEffect(TelemetryConfigJson)(config), + ); + }); -function readConfig(dir: string): TelemetryConfig { - return JSON.parse(readFileSync(path.join(dir, "telemetry.json"), "utf8")); -} +const readConfig = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const content = yield* fs.readFileString(path.join(dir, "telemetry.json")); + return yield* Schema.decodeEffect(TelemetryConfigJson)(content); + }); const fsLayer = BunServices.layer; +const tempRoot = useTempWorkdir("supabase-identity-test-"); + describe("resolveIdentity", () => { - it.live("generates new device_id on first run", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { deviceId } = yield* resolveIdentity(dir); + it.effect("generates new device_id on first run", () => + Effect.gen(function* () { + const { deviceId } = yield* resolveIdentity(tempRoot.current); expect(deviceId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); - it.live("generates new session_id on first run", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { sessionId } = yield* resolveIdentity(dir); + it.effect("generates new session_id on first run", () => + Effect.gen(function* () { + const { sessionId } = yield* resolveIdentity(tempRoot.current); expect(sessionId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); - it.live("isFirstRun is true on first call", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { isFirstRun } = yield* resolveIdentity(dir); + it.effect("isFirstRun is true on first call", () => + Effect.gen(function* () { + const { isFirstRun } = yield* resolveIdentity(tempRoot.current); expect(isFirstRun).toBe(true); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); - it.live("writes config on first run with granted consent", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { + it.effect("writes config on first run with granted consent", () => + Effect.gen(function* () { + const dir = tempRoot.current; yield* resolveIdentity(dir); - const config = readConfig(dir); + const config = yield* readConfig(dir); expect(config.consent).toBe("granted"); expect(config.device_id).toMatch(UUID_PATTERN); expect(config.session_id).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("preserves device_id across runs", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now(), - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("preserves device_id across runs", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: yield* Clock.currentTimeMillis, + }); const { deviceId } = yield* resolveIdentity(dir); expect(deviceId).toBe("existing-device-id"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("isFirstRun is false on subsequent runs", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now(), - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("isFirstRun is false on subsequent runs", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: yield* Clock.currentTimeMillis, + }); const { isFirstRun } = yield* resolveIdentity(dir); expect(isFirstRun).toBe(false); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("preserves session_id within 30min", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now() - 10 * 60 * 1000, - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("preserves session_id within 30min", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: (yield* Clock.currentTimeMillis) - 10 * 60 * 1000, + }); const { sessionId } = yield* resolveIdentity(dir); expect(sessionId).toBe("existing-session-id"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("rotates session_id after 30min idle", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "old-session-id", - session_last_active: Date.now() - 31 * 60 * 1000, - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("rotates session_id after 30min idle", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "old-session-id", + session_last_active: (yield* Clock.currentTimeMillis) - 31 * 60 * 1000, + }); const { sessionId } = yield* resolveIdentity(dir); expect(sessionId).not.toBe("old-session-id"); expect(sessionId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); - - it.live("updates session_last_active on every call", () => { - const dir = makeTempDir(); - const before = Date.now(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now() - 5000, - }); - return Effect.gen(function* () { + }).pipe(Effect.provide(fsLayer)), + ); + + it.effect("updates session_last_active on every call", () => + Effect.gen(function* () { + const dir = tempRoot.current; + const before = yield* Clock.currentTimeMillis; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: (yield* Clock.currentTimeMillis) - 5000, + }); yield* resolveIdentity(dir); - const config = readConfig(dir); + const config = yield* readConfig(dir); expect(config.session_last_active).toBeGreaterThanOrEqual(before); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); }); describe("resetIdentity", () => { - it.live("rotates the persisted device_id and drops the distinct_id", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "old-device-id", - session_id: "session-id", - session_last_active: Date.now(), - distinct_id: "user-a", - }); - return Effect.gen(function* () { + it.effect("rotates the persisted device_id and drops the distinct_id", () => + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeConfig(dir, { + consent: "granted", + device_id: "old-device-id", + session_id: "session-id", + session_last_active: yield* Clock.currentTimeMillis, + distinct_id: "user-a", + }); yield* resetIdentity(dir); - const config = readConfig(dir); + const config = yield* readConfig(dir); expect(config.distinct_id).toBeUndefined(); expect(config.device_id).not.toBe("old-device-id"); expect(config.consent).toBe("granted"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }); + }).pipe(Effect.provide(fsLayer)), + ); }); describe("makeTelemetryIdentity", () => { diff --git a/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts b/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts index c7674c3bc3..15efec285e 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts @@ -1,58 +1,62 @@ -import { createServer, type Server, type Socket } from "node:net"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { runSupabase } from "../../../tests/helpers/cli.ts"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Deferred, Effect, Exit } from "effect"; +import { runSupabaseEffect } from "../../../tests/helpers/cli.ts"; -// A TCP blackhole: accepts connections and never responds, so telemetry requests hang until -// aborted. Asserts on the spawned process's wall-clock exit, since pending sockets keep the -// runtime alive and only actual process exit proves the telemetry exit cap holds end to end. -describe("telemetry against a blackholed PostHog endpoint", () => { - let server: Server; - let host: string; - let connections = 0; - const sockets = new Set<Socket>(); - - beforeAll(async () => { - server = createServer((socket) => { - connections += 1; - sockets.add(socket); - // Aborted requests reset the connection; without a listener the - // server-side ECONNRESET becomes an uncaught exception. - socket.on("error", () => {}); - socket.on("close", () => sockets.delete(socket)); - }); - await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Failed to allocate a blackhole port"); - } - host = `http://127.0.0.1:${address.port}`; - }); +class BlackholeServerError extends Data.TaggedError("BlackholeServerError")<{ + readonly cause: unknown; +}> {} - afterAll(async () => { - for (const socket of sockets) socket.destroy(); - await new Promise<void>((resolve) => server.close(() => resolve())); - }); +// A blackholed endpoint: accepts requests and never responds while the CLI runs, so telemetry +// requests hang until aborted. Asserts on the spawned process's wall-clock exit, since pending +// sockets keep the runtime alive and only actual process exit proves the telemetry exit cap holds +// end to end. +describe("telemetry against a blackholed PostHog endpoint", () => { + it.live("commands exit promptly, cleanly, and quietly", () => + Effect.gen(function* () { + const requestArrived = yield* Deferred.make<void>(); + const released = yield* Deferred.make<void>(); + const runPromise = Effect.runPromiseWith(yield* Effect.context<never>()); + const server = yield* Effect.acquireRelease( + Effect.try({ + try: () => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + Deferred.doneUnsafe(requestArrived, Exit.void); + return runPromise( + Deferred.await(released).pipe(Effect.as(new Response(null, { status: 204 }))), + ); + }, + }), + catch: (cause) => new BlackholeServerError({ cause }), + }), + (running) => + Deferred.succeed(released, undefined).pipe( + Effect.andThen(Effect.promise(() => running.stop(true))), + ), + ); - test("commands exit promptly, cleanly, and quietly", async () => { - const startedAt = performance.now(); - const { stdout, stderr, exitCode } = await runSupabase(["telemetry", "status"], { - env: { - // spawnSupabase disables telemetry for every test by default; this - // test exists to exercise it, so turn it back on explicitly. - SUPABASE_TELEMETRY_DISABLED: "0", - DO_NOT_TRACK: "0", - SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_e2e_blackhole_test", - SUPABASE_TELEMETRY_POSTHOG_HOST: host, - }, - }); - const elapsedMs = performance.now() - startedAt; + const startedAt = performance.now(); + const { stdout, stderr, exitCode } = yield* runSupabaseEffect(["telemetry", "status"], { + env: { + // spawnSupabase disables telemetry for every test by default; this + // test exists to exercise it, so turn it back on explicitly. + SUPABASE_TELEMETRY_DISABLED: "0", + DO_NOT_TRACK: "0", + SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_e2e_blackhole_test", + SUPABASE_TELEMETRY_POSTHOG_HOST: server.url.origin, + }, + }); + const elapsedMs = performance.now() - startedAt; - expect(exitCode).toBe(0); - expect(stdout).toContain("Telemetry is enabled."); - expect(stderr).toBe(""); - expect(connections).toBeGreaterThanOrEqual(1); - // Healthy runs land near 2.5s (2s drain cap + spawn overhead); the nearest real failure - // signature is the SDK's 5s default deadline plus startup. - expect(elapsedMs).toBeLessThan(4_500); - }); + expect(exitCode).toBe(0); + expect(stdout).toContain("Telemetry is enabled."); + expect(stderr).toBe(""); + expect(yield* Deferred.isDone(requestArrived)).toBe(true); + // Healthy runs land near 2.5s (2s drain cap + spawn overhead); the nearest real failure + // signature is the SDK's 5s default deadline plus startup. + expect(elapsedMs).toBeLessThan(4_500); + }), + ); }); diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts index 28d187ed01..176a923a34 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.ts @@ -1,27 +1,79 @@ -import { Effect } from "effect"; +import { type Context, Effect, Exit, Option, Stream } from "effect"; +import { constTrue } from "effect/Function"; +import { + Headers, + HttpBody, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; import { PostHog, type PostHogOptions } from "posthog-node"; const EXIT_DELAY_CAP_MS = 2_000; -const delivered = { +type PostHogFetch = NonNullable<PostHogOptions["fetch"]>; +type PostHogFetchOptions = Parameters<PostHogFetch>[1]; +type PostHogFetchResponse = Awaited<ReturnType<PostHogFetch>>; + +const delivered: PostHogFetchResponse = { status: 200, text: () => Promise.resolve(""), json: () => Promise.resolve({}), }; +function toPostHogResponse( + response: HttpClientResponse.HttpClientResponse, + context: Context.Context<never>, + signal: AbortSignal | undefined, +): PostHogFetchResponse { + const body = () => + Stream.toReadableStreamWith( + response.stream.pipe( + Stream.catchReason("HttpClientError", "EmptyBodyError", () => Stream.empty), + ), + context, + ).pipeThrough(new TransformStream(), { signal }); + return { + status: response.status, + headers: { get: (name) => Option.getOrNull(Headers.get(response.headers, name)) }, + text: () => new Response(body()).text(), + json: () => new Response(body()).json(), + get body() { + return body(); + }, + }; +} + +const sendPosthogRequest = Effect.fnUntraced(function* (url: string, options: PostHogFetchOptions) { + const client = yield* HttpClient.HttpClient; + const context = yield* Effect.context<never>(); + const request = HttpClientRequest.make(options.method)(url).pipe( + HttpClientRequest.setBody( + options.body === undefined ? HttpBody.empty : HttpBody.raw(options.body), + ), + HttpClientRequest.setHeaders(options.headers), + ); + const response = yield* client + .execute(request) + .pipe(Effect.provideService(HttpClient.TracerDisabledWhen, constTrue)); + return response.status >= 400 ? delivered : toPostHogResponse(response, context, options.signal); +}); + // posthog-node has no logger hook: delivery failures hit hardcoded // console.error calls and multi-second retries, so report them as delivered. -export const fireAndForgetFetch: NonNullable<PostHogOptions["fetch"]> = async (url, options) => { - try { - const response = await globalThis.fetch(url, options); - return response.status >= 400 ? delivered : response; - } catch { - return delivered; - } -}; +export function makePosthogFetch(context: Context.Context<HttpClient.HttpClient>): PostHogFetch { + const runExit = Effect.runPromiseExitWith(context); + return (url, options) => + options.signal?.aborted + ? Promise.resolve(delivered) + : runExit(sendPosthogRequest(url, options), { signal: options.signal }).then( + Exit.match({ onSuccess: (response) => response, onFailure: () => delivered }), + ); +} -export const scopedPosthogClient = (apiKey: string, host: string) => - Effect.acquireRelease( +export const scopedPosthogClient = Effect.fnUntraced(function* (apiKey: string, host: string) { + const posthogFetch = makePosthogFetch(yield* Effect.context<HttpClient.HttpClient>()); + const { client } = yield* Effect.acquireRelease( Effect.sync(() => { const shutdown = new AbortController(); const client = new PostHog(apiKey, { @@ -30,7 +82,7 @@ export const scopedPosthogClient = (apiKey: string, host: string) => flushInterval: 0, requestTimeout: EXIT_DELAY_CAP_MS, fetch: (url, options) => - fireAndForgetFetch(url, { + posthogFetch(url, { ...options, signal: options.signal ? AbortSignal.any([options.signal, shutdown.signal]) @@ -48,4 +100,6 @@ export const scopedPosthogClient = (apiKey: string, host: string) => // fetches lets that background drain settle without active requests. Effect.ensuring(Effect.sync(() => shutdown.abort())), ), - ).pipe(Effect.map(({ client }) => client)); + ); + return client; +}); diff --git a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts index 0f43315014..bc41356563 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts @@ -1,106 +1,229 @@ import { describe, expect, it } from "@effect/vitest"; -import { afterEach, vi } from "vitest"; -import { Effect } from "effect"; +import { Deferred, Effect, Layer, Ref } from "effect"; +import { + FetchHttpClient, + HttpBody, + HttpClient, + HttpClientError, + type HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import { PostHog } from "posthog-node"; -import { fireAndForgetFetch, scopedPosthogClient } from "./posthog-client.ts"; +import { makePosthogFetch, scopedPosthogClient } from "./posthog-client.ts"; const BATCH_URL = "https://eu.i.posthog.com/batch/"; -const BATCH_OPTIONS = { method: "POST" as const, headers: {}, body: "{}" }; +const BATCH_OPTIONS = { + method: "POST" as const, + headers: { "Content-Type": "application/json" }, + body: "{}", +}; + +const respondingClient = ( + response: () => Response, + requests: Array<HttpClientRequest.HttpClientRequest> = [], +) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push(request); + return HttpClientResponse.fromWeb(request, response()); + }), + ), + ); -describe("fireAndForgetFetch", () => { - afterEach(() => { - vi.unstubAllGlobals(); +describe("makePosthogFetch", () => { + it.live("sends the SDK request as given and passes successful responses through", () => { + const requests: Array<HttpClientRequest.HttpClientRequest> = []; + return Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context<HttpClient.HttpClient>()); + const response = yield* Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); + + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe(`{"status":1}`); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe(BATCH_URL); + expect(requests[0]?.headers).toEqual({ "content-type": "application/json" }); + expect(requests[0]?.body).toEqual(HttpBody.raw(BATCH_OPTIONS.body)); + }).pipe( + Effect.provide( + respondingClient(() => new Response(`{"status":1}`, { status: 200 }), requests), + ), + ); }); - it("passes successful responses through untouched", async () => { - vi.stubGlobal("fetch", async () => new Response(`{"status":1}`, { status: 200 })); - - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); - - expect(response.status).toBe(200); - expect(await response.text()).toBe(`{"status":1}`); + it.live("sends nothing when the signal is already aborted", () => { + const requests: Array<HttpClientRequest.HttpClientRequest> = []; + return Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context<HttpClient.HttpClient>()); + const response = yield* Effect.promise(() => + posthogFetch(BATCH_URL, { ...BATCH_OPTIONS, signal: AbortSignal.abort() }), + ); + + expect(response.status).toBe(200); + expect(requests).toEqual([]); + }).pipe(Effect.provide(respondingClient(() => new Response("{}"), requests))); }); - it("reports success when the network is unreachable", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("connect ECONNREFUSED"); - }); - - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); - - expect(response.status).toBe(200); - expect(await response.text()).toBe(""); - expect(await response.json()).toEqual({}); + it.live("rejects stalled body reads with the abort reason and releases their transport", () => { + const transportAborts: Array<Deferred.Deferred<void>> = []; + return Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context<HttpClient.HttpClient>()); + const { signal, reads } = yield* Effect.scoped( + Effect.gen(function* () { + const signal = yield* Effect.abortSignal; + const send = () => + Effect.promise(() => posthogFetch(BATCH_URL, { ...BATCH_OPTIONS, signal })); + const reads = [ + (yield* send()).text(), + (yield* send()).json(), + Promise.resolve((yield* send()).body?.getReader().read()), + ]; + return { signal, reads }; + }), + ); + + const outcomes = yield* Effect.promise(() => Promise.allSettled(reads)); + expect(outcomes).toEqual( + Array.from({ length: 3 }, () => ({ status: "rejected", reason: signal.reason })), + ); + yield* Effect.forEach(transportAborts, Deferred.await).pipe(Effect.timeout("2 seconds")); + }).pipe( + Effect.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, _url, signal) => + Effect.gen(function* () { + const aborted = yield* Deferred.make<void>(); + signal.addEventListener("abort", () => Deferred.doneUnsafe(aborted, Effect.void), { + once: true, + }); + transportAborts.push(aborted); + return HttpClientResponse.fromWeb(request, new Response(new ReadableStream())); + }), + ), + ), + ), + ); }); - it("reports success on error responses so the SDK never retries or logs", async () => { - vi.stubGlobal( - "fetch", - async () => new Response("Proxy Authentication Required", { status: 407 }), - ); + it.live("reads a null body the way native fetch does", () => + Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context<HttpClient.HttpClient>()); + const send = () => Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); + const reads = [(yield* send()).text(), (yield* send()).json()]; + const nativeReads = [ + new Response(null, { status: 204 }).text(), + new Response(null, { status: 204 }).json(), + ]; + + const [text, json] = yield* Effect.promise(() => Promise.allSettled(reads)); + const [nativeText, nativeJson] = yield* Effect.promise(() => Promise.allSettled(nativeReads)); + expect(text).toEqual(nativeText); + expect(nativeText).toEqual({ status: "fulfilled", value: "" }); + expect(json).toEqual({ status: "rejected", reason: expect.any(SyntaxError) }); + expect(nativeJson).toEqual({ status: "rejected", reason: expect.any(SyntaxError) }); + }).pipe(Effect.provide(respondingClient(() => new Response(null, { status: 204 })))), + ); - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); + it.live("reports success when the network is unreachable", () => + Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context<HttpClient.HttpClient>()); + const response = yield* Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); + + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe(""); + expect(yield* Effect.promise(() => response.json())).toEqual({}); + }).pipe( + Effect.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("connect ECONNREFUSED"), + }), + }), + ), + ), + ), + ), + ), + ); - expect(response.status).toBe(200); - expect(await response.text()).toBe(""); - }); + it.live("reports success on error responses so the SDK never retries or logs", () => + Effect.gen(function* () { + const posthogFetch = makePosthogFetch(yield* Effect.context<HttpClient.HttpClient>()); + const response = yield* Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); + + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.text())).toBe(""); + }).pipe( + Effect.provide( + respondingClient(() => new Response("Proxy Authentication Required", { status: 407 })), + ), + ), + ); }); describe("scopedPosthogClient", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - it.live("captures and shuts down cleanly against an unreachable host", () => Effect.gen(function* () { const client = yield* scopedPosthogClient("phc_test", "http://127.0.0.1:9"); expect(client).toBeInstanceOf(PostHog); client.capture({ event: "verify_event", distinctId: "device-1" }); - }).pipe(Effect.scoped), + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)), ); it.live( "bounds the whole shutdown when a request is in flight and another event is queued", () => Effect.gen(function* () { - let requestStarted = () => {}; - const firstRequestInFlight = new Promise<void>((resolve) => { - requestStarted = resolve; - }); - let activeRequests = 0; - vi.stubGlobal( - "fetch", - (_url: string, options: { signal?: AbortSignal }) => - new Promise<Response>((_resolve, reject) => { - activeRequests += 1; - requestStarted(); - const abort = () => { - activeRequests -= 1; - reject(new DOMException("The operation was aborted.", "AbortError")); - }; - if (options.signal?.aborted) { - abort(); - return; - } - options.signal?.addEventListener("abort", abort); - }), + const firstRequestInFlight = yield* Deferred.make<void>(); + const drainSettled = yield* Deferred.make<void>(); + const activeRequests = yield* Ref.make(0); + const hangingClient = HttpClient.make(() => + Effect.acquireUseRelease( + Ref.update(activeRequests, (count) => count + 1), + () => + Deferred.succeed(firstRequestInFlight, undefined).pipe( + Effect.andThen((first) => + first ? Effect.void : Deferred.succeed(drainSettled, undefined), + ), + Effect.andThen(Effect.never), + ), + () => Ref.update(activeRequests, (count) => count - 1), + ), ); const startedAt = performance.now(); yield* Effect.gen(function* () { const client = yield* scopedPosthogClient("phc_test", "https://blackhole.invalid"); + client.on("flush", (messages: ReadonlyArray<{ readonly event: string }>) => { + if (messages.some(({ event }) => event === "second_event")) { + Deferred.doneUnsafe(drainSettled, Effect.void); + } + }); client.capture({ event: "first_event", distinctId: "device-1" }); - yield* Effect.promise(() => firstRequestInFlight); + yield* Deferred.await(firstRequestInFlight); client.capture({ event: "second_event", distinctId: "device-1" }); - }).pipe(Effect.scoped); + }).pipe(Effect.scoped, Effect.provideService(HttpClient.HttpClient, hangingClient)); expect(performance.now() - startedAt).toBeLessThan(3_000); // The SDK's drain keeps running past the shutdown deadline; without // cancellation it starts the queued request AFTER scope release and // keeps the process alive for that request's own timeout. - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); - expect(activeRequests).toBe(0); + yield* Deferred.await(drainSettled).pipe( + Effect.timeoutOrElse({ + duration: "6 seconds", + orElse: () => Effect.die(new Error("SDK drain never settled after shutdown")), + }), + ); + expect(yield* Ref.get(activeRequests)).toBe(0); }), 10_000, ); diff --git a/apps/cli/src/shared/telemetry/posthog-config.ts b/apps/cli/src/shared/telemetry/posthog-config.ts index a452eca38c..61ffe34edb 100644 --- a/apps/cli/src/shared/telemetry/posthog-config.ts +++ b/apps/cli/src/shared/telemetry/posthog-config.ts @@ -1,6 +1,9 @@ // PostHog connection config shared by the analytics layers. // Release builds inject the shipped host/key via apps/cli/scripts/build.ts. -import { Config, type ConfigProvider, Effect, Option } from "effect"; +import { Config, ConfigProvider, Effect, Option } from "effect"; + +declare const SUPABASE_CLI_POSTHOG_HOST: string | undefined; +declare const SUPABASE_CLI_POSTHOG_KEY: string | undefined; const DEFAULT_HOST = "https://eu.i.posthog.com"; @@ -9,40 +12,50 @@ export interface PosthogConfig { readonly key: Option.Option<string>; } -function nonEmptyString(value: string | undefined): Option.Option<string> { - return value === undefined || value === "" ? Option.none() : Option.some(value); -} - -function shippedPosthogHost(): Option.Option<string> { - return nonEmptyString(process.env.SUPABASE_CLI_POSTHOG_HOST); +function nonEmptyString(value: string): Option.Option<string> { + return value === "" ? Option.none() : Option.some(value); } -function shippedPosthogKey(): Option.Option<string> { - return nonEmptyString(process.env.SUPABASE_CLI_POSTHOG_KEY); +function readNonEmptyString( + provider: ConfigProvider.ConfigProvider, + name: string, +): Effect.Effect<Option.Option<string>, Config.ConfigError> { + return Config.option(Config.string(name)) + .parse(provider) + .pipe(Effect.map(Option.flatMap(nonEmptyString))); } -function resolvePosthogConfigValues( - host: Option.Option<string>, - key: Option.Option<string>, -): PosthogConfig { - return { - host: Option.getOrElse(Option.orElse(host, shippedPosthogHost), () => DEFAULT_HOST), - key: Option.orElse(key, shippedPosthogKey), - }; +function readShippedValue( + injected: string | undefined, + name: string, +): Effect.Effect<Option.Option<string>, Config.ConfigError> { + return injected === undefined + ? Effect.suspend(() => readNonEmptyString(ConfigProvider.fromEnv(), name)) + : Effect.succeed(nonEmptyString(injected)); } export function resolvePosthogConfig( provider: ConfigProvider.ConfigProvider, ): Effect.Effect<PosthogConfig, Config.ConfigError> { return Effect.gen(function* () { - const host = Option.filter( - yield* Config.option(Config.string("SUPABASE_TELEMETRY_POSTHOG_HOST")).parse(provider), - (value) => value.length > 0, - ); - const key = Option.filter( - yield* Config.option(Config.string("SUPABASE_TELEMETRY_POSTHOG_KEY")).parse(provider), - (value) => value.length > 0, - ); - return resolvePosthogConfigValues(host, key); + const host = yield* readNonEmptyString(provider, "SUPABASE_TELEMETRY_POSTHOG_HOST"); + const key = yield* readNonEmptyString(provider, "SUPABASE_TELEMETRY_POSTHOG_KEY"); + return { + host: Option.getOrElse( + Option.isSome(host) + ? host + : yield* readShippedValue( + typeof SUPABASE_CLI_POSTHOG_HOST === "string" ? SUPABASE_CLI_POSTHOG_HOST : undefined, + "SUPABASE_CLI_POSTHOG_HOST", + ), + () => DEFAULT_HOST, + ), + key: Option.isSome(key) + ? key + : yield* readShippedValue( + typeof SUPABASE_CLI_POSTHOG_KEY === "string" ? SUPABASE_CLI_POSTHOG_KEY : undefined, + "SUPABASE_CLI_POSTHOG_KEY", + ), + }; }); } diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index 66cfe5ea36..1715c83bf1 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Config, ConfigProvider, Effect, Layer, PlatformError } from "effect"; +import { + Config, + ConfigProvider, + Effect, + FileSystem, + Layer, + Path, + PlatformError, + Schema, +} from "effect"; import { cliSettingsLayer } from "../config/cli-settings.layer.ts"; import { TelemetryRuntime } from "./runtime.service.ts"; import { telemetryRuntimeLayer } from "./runtime.layer.ts"; @@ -13,10 +19,17 @@ import { mockTty, processEnvLayer, } from "../../../tests/helpers/mocks.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-runtime-test-")); -} +const tempRoot = useTempWorkdir("supabase-runtime-test-"); + +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const telemetryConfigPath = (homeDir: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(homeDir, "telemetry.json"); + }); function buildLayer(opts: { homeDir: string; @@ -40,6 +53,7 @@ function buildLayer(opts: { Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(providerLayer), + Layer.provide(BunServices.layer), ); const telemetryLayer = telemetryRuntimeLayer.pipe( Layer.provide(configLayer), @@ -53,140 +67,140 @@ function buildLayer(opts: { } describe("telemetryRuntimeLayer", () => { - it.live("does not create telemetry.json when telemetry is disabled by env on first run", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("denied"); - expect(runtime.isFirstRun).toBe(false); - expect(existsSync(configPath)).toBe(false); - }).pipe( - Effect.provide( - buildLayer({ - homeDir, - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - }), - ), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("does not create telemetry.json when telemetry is disabled by env on first run", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.isFirstRun).toBe(false); + expect(yield* fs.exists(configPath)).toBe(false); + }).pipe( + Effect.provide( + buildLayer({ + homeDir, + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + }), + ), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("marks the actual first granted invocation as first run", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("marks the actual first granted invocation as first run", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* fs.exists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("treats a malformed telemetry.json as a fresh first run instead of crashing", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync(configPath, ""); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("treats a malformed telemetry.json as a fresh first run instead of crashing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + yield* fs.writeFileString(configPath, ""); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* fs.exists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("silently ignores structurally invalid telemetry.json instead of crashing", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync(configPath, JSON.stringify({ consent: "granted" })); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("silently ignores structurally invalid telemetry.json instead of crashing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + yield* fs.writeFileString(configPath, yield* encodeJson({ consent: "granted" })); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* fs.exists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.live("honors a legacy disabled telemetry state", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync( - configPath, - JSON.stringify({ - enabled: false, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("denied"); - expect(runtime.deviceId).toBe("legacy-device"); - expect(runtime.sessionId).toBe("legacy-session"); - expect(runtime.isFirstRun).toBe(false); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir, stdoutIsTty: true })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + it.effect("honors a legacy disabled telemetry state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + yield* fs.writeFileString( + configPath, + yield* encodeJson({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.deviceId).toBe("legacy-device"); + expect(runtime.sessionId).toBe("legacy-session"); + expect(runtime.isFirstRun).toBe(false); + expect(yield* fs.exists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir, stdoutIsTty: true }))); + }).pipe(Effect.provide(BunServices.layer)), + ); // `consent` is read from disk once at layer-construction time and does not reflect a later // on-disk write, so a command that rewrites telemetry.json mid-run doesn't retroactively // change what that invocation already captured. - it.live("captures consent once; a later on-disk write does not change it", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync( - configPath, - JSON.stringify({ - enabled: true, - device_id: "device-123", - session_id: "session-123", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - - // Simulates `disable` rewriting telemetry.json mid-command, after this layer already - // resolved `consent`. - yield* Effect.sync(() => - writeFileSync( + it.effect("captures consent once; a later on-disk write does not change it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const homeDir = tempRoot.current; + const configPath = yield* telemetryConfigPath(homeDir); + yield* fs.writeFileString( + configPath, + yield* encodeJson({ + enabled: true, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + + yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + + // Simulates `disable` rewriting telemetry.json mid-command, after this layer already + // resolved `consent`. + yield* fs.writeFileString( configPath, - JSON.stringify({ + yield* encodeJson({ enabled: false, device_id: "device-123", session_id: "session-123", session_last_active: "2026-04-01T12:00:00Z", schema_version: 1, }), - ), - ); + ); - expect(runtime.consent).toBe("granted"); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); - }); + expect(runtime.consent).toBe("granted"); + }).pipe(Effect.provide(buildLayer({ homeDir }))); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts b/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts index a3d9ea9b4c..b6eaa097db 100644 --- a/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts @@ -1,17 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { + Clock, ConfigProvider, Context, Deferred, @@ -22,27 +12,58 @@ import { Fiber, Layer, Option, + Path, PlatformError, + Schema, Sink, Stdio, Tracer, } from "effect"; import { TestClock } from "effect/testing"; import { CliSettings } from "../config/cli-settings.service.ts"; -import type { TelemetryConfig } from "./types.ts"; +import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; +import { useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; import { mockCliProjectContext, mockRuntimeInfo, mockTty } from "../../../tests/helpers/mocks.ts"; import { tracingLayer } from "./tracing.layer.ts"; const fsLayer = BunServices.layer; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-tracing-test-")); -} +const tempRoot = useTempWorkdir("supabase-tracing-test-"); -function writeConfig(dir: string, config: TelemetryConfig): void { - mkdirSync(dir, { recursive: true }); - writeFileSync(path.join(dir, "telemetry.json"), JSON.stringify(config)); -} +const TelemetryConfigJson = Schema.fromJsonString(TelemetryConfigSchema); + +const writeConfig = (dir: string, config: TelemetryConfig) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "telemetry.json"), + yield* Schema.encodeEffect(TelemetryConfigJson)(config), + ); + }); + +const readConfig = (configPath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* Schema.decodeEffect(TelemetryConfigJson)(yield* fs.readFileString(configPath)); + }); + +const hasNdjsonTrace = (tracesDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(tracesDir))) return false; + return (yield* fs.readDirectory(tracesDir)).some((file) => file.endsWith(".ndjson")); + }); + +const readTraceFile = (tracesDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const traceFile = (yield* fs.readDirectory(tracesDir)).find((file) => file.endsWith(".ndjson")); + expect(traceFile).toBeDefined(); + return yield* fs.readFileString(path.join(tracesDir, traceFile!)); + }); function buildLayer(opts: { home: string; @@ -66,23 +87,26 @@ function buildLayer(opts: { const value = opts.env?.[key]; return value === undefined ? Option.none<string>() : Option.some(value); }; - const settingsLayer = Layer.succeed( + const settingsLayer = Layer.effect( CliSettings, - CliSettings.of({ - apiUrl: "https://api.supabase.com", - dashboardUrl: "https://supabase.com/dashboard", - projectHost: "supabase.co", - telemetryPosthogHost: "https://eu.i.posthog.com", - telemetryPosthogKey: Option.none(), - accessToken: Option.none(), - noKeyring: Option.none(), - supabaseHome: path.join(opts.home, ".supabase"), - debug: setting("SUPABASE_DEBUG"), - telemetryDebug: setting("SUPABASE_TELEMETRY_DEBUG"), - telemetryDisabled: setting("SUPABASE_TELEMETRY_DISABLED"), - doNotTrack: Option.none(), + Effect.gen(function* () { + const path = yield* Path.Path; + return CliSettings.of({ + apiUrl: "https://api.supabase.com", + dashboardUrl: "https://supabase.com/dashboard", + projectHost: "supabase.co", + telemetryPosthogHost: "https://eu.i.posthog.com", + telemetryPosthogKey: Option.none(), + accessToken: Option.none(), + noKeyring: Option.none(), + supabaseHome: path.join(opts.home, ".supabase"), + debug: setting("SUPABASE_DEBUG"), + telemetryDebug: setting("SUPABASE_TELEMETRY_DEBUG"), + telemetryDisabled: setting("SUPABASE_TELEMETRY_DISABLED"), + doNotTrack: Option.none(), + }); }), - ); + ).pipe(Layer.provide(fsLayer)); return Layer.mergeAll( fsLayer, opts.fileSystem ?? Layer.empty, @@ -152,122 +176,110 @@ function capturingStdio(chunks: Array<string>): Layer.Layer<Stdio.Stdio> { }); } -function makeSpanOptions( +const makeSpanOptions = ( overrides: Partial<{ name: string; sampled: boolean; parent: Option.Option<Tracer.AnySpan>; }> = {}, -) { - return { +) => + Effect.map(Clock.currentTimeMillis, (now) => ({ name: overrides.name ?? "test-span", parent: overrides.parent ?? Option.none(), annotations: Context.empty(), links: [] as Tracer.SpanLink[], - startTime: BigInt(Date.now()) * 1_000_000n, + startTime: BigInt(now) * 1_000_000n, kind: "internal" as Tracer.SpanKind, root: false, sampled: overrides.sampled ?? true, - }; -} + })); describe("tracingLayer – layer construction & first-run", () => { it.live("first-run TTY: creates telemetry.json with consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); + const home = tempRoot.current; return Effect.gen(function* () { - yield* Effect.void; - }).pipe( - Effect.provide(buildTracingLayer({ home, stdoutIsTty: true })), - Effect.ensuring( - Effect.sync(() => { - const configPath = path.join(configDir, "telemetry.json"); - expect(existsSync(configPath)).toBe(true); - const config: TelemetryConfig = JSON.parse(readFileSync(configPath, "utf8")); - expect(config.consent).toBe("granted"); - expect(typeof config.device_id).toBe("string"); - expect(config.device_id.length).toBeGreaterThan(0); - expect(typeof config.session_id).toBe("string"); - expect(config.session_id.length).toBeGreaterThan(0); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + yield* Effect.void.pipe(Effect.provide(buildTracingLayer({ home, stdoutIsTty: true }))); + + const configPath = path.join(configDir, "telemetry.json"); + expect(yield* fs.exists(configPath)).toBe(true); + const config = yield* readConfig(configPath); + expect(config.consent).toBe("granted"); + expect(typeof config.device_id).toBe("string"); + expect(config.device_id.length).toBeGreaterThan(0); + expect(typeof config.session_id).toBe("string"); + expect(config.session_id.length).toBeGreaterThan(0); + }).pipe(Effect.provide(fsLayer)); }); it.live("first-run non-TTY: creates telemetry.json with consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); + const home = tempRoot.current; return Effect.gen(function* () { - yield* Effect.void; - }).pipe( - Effect.provide(buildTracingLayer({ home, stdoutIsTty: false })), - Effect.ensuring( - Effect.sync(() => { - const configPath = path.join(configDir, "telemetry.json"); - expect(existsSync(configPath)).toBe(true); - const config: TelemetryConfig = JSON.parse(readFileSync(configPath, "utf8")); - expect(config.consent).toBe("granted"); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + yield* Effect.void.pipe(Effect.provide(buildTracingLayer({ home, stdoutIsTty: false }))); + + const configPath = path.join(configDir, "telemetry.json"); + expect(yield* fs.exists(configPath)).toBe(true); + const config = yield* readConfig(configPath); + expect(config.consent).toBe("granted"); + }).pipe(Effect.provide(fsLayer)); }); it.live("existing config with consent=granted: layer builds and tracer is usable", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - writeConfig(configDir, { - consent: "granted", - device_id: "existing-device", - session_id: "existing-session", - session_last_active: Date.now(), - }); + const home = tempRoot.current; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - expect(span).toBeDefined(); - expect(span.name).toBe("test-span"); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); - }); - - it.live( - "SUPABASE_TELEMETRY_DISABLED=1 overrides consent=granted: no NDJSON export on span end", - () => { - const home = makeTempDir(); + const path = yield* Path.Path; const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); - writeConfig(configDir, { + yield* writeConfig(configDir, { consent: "granted", device_id: "existing-device", session_id: "existing-session", - session_last_active: Date.now(), + session_last_active: yield* Clock.currentTimeMillis, }); - return Effect.gen(function* () { + yield* Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const span = tracer.span(yield* makeSpanOptions()); + expect(span).toBeDefined(); + expect(span.name).toBe("test-span"); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + }).pipe(Effect.provide(fsLayer)); + }); + + it.live( + "SUPABASE_TELEMETRY_DISABLED=1 overrides consent=granted: no NDJSON export on span end", + () => { + const home = tempRoot.current; + return Effect.gen(function* () { + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + const tracesDir = path.join(configDir, "traces"); + yield* writeConfig(configDir, { + consent: "granted", + device_id: "existing-device", + session_id: "existing-session", + session_last_active: yield* Clock.currentTimeMillis, + }); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe( + Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), + ); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(false); + }).pipe(Effect.provide(fsLayer)); }, ); }); describe("tracingLayer – span behaviour", () => { it.live("waits for a blocked exporter before closing its scope", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const started = yield* Deferred.make<void>(); const release = yield* Deferred.make<void>(); @@ -275,7 +287,7 @@ describe("tracingLayer – span behaviour", () => { const run = Effect.scoped( Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(1_000_000_000n, Exit.void); + tracer.span(yield* makeSpanOptions()).end(1_000_000_000n, Exit.void); }).pipe( Effect.provide( buildTracingLayer({ @@ -301,18 +313,18 @@ describe("tracingLayer – span behaviour", () => { (entry === "finished" && order[index - 1] === "started"), ), ).toBe(true); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true })))); + }); }); it.live("drains a blocked exporter when the scoped program fails", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const started = yield* Deferred.make<void>(); const release = yield* Deferred.make<void>(); const order: Array<string> = []; const run = Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(1_000_000_000n, Exit.void); + tracer.span(yield* makeSpanOptions()).end(1_000_000_000n, Exit.void); return yield* Effect.fail("injected program failure"); }).pipe( Effect.provide( @@ -330,11 +342,11 @@ describe("tracingLayer – span behaviour", () => { const exit = yield* Fiber.await(fiber); expect(Exit.isFailure(exit)).toBe(true); expect(order.at(-1)).toBe("finished"); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true })))); + }); }); it.live("drains a blocked exporter when the scoped program is interrupted", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const started = yield* Deferred.make<void>(); const release = yield* Deferred.make<void>(); @@ -344,8 +356,8 @@ describe("tracingLayer – span behaviour", () => { const run = Effect.scoped( Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(1_000_000_000n, Exit.void); - yield* Effect.never.pipe( + tracer.span(yield* makeSpanOptions()).end(1_000_000_000n, Exit.void); + return yield* Effect.never.pipe( Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), ); }).pipe( @@ -370,11 +382,11 @@ describe("tracingLayer – span behaviour", () => { const exit = yield* Fiber.await(fiber); expect(Exit.isFailure(exit)).toBe(true); expect(order.at(-1)).toBe("finished"); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true })))); + }); }); it.effect("bounds shutdown when an exporter never completes", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const started = yield* Deferred.make<void>(); const release = yield* Deferred.make<void>(); @@ -382,7 +394,7 @@ describe("tracingLayer – span behaviour", () => { const run = Effect.scoped( Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(1_000_000_000n, Exit.void); + tracer.span(yield* makeSpanOptions()).end(1_000_000_000n, Exit.void); }).pipe( Effect.provide( buildTracingLayer({ @@ -401,45 +413,43 @@ describe("tracingLayer – span behaviour", () => { expect(Exit.isSuccess(exit)).toBe(true); expect(order).toEqual(["started"]); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true })))); + }); }); it.live("keeps exporting after debug formatting fails", () => { - const home = makeTempDir(); - const tracesDir = path.join(home, ".supabase", "traces"); + const home = tempRoot.current; const stderrChunks: string[] = []; const cyclic: Record<string, unknown> = {}; cyclic.self = cyclic; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const badSpan = tracer.span(makeSpanOptions({ name: "bad-debug-span" })); - badSpan.attribute("cyclic", cyclic); - badSpan.end(1_000_000_000n, Exit.void); - tracer.span(makeSpanOptions({ name: "good-debug-span" })).end(1_000_000_000n, Exit.void); - }).pipe( - Effect.provide( - buildTracingLayer({ - home, - env: { SUPABASE_DEBUG: "1" }, - stdio: capturingStdio(stderrChunks), - }), - ), - Effect.ensuring( - Effect.sync(() => { - const traceFile = readdirSync(tracesDir).find((file) => file.endsWith(".ndjson")); - expect(traceFile).toBeDefined(); - const traces = readFileSync(path.join(tracesDir, traceFile!), "utf8"); - expect(traces).toContain("good-debug-span"); - expect(stderrChunks.join(" ")).toContain("good-debug-span"); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const tracesDir = path.join(home, ".supabase", "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const badSpan = tracer.span(yield* makeSpanOptions({ name: "bad-debug-span" })); + badSpan.attribute("cyclic", cyclic); + badSpan.end(1_000_000_000n, Exit.void); + tracer + .span(yield* makeSpanOptions({ name: "good-debug-span" })) + .end(1_000_000_000n, Exit.void); + }).pipe( + Effect.provide( + buildTracingLayer({ + home, + env: { SUPABASE_DEBUG: "1" }, + stdio: capturingStdio(stderrChunks), + }), + ), + ); + + const traces = yield* readTraceFile(tracesDir); + expect(traces).toContain("good-debug-span"); + expect(stderrChunks.join(" ")).toContain("good-debug-span"); + }).pipe(Effect.provide(fsLayer)); }); it.live("continues file export when debug output fails", () => { - const home = makeTempDir(); - const tracesDir = path.join(home, ".supabase", "traces"); + const home = tempRoot.current; const failure = PlatformError.systemError({ _tag: "Unknown", module: "stderr", @@ -448,30 +458,32 @@ describe("tracingLayer – span behaviour", () => { pathOrDescriptor: "stderr", }); return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions()).end(BigInt(Date.now()) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide( - buildTracingLayer({ - home, - env: { SUPABASE_TELEMETRY_DEBUG: "1" }, - stdio: Stdio.layerTest({ stderr: () => Sink.fail(failure) }), - }), - ), - Effect.ensuring( - Effect.sync(() => { - expect(readdirSync(tracesDir).some((file) => file.endsWith(".ndjson"))).toBe(true); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const tracesDir = path.join(home, ".supabase", "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + tracer + .span(yield* makeSpanOptions()) + .end(BigInt(yield* Clock.currentTimeMillis) * 1_000_000n, Exit.void); + }).pipe( + Effect.provide( + buildTracingLayer({ + home, + env: { SUPABASE_TELEMETRY_DEBUG: "1" }, + stdio: Stdio.layerTest({ stderr: () => Sink.fail(failure) }), + }), + ), + ); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(true); + }).pipe(Effect.provide(fsLayer)); }); it.live("span creation attaches global attributes", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); + const span = tracer.span(yield* makeSpanOptions()); expect(span.attributes.get("schema_version")).toBe(1); expect(typeof span.attributes.get("device_id")).toBe("string"); expect(typeof span.attributes.get("session_id")).toBe("string"); @@ -481,18 +493,15 @@ describe("tracingLayer – span behaviour", () => { expect(span.attributes.get("os")).toBe("linux"); expect(span.attributes.get("arch")).toBe("x64"); expect(span.attributes.get("cli_version")).toBe("0.0.0-dev"); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home }))); }); it.live("span end exports to debug console when SUPABASE_DEBUG=1", () => { - const home = makeTempDir(); + const home = tempRoot.current; const stderrChunks: string[] = []; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions({ name: "debug-span" })).end(1_000_000_000n, Exit.void); + tracer.span(yield* makeSpanOptions({ name: "debug-span" })).end(1_000_000_000n, Exit.void); }).pipe( Effect.provide( buildTracingLayer({ @@ -504,18 +513,19 @@ describe("tracingLayer – span behaviour", () => { Effect.ensuring( Effect.sync(() => { expect(stderrChunks.join(" ")).toContain("debug-span"); - rmSync(home, { recursive: true, force: true }); }), ), ); }); it.live("span end exports to debug console when SUPABASE_TELEMETRY_DEBUG=1", () => { - const home = makeTempDir(); + const home = tempRoot.current; const stderrChunks: string[] = []; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - tracer.span(makeSpanOptions({ name: "telemetry-debug-span" })).end(1_000_000_000n, Exit.void); + tracer + .span(yield* makeSpanOptions({ name: "telemetry-debug-span" })) + .end(1_000_000_000n, Exit.void); }).pipe( Effect.provide( buildTracingLayer({ @@ -527,154 +537,122 @@ describe("tracingLayer – span behaviour", () => { Effect.ensuring( Effect.sync(() => { expect(stderrChunks.join(" ")).toContain("telemetry-debug-span"); - rmSync(home, { recursive: true, force: true }); }), ), ); }); it.live("span end exports to NDJSON file when consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); + const home = tempRoot.current; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(true); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + const tracesDir = path.join(configDir, "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(true); + }).pipe(Effect.provide(fsLayer)); }); it.live("does not write API keys to trace files", () => { - const home = makeTempDir(); - const tracesDir = path.join(home, ".supabase", "traces"); + const home = tempRoot.current; const secretKey = `sb_secret_${"a".repeat(40)}`; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.attribute("http.request.header.apikey", secretKey); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - try { - const traceFile = readdirSync(tracesDir).find((file) => file.endsWith(".ndjson")); - expect(traceFile).toBeDefined(); - const trace = readFileSync(path.join(tracesDir, traceFile!), "utf8"); - expect(trace).not.toContain(secretKey); - expect(trace).not.toContain("http.request.header.apikey"); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }), - ), - ); + const path = yield* Path.Path; + const tracesDir = path.join(home, ".supabase", "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.attribute("http.request.header.apikey", secretKey); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + + const trace = yield* readTraceFile(tracesDir); + expect(trace).not.toContain(secretKey); + expect(trace).not.toContain("http.request.header.apikey"); + }).pipe(Effect.provide(fsLayer)); }); it.live("span end does NOT export to NDJSON when SUPABASE_TELEMETRY_DISABLED=1", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); + const home = tempRoot.current; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + const tracesDir = path.join(configDir, "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe( + Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), + ); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(false); + }).pipe(Effect.provide(fsLayer)); }); it.live("span end skips unsampled spans – no NDJSON export", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); + const home = tempRoot.current; return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions({ sampled: false })); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), - ); + const path = yield* Path.Path; + const configDir = path.join(home, ".supabase"); + const tracesDir = path.join(configDir, "traces"); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions({ sampled: false })); + span.end(BigInt((yield* Clock.currentTimeMillis) + 100) * 1_000_000n, Exit.void); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + + expect(yield* hasNdjsonTrace(tracesDir)).toBe(false); + }).pipe(Effect.provide(fsLayer)); }); it.live("CI detection via CI env var sets is_ci=true on span", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); + const span = tracer.span(yield* makeSpanOptions()); expect(span.attributes.get("is_ci")).toBe(true); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { CI: "true" } })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home, env: { CI: "true" } }))); }); }); describe("ExportableSpan unit tests", () => { it.live("child span inherits traceId from parent span", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const parent = tracer.span(makeSpanOptions({ name: "parent" })); - const child = tracer.span(makeSpanOptions({ name: "child", parent: Option.some(parent) })); + const parent = tracer.span(yield* makeSpanOptions({ name: "parent" })); + const child = tracer.span( + yield* makeSpanOptions({ name: "child", parent: Option.some(parent) }), + ); expect(child.traceId).toBe(parent.traceId); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home }))); }); it.live("event() and addLinks() are no-ops that do not throw", () => { - const home = makeTempDir(); + const home = tempRoot.current; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.event("test-event", BigInt(Date.now()) * 1_000_000n, { key: "val" }); + const span = tracer.span(yield* makeSpanOptions()); + span.event("test-event", BigInt(yield* Clock.currentTimeMillis) * 1_000_000n, { key: "val" }); span.addLinks([]); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home }))); }); it.live("span without parent generates 32-char hex traceId and 16-char hex spanId", () => { - const home = makeTempDir(); + const home = tempRoot.current; const HEX_32 = /^[0-9a-f]{32}$/; const HEX_16 = /^[0-9a-f]{16}$/; return Effect.gen(function* () { const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); + const span = tracer.span(yield* makeSpanOptions()); expect(span.traceId).toMatch(HEX_32); expect(span.spanId).toMatch(HEX_16); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(buildTracingLayer({ home }))); }); }); diff --git a/apps/cli/src/shared/telemetry/types.ts b/apps/cli/src/shared/telemetry/types.ts index 44d1823536..e96f02e5da 100644 --- a/apps/cli/src/shared/telemetry/types.ts +++ b/apps/cli/src/shared/telemetry/types.ts @@ -1,4 +1,10 @@ -import { Schema } from "effect"; +import { Predicate, Schema } from "effect"; + +/** + * Any JSON number, including an overflowed value decoded as ±Infinity, so one out-of-range field + * does not fail this schema's decode and drop the consent it reads. + */ +export const PersistedNumberSchema = Schema.declare(Predicate.isNumber); const ConsentStateSchema = Schema.Literals(["granted", "denied"] as const); export type ConsentState = Schema.Schema.Type<typeof ConsentStateSchema>; @@ -7,7 +13,7 @@ export const TelemetryConfigSchema = Schema.Struct({ consent: ConsentStateSchema, device_id: Schema.String, session_id: Schema.String, - session_last_active: Schema.Number, + session_last_active: PersistedNumberSchema, distinct_id: Schema.optionalKey(Schema.String), }); export type TelemetryConfig = Schema.Schema.Type<typeof TelemetryConfigSchema>; diff --git a/apps/cli/src/telemetry/analytics.layer.integration.test.ts b/apps/cli/src/telemetry/analytics.layer.integration.test.ts index 4c2c3e6fc1..75e9ae1d0d 100644 --- a/apps/cli/src/telemetry/analytics.layer.integration.test.ts +++ b/apps/cli/src/telemetry/analytics.layer.integration.test.ts @@ -1,7 +1,7 @@ import { BunServices } from "@effect/platform-bun"; -import { afterEach, describe, expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import { ConfigProvider, Effect, Layer, Option } from "effect"; -import { vi } from "vitest"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; import { mockRuntimeInfo, mockTty } from "../../tests/helpers/mocks.ts"; @@ -12,14 +12,14 @@ import { analyticsLayer } from "./analytics.layer.ts"; const tempRoot = useTempWorkdir("supabase-analytics-destination-"); describe("analytics destination", () => { - afterEach(() => vi.unstubAllGlobals()); - it.live("uses ambient telemetry configuration despite project destination settings", () => { const destinations: string[] = []; - vi.stubGlobal("fetch", async (url: string) => { - destinations.push(String(url)); - return Response.json({ status: 1 }); - }); + const recordingClient = HttpClient.make((request, url) => + Effect.sync(() => { + destinations.push(url.toString()); + return HttpClientResponse.fromWeb(request, Response.json({ status: 1 })); + }), + ); const settings = Layer.succeed(CliSettings, { apiUrl: "https://api.supabase.com", dashboardUrl: "https://supabase.com/dashboard", @@ -39,6 +39,7 @@ describe("analytics destination", () => { Layer.provide(mockRuntimeInfo({ homeDir: tempRoot.current })), Layer.provide(mockTty({ stdoutIsTty: false })), Layer.provide(BunServices.layer), + Layer.provide(Layer.succeed(HttpClient.HttpClient, recordingClient)), ); return Effect.gen(function* () { yield* Analytics.use((analytics) => analytics.capture("destination_test")).pipe( diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index a8b950b96a..6e8b8598a2 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunCrypto } from "@effect/platform-bun"; import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; import { Flag } from "effect/unstable/cli"; import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts"; @@ -99,7 +100,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -130,7 +131,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list", "--output", "yaml"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties.output_format).toBe("yaml"); @@ -159,7 +160,7 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties.output_format).toBe("json"); @@ -183,7 +184,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["secrets", "list", "--project-ref", "abcdefghijklmnopqrst"]), }), ), - Effect.provide(commandRuntimeLayer(["secrets", "list"])), + Effect.provide(commandRuntimeLayer(["secrets", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -211,7 +212,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["secrets", "set", "--env-file=/path/to/.env"]), }), ), - Effect.provide(commandRuntimeLayer(["secrets", "set"])), + Effect.provide(commandRuntimeLayer(["secrets", "set"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -234,7 +235,7 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["db", "dump", "--password", "super-secret"]) }), ), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -260,7 +261,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "-s", "public"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -292,7 +293,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["stack", "logs", "-f", "--service", "database"]), }), ), - Effect.provide(commandRuntimeLayer(["stack", "logs"])), + Effect.provide(commandRuntimeLayer(["stack", "logs"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -319,7 +320,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "dump", "-x", "public.users", "-f", "out.sql"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -345,7 +346,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "query", "-f", "query.sql"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "query"])), + Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -380,7 +381,11 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["db", "schema", "declarative", "generate"])), + Effect.provide( + commandRuntimeLayer(["db", "schema", "declarative", "generate"]).pipe( + Layer.provide(BunCrypto.layer), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -415,7 +420,11 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["db", "schema", "declarative", "sync"])), + Effect.provide( + commandRuntimeLayer(["db", "schema", "declarative", "sync"]).pipe( + Layer.provide(BunCrypto.layer), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -448,7 +457,9 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["ssl-enforcement", "update"])), + Effect.provide( + commandRuntimeLayer(["ssl-enforcement", "update"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -477,7 +488,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["link", "--project-ref", "abcdefghijklmnopqrst"]), }), ), - Effect.provide(commandRuntimeLayer(["link"])), + Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -506,7 +517,7 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["gen", "types", "--lang", "python"]) }), ), - Effect.provide(commandRuntimeLayer(["gen", "types"])), + Effect.provide(commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -538,7 +549,9 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "RS256"]) }), ), - Effect.provide(commandRuntimeLayer(["gen", "signing-key"])), + Effect.provide( + commandRuntimeLayer(["gen", "signing-key"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -567,7 +580,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["sso", "add", "-t", "saml"]) })), - Effect.provide(commandRuntimeLayer(["sso", "add"])), + Effect.provide(commandRuntimeLayer(["sso", "add"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -595,7 +608,9 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "ES256"]) }), ), - Effect.provide(commandRuntimeLayer(["gen", "signing-key"])), + Effect.provide( + commandRuntimeLayer(["gen", "signing-key"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -625,7 +640,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["gen", "types", "--lang", "go", "--schema", "public"]), }), ), - Effect.provide(commandRuntimeLayer(["gen", "types"])), + Effect.provide(commandRuntimeLayer(["gen", "types"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -644,7 +659,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -663,7 +678,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.exit, Effect.tap(() => Effect.sync(() => { @@ -694,7 +709,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -718,7 +733,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -745,7 +760,7 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "json" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "lint"]) })), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -775,7 +790,7 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "json" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "advisors"]) })), - Effect.provide(commandRuntimeLayer(["db", "advisors"])), + Effect.provide(commandRuntimeLayer(["db", "advisors"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -810,7 +825,7 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "json" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide(commandRuntimeLayer(["db", "dump"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -838,7 +853,9 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["unknown", "command"]) })), - Effect.provide(commandRuntimeLayer(["unknown", "command"])), + Effect.provide( + commandRuntimeLayer(["unknown", "command"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -871,7 +888,9 @@ describe("withCommandTelemetry", () => { Effect.provide( Stdio.layerTest({ args: Effect.succeed(["config", "diff", "--exit-code"]) }), ), - Effect.provide(commandRuntimeLayer(["config", "diff"])), + Effect.provide( + commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -904,7 +923,9 @@ describe("withCommandTelemetry", () => { Effect.provide(processControl.layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -932,7 +953,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["config", "diff"]) })), - Effect.provide(commandRuntimeLayer(["config", "diff"])), + Effect.provide(commandRuntimeLayer(["config", "diff"]).pipe(Layer.provide(BunCrypto.layer))), Effect.exit, Effect.tap(() => Effect.sync(() => { @@ -959,7 +980,9 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) })), - Effect.provide(commandRuntimeLayer(["telemetry", "enable"])), + Effect.provide( + commandRuntimeLayer(["telemetry", "enable"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toEqual([]); @@ -992,7 +1015,9 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "restore"])), + Effect.provide( + commandRuntimeLayer(["backups", "restore"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1012,7 +1037,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(mockProcessControl().layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "table"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), // `table` is valid on the shared global union but not for a resource command. Effect.provide(Layer.succeed(OutputFlag, Option.some("table" as const))), Effect.flip, @@ -1037,7 +1062,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(mockProcessControl().layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "csv"]) })), - Effect.provide(commandRuntimeLayer(["db", "query"])), + Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(OutputFlag, Option.some("csv" as const))), Effect.tap(() => Effect.sync(() => { @@ -1058,7 +1083,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["link"]) })), - Effect.provide(commandRuntimeLayer(["link"])), + Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(stitch.layer), Effect.tap(() => Effect.sync(() => { @@ -1078,7 +1103,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(mockProcessControl().layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "yaml"]) })), - Effect.provide(commandRuntimeLayer(["db", "query"])), + Effect.provide(commandRuntimeLayer(["db", "query"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(OutputFlag, Option.some("yaml" as const))), Effect.flip, Effect.tap((error) => @@ -1101,7 +1126,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["link"]) })), - Effect.provide(commandRuntimeLayer(["link"])), + Effect.provide(commandRuntimeLayer(["link"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(stitch.layer), Effect.tap(() => Effect.sync(() => { @@ -1123,7 +1148,9 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -1150,7 +1177,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "--schema", "--linked"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; @@ -1177,7 +1204,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "--schema=public", "--linked"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; @@ -1203,7 +1230,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "-s", "public", "--linked"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; @@ -1229,7 +1256,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "lint", "--db-url", "x", "--local"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide(commandRuntimeLayer(["db", "lint"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; @@ -1249,7 +1276,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(DebugFlag, true)), Effect.tap(() => Effect.sync(() => { @@ -1281,7 +1308,7 @@ describe("withCommandTelemetry", () => { ]), }), ), - Effect.provide(commandRuntimeLayer(["secrets", "list"])), + Effect.provide(commandRuntimeLayer(["secrets", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(DebugFlag, true)), Effect.tap(() => Effect.sync(() => { @@ -1310,7 +1337,9 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list", "--workdir", "/tmp/project"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.provide(Layer.succeed(WorkdirFlag, Option.some("/tmp/project"))), Effect.tap(() => Effect.sync(() => { @@ -1337,7 +1366,9 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list", "--dns-resolver", "https"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.provide(Layer.succeed(DnsResolverFlag, "https" as const)), Effect.tap(() => Effect.sync(() => { @@ -1364,7 +1395,9 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["backups", "list", "--agent", "yes"]), }), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.provide(Layer.succeed(AgentFlag, "yes" as const)), Effect.tap(() => Effect.sync(() => { @@ -1395,7 +1428,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["db", "diff", "--output", "diff.sql"]), }), ), - Effect.provide(commandRuntimeLayer(["db", "diff"])), + Effect.provide(commandRuntimeLayer(["db", "diff"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1415,7 +1448,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1438,7 +1471,7 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["test", "db", "--", "--linked"]), }), ), - Effect.provide(commandRuntimeLayer(["test", "db"])), + Effect.provide(commandRuntimeLayer(["test", "db"]).pipe(Layer.provide(BunCrypto.layer))), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags; @@ -1457,7 +1490,7 @@ describe("withCommandTelemetry", () => { Effect.provide(mockProcessControl().layer), Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "json"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide(commandRuntimeLayer(["backups", "list"]).pipe(Layer.provide(BunCrypto.layer))), Effect.provide(Layer.succeed(OutputFlag, Option.some("json" as const))), Effect.tap(() => Effect.sync(() => { @@ -1489,7 +1522,9 @@ describe("withCommandTelemetry", () => { args: Effect.succeed(["secrets", "set", "--env-file", "--debug"]), }), ), - Effect.provide(commandRuntimeLayer(["secrets", "set"])), + Effect.provide( + commandRuntimeLayer(["secrets", "set"]).pipe(Layer.provide(BunCrypto.layer)), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; diff --git a/apps/cli/src/telemetry/telemetry-state.layer.ts b/apps/cli/src/telemetry/telemetry-state.layer.ts index 7dedabc190..48b603e828 100644 --- a/apps/cli/src/telemetry/telemetry-state.layer.ts +++ b/apps/cli/src/telemetry/telemetry-state.layer.ts @@ -26,7 +26,7 @@ const SCHEMA_VERSION = 1; const SESSION_ROTATION_MS = 30 * 60 * 1000; function telemetryPath(env: Record<string, string | undefined>, pathSvc: Path.Path): string { - return pathSvc.join(supabaseHome(homedir(), env), "telemetry.json"); + return pathSvc.join(supabaseHome(pathSvc, homedir(), env), "telemetry.json"); } /** diff --git a/apps/cli/tests/e2e-sequencer.ts b/apps/cli/tests/e2e-sequencer.ts new file mode 100644 index 0000000000..576b9e16bd --- /dev/null +++ b/apps/cli/tests/e2e-sequencer.ts @@ -0,0 +1,117 @@ +import { readFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import { BaseSequencer, type TestSpecification } from "vitest/node"; + +/** Bun `--timings` file of measured e2e file durations, refreshed from CI artifacts. */ +const TIMINGS_FILE = "tests/e2e-timings.json"; + +/** Duration assumed for every file while no timing has been measured yet. */ +export const DEFAULT_DURATION_MS = 30_000; + +/** The median of the measured durations, or the default when nothing has been measured. */ +export function fallbackDuration(timings: Readonly<Record<string, number>>): number { + const measured = Object.values(timings).sort((a, b) => a - b); + if (measured.length === 0) { + return DEFAULT_DURATION_MS; + } + const middle = Math.floor(measured.length / 2); + const upper = measured[middle] ?? DEFAULT_DURATION_MS; + if (measured.length % 2 === 1) { + return upper; + } + const lower = measured[middle - 1] ?? upper; + return (lower + upper) / 2; +} + +function compareKeys(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * Splits keys into `count` shards by measured duration, longest first into the emptiest shard. + * The result depends only on the set of keys, so every shard process derives the same partition. + */ +export function packShards( + keys: readonly string[], + timings: Readonly<Record<string, number>>, + count: number, +): string[][] { + const fallback = fallbackDuration(timings); + const durations = keys + .map((key) => ({ key, duration: timings[key] ?? fallback })) + .sort((a, b) => b.duration - a.duration || compareKeys(a.key, b.key)); + + const shards = Array.from({ length: count }, () => ({ load: 0, keys: new Array<string>() })); + for (const { key, duration } of durations) { + let target = shards[0]; + for (const shard of shards) { + if (target === undefined || shard.load < target.load) { + target = shard; + } + } + if (target !== undefined) { + target.load += duration; + target.keys.push(key); + } + } + return shards.map((shard) => shard.keys); +} + +function isFilesMap(value: unknown): value is Readonly<Record<string, number>> { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every( + (duration) => typeof duration === "number" && Number.isFinite(duration), + ) + ); +} + +function isTimingsEnvelope( + value: unknown, +): value is { version: 1; files: Readonly<Record<string, number>> } { + return ( + typeof value === "object" && + value !== null && + "version" in value && + value.version === 1 && + "files" in value && + isFilesMap(value.files) + ); +} + +/** Reads the committed Bun `--timings` file under `root`; anything but that envelope reads as absent. */ +export function readTimings(root: string): Readonly<Record<string, number>> | undefined { + try { + const parsed: unknown = JSON.parse(readFileSync(resolve(root, TIMINGS_FILE), "utf8")); + return isTimingsEnvelope(parsed) ? parsed.files : undefined; + } catch { + return undefined; + } +} + +/** + * Shards the e2e project by measured duration. Other projects, and the e2e project while + * the timings file is absent, keep Vitest's hash split. + */ +export class E2eSequencer extends BaseSequencer { + override async shard(files: TestSpecification[]): Promise<TestSpecification[]> { + const { root, shard } = this.ctx.config; + const timings = files.every((spec) => spec.project.name === "e2e") + ? readTimings(root) + : undefined; + if (shard === undefined || timings === undefined) { + return super.shard(files); + } + + const byKey = new Map( + files.map((spec) => [relative(root, spec.moduleId).replaceAll("\\", "/"), spec]), + ); + const assigned = packShards([...byKey.keys()], timings, shard.count)[shard.index - 1] ?? []; + return assigned.flatMap((key) => { + const spec = byKey.get(key); + return spec === undefined ? [] : [spec]; + }); + } +} diff --git a/apps/cli/tests/e2e-sequencer.unit.test.ts b/apps/cli/tests/e2e-sequencer.unit.test.ts new file mode 100644 index 0000000000..1826cab568 --- /dev/null +++ b/apps/cli/tests/e2e-sequencer.unit.test.ts @@ -0,0 +1,161 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { DEFAULT_DURATION_MS, fallbackDuration, packShards, readTimings } from "./e2e-sequencer.ts"; + +const files = ["src/a.e2e.test.ts", "src/b.e2e.test.ts", "src/c.e2e.test.ts", "src/d.e2e.test.ts"]; + +const sortedShards = (shards: readonly (readonly string[])[]) => + shards.map((shard) => [...shard].sort()); + +describe("packShards", () => { + test.each([1, files.length, files.length + 3])( + "partitions every file into exactly one of %i shards", + (count) => { + const shards = packShards(files, { "src/a.e2e.test.ts": 100 }, count); + + expect(shards).toHaveLength(count); + expect(shards.flat().sort()).toEqual([...files].sort()); + }, + ); + + test("leaves trailing shards empty when there are more shards than files", () => { + const shards = packShards(["src/a.e2e.test.ts", "src/b.e2e.test.ts"], {}, 4); + + expect(shards).toEqual([["src/a.e2e.test.ts"], ["src/b.e2e.test.ts"], [], []]); + }); + + test("produces the same partition regardless of input order", () => { + const timings = { + "src/a.e2e.test.ts": 500, + "src/b.e2e.test.ts": 200, + "src/c.e2e.test.ts": 900, + }; + + const forward = packShards(files, timings, 2); + const reversed = packShards([...files].reverse(), timings, 2); + + expect(reversed).toEqual(forward); + }); + + test("isolates a file that outweighs all the others combined", () => { + const timings = { + "src/a.e2e.test.ts": 10, + "src/b.e2e.test.ts": 1_000, + "src/c.e2e.test.ts": 10, + "src/d.e2e.test.ts": 10, + }; + + const shards = packShards(files, timings, 2); + + expect(sortedShards(shards)).toEqual([ + ["src/b.e2e.test.ts"], + ["src/a.e2e.test.ts", "src/c.e2e.test.ts", "src/d.e2e.test.ts"], + ]); + }); + + test("weights an unmeasured file at the median of the measured ones", () => { + const timings = { + "src/a.e2e.test.ts": 100, + "src/b.e2e.test.ts": 300, + "src/c.e2e.test.ts": 500, + }; + + // d is unmeasured; at the median (300) it ties with b and follows it into the second shard. + const shards = packShards(files, timings, 2); + + expect(sortedShards(shards)).toEqual([ + ["src/a.e2e.test.ts", "src/c.e2e.test.ts"], + ["src/b.e2e.test.ts", "src/d.e2e.test.ts"], + ]); + }); + + test("weights every file at the default duration when nothing has been measured", () => { + const allDefault = Object.fromEntries(files.map((file) => [file, DEFAULT_DURATION_MS])); + + expect(packShards(files, {}, 3)).toEqual(packShards(files, allDefault, 3)); + }); + + test("breaks duration ties by key so equal files spread deterministically", () => { + const timings = { "src/c.e2e.test.ts": 50, "src/a.e2e.test.ts": 50, "src/b.e2e.test.ts": 50 }; + + const shards = packShards(Object.keys(timings), timings, 3); + + expect(shards).toEqual([["src/a.e2e.test.ts"], ["src/b.e2e.test.ts"], ["src/c.e2e.test.ts"]]); + }); +}); + +describe("fallbackDuration", () => { + test("is the default duration when nothing has been measured", () => { + expect(fallbackDuration({})).toBe(DEFAULT_DURATION_MS); + }); + + test("is the middle value for an odd number of measurements", () => { + expect(fallbackDuration({ a: 900, b: 100, c: 300 })).toBe(300); + }); + + test("is the mean of the two middle values for an even number of measurements", () => { + expect(fallbackDuration({ a: 100, b: 200, c: 400, d: 900 })).toBe(300); + }); +}); + +describe("readTimings", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "e2e-timings-")); + mkdirSync(join(root, "tests")); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const writeTimings = (content: string) => + writeFileSync(join(root, "tests", "e2e-timings.json"), content); + + test("reads the files map from a version 1 envelope", () => { + writeTimings( + JSON.stringify({ + version: 1, + files: { "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 }, + }), + ); + + expect(readTimings(root)).toEqual({ "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 }); + }); + + test("reads as absent when the file is missing", () => { + expect(readTimings(root)).toBeUndefined(); + }); + + test("reads as absent rather than half-applying when any duration is not a number", () => { + writeTimings( + JSON.stringify({ + version: 1, + files: { "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": "300" }, + }), + ); + + expect(readTimings(root)).toBeUndefined(); + }); + + test("reads as absent when the file is not JSON", () => { + writeTimings("{ not json"); + + expect(readTimings(root)).toBeUndefined(); + }); + + test("reads as absent for a flat map with no envelope", () => { + writeTimings(JSON.stringify({ "src/a.e2e.test.ts": 1200, "src/b.e2e.test.ts": 300 })); + + expect(readTimings(root)).toBeUndefined(); + }); + + test("reads as absent for an unknown envelope version", () => { + writeTimings(JSON.stringify({ version: 2, files: { "src/a.e2e.test.ts": 1200 } })); + + expect(readTimings(root)).toBeUndefined(); + }); +}); diff --git a/apps/cli/tests/e2e-timings.json b/apps/cli/tests/e2e-timings.json new file mode 100644 index 0000000000..161bf498ed --- /dev/null +++ b/apps/cli/tests/e2e-timings.json @@ -0,0 +1,57 @@ +{ + "version": 1, + "files": { + "scripts/publish-docs-spec.e2e.test.ts": 196, + "src/cli/agent-output.e2e.test.ts": 1179, + "src/cli/complete.e2e.test.ts": 1390, + "src/cli/main.e2e.test.ts": 295, + "src/command-internal/db-bootstrap/shadow-cache.e2e.test.ts": 64985, + "src/commands/completion/completion.e2e.test.ts": 485, + "src/commands/config/diff/diff.e2e.test.ts": 293, + "src/commands/config/pull/pull.e2e.test.ts": 562, + "src/commands/config/push/push.e2e.test.ts": 1016, + "src/commands/db/diff/diff.declarative.e2e.test.ts": 64523, + "src/commands/db/diff/diff.e2e.test.ts": 276, + "src/commands/db/diff/diff.stack-cache.e2e.test.ts": 17572, + "src/commands/db/pull/pull.e2e.test.ts": 235, + "src/commands/db/push/push.e2e.test.ts": 680, + "src/commands/db/reset/reset.e2e.test.ts": 232, + "src/commands/db/reset/reset.stack.e2e.test.ts": 11954, + "src/commands/db/schema/declarative/sync/sync.e2e.test.ts": 86790, + "src/commands/db/start/start.e2e.test.ts": 37852, + "src/commands/db/start/start.roles.e2e.test.ts": 14108, + "src/commands/encryption/encryption.e2e.test.ts": 406, + "src/commands/experimental/stack/start/start.e2e.test.ts": 10026, + "src/commands/feedback/add/add.e2e.test.ts": 176, + "src/commands/functions/deploy/deploy.e2e.test.ts": 1196, + "src/commands/functions/download/download.e2e.test.ts": 846, + "src/commands/functions/serve/serve.stack.e2e.test.ts": 21998, + "src/commands/gen/signing-key/signing-key.e2e.test.ts": 401, + "src/commands/gen/types/types.e2e.test.ts": 3983, + "src/commands/inspect/db/inspect-db.e2e.test.ts": 391, + "src/commands/inspect/report/report.e2e.test.ts": 197, + "src/commands/link/link.e2e.test.ts": 263, + "src/commands/login/login.e2e.test.ts": 437, + "src/commands/logout/logout.e2e.test.ts": 722, + "src/commands/migration/fetch/fetch.e2e.test.ts": 223, + "src/commands/migration/new/new.e2e.test.ts": 189, + "src/commands/migration/squash/squash.e2e.test.ts": 417, + "src/commands/pull/pull.e2e.test.ts": 396, + "src/commands/seed/buckets/buckets.e2e.test.ts": 771, + "src/commands/snippets/snippets.e2e.test.ts": 297, + "src/commands/sso/sso.e2e.test.ts": 1554, + "src/commands/start/start.e2e.test.ts": 199, + "src/commands/start/start.lifecycle.e2e.test.ts": 152494, + "src/commands/start/start.slim-images.e2e.test.ts": 69840, + "src/commands/status/status.e2e.test.ts": 27905, + "src/commands/stop/stop.e2e.test.ts": 59483, + "src/commands/storage/storage.e2e.test.ts": 1072, + "src/commands/test/new/new.e2e.test.ts": 326, + "src/commands/unlink/unlink.e2e.test.ts": 587, + "src/commands/whoami/whoami.e2e.test.ts": 214, + "src/shared/cli/run.e2e.test.ts": 2252, + "src/shared/functions/serve-main-offline.e2e.test.ts": 8693, + "src/shared/telemetry/failure-metadata.e2e.test.ts": 550, + "src/shared/telemetry/posthog-client.e2e.test.ts": 2277 + } +} diff --git a/apps/cli/tests/helpers/child-process-spawner.ts b/apps/cli/tests/helpers/child-process-spawner.ts index 13922bd1bf..7dc2ad5a0e 100644 --- a/apps/cli/tests/helpers/child-process-spawner.ts +++ b/apps/cli/tests/helpers/child-process-spawner.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Layer, Predicate, Sink, Stream } from "effect"; +import { Deferred, Effect, Layer, PlatformError, Predicate, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; interface SpawnRecord { @@ -102,3 +102,62 @@ export function mockChildProcessSpawner( }, }; } + +/** How a host's container engine answers: absent from PATH, installed with its daemon down, or serving. */ +export type ContainerEngineState = "missing" | "stopped" | "running"; + +/** + * Spawner for a host whose `docker` and `podman` commands behave as `engines` describe. Other + * commands fail with `NotFound`, or run on the real spawner through `hidingLayer`. + */ +export function containerEngineSpawner(engines: { + readonly docker: ContainerEngineState; + readonly podman: ContainerEngineState; +}) { + const spawned: SpawnRecord[] = []; + const notFound = (command: string) => + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: command, + }); + const spawner = (delegate?: ChildProcessSpawner.ChildProcessSpawner["Service"]) => + ChildProcessSpawner.make((command) => { + const cmd = Predicate.isTagged(command, "StandardCommand") ? command.command : ""; + const args = Predicate.isTagged(command, "StandardCommand") ? command.args : []; + if (cmd !== "docker" && cmd !== "podman") + return delegate === undefined ? Effect.fail(notFound(cmd)) : delegate.spawn(command); + spawned.push({ command: cmd, args }); + const state = engines[cmd]; + if (state === "missing") return Effect.fail(notFound(cmd)); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(2000 + spawned.length), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(state === "running" ? 0 : 1)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + return { + layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner()), + /** Wraps the provided real spawner so only the container engine commands are faked. */ + hidingLayer: Layer.effect( + ChildProcessSpawner.ChildProcessSpawner, + Effect.gen(function* () { + return spawner(yield* ChildProcessSpawner.ChildProcessSpawner); + }), + ), + get spawned() { + return spawned; + }, + }; +} diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 5028c6f454..1f8cb7c04a 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -4,7 +4,17 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { type ApiClient, makeApiClient, type SupabaseApiConfigError } from "@supabase/api/effect"; -import { Effect, FileSystem, Layer, Option, Predicate, Redacted, Sink, Stream } from "effect"; +import { + ConfigProvider, + Effect, + FileSystem, + Layer, + Option, + Predicate, + Redacted, + Sink, + Stream, +} from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; import type { ChildProcess } from "effect/unstable/process"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -768,6 +778,28 @@ export const withEnvVar = <A, E, R>( }), ); +/** + * Pins `values` for the `Config` reads inside `body`, ahead of the ambient provider. Use it + * instead of {@link withEnvVar} for code that reads through `Config`: without a provided + * `ConfigProvider`, Effect's default one snapshots `process.env` on first use, so later + * `process.env` edits are invisible. Pin an empty string to shadow an ambient value. A layer + * inside `body` that installs its own `ConfigProvider` (`processEnvLayer`, + * `isolatedHomeLayer`) replaces the pin, so provide such a layer around the + * `withConfigEnv(...)` call instead. + */ +export const withConfigEnv = <A, E, R>( + values: Readonly<Record<string, string>>, + body: Effect.Effect<A, E, R>, +): Effect.Effect<A, E, R> => + body.pipe( + Effect.provide( + ConfigProvider.layerAdd( + ConfigProvider.fromEnvRecord(values, { preserveEmptyStrings: true }), + { asPrimary: true }, + ), + ), + ); + /** * Pins `SUPABASE_SHADOW_CACHE=0` for the calling file so the default-ON cache cannot * flip mocked-spawner suites onto the cache path. Call at module scope (or diff --git a/apps/cli/tests/helpers/config-fixtures.ts b/apps/cli/tests/helpers/config-fixtures.ts index 74a962a86d..a9b22abffa 100644 --- a/apps/cli/tests/helpers/config-fixtures.ts +++ b/apps/cli/tests/helpers/config-fixtures.ts @@ -90,6 +90,12 @@ export function v2ProjectConfigResponse( mailer_notifications_mfa_factor_enrolled_enabled: false, mailer_notifications_mfa_factor_unenrolled_enabled: false, external_phone_enabled: false, + sms_provider: "twilio", + sms_twilio_account_sid: null, + sms_twilio_verify_account_sid: null, + sms_messagebird_originator: null, + sms_textlocal_sender: null, + sms_vonage_from: null, sms_autoconfirm: false, sms_max_frequency: 5, sms_otp_exp: 60, diff --git a/apps/cli/tests/helpers/live-env.ts b/apps/cli/tests/helpers/live-env.ts index 92c2519b79..284e40906b 100644 --- a/apps/cli/tests/helpers/live-env.ts +++ b/apps/cli/tests/helpers/live-env.ts @@ -2,6 +2,12 @@ export const LIVE_EXIT_TIMEOUT_MS = 240_000; +/** + * The default shadow port (54320) is inside Linux's default ephemeral range, so an outbound socket + * can hold it. Live files run serially, so one fixed port below that range is safe. + */ +export const LIVE_SHADOW_PORT = "24320"; + export function liveApiUrl(): string { const value = process.env["SUPABASE_LIVE_API_URL"]?.trim(); if (value === undefined || value.length === 0) { diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 397fa18da7..5421e6e75c 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -6,6 +6,7 @@ import { Effect, Predicate } from "effect"; import pg from "pg"; import { expect, inject, test as vitestTest } from "vitest"; +import { rootCaBundle } from "../../src/commands/gen/types/types.shared.ts"; import { type CliRunError, makeTempHome, @@ -13,7 +14,7 @@ import { runSupabase, runSupabaseEffect, } from "./cli.ts"; -import { LIVE_EXIT_TIMEOUT_MS } from "./live-env.ts"; +import { LIVE_EXIT_TIMEOUT_MS, LIVE_SHADOW_PORT } from "./live-env.ts"; import type { LiveCliProjectEnvironment } from "./live-project.ts"; export type LiveProject = LiveCliProjectEnvironment["project"]; @@ -87,6 +88,7 @@ const base = vitestTest.extend<LiveFixtures>({ exitTimeoutMs: options?.exitTimeoutMs ?? LIVE_EXIT_TIMEOUT_MS, env: { SUPABASE_PROFILE: inject("liveProfilePath"), + SUPABASE_DB_SHADOW_PORT: LIVE_SHADOW_PORT, ...options?.env, }, }), @@ -102,6 +104,7 @@ const base = vitestTest.extend<LiveFixtures>({ exitTimeoutMs: options?.exitTimeoutMs ?? LIVE_EXIT_TIMEOUT_MS, env: { SUPABASE_PROFILE: inject("liveProfilePath"), + SUPABASE_DB_SHADOW_PORT: LIVE_SHADOW_PORT, ...options?.env, }, }), @@ -276,7 +279,8 @@ export async function queryLiveDb<T extends Record<string, unknown>>( query: string, values?: ReadonlyArray<unknown>, ): Promise<T[]> { - const client = new pg.Client({ connectionString: dbUrl }); + // Verified TLS against the Supabase CA, so the query works whatever the project's SSL enforcement. + const client = new pg.Client({ connectionString: dbUrl, ssl: { ca: rootCaBundle() } }); await client.connect(); try { const result = await client.query(query, values === undefined ? undefined : [...values]); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 54a5a6d9dc..bbd0af32c5 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -2,7 +2,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import process from "node:process"; import { BunServices } from "@effect/platform-bun"; -import { ConfigProvider, Deferred, Effect, Layer, Option, Stream } from "effect"; +import { Console, ConfigProvider, Deferred, Effect, Layer, Option, Stream } from "effect"; import type { CliProjectEnvironment, CliProjectPaths } from "@supabase/config"; import { cliSettingsLayer } from "../../src/shared/config/cli-settings.layer.ts"; import { CliProjectHome } from "../../src/shared/config/cli-project-home.service.ts"; @@ -89,15 +89,14 @@ export function mockStdin(isTTY: boolean, pipedInput?: string | Uint8Array): Lay ? Stream.fromIterable([pipedBytes.value]) : Stream.empty, readPipedText: Effect.succeed(pipedText), - // Ignores any timeout argument; dispenses piped lines one per call (trimmed), - // then None once exhausted. + // Ignores any timeout argument; dispenses piped lines one per call, then None once + // exhausted. readLine: () => Effect.sync(() => { if (lineIndex >= lines.length) { return Option.none<string>(); } - const line = (lines[lineIndex++] ?? "").trim(); - return line.length > 0 ? Option.some(line) : Option.none<string>(); + return Option.some(lines[lineIndex++] ?? ""); }), }); } @@ -285,7 +284,7 @@ export function mockOutput( messages.push({ type: "warn", message: nextMessage }); } }), - clear: () => Effect.void, + clear: Effect.void, }; }), event: (event) => @@ -699,6 +698,48 @@ export function emptyEnv() { Layer.provide(runtimeInfoLayer), Layer.provide(cliProjectContextLayer), Layer.provide(envLayer), + Layer.provide(BunServices.layer), ), ); } + +/** + * A `Console.Console` test double recording `log`/`error` calls — `--help` and parse-error + * output render through it. Not `vi.spyOn`-based: spying on `console.*` here unreliably breaks + * call detection under this repo's Bun + Vitest combination. + */ +export function fakeConsole(): { + readonly console: Console.Console; + readonly calls: Array<string>; +} { + const calls: Array<string> = []; + const unused = () => {}; + return { + calls, + console: { + assert: unused, + clear: unused, + count: unused, + countReset: unused, + debug: unused, + dir: unused, + dirxml: unused, + error: (...args: ReadonlyArray<unknown>) => { + calls.push(`error:${args.join(" ")}`); + }, + group: unused, + groupCollapsed: unused, + groupEnd: unused, + info: unused, + log: (...args: ReadonlyArray<unknown>) => { + calls.push(`log:${args.join(" ")}`); + }, + table: unused, + time: unused, + timeEnd: unused, + timeLog: unused, + trace: unused, + warn: unused, + }, + }; +} diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index a3380a4980..27b5d33b2c 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -286,7 +286,7 @@ export function setupNotebooks(options: NotebooksSetupOptions) { cache.layer, analytics.layer, process.layer, - commandRuntimeLayer(["notebooks", command]), + commandRuntimeLayer(["notebooks", command]).pipe(Layer.provide(BunServices.layer)), Layer.succeed( OutputFlag, options.goOutput === undefined ? Option.none() : Option.some(options.goOutput), diff --git a/apps/cli/tests/helpers/slim-images.ts b/apps/cli/tests/helpers/slim-images.ts new file mode 100644 index 0000000000..0bab6745fc --- /dev/null +++ b/apps/cli/tests/helpers/slim-images.ts @@ -0,0 +1,29 @@ +import { catalogPins } from "@supabase/stack/internal/artifacts"; + +import { slimCatalogPin } from "../../src/shared/services/slim-images.ts"; + +/** A regression to the docker.io fallback must fail an assertion built from this. */ +export const GHCR_SLIM_IMAGE_PATTERN = /^ghcr\.io\/supabase\/cli\/.+@sha256:[0-9a-f]{64}$/; + +/** + * The catalog's own pinned image for `alias`'s (docker.io) `image` — read straight from + * `catalogPins()`, independent of `toSlimImage`, so a test asserting against this actually + * exercises the catalog lookup instead of passing whether or not it resolves (a default + * Dockerfile tag is generated from the catalog, so it always matches a catalog pin). Only reuses `slimCatalogPin` for alias + * and tag normalization (`v`-prefixing), not the catalog image lookup itself. Throws when + * nothing is pinned, so a caller never silently falls back to a weaker assertion. + */ +export function expectedPinnedImage(alias: string, image: string): string { + const pin = slimCatalogPin(alias, image); + if (pin === undefined) { + throw new Error(`no slim catalog pin for ${alias} ${image}`); + } + const entry = catalogPins().find( + (candidate) => + candidate.sourceService === pin.service && candidate.pin.upstreamVersion === pin.version, + ); + if (entry === undefined) { + throw new Error(`no catalog pin for ${pin.service} ${pin.version}`); + } + return entry.pin.image; +} diff --git a/apps/cli/tests/helpers/stack-cleanup.ts b/apps/cli/tests/helpers/stack-cleanup.ts index f472487a13..c5326195a5 100644 --- a/apps/cli/tests/helpers/stack-cleanup.ts +++ b/apps/cli/tests/helpers/stack-cleanup.ts @@ -1,6 +1,10 @@ +import type { Stack } from "@supabase/stack/effect"; import { Cause, Effect, Exit } from "effect"; import type { StackApi } from "../../src/command-internal/stack-api.ts"; -import { destroyTestStack } from "../../../../packages/stack/tests/stack-cleanup.ts"; + +/** Destroys a test stack, failing the test when teardown fails. */ +export const destroyTestStack = (stack: Stack): Effect.Effect<void, never> => + stack.destroy.pipe(Effect.orDie); export const destroyTestStacks = ( api: StackApi["Service"], @@ -14,7 +18,7 @@ export const destroyTestStacks = ( ({ definition }) => api .open({ id: definition.id, stateRoot, cacheRoot }) - .pipe(Effect.flatMap(destroyTestStack), Effect.exit), + .pipe(Effect.flatMap(destroyTestStack), Effect.scoped, Effect.exit), { concurrency: "unbounded" }, ); const failures = exits.filter(Exit.isFailure); diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 942bef7e27..5a5a1f2479 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -128,7 +128,6 @@ const databaseCreation: Extract<ServiceCreation, { service: "database" }> = { const storageCreation: Extract<ServiceCreation, { service: "storage" }> = { service: "storage", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: STORAGE_TEST_JWT_SECRET, serviceRoleKey: stackCredentials.serviceRoleKey, filePath: "/tmp/storage", @@ -254,6 +253,7 @@ export function buildStorageStackApi( }, credentials: { get: Effect.succeed(stackCredentials) }, composition: { + plan: () => Effect.succeed([]), supabase: () => Effect.die("unused"), configure: () => Effect.die("unused"), describe: Effect.succeed({ members, dependencies: [] }), @@ -263,7 +263,7 @@ export function buildStorageStackApi( }, stop: Effect.die("unused"), destroy: Effect.die("unused"), - tools: { run: () => Effect.die("unused") }, + commands: { run: () => Effect.die("unused") }, }; const definition = { id: STORAGE_STACK_ID, @@ -274,6 +274,7 @@ export function buildStorageStackApi( ...(storageEnabled ? [{ id: storage.id, creation: storageCreation }] : []), ], composition: { members, dependencies: [] }, + lifetime: "detached" as const, ports: [], }; const findStackCalls: Array<{ readonly projectRoot: string }> = []; @@ -283,13 +284,17 @@ export function buildStorageStackApi( create: () => Effect.die("Service not found: supabase/stack/StackApi"), open: () => Effect.die("Service not found: supabase/stack/StackApi"), discover: () => Effect.die("Service not found: supabase/stack/StackApi"), - resolveIdentity: () => Effect.die("Service not found: supabase/stack/StackApi"), + find: () => Effect.die("Service not found: supabase/stack/StackApi"), }) : Layer.succeed(StackApi, { create: () => Effect.die("unused"), - resolveIdentity: () => Effect.succeed(definition.identity), - discover: () => - Effect.succeed(options.found === false ? [] : [{ definition, host: undefined }]), + discover: () => Effect.die("unused"), + find: () => + Effect.succeed( + options.found === false + ? Option.none() + : Option.some({ definition, host: undefined }), + ), open: () => { findStackCalls.push({ projectRoot: workdir }); return Effect.succeed(stack); diff --git a/apps/cli/tests/helpers/unused-stack.ts b/apps/cli/tests/helpers/unused-stack.ts index fdbf9ba205..ea1039f398 100644 --- a/apps/cli/tests/helpers/unused-stack.ts +++ b/apps/cli/tests/helpers/unused-stack.ts @@ -9,7 +9,7 @@ export const unusedStackServices = Layer.mergeAll( create: unused, open: unused, discover: unused, - resolveIdentity: unused, + find: unused, }), Layer.succeed(StackCatalogSetup, { apply: unused }), ); diff --git a/apps/cli/tsconfig.types.json b/apps/cli/tsconfig.types.json index ac42ee7bfb..b3f71b369e 100644 --- a/apps/cli/tsconfig.types.json +++ b/apps/cli/tsconfig.types.json @@ -1,16 +1,13 @@ { - // Type-check-only overlay. `@supabase/postgrest-typegen`'s `bun` exports condition points at - // its unbuilt `src/*.ts`, which does not satisfy this workspace's stricter compiler options; - // its published `dist/*.d.ts` describes the same API and does. The pin cannot live in - // `tsconfig.json` because Bun honours `paths` at runtime too, and would then load a - // declaration file instead of the implementation. `types.generator.unit.test.ts` - // exercises the Bun-resolved runtime API so the two views cannot drift unnoticed. + // Type-check-only overlay for the `@supabase/pg-topo` pin from `tsconfig.json` (see the note + // there). The pin cannot live in `tsconfig.json` alone for type-checking purposes because Bun + // honours `paths` at runtime too, and would then load a declaration file instead of the + // implementation. "extends": "./tsconfig.json", "compilerOptions": { // `paths` replaces the base map wholesale, so the inherited pin is repeated here. "paths": { - "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"], - "@supabase/postgrest-typegen": ["./node_modules/@supabase/postgrest-typegen/dist/index.d.ts"] + "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"] } } } diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 72c8653981..8c65253e02 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { defaultClientConditions, defaultServerConditions } from "vite"; import { defineConfig } from "vitest/config"; +import { E2eSequencer } from "./tests/e2e-sequencer.ts"; function dockerfileTextPlugin() { return { @@ -28,6 +29,9 @@ export default defineConfig({ plugins: [dockerfileTextPlugin()], test: { passWithNoTests: true, + // Vitest reads `sequence.sequencer` from the root config only; the sequencer + // itself applies duration-aware sharding to the e2e project alone. + sequence: { sequencer: E2eSequencer }, coverage: { enabled: false, provider: "v8", @@ -62,7 +66,7 @@ export default defineConfig({ hookTimeout: 120_000, include: ["**/*.integration.test.ts"], // Integration workers start real service processes and containers. - maxWorkers: 2, + maxWorkers: 4, sequence: { groupOrder: 1 }, }, }, diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index bf021363eb..0047ef3cf9 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -1776,6 +1776,10 @@ "description": "The database major version to use. This has to be the same as your remote database's.", "default": 17 }, + "orioledb_version": { + "type": "string", + "description": "OrioleDB version of the Postgres image to use for the local database. Requires `major_version` 15 or 17." + }, "pooler": { "type": "object", "properties": { @@ -2418,7 +2422,7 @@ }, "orioledb_version": { "type": "string", - "description": "Postgres storage engine version for OrioleDB." + "description": "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB." }, "s3_host": { "type": "string", @@ -4291,6 +4295,10 @@ "description": "The database major version to use. This has to be the same as your remote database's.", "default": 17 }, + "orioledb_version": { + "type": "string", + "description": "OrioleDB version of the Postgres image to use for the local database. Requires `major_version` 15 or 17." + }, "pooler": { "type": "object", "properties": { @@ -4933,7 +4941,7 @@ }, "orioledb_version": { "type": "string", - "description": "Postgres storage engine version for OrioleDB." + "description": "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB." }, "s3_host": { "type": "string", diff --git a/docs/adr/0011-cli-release-and-distribution-strategy.md b/docs/adr/0011-cli-release-and-distribution-strategy.md index d94538dd61..4cc4fed6c0 100644 --- a/docs/adr/0011-cli-release-and-distribution-strategy.md +++ b/docs/adr/0011-cli-release-and-distribution-strategy.md @@ -141,8 +141,8 @@ flowchart TD build["build job<br/>sync-versions then build.ts then nfpm<br/>upload artifact"] smoke["smoke-test matrix<br/>ubuntu / macos-latest / macos-15-intel / windows-latest"] publish["publish job<br/>bun publish × 8 platform pkgs<br/>then bun publish umbrella"] - ghRelease["draft GitHub Release<br/>tar/zip/deb/rpm/apk/checksums"] - finalize["gh release edit --draft=false"] + ghRelease["empty draft GitHub Release<br/>gh release upload --clobber per asset<br/>tar/zip/deb/rpm/apk/checksums"] + finalize["verify assets then<br/>gh release edit --draft=false"] hbUpdate["update-homebrew.ts<br/>Formula push"] scoopUpdate["update-scoop.ts<br/>manifest push"] @@ -197,7 +197,7 @@ Production bucket: `supabase/scoop-bucket`. #### GitHub Releases -Draft + finalize by the shared workflow: +Draft, upload, verify, and finalize by the shared workflow: ```yaml # .github/workflows/release-shared.yml (publish job, excerpt) @@ -206,14 +206,13 @@ Draft + finalize by the shared workflow: tag_name: v${{ inputs.version }} draft: true prerelease: ${{ inputs.prerelease }} - files: | - dist/supabase_…_darwin_arm64.tar.gz - dist/supabase_…_linux_amd64.deb - … - dist/checksums.txt -- run: gh release edit v${{ inputs.version }} --draft=false +- run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts upload --version "${VERSION}" +- run: pnpm exec bun apps/cli/scripts/upload-release-assets.ts verify --version "${VERSION}" +- run: gh release edit v${VERSION} --draft=false ``` +Assets go through [`upload-release-assets.ts`](../../apps/cli/scripts/upload-release-assets.ts), which calls `gh release upload` one asset at a time with a retry and then verifies the asset set, instead of the action's `files:` input: `uploads.github.com` fails single uploads often enough that one per release is routine, and the action uploads everything in parallel with no retry. Details in [release-process.md](../../apps/cli/docs/release-process.md). + Archive layout (per-platform): ```text diff --git a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md index f75180d5d5..0b55385e27 100644 --- a/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md +++ b/docs/adr/0025-ephemeral-postgres-for-schema-tooling.md @@ -1,6 +1,6 @@ # 0025. Ephemeral Postgres for schema tooling -**Status**: proposed +**Status**: superseded by throwaway stack shadow stacks and [database snapshots](../../packages/stack/ARCHITECTURE.md#snapshots-belong-to-the-database-instance) — `EphemeralPostgres` was never built; see [`stack-shadow.ts`](../../apps/cli/src/command-internal/stack-shadow.ts) **Date**: 2026-09-09 ## Problem Statement diff --git a/docs/adr/0026-slim-artifact-mirrors.md b/docs/adr/0026-slim-artifact-mirrors.md index 31b429dcc1..cba056ffe3 100644 --- a/docs/adr/0026-slim-artifact-mirrors.md +++ b/docs/adr/0026-slim-artifact-mirrors.md @@ -1,48 +1,191 @@ # 0026. Slim image and native artifact mirrors -**Status**: proposed -**Date**: 2026-09-17 +**Status**: accepted +**Date**: 2026-09-29 ## Problem Statement -Slim service images publish to `ghcr.io/supabase/cli/<service>:<version>`. Native archives publish to GitHub Releases. A broken build must be replaceable under the **same** upstream version string (`force=true`); bumping the tag is not an option. +slim-services used to publish each artifact under the exact upstream version string, so the +only way to fix bad packaging was `force=true`: overwrite the GitHub release assets +(`--clobber`), move the GHCR/ECR tags, and overwrite the S3 objects. That broke already-released +CLIs pinned to that version, and the CLI itself resolved artifacts inconsistently: some catalog +images were pinned by `@sha256` and some were tag-only, native archives had no pin at all, and +`manifest.json` was not integrity-checked despite supplying `entrypoint`/`cmd`. The runtime +checksum came from mutable sources — the release `SHA256SUMS` or a GHCR `:<v>-native-<target>` +tag — so one CLI version could run different bytes on different machines. -Some consumers cannot reach GitHub release assets or a single registry blob CDN. This ADR covers publishing those copies and the local stack's fail-through. +Some consumers also cannot reach GitHub release assets or a single registry blob CDN. This ADR +covers the immutable publish scheme, the CLI's content pins, and the local stack's fail-through +across mirrors. ## Decision -Publish each slim **image** to GHCR and AWS ECR Public (`public.ecr.aws/supabase/cli/<service>`), digest-preserving, via `mirror-slim-image.yml` and `.github/scripts/mirror-slim-image.ts`. Publish each slim **native** archive as an OCI artifact on the same two repositories under `:version-native-<target>` (not `:version-linux-*`, which are image platform tags). GitHub Releases remain the human HTTPS copy and `--clobber` on force. - -`force=true` always requests the ECR copy, including when the image digest is unchanged (natives may have moved). The **image** destination digest gates `publish-release` once the dispatch token exists. Native ECR copy is best-effort (`continue-on-error`) and must not fail that release. Native tags ride in a separate `natives[]` payload field. This mirror does not open a pull request to pin `packages/stack/src/Artifacts.ts`. A Dependabot Dockerfile bump commits that catalog pin onto the same pull request, so the docker.io tag, the slim image, and the native archive stay on one version. A PostgreSQL release line that is not the Dockerfile `pg` tag, and a same-version digest republish, stay put until that Dockerfile line changes. Do not prune untagged GHCR or ECR manifests: already-shipped CLIs still pin old image digests until that pull request merges. Natives follow the moved tag immediately. - -ECR Public tags are always mutable; `ecr-public create-repository` has no immutability flag. Reuse the existing `PROD_AWS_ROLE` in `supabase/cli`. - -A public S3 bucket on `*.amazonaws.com` holds native archives for hosts that cannot reach GitHub Releases ([infra/cli-artifacts](../../infra/cli-artifacts/README.md)). It is not a checksum authority. The stack tries the GitHub Release first and falls back to that bucket. The expected checksum comes from the release `SHA256SUMS` or, when that is blocked, from the archive layer of the `:version-native-<target>` artifact on GHCR. An archive from either host is accepted only when it matches. - -The container runtime pulls a catalog image from GHCR first and falls back to the same reference on ECR Public. A failing fallback, for an image or a native archive, still reports the primary's original error. +### Immutable revisions + +Every slim-services publish carries a release version `R = <U>-r<N>`, where `U` is the upstream +tag exactly as the service's `tag_pattern` matches it and `N` is a revision starting at `0`. +Revision `N` of `U` is taken exactly when the GitHub release `<svc>-U-rN` exists — the GitHub +release is the commit point. Allocation is `max(taken) + 1`, computed from the full paginated +release list before any push. Once a release exists, `gh release create` is create-only: nothing +ever writes to that `-rN` again, so **there is no same-version overwrite**. A hotfix publishes a +new `-rN`; it never touches a previously published revision's bytes. `R` is never fed to a +semver library — ordering is the service's upstream version key, then revision, numerically. + +**Legacy tags** (without `-rN`) are frozen: nothing parses, audits, rewrites, or unpublishes +them, so CLIs pinned to a legacy tag keep working. + +### The catalog is the single version table + +The CLI's stack catalog (`packages/stack/src/Artifacts.ts`) is the single version table for +every consumer of a slim-capable service: the new stack, legacy `supabase start`, and legacy +slim mode. Each slim-capable service pins exactly one committed slim-services release per +release line it carries (`ArtifactPin`, keyed by upstream version; only postgres carries more +than one line, its default plus an additional Postgres 15 pin). Everything else is derived from +that pin — never the other way around. + +`apps/cli/src/shared/services/Dockerfile`, and its byte-identical Go copy +(`apps/cli-go/pkg/config/templates/Dockerfile`), keep every existing consumer (the legacy TS +stack, the Go CLI embed, `mirror-template-images.yml`, `detect-unmirrored-images.ts`) unchanged, +but for a slim-capable alias its `FROM` line is now a **generated view** of the catalog's +`ArtifactPin.upstreamImage`: `apps/cli/scripts/render-service-dockerfile.ts` rewrites those lines +in place, and a CI check (`render-service-dockerfile.unit.test.ts`) fails on drift between the +Dockerfile and the catalog. Kong and Postgres 14 have no slim build and are bumped by hand (both +share `library/kong`'s or `supabase/postgres`'s Dependabot `ignore` entry — see "Tradeoffs" below). +The one-shot job images (migra, pg_prove, pgadmin-schema-diff) also have no slim build, but are +upstream-only: they are the images Dependabot still bumps. + +### Content pins, not runtime checksums + +Every `ArtifactPin` pins its release by content: + +- the slim image, as `ghcr.io/supabase/cli/<service>:<R>@sha256:<digest>`; +- each target's archive sha256 and manifest sha256 (`ArtifactPin.natives`); +- `upstreamImage`, the exact upstream image the release was built or mirrored from, normalized to + the Dockerfile's `FROM` form — this is what legacy non-slim mode resolves against, and what the + generator reads to render the Dockerfile. + +`SlimServicesSource` hash-checks the manifest against its pin before parsing it (its `version` +field must equal `R`) and hash-checks the archive against its pin while it streams. There is no +runtime checksum authority: **GitHub Releases, the S3 mirror, GHCR, and ECR Public are byte +mirrors only** for both images and native archives. None of them is consulted for the expected +hash — that comes only from the pin already committed to the catalog. + +### Sync and the S3-staleness check + +`.github/scripts/sync-artifacts-catalog.ts` writes those pins, in manual mode: given a service +and either `--upstream` (the highest committed revision of that upstream version) or `--release` +(exactly that committed `<upstream>-r<N>`, never "highest at apply time"), it reads the target +release's `SHA256SUMS` for the archive and manifest sha256 per target, and resolves the image +digest with `regctl manifest head`. Before writing the pin, it downloads each target's S3 archive +and manifest and hashes them against those same release sums. This exists because +`publish-release` does not wait for the ECR/S3 mirror to finish, so a freshly committed +revision's S3 copy can briefly lag. A missing object waits, bounded (`waitForExpectedRelease`'s +retry helper, generalized); an object that exists with the wrong bytes fails the sync +immediately — that's corruption, not lag, and means "run the mirror backfill", not "the CLI is +broken". Hosts that reach GitHub are unaffected — GitHub is the primary mirror — but a host that +can only reach S3 would otherwise fail verification with no fallback. + +### Hotfix and upgrade pickup + +The last step of slim-services' `publish-release` sends a `repository_dispatch` +(`slim-release-published`) to the CLI repo with `{service, upstream_version, revision, +release_version}`. `slim-release-published.yml` treats the dispatch as a trigger, not as the +payload to apply: it reconciles the named service against every committed (published, non-draft) +`<service>-...-r<N>` release it can currently see (`planSlimUpdates`), and opens or updates, per +release line the service carries: + +- a **hotfix**, when the pinned upstream version has a higher committed revision — branch + `slim-hotfix/<svc>[-<line>]`, title `chore(stack): pin <svc> <release_version>`; +- an **upgrade**, when the newest committed upstream on that line is newer than the pinned one — + branch `slim-bump/<svc>[-<line>]`, title `chore(stack): bump <svc> to <release_version>`. + +Both can be planned in the same run. Plan and apply run from the same checkout of the default +branch in one job, so a re-run always recomputes from the latest develop: a stale plan can never +be applied, and a superseded PR's branch is rewritten (force-pushed) in place rather than raced +by a new one — the workflow deliberately never auto-closes a superseded PR. A backlog republish +of an older upstream version naturally plans nothing. The fallback, if the push or PR step fails, +is a documented manual `bun .github/scripts/sync-artifacts-catalog.ts --service <svc> --release +<U>-r<N>` invocation, followed by `apps/cli/scripts/render-service-dockerfile.ts` — the release +itself is already committed by then, so a failure here means "open the pull request by hand", not +"republish". + +### Registry and bucket mirrors + +Publish each slim **image** to GHCR and AWS ECR Public (`public.ecr.aws/supabase/cli/<service>`), +digest-preserving, via `mirror-slim-image.yml` and `.github/scripts/mirror-slim-image.ts`. +Publish each slim **native** archive as an OCI artifact on the same two repositories under +`:R-native-<target>`. A public S3 bucket on `*.amazonaws.com` +([infra/cli-artifacts](../../infra/cli-artifacts/README.md)) holds native archives for hosts that +cannot reach GitHub Releases. The container runtime pulls a catalog image from GHCR first and +falls back to the same reference on ECR Public. A failing image fallback still reports the +primary's original error. When every native archive mirror fails to serve the pinned bytes, the +error names each mirror with its failure. + +ECR Public tags are always mutable; `ecr-public create-repository` has no immutability flag. +Mirror backfills of a committed revision copy the immutable GHCR bytes by digest, so re-running +one is idempotent. Reuse the existing `PROD_AWS_ROLE` in `supabase/cli`. ## Follow-up -- Native ECR copy is best-effort; a daily mirror audit should report native drift. That audit is not defined in this repository yet. +- Native ECR copy is best-effort; a daily mirror audit should report native drift and treat the + committed GHCR digest as the source of truth for backfills. That audit is not defined in this + repository yet. +- Unpublishing legacy (non-revision) artifacts is planned as a separate task; they stay published + and frozen until then. ## Rationale -ECR Public mirroring already exists for other CLI images and needs no new vendor. Native OCI on those same repos reuses `regctl` and that role. Extracting the workflow into bun scripts lets the copy, digest check, and native payload validation run in CI and locally. +ECR Public mirroring already exists for other CLI images and needs no new vendor. Native OCI on +those same repos reuses `regctl` and that role. Making the GitHub release the single commit +point, rather than a destination-specific immutability guard on each mirror, keeps the +invariant in one place: once `<svc>-U-rN` exists, every downstream copy of it is either correct +or considered stale and backfilled — never rewritten in place. ## Consequences -- Same-version overwrite works on GHCR, GitHub Releases, and ECR Public. -- Images come from the GHCR catalog pin with ECR Public as fallback, and natives from GitHub Releases with the S3 bucket as fallback. -- A GitHub native can be live while the ECR native is stale. -- Old CLI releases keep pulling the previous image digest. +- Hotfixing packaging never touches previously published bytes; it always publishes a new + revision. +- The CLI verifies every artifact it downloads against a pin already committed to its own + catalog — no destination is trusted to supply the expected hash at runtime. +- A same-version republish is no longer possible; every fix is a new, allocatable revision. +- A revision's ECR/S3 copy can briefly lag GitHub after publish; the sync's S3-staleness check + and the mirror backfill are what keep them converging. +- Old CLI releases keep pulling their originally pinned image digest and native bytes forever. + +### Tradeoffs of moving updates onto the catalog + +- Upstream and security fixes for slim-capable images now reach the CLI only once + slim-services publishes a release. Direct Dependabot discovery is gone for them; Dependabot's + `docker` ecosystem `ignore` list (`.github/dependabot.yml`) now excludes every slim-capable + image plus `library/kong` and `supabase/postgres`, so it bumps only the three images that + remain genuinely upstream-only: migra, pg_prove, pgadmin-schema-diff. +- Dependabot's 7-day cooldown no longer governs the excluded images — hotfix and upgrade PRs for + slim-capable ones land as soon as `slim-release-published` fires, on whatever cadence + slim-services publishes; kong and `pg14` are simply bumped by hand. +- `pg14` has no slim build and was already upstream-only; it is bumped by hand alongside kong, + not by Dependabot — Dependabot's `docker` ecosystem ignores `supabase/postgres` entirely now, + since it cannot tell `pg14`'s `FROM` line apart from `pg`'s and `pg15`'s by repository name + alone. +- The Deno 1 edge-runtime override (`apps/cli/src/shared/functions/functions.shared.ts`, and the + Go `deno1` constant) stays a separately pinned, upstream-only exception — it never goes through + the catalog. ## Alternatives considered -1. **Google Artifact Registry / GCS** — blob host `storage.googleapis.com` is on at least one major sandbox Trusted list. Rejected for this cut: new company-wide vendor. -2. **Public S3 bucket** — HTTPS GET on `*.amazonaws.com` can work without CloudFront. Adopted for native archives. The bucket is not a checksum authority, and image pulls stay on GHCR and ECR Public. +1. **Google Artifact Registry / GCS** — blob host `storage.googleapis.com` is on at least one + major sandbox Trusted list. Rejected for this cut: new company-wide vendor. +2. **Public S3 bucket** — HTTPS GET on `*.amazonaws.com` can work without CloudFront. Adopted for + native archives, as a byte mirror only; the CLI's checksum authority is its own catalog pin, + verified against the release's `SHA256SUMS` once at sync time, not read at runtime. 3. **npm packages** — allowlisted widely, but a published version cannot be replaced. -4. **Docker Hub `supabase/cli-*`** — deferred; blob CDN is also off some default allowlists, and names collide with upstream `supabase/<service>`. -5. **Unpin slim images** — would make old CLIs see a moved tag. Rejected: conflicts with ADR 0017. +4. **Docker Hub `supabase/cli-*`** — deferred; blob CDN is also off some default allowlists, and + names collide with upstream `supabase/<service>`. +5. **Same-version overwrite (`force=true` clobber)** — the original model. Rejected: it broke + already-released CLIs pinned to that version and made a republish silently move bytes out + from under them. Replaced by immutable `-rN` revisions. +6. **A destination-specific immutability guard (for example, an S3 conditional write)** — + rejected in favor of making the GitHub release the single commit point; every other + destination is just a copy that either matches it or is stale. ## Related diff --git a/docs/adr/README.md b/docs/adr/README.md index b25e8af88b..27eb9beae9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -53,11 +53,12 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0007 | [Real-time Progress in Command Handlers](0007-realtime-progress-in-command-handlers.md) | proposed | | 0008 | [Authentication & Token Management](0008-authentication-and-token-management.md) | proposed | | 0009 | [Configuration Schema & Validation](0009-configuration-schema-and-validation.md) | proposed | -| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | proposed | +| 0011 | [CLI Release & Distribution Strategy](0011-cli-release-and-distribution-strategy.md) | accepted | | 0013 | [Live E2E Tests Bypass the Replay Server](0013-live-e2e-bypasses-replay-server.md) | accepted | +| 0014 | [macOS Code Signing & Notarization](0014-macos-code-signing-and-notarization.md) | accepted | | 0015 | [Managed Stack Contract Fixtures](0015-managed-stack-contract-fixtures.md) | superseded | | 0016 | [Legacy Port Completion and Go CLI Authority Scope](0016-legacy-port-completion-and-go-cli-authority-scope.md) | proposed | -| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | accepted | +| 0017 | [Simplified Managed Stack Architecture](0017-simplified-managed-stack-architecture.md) | superseded | | 0018 | [Sparse Config Subtraction](0018-sparse-config-subtraction.md) | proposed | | 0019 | [Raw API-Response Passthrough on API-Sourced Config](0019-config-api-response-passthrough.md) | accepted | | 0020 | [Config Naming Vocabulary](0020-config-naming-vocabulary.md) | accepted | @@ -65,7 +66,7 @@ When an ADR becomes outdated, mark it as `deprecated` or reference the supersedi | 0022 | [Config Diff Classification and Managed Surface](0022-config-diff-classification-and-managed-surface.md) | accepted | | 0023 | [Config Pull Write Strategy and Scope Resolution](0023-config-pull-write-strategy-and-scope-resolution.md) | accepted | | 0024 | [Top-Level `pull` Orchestration](0024-top-level-pull-orchestration.md) | accepted | -| 0025 | [Ephemeral Postgres for Schema Tooling](0025-ephemeral-postgres-for-schema-tooling.md) | proposed | +| 0025 | [Ephemeral Postgres for Schema Tooling](0025-ephemeral-postgres-for-schema-tooling.md) | superseded | | 0026 | [Slim Image and Native Artifact Mirrors](0026-slim-artifact-mirrors.md) | proposed | ## Template diff --git a/infra/cli-artifacts/README.md b/infra/cli-artifacts/README.md index 9413175da1..07f0b01708 100644 --- a/infra/cli-artifacts/README.md +++ b/infra/cli-artifacts/README.md @@ -18,13 +18,22 @@ agent sandboxes that allow `*.amazonaws.com`. Objects are addressed as ``` -https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<version>/<service>-<version>-<target>.tar.zst -https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<version>/<service>-<version>-<target>.manifest.json -https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<version>/<service>-<version>-<target>.SHA256SUMS +https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<release-version>/<service>-<release-version>-<target>.tar.zst +https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<release-version>/<service>-<release-version>-<target>.manifest.json +https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/<service>/<release-version>/<service>-<release-version>-<target>.SHA256SUMS ``` -which keeps the GitHub release asset names, so the `SHA256SUMS` lines match the archive name -without rewriting. +where `<release-version>` is the immutable `<upstream>-r<N>` slim-services revision, which keeps +the GitHub release asset names so the `SHA256SUMS` lines match the archive name without +rewriting. + +This bucket, like GHCR and ECR, is a byte mirror only — it is never a checksum authority. The +CLI does not fetch `SHA256SUMS` (from here or from GitHub) at runtime: it pins each target's +archive and manifest sha256 directly in `packages/stack/src/Artifacts.ts`, written by +`.github/scripts/sync-artifacts-catalog.ts` after that script has already verified this bucket's +copy against the release's committed `SHA256SUMS` (see +[ADR 0026](../../docs/adr/0026-slim-artifact-mirrors.md)). A stale object here fails that sync, +not a CLI download. ## How it deploys diff --git a/knip.jsonc b/knip.jsonc index 524d79f80d..c1edba7619 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -57,8 +57,12 @@ "entry": ["scripts/*.ts"], }, "packages/stack": { - // Source-mode NativeProcess resolves this standalone launcher by URL. - "entry": ["src/runtime/native-launcher.ts"], + "entry": [ + // Source-mode NativeProcess resolves this standalone launcher by URL. + "src/runtime/native-launcher.ts", + // scripts/generate-functions-bootstrap.ts bundles the Functions main service by path. + "src/functions/serve.main.ts", + ], // Used by the raw WebSocket integration fixture; Knip excludes that test-only import. "ignoreDependencies": ["@types/ws", "ws"], }, diff --git a/mise.lock b/mise.lock index beb5bc0ac6..5980635e1a 100644 --- a/mise.lock +++ b/mise.lock @@ -3,53 +3,53 @@ lockfile_version = 1 [[tools.bun]] -version = "1.4.1" +version = "1.4.2" backend = "core:bun" -specifiers = ["1.4.1"] +specifiers = ["1.4.2"] [tools.bun."platforms.linux-arm64"] -checksum = "sha256:580ce77533108dc6b10bec1721397e4f5aa44e909726da2451d483dfc5e581d6" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-aarch64.zip" +checksum = "sha256:54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-aarch64.zip" [tools.bun."platforms.linux-arm64-musl"] -checksum = "sha256:53895807a00508f70e76715947097aa533ec520aac066501c00fb77d2b1a7a6c" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-aarch64-musl.zip" +checksum = "sha256:71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-aarch64-musl.zip" [tools.bun."platforms.linux-x64"] -checksum = "sha256:74c1c3bee7cd998500c8f969cd8972355ac6a07207e94a39eece1999b56ffabf" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64.zip" +checksum = "sha256:36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64.zip" [tools.bun."platforms.linux-x64-baseline"] -checksum = "sha256:a8c9c6738202e2fced555dd860a953c56c0cd059f75041e7010ae81a32802646" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64-baseline.zip" +checksum = "sha256:c678040f14fe0440eb839d37cbd0ce4c051a32da72806ac97de6a6aab6bf728f" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64-baseline.zip" [tools.bun."platforms.linux-x64-musl"] -checksum = "sha256:ea116fe09f2f764c87b9bf735225b781d1f7674ca58d66040470f38a7943c8ab" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64-musl.zip" +checksum = "sha256:4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64-musl.zip" [tools.bun."platforms.linux-x64-musl-baseline"] -checksum = "sha256:74d04038a21e6ae816f9e74525b754b85294be99632b336cb4c57019c9313829" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-linux-x64-musl-baseline.zip" +checksum = "sha256:76e1db84e98f22f78de0a87e309bfbbf297732847f9720db36750646c85c8c18" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-linux-x64-musl-baseline.zip" [tools.bun."platforms.macos-arm64"] -checksum = "sha256:d8973ce835fa7867e5cc79afee6fc6f1ae0117aa4bd5fc2546fd00c512f71386" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-darwin-aarch64.zip" +checksum = "sha256:90987a3a16d7db556d886ac3d551e7b6d3edf0a1cf43acaed622e8676be1d12f" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-darwin-aarch64.zip" [tools.bun."platforms.macos-x64"] -checksum = "sha256:8f34239f276a3f0d27bfcd1ffecfe5d2127e74fb0aa4c0971a0cdec7b225c965" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-darwin-x64.zip" +checksum = "sha256:80520d7e17526308c9185d261679ac6d27798d3803a0e9f7ff9121ab8affb012" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-darwin-x64.zip" [tools.bun."platforms.macos-x64-baseline"] -checksum = "sha256:498e76d61bbe87d2306f65fed60ae86b6b0c8ee2da709f83202808db1a09e407" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-darwin-x64-baseline.zip" +checksum = "sha256:bad5bbd6cf14d0980d115f5954c9ff904df619d5e994d2da1ffccd3f316300b0" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-darwin-x64-baseline.zip" [tools.bun."platforms.windows-x64"] -checksum = "sha256:52b1f3028b01f43d37fefdf669d034a1ee2e0d96c56bb13c393bdaf169b1af84" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-windows-x64.zip" +checksum = "sha256:ce4c17497b2f29712a99d3d53f028de28cd42e3bacb8589599e7f000e49b6405" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-windows-x64.zip" [tools.bun."platforms.windows-x64-baseline"] -checksum = "sha256:2d1871e72b28165a8574a9c91de867cbe499f55b8c75facf4fb9e42888eddba6" -url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.1/bun-windows-x64-baseline.zip" +checksum = "sha256:78c221c2376f79731ccf4e4af0b3bb46d81fefa3296c5abee09ad8a1b21e68c6" +url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/bun-windows-x64-baseline.zip" [[tools.go]] version = "1.26.5" @@ -172,109 +172,121 @@ url_api = "https://api.github.com/repos/golangci/golangci-lint/releases/assets/4 provenance = "github-attestations" [[tools.node]] -version = "24.20.0" +version = "24.21.0" backend = "core:node" specifiers = ["24"] [tools.node."platforms.linux-arm64"] -checksum = "sha256:3515603e2487879a39bc75716f1a2affd027500c64ba50e845cf72cb33219013" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-arm64.tar.gz" +checksum = "sha256:724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-arm64.tar.gz" [tools.node."platforms.linux-arm64-musl"] -checksum = "sha256:2c8c507ccb0f20812d9526ba8ca454b1652aadef68fc8bad06f07fb1122dd1ef" -url = "https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-arm64-musl.tar.gz" +checksum = "sha256:3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4" +url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-arm64-musl.tar.gz" [tools.node."platforms.linux-x64"] -checksum = "sha256:855d581f8a4eb1a8117e3426de25fe02770592febcfb31369aee1ffbfee9e8ec" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-x64.tar.gz" +checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz" [tools.node."platforms.linux-x64-baseline"] -checksum = "sha256:855d581f8a4eb1a8117e3426de25fe02770592febcfb31369aee1ffbfee9e8ec" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-linux-x64.tar.gz" +checksum = "sha256:6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-linux-x64.tar.gz" [tools.node."platforms.linux-x64-musl"] -checksum = "sha256:9ae1399fef4bd8990e15773ce1327b336a20b9e97d8c7549f4f42ca73c43f562" -url = "https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-x64-musl.tar.gz" +checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a" +url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz" [tools.node."platforms.linux-x64-musl-baseline"] -checksum = "sha256:9ae1399fef4bd8990e15773ce1327b336a20b9e97d8c7549f4f42ca73c43f562" -url = "https://unofficial-builds.nodejs.org/download/release/v24.20.0/node-v24.20.0-linux-x64-musl.tar.gz" +checksum = "sha256:01a713e34da98e7b4d2a3191ee7ca3f93dd0384d1a2ef23c379b2161ee6e133a" +url = "https://unofficial-builds.nodejs.org/download/release/v24.21.0/node-v24.21.0-linux-x64-musl.tar.gz" [tools.node."platforms.macos-arm64"] -checksum = "sha256:40e5607e5ecb3db9192723776da2d75d966260fc74a7a9e731c1bd67dda96bc8" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-darwin-arm64.tar.gz" +checksum = "sha256:bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-arm64.tar.gz" [tools.node."platforms.macos-x64"] -checksum = "sha256:9e5b2644cf107befb6aefca676b96d3296bc10138096f022ed378d6233ed81f4" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-darwin-x64.tar.gz" +checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz" [tools.node."platforms.macos-x64-baseline"] -checksum = "sha256:9e5b2644cf107befb6aefca676b96d3296bc10138096f022ed378d6233ed81f4" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-darwin-x64.tar.gz" +checksum = "sha256:1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-darwin-x64.tar.gz" [tools.node."platforms.windows-x64"] -checksum = "sha256:6cac9ffbca8f6a47091e4b5c772e0606049c3871cb67d900c0cedde630e545ba" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-win-x64.zip" +checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip" [tools.node."platforms.windows-x64-baseline"] -checksum = "sha256:6cac9ffbca8f6a47091e4b5c772e0606049c3871cb67d900c0cedde630e545ba" -url = "https://nodejs.org/dist/v24.20.0/node-v24.20.0-win-x64.zip" +checksum = "sha256:158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541" +url = "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip" [[tools.pnpm]] -version = "12.4.0" +version = "12.8.1" backend = "aqua:pnpm/pnpm" -specifiers = ["12.4.0"] +specifiers = ["12.8.1"] [tools.pnpm."platforms.linux-arm64"] -checksum = "sha256:74fc88eacd2975df4ec5bae517482986ce824bc35a7ac5965c1baf1c854fe933" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351855" +checksum = "sha256:75c5e34d3164fe3d3549580b1b1b59c9dd16543e558dec722b686193cdb7dfa1" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935899" provenance = "github-attestations" [tools.pnpm."platforms.linux-arm64-musl"] -checksum = "sha256:e2e9ec97dbf6165e0936883c078850af967fd7842cfabbb130c24f594bba589f" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-arm64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351859" +checksum = "sha256:27ad77bdf5368c1747f46fff0d0ee213ed6185aaf69e13342964f5f7cc3f8714" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-arm64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935889" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64"] -checksum = "sha256:fcff7ecad46365d356718a40a88a72b4c02eae9d075d567e032697d769f3d999" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351861" +checksum = "sha256:db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935887" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-baseline"] -checksum = "sha256:fcff7ecad46365d356718a40a88a72b4c02eae9d075d567e032697d769f3d999" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351861" +checksum = "sha256:db3906881f63e41b655e3b97d473603dc26326c2470d0b1b8083689e1bcbd1e8" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935887" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-musl"] -checksum = "sha256:cb90483341f8601d7d62c93dbd3f17f057e1d95f8cc8720cfe6df4411a616bde" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-x64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351865" +checksum = "sha256:f0a2db13d0a1b63c0053a5ddcfbf5c454b040e7ef54ca8771750e2cb55b86693" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-x64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935901" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-musl-baseline"] -checksum = "sha256:cb90483341f8601d7d62c93dbd3f17f057e1d95f8cc8720cfe6df4411a616bde" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-linux-x64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351865" +checksum = "sha256:f0a2db13d0a1b63c0053a5ddcfbf5c454b040e7ef54ca8771750e2cb55b86693" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-linux-x64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935901" provenance = "github-attestations" [tools.pnpm."platforms.macos-arm64"] -checksum = "sha256:dae611f18e7acc416fac9134ac86fcb1cc8670868ea2e5a51e82e184aec01d63" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-darwin-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351864" +checksum = "sha256:8eeeae4cd714b2f1755750d303f5b1bcfe23d95ed7245536305d0c3877c5ce41" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935883" +provenance = "github-attestations" + +[tools.pnpm."platforms.macos-x64"] +checksum = "sha256:8cbc5a840b4bcd8c0da878e6616a832d88e98a8acbedf1736310b395467f4a13" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-darwin-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935890" +provenance = "github-attestations" + +[tools.pnpm."platforms.macos-x64-baseline"] +checksum = "sha256:8cbc5a840b4bcd8c0da878e6616a832d88e98a8acbedf1736310b395467f4a13" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-darwin-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935890" provenance = "github-attestations" [tools.pnpm."platforms.windows-x64"] -checksum = "sha256:0c25c9921d61171c1551ed38cf6bc5240bcd3acaff81431a4edb5fe33c720674" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-win32-x64.zip" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351858" +checksum = "sha256:823fc131326afeee292e113e2299ca9851c3ee5148a7de921c897f0170de6f05" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-win32-x64.zip" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935888" provenance = "github-attestations" [tools.pnpm."platforms.windows-x64-baseline"] -checksum = "sha256:0c25c9921d61171c1551ed38cf6bc5240bcd3acaff81431a4edb5fe33c720674" -url = "https://github.com/pnpm/pnpm/releases/download/v12.4.0/pnpm-win32-x64.zip" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/550351858" +checksum = "sha256:823fc131326afeee292e113e2299ca9851c3ee5148a7de921c897f0170de6f05" +url = "https://github.com/pnpm/pnpm/releases/download/v12.8.1/pnpm-win32-x64.zip" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/595935888" provenance = "github-attestations" diff --git a/package.json b/package.json index 0dcaf3b62c..8f86e271b2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "prepare": "effect-tsgo patch --no-typescript --oxlint && husky", "build": "pnpm exec turbo run build", - "generate": "pnpm exec turbo run @supabase/api#generate && pnpm exec turbo run @supabase/docs#generate", + "generate": "pnpm exec turbo run @supabase/api#generate @supabase/stack#generate && pnpm exec turbo run @supabase/docs#generate", "test:live": "pnpm exec turbo run supabase#test:live --concurrency=1 --", "record": "pnpm exec turbo run @supabase/cli-e2e#record --", "test:smoke": "pnpm exec turbo run supabase#test:smoke --", @@ -52,7 +52,7 @@ "devEngines": { "packageManager": { "name": "pnpm", - "version": "12.4.0", + "version": "12.8.1", "onFail": "warn" } } diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 716e098581..b1b7bd52ac 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -5282,6 +5282,13 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), presence_enabled: Schema.Boolean.annotate({ description: "Whether to enable presence" }), + admin_suspended_at: Schema.Union([ + Schema.String.annotate({ + description: "If set, the Realtime service has been suspended by an admin.", + format: "date-time", + }), + Schema.Null, + ]), }); export const V1GetRestorePointInput = Schema.Struct({ ref: Schema.String.check( @@ -9914,7 +9921,13 @@ export const V2AssignOrganizationMemberRoleInput = Schema.Struct({ data: Schema.Struct({ type: Schema.Literal("organization_member_role").annotate({ description: "Resource type." }), attributes: Schema.Struct({ - role: Schema.Literals(["owner", "administrator", "developer", "read-only"]).annotate({ + role: Schema.Literals([ + "owner", + "administrator", + "developer", + "read-only", + "no-access", + ]).annotate({ description: "Role name to assign. Must be one of: owner, administrator, developer, read-only. Must be on a Team or Enterprise plan to use the read-only role.", }), @@ -10711,9 +10724,15 @@ export const V2CreateOrganizationInvitationsInput = Schema.Struct({ "a string matching the RegExp ^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", }), ), - role: Schema.Literals(["owner", "administrator", "developer", "read-only"]).annotate({ + role: Schema.Literals([ + "owner", + "administrator", + "developer", + "read-only", + "no-access", + ]).annotate({ description: - "Role name to assign. Must be on a Team or Enterprise plan to use the read-only role.", + "Role name to assign. Must be on an Enterprise plan to use the read-only or no-access roles. no-access grants no project visibility until project-scoped roles are assigned separately.", }), projects: Schema.optionalKey( Schema.Array( @@ -10910,6 +10929,10 @@ export const V2CreatePrivateLinkAssociationOutput = Schema.Struct({ description: "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", }), + custom_dns_name: Schema.String.annotate({ + description: + "The custom DNS name configured on the AWS VPC Lattice resource configuration.", + }), resource_access_manager_resource_config_id: Schema.optionalKey( Schema.String.annotate({ description: @@ -12879,6 +12902,10 @@ export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ description: "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", }), + custom_dns_name: Schema.String.annotate({ + description: + "The custom DNS name configured on the AWS VPC Lattice resource configuration.", + }), resource_access_manager_resource_config_id: Schema.optionalKey( Schema.String.annotate({ description: diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 5136f2fe06..60c3ec30c1 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -22826,6 +22826,13 @@ "presence_enabled": { "type": "boolean", "description": "Whether to enable presence" + }, + "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)))$", + "description": "If set, the Realtime service has been suspended by an admin.", + "nullable": true } }, "required": [ @@ -22840,7 +22847,8 @@ "max_presence_events_per_second", "max_payload_size_in_kb", "suspend", - "presence_enabled" + "presence_enabled", + "admin_suspended_at" ] }, "UpdateRealtimeConfigBody": { @@ -27269,6 +27277,10 @@ "type": "string", "description": "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier." }, + "custom_dns_name": { + "type": "string", + "description": "The custom DNS name configured on the AWS VPC Lattice resource configuration." + }, "resource_access_manager_resource_config_id": { "description": "ID of the AWS VPC Lattice resource configuration backing this PrivateLink share.", "type": "string" @@ -27287,7 +27299,8 @@ "status", "shared_at", "database_type", - "database_identifier" + "database_identifier", + "custom_dns_name" ] } }, @@ -27392,6 +27405,10 @@ "type": "string", "description": "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier." }, + "custom_dns_name": { + "type": "string", + "description": "The custom DNS name configured on the AWS VPC Lattice resource configuration." + }, "resource_access_manager_resource_config_id": { "description": "ID of the AWS VPC Lattice resource configuration backing this PrivateLink share.", "type": "string" @@ -27410,7 +27427,8 @@ "status", "shared_at", "database_type", - "database_identifier" + "database_identifier", + "custom_dns_name" ] } }, @@ -27850,7 +27868,7 @@ "properties": { "role": { "type": "string", - "enum": ["owner", "administrator", "developer", "read-only"], + "enum": ["owner", "administrator", "developer", "read-only", "no-access"], "description": "Role name to assign. Must be one of: owner, administrator, developer, read-only. Must be on a Team or Enterprise plan to use the read-only role.", "example": "developer" }, @@ -27986,8 +28004,8 @@ }, "role": { "type": "string", - "enum": ["owner", "administrator", "developer", "read-only"], - "description": "Role name to assign. Must be on a Team or Enterprise plan to use the read-only role.", + "enum": ["owner", "administrator", "developer", "read-only", "no-access"], + "description": "Role name to assign. Must be on an Enterprise plan to use the read-only or no-access roles. no-access grants no project visibility until project-scoped roles are assigned separately.", "example": "developer" }, "projects": { diff --git a/packages/config/src/db.ts b/packages/config/src/db.ts index 28e1fdabe6..67f5d3219f 100644 --- a/packages/config/src/db.ts +++ b/packages/config/src/db.ts @@ -92,6 +92,13 @@ export const db = Schema.Struct({ tags, links: [links.postgres], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMajorVersion))), + orioledb_version: Schema.optionalKey( + Schema.String.annotate({ + description: + "OrioleDB version of the Postgres image to use for the local database. Requires `major_version` 15 or 17.", + tags, + }), + ), pooler: Schema.Struct({ enabled: Schema.Boolean.annotate({ default: defaultPoolerEnabled, diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 7f1d6e8466..92473d5e5e 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -495,6 +495,7 @@ describe("src/internal.ts export surface", () => { "decodeCliConfigDocumentForValidationEffect", "dualScopeProjectConfigPaths", "loadCliConfig", + "normalizeDeprecatedOrioleDBVersion", "projectConfigApiBlockKeys", "projectConfigMappingRows", "remoteNameForProjectRef", diff --git a/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 83d0bbcbad..8086e802ed 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -47,7 +47,8 @@ export const experimental = Schema.Struct({ ), orioledb_version: Schema.optionalKey( Schema.String.annotate({ - description: "Postgres storage engine version for OrioleDB.", + description: + "Deprecated: use `db.orioledb_version` instead. Postgres storage engine version for OrioleDB.", tags, }), ), diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index 42964d68c7..e7e4c33a36 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -31,5 +31,6 @@ export { writeCliConfigDocumentText, decodeCliConfigDocumentForValidationEffect, type DecodeCliConfigDocumentForValidationEffectOptions, + normalizeDeprecatedOrioleDBVersion, } from "./io.ts"; export { CliConfigWriteError } from "./errors.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index ce4c9284a5..dad9fc13c2 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -254,6 +254,103 @@ function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocum return { document: normalized, deprecatedSections }; } +export interface NormalizedOrioleDBVersionDocument { + readonly document: unknown; + /** + * Dotted paths whose legacy `experimental.orioledb_version` was non-empty, e.g. + * `experimental.orioledb_version` or `remotes.staging.experimental.orioledb_version`. Emitted + * whether or not the value was actually promoted (an explicit `db.orioledb_version` still + * wins, but the legacy key is deprecated either way). + */ + readonly deprecatedPaths: ReadonlyArray<string>; +} + +/** + * Moves a section's string `experimental.orioledb_version` out of `experimental`, promoting a + * non-empty value to `db.orioledb_version` when that is absent or `""` (the init template always + * writes `""`). Non-string values, a non-empty `db.orioledb_version`, and a non-table `db` are + * left untouched so schema validation still sees them. + */ +function promoteOrioleDBVersion(section: Record<string, unknown>): { + readonly section: Record<string, unknown>; + readonly warned: boolean; +} { + const experimental = section.experimental; + if (!isObject(experimental) || !("orioledb_version" in experimental)) { + return { section, warned: false }; + } + const legacyValue = experimental.orioledb_version; + if (typeof legacyValue !== "string") { + return { section, warned: false }; + } + const legacyNonEmpty = legacyValue.length > 0; + + const normalizedExperimental = { ...experimental }; + delete normalizedExperimental.orioledb_version; + const normalizedSection: Record<string, unknown> = { + ...section, + experimental: normalizedExperimental, + }; + + if (!legacyNonEmpty) { + return { section: normalizedSection, warned: false }; + } + if ("db" in section && !isObject(section.db)) { + return { section: normalizedSection, warned: true }; + } + + const db = isObject(section.db) ? section.db : undefined; + const dbValue = db?.orioledb_version; + const dbCanBePromotedOver = dbValue === undefined || dbValue === ""; + if (dbCanBePromotedOver) { + normalizedSection.db = { ...db, orioledb_version: legacyValue }; + } + + return { section: normalizedSection, warned: true }; +} + +/** + * Rewrites the deprecated `experimental.orioledb_version` (top-level and per `[remotes.*]`) to + * `db.orioledb_version`, following the same shape as {@link normalizeDeprecatedSMTPSections}. + * Exposed via `@supabase/config/internal` so `apps/cli`'s raw TOML reader + * (`db-config.toml-read.ts`) can apply the same precedence before its own `[remotes.*]` merge. + */ +export function normalizeDeprecatedOrioleDBVersion( + document: unknown, +): NormalizedOrioleDBVersionDocument { + if (!isObject(document)) { + return { document, deprecatedPaths: [] }; + } + const deprecatedPaths: Array<string> = []; + let normalized: Record<string, unknown> = { ...document }; + + const top = promoteOrioleDBVersion(normalized); + normalized = top.section; + if (top.warned) { + deprecatedPaths.push("experimental.orioledb_version"); + } + + if (isObject(normalized.remotes)) { + normalized = { + ...normalized, + remotes: Object.fromEntries( + Object.entries(normalized.remotes).map(([name, remote]) => { + if (!isObject(remote)) { + return [name, remote]; + } + const result = promoteOrioleDBVersion(remote); + if (result.warned) { + deprecatedPaths.push(`remotes.${name}.experimental.orioledb_version`); + } + return [name, result.section]; + }), + ), + }; + } + + return { document: normalized, deprecatedPaths }; +} + interface NormalizedExternalProvidersDocument { readonly document: unknown; /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled`. */ @@ -477,7 +574,10 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( try: () => parseCliConfigDocument(content, format), catch: (cause) => new CliConfigParseError({ path: filePath, format, cause }), }); - const { document: normalized, deprecatedSections } = normalizeDeprecatedSMTPSections(document); + const { document: smtpNormalized, deprecatedSections } = + normalizeDeprecatedSMTPSections(document); + const { document: normalized, deprecatedPaths: deprecatedOrioleDBPaths } = + normalizeDeprecatedOrioleDBVersion(smtpNormalized); // Warn on stderr, writing directly to the real console (bypassing whatever `Console.Console` // is ambient) so a caller wrapping this in a deferred/buffered console can't delay or // swallow it. @@ -487,6 +587,12 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( `WARN: config section [${section}] is deprecated. Please use [${replacement}] instead.`, ).pipe(Effect.provideService(Console.Console, globalThis.console)); } + for (const path of deprecatedOrioleDBPaths) { + const replacement = path.replace(/experimental\.orioledb_version$/, "db.orioledb_version"); + yield* Console.error(`WARN: ${path} is deprecated. Please use ${replacement} instead.`).pipe( + Effect.provideService(Console.Console, globalThis.console), + ); + } // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env before schema // decode, since a numeric/boolean field would otherwise crash the strict decoder on a string. diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 16151f74eb..a6a219c264 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -2413,6 +2413,203 @@ port = 54324 }); }); +describe("config io deprecated experimental.orioledb_version back-compat", () => { + let warnings: Array<string> = []; + let errorSpy: MockInstance<typeof console.error> | undefined; + + function captureWarnings() { + warnings = []; + errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + } + + afterEach(() => { + errorSpy?.mockRestore(); + errorSpy = undefined; + }); + + async function loadToml(contents: string) { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile(path, contents); + try { + return await runConfigEffect(loadCliConfigFile(path)); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + + test("promotes a non-empty legacy experimental.orioledb_version to db.orioledb_version", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[experimental] +orioledb_version = "15.1.0.150" +`, + ); + + expect(loaded.config.db.orioledb_version).toBe("15.1.0.150"); + expect(loaded.config.experimental.orioledb_version).toBeUndefined(); + expect(loaded.document).not.toHaveProperty("experimental.orioledb_version"); + expect( + warnings.some((m) => + m.includes( + "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", + ), + ), + ).toBe(true); + }); + + test("does not warn and stays absent when the legacy value is an empty string", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[experimental] +orioledb_version = "" +`, + ); + + expect(loaded.config.db.orioledb_version).toBeUndefined(); + expect(loaded.config.experimental.orioledb_version).toBeUndefined(); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }); + + test("promotes a non-empty legacy value when db.orioledb_version is present but empty", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[db] +orioledb_version = "" + +[experimental] +orioledb_version = "15.1.0.150" +`, + ); + + expect(loaded.config.db.orioledb_version).toBe("15.1.0.150"); + expect( + warnings.some((m) => + m.includes( + "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", + ), + ), + ).toBe(true); + }); + + test("an explicit db.orioledb_version wins over a non-empty legacy value, but still warns", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[db] +orioledb_version = "17.0.0.1" + +[experimental] +orioledb_version = "15.1.0.150" +`, + ); + + expect(loaded.config.db.orioledb_version).toBe("17.0.0.1"); + expect( + warnings.some((m) => + m.includes( + "WARN: experimental.orioledb_version is deprecated. Please use db.orioledb_version instead.", + ), + ), + ).toBe(true); + }); + + test("rejects a non-string legacy experimental.orioledb_version instead of silently discarding it", async () => { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + path, + `project_id = "abc123" + +[experimental] +orioledb_version = 1 +`, + ); + try { + const exit = await Effect.runPromiseExit( + loadCliConfigFile(path).pipe(Effect.provide(BunServices.layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value._tag).toBe("CliConfigParseError"); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("rejects a non-string canonical db.orioledb_version beside a valid legacy value", async () => { + const cwd = makeTempProject(); + const path = await runConfigEffect(configTomlPath(cwd)); + await mkdir(join(cwd, "supabase"), { recursive: true }); + await writeFile( + path, + `project_id = "abc123" + +[db] +orioledb_version = 1 + +[experimental] +orioledb_version = "15.1.0.150" +`, + ); + try { + const exit = await Effect.runPromiseExit( + loadCliConfigFile(path).pipe(Effect.provide(BunServices.layer)), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value._tag).toBe("CliConfigParseError"); + } + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("normalizes a deprecated remotes.*.experimental.orioledb_version", async () => { + captureWarnings(); + const loaded = await loadToml( + `project_id = "abc123" + +[remotes.staging] +project_id = "stagingrefaaaaaaaaaa" + +[remotes.staging.experimental] +orioledb_version = "15.1.0.150" +`, + ); + + const staging = loaded.config.remotes.staging; + expect(staging?.db?.orioledb_version).toBe("15.1.0.150"); + expect( + warnings.some((m) => + m.includes( + "WARN: remotes.staging.experimental.orioledb_version is deprecated. Please use remotes.staging.db.orioledb_version instead.", + ), + ), + ).toBe(true); + }); +}); + describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () => { let warnings: Array<string> = []; let errorSpy: MockInstance<typeof console.error> | undefined; diff --git a/packages/config/src/project-config/hosted-sections.ts b/packages/config/src/project-config/hosted-sections.ts index 927d39fcbf..d8e924bcb6 100644 --- a/packages/config/src/project-config/hosted-sections.ts +++ b/packages/config/src/project-config/hosted-sections.ts @@ -21,7 +21,9 @@ export type HostedSectionKey = (typeof HOSTED_SECTION_KEYS)[number]; * `ProjectConfig`'s shape, `ProjectConfigSchema`/`toProjectConfigJsonSchema`, and * `fromConfigDocument`'s output alike: local bind ports/TLS overrides, `db.pooler`'s * `enabled`/`port`, the `db.migrations`/`db.seed` subtrees, every config-side `realtime.*` field, - * and local-only `experimental.*` engine/backend selection. + * `db.orioledb_version` (plus its deprecated `experimental.orioledb_version` alias — a document + * that hasn't gone through the loader's deprecation normalization can still carry it), and + * local-only `experimental.*` engine/backend selection. * * `db.major_version` and `db.pooler`'s other three fields (`pool_mode`, `default_pool_size`, * `max_client_conn`) are real hosted facts and excluded from this list, so `config @@ -38,6 +40,7 @@ export const DOCUMENT_ONLY_LOCAL_PATHS = [ ["db", "port"], ["db", "shadow_port"], ["db", "health_timeout"], + ["db", "orioledb_version"], ["db", "pooler", "enabled"], ["db", "pooler", "port"], ["db", "migrations"], diff --git a/packages/config/src/project-config/project-config.unit.test.ts b/packages/config/src/project-config/project-config.unit.test.ts index 4ba7089baf..4961bf83ea 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -579,6 +579,7 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { shadow_port: 2, health_timeout: "5m", major_version: 15, + orioledb_version: "1.0", pooler: { enabled: true, port: 7777, pool_mode: "session" }, migrations: { enabled: false }, seed: { enabled: false }, @@ -1100,6 +1101,41 @@ describe("fromApiProjectConfig — auth section", () => { }); }); + test("a named provider with a null identity attribute is unconfigured, never enabled", () => { + const result = fromApiProjectConfig({ + auth: { sms_provider: "twilio", external_phone_enabled: false, sms_twilio_account_sid: null }, + }); + expect(result.auth?.sms).toEqual({ + enable_signup: false, + twilio: { enabled: false }, + twilio_verify: { enabled: false }, + messagebird: { enabled: false }, + textlocal: { enabled: false }, + vonage: { enabled: false }, + }); + }); + + test.each([ + ["twilio", "sms_twilio_account_sid"], + ["twilio_verify", "sms_twilio_verify_account_sid"], + ["messagebird", "sms_messagebird_originator"], + ["textlocal", "sms_textlocal_sender"], + ["vonage", "sms_vonage_from"], + ] as const)("%s is enabled only with its identity attribute set", (provider, key) => { + const auth = { sms_provider: provider, external_phone_enabled: true }; + const identified = fromApiProjectConfig({ auth: { ...auth, [key]: "id" } }); + expect(identified.auth?.sms?.[provider]?.enabled).toBe(true); + for (const blank of ["", null]) { + const result = fromApiProjectConfig({ auth: { ...auth, [key]: blank } }); + expect(result.auth?.sms?.[provider]?.enabled).toBe(false); + } + }); + + test("an identity attribute without sms_provider names no provider", () => { + const result = fromApiProjectConfig({ auth: { sms_twilio_account_sid: "AC1" } }); + expect(result.auth?.sms).toEqual({ twilio: { account_sid: "AC1" } }); + }); + test("external_github_enabled maps to auth.external.github.enabled", () => { const result = fromApiProjectConfig({ auth: { external_github_enabled: true } }); expect(result.auth?.external?.github).toEqual({ enabled: true }); diff --git a/packages/config/src/project-config/registry-auth.ts b/packages/config/src/project-config/registry-auth.ts index f456fc1bdd..28ef052255 100644 --- a/packages/config/src/project-config/registry-auth.ts +++ b/packages/config/src/project-config/registry-auth.ts @@ -804,23 +804,33 @@ const smsBaseRows: ReadonlyArray<ProjectConfigMappingRow> = [ }, ]; -// A single `sms_provider` string names exactly one active provider; reconciled unconditionally -// since a standalone mapping has no local document to consult for "already enabled". An -// unrecognized value maps every provider's `enabled` to `false` rather than surfacing as a bug — -// there's no single field to flag it against, but the raw string stays reachable at -// `_apiResponse.auth.sms_provider`. +// A single `sms_provider` string names exactly one active provider, but a provider whose identity +// attribute is explicitly unset is unconfigured, never enabled. An unrecognized value maps +// every provider's `enabled` to `false` rather than surfacing as a bug — there's no single field to +// flag it against, but the raw string stays reachable at `_apiResponse.auth.sms_provider`. const SMS_PROVIDERS = ["twilio", "twilio_verify", "messagebird", "textlocal", "vonage"] as const; +const SMS_IDENTITY_ATTRIBUTES: Record<(typeof SMS_PROVIDERS)[number], string> = { + twilio: "sms_twilio_account_sid", + twilio_verify: "sms_twilio_verify_account_sid", + messagebird: "sms_messagebird_originator", + textlocal: "sms_textlocal_sender", + vonage: "sms_vonage_from", +}; + const smsProviderSelectionRows: ReadonlyArray<ProjectConfigMappingRow> = SMS_PROVIDERS.map( (provider) => ({ configPath: ["auth", "sms", provider, "enabled"], apiPath: ["auth", "sms_provider"], - // Null/empty → omit all five (no provider named); a non-string throws like every other + alsoConsumes: [["auth", SMS_IDENTITY_ATTRIBUTES[provider]]], + // Absent/null/empty → omit all five (no provider named); a non-string throws like every other // mapped field. - transform: (value) => { - if (value === null) return undefined; + transform: (value, attributes) => { + if (value === undefined || value === null) return undefined; const named = expectString(value, ["auth", "sms_provider"]); - return named.length > 0 ? named === provider : undefined; + if (named.length === 0) return undefined; + const identity = readAuthAttribute(attributes, SMS_IDENTITY_ATTRIBUTES[provider]); + return named === provider && identity !== null && identity !== ""; }, }), ); diff --git a/packages/stack/ARCHITECTURE.md b/packages/stack/ARCHITECTURE.md index 380d2a1cef..51017714da 100644 --- a/packages/stack/ARCHITECTURE.md +++ b/packages/stack/ARCHITECTURE.md @@ -1,10 +1,10 @@ # Stack package architecture -This document defines the stack package architecture. The package implements the service graph, lifecycle, composition, proxy, tools, and detached owner described here. CLI integration is a separate consumer concern. +This document defines the stack package architecture. The package implements the service graph, lifecycle, composition, proxy, commands, and detached owner described here. CLI integration is a separate consumer concern. ## Core model -Use **a graph of service instances, with a small serialized lifecycle for each instance**. Keep application readiness separate from that lifecycle. Add a proxy that starts services on public traffic and sleeps them on public inactivity. Run finite tools with the same identity, artifacts and execution primitives, without treating them as services. +Use **a graph of service instances, with a small serialized lifecycle for each instance**. Keep application readiness separate from that lifecycle. Add a proxy that starts services on public traffic and sleeps them on public inactivity. Run finite commands with the same identity, artifacts and execution primitives, without treating them as services. The critical simplification is: @@ -46,22 +46,24 @@ Each long-running service is an individually identified instance with its own ex ## Implementation organization -Keep the implementation Effect V4 from the domain inward. Promise is the outer facade for package consumers and the boundary for foreign APIs; adapt a foreign Promise once at its leaf with typed errors. Host-owned execution fibers own admitted transitions, while callers may cancel only their wait. Use bounded streams and backpressure for tool and log transport. +Keep the implementation Effect V4 from the domain inward. Promise is the outer facade for package consumers and the boundary for foreign APIs; adapt a foreign Promise once at its leaf with typed errors. Host-owned execution fibers own admitted transitions, while callers may cancel only their wait. Use bounded streams and backpressure for command and log transport. Organize by cohesive responsibilities. The package shape is: - `src/` modules: the instance executor, orchestrator, owner, detached host, networking, persistence, and RPC boundary. - `services/`: one definition per service, owning its configuration, endpoints, launch settings, and readiness. `Catalog.ts` validates and dispatches creation; `Recipe.ts` defines their contract and `ProcessRecipe.ts` shares process mechanics. - `composition/Supabase.ts`: default Supabase membership, dependency edges, and input wiring. -- `host/`: endpoint projections, tool execution, and tool attachment transport. +- `host/`: endpoint projections, command execution, and command attachment transport. - `runtime/`: native and container adapters. -- `Tools.ts`: public finite-tool descriptors. +- `Commands.ts`: public finite-command descriptors. - `effect.ts`: Effect-facing composition and services. -- `index.ts`: Promise-facing public boundary. +- `index.ts`: Promise-facing public boundary. `PromiseClient.ts` derives its types from the Effect handles and adapts them by shape: Effects become cancellable calls, Streams async iterables, and returned handles are adapted recursively. Only operations whose inputs differ (plain creations, Promise command sinks) and the per-kind creation type are written by hand. +- `testing.ts`: disposable, composed session stacks for tests, with database checkpoints, in Promise and Effect forms. +- `Defaults.ts`: shared local-development credentials, exported once as `./defaults`. This is navigational guidance, not a required file scaffold. Split modules when a responsibility needs it; avoid one folder or interface per operation. Keep service definitions narrow, with graph edges and input wiring in composition. Do not introduce capabilities, projections, recovery journals, reservations, public sleep APIs, or extra lifecycle states to force this shape. -Application services use Effect `Context.Service` and `Layer.effect`; consumers obtain their dependencies from the Effect context. Each owner receives an isolated orchestrator graph. Tools share the host lifetime alongside the owner. Individual executors, recipes, and process handles remain scoped resources because a stack owns multiple independently identified instances. +Application services use Effect `Context.Service` and `Layer.effect`; consumers obtain their dependencies from the Effect context. Each owner receives an isolated orchestrator graph. Commands share the host lifetime alongside the owner. Individual executors, recipes, and process handles remain scoped resources because a stack owns multiple independently identified instances. ## 1. Follow Compose's useful separation @@ -126,7 +128,7 @@ Dependency waits and health waits never hold a lifecycle transition open. Artifa | Composition | Explicit member selection, dependency edges, input wiring and eager/lazy activation policy | | Service recipe | Typed configuration inputs, native/container launch specifications, readiness and optional storage operations; no stack graph knowledge | | Service instance | Immutable ID, recipe/configuration, runtime handle, lifecycle and health observations; graph relationships belong to stack composition | -| Tool job | One finite artifact-backed execution belonging to the stack | +| Command job | One finite artifact-backed execution belonging to the stack | The default Supabase composition is a factory that registers, selects and connects instances. Registration establishes ownership; membership establishes participation in the default application. Registering or starting another instance never implicitly adds it to that composition. A shadow database is an ordinary database instance with its own ID, password, ports and data, owned by the same stack but managed individually. @@ -139,7 +141,7 @@ flowchart TB end ShadowA["Standalone database A"] ShadowB["Standalone database B"] - Tool["Temporary tool invocation"] + Command["Temporary command invocation"] end ``` @@ -209,9 +211,9 @@ Make operation scope explicit in the proposed API: | `instance.start/stop/restart/destroy()` | That instance, subject to the ordinary graph rules | | `stack.composition.start/stop/restart()` | Default composition members; start also includes their declared prerequisites | | `stack.stop()` | All owned service instances, including standalone instances | -| `stack.destroy()` | All owned resources, including standalone instances and tool jobs | +| `stack.destroy()` | All owned resources, including standalone instances and command jobs | -Namespace stop also cancels and settles attached jobs before StackHost shutdown; composition stop does not reserve services on behalf of tools. Namespace destruction performs shutdown before owned-data removal. The CLI's full stop uses namespace scope; restarting the application composition does not restart shadows. Composition startup never resurrects a standalone instance. Membership and eager/lazy activation are separate: eager means start when the containing composition is started, not include every registered eager instance. The existing stack-wide start convenience, if retained, delegates to default composition startup only. +Namespace stop also cancels and settles attached jobs before StackHost shutdown; composition stop does not reserve services on behalf of commands. Namespace destruction performs shutdown before owned-data removal. The CLI's full stop uses namespace scope; restarting the application composition does not restart shadows. Composition startup never resurrects a standalone instance. Membership and eager/lazy activation are separate: eager means start when the containing composition is started, not include every registered eager instance. The existing stack-wide start convenience, if retained, delegates to default composition startup only. For example, REST binds to the primary database; a shadow database has no relationship to it unless the caller asks to copy its initialization profile. Functions may run without PostgreSQL or Auth. An API URL in configuration is not automatically a hard lifecycle dependency. @@ -245,7 +247,7 @@ Cancellation has a limited meaning at each boundary: | Preparing artifacts or waiting for execution admission | Abandon this request before its lifecycle operation begins; shared preparation may continue for other callers | | Waiting for an executing instance operation | Stop waiting; the StackHost finishes the operation and its cleanup | | Waiting for readiness or following logs | End the observation without changing lifecycle | -| Running an attached tool invocation | Terminate and clean up that invocation | +| Running an attached command invocation | Terminate and clean up that invocation | A service is stopped by an explicit stop command, not by a disconnected caller. Launch completes at running, so stop during health-starting uses the ordinary stop operation. If the launch ends while a caller awaits readiness, that observation fails rather than attaching to a later launch. @@ -312,7 +314,7 @@ Composition restart is two passes: stop the selected instances in reverse depend A recipe receives ordinary typed configuration and a context for its own resources. A database URL is just an input value: the recipe does not receive its producer’s identity, another service handle, or the stack graph. It does not receive the entire persisted stack document. The orchestrator owns resolving managed outputs into these configuration values. -The backend provides mechanical operations: launch, observe exit/logs, stop and remove exact resources. Native and container implementations differ in commands, mounts, network setup and cleanup, but share the lifecycle contract. Keep one runtime choice per stack; mixed backends and automatic tool-runtime fallback are unnecessary for the current scope. Preparation validates that the selected service/tool artifact exists for that runtime and platform, and reports an unsupported-platform/artifact error otherwise. A native stack requires native artifacts; Windows native support is not implied by a fallback branch in the current CLI. +The backend provides mechanical operations: launch, observe exit/logs, stop and remove exact resources. Native and container implementations differ in commands, mounts, network setup and cleanup, but share the lifecycle contract. Keep one runtime choice per stack; mixed backends and automatic command-runtime fallback are unnecessary for the current scope. Preparation validates that the selected service/command artifact exists for that runtime and platform, and reports an unsupported-platform/artifact error otherwise. A native stack requires native artifacts; Windows native support is not implied by a fallback branch in the current CLI. A successful launch returns an owned runtime handle with readiness observation and cleanup. One shared readiness program belongs to each runtime launch and has a bounded outcome. Readiness timeout or initialization failure leaves the process running with unhealthy status and an actionable error. It does not automatically stop or restart the process. Stop remains available, and traffic/dependent launches do not treat unhealthy as ready. Initialization that requires a running process belongs to that runtime session, not the start transition. For PostgreSQL, healthy means required catalog initialization and credential reconciliation have completed—not merely that TCP accepts a connection. Stopping the running instance closes that session, settling its health probes and initialization helpers as ordinary resource cleanup. There are not two competing lifecycle operations. @@ -353,11 +355,11 @@ Sleep retains the public listener needed to wake. Whole-stack stop closes listen Composition validation permits lazy activation only for instances with a configured public wake endpoint. A route-less prerequisite, such as pg-meta without its own public endpoint, is eager; it is not implicitly armed through a dependent. This avoids a second wake-permission mechanism. Internal sleep is available only for instances with a supported public wake route and an enabled idle policy. Database and Functions need no automatic idle timer by default. Functions inspector access remains possible while health is starting; ordinary application traffic waits for healthy. -## 6. Tools share execution, not service lifecycle +## 6. Commands share execution, not service lifecycle Provide a stack-scoped finite runner for concrete CLI needs such as `pg_dump` and `psql`. Each invocation has a job ID; temp files, logs and runtime resources belong to that stack. Jobs reuse artifact selection and native/container process execution. They have exit results and byte streams, not healthchecks, wake policy or service registrations. -For `pg_dump` and `psql`, container mode launches a temporary tool container from a compatible PostgreSQL image, overriding its entrypoint to run the client command. It may reuse the database service's image, but it creates a separate container without starting another database server or mounting the server's data directory. Native mode launches the corresponding binary from the selected native artifact. The tool definition supplies these two executable forms; the runner supplies stack ownership, arguments, environment, streams and cleanup. Docker supports entrypoint overrides and automatic removal for this execution shape. [Docker run reference](https://docs.docker.com/engine/containers/run/). +For `pg_dump` and `psql`, container mode launches a temporary command container from a compatible PostgreSQL image, overriding its entrypoint to run the client command. It may reuse the database service's image, but it creates a separate container without starting another database server or mounting the server's data directory. Native mode launches the corresponding binary from the selected native artifact. The command definition supplies these two executable forms; the runner supplies stack ownership, arguments, environment, streams and cleanup. Docker supports entrypoint overrides and automatic removal for this execution shape. [Docker run reference](https://docs.docker.com/engine/containers/run/). | Invocation | Native runtime | Container runtime | | ----------------------------- | -------------------------------------------- | ------------------------------------------------------------------------ | @@ -365,48 +367,55 @@ For `pg_dump` and `psql`, container mode launches a temporary tool container fro | Connect to a managed database | Resolved public proxy endpoint | The same public proxy endpoint, addressed from the container network | | Return the result | Stream stdout/stderr and collect exit status | Stream stdout/stderr, collect exit status and remove the owned container | -The caller supplies ordinary arguments and environment values, including any connection string. The tool runner treats them as opaque: it does not resolve service references, infer dependencies or rewrite URLs. The caller can obtain execution-reachable connection values through `service.credentials({ from: "runtime" })`; `from: "host"` is the default for ordinary host clients. This extends the existing read operation rather than adding tool-specific inputs. The networking/endpoint renderer supplies these concrete values; `localhost` inside a tool container is not generally the host. Managed tool traffic uses the public proxy so the existing wake/activity rules apply. Tools publish no listening ports. Dump output streams to the CLI's destination; temporary files, when needed, use the stack/job directory. Use a client version compatible with the target; matching the managed database's selected major is the simple default. [PostgreSQL client compatibility](https://www.postgresql.org/docs/17/app-pgdump.html). +The caller supplies ordinary arguments and environment values, including any connection string. The command runner treats them as opaque: it does not resolve service references, infer dependencies or rewrite URLs. The caller can obtain execution-reachable connection values through `service.credentials({ from: "runtime" })`; `from: "host"` is the default for ordinary host clients. This extends the existing read operation rather than adding command-specific inputs. The networking/endpoint renderer supplies these concrete values; `localhost` inside a command container is not generally the host. Managed command traffic uses the public proxy so the existing wake/activity rules apply. Commands publish no listening ports. Dump output streams to the CLI's destination; temporary files, when needed, use the stack/job directory. Use a client version compatible with the target; matching the managed database's selected major is the simple default. [PostgreSQL client compatibility](https://www.postgresql.org/docs/17/app-pgdump.html). -Managed-local execution uses this temporary-container pattern under stack ownership. Executing a command inside an existing service container is unnecessary for these network clients and need not become a second public tool mechanism. +Managed-local execution uses this temporary-container pattern under stack ownership. Executing a command inside an existing service container is unnecessary for these network clients and need not become a second public command mechanism. For the managed local backend, the StackHost owns jobs so it can clean up exact resources after client disconnection. Attached-job cancellation stops that job, not a service transition. Explicit StackHost shutdown cancels and settles attached jobs; finishing the last job does not automatically retire the host. Use bounded, backpressured stdin/stdout/stderr transport; do not buffer entire dumps or confuse output EOF with successful exit. -Tools do not participate in the service dependency graph. Traffic to a public endpoint wakes a sleeping service through the proxy, exactly as traffic from any other client does. An explicitly stopped service remains stopped. There are no tool-specific readiness checks or lifecycle locks: an explicit stop or restart can disrupt the connection and the tool reports its ordinary error. Internal bootstrap helpers use the same low-level runner under their parent service operation. +Commands do not participate in the service dependency graph. Traffic to a public endpoint wakes a sleeping service through the proxy, exactly as traffic from any other client does. An explicitly stopped service remains stopped. There are no command-specific readiness checks or lifecycle locks: an explicit stop or restart can disrupt the connection and the command reports its ordinary error. Internal bootstrap helpers use the same low-level runner under their parent service operation. -Migrate **managed-local** dump/reset execution first. Linked/remote and legacy compose CLI paths remain outside this package's lifecycle; do not create a stack or reserve ports just to run their tools. An explicitly supplied stack handle may still run a job against an external URL. The identity claim applies to everything executed through that stack. +Migrate **managed-local** dump/reset execution first. Linked/remote and legacy compose CLI paths remain outside this package's lifecycle; do not create a stack or reserve ports just to run their commands. An explicitly supplied stack handle may still run a job against an external URL. The identity claim applies to everything executed through that stack. CLI policy remains CLI policy: SQL scripts, migrations, seeds, hosted targets, output files and command flags. The caller chooses a compatible client version; the runner resolves that version to its executable artifact and owns execution. -### Proposed public tool API +### Proposed public command API -Expose one awaited `stack.tools.run` operation. The Promise facade below has an Effect counterpart for CLI consumers; both use the same owner-side runner. +Expose one awaited `stack.commands.run` operation. PostgreSQL client commands and one-shot service initialization share the same owner-side runner. Initialization runs a service recipe without registering a service instance or changing the service graph. ```ts -import { postgres } from "@supabase/stack/tools"; +import { postgres } from "@supabase/stack"; // Connection values are plain data, rendered for the stack runtime. const { databaseUrl } = await database.credentials({ from: "runtime" }); -const result = await stack.tools.run(postgres.pgDump({ major: 17 }), { - args: ["--dbname", databaseUrl, "--schema-only", "--no-owner"], - stdout: (bytes) => destination.write(bytes), - stderr: (bytes) => diagnostics.write(bytes), - signal, -}); +const result = await stack.commands.run( + postgres.pgDump({ major: 17 }), + { + args: ["--dbname", databaseUrl, "--schema-only", "--no-owner"], + stdout: (bytes) => destination.write(bytes), + stderr: (bytes) => diagnostics.write(bytes), + }, + { signal }, +); // result: { jobId, exitCode } + +await stack.commands.run({ type: "auth.initialize", databaseUrl }); +await stack.commands.run({ type: "storage.initialize", databaseUrl, filePath }); +await stack.commands.run({ type: "realtime.initialize", databaseUrl }); ``` -`postgres.pgDump({ major })` describes the selected client package and how to launch its native and container forms. The caller chooses the version; 17 is illustrative. The stack's runtime chooses the execution form. The invocation supplies ordinary arguments, environment and byte streams. Neither descriptor nor invocation declares managed service dependencies. +`postgres.pgDump({ major })` describes the selected client package and how to launch its native and container forms. The caller chooses the version; 17 is illustrative. The stack's runtime chooses the execution form. The invocation supplies ordinary arguments, environment and byte streams. Initialization invocations select Auth, Storage, or Realtime, provide the database URL, and provide Storage's file path. They require stack credentials already established by database or composition setup; credential lookup is read-only and fails when no stack identity has been established. They resolve the same service recipe artifact, environment, mounts and working directory as normal service launch while leaving no service instance behind. The CLI awaits selected initialization commands concurrently before applying its catalog overlay. -`databaseUrl` is a plain string. The same invocation can target the primary database, a shadow database or an external database without changing the tool definition or the runner. The proxy handles any wake-up caused by connecting to a sleeping managed service. A URL obtained for container execution is already reachable from that container; endpoint address selection remains outside the generic tool runner. The orchestrator uses the same endpoint renderer when supplying connection values to service instances. It handles the host gateway and the listener binding needed to reach it, not just hostname substitution, and returns an error if the selected networking configuration cannot reach the endpoint. Caller-supplied external URLs remain unchanged. +`databaseUrl` is a plain string. The same invocation can target the primary database, a shadow database or an external database without changing the command definition or the runner. The proxy handles any wake-up caused by connecting to a sleeping managed service. A URL obtained for container execution is already reachable from that container; endpoint address selection remains outside the generic command runner. The orchestrator uses the same endpoint renderer when supplying connection values to service instances. It handles the host gateway and the listener binding needed to reach it, not just hostname substitution, and returns an error if the selected networking configuration cannot reach the endpoint. Caller-supplied external URLs remain unchanged. Arguments are passed as an argument vector, not interpreted as shell text. Stdin accepts an optional asynchronous byte stream; stdout/stderr callbacks receive bytes and may return Promises, which the runner awaits for backpressure. All examples use byte sinks whose writes await capacity. `psql` uses the same operation with `postgres.psql({ major })`, optionally supplying stdin. Callers supply stdout/stderr sinks explicitly. Neither facade collects unbounded output into a return value. -The operation settles after process exit, output delivery and owned-resource cleanup. A nonzero tool exit is returned in `exitCode` for CLI-specific handling; preparation, transport, sink and cleanup failures reject with typed execution errors. Cancellation stops and cleans up the attached job and reports cancellation. The StackHost assigns `jobId` and owns the resources. No public job registry, job healthcheck, persistent tool service or separate launch/attach/wait sequence is needed for these use cases. +The operation settles after process exit, output delivery and owned-resource cleanup. PostgreSQL client commands return a nonzero process exit in `exitCode` for CLI-specific handling; initialization commands reject with a typed error on nonzero exit. Preparation, transport, sink and cleanup failures also reject with typed execution errors. Cancellation stops and cleans up the attached job and reports cancellation. The StackHost assigns `jobId` and owns the resources. No public job registry, job healthcheck, persistent command service or separate launch/attach/wait sequence is needed for these use cases. ## 7. StackHost owns lifetime; components own behavior -Use `StackHost` for the detached process that owns one stack identity. It hosts the service executors, composition orchestrator, proxy, tool runner and shared resources. A standalone instance needs this owner and its executor without needing composition scheduling. Tools use the runner without entering the service dependency graph. +Use `StackHost` for the detached process that owns one stack identity. It hosts the service executors, composition orchestrator, proxy, command runner and shared resources. A standalone instance needs this owner and its executor without needing composition scheduling. Commands use the runner without entering the service dependency graph. The host acquires exclusive ownership, constructs the components, exposes Effect RPC, delegates requests and coordinates explicit shutdown. Domain behavior remains in the components: the host does not understand PostgreSQL archives, decide dependency order or implement another service state machine. Existing dependency checks still apply when individual operations target instances with declared graph relationships. @@ -416,14 +425,24 @@ flowchart TB subgraph Host["StackHost — one detached process per stack identity"] RPC --> Composition["Composition orchestrator"] RPC --> Instances["Service instance executors"] - RPC --> Tools["Tool runner"] + RPC --> Commands["Command runner"] Composition --> Instances Proxy["Public proxy"] --> Composition Resources["Shared ownership, ports and metadata"] end ``` -Keep Effect RPC as the transport initially. Its handlers should mostly delegate to the same operations used by internal components. Composition startup calls the executors directly, not RPC back into its own host. Replacing RPC with handwritten messages would still require framing, validation, errors and stream transport; that replacement is not part of this simplification. +Keep Effect RPC as the transport initially. Composition startup calls the executors directly, not RPC back into its own host. Replacing RPC with handwritten messages would still require framing, validation, errors and stream transport; that replacement is not part of this simplification. + +| Module | Responsibility | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `StackHost` | Process lifetime: lease, signals, session lifeline, sweeps, shutdown state machine; serves `/identity`, `POST /shutdown` and `/rpc` (owner handlers plus command RPCs) | +| `Owner` | Builds the instance and composition RPC handlers, maps domain failures to `StackError` once, persists definitions and adapts recipes, listeners and the Supabase composition | +| `Orchestrator` | The single in-memory registry of instance entries and the composition: admission, start plans, activity, idle sleep and exit watchers | +| `Network` | Public listeners, the shared API proxy and port claims | +| `host/*` | Service-specific endpoint routes, rendered connection values and stack credential rules | + +Definition changes (service creation and destruction, composition configuration and Supabase composition) run one at a time in the owner's scope, and a caller that disconnects stops waiting. Creation saves the instance before registering it and removes the saved instance again if registration fails. Until registration completes, a concurrent `get` or `list` can already return the new id, while `start` or `status` for it fails with an unknown-instance error. ### Proposed RPC surface @@ -435,13 +454,13 @@ Keep Effect RPC as the transport initially. Its handlers should mostly delegate | Instance observation | `service.status`, `service.ready`, `service.followStatus`, `service.logs`, `service.followLogs`, `service.credentials` | Read state, await health and inspect outputs | | Database snapshots | `database.saveSnapshot`, `database.restoreSnapshot` | Database-specific managed storage operations | | Composition | `composition.configure`, `composition.describe`, `composition.start`, `composition.stop`, `composition.restart` | Define and operate the application selection and dependencies | -| Tools | `tools.run`, `tools.writeStdin`, `tools.closeStdin` | Execute an attached command with streamed input/output | +| Commands | `runCommand`, `commandInput` | Execute an attached command with streamed input/output | These are proposed wire names. Public `shadow.start()` maps to `service.start({ id })`; `stack.stop()` maps to `host.stop`. Public `stack.composition.stop()` leaves the host and independent instances available. `composition.configure` sends validated declarative membership, edges and input wiring, not executable callbacks. Configuration changes use the same graph and lifecycle admission rules as other mutations. ### Host lifecycle -Launch the host when an operation needs a live owner. Once started, it remains alive until namespace stop or destruction. Client disconnection, completion of a tool, or stopping the final individual instance does not cause automatic retirement. The accepted tradeoff is one resident process for an opened stack until explicitly stopped, even if all its service instances are stopped. +Launch the host when an operation needs a live owner. Once started, it remains alive until namespace stop or destruction. Client disconnection, completion of a command, or stopping the final individual instance does not cause automatic retirement. The accepted tradeoff is one resident process for an opened stack until explicitly stopped, even if all its service instances are stopped. ```mermaid stateDiagram-v2 @@ -456,28 +475,38 @@ stateDiagram-v2 Exited --> [*] ``` -During Starting, acquire the exclusive stack lease, load the instance definitions and saved resources needed for normal restart, construct components and open the control endpoint. The lease is held by an operating-system locking primitive whose ownership ends with the process; an on-disk PID/endpoint file is only discovery metadata and never proves an active owner. A competing launcher connects to the winning owner. For a container stack, remove containers labeled with this stack and its canonical data root before accepting work. This is cleanup of proven-owned workloads, not adoption or replay of interrupted operations. Other runtime or port conflicts remain errors. +During Starting, acquire the exclusive stack lease, load the instance definitions and saved resources needed for normal restart, construct components and open the control endpoint. For a container stack, remove containers labeled with this stack and its canonical data root before accepting work. This is cleanup of proven-owned workloads, not adoption or replay of interrupted operations. Other runtime or port conflicts remain errors. + +**Lease and discovery.** The lease is a SQLite write transaction on `<stateRoot>/<id>/owner.lock`, held for the owner's whole lifetime; the operating system releases it when the process exits for any reason. POSIX record locks belong to the process, so only the lease code opens that file. A waiter may open the file just before a releasing holder unlinks it; after locking, it checks that the path still names the file it locked and otherwise reopens. A stack is live exactly while its lease is held. After taking the lease the owner binds a loopback control listener on an OS-assigned port and, once it serves, atomically publishes `owner.json`, readable only by its user, with its port, PID, release, lifetime, start time and a random per-owner secret; it retracts the record on exit. A client first try-locks the lease: a free lease means no owner, so `discover` reports dead stacks without any network probe. Only while the lease is held does a client read `owner.json`, then validate the stack identity, PID and port through `GET /identity`. Every control request carries the secret as `Authorization: Bearer <secret>`, a header HTTP tracing redacts, and the owner rejects requests without it after a constant-time comparison. A stale record, a foreign listener on a previous control port, or another owner that later binds that port therefore never receives this stack's requests; a client treats a rejection as a stale record and resolves the owner again. Concurrent spawners may start several owners; each loser fails to take the lease, reports that on its readiness descriptor and exits, and its spawner attaches to the winner through `owner.json`. + +**Release handshake.** `/identity` reports the owner's release: the package version plus a build identifier. The identifier is a digest of this package's module sources and its Effect version: the CLI build scripts embed it in every compiled binary and a source checkout computes it, so a binary and a source run of the same sources interoperate, and any change to the owner or its protocol is a new release. Operations fail with an error asking the user to stop or destroy the stack when the releases differ. `stop` and `destroy` use `POST /shutdown` with the bearer secret and a `{ "destroy": boolean }` body, which do not depend on the RPC schema and so reach owners of any release. + +**Session lifetime.** A session stack is registered by its owner under the lease, so it never exists without a live owner except after that owner dies. Its spawner keeps the owner's stdin pipe open for the life of the creating handle. End of input means the creator is gone, whether it closed the handle or its process died: the owner destroys the stack and exits. Only the creating handle starts a session stack's owner; other handles attach. `create({ startOwner: true })` registers a detached stack through its owner the same way; an owner whose startup fails removes the registration it made, so a failed or interrupted first launch leaves no stack behind. + +**Orphan sweep.** Stack-labelled containers and session stacks exist only while their lease is held. After readiness, each owner visits every other stack in its state root in the background, with a bounded time per stack. It skips stacks whose lease is held. For a free lease it takes that lease for the duration of the visit, publishing a sweeper record in `owner.json` so clients wait for the visit instead of mistaking it for a starting owner, removes containers labelled with the stack and its data root, and destroys the stack through the owner's own destroy path when its lifetime is `session`. Filtering on the data-root label keeps other state roots untouched. Creating a stack whose identity belongs to a dead session stack reclaims that stack the same way first. + +**Unreachable engine.** An owner of a container stack fails startup with a `runtime-unavailable` reason when its first container sweep finds the engine CLI missing or its daemon not listening; permission, TLS, authentication and timeout failures are ordinary startup errors. `destroy` then proceeds without an owner: it takes the free lease, publishing a sweeper record like the orphan sweep, refuses when any stack data directory cannot be deleted by the current user, removes the host data and the registration with its port claims, and returns the shell commands that remove the stack's containers and engine-volume data once the engine runs. `stop` without a live owner already succeeds without contacting the engine. During Serving, keep the owner alive independently of callers. Sleeping instances still need its public listeners. This is process lifetime management, not automatic service restart or continuous reconciliation. -Where the platform delivers SIGTERM or SIGINT to the host, treat it as the same graceful shutdown request as `host.stop`. Repeated shutdown requests join that shutdown; they do not pre-empt executing transitions. On Unix, native launchers stop their process groups when the host pipe closes. Graceful shutdown stops owned services and tools, then synchronously removes containers labeled with the stack and canonical data root before the host exits. Forced termination may require manual cleanup. +Where the platform delivers SIGTERM or SIGINT to the host, treat it as the same graceful shutdown request as `host.stop`. Repeated shutdown requests join that shutdown; they do not pre-empt executing transitions. On Unix, native launchers stop their process groups when the host pipe closes. Graceful shutdown stops owned services and commands, then synchronously removes containers labeled with the stack and canonical data root before the host exits. After forced termination, the orphan sweep removes leftover containers. During Draining: 1. Close admission to new mutations and proxy wake requests. 2. Reject queued work that has not begun. 3. Let executing instance operations settle. -4. Cancel and settle attached tools and stop owned services through their existing operations. +4. Cancel and settle attached commands and stop owned services through their existing operations. 5. For destruction, remove proven-owned data and metadata after shutdown. 6. Send the outcome, close the control endpoint and release ownership. -**Successful whole-stack shutdown.** `stack.stop()` reports success only after admitted work settles, every owned live service and tool workload has stopped (including native processes, descendants and containers labeled with this stack and data root), stack listeners close, the shutdown RPC is acknowledged, and the detached host's exit is confirmed. If workload cleanup cannot be confirmed, stop fails and the live host retains ownership for inspection and retry; the stack is not reported stopped. If cleanup succeeds but host exit cannot be confirmed, stop also fails, though the host may already have exited. A delivered SIGTERM or SIGINT follows this cleanup path. Stop preserves stack definitions, service data, caches and saved port assignments. `destroy` follows the same live-workload cleanup, then removes only proven-owned persistent data. +**Successful whole-stack shutdown.** `stack.stop()` reports success only after admitted work settles, every owned live service and command workload has stopped (including native processes, descendants and containers labeled with this stack and data root), stack listeners close, the shutdown request is acknowledged, and the detached host's exit is confirmed. If workload cleanup cannot be confirmed, stop fails and the live host retains ownership for inspection and retry; the stack is not reported stopped. If cleanup succeeds but host exit cannot be confirmed, stop also fails, though the host may already have exited. A delivered SIGTERM or SIGINT follows this cleanup path. Stop preserves stack definitions, service data, caches and saved port assignments. `destroy` follows the same live-workload cleanup, then removes only proven-owned persistent data. -The client captures the live owner PID from the validated identity endpoint or readiness handshake, completes and closes the shutdown RPC, then performs bounded process-existence checks. An absent PID confirms exit; a permission-denied probe remains inconclusive until the deadline. Failure to confirm exit is a `shutdown-exit` error carrying the PID in its message, even when workload cleanup has already succeeded. This does not require persisted PID records or forceful termination. Caller cancellation ends its wait without cancelling admitted owner cleanup. +The client captures the live owner PID from the validated identity endpoint or readiness handshake, completes the shutdown request, then performs bounded process-existence checks. An absent PID confirms exit; a permission-denied probe remains inconclusive until the deadline. Failure to confirm exit is a `shutdown-exit` error carrying the PID in its message, even when workload cleanup has already succeeded. This does not require persisted PID records or forceful termination. Caller cancellation ends its wait without cancelling admitted owner cleanup. -Callers must not start or restart the same stack concurrently with whole-stack shutdown. In particular, replacing an owner between identity lookup and the shutdown request is outside this guarantee. Parallel stacks with separate identities remain independent. Client disposal and Effect scope closure do not implicitly stop a detached stack; disposable fixtures register explicit destruction. +Callers must not start or restart the same stack concurrently with whole-stack shutdown. In particular, replacing an owner between identity lookup and the shutdown request is outside this guarantee. Parallel stacks with separate identities remain independent. Client disposal and Effect scope closure do not implicitly stop a detached stack; disposable fixtures register explicit destruction or use a session stack, which closing its creating handle destroys. -Returning to Serving after cleanup failure does not undo completed cleanup. Unexpected host death does not resume interrupted operations or restore live service state. The next host startup sweeps containers owned by that stack and data root without removing volumes or saved definitions. A forced host termination does not guarantee immediate container cleanup. A lost control response is reported as uncertain; do not blindly retry a mutation. +Returning to Serving after cleanup failure does not undo completed cleanup. Unexpected host death does not resume interrupted operations or restore live service state. Native launchers stop their process groups when the dead host's pipe closes. The next host startup for that stack sweeps its containers without removing volumes or saved definitions, and any host start in the same state root sweeps them in the background, also destroying the stack if it is a session stack. A forced host termination does not guarantee immediate container cleanup. A lost control response is reported as uncertain; do not blindly retry a mutation. ### Request lifetime is separate from execution lifetime @@ -503,7 +532,7 @@ sequenceDiagram A lost response does not prove failure. While the host lives, callers can inspect its instance observations and in-memory operation result; never blindly repeat creation or restoration. No operation journal or result history survives host death, and there is no command replay or exactly-once execution promise. -Attached tools have a separate contract. `tools.run` streams `started(jobId)`, stdout/stderr chunks and a terminal exit result. Optional input arrives through bounded `tools.writeStdin` calls and `tools.closeStdin`. These calls are restricted to the originating invocation/session. Cancelling or losing the execution stream terminates and cleans up that job. The public `stack.tools.run()` wrapper handles this exchange behind its stdin/stdout interface; no separate public launch/attach/wait workflow is required. The terminal success response follows output delivery and cleanup, with execution/cleanup failures reported as errors. +Attached commands have a separate contract. `runCommand` streams `started(jobId)`, stdout/stderr chunks and a terminal exit result. Optional input arrives through bounded `commandInput` calls; a final call closes stdin. These calls are restricted to the originating invocation/session. Cancelling or losing the execution stream terminates and cleans up that job. The public `stack.commands.run()` wrapper handles this exchange behind its stdin/stdout interface; no separate public launch/attach/wait workflow is required. The terminal success response follows output delivery and cleanup, with execution/cleanup failures reported as errors. ## 8. Keep safety infrastructure at its boundary @@ -584,7 +613,7 @@ A shared listener stays bound while any route is running or armed. When no route Standalone and composed instances use the same allocator. Two shadow databases have separate IDs, data and dedicated listeners. Composition membership does not change listener ownership. The runtime reports the backend address; the proxy updates its target without altering the public assignment. -The endpoint renderer produces host-facing or stack-runtime-facing connection values for `service.credentials({ from: "host" | "runtime" })` and for composition wiring. This is ordinary networking configuration, not a dependency-aware tool API. In container mode, both the rendered address and configured listener reachability must work from that network; report unsupported network configurations before executing the dependent/tool. +The endpoint renderer produces host-facing or stack-runtime-facing connection values for `service.credentials({ from: "host" | "runtime" })` and for composition wiring. This is ordinary networking configuration, not a dependency-aware command API. In container mode, both the rendered address and configured listener reachability must work from that network; report unsupported network configurations before executing the dependent/command. | Owner | Responsibility | | ------------------ | ----------------------------------------------------------------------------------------------------- | @@ -596,7 +625,7 @@ The endpoint renderer produces host-facing or stack-runtime-facing connection va Mutable files live under the stack namespace; container resources carry equivalent identity labels. Each managed container is addressed by its unique preassigned launch name for its whole owned lifetime; renaming a managed container is outside the lifecycle contract. Shared immutable artifact caches and host-wide port coordination are justified exceptions. User-requested exports can live at their chosen destination. -The StackHost serializes updates to saved instance definitions, composition wiring and resource assignments. Lifecycle, health, active operations, runtime handles and errors remain in the live instance observation. There is no durable lifecycle/operation journal, projected capability state or duplicate stack lifecycle state. +The owner serializes updates to saved instance definitions, composition wiring and resource assignments. Lifecycle, health, active operations, runtime handles and errors remain in the live instance observation. There is no durable lifecycle/operation journal, projected capability state or duplicate stack lifecycle state. Persistence supports reopening normally stopped instances, not reconstructing interrupted execution after owner loss. Do not infer current runtime state from saved configuration. When the host is absent or unreachable, expose the saved definitions and ports separately from unavailable live observations. Container cleanup after owner loss uses live daemon labels, not persisted process state; service recovery, resource adoption, interrupted-operation replay and private-format migration machinery are outside scope. @@ -607,6 +636,9 @@ The state root is the stack registry root. Each stack keeps one state document a ```text <stateRoot>/<stack-id>/state.json <stateRoot>/<stack-id>/data/<instance-id>/... +<stateRoot>/<stack-id>/owner.lock lease; opened only by SQLite +<stateRoot>/<stack-id>/owner.json endpoint of the lease holder +<stateRoot>/<stack-id>/owner.log owner stdout and stderr, truncated at each owner start ``` Registry updates use an OS-backed lock through a private `node:sqlite` connection to @@ -618,7 +650,7 @@ SQLite. No tables, state records, or WAL are created there. Saved stack data rem the lock does not make multi-file operations transactional or recover interrupted operations. This uses the built-in SQLite API available in the pinned Bun runtime and modern Node.js. -The artifact cache is independent and shared across stacks. Normal stop preserves the stack directory and service data. Destroy removes the state document and proven-owned, empty parents; caller-owned paths such as Storage uploads remain untouched. +The artifact cache is independent and shared across stacks. Normal stop preserves the stack directory and service data. Destroy removes the state document, owner files and proven-owned, empty parents; the lease file goes last, while its lock is still held; caller-owned paths such as Storage uploads remain untouched. ### Snapshots belong to the database instance @@ -629,8 +661,8 @@ Expose `saveSnapshot` and `restoreSnapshot` on `DatabaseInstance` only. The comm ```ts interface DatabaseInstance extends ServiceInstance { readonly service: "database"; - saveSnapshot(key: string): Promise<void>; - restoreSnapshot(key: string): Promise<boolean>; + saveSnapshot(key: string, options?: { scope?: "cache" | "instance" }): Promise<void>; + restoreSnapshot(key: string, options?: { scope?: "cache" | "instance" }): Promise<boolean>; } // `baseline` has already been initialized; `shadow` is a fresh instance. @@ -644,7 +676,7 @@ await shadow.start(); await shadow.ready(); ``` -The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below `cacheRoot`; Docker entries share the data volume, in a separate namespace derived from `cacheRoot`. Docker cache reuse requires the same daemon, `stateRoot`, and `cacheRoot`. Each store retains three entries by last use; saving a key replaces the previous complete entry for that key. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents. +The database implementation owns the snapshot format, PostgreSQL data selection, compatibility validation, initialization metadata and credential reconciliation. It uses native filesystem clone/copy operations or container volume/helper operations through the runtime backend. Native entries live below `cacheRoot`; Docker entries share the data volume, in a separate namespace derived from `cacheRoot`. Docker cache reuse requires the same daemon, `stateRoot`, and `cacheRoot`. The cache store retains three entries by last use; saving a key replaces the previous complete entry for that key. Instance-scoped snapshots, which test checkpoints use, live beside the instance's data (native instance root, Docker data namespace or host-backed instance root), are outside cache retention, and are removed when the instance is destroyed; reset keeps them. Cache entries are disposable and do not promise durability across power loss. The orchestrator knows only admission, instance ownership and operation settlement; it never needs to understand PostgreSQL data contents. Keep the contract narrow: @@ -652,9 +684,9 @@ Keep the contract narrow: - Restore requires a confirmed stopped instance with empty data. Validate format, artifact/runtime compatibility and initialization profile before installing restored data. A missing key returns `false`; a compatible published entry returns `true`; reject a nonempty target rather than overwriting it. - Both operations occupy the instance's existing serial operation gate and leave lifecycle stopped. Queued start, destroy or another storage operation waits for settlement and revalidates. No new lifecycle states are necessary; the observable pending operation identifies snapshot work. An armed wake route is not a substitute for explicit stop. - Native snapshots copy or clone the host data; container snapshots copy database data through a managed volume and helper. Docker data normally lives in a managed volume, while existing host data can be retained through the host-backed fallback. The host storage marker detects a missing or mismatched Docker volume; deleting that volume loses its database data. -- Restore transfers compatible database contents, not the source instance's identity, public port claims or composition membership. The target retains its own data location and configuration, with database-specific credentials reconciled before readiness. Snapshots survive destruction of the source instance because their managed storage is separate. +- Restore transfers compatible database contents, not the source instance's identity, public port claims or composition membership. The target retains its own data location and configuration, with database-specific credentials reconciled before readiness. Cache snapshots survive destruction of the source instance because their managed storage is separate; instance snapshots restore only into their own instance. -These are physical database snapshots for the cache use case. A `pg_dump` invocation remains an ordinary client tool for logical exports. CLI code owns cache keys, migrations and the decision to fall back to rebuilding a baseline; managed storage owns publication and retention. The snapshot API does not acquire CLI cache policy. +These are physical database snapshots for the cache use case. A `pg_dump` invocation remains an ordinary client command for logical exports. CLI code owns cache keys, migrations and the decision to fall back to rebuilding a baseline; managed storage owns publication and retention. The snapshot API does not acquire CLI cache policy. ### Resetting database data @@ -692,19 +724,21 @@ Update actual consumers together: | Functions serve | Launch/restart → await ready → follow logs and status; inspector flags are unsupported | | Proxy wake | Launch if sleeping → await ready → forward | | DB reset/cache | Stop selected dependents → stopped snapshot/restore or DB config work → launch/ready → resume dependents | -| Tools | Select identity/artifact → execute/stream → await exit and cleanup | +| Commands | Select identity/artifact → execute/stream → await exit and cleanup | A Functions runtime exit must reach `followStatus` so serve can report it. ### Established CLI integration boundaries -The Stack package remains the owner of identity semantics. It canonicalizes `projectRoot`, resolves the Git branch context (or ordinary-workspace fallback), and validates the stack name in [`Identity.ts`](./src/identity/Identity.ts); it also owns `deriveStackId` from that complete tuple. The CLI currently calls `resolveStackIdentity` through the internal [`identity` entrypoint](./src/identity/Identity.ts), then uses the result when matching `discover` records for status and related read operations. Resolving identity is read-only and does not create a stack. A follow-up recommendation is to expose an equivalent public, read-only `resolveIdentity` operation so the CLI need not import an internal entrypoint; this is a recommended public API, not an existing export. +The Stack package remains the owner of identity semantics. It canonicalizes `projectRoot`, resolves the Git branch context (or ordinary-workspace fallback), and validates the stack name in [`Identity.ts`](./src/identity/Identity.ts); it also owns `deriveStackId` from that complete tuple and the `StackId` format. The CLI selects a stack with the public, read-only `find`, by project root and stack name or by ID; `find` derives the ID, reads only that state document, and reports the live owner. It never creates a stack, and an unreadable document fails selection rather than reading as absent. `discover` remains the listing operation for `stack list` and `stack stop --all`. + +Composition policy stays in the package. `composition.supabase` owns the managed bindings, the inputs derived from the API endpoint and the stack credentials, and the activation policy; the CLI passes `--eager` as the `eager` preference. Creation schemas make every bound or injected input optional, and a launch without a required input fails with a typed `ServiceError` naming it. Before recomposing a stopped stack, the CLI calls `composition.plan`, which compares requested creations with the saved instances while ignoring package-managed inputs and normalising the shared API port as composition does. The CLI rejects `incompatible` members, recomposes `changed` members with their new configuration under the same identities, and reuses stopped standalone instances that are not incompatible. `stack status` reports the same plan as configuration drift. Required inputs are checked after dependency inputs are merged and before a start or restart changes lifecycle, so a restart without one leaves the instance running. -When `stack start` includes Functions, the CLI uses the existing package export `@supabase/stack/internal/functions/serve-main`, whose source is [`serve.main.ts`](./src/functions/serve.main.ts), as the bootstrap entrypoint for esbuild bundling in [`stack-functions-bundler.ts`](../../apps/cli/src/command-internal/stack-functions-bundler.ts). The stack-backed `functions serve` command uses the same bootstrap when it creates a temporary Functions instance. Configured embedded templates may satisfy the same bootstrap input before bundling is needed. +The Functions recipe publishes a default Edge Runtime main service built from [`serve.main.ts`](./src/functions/serve.main.ts). [`generate-functions-bootstrap.ts`](./scripts/generate-functions-bootstrap.ts) bundles it, with its dependencies inlined for offline use, into the committed module [`serve-main-bundle.ts`](./src/functions/generated/serve-main-bundle.ts); `pnpm generate` refreshes it and a unit test fails when it drifts from the sources. A creation may override it with `bootstrap`; the default is not saved in the stack document. `stack start` and the stack-backed `functions serve` command use the default. The CLI owns the foreground `functions serve` session. It attaches to an existing composition member and leaves it available on exit. Supported explicit overrides replace its configuration for the session, then restore it on normal cleanup. If Functions is excluded, the CLI creates and later destroys one standalone instance without changing composition. The package needs no session or recovery API: ordinary create, start, restart, status, logs, and destroy suffice. Functions accepts custom environment values and a database URL; its recipe derives default keys, while the composer supplies the runtime database URL without a dependency edge. See the [command lifecycle](../../apps/cli/docs/stack-commands.md) for supported flags and cleanup limits. -PostgreSQL artifact knowledge remains in Stack and is exposed through [`postgres-artifact.ts`](./src/internal/postgres-artifact.ts), including catalog resolution and native artifact preparation and verification. A remote `db dump --db-url` can use those existing helpers and run without creating a local or dummy stack: the CLI owns the external process or container execution, as shown by [`bundled-postgres-client.ts`](../../apps/cli/src/command-internal/bundled-postgres-client.ts), while managed jobs continue to use `stack.tools.run`. +Artifact knowledge remains in Stack and is exposed to the CLI through the single internal [`artifacts` entrypoint](./src/internal/artifacts.ts), including the service catalog, PostgreSQL version resolution, and native artifact preparation and verification. A remote `db dump --db-url` can use those existing helpers and run without creating a local or dummy stack: the CLI owns the external process or container execution, as shown by [`bundled-postgres-client.ts`](../../apps/cli/src/command-internal/bundled-postgres-client.ts), while managed jobs continue to use `stack.commands.run`. Changing internal and public-to-repository contracts is acceptable when callers are updated; preserving valuable data is still required. Keep per-instance configuration replacement through `service.restart({ config })`. Validate the candidate configuration and prepare its artifacts before stopping the existing runtime; invalid input must leave it running. The admitted restart then performs ordinary stop and launch without waiting for application health inside the gate. Adding or removing a companion means explicitly adding or removing an ordinary instance and updating composition edges. Validate the graph using the same rules as registration; do not implement private-child expansion or group replacement logic. Defer live shared configuration changes and config-bearing whole-stack restart. @@ -719,7 +753,7 @@ Changing internal and public-to-repository contracts is acceptable when callers | Durable operation journals and crash reconciliation | Remove; persist only normal stop/start definitions and resources | | Separate capability/stack lifecycle projections | Observe instance state directly; composition returns its member observations | | Separate whole-stack behavior | Selection over the same graph operations | -| Managed-local CLI tool ownership branches | Shared stack job execution | +| Managed-local CLI command ownership branches | Shared stack job execution | Before the package is considered complete or integrated with the final CLI, remove superseded package capability, supervisor, projection and journal implementations together with tests and exports that only served those implementations. The final package has one implementation path and no compatibility facade or parallel lifecycle implementation. @@ -738,4 +772,4 @@ Verify through consumed integration flows: 5. Unexpected exit disables wake and triggers ordinary cleanup of the owned runtime before another launch; Functions can explicitly restart afterward. Traffic cannot restart an exited prerequisite. A stale exit cannot damage a replacement. 6. Normal snapshot failures do not publish partial entries or overwrite existing data. Client disconnection leaves admitted snapshot work owned by the live host. Host-crash recovery is not an acceptance requirement; unavailable observations must not be presented as current running/healthy/stopped state. 7. Container-mode service wiring and dumps receive a reachable runtime-facing database URL as plain data. Dumps stream with bounded buffering and exact cleanup; stackless commands create no managed stack. Stopping/destroying REST leaves Auth on the shared API port working; shared port claims survive removal of the final route. -8. Namespace stop settles executing work and leaves no live runtime resources; failed cleanup cannot report successful stop. Disconnecting a client does not stop admitted lifecycle/snapshot operations. Stopping the last individual instance or finishing the last tool does not retire the StackHost; namespace stop/destroy does. +8. Namespace stop settles executing work and leaves no live runtime resources; failed cleanup cannot report successful stop. Disconnecting a client does not stop admitted lifecycle/snapshot operations. Stopping the last individual instance or finishing the last command does not retire the StackHost; namespace stop/destroy does. diff --git a/packages/stack/README.md b/packages/stack/README.md index 81b0aa4cce..5363725a53 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -9,7 +9,8 @@ The package accepts service configuration directly. Loading CLI configuration, m The Promise entrypoint accepts plain configuration: ```ts -import { create, postgres } from "@supabase/stack"; +import { create } from "@supabase/stack"; +import { postgres } from "@supabase/stack/commands"; const stack = await create({ projectRoot: process.cwd(), @@ -34,7 +35,7 @@ await database.ready(); // initialization and health completed const { databaseUrl } = await database.credentials({ from: "runtime" }); if (databaseUrl === undefined) throw new Error("Database endpoint missing"); -await stack.tools.run(postgres.psql({ major: 17 }), { +await stack.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", databaseUrl, "--command", "SELECT 1"], stdout: (bytes) => { process.stdout.write(bytes); @@ -44,32 +45,49 @@ await stack.tools.run(postgres.psql({ major: 17 }), { }, }); +// Runs recipe initialization without registering a service instance. +const { authDatabaseUrl } = await database.credentials({ from: "runtime" }); +if (authDatabaseUrl === undefined) throw new Error("Auth database URL missing"); +await stack.commands.run({ type: "auth.initialize", databaseUrl: authDatabaseUrl }); + await database.stop(); await database.saveSnapshot("baseline"); await stack.close(); // disconnects this client ``` -`start` and `ready` are separate operations. `restart({ config })` replaces recipe configuration while retaining the instance identity and endpoint intentions. A health failure leaves a launched process running and observable; it does not prevent `stop`. Database snapshots require a stopped instance with wake disabled. `saveSnapshot(key)` publishes complete data to managed backend storage and replaces the previous entry for that key; `restoreSnapshot(key)` returns `false` on a miss and `true` after restoring a compatible entry. Managed retention may evict older keys, while snapshots survive destruction of the source stack. Other service handles have no snapshot methods. +`start` and `ready` are separate operations. `restart({ config })` replaces recipe configuration while retaining the instance identity and endpoint intentions. A health failure leaves a launched process running and observable; it does not prevent `stop`. Database snapshots require a stopped instance with wake disabled. `saveSnapshot(key)` publishes complete data to managed backend storage and replaces the previous entry for that key; `restoreSnapshot(key)` returns `false` on a miss and `true` after restoring a compatible entry. By default snapshots live in the shared cache, whose retention may evict older keys, and survive destruction of the source stack. `{ scope: "instance" }` keeps a snapshot with the instance instead: it is never evicted, restores only into that instance, survives `resetData`, and is removed when the instance is destroyed. Other service handles have no snapshot methods. -Creating a service records its definition. Configured public ports are bound during startup and retained across normal stop/start and owner reopening. An occupied saved port reports a conflict instead of moving. Omitted public endpoints are not exposed. +Creating a service records its definition. Configured public ports are bound during startup and retained across normal stop/start and owner reopening. An occupied saved port reports a conflict instead of moving. Automatic ports avoid numbers saved by any stack under the same `stateRoot`; a fixed port is decided by binding it, so another stack's saved port blocks only while something listens on it; that conflict names the stack that saved the port. Omitted public endpoints are not exposed. Native public listeners bind to loopback. Docker and Podman public proxies bind all interfaces so services inside the container network can reach them; those listeners are reachable from the LAN according to the host firewall. -Functions configuration requires bootstrap source. Database versions belong in `config.version`; other recipes accept an optional top-level artifact `version`. +Functions use a package-provided, self-contained Edge Runtime main service unless the configuration supplies `bootstrap` source; only an explicit `bootstrap` is saved with the service definition. Database versions belong in `config.version`; other recipes accept an optional top-level artifact `version`. On Linux, native Functions project files must be outside `/tmp`: Edge Runtime uses a private filesystem at that path. Docker and Podman mount project files at a separate runtime path. -`open({ id, stateRoot, cacheRoot })` reconnects to a saved stack. The package stores the stack document at `<stateRoot>/<id>/state.json` and service data at `<stateRoot>/<id>/data/<instance-id>`. `discover({ stateRoot })` lists saved definitions and port assignments separately from live-owner availability. Offline definitions are not live lifecycle observations. +`open({ id, stateRoot, cacheRoot })` reconnects to a saved stack. The package stores the stack document at `<stateRoot>/<id>/state.json` and service data at `<stateRoot>/<id>/data/<instance-id>`. `discover({ stateRoot })` lists saved definitions and port assignments separately from live-owner availability. It skips each entry that cannot be read or decoded and reports it to `onInvalidState(id, error)`; only a failure to read `stateRoot` itself fails discovery. Port allocation skips the same entries. Offline definitions are not live lifecycle observations. + +`find({ stateRoot, projectRoot, name })` derives the stack ID with the same identity rules as `create` and reads only that stack; `find({ stateRoot, id })` reads a known ID. It returns the saved definition with the live owner's endpoint, if any, or nothing when no such stack is saved. Unlike `discover`, an unreadable state document fails the call instead of being skipped. The Effect entrypoint's `StackId` schema validates an ID before lookup. + +Pass `startOwner: true` to `open` when live status and other owner-backed operations are needed; this starts only the detached owner and does not start services. The owner writes its output to `<stateRoot>/<id>/owner.log`, which each owner start truncates; owner start and connection failures name that file. A client drives only an owner of its own release; other operations fail and ask you to stop or destroy the stack, which work across releases. + +### Lifetimes + +`create` defaults to `lifetime: "detached"`: the stack and its owner outlive the creating client. `create({ ..., lifetime: "session" })` starts the owner immediately and ties the stack to the creating client. Closing that client, or its process exiting for any reason, destroys the stack: instances stop, data and registrations are removed, and port claims are released. Other clients may attach to a running session stack but cannot start its owner. A session stack whose owner has stopped is disposable: the next owner start in the state root removes it, and creating a stack with the same identity replaces it. + +`create({ ..., startOwner: true })` starts a detached stack's owner immediately and lets it register the stack under its lease, as a session stack always does. If the owner fails to start or the launch is interrupted before it reports ready, the owner removes that registration and `create` fails with the launch error, so a failed launch leaves no stack behind. -Pass `startOwner: true` to `open` when live status and other owner-backed operations are needed; this starts only the detached owner and does not start services. +`destroy` normally returns `{ runtimeCleanup: "complete" }`. When no owner is running and a new owner cannot start because the stack's container engine reports that its daemon cannot be reached, `destroy` takes the stack's lease, so no owner can start meanwhile, and removes the local registration, port claims and host data anyway and returns `{ runtimeCleanup: "skipped", engine, cleanupCommands }`; its containers and any database data in engine volumes remain, and `cleanupCommands` are the shell commands that remove them once the engine is running. If some host data cannot be deleted by the current user, `destroy` fails before removing anything so it can be retried with the engine running. -The stack owns database, Functions bootstrap, and tool-job directories below its data directory. Storage uploads remain at the caller-supplied Storage `filePath` and are preserved when the stack is destroyed; the caller owns that directory. Host metadata remains under `stateRoot`; native database data uses host files. Docker database data normally uses a managed volume, while existing host data is retained through the host-backed fallback. A host marker records the selected Docker storage and detects a missing or mismatched volume; deleting that volume loses the associated database data. Native snapshot entries live below `cacheRoot`. Docker snapshots share the managed data volume in a separate namespace derived from `cacheRoot`, so they survive source destruction and can use filesystem cloning. A Docker cache hit requires the same daemon, `stateRoot`, and `cacheRoot`. There is no portable tar snapshot API. +The stack owns database, Functions bootstrap, and command-job directories below its data directory. Storage uploads remain at the caller-supplied Storage `filePath` and are preserved when the stack is destroyed; the caller owns that directory. Host metadata remains under `stateRoot`; native database data uses host files. Docker database data normally uses a managed volume, while existing host data is retained through the host-backed fallback. A host marker records the selected Docker storage and detects a missing or mismatched volume; deleting that volume loses the associated database data. Native snapshot entries live below `cacheRoot`. Docker snapshots share the managed data volume in a separate namespace derived from `cacheRoot`, so they survive source destruction and can use filesystem cloning. A Docker cache hit requires the same daemon, `stateRoot`, and `cacheRoot`. There is no portable tar snapshot API. Omitted database `jwtSecret` and `rootKey` inputs use the shared local-development values exported as `DEFAULT_LOCAL_JWT_SECRET` and `DEFAULT_POSTGRES_ROOT_KEY`. Explicit values override these defaults. The effective root key is supplied through a stack-owned file for both native and container runtimes. -Native PostgreSQL refuses to run as uid 0. When the stack runs as root inside a detected agent sandbox (Claude Code), or `SUPABASE_NATIVE_POSTGRES_USER=<name>` names a non-root system user, only the PostgreSQL process runs as that user: the instance data directory, root key file, socket directory, and the bundle's `pgsodium_getkey.sh` are chowned to it, and the instance directory, the PostgreSQL bundle directory, and their ancestors receive traverse-only (`o+x`) permission, which the artifact cache and stack state keep when they restrict their roots to the owner. Running as root elsewhere fails before PostgreSQL launches. +Native PostgreSQL refuses to run as uid 0. When the stack runs as root inside a detected agent sandbox (Claude Code, Modal Sandbox), or `SUPABASE_NATIVE_POSTGRES_USER=<name>` names a non-root system user, only the PostgreSQL process runs as that user: the instance data directory, root key file, socket directory, and the bundle's `pgsodium_getkey.sh` are chowned to it, and the instance directory, the PostgreSQL bundle directory, and their ancestors receive traverse-only (`o+x`) permission, which the artifact cache and stack state keep when they restrict their roots to the owner. Running as root elsewhere fails before PostgreSQL launches. + +Native PostgreSQL listens only on its socket and reads a stack-generated HBA file, written to the socket directory on every launch, instead of `PGDATA/pg_hba.conf`. It trusts `supabase_admin`, including through the proxied loopback database port, and requires `scram-sha-256` passwords from every other role. ## Composition and operation scope @@ -89,23 +107,27 @@ const services = await stack.composition.supabase([ }, { service: "rest", - config: { databaseUrl: "postgresql://configured-by-composition" }, + config: {}, endpoints: { http: { port: "auto" } }, }, ]); await stack.composition.start(); ``` -The factory accepts one instance of each selected recipe, binds its configured public endpoints, and wires managed inputs such as REST's database URL. When the database SQL endpoint is configured, Functions receives the saved database URL as an ordinary input too; recompose the composition after rotating database credentials to refresh that value. This binding does not make Functions wait for database readiness. Database is eager; Functions are lazy without an idle timeout; other public services are lazy with a 60-second idle timeout. Services without public endpoints are eager. A managed URL binding requires its producer's endpoint to be configured. The factory also supplies ordinary host/runtime API URLs to Auth, Studio, and Functions without adding dependencies from those URLs. It rejects an already configured composition. +The factory accepts one instance of each selected recipe, binds its configured public endpoints, and wires managed inputs such as REST's database URL. When the database SQL endpoint is configured, Functions receives the saved database URL as an ordinary input too; recompose the composition after rotating database credentials to refresh that value. This binding does not make Functions wait for database readiness. Database is eager; Functions are lazy without an idle timeout; Studio is lazy with a 5-minute idle timeout; other public services are lazy with a 60-second idle timeout. Services without public endpoints are eager. Pass `{ eager: true }` to start every member eagerly. A managed URL binding requires its producer's endpoint to be configured. The factory also supplies ordinary host/runtime API URLs to Auth, Studio, and Functions without adding dependencies from those URLs. It rejects an already configured composition. + +Inputs that the composition binds or the owner fills from the stack credentials, such as REST's `databaseUrl` or a JWT secret, are optional in creation configuration. Starting or restarting an instance without a required input that was neither bound nor provided fails before any lifecycle change with a `ServiceError` whose `operation` is `"input"` and whose message names the input; a running instance keeps running. + +To recompose a stopped stack, pass the IDs to keep in `reuseIds`. `composition.plan(services)` compares requested creations with every saved instance of the same kinds without contacting the owner or changing state. Each entry reports its instance `id`, `service`, whether it is a composition `member`, and a `change`: `unchanged`, `changed` with the differing config `paths`, or `incompatible` with the `paths` of an endpoint, artifact version, or PostgreSQL major-version change that the saved instance cannot adopt. Inputs that the composition or the stack credentials supply are ignored, an automatic API port is compared as the shared fixed port the composition would assign, and a PostgreSQL major alias matches its pinned version. Recomposing with `reuseIds` replaces a `changed` instance's configuration while keeping its identity, data, and ports. -The whole-stack E2E suite covers the default lazy lifecycle and reopen, all-eager startup, idle and wake, and parallel stack isolation for native and Docker runtimes. Run one runtime with `pnpm --filter @supabase/stack test:e2e:run src/whole-stack.native.e2e.test.ts` or the corresponding Docker test file. +The whole-stack E2E suite covers the default lazy lifecycle and reopen, all-eager startup, idle and wake, and parallel stack isolation for native and Docker runtimes, split into a `lifecycle` and an `idle-parallel` file per runtime. Run one runtime with a filter such as `pnpm --filter @supabase/stack test:e2e:run src/whole-stack.native` (matches both native files) or target one file, for example `src/whole-stack.docker.idle-parallel.e2e.test.ts`. For multiple instances or custom dependencies, configure members and bindings explicitly instead: ```ts const rest = await stack.services.create({ service: "rest", - config: { databaseUrl: "postgresql://configured-by-composition" }, + config: {}, endpoints: { http: { port: "auto" } }, }); @@ -129,31 +151,49 @@ Bindings supply ordinary configuration values; a URL alone never creates a depen - Instance methods affect that instance, subject to dependency checks. - Composition methods affect selected members; startup also includes declared prerequisites. -- `stack.stop()` stops every owned instance and attached tool and returns after confirming owner exit. It requires a live owner; an unavailable owner cannot confirm cleanup. +- `stack.stop()` stops every owned instance and attached command and returns after confirming owner exit. Without a live owner nothing runs, so it returns without starting one; an instance's `stop()` behaves the same way. - `stack.destroy()` additionally removes owned data and registrations, and also waits for owner exit. -- `stack.close()` disposes the client and invalidates its active observation iterators. Stopping the last instance leaves the owner available. +- `stack.close()` disposes the client and invalidates its active observation iterators. Closing the creating client of a session stack destroys the stack. Stopping the last instance leaves the owner available. Exit confirmation is bounded. If cleanup is acknowledged but owner exit cannot be confirmed, the operation fails with `operation: "shutdown-exit"` and the owner PID in the message. A failed or cancelled call does not guarantee that teardown has completed. Do not start or restart the same stack concurrently with whole-stack shutdown; separate stacks remain independent. -Disposable test stacks need explicit teardown; closing a client does not own their lifetime: +Each service exposes `status`, `followStatus`, `logs`, and `credentials`. Observations include the currently bound public endpoints, including listeners for sleeping services. Credentials default to host addressing. Use `from: "runtime"` for a URL passed to a service or command container. + +## Testing + +`@supabase/stack/testing` creates a disposable, ready stack for a test: ```ts -try { - // Exercise the stack. -} finally { - try { - await stack.destroy(); - } finally { - await stack.close(); - } -} +import { createTestStack } from "@supabase/stack/testing"; + +await using test = await createTestStack({ services: ["database", "rest"] }); +const { databaseUrl } = await test.services.database.credentials(); + +await test.checkpoint("seeded"); // after loading fixtures +// Exercise the stack. +await test.reset("seeded"); // discard writes made since the checkpoint ``` -Each service exposes `status`, `followStatus`, `logs`, and `credentials`. Observations include the currently bound public endpoints, including listeners for sleeping services. Credentials default to host addressing. Use `from: "runtime"` for a URL passed to a service or tool container. +The stack is a session stack composed with `composition.supabase` and `{ eager: true }`; each selected service is configured for local development with every endpoint on an automatic port, and `test.services` holds a typed handle per selected kind. Select a kind by name, or pass `{ service, config, endpoints }` to override part of its configuration. Disposal destroys the stack, then closes the client and removes the temporary project root. A session stack is also destroyed when the test process exits. + +Every test stack for the current OS user shares one state root and the package's artifact cache root under the OS temp directory, so their ports are coordinated and Docker uses one data volume; each gets a unique temporary project root and name. Pass `stateRoot`, `cacheRoot`, or `projectRoot` to override them. The runtime comes from `runtime`, then `SUPABASE_STACK_TEST_RUNTIME`, then the platform default: native where the catalog publishes native artifacts and Docker elsewhere. A startup failure names the state of each registered service and the owner log. + +`checkpoint(name)` stops the composition, saves the database data as an instance-scoped snapshot, and starts the composition again. `reset(name)` stops it, clears the database data, restores that snapshot, and waits until every service is ready. Checkpoints are never evicted by other stacks' snapshots and are removed when the test stack is destroyed. + +Effect tests use the scoped `makeTestStack`, which destroys the stack when its scope closes: + +```ts +import { makeTestStack } from "@supabase/stack/testing"; + +const test = Effect.gen(function* () { + const { services } = yield* makeTestStack({ services: ["database"] }); + yield* exercise(services.database); +}).pipe(Effect.scoped); +``` ## Effect consumers -The Effect entrypoint exposes the same operations as Effects and Streams. Database secrets use `Redacted` in the Effect configuration: +The Effect entrypoint exposes the same operations as Effects and Streams. `create` and `open` return a handle that lasts until the enclosing `Scope` closes; closing the creating scope of a session stack destroys it. Database secrets use `Redacted` in the Effect configuration: ```ts import { NodeHttpClient, NodeServices } from "@effect/platform-node"; @@ -169,20 +209,15 @@ const program = Effect.gen(function* () { }); await Effect.runPromise( - program.pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + program.pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), ); ``` -Cancelling an admitted lifecycle caller ends its wait; the owner finishes the operation. Cancelling an attached tool ends that job and cleans up its resources. Tool input and output stream with backpressure; the result contains a job ID and exit code, not collected output. Promise tool sinks should return a Promise when the destination requires waiting for capacity. - -For disposable Effect test fixtures, `acquireUseRelease` runs teardown on failure and interruption while retaining typed cleanup errors: +The Promise entrypoint is derived from this one: each Effect becomes a call that accepts `{ signal }`, each Effect-returning function takes the same arguments plus a trailing `{ signal }` (a `signal` inside any other options object, such as command or composition options, is ignored), each Stream becomes an async iterable, and `Redacted` configuration values become plain strings. `commands.run` takes Promise-returning sinks and an async-iterable `stdin`. -```ts -const test = Effect.acquireUseRelease( - Stack.create(options), - (stack) => exerciseStack(stack), - (stack) => stack.destroy, -); -``` +Cancelling an admitted lifecycle caller ends its wait; the owner finishes the operation. Cancelling an attached command ends that job and cleans up its resources. Command input and output stream with backpressure; the result contains a job ID and exit code, not collected output. Promise command sinks should return a Promise when the destination requires waiting for capacity. -The owner supports normal stop/start persistence. Unexpected owner death does not trigger resource adoption, orphan removal, or interrupted-operation recovery. CLI integration is maintained separately from this package. +The owner supports normal stop/start persistence. When an owner dies unexpectedly, its native processes die with it. Its containers remain until the next owner start in the same `stateRoot` removes them; that start also destroys session stacks whose owner is gone. Unexpected owner death does not trigger resource adoption or interrupted-operation recovery. CLI integration is maintained separately from this package. diff --git a/packages/stack/package.json b/packages/stack/package.json index bae03af447..c93c0764a7 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -7,14 +7,14 @@ ".": "./src/index.ts", "./effect": "./src/effect.ts", "./defaults": "./src/Defaults.ts", - "./tools": "./src/Tools.ts", + "./testing": "./src/testing.ts", + "./commands": "./src/Commands.ts", "./internal/dispatch": "./src/internal/dispatch.ts", - "./internal/identity": "./src/identity/Identity.ts", - "./internal/functions/serve-main": "./src/functions/serve.main.ts", - "./internal/postgres-artifact": "./src/internal/postgres-artifact.ts", - "./internal/service-catalog": "./src/internal/service-catalog.ts" + "./internal/artifacts": "./src/internal/artifacts.ts", + "./internal/release": "./src/internal/release.ts" }, "scripts": { + "generate": "bun run scripts/generate-functions-bootstrap.ts", "test": "pnpm run test:unit && pnpm run test:integration", "test:unit": "pnpm exec turbo run @supabase/stack#test:unit:run --", "test:unit:run": "bun --bun vitest run --project unit", diff --git a/packages/stack/scripts/generate-functions-bootstrap.ts b/packages/stack/scripts/generate-functions-bootstrap.ts new file mode 100644 index 0000000000..66a2d0fc4b --- /dev/null +++ b/packages/stack/scripts/generate-functions-bootstrap.ts @@ -0,0 +1,76 @@ +import { NodeRuntime, NodeServices } from "@effect/platform-node"; +import { build, stop } from "esbuild"; +import { Data, Effect, FileSystem, Path } from "effect"; + +class FunctionsBootstrapBundleError extends Data.TaggedError("FunctionsBootstrapBundleError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const packageRoot = Effect.gen(function* () { + const path = yield* Path.Path; + return path.dirname(path.dirname(yield* path.fromFileUrl(new URL(import.meta.url)))); +}); + +/** Location of the committed default Functions bootstrap module. */ +export const generatedModulePath = Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(yield* packageRoot, "src", "functions", "generated", "serve-main-bundle.ts"); +}); + +const bundleServeMain = Effect.gen(function* () { + const path = yield* Path.Path; + const entrypoint = path.join(yield* packageRoot, "src", "functions", "serve.main.ts"); + const result = yield* Effect.tryPromise({ + try: () => + build({ + entryPoints: [entrypoint], + bundle: true, + format: "esm", + platform: "browser", + minify: true, + write: false, + legalComments: "none", + logLevel: "silent", + }), + catch: (cause) => + new FunctionsBootstrapBundleError({ message: "Unable to bundle functions bootstrap", cause }), + }); + const output = result.outputFiles[0]?.text; + if (output === undefined) + return yield* new FunctionsBootstrapBundleError({ + message: "esbuild produced no functions bootstrap output", + }); + return output; +}).pipe((program) => + Effect.uninterruptibleMask((restore) => + Effect.flatMap(Effect.exit(restore(program)), (result) => + Effect.tryPromise({ + try: () => stop(), + catch: (cause) => + new FunctionsBootstrapBundleError({ message: "Unable to stop esbuild", cause }), + }).pipe(Effect.andThen(result)), + ), + ), +); + +/** Renders the generated module that embeds the self-contained Edge Runtime main service. */ +export const renderFunctionsBootstrapModule = Effect.map( + bundleServeMain, + (bundle) => + "// Generated by packages/stack/scripts/generate-functions-bootstrap.ts; run `pnpm generate`.\n" + + "/** Bundled Edge Runtime main service used when a Functions creation has no bootstrap. */\n" + + `export const defaultFunctionsBootstrap = ${JSON.stringify(bundle)};\n`, +); + +if (import.meta.main) + NodeRuntime.runMain( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = yield* generatedModulePath; + const source = yield* renderFunctionsBootstrapModule; + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, source); + }).pipe(Effect.provide(NodeServices.layer)), + ); diff --git a/packages/stack/scripts/generate-functions-bootstrap.unit.test.ts b/packages/stack/scripts/generate-functions-bootstrap.unit.test.ts new file mode 100644 index 0000000000..e2830cfba5 --- /dev/null +++ b/packages/stack/scripts/generate-functions-bootstrap.unit.test.ts @@ -0,0 +1,24 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; +import { + generatedModulePath, + renderFunctionsBootstrapModule, +} from "./generate-functions-bootstrap.ts"; + +describe("generated Functions bootstrap", () => { + it.effect( + "matches a fresh bundle of the main service sources", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const committed = yield* fs.readFileString(yield* generatedModulePath); + const rendered = yield* renderFunctionsBootstrapModule; + expect( + committed === rendered, + "src/functions/generated/serve-main-bundle.ts is stale; run `pnpm generate` in packages/stack", + ).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + 30_000, + ); +}); diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 78b6d5032c..4ffbe1290d 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -30,10 +30,42 @@ export class ArtifactError extends Data.TaggedError("ArtifactError")<{ readonly cause?: unknown; }> {} +/** Lowercase hexadecimal SHA-256 digest. */ +type Sha256 = string; + +/** Content digests of one native target's published archive and manifest. */ +export interface NativePin { + readonly archive: Sha256; + readonly manifest: Sha256; +} + +/** One published slim-services revision of an upstream version, pinned by content. */ +export interface ArtifactPin { + readonly upstreamVersion: string; + readonly revision: number; + /** `ghcr.io/supabase/cli/<service>:<release version>@sha256:<digest>`. */ + readonly image: string; + /** + * The exact upstream image this release was built or mirrored from, as slim-services recorded + * it: the release manifest's `upstream_image` for a derived service, or the release's + * `oci-provenance.json` `source` for a mirrored one (vector, mailpit, imgproxy). Normalized to + * the Dockerfile's `FROM` form — no leading `docker.io/`, no digest — so legacy non-slim mode + * can use it as the upstream tag directly. + */ + readonly upstreamImage: string; + readonly natives: Readonly<Record<NativeTarget, NativePin>>; +} + +/** The published release version, `<upstream>-r<revision>`. */ +const releaseVersion = (pin: ArtifactPin): string => `${pin.upstreamVersion}-r${pin.revision}`; + interface ArtifactResolution { readonly service: ServiceKind; + /** Upstream version. */ readonly version: string; + readonly releaseVersion: string; readonly image: string; + readonly natives: ArtifactPin["natives"]; readonly executablePath: string; readonly requiredRuntimePaths: ReadonlyArray<string>; } @@ -48,94 +80,380 @@ export interface PreparedNativeArtifact { interface ArtifactDefinition { readonly sourceService: string; readonly defaultVersion: string; - readonly images: Readonly<Record<string, string>>; + /** Pins keyed by upstream version. */ + readonly pins: Readonly<Record<string, ArtifactPin>>; readonly requiredRuntimePaths: ReadonlyArray<string>; readonly executablePath: string; } const definition = ( sourceService: string, - defaultVersion: string, - image: string, + pin: ArtifactPin, executablePath: string, requiredRuntimePaths: ReadonlyArray<string> = [executablePath], - additionalImages: Readonly<Record<string, string>> = {}, + additionalPins: Readonly<Record<string, ArtifactPin>> = {}, ): ArtifactDefinition => ({ sourceService, - defaultVersion, - images: { [defaultVersion]: image, ...additionalImages }, + defaultVersion: pin.upstreamVersion, + pins: { [pin.upstreamVersion]: pin, ...additionalPins }, requiredRuntimePaths, executablePath, }); +const SLIM_IMAGE_GHCR_REGISTRY = "ghcr.io/supabase/cli/"; + const definitions: Readonly<Record<ServiceKind, ArtifactDefinition>> = { database: definition( "postgres", - "17.6.1.173", - "ghcr.io/supabase/cli/postgres:17.6.1.173@sha256:9d6e542382946cad5eb1f11f1c8108a51297902ee42fe5098358816d3784ba5a", + { + upstreamVersion: "17.11.0.002", + revision: 0, + image: + "ghcr.io/supabase/cli/postgres:17.11.0.002-r0@sha256:5a551a204a41267c4f78a2e5b34657f5c6fdd39eddccd50262466481b95739cc", + upstreamImage: "supabase/postgres:17.11.0.002", + natives: { + "darwin-arm64": { + archive: "10410e402c77210a0543539d9bafcabd0c04923d8e796928cbd2a053cf7b4f4f", + manifest: "6c1f236324f02baf472152aafed68884cb115b3979fbf1c49209717485d36ac2", + }, + "linux-amd64": { + archive: "4a4410791bdeeda2e08fda400039b4319bb26d74e08e68951f38abc9dacd7c26", + manifest: "cd76249031db380831d773acea6fea8a54104d5ca97bb18db34d05e7bd124e5f", + }, + "linux-arm64": { + archive: "1d54954b2441990643f25a825e4e46ec50ecf538a63baa8e413e353e7faeb939", + manifest: "8dda36ffdb7b5b501873ff41c265bb061e8eb9aeed0e4372c5ad9af59110776b", + }, + }, + }, "bin/supabase-postgres-start", ["bin/supabase-postgres-start", "bin/pg_dump", "bin/pg_dumpall", "bin/pg_prove", "bin/psql"], { - "15.14.1.173": - "ghcr.io/supabase/cli/postgres:15.14.1.173@sha256:3abb20e89700d6211e741148b85c3a052bb290cae3b4f751311442f4a5fe2324", + "15.19.0.002": { + upstreamVersion: "15.19.0.002", + revision: 0, + image: + "ghcr.io/supabase/cli/postgres:15.19.0.002-r0@sha256:a3f343f19323497a5766fa4e86a51dba495a8eca354cc128d879b4632c6bd017", + upstreamImage: "supabase/postgres:15.19.0.002", + natives: { + "darwin-arm64": { + archive: "59b05ca76db9d807803840264d885a5ff435e552f294d7f22ca831fac1ffb4a3", + manifest: "b7956520ee15a8265635cabb318490f89c4737706f9349246da9921a17fbac07", + }, + "linux-amd64": { + archive: "bdf565e0b866ca7b2494b56b502963ef57d54ab1c99eac6855f760caaffda712", + manifest: "e1ecda082f1c414fea6356d0399eaf76d22a25b96808961a945aacb242f904c9", + }, + "linux-arm64": { + archive: "c7420f0eb0938397d90906265630359313936cc576035cb31c2bdef6451565c6", + manifest: "207feac260bcbc5607decca9e14e0422accc4a0ae1f7d24b8fe0992d284cc43c", + }, + }, + }, + }, + ), + rest: definition( + "postgrest", + { + upstreamVersion: "v16.4", + revision: 0, + image: + "ghcr.io/supabase/cli/postgrest:v16.4-r0@sha256:63a8d4acfdeb107b6568f4582759c78072100ef07951a7fbe58c9a51241138a7", + upstreamImage: "postgrest/postgrest:v16.4", + natives: { + "darwin-arm64": { + archive: "a1f5449d739404cbd042ec9dbabadea6dcf810db636e44c7176b859d5e78ab07", + manifest: "943cbeb7a3cf2f9dd0406152a178d814e81221d0061626b04ab2b042209d5e03", + }, + "linux-amd64": { + archive: "d9170378062dba1188c25fb05cd08a3397bb62afd63151812f831eed4fab1051", + manifest: "a3f3f29c4c4c5f7b004b507152f29316bc79df052652fb00ad5ad9bdd6f7be29", + }, + "linux-arm64": { + archive: "c1a99eddaac0c005b2520960d258c7fc79945ba0703eff3a2515aa119fac4477", + manifest: "7ced80e3f814d9748763983de74706c6e04a897f8c024366af156c8093cf150f", + }, + }, + }, + "bin/postgrest", + ), + auth: definition( + "auth", + { + upstreamVersion: "v2.197.0", + revision: 0, + image: + "ghcr.io/supabase/cli/auth:v2.197.0-r0@sha256:7eb303831d170865840dbb3f9018b48561c988704ad1d9a2bafabb4e93e92da5", + upstreamImage: "supabase/gotrue:v2.197.0", + natives: { + "darwin-arm64": { + archive: "eb365c4aea1e1cd04998ed16cd49b1b90e6a097ce633e5ee55f8bbbebdd349c7", + manifest: "178f748da1c886b07945a67a839d3a17fa546e01215a90329ded1a634831deb6", + }, + "linux-amd64": { + archive: "bd8f59ed1a817014afe71288c2275ad0396630f016f8cb5ab5129388860ec49b", + manifest: "ccad45ee54edf7506b055b85dcebee6db8c5b9ec57ac3c6d30baf38bb81b1deb", + }, + "linux-arm64": { + archive: "9598a8dd5a08707f65795505211c479b686c54e3dadbe64b3e1989855215cf0c", + manifest: "4f5f3a6284f0b161cf49a201fe551470d5b6e0e2b37dedb4dacdd0e42abaf3b1", + }, + }, }, + "bin/auth", ), - rest: definition("postgrest", "v16.2", "ghcr.io/supabase/cli/postgrest:v16.2", "bin/postgrest"), - auth: definition("auth", "v2.196.0", "ghcr.io/supabase/cli/auth:v2.196.0", "bin/auth"), realtime: definition( "realtime", - "v2.134.5", - "ghcr.io/supabase/cli/realtime:v2.134.5@sha256:7fb53cc6987085d739c7d161608505ed138d1895df884c3bdc2147cef444138a", + { + upstreamVersion: "v2.140.3", + revision: 0, + image: + "ghcr.io/supabase/cli/realtime:v2.140.3-r0@sha256:af7b883647770351eef150cc16e1f12d7fe4b4920324e2e40487961ad3b22789", + upstreamImage: "supabase/realtime:v2.140.3", + natives: { + "darwin-arm64": { + archive: "e015ced15ed2110c2a0123b6e75edb2aeb12ac6bdbacffdfb296262a4215f97e", + manifest: "a833a92e7b2e34b6bfcf41c4c934587f85e96eb11acb9cce14852b8ffec312d6", + }, + "linux-amd64": { + archive: "d528a255b2cb20df25ab9440e29f026e2fb3c993e4ed7b0707e037d0cb3880d9", + manifest: "fc5445d4b28d41691091c8cdccde7cff0dbd06af2bf56e70401ec6a049fb8757", + }, + "linux-arm64": { + archive: "bb3b810540dbba2f9a94eb974799384cbffe67756cd91ebb3b8254d02d890e2e", + manifest: "1a33f5620a39a64b49645c25878a892122d695f8f1a26d3548fb5332d6e0d88b", + }, + }, + }, "bin/server", ["bin/server", "bin/prepare"], ), storage: definition( "storage", - "v1.73.0", - "ghcr.io/supabase/cli/storage:v1.73.0@sha256:69590a75f916837641976d4018e5ead7c7d2c2305312d9bfb06d86aec8fb1cdd", + { + upstreamVersion: "v1.79.28", + revision: 1, + image: + "ghcr.io/supabase/cli/storage:v1.79.28-r1@sha256:95e0007f273e7c990ab81c44e021e4278b8953dd95ae2fbdc19fca047d4bd470", + upstreamImage: "supabase/storage-api:v1.79.28", + natives: { + "darwin-arm64": { + archive: "bea019baff21e10b78291a9c23687ec15ebb45994d0d03c656ffda13e13108d4", + manifest: "8612ea30b918ac6590f30660b518ea41b80d9d591734fa3313a64c6607621cb2", + }, + "linux-amd64": { + archive: "c8ce124acfe46a2151052f160a81653f751277516f5c378d93ba7f18e7b3efe4", + manifest: "edce6bb24a84e5245956c1de3fa93d96d3813efbe2e5dac105b385ebae7e3f0d", + }, + "linux-arm64": { + archive: "8a51c2b8bc4d1076665321ae0da7eca81dbdff84dbc687a209a958955cf9cb69", + manifest: "befaaec0e8660d99a4040f81ece4277e4db27812b8e2630a4d0b15d16b3ed831", + }, + }, + }, "bin/storage", ["bin/storage", "bin/prepare"], ), imgproxy: definition( "imgproxy", - "v3.8.0", - "ghcr.io/supabase/cli/imgproxy:v3.8.0", + { + upstreamVersion: "v3.26.0", + revision: 0, + image: + "ghcr.io/supabase/cli/imgproxy:v3.26.0-r0@sha256:5871582cf6c5140d3b50b21e0d1aa236fdf46ee6133e3bfec1baf63545e01cf0", + upstreamImage: "ghcr.io/imgproxy/imgproxy:v3.26.0", + natives: { + "darwin-arm64": { + archive: "1f82084b056bf4806bf492bca0b6f963068d07a6776a2a122b61da940732683b", + manifest: "53b5c4f4f069db88c3919571dbee12af2373e5c90db291cf5d7456aa74e17ecb", + }, + "linux-amd64": { + archive: "7865edf054b8217c2daf9d42bce6bebe34f83df5c4183fbbe6495a3603dcd4a8", + manifest: "5ae7f22a6fbdc55f41ffc3334747a3475295676699ac1d2c372bc5fe2ad239bb", + }, + "linux-arm64": { + archive: "4528fd7877c9df3052cd6ec06c8cafd7e92977dd26490fb532964c19faec3b0a", + manifest: "5420e6d9b3661060b2dedfe157c18b56f22011c43cc3127031b4bbc65e936b59", + }, + }, + }, "bin/imgproxy", ), functions: definition( "edge-runtime", - "v1.76.2", - "ghcr.io/supabase/cli/edge-runtime:v1.76.2", + { + upstreamVersion: "v1.77.1", + revision: 0, + image: + "ghcr.io/supabase/cli/edge-runtime:v1.77.1-r0@sha256:98582ce39914bba6ac856a5bf6ed3c1584ce4616687ca359c8acd5f59dd9ff59", + upstreamImage: "supabase/edge-runtime:v1.77.1", + natives: { + "darwin-arm64": { + archive: "e52fdbedccf258fd9a427b19bd34569ca210c1ca257a85fc23f66b4e18c1d6a1", + manifest: "ef2590d8119fe1c2c877746ef72f58ed4d935ce23647b893fd5d36892b0496e0", + }, + "linux-amd64": { + archive: "e524630f0743319d77535f93748a894520fd52cf9c8fa8c2adb22e08ddb39419", + manifest: "3ff18dc0667373dfc108da7d206dbfbf844f2596a804319343d5793298416e9c", + }, + "linux-arm64": { + archive: "c0fe416e506087afd75771beeeb6cfd070d4d6056293a940e3bf453637ba7a5f", + manifest: "703581ebe6213c484955313fbec3ea6ad5f20a86f0c48eb0e29d1bbe9d1c286b", + }, + }, + }, "bin/edge-runtime", ), studio: definition( "studio", - "2026.09.04-sha-5a67366", - "ghcr.io/supabase/cli/studio:2026.09.04-sha-5a67366@sha256:9823a31668028f1846e87331bc21598d9cd74bcaa1466c72dab58c33c9c82720", + { + upstreamVersion: "2026.09.28-sha-5e59b60", + revision: 0, + image: + "ghcr.io/supabase/cli/studio:2026.09.28-sha-5e59b60-r0@sha256:4cf4f70978bb0866d1644b43cb0d23acc4b4ef7a898592043ad0d13cad8059be", + upstreamImage: "supabase/studio:2026.09.28-sha-5e59b60", + natives: { + "darwin-arm64": { + archive: "37f420f6af3d5ee7dab884e8c39fe2c3bcddcee0d90e53d1e075a05ae3cb4b73", + manifest: "b5d6209c4c28e594cc8b0ab961105465418f0021ba50a3fd92067ff5720ae820", + }, + "linux-amd64": { + archive: "19a9903732b71fba04ce342e4d13b83bf7579bce806981cf8f2b491ad792fc6e", + manifest: "5a4f1e316ad84ff058263601b9b144177ab0cd18443e826354551aec88013199", + }, + "linux-arm64": { + archive: "ce69544c9ec5dbae50e5da731770bc199e12f0f1d63b22ec7a43b6a0ba00cb4c", + manifest: "c27374ccc51cc6419f9bbd14f095f1a16db4bda369928effcc15a6366dcb3d1f", + }, + }, + }, "bin/studio", ), pgmeta: definition( "pgmeta", - "v0.99.0", - "ghcr.io/supabase/cli/pgmeta:v0.99.0@sha256:90de2dcf03ac548ae2d1d3e71b3cd10bde4c627572720a42e4c3946b7090292e", + { + upstreamVersion: "v0.99.0", + revision: 0, + image: + "ghcr.io/supabase/cli/pgmeta:v0.99.0-r0@sha256:c19f6bba3ab66fcf30c8737d2361ec9f8e12a4fa8a071481f53366dc13e83340", + upstreamImage: "supabase/postgres-meta:v0.99.0", + natives: { + "darwin-arm64": { + archive: "337f8cc6a23d93f3f9cfeed12de2a86d686ce6f4346ff9b334b5dfdcba7e890f", + manifest: "6b35ee42d334138562444842ed0662b97b3cbde504caa11e97407b137bc8b2ca", + }, + "linux-amd64": { + archive: "43156ba28901710bf02a333dc04c4b30754eb6a2334ff1d890a4a3ea55c02f22", + manifest: "dbd8a7eac705b6c6df16826f22f3317906842be8498ce44fa1229ae6f16273ad", + }, + "linux-arm64": { + archive: "a8565d6e550efa8a8481c7d542b612e7a6d50668321fcd2e668b2ee9bcb28ed7", + manifest: "1b67627c5ccde0f94a4997ab223a0ac072c70e9bf1bb86d3e4d0df42e90b4059", + }, + }, + }, "bin/pgmeta", ), - mail: definition("mailpit", "v1.30.2", "ghcr.io/supabase/cli/mailpit:v1.30.2", "bin/mailpit"), + mail: definition( + "mailpit", + { + upstreamVersion: "v1.31.3", + revision: 0, + image: + "ghcr.io/supabase/cli/mailpit:v1.31.3-r0@sha256:ed9b00c609e77e99c79b93f1178255ebc271868920f2c69a8d166bd5634ed10d", + upstreamImage: "axllent/mailpit:v1.31.3", + natives: { + "darwin-arm64": { + archive: "d460a6a55693a2a321ad83ebb83417b8078324751a744f982bb3512da0632712", + manifest: "43177dc88b6f625bec6d98096e39c10f4cd10956081df01b5b7cebeeae47766a", + }, + "linux-amd64": { + archive: "57e39cb39b2288313e26abe9ae9300f6af9332abd1f76f4d67be533741b2c942", + manifest: "36595519f40e406b17a0ce6e21f153400f217ae24b0365f0a5c495ff315b9f82", + }, + "linux-arm64": { + archive: "805b09d9008e9be2c6c9230e668bd2c6ab446eeae26389e91732f9e20b2c3f5f", + manifest: "973b76700a5a865243bd5a5c36664121ed99c538d58633921cc8fd7c675e655c", + }, + }, + }, + "bin/mailpit", + ), analytics: definition( "analytics", - "v1.50.9", - "ghcr.io/supabase/cli/analytics:v1.50.9@sha256:7db85cc6cb0cdeb4b71f2fadb49c0f9197bea0492daf7896f6ae69edad76d28e", + { + upstreamVersion: "v1.50.15", + revision: 0, + image: + "ghcr.io/supabase/cli/analytics:v1.50.15-r0@sha256:cea1595bafa6ab32df4854d407261ddef6e35394e300ae29770ba48c49c8dc7e", + upstreamImage: "supabase/logflare:1.50.15", + natives: { + "darwin-arm64": { + archive: "336c78c501aff270b0fcd2a42229b76d9f2feafa646f49a59c3de29358e3de17", + manifest: "d0e1a0c3fa0a4ebfcebbe490f290699424e3b8c92a542f50111ff88d6e47b8b5", + }, + "linux-amd64": { + archive: "a699ce1522dac43989987a9d152648ed0506fb887ffedff49c148da02cbbc7e7", + manifest: "ba8c55eeb06be91ad582156854babdc6a06d7a26fa6956bc1271eee4cae9d536", + }, + "linux-arm64": { + archive: "bb977b9cd0623e1b1ece9875377fdf29577f96991d2e5c72a38a1b664c6a7538", + manifest: "1739bbd4afb838e62b6191c4de8aa39685df9f5d83e1b7cc303bd096237ba08c", + }, + }, + }, "bin/logflare", ["bin/logflare", "bin/prepare"], ), - vector: definition("vector", "0.53.0", "ghcr.io/supabase/cli/vector:0.53.0", "bin/vector", [ + vector: definition( + "vector", + { + upstreamVersion: "0.58.0", + revision: 0, + image: + "ghcr.io/supabase/cli/vector:0.58.0-r0@sha256:5dcf67db0ee378caa87f3395cb9484ebe3e97bb0334d119f2ac33116e00c5773", + upstreamImage: "timberio/vector:0.58.0-alpine", + natives: { + "darwin-arm64": { + archive: "567245cf9a7d54eee45ecf74e1c9a61ca6d02cdca103edc7b32b005e54f4e632", + manifest: "a973a763b00599858ceae8f304714fb99f785860112e9e0812ef0df8f81dd2ee", + }, + "linux-amd64": { + archive: "697f4fae35be3026474695bef16336f6fcfd429ce8cfba884c595896e96c30ec", + manifest: "8989b8b061f08bd7653e9ae71c6ee0e5cf01a0b10f2d358fe3dbc671ec5b63e1", + }, + "linux-arm64": { + archive: "c66b8ad0a0dd0fdcb3e4ee36bae8040b7b23b44ec2b4023565ca335884268c1d", + manifest: "992467ec68a99a6413468b9311294abb7a1f86b7857ad37f1f3bd9e6aecc9dbb", + }, + }, + }, "bin/vector", - "share/doc/vector/config/vector.yaml", - ]), + ["bin/vector", "share/doc/vector/config/vector.yaml"], + ), pooler: definition( "pooler", - "v2.9.12", - "ghcr.io/supabase/cli/pooler:v2.9.12@sha256:12bb9dcb7ddace79bee173ccb7327c6646af2236679f3bd932a86b3a06479aac", + { + upstreamVersion: "v2.9.13", + revision: 0, + image: + "ghcr.io/supabase/cli/pooler:v2.9.13-r0@sha256:3a32b56d03675ed24e84408afcbb12fa52b7ec6e7741f913770fea5b66c2ddd3", + upstreamImage: "supabase/supavisor:2.9.13", + natives: { + "darwin-arm64": { + archive: "c05285be2a945a29d5be491661926f8c88d976540d49ddbcab10aa7276b29934", + manifest: "0fafa8dcf601ff3cea7326e82f7a5191ffe03e3e4edd0d2b155e80e8c9f53d78", + }, + "linux-amd64": { + archive: "d94e36f44267b4159fa55e1aea39320a68789b6af07f9cc87c27327ed6a00602", + manifest: "4f181d1d25da91f72ca37c22cdc46278a424a4edef6bdd2ea2c30f463c629a5a", + }, + "linux-arm64": { + archive: "124f3e6c95e6ba985239e013cab76c5be28fbd92cb4986b687dd5e827f0525ca", + manifest: "c6cdc298bc3a00d082a1c9c34f4131f13437df51fb0bf9a0ce857b0a2fd66460", + }, + }, + }, "bin/server", ["bin/server", "bin/prepare", "bin/provision-tenant"], ), @@ -151,6 +469,14 @@ const targetForPlatform = (platform: { return undefined; }; +/** Selects native execution where the catalog publishes native artifacts, and Docker elsewhere. */ +export const defaultRuntime = ( + platform: { readonly os: string; readonly arch: string } = { + os: process.platform, + arch: process.arch, + }, +): "native" | "docker" => (targetForPlatform(platform) === undefined ? "docker" : "native"); + const platformText = (platform: { readonly os: string; readonly arch: string }): string => `${platform.os}/${platform.arch}`; @@ -166,10 +492,6 @@ const SLIM_NATIVE_GITHUB_RELEASES = "https://github.com/supabase/slim-services/r */ const SLIM_NATIVE_SUPABASE_S3_MIRROR = "https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com"; -const SLIM_NATIVE_GHCR_REGISTRY = "ghcr.io"; -const SLIM_NATIVE_GHCR_REPOSITORY = "supabase/cli"; - -const SLIM_IMAGE_GHCR_REGISTRY = "ghcr.io/supabase/cli/"; const SLIM_IMAGE_SUPABASE_ECR_MIRROR = "public.ecr.aws/supabase/cli/"; /** Mirrors carrying a catalog slim image under the same tag and digest, in fallback order. */ @@ -184,27 +506,21 @@ const artifactFor = ( target: NativeTarget, ): SlimServicesArtifact => { const sourceService = definitions[service].sourceService; - const releaseTag = `${sourceService}-${resolved.version}`; + const releaseTag = `${sourceService}-${resolved.releaseVersion}`; const assetName = `${releaseTag}-${target}`; const githubRelease = `${SLIM_NATIVE_GITHUB_RELEASES}/${releaseTag}`; - const supabaseS3 = `${SLIM_NATIVE_SUPABASE_S3_MIRROR}/${sourceService}/${resolved.version}`; + const supabaseS3 = `${SLIM_NATIVE_SUPABASE_S3_MIRROR}/${sourceService}/${resolved.releaseVersion}`; + const pin = resolved.natives[target]; return { provider: "supabase/slim-services", service: sourceService, - version: resolved.version, + version: resolved.releaseVersion, releaseTag, target, archive: "tar.zst", assetName, - checksums: [ - { kind: "sha256sums", url: `${githubRelease}/SHA256SUMS` }, - { - kind: "oci", - registry: SLIM_NATIVE_GHCR_REGISTRY, - repository: `${SLIM_NATIVE_GHCR_REPOSITORY}/${sourceService}`, - tag: `${resolved.version}-native-${target}`, - }, - ], + sha256: pin.archive, + manifestSha256: pin.manifest, mirrors: [ { downloadUrl: `${githubRelease}/${assetName}.tar.zst`, @@ -228,8 +544,8 @@ export const resolveArtifact = Effect.fn("Artifacts.resolveArtifact")(function* return yield* new ArtifactError({ message: `Unknown service kind: ${request.service}` }); const selected = definitions[request.service]; const version = request.version ?? selected.defaultVersion; - const image = Object.entries(selected.images).find(([candidate]) => candidate === version)?.[1]; - if (image === undefined) + const pin = Object.entries(selected.pins).find(([candidate]) => candidate === version)?.[1]; + if (pin === undefined) return yield* new ArtifactError({ message: `Unsupported ${request.service} artifact version: ${version}`, service: request.service, @@ -238,7 +554,9 @@ export const resolveArtifact = Effect.fn("Artifacts.resolveArtifact")(function* return { service: request.service, version, - image, + releaseVersion: releaseVersion(pin), + image: pin.image, + natives: pin.natives, executablePath: selected.executablePath, requiredRuntimePaths: selected.requiredRuntimePaths, }; @@ -246,13 +564,33 @@ export const resolveArtifact = Effect.fn("Artifacts.resolveArtifact")(function* /** Resolves a PostgreSQL major alias against the pinned database artifacts. */ export const postgresVersion = (version: string): string => - Object.keys(definitions.database.images).find( - (candidate) => candidate.split(".")[0] === version, - ) ?? version; + Object.keys(definitions.database.pins).find((candidate) => candidate.split(".")[0] === version) ?? + version; /** Service kinds in artifact catalog order. */ export const artifactServiceKinds = (): ReadonlyArray<ServiceKind> => Record.keys(definitions); +/** + * Every catalog pin in catalog order, including additional upstream lines. `isDefault` marks the + * pin `resolveArtifact` picks when no version is requested (postgres's 17.x line today); every + * other pin (postgres's 15.x additional line) carries `isDefault: false`. + */ +export const catalogPins = (): ReadonlyArray<{ + readonly service: ServiceKind; + readonly sourceService: string; + readonly pin: ArtifactPin; + readonly isDefault: boolean; +}> => + artifactServiceKinds().flatMap((service) => { + const { sourceService, defaultVersion, pins } = definitions[service]; + return Object.values(pins).map((pin) => ({ + service, + sourceService, + pin, + isDefault: pin.upstreamVersion === defaultVersion, + })); + }); + const artifactKey = (artifact: SlimServicesArtifact): string => `slim-services/${artifact.service}/${artifact.version}/${artifact.target}`; diff --git a/packages/stack/src/Artifacts.unit.test.ts b/packages/stack/src/Artifacts.unit.test.ts index 0e0ecf510d..50c55fd78c 100644 --- a/packages/stack/src/Artifacts.unit.test.ts +++ b/packages/stack/src/Artifacts.unit.test.ts @@ -1,5 +1,14 @@ import { expect, it } from "@effect/vitest"; -import { slimImageMirrors } from "./Artifacts.ts"; +import { catalogPins, slimImageMirrors } from "./Artifacts.ts"; + +it("carries a normalized upstreamImage for every catalog pin", () => { + for (const { pin } of catalogPins()) { + expect(pin.upstreamImage).not.toBe(""); + expect(pin.upstreamImage).not.toContain("docker.io/"); + expect(pin.upstreamImage).not.toContain("@sha256:"); + expect(pin.upstreamImage.split(":").at(-1)).not.toBe(""); + } +}); it("rewrites a GHCR catalog image onto ECR Public and keeps the tag and digest", () => { expect( diff --git a/packages/stack/src/Tools.integration.test.ts b/packages/stack/src/Commands.integration.test.ts similarity index 75% rename from packages/stack/src/Tools.integration.test.ts rename to packages/stack/src/Commands.integration.test.ts index f03ca504d2..7e6d3b7a36 100644 --- a/packages/stack/src/Tools.integration.test.ts +++ b/packages/stack/src/Commands.integration.test.ts @@ -11,13 +11,16 @@ import { Predicate, Redacted, Ref, + Scope, Schedule, Stream, } from "effect"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { postgres } from "./Tools.ts"; -import * as ToolRunner from "./host/ToolRunner.ts"; +import { postgres } from "./Commands.ts"; +import * as CommandRunner from "./host/CommandRunner.ts"; +import { CommandError } from "./host/CommandRunner.ts"; +import type { PgProveOptions, PostgresCommand } from "./Commands.ts"; import { makeDatabase } from "./services/Database.ts"; import { makeService } from "./Service.ts"; import { bindTcp, serveTcp } from "./Proxy.ts"; @@ -28,24 +31,54 @@ class PgProveTestError extends Data.TaggedError("PgProveTestError")<{ readonly message: string; }> {} -const makeTestToolRunner = (options: { +const makeTestCommandRunner = (options: { readonly stackId: string; readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; }) => - Layer.build(ToolRunner.layer(options)).pipe( - Effect.map((context) => Context.get(context, ToolRunner.Service)), + Layer.build(CommandRunner.layer(options)).pipe( + Effect.map((context) => Context.get(context, CommandRunner.Service)), ); -describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { +const runPostgres = ( + runner: CommandRunner.Interface, + input: { + readonly postgres: PostgresCommand; + readonly args: ReadonlyArray<string>; + readonly env: Readonly<Record<string, string>>; + readonly pgProve?: PgProveOptions; + readonly stdin?: Stream.Stream<Uint8Array>; + readonly stdout: (bytes: Uint8Array) => Effect.Effect<void>; + readonly stderr: (bytes: Uint8Array) => Effect.Effect<void>; + }, +): Effect.Effect< + { readonly jobId: string; readonly exitCode: number }, + CommandError, + Scope.Scope +> => + runner.run({ + command: { + type: "postgres", + command: input.postgres, + args: input.args, + env: input.env, + ...(input.pgProve === undefined ? {} : { pgProve: input.pgProve }), + stdin: input.stdin !== undefined, + }, + stdin: input.stdin, + stdout: input.stdout, + stderr: input.stderr, + }); + +describe("finite PostgreSQL commands", { timeout: 180_000 }, () => { for (const runtime of ["native", "docker"] as const) { it.live(`${runtime} runs SQL from stdin, streams a dump, and returns client failure`, () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-tools-" }); - const stackId = "tools-integration"; + const stackId = `tools-integration-${randomUUID()}`; const database = yield* makeDatabase({ root, cacheRoot, @@ -73,8 +106,9 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { Effect.succeed( endpoint.kind === "unix" ? { path: `${endpoint.path}/.s.PGSQL.5432` } : endpoint, ), + "database:sql", ).pipe(Effect.forkScoped); - const runner = yield* makeTestToolRunner({ root, cacheRoot, stackId, runtime }); + const runner = yield* makeTestCommandRunner({ root, cacheRoot, stackId, runtime }); const env = { PGHOST: runtime === "native" ? "127.0.0.1" : "host.docker.internal", PGPORT: String(listener.address.port), @@ -90,8 +124,8 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { stderr: (bytes: Uint8Array) => Ref.update(stderr, (text) => text + new TextDecoder().decode(bytes)), }; - const sql = yield* runner.run({ - tool: postgres.psql({ major: 17 }), + const sql = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: ["-X", "-v", "ON_ERROR_STOP=1"], env, stdin: Stream.make( @@ -104,8 +138,8 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { expect(sql.exitCode).toBe(0); expect(yield* Ref.get(stdout)).toContain("streamed-row"); yield* Ref.set(stdout, ""); - const dump = yield* runner.run({ - tool: postgres.pgDump({ major: 17 }), + const dump = yield* runPostgres(runner, { + postgres: postgres.pgDump({ major: 17 }), args: ["--data-only", "--table=tool_story"], env, ...output, @@ -114,8 +148,8 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { expect(dump.jobId).not.toBe(sql.jobId); expect(yield* Ref.get(stdout)).toContain("COPY public.tool_story"); expect(yield* Ref.get(stdout)).toContain("streamed-row"); - const failed = yield* runner.run({ - tool: postgres.psql({ major: 17 }), + const failed = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: ["-X", "-v", "ON_ERROR_STOP=1", "-c", "SELECT missing_column FROM tool_story"], env, ...output, @@ -123,18 +157,18 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { expect(failed.exitCode).not.toBe(0); expect(yield* Ref.get(stderr)).toContain("missing_column"); const rejected = yield* Effect.flip( - runner.run({ - tool: postgres.psql({ major: 17 }), + runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: [], env, pgProve: { mounts: [] }, ...output, }), ); - expect(rejected.message).toContain("pgProve options require the pg_prove tool"); + expect(rejected.message).toContain("pgProve options require the pg_prove command"); yield* Ref.set(stderr, ""); - const early = yield* runner.run({ - tool: postgres.psql({ major: 17 }), + const early = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: ["-X", "-v", "ON_ERROR_STOP=1"], env, stdin: Stream.make( @@ -147,26 +181,24 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { expect(early.exitCode).not.toBe(0); expect(yield* Ref.get(stderr)).toContain("missing_column"); const attached = yield* Deferred.make<void>(); - const job = yield* runner - .run({ - tool: postgres.psql({ major: 17 }), - args: ["-X", "-q", "-t", "-A"], - env: { ...env, PGAPPNAME: "attached-tool-story" }, - stdin: Stream.make(new TextEncoder().encode("SELECT 'attached-ready';\n")).pipe( - Stream.concat(Stream.never), - ), - stdout: (bytes) => - new TextDecoder().decode(bytes).includes("attached-ready") - ? Deferred.succeed(attached, undefined).pipe(Effect.asVoid) - : Effect.void, - stderr: output.stderr, - }) - .pipe(Effect.forkChild); + const job = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), + args: ["-X", "-q", "-t", "-A"], + env: { ...env, PGAPPNAME: "attached-tool-story" }, + stdin: Stream.make(new TextEncoder().encode("SELECT 'attached-ready';\n")).pipe( + Stream.concat(Stream.never), + ), + stdout: (bytes) => + new TextDecoder().decode(bytes).includes("attached-ready") + ? Deferred.succeed(attached, undefined).pipe(Effect.asVoid) + : Effect.void, + stderr: output.stderr, + }).pipe(Effect.forkChild); yield* Deferred.await(attached); yield* Fiber.interrupt(job); yield* Ref.set(stdout, ""); - const remaining = yield* runner.run({ - tool: postgres.psql({ major: 17 }), + const remaining = yield* runPostgres(runner, { + postgres: postgres.psql({ major: 17 }), args: [ "-X", "-t", @@ -193,7 +225,7 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: `stack-pgprove-${major}-` }); - const stackId = `tools-pgprove-${runtime}-${major}`; + const stackId = `tools-pgprove-${runtime}-${major}-${randomUUID()}`; const database = yield* makeDatabase({ root, cacheRoot, @@ -221,8 +253,9 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { Effect.succeed( endpoint.kind === "unix" ? { path: `${endpoint.path}/.s.PGSQL.5432` } : endpoint, ), + "database:sql", ).pipe(Effect.forkScoped); - const runner = yield* makeTestToolRunner({ root, cacheRoot, stackId, runtime }); + const runner = yield* makeTestCommandRunner({ root, cacheRoot, stackId, runtime }); const env = { PGHOST: runtime === "native" ? "127.0.0.1" : "host.docker.internal", PGPORT: String(listener.address.port), @@ -246,16 +279,16 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { stderr: (bytes: Uint8Array) => Ref.update(stderr, (text) => text + new TextDecoder().decode(bytes)), }; - const extension = yield* runner.run({ - tool: postgres.psql({ major }), + const extension = yield* runPostgres(runner, { + postgres: postgres.psql({ major }), args: ["-X", "-v", "ON_ERROR_STOP=1", "-c", "CREATE EXTENSION IF NOT EXISTS pgtap"], env, ...output, }); expect(extension.exitCode).toBe(0); const pgProve = (file: string) => - runner.run({ - tool: postgres.pgProve({ major }), + runPostgres(runner, { + postgres: postgres.pgProve({ major }), args: ["--ext", ".sql", file, "--verbose"], env, pgProve: { @@ -295,34 +328,32 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { "SELECT plan(1);\nSELECT pass('pgprove-idle');\n\\watch 60\n", ); const cancelOutput = yield* Ref.make(""); - const running = yield* runner - .run({ - tool: postgres.pgProve({ major }), - args: ["--ext", ".sql", "cancel-ready.sql", "cancel.sql", "--verbose"], - env: { ...env, PGAPPNAME: applicationName }, - pgProve: { - mounts: [{ source: tests, target: "/tests" }], - cwd: tests, - workingDir: "/tests", - }, - stdout: (bytes) => - Effect.gen(function* () { - yield* Ref.update( - cancelOutput, - (text) => text + new TextDecoder().decode(bytes), - ); - if ((yield* Ref.get(cancelOutput)).includes("pgprove-ready")) - yield* Deferred.succeed(ready, undefined); - }), - stderr: output.stderr, - }) - .pipe(Effect.forkChild); + const running = yield* runPostgres(runner, { + postgres: postgres.pgProve({ major }), + args: ["--ext", ".sql", "cancel-ready.sql", "cancel.sql", "--verbose"], + env: { ...env, PGAPPNAME: applicationName }, + pgProve: { + mounts: [{ source: tests, target: "/tests" }], + cwd: tests, + workingDir: "/tests", + }, + stdout: (bytes) => + Effect.gen(function* () { + yield* Ref.update( + cancelOutput, + (text) => text + new TextDecoder().decode(bytes), + ); + if ((yield* Ref.get(cancelOutput)).includes("pgprove-ready")) + yield* Deferred.succeed(ready, undefined); + }), + stderr: output.stderr, + }).pipe(Effect.forkChild); yield* Deferred.await(ready); const idleOutput = yield* Ref.make(""); const idle = Effect.gen(function* () { yield* Ref.set(idleOutput, ""); - const probe = yield* runner.run({ - tool: postgres.psql({ major }), + const probe = yield* runPostgres(runner, { + postgres: postgres.psql({ major }), args: [ "-X", "-t", @@ -347,8 +378,8 @@ describe("finite PostgreSQL tools", { timeout: 180_000 }, () => { const remainingOutput = yield* Ref.make(""); const remaining = Effect.gen(function* () { yield* Ref.set(remainingOutput, ""); - const probe = yield* runner.run({ - tool: postgres.psql({ major }), + const probe = yield* runPostgres(runner, { + postgres: postgres.psql({ major }), args: [ "-X", "-t", diff --git a/packages/stack/src/Commands.ts b/packages/stack/src/Commands.ts new file mode 100644 index 0000000000..05ed207925 --- /dev/null +++ b/packages/stack/src/Commands.ts @@ -0,0 +1,116 @@ +import { Schema } from "effect"; + +const PgProveMount = Schema.Struct({ + source: Schema.String, + target: Schema.String, +}); + +export const PgProveOptions = Schema.Struct({ + mounts: Schema.Array(PgProveMount), + cwd: Schema.optional(Schema.String), + workingDir: Schema.optional(Schema.String), +}); +export interface PgProveOptions extends Schema.Schema.Type<typeof PgProveOptions> {} + +export const PostgresCommand = Schema.Struct({ + command: Schema.Literals(["pg_dump", "pg_dumpall", "pg_prove", "psql"]), + major: Schema.Literals([15, 17]), +}); +export interface PostgresCommand extends Schema.Schema.Type<typeof PostgresCommand> {} + +const AuthInitializationCommand = Schema.Struct({ + type: Schema.Literal("auth.initialize"), + version: Schema.optional(Schema.String), + databaseUrl: Schema.String, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +const StorageInitializationCommand = Schema.Struct({ + type: Schema.Literal("storage.initialize"), + version: Schema.optional(Schema.String), + databaseUrl: Schema.String, + filePath: Schema.String, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +const RealtimeInitializationCommand = Schema.Struct({ + type: Schema.Literal("realtime.initialize"), + version: Schema.optional(Schema.String), + databaseUrl: Schema.String, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); + +/** A finite service setup action with inputs owned by its service definition. */ +export const InitializationCommand = Schema.Union([ + AuthInitializationCommand, + StorageInitializationCommand, + RealtimeInitializationCommand, +]); +export type InitializationCommand = Schema.Schema.Type<typeof InitializationCommand>; + +/** A finite action that runs to completion and does not own service lifecycle. */ +export const Command = Schema.Union([PostgresCommand, InitializationCommand]); +export type Command = Schema.Schema.Type<typeof Command>; + +const PostgresInvocation = Schema.Struct({ + type: Schema.Literal("postgres"), + command: PostgresCommand, + args: Schema.Array(Schema.String), + env: Schema.Record(Schema.String, Schema.String), + pgProve: Schema.optional(PgProveOptions), + stdin: Schema.Boolean, +}); + +/** A command invocation serialized through the stack host attachment. */ +export const CommandInvocation = Schema.Union([ + PostgresInvocation, + AuthInitializationCommand, + StorageInitializationCommand, + RealtimeInitializationCommand, +]); +export type CommandInvocation = Schema.Schema.Type<typeof CommandInvocation>; + +export interface ResolvedCommand { + readonly service: "auth" | "storage" | "realtime"; + readonly version: string; + readonly image: string; + readonly nativeExecutable: string; + readonly containerEntrypoint: string; + readonly args: ReadonlyArray<string>; + readonly env: Readonly<Record<string, string>>; + readonly cwd?: string; + readonly workingDir?: string; + readonly mounts: ReadonlyArray<{ + readonly source: string; + readonly target: string; + readonly readOnly: boolean; + }>; +} + +/** Describes PostgreSQL clients without managing a database service. */ +export const postgres = { + pgDump: ({ major }: { readonly major: 15 | 17 }): PostgresCommand => ({ + command: "pg_dump", + major, + }), + pgDumpAll: ({ major }: { readonly major: 15 | 17 }): PostgresCommand => ({ + command: "pg_dumpall", + major, + }), + pgProve: ({ major }: { readonly major: 15 | 17 }): PostgresCommand => ({ + command: "pg_prove", + major, + }), + psql: ({ major }: { readonly major: 15 | 17 }): PostgresCommand => ({ + command: "psql", + major, + }), +}; + +export const initialization = { + auth: (input: Omit<Extract<InitializationCommand, { type: "auth.initialize" }>, "type">) => ({ + type: "auth.initialize" as const, + ...input, + }), + storage: ( + input: Omit<Extract<InitializationCommand, { type: "storage.initialize" }>, "type">, + ) => ({ type: "storage.initialize" as const, ...input }), + realtime: ( + input: Omit<Extract<InitializationCommand, { type: "realtime.initialize" }>, "type">, + ) => ({ type: "realtime.initialize" as const, ...input }), +}; diff --git a/packages/stack/src/HostProcess.integration.test.ts b/packages/stack/src/HostProcess.integration.test.ts index 4f88e9c57a..f6a89455ba 100644 --- a/packages/stack/src/HostProcess.integration.test.ts +++ b/packages/stack/src/HostProcess.integration.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; import { Context, Data, + DateTime, Effect, FileSystem, Fiber, @@ -11,20 +12,70 @@ import { Path, Schema, Stream, + Tracer, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- integration verifies exact-port reopening. import * as Net from "node:net"; import { fileURLToPath } from "node:url"; -import { HostEndpoint, connectHost, launchHost } from "./HostProcess.ts"; +import { + HostEndpoint, + connectHost, + currentRelease, + launchHost, + shutdownHost, + type HostAccess, +} from "./HostProcess.ts"; +import { discover } from "./effect.ts"; +import { watchLeaseRelease } from "../tests/owner.ts"; import * as State from "./State.ts"; class ProcessTestError extends Data.TaggedError("ProcessTestError")<{ readonly message: string }> {} +const fixtureEntrypoint = fileURLToPath( + new URL("../tests/host-process-fixture.ts", import.meta.url), +); + +const savedStack = (root: string, stackName: string): State.SavedStack => ({ + id: "stack", + runtime: "native", + identity: { projectRoot: root, branchContext: "main", stackName }, + instances: [], + lifetime: "detached", + composition: { members: [], dependencies: [] }, + ports: [], +}); + +/** A loopback listener that accepts connections and never answers, counting each one. */ +const silentListener = Effect.acquireRelease( + Effect.callback< + { readonly server: Net.Server; readonly sockets: Set<Net.Socket> }, + ProcessTestError + >((resume) => { + const sockets = new Set<Net.Socket>(); + const server = Net.createServer((socket) => sockets.add(socket)); + server.once("error", (cause) => + resume(Effect.fail(new ProcessTestError({ message: cause.message }))), + ); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed({ server, sockets }))); + }), + ({ server, sockets }) => + Effect.callback<void>((resume) => { + for (const socket of sockets) socket.destroy(); + server.close(() => resume(Effect.void)); + }), +).pipe( + Effect.flatMap(({ server, sockets }) => { + const address = server.address(); + return typeof address === "object" && address !== null + ? Effect.succeed({ port: address.port, connections: () => sockets.size }) + : Effect.fail(new ProcessTestError({ message: "Silent listener has no port" })); + }), +); + const makeTestState = (root: string) => Layer.build(State.layer({ root })).pipe( Effect.map((context) => Context.get(context, State.Service)), @@ -95,10 +146,14 @@ const waitForReady = (handle: ChildHandle) => Effect.flatMap((line) => Schema.decodeEffect( Schema.fromJsonString( - Schema.Struct({ type: Schema.Literal("ready"), endpoint: HostEndpoint }), + Schema.Struct({ + type: Schema.Literal("ready"), + endpoint: HostEndpoint, + secret: Schema.String, + }), ), )(line).pipe( - Effect.map((message) => message.endpoint), + Effect.map(({ endpoint, secret }): HostAccess => ({ endpoint, secret })), Effect.mapError((cause) => new ProcessTestError({ message: String(cause) })), ), ), @@ -122,27 +177,20 @@ const stopChild = (handle: ChildHandle) => }), ).pipe(Effect.asVoid); -const bestEffortShutdown = (client: HttpClient.HttpClient, endpoint: HostEndpoint) => - Effect.exit( - client - .execute( - HttpClientRequest.post(`http://127.0.0.1:${endpoint.port}/shutdown`).pipe( - HttpClientRequest.setHeader("connection", "close"), - ), - ) - .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)), - ).pipe(Effect.asVoid); +/** Stops an owner and waits for it to release its lease, so its files are no longer in use. */ +const bestEffortShutdown = (stateRoot: string) => (access: HostAccess) => + Effect.scoped( + Effect.gen(function* () { + const released = yield* watchLeaseRelease(stateRoot, access.endpoint.stackId); + yield* shutdownHost(access, false); + yield* released; + }), + ).pipe(Effect.exit, Effect.asVoid); -const bestEffortShutdownByState = ( - client: HttpClient.HttpClient, - state: State.Interface, - stackId: string, -) => - Effect.exit( - connectHost(state, stackId).pipe( - Effect.flatMap((endpoint) => bestEffortShutdown(client, endpoint)), - ), - ).pipe(Effect.asVoid); +const bestEffortShutdownByState = (stateRoot: string, state: State.Interface, stackId: string) => + Effect.exit(connectHost(state, stackId).pipe(Effect.flatMap(bestEffortShutdown(stateRoot)))).pipe( + Effect.asVoid, + ); const bindExact = (port: number) => Effect.callback<Net.Server, ProcessTestError>((resume) => { @@ -189,7 +237,7 @@ const waitForMarker = (marker: string) => }), ); -it.live("launches one detached owner and attaches competing launchers", () => +it.live("starts exactly one owner for concurrent launchers and attaches the others", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -200,6 +248,7 @@ it.live("launches one detached owner and attaches competing launchers", () => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "local" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -212,73 +261,128 @@ it.live("launches one detached owner and attaches competing launchers", () => stackId: "stack", entrypoint, }; - const client = yield* HttpClient.HttpClient; yield* Effect.gen(function* () { - const [first, second] = yield* Effect.all( - [launchHost(state, options), launchHost(state, options)], - { concurrency: 2 }, + const launched = yield* Effect.all( + [launchHost(state, options), launchHost(state, options), launchHost(state, options)], + { concurrency: "unbounded" }, ); - expect(second.port).toBe(first.port); - expect(second.pid).toBe(first.pid); - }).pipe(Effect.ensuring(bestEffortShutdownByState(client, state, "stack"))); + expect(new Set(launched.map(({ endpoint }) => endpoint.pid)).size).toBe(1); + expect(new Set(launched.map(({ endpoint }) => endpoint.port)).size).toBe(1); + }).pipe(Effect.ensuring(bestEffortShutdownByState(root, state, "stack"))); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); -it.live("lets an external launcher attach, then reopens the owner port after shutdown", () => +it.live("relaunches after shutdown while a foreign listener holds the previous control port", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-launcher-" }); const state = yield* makeTestState(root); - yield* state.save({ - id: "stack", - runtime: "native", - identity: { projectRoot: root, branchContext: "main", stackName: "local" }, - instances: [], - composition: { members: [], dependencies: [] }, - ports: [], - }); - const entrypoint = fileURLToPath( - new URL("../tests/host-process-fixture.ts", import.meta.url), - ); - const args = [entrypoint, root, root, "stack"]; + yield* state.save(savedStack(root, "local")); + const args = [fixtureEntrypoint, root, root, "stack"]; const owner = yield* Effect.acquireRelease( spawnChild(args, ["ignore", "ignore", "ignore", "pipe"]), stopChild, ); - const endpoint = yield* waitForReady(owner); - const observedEndpoint = yield* connectHost(state, "stack"); + const access = yield* waitForReady(owner); + const { endpoint } = access; + const observed = yield* connectHost(state, "stack"); const client = yield* HttpClient.HttpClient; - expect(observedEndpoint.pid).toBe(endpoint.pid); + expect(observed.endpoint.pid).toBe(endpoint.pid); const ownerExitFiber = yield* waitForClose(owner).pipe(Effect.forkScoped); yield* Effect.gen(function* () { - const response = yield* client.execute( + const unauthenticated = yield* client.execute( HttpClientRequest.post(`http://127.0.0.1:${endpoint.port}/shutdown`).pipe( - HttpClientRequest.setHeader("connection", "close"), + HttpClientRequest.bodyJsonUnsafe({ destroy: true }), ), ); - yield* HttpClientResponse.filterStatusOk(response); + expect(unauthenticated.status, "shutdown requires the owner secret").toBe(401); + expect(Option.isNone(yield* shutdownHost(access, false))).toBe(true); const ownerExit = yield* Fiber.join(ownerExitFiber); owner.closed = true; owner.closeCode = ownerExit.code; owner.closeSignal = ownerExit.signal; expect(ownerExit.signal).toBeNull(); - }).pipe(Effect.ensuring(bestEffortShutdown(client, endpoint))); - yield* Effect.scoped( - Effect.gen(function* () { - const reopened = yield* Effect.acquireRelease(bindExact(endpoint.port), closeServer); - expect(reopened.listening).toBe(true); - }), - ); + }).pipe(Effect.ensuring(bestEffortShutdown(root)(access))); + yield* Effect.acquireRelease(bindExact(endpoint.port), closeServer); const relaunched = yield* Effect.acquireRelease( - launchHost(state, { stateRoot: root, cacheRoot: root, stackId: "stack", entrypoint }).pipe( - Effect.provide(FetchHttpClient.layer), - ), - (next) => bestEffortShutdown(client, next), + launchHost(state, { + stateRoot: root, + cacheRoot: root, + stackId: "stack", + entrypoint: fixtureEntrypoint, + }).pipe(Effect.provide(FetchHttpClient.layer)), + bestEffortShutdown(root), + ); + expect(relaunched.endpoint.pid).not.toBe(endpoint.pid); + expect(relaunched.endpoint.port).not.toBe(endpoint.port); + expect(yield* connectHost(state, "stack")).toEqual(relaunched); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("ignores a stale endpoint record once no process holds the lease", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-stale-" }); + const state = yield* makeTestState(root); + yield* state.save(savedStack(root, "local")); + const unresponsive = yield* silentListener; + yield* state.publishHolder("stack", { + role: "owner", + secret: "stale", + port: unresponsive.port, + pid: process.pid, + release: yield* currentRelease, + lifetime: "detached", + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + const [live] = yield* discover({ stateRoot: root }); + expect(live?.host).toBeUndefined(); + const launched = yield* Effect.acquireRelease( + launchHost(state, { + stateRoot: root, + cacheRoot: root, + stackId: "stack", + entrypoint: fixtureEntrypoint, + }), + bestEffortShutdown(root), ); - expect(relaunched.port).toBe(endpoint.port); - expect(relaunched.pid).not.toBe(endpoint.pid); + expect(launched.endpoint.port).not.toBe(unresponsive.port); + const published = yield* state.readHolder("stack"); + expect(published?.role === "owner" ? published.port : undefined).toBe(launched.endpoint.port); + expect(unresponsive.connections()).toBe(0); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("discovers dead stacks from their free leases without contacting recorded endpoints", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-discover-" }); + const state = yield* makeTestState(root); + const unresponsive = yield* silentListener; + const ids = ["dead-a", "dead-b", "dead-c"]; + for (const id of ids) { + yield* state.save({ ...savedStack(root, id), id }); + yield* Effect.scoped(state.lease(id)); + yield* state.publishHolder(id, { + role: "owner", + secret: "stale", + port: unresponsive.port, + pid: process.pid, + release: yield* currentRelease, + lifetime: "detached", + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + } + const entries = yield* discover({ stateRoot: root }); + expect(entries.map(({ definition }) => definition.id).toSorted()).toEqual(ids); + expect(entries.every(({ host }) => host === undefined)).toBe(true); + expect(unresponsive.connections()).toBe(0); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -298,13 +402,13 @@ it.live( runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "local" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); const entrypoint = fileURLToPath( new URL("../tests/host-process-fixture.ts", import.meta.url), ); - const client = yield* HttpClient.HttpClient; yield* Effect.gen(function* () { const args = [entrypoint, root, root, "stack", "launcher", entrypoint]; const launcher = yield* Effect.acquireRelease( @@ -315,8 +419,8 @@ it.live( const launcherExit = yield* waitForClose(launcher); expect(launcherExit.code).toBe(0); const connected = yield* connectHost(state, "stack"); - expect(connected).toEqual(endpoint); - }).pipe(Effect.ensuring(bestEffortShutdownByState(client, state, "stack"))); + expect(connected.endpoint).toEqual(endpoint); + }).pipe(Effect.ensuring(bestEffortShutdownByState(root, state, "stack"))); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -333,6 +437,7 @@ it.live("terminates a detached child when readiness is interrupted", () => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "slow-handshake" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -357,3 +462,58 @@ it.live("terminates a detached child when readiness is interrupted", () => }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + +it.live("keeps the owner log tail in a start failure after the owner removed its log", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-failed-start-" }); + const state = yield* makeTestState(root); + const failure = yield* launchHost(state, { + stateRoot: root, + cacheRoot: root, + stackId: "stack", + entrypoint: fileURLToPath(new URL("../tests/failing-owner-fixture.ts", import.meta.url)), + register: savedStack(root, "failing"), + }).pipe(Effect.flip); + expect(failure.message).toContain("owner startup failed"); + expect(failure.message).toContain("failing-owner-diagnostic"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("keeps the owner secret out of recorded HTTP span attributes", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "host-process-tracing-" }); + const state = yield* makeTestState(root); + yield* state.save(savedStack(root, "local")); + const spans: Array<Tracer.NativeSpan> = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const access = yield* launchHost(state, { + stateRoot: root, + cacheRoot: root, + stackId: "stack", + entrypoint: fixtureEntrypoint, + }).pipe( + Effect.andThen(connectHost(state, "stack")), + Effect.tap(bestEffortShutdown(root)), + Effect.withTracer(tracer), + ); + const attributes = spans.flatMap((span) => Array.from(span.attributes)); + const authorization = attributes.filter( + ([key]) => key === "http.request.header.authorization", + ); + expect(authorization.length, "identity and shutdown requests were traced").toBeGreaterThan(1); + expect(authorization.every(([, value]) => value === "<redacted>")).toBe(true); + expect(attributes.filter(([, value]) => String(value).includes(access.secret))).toEqual([]); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/packages/stack/src/HostProcess.ts b/packages/stack/src/HostProcess.ts index 51a3031395..46b60d3820 100644 --- a/packages/stack/src/HostProcess.ts +++ b/packages/stack/src/HostProcess.ts @@ -1,16 +1,80 @@ -import { NodeHttpServer } from "@effect/platform-node"; -import { Data, Effect, Duration, Option, Schedule, Schema, Scope, Stream } from "effect"; -import * as HttpServer from "effect/unstable/http/HttpServer"; +import { NodeStream } from "@effect/platform-node"; +import { + Crypto, + Data, + Deferred, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schedule, + Schema, + Scope, + Stream, +} from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Effect ChildProcess cannot hand the owner a log file descriptor or release its lifeline pipe from the event loop. +import { spawn, type ChildProcess } from "node:child_process"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the spawner shares its owner log descriptor with the owner and reads the log tail through it. +import { closeSync, fstatSync, openSync, readSync } from "node:fs"; import { fileURLToPath } from "node:url"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- NodeHttpServer.make requires a native server factory. -import * as Http from "node:http"; +import packageJson from "../package.json" with { type: "json" }; import { HOST_PROCESS_DISPATCH_SENTINEL, isBunVirtualPath } from "./internal/dispatch-markers.ts"; -import { makePorts, PortError } from "./Ports.ts"; -import type * as State from "./State.ts"; +import { failureMessage } from "./internal/failure-message.ts"; +import { stackSourceDigest } from "./internal/release.ts"; +import { StackRpc } from "./Rpc.ts"; +import { SavedStack, type Interface as StateInterface, type StateError } from "./State.ts"; + +declare const SUPABASE_STACK_BUILD_ID: string | undefined; + +/** + * A compiled CLI embeds the digest of the stack sources it was built from; a source checkout + * computes it, so both agree for the same sources and any change to them is a new release. + */ +const computeRelease = Effect.gen(function* () { + if (typeof SUPABASE_STACK_BUILD_ID === "string") + return `${packageJson.version}+${SUPABASE_STACK_BUILD_ID}`; + if (isBunVirtualPath(fileURLToPath(import.meta.url))) { + const fs = yield* FileSystem.FileSystem; + const binary = yield* fs.stat(process.execPath); + const modified = Option.match(binary.mtime, { onNone: () => 0, onSome: (at) => at.getTime() }); + return `${packageJson.version}+binary.${binary.size}.${modified}`; + } + return `${packageJson.version}+${yield* stackSourceDigest}`; +}).pipe(Effect.orDie); + +/** The release of this build, computed once per process; clients only drive owners of it. */ +export const currentRelease: Effect.Effect< + string, + never, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +> = Effect.runSync(Effect.cached(computeRelease)); + +/** + * Control requests carry the owner secret as a bearer token, part of the release-stable contract; + * HTTP tracing redacts `authorization`, so the secret never reaches span attributes. + */ +export const ownerAuthorization = (secret: string) => `Bearer ${secret}`; + +/** Checks a request's `authorization` header against the owner secret in constant time. */ +export const authorizes = (header: string | undefined, secret: string) => { + const expected = ownerAuthorization(secret); + if (header === undefined || header.length !== expected.length) return false; + let difference = 0; + for (let index = 0; index < expected.length; index++) + difference |= expected.charCodeAt(index) ^ header.charCodeAt(index); + return difference === 0; +}; + +/** How long a sweeping owner may hold a dead stack's lease. */ +export const sweepTimeout = Duration.minutes(1); export class HostProcessError extends Data.TaggedError("HostProcessError")<{ readonly operation: string; @@ -19,82 +83,112 @@ export class HostProcessError extends Data.TaggedError("HostProcessError")<{ readonly reason?: HostFailureReason; }> {} type HostFailureReason = - | "missing-control-listener" + | "unregistered" + | "not-running" + | "sweeping" + | "owner-starting" | "connection-failure" - | "bind-conflict" + | "release-mismatch" + | "runtime-unavailable" | "invalid-owner-pid" | "owner-exit-pending" + | "owner-exit-zombie" | "owner-exit-probe"; const HostIdentity = Schema.Struct({ projectRoot: Schema.String, branchContext: Schema.String, stackName: Schema.String, }); -interface HostIdentity extends Schema.Schema.Type<typeof HostIdentity> {} export const HostEndpoint = Schema.Struct({ stackId: Schema.String, identity: HostIdentity, pid: Schema.Int, port: Schema.Int, + release: Schema.String, }); export interface HostEndpoint extends Schema.Schema.Type<typeof HostEndpoint> {} + +/** A validated owner endpoint and the secret its control requests carry. */ +export interface HostAccess { + readonly endpoint: HostEndpoint; + readonly secret: string; +} + +/** The release-stable body of `POST /shutdown`. */ +export const ShutdownRequest = Schema.Struct({ destroy: Schema.Boolean }); +/** The release-stable body of a rejected `POST /shutdown`. */ +export const ShutdownFailure = Schema.Struct({ + message: Schema.String, + outcomes: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + id: Schema.String, + succeeded: Schema.Boolean, + error: Schema.optionalKey(Schema.String), + }), + ), + ), +}); +export interface ShutdownFailure extends Schema.Schema.Type<typeof ShutdownFailure> {} + const error = (operation: string, cause: unknown, reason?: HostFailureReason) => new HostProcessError({ operation, - message: cause instanceof Error ? cause.message : String(cause), + message: failureMessage(cause), cause, ...(reason === undefined ? {} : { reason }), }); +/** Matches owner failures by reason. */ +export const hasReason = + (...reasons: ReadonlyArray<HostFailureReason>) => + (failure: unknown) => + failure instanceof HostProcessError && + failure.reason !== undefined && + reasons.includes(failure.reason); -export const acquireHost = Effect.fn("HostProcess.acquireHost")(function* ( - state: State.Interface, - stackId: string, -): Effect.fn.Return< - { - readonly port: number; - readonly server: HttpServer.HttpServer["Service"]; - readonly closeConnections: Effect.Effect<void>; - }, - HostProcessError | PortError | State.StateError, - Scope.Scope -> { - if ((yield* state.read(stackId)) === undefined) - return yield* error("acquire", "Stack is not registered"); - const ports = yield* makePorts(state); - let rawServer: Http.Server | undefined; - const acquired = yield* ports.acquire( - { stackId, key: "control", host: "127.0.0.1", port: "auto" }, - (host, port) => { - const server = Http.createServer(); - rawServer = server; - return NodeHttpServer.make(() => server, { host, port }).pipe( - Effect.mapError( - (cause) => - new PortError({ key: "control", message: "Cannot bind control listener", cause }), +const causeCode = (cause: unknown): string | undefined => { + if (typeof cause !== "object" || cause === null) return undefined; + if ("code" in cause && typeof cause.code === "string") return cause.code; + if ("cause" in cause) return causeCode(cause.cause); + return undefined; +}; + +/** An RPC client whose requests carry the owner secret; a rejected secret is a status failure. */ +export const ownerClient = (access: HostAccess) => + RpcClient.make(StackRpc).pipe( + Effect.provide( + RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${access.endpoint.port}/rpc` }).pipe( + Layer.provide(RpcSerialization.layerNdjson), + Layer.provide( + Layer.effect( + HttpClient.HttpClient, + HttpClient.HttpClient.pipe( + Effect.map((client) => + client.pipe( + HttpClient.mapRequest(HttpClientRequest.bearerToken(access.secret)), + HttpClient.filterStatusOk, + ), + ), + ), + ), ), - ); - }, + ), + ), ); - return { - port: acquired.port, - server: acquired.listener, - closeConnections: Effect.sync(() => { - rawServer?.closeAllConnections(); - rawServer?.closeIdleConnections(); - }), - }; -}); -const endpointFromResponse = Effect.fn("HostProcess.endpointFromResponse")(function* ( - state: State.Interface, - stackId: string, - port: number, +/** Anything but a matching answer means the record names a process that no longer serves it. */ +const identityOf = Effect.fn("HostProcess.identityOf")(function* ( + stack: SavedStack, + owner: { readonly port: number; readonly pid: number; readonly secret: string }, ) { - const stack = yield* state.read(stackId); - if (stack === undefined) return yield* error("connect", "Stack is not registered"); const client = yield* HttpClient.HttpClient; + const stale = (cause: unknown) => error("connect", cause, "owner-starting"); const response = yield* client - .execute(HttpClientRequest.get(`http://127.0.0.1:${port}/identity`)) + .execute( + HttpClientRequest.get(`http://127.0.0.1:${owner.port}/identity`).pipe( + HttpClientRequest.bearerToken(owner.secret), + ), + ) .pipe( Effect.timeoutOrElse({ duration: Duration.seconds(2), @@ -102,42 +196,136 @@ const endpointFromResponse = Effect.fn("HostProcess.endpointFromResponse")(funct Effect.fail( error( "connect", - "Timed out connecting to the host control listener", + "Timed out connecting to the owner control endpoint", "connection-failure", ), ), }), - Effect.mapError((cause) => - cause instanceof HostProcessError ? cause : error("connect", cause), - ), + Effect.mapError((cause) => (cause instanceof HostProcessError ? cause : stale(cause))), ); - yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.mapError((cause) => error("connect", cause)), - ); + yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.mapError(stale)); const remote = yield* HttpClientResponse.schemaBodyJson(HostEndpoint)(response).pipe( - Effect.mapError((cause) => error("connect", cause)), + Effect.mapError(stale), ); if ( - remote.port !== port || - remote.stackId !== stackId || + remote.port !== owner.port || + remote.pid !== owner.pid || + remote.stackId !== stack.id || remote.identity.projectRoot !== stack.identity.projectRoot || remote.identity.branchContext !== stack.identity.branchContext || remote.identity.stackName !== stack.identity.stackName ) - return yield* error("connect", "Control listener belongs to another stack"); - return remote; + return yield* stale("The control endpoint belongs to another process"); + return { endpoint: remote, secret: owner.secret } satisfies HostAccess; }); +const registered = (state: StateInterface, stackId: string) => + state + .read(stackId) + .pipe( + Effect.flatMap((stack) => + stack === undefined + ? Effect.fail(error("connect", "Stack is not registered", "unregistered")) + : Effect.succeed(stack), + ), + ); + +/** Reads the live owner's endpoint without waiting; dead stacks cost no network round trip. */ +export const observeHost = Effect.fn("HostProcess.observeHost")(function* ( + state: StateInterface, + stack: SavedStack, +): Effect.fn.Return<HostEndpoint | undefined, never, HttpClient.HttpClient> { + return yield* Effect.gen(function* () { + if (!(yield* state.leased(stack.id))) return undefined; + const holder = yield* state.readHolder(stack.id); + if (holder?.role !== "owner") return undefined; + return (yield* identityOf(stack, holder)).endpoint; + }).pipe(Effect.orElseSucceed(() => undefined)); +}); + +export interface ConnectOptions { + /** Accepts an owner of another release, for the release-stable shutdown endpoint. */ + readonly anyRelease?: boolean; +} + +/** + * Resolves the owner holding the stack's lease, waiting while it publishes its endpoint. Fails + * with `not-running` when no process holds the lease and `sweeping` while a sweeper holds it. + */ export const connectHost = Effect.fn("HostProcess.connectHost")(function* ( - state: State.Interface, + state: StateInterface, stackId: string, -): Effect.fn.Return<HostEndpoint, HostProcessError | State.StateError, HttpClient.HttpClient> { - const stack = yield* state.read(stackId); - if (stack === undefined) return yield* error("connect", "Stack is not registered"); - const claim = stack.ports.find((entry) => entry.key === "control"); - if (claim === undefined) - return yield* error("connect", "Stack has no control listener", "missing-control-listener"); - return yield* endpointFromResponse(state, stackId, claim.port); + options: ConnectOptions = {}, +): Effect.fn.Return< + HostAccess, + HostProcessError | StateError, + HttpClient.HttpClient | FileSystem.FileSystem | Path.Path | Crypto.Crypto +> { + const stack = yield* registered(state, stackId); + const access = yield* Effect.gen(function* () { + if (!(yield* state.leased(stackId))) + return yield* error("connect", "Stack owner is not running", "not-running"); + const holder = yield* state.readHolder(stackId); + if (holder === undefined) + return yield* error( + "connect", + "Stack owner has not published its endpoint", + "owner-starting", + ); + if (holder.role === "sweeper") + return yield* error("connect", "Another owner is sweeping this stack", "sweeping"); + return yield* identityOf(stack, holder); + }).pipe( + Effect.retry({ + schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "30 seconds" })), + while: hasReason("owner-starting"), + }), + Effect.mapError((failure) => + hasReason("owner-starting")(failure) + ? error( + "connect", + `Stack owner did not become reachable: ${failure.message} (owner log: ${state.ownerLog(stackId)})`, + ) + : failure, + ), + ); + const release = yield* currentRelease; + if (options.anyRelease !== true && access.endpoint.release !== release) + return yield* error( + "connect", + `Stack ${stackId} is served by release ${access.endpoint.release}, but this client is release ${release}; stop or destroy the stack (both work across releases) and retry`, + "release-mismatch", + ); + return access; +}); + +/** Requests shutdown through the release-stable endpoint; `Some` carries the owner's refusal. */ +export const shutdownHost = Effect.fn("HostProcess.shutdownHost")(function* ( + access: HostAccess, + destroy: boolean, +): Effect.fn.Return<Option.Option<ShutdownFailure>, HostProcessError, HttpClient.HttpClient> { + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.post(`http://127.0.0.1:${access.endpoint.port}/shutdown`).pipe( + HttpClientRequest.bearerToken(access.secret), + HttpClientRequest.bodyJsonUnsafe({ destroy }), + ), + ) + .pipe( + Effect.mapError((cause) => + error( + "shutdown", + `Owner response unavailable; the shutdown outcome is uncertain: ${cause.message}`, + ), + ), + ); + if (response.status >= 200 && response.status < 300) return Option.none(); + return Option.some( + yield* HttpClientResponse.schemaBodyJson(ShutdownFailure)(response).pipe( + Effect.orElseSucceed(() => ({ message: `Owner rejected shutdown (${response.status})` })), + ), + ); }); export interface LaunchOptions { @@ -145,132 +333,302 @@ export interface LaunchOptions { readonly cacheRoot: string; readonly stackId: string; readonly entrypoint?: string; + /** Registers this definition once the spawned owner holds the lease. */ + readonly register?: SavedStack; + /** Ties a spawned owner to the enclosing scope, whose closure destroys the stack. */ + readonly lifeline?: boolean; } const readyLine = Schema.Union([ - Schema.Struct({ type: Schema.Literal("ready"), endpoint: HostEndpoint }), + Schema.Struct({ type: Schema.Literal("ready"), endpoint: HostEndpoint, secret: Schema.String }), Schema.Struct({ type: Schema.Literal("error"), message: Schema.String, - reason: Schema.optionalKey(Schema.Literal("bind-conflict")), + reason: Schema.optionalKey(Schema.Literals(["lease-held", "exists", "runtime-unavailable"])), }), ]); -const spawnDetached = Effect.fn("HostProcess.spawnDetached")(function* ( + +const exited = (child: ChildProcess) => + child.exitCode !== null || child.signalCode !== null || child.pid === undefined; +const awaitExit = (child: ChildProcess) => + Effect.callback<void>((resume) => { + if (exited(child)) { + resume(Effect.void); + return Effect.void; + } + const done = () => resume(Effect.void); + child.once("exit", done); + child.once("error", done); + return Effect.sync(() => { + child.off("exit", done); + child.off("error", done); + }); + }); +const signal = (child: ChildProcess, name: NodeJS.Signals) => + Effect.sync(() => { + if (exited(child) || child.pid === undefined) return; + try { + if (process.platform === "win32") child.kill(name); + else process.kill(-child.pid, name); + } catch { + child.kill(name); + } + }); +const terminate = (child: ChildProcess) => + signal(child, "SIGTERM").pipe( + Effect.andThen(awaitExit(child)), + Effect.timeoutOrElse({ + duration: "2 seconds", + orElse: () => signal(child, "SIGKILL").pipe(Effect.andThen(awaitExit(child))), + }), + ); + +/** Ends the lifeline and waits for the owner to finish destroying the stack. */ +const closeLifeline = (child: ChildProcess) => + Effect.sync(() => child.stdin?.end()).pipe( + Effect.andThen(awaitExit(child)), + Effect.timeoutOrElse({ + duration: "2 minutes", + orElse: () => Effect.logWarning(`Stack owner ${child.pid} is still destroying its stack`), + }), + ); + +/** Reads the end of the owner log through the spawner's descriptor, which outlives its unlink. */ +const logTail = (descriptor: number) => { + try { + const size = fstatSync(descriptor).size; + const length = Math.min(size, 4096); + const bytes = Buffer.alloc(length); + readSync(descriptor, bytes, 0, length, size - length); + const lines = bytes.toString("utf8").trimEnd().split("\n").slice(-20); + return lines.join("\n"); + } catch { + return ""; + } +}; + +type Spawned = + | { readonly _tag: "Ready"; readonly access: HostAccess } + | { readonly _tag: "LeaseHeld" }; + +const spawnOwner = Effect.fn("HostProcess.spawnOwner")(function* ( + state: StateInterface, options: LaunchOptions, entrypoint: string, -) { - return yield* Effect.scoped( - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner - .spawn( - ChildProcess.make( - process.execPath, - [entrypoint, options.stateRoot, options.cacheRoot, options.stackId], - { - cwd: process.cwd(), - detached: true, - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - additionalFds: { fd3: { type: "output" } }, - forceKillAfter: "2 seconds", - }, +): Effect.fn.Return<Spawned, HostProcessError, Scope.Scope | FileSystem.FileSystem | Path.Path> { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const log = state.ownerLog(options.stackId); + yield* fs + .makeDirectory(path.dirname(log), { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => error("startup", cause))); + const register = + options.register === undefined + ? [] + : [ + yield* Schema.encodeEffect(Schema.fromJsonString(SavedStack))(options.register).pipe( + Effect.mapError((cause) => error("startup", cause)), ), - ) - .pipe(Effect.mapError((cause) => error("startup", cause))); - const readiness = child.getOutputFd(3).pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runHead, - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(error("startup", "Host readiness stream closed")), - onSome: Effect.succeed, + ]; + return yield* Effect.acquireUseRelease( + Effect.try({ + try: () => openSync(log, "a+", 0o600), + catch: (cause) => error("startup", `Cannot open the owner log ${log}: ${String(cause)}`), + }), + (descriptor) => + Effect.gen(function* () { + const failure = (cause: unknown, reason?: HostFailureReason) => { + const tail = logTail(descriptor); + return error( + "startup", + `Stack owner failed to start: ${error("startup", cause).message} (owner log: ${log})${tail.length === 0 ? "" : `\n${tail}`}`, + reason, + ); + }; + const spawnFailed = yield* Deferred.make<never, HostProcessError>(); + return yield* Effect.acquireUseRelease( + Effect.try({ + try: () => { + const started = spawn( + process.execPath, + [entrypoint, options.stateRoot, options.cacheRoot, options.stackId, ...register], + { + cwd: process.cwd(), + detached: true, + windowsHide: true, + stdio: [ + options.lifeline === true ? "pipe" : "ignore", + descriptor, + descriptor, + "pipe", + ], + }, + ); + started.on("error", (cause) => { + Deferred.doneUnsafe(spawnFailed, Exit.fail(failure(cause))); + }); + return started; + }, + catch: failure, }), - ), - Effect.mapError((cause) => error("startup", cause)), - ); - return yield* readiness.pipe( - Effect.timeout(Duration.seconds(30)), - Effect.mapError((cause) => error("startup", cause)), - Effect.flatMap((line) => - Schema.decodeEffect(Schema.fromJsonString(readyLine))(line).pipe( - Effect.mapError((cause) => error("startup", cause)), - Effect.flatMap((decoded) => { - if (decoded.type === "error") - return Effect.fail(error("startup", decoded.message, decoded.reason)); - if (decoded.endpoint.stackId !== options.stackId) - return Effect.fail( - error("startup", "Host readiness identity does not match the requested stack"), - ); - return child.unref.pipe( - Effect.mapError((cause) => error("startup", cause)), - Effect.as(decoded.endpoint), + (child) => + Effect.gen(function* () { + const readiness = child.stdio[3]; + const line = yield* ( + readiness === null || readiness === undefined || !("read" in readiness) + ? Effect.fail(failure("Owner readiness descriptor is unavailable")) + : NodeStream.fromReadable({ evaluate: () => readiness, onError: failure }).pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(failure("Owner exited before reporting readiness")), + onSome: Effect.succeed, + }), + ), + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => Effect.fail(failure("Timed out waiting for owner readiness")), + }), + ) + ).pipe( + Effect.raceFirst(Deferred.await(spawnFailed)), + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(readyLine))(text).pipe( + Effect.mapError(failure), + ), + ), + Effect.ensuring(Effect.sync(() => readiness?.destroy())), ); + if (line.type === "error") { + yield* awaitExit(child).pipe(Effect.timeout("5 seconds"), Effect.ignore); + if (line.reason === "lease-held" && options.register === undefined) + return { _tag: "LeaseHeld" } as const; + return yield* line.reason === "lease-held" || line.reason === "exists" + ? error("startup", "Stack already exists; use open") + : failure(line.message, line.reason); + } + if (line.endpoint.stackId !== options.stackId) + return yield* failure( + "Owner readiness identity does not match the requested stack", + ); + child.unref(); + if (options.lifeline === true) { + const stdin = child.stdin; + // Ending the lifeline of an owner that already exited reports EPIPE, which changes nothing. + stdin?.on("error", () => undefined); + if ( + stdin !== null && + Predicate.hasProperty(stdin, "unref") && + typeof stdin.unref === "function" + ) + stdin.unref(); + yield* Effect.addFinalizer(() => closeLifeline(child)); + } + return { + _tag: "Ready", + access: { endpoint: line.endpoint, secret: line.secret }, + } as const; }), - ), - ), - ); - }), + (child, exit) => (Exit.isSuccess(exit) ? Effect.void : terminate(child)), + ); + }), + (descriptor) => Effect.sync(() => closeSync(descriptor)), ); }); -const causeCode = (cause: unknown): string | undefined => { - if (typeof cause !== "object" || cause === null) return undefined; - if ("code" in cause && typeof cause.code === "string") return cause.code; - if ("cause" in cause) return causeCode(cause.cause); - return undefined; -}; -const isConnectionFailure = (failure: HostProcessError) => { - const code = causeCode(failure.cause); - return ( - failure.reason === "connection-failure" || - code === "ECONNREFUSED" || - code === "ConnectionRefused" || - code === "ECONNRESET" || - code === "ETIMEDOUT" || - code === "UND_ERR_CONNECT_TIMEOUT" +/** Connects to the stack's live owner, or spawns one and attaches to whichever owner wins the lease. */ +export const launchHost = Effect.fn("HostProcess.launchHost")(function* ( + state: StateInterface, + options: LaunchOptions, +): Effect.fn.Return< + HostAccess, + HostProcessError | StateError, + Scope.Scope | HttpClient.HttpClient | FileSystem.FileSystem | Path.Path | Crypto.Crypto +> { + const entrypoint = options.entrypoint ?? hostEntrypointFor(import.meta.url); + const attempt = Effect.gen(function* () { + if (options.register === undefined) { + const existing = yield* connectHost(state, options.stackId).pipe( + Effect.map(Option.some), + Effect.catchIf(hasReason("not-running"), () => Effect.succeed(Option.none())), + ); + if (Option.isSome(existing)) return existing.value; + } + const spawned = yield* spawnOwner(state, options, entrypoint); + return spawned._tag === "Ready" ? spawned.access : yield* connectHost(state, options.stackId); + }); + // A sweeper holds a dead stack's lease for a bounded time; a displaced spawn waits it out. + return yield* attempt.pipe( + Effect.retry({ + schedule: Schedule.spaced("100 millis").pipe( + Schedule.upTo({ duration: Duration.sum(sweepTimeout, Duration.seconds(10)) }), + ), + while: hasReason("not-running", "sweeping"), + }), ); -}; -const isBindConflict = (failure: HostProcessError) => { - const code = causeCode(failure.cause); - return failure.reason === "bind-conflict" || code === "EADDRINUSE"; -}; +}); export type OwnerExitProbeResult = | { readonly state: "absent" } | { readonly state: "present" } + | { readonly state: "zombie" } | { readonly state: "inconclusive"; readonly code: "EPERM" }; export type OwnerExitProbe = (pid: number) => Effect.Effect<OwnerExitProbeResult, HostProcessError>; -/** Probes the captured owner PID with signal 0. */ -const probeOwnerExit: OwnerExitProbe = Effect.fn("HostProcess.probeOwnerExit")(function* (pid) { - return yield* Effect.try({ - try: () => { - process.kill(pid, 0); - return { state: "present" } as const; - }, - catch: (cause) => - new HostProcessError({ - operation: "shutdown-exit", - message: `Shutdown acknowledged, but probing owner process ${pid} failed: ${String(cause)}`, - reason: "owner-exit-probe", - cause, +const zombieProcStates = new Set(["Z", "X"]); + +/** The state field follows the last `)`, because `comm` may itself contain spaces and parens. */ +const procStatState = (stat: string): string | undefined => + stat + .slice(stat.lastIndexOf(")") + 1) + .trim() + .split(/\s+/u)[0]; + +/** Probes the owner PID with signal 0 and, on Linux, reports a `Z` or `X` owner as a zombie. */ +export const ownerExitProbe = ( + fs: FileSystem.FileSystem, + platform: string = process.platform, +): OwnerExitProbe => + Effect.fn("HostProcess.probeOwnerExit")(function* (pid) { + const signalResult = yield* Effect.try({ + try: () => { + process.kill(pid, 0); + return { state: "present" } as const; + }, + catch: (cause) => + new HostProcessError({ + operation: "shutdown-exit", + message: `Shutdown acknowledged, but probing owner process ${pid} failed: ${String(cause)}`, + reason: "owner-exit-probe", + cause, + }), + }).pipe( + Effect.catch((failure): Effect.Effect<OwnerExitProbeResult, HostProcessError> => { + const code = causeCode(failure.cause); + if (code === "ESRCH") return Effect.succeed({ state: "absent" } as const); + if (code === "EPERM") return Effect.succeed({ state: "inconclusive", code } as const); + return Effect.fail(failure); }), - }).pipe( - Effect.catch((failure): Effect.Effect<OwnerExitProbeResult, HostProcessError> => { - const code = causeCode(failure.cause); - if (code === "ESRCH") return Effect.succeed({ state: "absent" } as const); - if (code === "EPERM") return Effect.succeed({ state: "inconclusive", code } as const); - return Effect.fail(failure); - }), - ); -}); + ); + if (signalResult.state !== "present" || platform !== "linux") return signalResult; + const state = yield* fs.readFileString(`/proc/${pid}/stat`).pipe( + Effect.map(procStatState), + Effect.orElseSucceed(() => undefined), + ); + return state !== undefined && zombieProcStates.has(state) + ? ({ state: "zombie" } as const) + : signalResult; + }); -/** Waits until the captured owner PID is absent from the process table. */ +/** + * Waits until the captured owner PID is absent from the process table. An owner still a zombie when + * the wait ends has exited but was never reaped, as under a sandbox PID 1 that does not reap orphans. + */ export const waitForOwnerExit = Effect.fn("HostProcess.waitForOwnerExit")(function* ( pid: number, - probe: OwnerExitProbe = probeOwnerExit, + probe: OwnerExitProbe, ) { if (!Number.isSafeInteger(pid) || pid <= 0) return yield* error( @@ -279,53 +637,42 @@ export const waitForOwnerExit = Effect.fn("HostProcess.waitForOwnerExit")(functi "invalid-owner-pid", ); const check = probe(pid).pipe( - Effect.flatMap((result) => - result.state === "absent" - ? Effect.void - : Effect.fail( + Effect.flatMap((result) => { + switch (result.state) { + case "absent": + return Effect.void; + case "zombie": + return Effect.fail( + error("shutdown-exit", `Owner process ${pid} is not reaped yet`, "owner-exit-zombie"), + ); + case "present": + return Effect.fail( error( "shutdown-exit", - result.state === "present" - ? `Owner shutdown acknowledgement completed, but process ${pid} is still running` - : `Owner shutdown acknowledgement completed, but process ${pid} is inaccessible (${result.code}); exit is inconclusive`, + `Owner shutdown acknowledgement completed, but process ${pid} is still running`, "owner-exit-pending", ), - ), - ), + ); + case "inconclusive": + return Effect.fail( + error( + "shutdown-exit", + `Owner shutdown acknowledgement completed, but process ${pid} is inaccessible (${result.code}); exit is inconclusive`, + "owner-exit-pending", + ), + ); + } + }), ); return yield* check.pipe( Effect.retry({ schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })), - while: (failure) => failure.reason === "owner-exit-pending", + while: (failure) => + failure.reason === "owner-exit-pending" || failure.reason === "owner-exit-zombie", }), - ); -}); - -export const launchHost = Effect.fn("HostProcess.launchHost")(function* ( - state: State.Interface, - options: LaunchOptions, -): Effect.fn.Return< - HostEndpoint, - HostProcessError | State.StateError, - Scope.Scope | HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner -> { - const existing = yield* connectHost(state, options.stackId).pipe( - Effect.map(Option.some), - Effect.catchTag("HostProcessError", (failure) => - failure.reason === "missing-control-listener" || isConnectionFailure(failure) - ? Effect.succeed(Option.none()) - : Effect.fail(failure), - ), - ); - if (Option.isSome(existing)) return existing.value; - return yield* spawnDetached( - options, - options.entrypoint ?? hostEntrypointFor(import.meta.url), - ).pipe( - Effect.catchTag("HostProcessError", (failure) => - isBindConflict(failure) - ? connectHost(state, options.stackId).pipe(Effect.mapError(() => failure)) - : Effect.fail(failure), + Effect.catchIf( + (failure) => failure.reason === "owner-exit-zombie", + () => Effect.void, ), ); }); diff --git a/packages/stack/src/HostProcess.unit.test.ts b/packages/stack/src/HostProcess.unit.test.ts index 96a8c78067..6ac07fd48e 100644 --- a/packages/stack/src/HostProcess.unit.test.ts +++ b/packages/stack/src/HostProcess.unit.test.ts @@ -1,10 +1,11 @@ import { expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Ref } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, PlatformError, Ref } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { HostProcessError, type OwnerExitProbe, type OwnerExitProbeResult, + ownerExitProbe, waitForOwnerExit, } from "./HostProcess.ts"; @@ -119,3 +120,66 @@ it.effect("rejects an invalid owner PID before probing", () => expect(yield* Ref.get(attempts)).toBe(0); }), ); + +// The test process itself answers signal 0, so /proc content alone decides the result. +const statOf = (stat: string) => + FileSystem.makeNoop({ readFileString: () => Effect.succeed(stat) }); + +it.effect("reports a zombie or dead owner on Linux", () => + Effect.gen(function* () { + const probe = (stat: string) => ownerExitProbe(statOf(stat), "linux")(process.pid); + const pid = process.pid; + expect(yield* probe(`${pid} (node) Z 1 1 1 0 -1 4194560`)).toEqual({ state: "zombie" }); + expect(yield* probe(`${pid} (node) X 1 1 1 0 -1 4194560`)).toEqual({ state: "zombie" }); + expect(yield* probe(`${pid} (my ) weird) proc) Z 1 1 1 0 -1 4194304`)).toEqual({ + state: "zombie", + }); + expect(yield* probe(`${pid} (node) S 1 1 1 0 -1 4194560`)).toEqual({ state: "present" }); + }), +); + +it.effect("waits for a zombie owner to be reaped", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const probe: OwnerExitProbe = () => + Ref.getAndUpdate(attempts, (count) => count + 1).pipe( + Effect.map((count): OwnerExitProbeResult => + count < 2 ? { state: "zombie" } : { state: "absent" }, + ), + ); + const fiber = yield* waitForOwnerExit(123, probe).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("100 millis"); + yield* Fiber.join(fiber); + expect(yield* Ref.get(attempts)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("accepts an owner that is still an unreaped zombie when the wait ends", () => + Effect.gen(function* () { + const probe: OwnerExitProbe = () => Effect.succeed({ state: "zombie" }); + const fiber = yield* waitForOwnerExit(123, probe).pipe(Effect.exit, Effect.forkChild); + yield* Effect.yieldNow; + yield* TestClock.adjust("6 seconds"); + expect(Exit.isSuccess(yield* Fiber.join(fiber))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), +); + +it.effect("keeps the signal result when /proc is unreadable or the platform is not Linux", () => + Effect.gen(function* () { + const unreadable = FileSystem.makeNoop({ + readFileString: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: path, + }), + ), + }); + expect(yield* ownerExitProbe(unreadable, "linux")(process.pid)).toEqual({ state: "present" }); + const zombie = statOf(`${process.pid} (node) Z 1 1 1 0 -1 4194560`); + expect(yield* ownerExitProbe(zombie, "darwin")(process.pid)).toEqual({ state: "present" }); + }), +); diff --git a/packages/stack/src/HttpProxy.integration.test.ts b/packages/stack/src/HttpProxy.integration.test.ts index 167df27dac..8e6398d412 100644 --- a/packages/stack/src/HttpProxy.integration.test.ts +++ b/packages/stack/src/HttpProxy.integration.test.ts @@ -1,15 +1,16 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Data, Deferred, Effect, Fiber, Layer, Logger, type LogLevel } from "effect"; +import { Data, Deferred, Effect, Fiber, Layer } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { createServer, type Server, type ServerResponse } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw server fixture. -import { Socket } from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw disconnect fixture. +import { createServer as createTcpServer, Socket, type Server as NetServer } from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- raw disconnect fixture. // oxlint-disable-next-line effecttsgo/node-builtin-import -- raw WebSocket upgrade fixture. import { WebSocket, WebSocketServer } from "ws"; +import { captureLogs } from "../tests/logs.ts"; import { ProxyError } from "./Proxy.ts"; import { makeHttpProxy, type HttpRoute } from "./HttpProxy.ts"; -const listen = (server: Server) => +const listen = (server: Server | NetServer, options?: { readonly beforeClose?: () => void }) => Effect.acquireRelease( Effect.callback<{ host: string; port: number }, HttpProxyTestError>((resume) => { const onError = (cause: Error) => @@ -25,6 +26,7 @@ const listen = (server: Server) => }), () => Effect.callback<void, never>((resume) => { + options?.beforeClose?.(); server.close(() => resume(Effect.void)); return Effect.void; }), @@ -35,15 +37,8 @@ class HttpProxyTestError extends Data.TaggedError("HttpProxyTestError")<{ readonly cause?: unknown; }> {} -const captureLogs = (levels: ReadonlyArray<LogLevel.LogLevel>) => (lines: Array<string>) => - Logger.layer([ - Logger.make(({ logLevel, message }) => { - if (levels.some((level) => level === logLevel)) - lines.push((Array.isArray(message) ? message : [message]).map(String).join(" ")); - }), - ]); - const captureErrors = captureLogs(["Error"]); +const captureWarnings = captureLogs(["Error", "Warn"]); /** Upstream that resets its first `drops` accepted connections without responding. */ const droppingBackend = (drops: number) => { @@ -157,6 +152,75 @@ it.live("keeps the retained listener and remaining route after one route is remo ).pipe(Effect.provide(NodeServices.layer)), ); +it.live("rewrites an exact-prefix request to the upstream prefix verbatim", () => + Effect.scoped( + Effect.gen(function* () { + const backend = createServer((request, response) => response.end(request.url)); + const backendAddress = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + const route = (id: string, prefix: string, upstreamPrefix: string): HttpRoute => ({ + id, + prefix, + upstreamPrefix, + target: Effect.succeed(backendAddress), + }); + yield* proxy.setRoutes([ + route("mcp", "/mcp", "/api/mcp"), + route("storage-s3", "/storage/v1/s3", "/s3"), + route("realtime-api", "/realtime/v1/api", "/api"), + ]); + const send = (path: string) => + request(proxy.port, path, new Uint8Array()).pipe( + Effect.provide(NodeHttpClient.layerNodeHttp), + ); + expect(new TextDecoder().decode((yield* send("/mcp")).body)).toBe("/api/mcp"); + expect(new TextDecoder().decode((yield* send("/mcp?read_only=true")).body)).toBe( + "/api/mcp?read_only=true", + ); + expect(new TextDecoder().decode((yield* send("/mcp/x")).body)).toBe("/api/mcp/x"); + expect((yield* send("/mcpx")).status).toBe(404); + expect(new TextDecoder().decode((yield* send("/storage/v1/s3")).body)).toBe("/s3"); + expect(new TextDecoder().decode((yield* send("/realtime/v1/api")).body)).toBe("/api"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("streams a joined MCP route's request and response through the exact upstream path", () => + Effect.scoped( + Effect.gen(function* () { + let seenUrl: string | undefined; + let seenBody = ""; + const backend = createServer((request, response) => { + seenUrl = request.url; + request.on("data", (chunk: Buffer) => (seenBody += chunk.toString())); + request.on("end", () => { + response.writeHead(200, { "content-type": "text/plain" }); + response.write("chunk-1"); + response.end("chunk-2"); + }); + }); + const backendAddress = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([ + { + id: "mcp", + prefix: "/mcp", + upstreamPrefix: "/api/mcp", + target: Effect.succeed(backendAddress), + }, + ]); + const response = yield* request( + proxy.port, + "/mcp?read_only=true", + new TextEncoder().encode("mcp-request-body"), + ).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)); + expect(seenUrl).toBe("/api/mcp?read_only=true"); + expect(seenBody).toBe("mcp-request-body"); + expect(new TextDecoder().decode(response.body)).toBe("chunk-1chunk-2"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + it.live("interrupts target acquisition quietly when a waiting client disconnects", () => { const logs: Array<string> = []; return Effect.scoped( @@ -518,11 +582,7 @@ it.live("retries a bodyless request once when the upstream drops the connection }), ).pipe( Effect.provide( - Layer.mergeAll( - NodeHttpClient.layerNodeHttp, - NodeServices.layer, - captureLogs(["Error", "Warn"])(logs), - ), + Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer, captureWarnings(logs)), ), ); }); @@ -554,6 +614,214 @@ it.live("does not replay a request with a body when the upstream drops the conne ); }); +/** + * Keep-alive upstream that answers once per connection and resets any reused connection; + * connections after `answered` are dropped unanswered. + */ +const oneRequestPerConnectionBackend = (answered = Number.POSITIVE_INFINITY) => { + let connections = 0; + const sockets = new Set<Socket>(); + const server = createTcpServer((socket) => { + connections += 1; + sockets.add(socket); + socket.on("error", () => {}); + socket.on("close", () => sockets.delete(socket)); + if (connections > answered) { + socket.destroy(); + return; + } + let buffered = Buffer.alloc(0); + let bodyEnd: number | undefined; + let replied = false; + socket.on("data", (chunk: Buffer) => { + if (replied) { + socket.resetAndDestroy(); + return; + } + buffered = Buffer.concat([buffered, chunk]); + if (bodyEnd === undefined) { + const headerEnd = buffered.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const contentLength = Number( + /content-length:\s*(\d+)/iu.exec( + buffered.subarray(0, headerEnd).toString("latin1"), + )?.[1] ?? 0, + ); + bodyEnd = headerEnd + 4 + contentLength; + } + if (buffered.length < bodyEnd) return; + replied = true; + socket.write("HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 2\r\n\r\nok"); + }); + }); + return { + connections: () => connections, + listen: listen(server, { + beforeClose: () => { + for (const socket of sockets) socket.destroy(); + }, + }), + }; +}; + +it.live( + "succeeds a second POST when a keep-alive backend only answers the first request per connection", + () => + Effect.scoped( + Effect.gen(function* () { + const backend = oneRequestPerConnectionBackend(); + const address = yield* backend.listen; + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "mcp", prefix: "/", target: Effect.succeed(address) }]); + const first = yield* request(proxy.port, "/mcp", new TextEncoder().encode("first-body")); + expect(first.status).toBe(200); + expect(new TextDecoder().decode(first.body)).toBe("ok"); + const second = yield* request(proxy.port, "/mcp", new TextEncoder().encode("second-body")); + expect(second.status).toBe(200); + expect(new TextDecoder().decode(second.body)).toBe("ok"); + expect(backend.connections()).toBe(2); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live( + "retries a GET once on a fresh connection when its pooled connection resets unanswered", + () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const backend = oneRequestPerConnectionBackend(); + const address = yield* backend.listen; + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "studio", prefix: "/", target: Effect.succeed(address) }]); + const first = yield* request(proxy.port, "/", new Uint8Array(), {}, "GET"); + expect(first.status).toBe(200); + const second = yield* request(proxy.port, "/", new Uint8Array(), {}, "GET"); + expect(second.status).toBe(200); + expect(new TextDecoder().decode(second.body)).toBe("ok"); + expect(backend.connections()).toBe(2); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Route studio GET upstream failed before responding, retrying"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer, captureWarnings(logs)), + ), + ); + }, +); + +it.live("returns a gateway error when the fresh retry after a pooled reset also fails", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const backend = oneRequestPerConnectionBackend(1); + const address = yield* backend.listen; + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "studio", prefix: "/", target: Effect.succeed(address) }]); + const first = yield* request(proxy.port, "/", new Uint8Array(), {}, "GET"); + expect(first.status).toBe(200); + const second = yield* request(proxy.port, "/", new Uint8Array(), {}, "GET"); + expect(second.status).toBe(502); + expect(backend.connections()).toBe(2); + expect(logs).toHaveLength(2); + expect(logs[0]).toContain("Route studio GET upstream failed before responding, retrying"); + expect(logs[1]).toContain("Route studio request failed"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer, captureWarnings(logs)), + ), + ); +}); + +it.live("delivers a keyed POST once when the upstream resets after reading its body", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const bodies: Array<string> = []; + let connections = 0; + // Answers GETs on a keep-alive connection; resets after reading a POST body in full. + const backend = createTcpServer((socket) => { + connections += 1; + socket.on("error", () => {}); + let buffered = Buffer.alloc(0); + socket.on("data", (chunk: Buffer) => { + buffered = Buffer.concat([buffered, chunk]); + const headerEnd = buffered.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const head = buffered.subarray(0, headerEnd).toString("latin1"); + const bodyEnd = headerEnd + 4 + Number(/content-length:\s*(\d+)/iu.exec(head)?.[1] ?? 0); + if (buffered.length < bodyEnd) return; + const body = buffered.subarray(headerEnd + 4, bodyEnd).toString(); + buffered = buffered.subarray(bodyEnd); + if (head.startsWith("GET ")) { + socket.write( + "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 2\r\n\r\nok", + ); + return; + } + bodies.push(body); + socket.resetAndDestroy(); + }); + }); + const address = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "rest", prefix: "/", target: Effect.succeed(address) }]); + const pooled = yield* request(proxy.port, "/rest/v1/", new Uint8Array(), {}, "GET"); + expect(pooled.status).toBe(200); + const post = yield* request(proxy.port, "/rest/v1/rpc", new TextEncoder().encode("insert"), { + "idempotency-key": "insert", + }); + expect(post.status).toBe(502); + expect(bodies).toEqual(["insert"]); + expect(connections).toBe(2); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Route rest request failed"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll(NodeHttpClient.layerNodeHttp, NodeServices.layer, captureWarnings(logs)), + ), + ); +}); + +it.live("reuses pooled upstream connections across thousands of concurrent requests", () => + Effect.scoped( + Effect.gen(function* () { + let connections = 0; + const backend = createServer((incoming, outgoing) => { + incoming.resume(); + incoming.once("end", () => outgoing.end(incoming.url)); + }); + backend.on("connection", () => { + connections += 1; + }); + const address = yield* listen(backend); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([{ id: "rest", prefix: "/", target: Effect.succeed(address) }]); + const responses = yield* Effect.forEach( + Array.from({ length: 3000 }, (_, index) => index), + (index) => + request(proxy.port, `/rest/v1/items?id=eq.${index}`, new Uint8Array(), {}, "GET").pipe( + Effect.map((response) => ({ + index, + status: response.status, + body: new TextDecoder().decode(response.body), + })), + ), + { concurrency: 16 }, + ); + expect( + responses.filter( + ({ index, status, body }) => status !== 200 || body !== `/rest/v1/items?id=eq.${index}`, + ), + ).toEqual([]); + expect(connections).toBeLessThan(100); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + it.live( "does not replay a bodyless non-idempotent request when the upstream drops the connection", () => { diff --git a/packages/stack/src/HttpProxy.ts b/packages/stack/src/HttpProxy.ts index 10755ad82f..d2ae57d119 100644 --- a/packages/stack/src/HttpProxy.ts +++ b/packages/stack/src/HttpProxy.ts @@ -1,7 +1,8 @@ -import { Data, Effect, FiberSet, Ref, Schedule, Scope } from "effect"; +import { Data, Effect, FiberSet, Ref, Scope } from "effect"; import { PortError } from "./Ports.ts"; import type { BackendAddress, ProxyError } from "./Proxy.ts"; import { + Agent, createServer, request as upstreamRequest, type IncomingMessage, @@ -74,6 +75,10 @@ const hasBody = (request: IncomingMessage) => request.headers["transfer-encoding"] !== undefined || Number(request.headers["content-length"] ?? 0) > 0; +// A write can commit before its connection resets, so only safe, bodyless requests are resent. +const isReplayable = (request: IncomingMessage) => + safeMethods.has(request.method ?? "GET") && !hasBody(request); + const headersFor = (headers: IncomingMessage["headers"]) => Object.fromEntries( Object.entries(headers).filter( @@ -136,7 +141,10 @@ const pathFor = (request: IncomingMessage, route: HttpRoute) => { const path = route.upstreamPrefix === undefined ? pathname - : `${route.upstreamPrefix.replace(/\/$/u, "")}${suffix.startsWith("/") ? suffix : `/${suffix}`}`; + : // Exact-path upstreams such as Studio's `/api/mcp` redirect a trailing slash. + suffix === "" + ? route.upstreamPrefix + : `${route.upstreamPrefix.replace(/\/$/u, "")}${suffix.startsWith("/") ? suffix : `/${suffix}`}`; return `${path}${rewriteQuery(query, route)}`; }; @@ -222,30 +230,24 @@ const connectInterruptibly = Effect.fn("HttpProxy.connect")((address: BackendAdd }), ); -// Mirrors nginx `proxy_next_upstream error`: a backend that drops a fresh connection before -// answering gets one more attempt, but only when nothing sent to the client or upstream would -// need replaying. -const retryOnce = (request: IncomingMessage, response: ServerResponse, route: HttpRoute) => - Schedule.recurs(1).pipe( - Schedule.setInputType<HttpProxyError | HttpProxyDisconnected>(), - Schedule.while( - ({ input }) => - input._tag === "HttpProxyError" && - input.responded === false && - !response.destroyed && - safeMethods.has(request.method ?? "GET") && - !hasBody(request), - ), - Schedule.tap(({ input }) => - Effect.logWarning( - `Route ${route.id} ${request.method ?? "GET"} upstream failed before responding, retrying`, - input, - ), - ), - ); +// Mirrors nginx `proxy_next_upstream error`: a backend that drops a connection before answering +// gets one more attempt, but only when nothing sent to the client or upstream would need replaying. +const isRetryable = + (request: IncomingMessage, response: ServerResponse) => + (error: HttpProxyError | HttpProxyDisconnected) => + error._tag === "HttpProxyError" && + error.responded === false && + !response.destroyed && + isReplayable(request); const forward = Effect.fn("HttpProxy.forward")( - (request: IncomingMessage, response: ServerResponse, route: HttpRoute, backend: BackendAddress) => + ( + request: IncomingMessage, + response: ServerResponse, + route: HttpRoute, + backend: BackendAddress, + agent: Agent | false, + ) => Effect.callback<void, HttpProxyError | HttpProxyDisconnected>((resume) => { let outgoing: ReturnType<typeof upstreamRequest> | undefined; let incoming: IncomingMessage | undefined; @@ -284,9 +286,11 @@ const forward = Effect.fn("HttpProxy.forward")( host: "path" in backend ? undefined : backend.host, port: "path" in backend ? undefined : backend.port, socketPath: "path" in backend ? backend.path : undefined, + agent, method: request.method, path: pathFor(request, route), - headers: upstreamHeadersFor(request.headers, route), + // Bun ends a streamed upstream response early when the request says Connection: close. + headers: { ...upstreamHeadersFor(request.headers, route), connection: "keep-alive" }, }, (value) => { incoming = value; @@ -319,11 +323,16 @@ const forward = Effect.fn("HttpProxy.forward")( ); const proxyRequest = Effect.fn("HttpProxy.proxyRequest")( - (request: IncomingMessage, response: ServerResponse, route: HttpRoute) => + (request: IncomingMessage, response: ServerResponse, route: HttpRoute, agent: Agent) => Effect.gen(function* () { const backend = yield* Effect.raceFirst(route.target, disconnected(request, response)); - yield* forward(request, response, route, backend).pipe( - Effect.retry(retryOnce(request, response, route)), + yield* forward(request, response, route, backend, isReplayable(request) ? agent : false).pipe( + Effect.catchIf(isRetryable(request, response), (error) => + Effect.logWarning( + `Route ${route.id} ${request.method ?? "GET"} upstream failed before responding, retrying`, + error, + ).pipe(Effect.andThen(forward(request, response, route, backend, false))), + ), ); }), ); @@ -398,6 +407,12 @@ export const makeHttpProxy = (options: { }): Effect.Effect<HttpProxy, PortError, Scope.Scope> => Effect.gen(function* () { const routes = yield* Ref.make<ReadonlyArray<HttpRoute>>([]); + // Sockets per upstream stay unbounded so long-lived streamed responses never queue requests. + // Idle sockets close before Node upstreams' default 5 s keep-alive timeout can race a reuse. + const agent = yield* Effect.acquireRelease( + Effect.sync(() => new Agent({ keepAlive: true, timeout: 4_000 })), + (value) => Effect.sync(() => value.destroy()), + ); const runRequest = yield* FiberSet.makeRuntime(); const sockets = new Set<Socket>(); const server = createServer((request, response) => { @@ -415,7 +430,7 @@ export const makeHttpProxy = (options: { response.statusCode = 404; response.end("Not Found"); } else { - yield* proxyRequest(request, response, route).pipe( + yield* proxyRequest(request, response, route, agent).pipe( Effect.tapError((cause) => cause._tag === "HttpProxyDisconnected" ? Effect.void diff --git a/packages/stack/src/Network.integration.test.ts b/packages/stack/src/Network.integration.test.ts index a5351f9a16..680424c0ad 100644 --- a/packages/stack/src/Network.integration.test.ts +++ b/packages/stack/src/Network.integration.test.ts @@ -1,10 +1,11 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Context, Data, Deferred, Effect, FileSystem, Layer, Path, Ref } from "effect"; +import { Context, Data, Deferred, Effect, Fiber, FileSystem, Layer, Path, Ref } from "effect"; import * as Net from "node:net"; // oxlint-disable-line effecttsgo/node-builtin-import -- fixture retains an idle HTTP connection. import { createServer } from "node:http"; // oxlint-disable-line effecttsgo/node-builtin-import -- real socket fixture. import { HttpClient } from "effect/unstable/http"; import * as Network from "./Network.ts"; +import { DOCKER_HOST_ALIAS } from "./runtime/Container.ts"; import * as State from "./State.ts"; const makeTestState = (root: string) => @@ -26,8 +27,9 @@ const stack = (id: string, port: number | "auto") => ({ id, identity: { projectRoot: "/tmp", branchContext: "test", stackName: id }, runtime: "native" as const, + lifetime: "detached" as const, instances: [], - composition: {}, + composition: { members: [], dependencies: [] }, ports: port === "auto" ? [] : [{ key: "api", host: "127.0.0.1", port }], }); @@ -76,6 +78,22 @@ const endpoint = (target: { host: string; port: number }, enabled: Effect.Effect enabled, }); +/** A dedicated HTTP endpoint that additionally joins the shared API listener, like Studio's `http`. */ +const joinEndpoint = (target: { host: string; port: number }, enabled: Effect.Effect<boolean>) => ({ + ...endpoint(target, enabled), + join: [{ prefix: "/mcp", upstreamPrefix: "/api/mcp" }], +}); + +const claimant = ( + id: string, + target: { host: string; port: number }, + enabled: Effect.Effect<boolean>, + port: number | "auto" = "auto", +) => ({ + id, + endpoints: { api: { ...endpoint(target, enabled), port, shared: [{ prefix: "/rest" }] } }, +}); + it.live("retains dedicated assignments across network reopen", () => Effect.scoped( Effect.gen(function* () { @@ -320,3 +338,292 @@ it.live("releases dedicated HTTP activity after the response while keep-alive st }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + +it.live( + "queues a joined route before any claimant binds, then serves it on the claimant's own port", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-fixed-port-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Effect.succeed(true)) }, + }); + yield* studio.bind; + // The join route is queued, not installed: it never claims or asserts the shared "api" + // port, so no shared listener or claim exists yet, whatever port a later claimant picks. + const beforeClaimant = yield* state.read("stack"); + expect(beforeClaimant?.ports.some((claim) => claim.key === "api")).toBe(false); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp?read_only=true")).toBe( + "backend:/api/mcp?read_only=true", + ); + expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("drops a queued join route when its namespace closes before any claimant binds", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-cancel-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Effect.succeed(false)) }, + }); + yield* studio.bind; + yield* studio.close; + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const address = yield* rest.address("api", "host"); + const response = yield* HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(`http://${address.host}:${address.port}/mcp`)), + Effect.provide(NodeHttpClient.layerNodeHttp), + ); + expect(response.status).toBe(404); + expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("stays idempotent across repeated binds of the joining namespace", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-idempotent-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Effect.succeed(true)) }, + }); + yield* studio.bind; + yield* studio.bind; + yield* studio.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("keeps a namespace's own shared route when its join endpoint also binds", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "network-join-shares-namespace-", + }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const combo = yield* network.register({ + id: "combo", + endpoints: { + api: { ...endpoint(target, Effect.succeed(true)), shared: [{ prefix: "/rest" }] }, + http: joinEndpoint(target, Effect.succeed(true)), + }, + }); + yield* combo.bind; + const address = yield* combo.address("api", "host"); + expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* combo.bind; + expect(yield* request(address.host, address.port, "/rest")).toBe("backend:/rest"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("re-adds a joined route after its namespace closes and rebinds", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-rebind-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const enabled = yield* Ref.make(true); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Ref.get(enabled)) }, + }); + yield* studio.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* Ref.set(enabled, false); + yield* studio.close; + const closedResponse = yield* HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(`http://${address.host}:${address.port}/mcp`)), + Effect.provide(NodeHttpClient.layerNodeHttp), + ); + expect(closedResponse.status).toBe(404); + yield* Ref.set(enabled, true); + yield* studio.bind; + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("never restores a joined route once its namespace is released", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-release-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const enabled = yield* Ref.make(true); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Ref.get(enabled)) }, + }); + yield* studio.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* Ref.set(enabled, false); + yield* studio.release; + const failure = yield* studio.bind.pipe(Effect.flip); + expect(failure.operation).toBe("bind"); + const releasedResponse = yield* HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(`http://${address.host}:${address.port}/mcp`)), + Effect.provide(NodeHttpClient.layerNodeHttp), + ); + expect(releasedResponse.status).toBe(404); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live( + "keeps a joined route working after the last claimant closes and closes the listener once it is gone too", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-last-close-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const restEnabled = yield* Ref.make(true); + const studioEnabled = yield* Ref.make(true); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Ref.get(restEnabled))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { http: joinEndpoint(target, Ref.get(studioEnabled)) }, + }); + yield* studio.bind; + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* Ref.set(restEnabled, false); + yield* rest.close; + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + expect( + (yield* HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(`http://${address.host}:${address.port}/rest`)), + Effect.provide(NodeHttpClient.layerNodeHttp), + )).status, + ).toBe(404); + // A served keep-alive request proves the listener accepted and tracks this socket, so its + // closure is observed directly instead of by reprobing the port. + const probe = yield* Effect.callback<Net.Socket, FixtureError>((resume) => { + const connection = Net.createConnection({ host: address.host, port: address.port }); + connection.once("connect", () => { + connection.write( + `GET /mcp HTTP/1.1\r\nHost: ${address.host}:${address.port}\r\nConnection: keep-alive\r\n\r\n`, + ); + }); + connection.once("data", () => resume(Effect.succeed(connection))); + connection.on("error", (cause) => + resume(Effect.fail(new FixtureError({ message: cause.message }))), + ); + return Effect.sync(() => connection.destroy()); + }); + const probeClosed = yield* Effect.callback<void, never>((resume) => { + probe.once("close", () => resume(Effect.void)); + return Effect.sync(() => probe.destroy()); + }).pipe(Effect.forkChild); + yield* Ref.set(studioEnabled, false); + yield* studio.close; + yield* Fiber.join(probeClosed).pipe(Effect.timeout("2 seconds")); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("wakes a sleeping backend when a request reaches its joined route", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-join-wake-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const woken = yield* Deferred.make<void>(); + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "native", state }); + const rest = yield* network.register(claimant("rest", target, Effect.succeed(true))); + yield* rest.bind; + const studio = yield* network.register({ + id: "studio", + endpoints: { + http: { + ...joinEndpoint(target, Effect.succeed(true)), + // Mirrors the orchestrator's acquire-then-resolve path: the target effect itself wakes + // the instance on demand instead of pointing at an already-running backend. + backend: Deferred.succeed(woken, undefined).pipe(Effect.as(target)), + }, + }, + }); + yield* studio.bind; + expect(yield* Deferred.isDone(woken)).toBe(false); + const address = yield* rest.address("api", "host"); + expect(yield* request(address.host, address.port, "/mcp")).toBe("backend:/api/mcp"); + yield* Deferred.await(woken).pipe(Effect.timeout("2 seconds")); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("addresses docker runtime endpoints through the stack host alias", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "network-docker-alias-" }); + const state = yield* makeTestState(root); + yield* state.save(stack("stack", "auto")); + const target = yield* backend; + const network = yield* makeTestNetwork({ stackId: "stack", runtime: "docker", state }); + const namespace = yield* network.register({ + id: "one", + endpoints: { api: endpoint(target, Effect.succeed(false)) }, + }); + yield* namespace.bind; + const host = yield* namespace.address("api", "host"); + const runtime = yield* namespace.address("api", "runtime"); + expect(host.host).toBe("127.0.0.1"); + expect(runtime).toEqual({ ...host, host: DOCKER_HOST_ALIAS }); + yield* namespace.release; + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/packages/stack/src/Network.ts b/packages/stack/src/Network.ts index 8c1b1cb8f7..bb80763509 100644 --- a/packages/stack/src/Network.ts +++ b/packages/stack/src/Network.ts @@ -1,4 +1,5 @@ import { Context, Data, Effect, Exit, Layer, Ref, Scope, Semaphore } from "effect"; +import { DOCKER_HOST_ALIAS } from "./runtime/Container.ts"; import * as State from "./State.ts"; import { makePorts, PortError } from "./Ports.ts"; import { bindTcp, serveTcp, type BackendAddress, type ProxyError } from "./Proxy.ts"; @@ -6,23 +7,31 @@ import { makeHttpProxy, type HttpProxy, type HttpRoute } from "./HttpProxy.ts"; export type NetworkRuntime = "native" | "docker" | "podman"; +type RouteContribution = Pick< + HttpRoute, + "prefix" | "upstreamPrefix" | "upstreamHost" | "keyRewrite" +>; + export interface NetworkEndpoint { readonly protocol: "tcp" | "http"; readonly port: number | "auto"; readonly backend: Effect.Effect<BackendAddress, ProxyError, Scope.Scope>; - readonly shared?: ReadonlyArray< - Pick<HttpRoute, "prefix" | "upstreamPrefix" | "upstreamHost" | "keyRewrite"> - >; + readonly shared?: ReadonlyArray<RouteContribution>; + /** + * Routes a dedicated endpoint additionally contributes to the shared API listener, without + * claiming the shared port itself. Installed when the shared listener exists, queued otherwise. + */ + readonly join?: ReadonlyArray<RouteContribution>; readonly enabled: Effect.Effect<boolean>; } -class NetworkError extends Data.TaggedError("NetworkError")<{ +export class NetworkError extends Data.TaggedError("NetworkError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; }> {} -export interface NetworkBinding { +interface NetworkBinding { readonly name: string; readonly protocol: "tcp" | "http"; readonly host: string; @@ -77,7 +86,8 @@ const makeNetwork = (options: { } | undefined >(undefined); - const namespaces = yield* Ref.make(new Map<string, ReadonlyArray<string>>()); + // Join routes queued before any claiming endpoint has created the shared listener. + const pendingJoins = yield* Ref.make<ReadonlyArray<HttpRoute>>([]); const listenHost = options.runtime === "native" ? "127.0.0.1" : "0.0.0.0"; const hostAddress = "127.0.0.1"; @@ -85,9 +95,47 @@ const makeNetwork = (options: { options.runtime === "native" ? "127.0.0.1" : options.runtime === "docker" - ? "host.docker.internal" + ? DOCKER_HOST_ALIAS : "host.containers.internal"; + const routeKey = (route: Pick<HttpRoute, "id" | "prefix">) => `${route.id}:${route.prefix}`; + + /** Maps one endpoint's route contributions to the shared listener's `HttpRoute` shape. */ + const toHttpRoutes = ( + id: string, + contributions: ReadonlyArray<RouteContribution>, + backend: NetworkEndpoint["backend"], + ): ReadonlyArray<HttpRoute> => + contributions.map((route) => ({ + id, + prefix: route.prefix, + upstreamPrefix: route.upstreamPrefix, + upstreamHost: route.upstreamHost, + ...(route.keyRewrite === undefined ? {} : { keyRewrite: route.keyRewrite }), + target: backend, + })); + + /** Installs a namespace's joined routes onto the shared listener, or queues them if it does not exist yet. */ + const installJoin = Effect.fn("Network.installJoin")(function* ( + routeId: string, + join: NonNullable<NetworkEndpoint["join"]>, + backend: NetworkEndpoint["backend"], + ) { + const routes = toHttpRoutes(routeId, join, backend); + const ownKeys = new Set(routes.map(routeKey)); + const current = yield* Ref.get(shared); + if (current === undefined) { + yield* Ref.update(pendingJoins, (existing) => [ + ...existing.filter((route) => !ownKeys.has(routeKey(route))), + ...routes, + ]); + return; + } + const next = [...current.routes.filter((route) => !ownKeys.has(routeKey(route))), ...routes]; + yield* current.proxy.setRoutes(next); + yield* Ref.set(shared, { ...current, routes: next }); + }); + const register = Effect.fn("Network.register")(function* ({ id, endpoints, @@ -95,12 +143,6 @@ const makeNetwork = (options: { readonly id: string; readonly endpoints: Readonly<Record<string, NetworkEndpoint>>; }) { - const names = Object.keys(endpoints); - const duplicate = yield* Ref.modify(namespaces, (current) => [ - current.has(id), - current.has(id) ? current : new Map(current).set(id, names), - ]); - if (duplicate) return yield* errorFor("register", `Duplicate instance ${id}`); const bound = yield* Ref.make<Map<string, NetworkBinding>>(new Map()); const scopes = yield* Ref.make<Map<string, Scope.Closeable>>(new Map()); const closed = yield* Ref.make(false); @@ -143,7 +185,7 @@ const makeNetwork = (options: { } const listener = yield* bindTcp(host, port); yield* Effect.forkIn( - serveTcp(listener, endpoint.backend).pipe( + serveTcp(listener, endpoint.backend, `${id}:${name}`).pipe( Effect.provideService(Scope.Scope, endpointScope), Effect.catch((cause) => Effect.logWarning("Public listener failed", cause), @@ -166,23 +208,19 @@ const makeNetwork = (options: { ); if (endpoint.shared !== undefined && result.listener.proxy !== undefined) { const previous = yield* Ref.get(shared); + let seeded: ReadonlyArray<HttpRoute> = []; + if (previous === undefined) seeded = yield* Ref.get(pendingJoins); const current = previous ?? { claim: result.port, proxy: result.listener.proxy, - routes: [], + routes: seeded, scope: endpointScope, }; if (previous !== undefined) yield* Scope.close(endpointScope, Exit.void); + else if (seeded.length > 0) yield* Ref.set(pendingJoins, []); const routes = [ ...current.routes, - ...endpoint.shared.map((route) => ({ - id, - prefix: route.prefix, - upstreamPrefix: route.upstreamPrefix, - upstreamHost: route.upstreamHost, - ...(route.keyRewrite === undefined ? {} : { keyRewrite: route.keyRewrite }), - target: endpoint.backend, - })), + ...toHttpRoutes(id, endpoint.shared, endpoint.backend), ]; yield* current.proxy.setRoutes(routes); yield* Ref.set(shared, { ...current, routes }); @@ -191,6 +229,8 @@ const makeNetwork = (options: { new Map(current).set(name, endpointScope), ); } + if (endpoint.join !== undefined) + yield* installJoin(id, endpoint.join, endpoint.backend); yield* Ref.update(bound, (current) => new Map(current).set(name, { name, @@ -213,6 +253,9 @@ const makeNetwork = (options: { if (yield* Ref.get(closed)) return; for (const endpoint of Object.values(endpoints)) if (yield* endpoint.enabled) return; + // Drop this namespace's queued join routes so a later listener never resurrects them. + yield* Ref.update(pendingJoins, (routes) => routes.filter((route) => route.id !== id)); + const current = yield* Ref.get(shared); let remainingRoutes = current?.routes ?? []; if (current !== undefined) { @@ -249,18 +292,13 @@ const makeNetwork = (options: { "Stop the instance before releasing its endpoints", ); yield* Ref.set(closed, true); - for (const name of names) { + for (const name of Object.keys(endpoints)) { const endpoint = endpoints[name]; if (endpoint?.shared === undefined) yield* ports .release(options.stackId, `${id}:${name}`) .pipe(Effect.mapError((cause) => errorFor("release", cause))); } - yield* Ref.update(namespaces, (current) => { - const next = new Map(current); - next.delete(id); - return next; - }); }), ), ), @@ -295,13 +333,9 @@ const makeNetwork = (options: { const release = Effect.fn("Network.releaseNamespace")(() => gate.withPermits(1)( - Effect.gen(function* () { - if ((yield* Ref.get(namespaces)).size !== 0) - return yield* errorFor("release", "Destroy instances before releasing the namespace"); - yield* ports - .release(options.stackId, "api") - .pipe(Effect.mapError((cause) => errorFor("release", cause))); - }), + ports + .release(options.stackId, "api") + .pipe(Effect.mapError((cause) => errorFor("release", cause))), ), ); return { register, release: release() } satisfies Interface; diff --git a/packages/stack/src/Orchestrator.integration.test.ts b/packages/stack/src/Orchestrator.integration.test.ts index 0759fd353d..1fe51757ff 100644 --- a/packages/stack/src/Orchestrator.integration.test.ts +++ b/packages/stack/src/Orchestrator.integration.test.ts @@ -1,34 +1,20 @@ import { describe, expect, it } from "@effect/vitest"; -import { - Cause, - Context, - Deferred, - Effect, - Exit, - Fiber, - Layer, - Option, - Ref, - Schema, - Scope, - Stream, -} from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Schema, Scope, Stream } from "effect"; import * as TestClock from "effect/testing/TestClock"; +import { captureLogs } from "../tests/logs.ts"; import * as Orchestrator from "./Orchestrator.ts"; import type { RegisteredInstance } from "./Orchestrator.ts"; import { makeService, ServiceError } from "./Service.ts"; const failure = (message: string) => new ServiceError({ operation: "fixture", message }); -const makeTestOrchestrator = () => - Layer.build(Layer.fresh(Orchestrator.layer)).pipe( - Effect.map((context) => Context.get(context, Orchestrator.Service)), - ); +const makeTestOrchestrator = () => Orchestrator.make<RegisteredInstance>(); const makeInstance = ( orchestrator: Orchestrator.Interface, id: string, options: { readonly endpoint?: boolean; readonly health?: Effect.Effect<void, ServiceError>; + readonly probe?: Effect.Effect<void, ServiceError>; readonly bind?: Effect.Effect<void, ServiceError>; readonly prepare?: Effect.Effect<void, ServiceError>; readonly launch?: Effect.Effect<void, ServiceError>; @@ -56,6 +42,7 @@ const makeInstance = ( const exited = yield* Deferred.make<Exit.Exit<void, ServiceError>>(); return { health: options.health ?? Effect.void, + ...(options.probe === undefined ? {} : { probe: options.probe }), exit: Deferred.await(options.exit ?? exited), stop: (options.stop ?? Effect.void).pipe( Effect.andThen(event("stop")), @@ -71,6 +58,7 @@ const makeInstance = ( ); const instance: RegisteredInstance = { id, + service: id, core, startAt: (revision, inputs, wake, guard) => core.startAt(revision, inputs, wake, guard), restart: (revision, inputs, candidate, guard) => @@ -796,3 +784,213 @@ it.live("allows later traffic to retry an armed service after a failed wake laun }), ), ); + +it.live("logs a named failure for a failed wake and readiness for a successful one", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const failing = yield* Ref.make(true); + yield* makeInstance(orchestrator, "api", { + launch: Ref.get(failing).pipe( + Effect.flatMap((value) => (value ? Effect.fail(failure("launch failed")) : Effect.void)), + ), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "lazy" }], + dependencies: [], + }); + yield* orchestrator.startComposition; + yield* Effect.scoped(orchestrator.acquire("api")).pipe(Effect.flip); + yield* Ref.set(failing, false); + yield* Effect.scoped(orchestrator.acquire("api")); + yield* orchestrator.stopNamespace; + expect(logs.some((line) => line.includes("api api failed to wake"))).toBe(true); + expect(logs.some((line) => line.includes("api api is ready"))).toBe(true); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +it.live("logs exactly one wake for many concurrent acquires of a sleeping member", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const launching = yield* Deferred.make<void>(); + const proceed = yield* Deferred.make<void>(); + yield* makeInstance(orchestrator, "api", { + launch: Deferred.succeed(launching, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "lazy" }], + dependencies: [], + }); + yield* orchestrator.startComposition; + const callers = yield* Effect.forEach(Array.from({ length: 20 }), () => + Effect.scoped(orchestrator.acquire("api")).pipe( + Effect.forkChild({ startImmediately: true }), + ), + ); + yield* Deferred.await(launching); + yield* Deferred.succeed(proceed, undefined); + yield* Effect.forEach(callers, Fiber.join); + yield* orchestrator.stopNamespace; + expect(logs.filter((line) => line.includes("Waking api api"))).toHaveLength(1); + expect(logs.filter((line) => line.includes("api api is ready"))).toHaveLength(1); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +it.live("logs a distinct failure when a launched member never becomes ready", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + yield* makeInstance(orchestrator, "api", { + health: Effect.fail(failure("still booting")), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "lazy" }], + dependencies: [], + }); + yield* orchestrator.startComposition; + yield* Effect.scoped(orchestrator.acquire("api")).pipe(Effect.flip); + yield* orchestrator.stopNamespace; + expect(logs.some((line) => line.includes("api api failed to become ready"))).toBe(true); + expect(logs.some((line) => line.includes("api api failed to wake"))).toBe(false); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +it.live("logs no extra wake while the initiator is still waiting for readiness", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const healthEntered = yield* Deferred.make<void>(); + const readyGate = yield* Deferred.make<void>(); + yield* makeInstance(orchestrator, "api", { + health: Deferred.succeed(healthEntered, undefined).pipe( + Effect.andThen(Deferred.await(readyGate)), + ), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "lazy" }], + dependencies: [], + }); + yield* orchestrator.startComposition; + const first = yield* Effect.scoped(orchestrator.acquire("api")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(healthEntered); + expect(logs.filter((line) => line.includes("Waking api api"))).toHaveLength(1); + const second = yield* Effect.scoped(orchestrator.acquire("api")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(readyGate, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + yield* orchestrator.stopNamespace; + expect(logs.filter((line) => line.includes("Waking api api"))).toHaveLength(1); + expect(logs.filter((line) => line.includes("api api is ready"))).toHaveLength(1); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +it.live("does not log a spurious wake for a member already starting outside acquire", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const launching = yield* Deferred.make<void>(); + const proceed = yield* Deferred.make<void>(); + yield* makeInstance(orchestrator, "api", { + launch: Deferred.succeed(launching, undefined).pipe( + Effect.andThen(Deferred.await(proceed)), + ), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "eager" }], + dependencies: [], + }); + const composition = yield* orchestrator.startComposition.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(launching); + const acquired = yield* Effect.scoped(orchestrator.acquire("api")).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(proceed, undefined); + yield* Fiber.join(composition); + yield* Fiber.join(acquired); + yield* orchestrator.stopNamespace; + expect(logs.filter((line) => line.includes("Waking api api"))).toHaveLength(0); + }), + ).pipe(Effect.provide(captureLogs(["Error", "Info"])(logs))); +}); + +describe("readiness recovery", () => { + const recoverableHealth = (healthy: Ref.Ref<boolean>) => + Ref.get(healthy).pipe( + Effect.flatMap((ok) => (ok ? Effect.void : Effect.fail(failure("still booting")))), + ); + + it.live("serves traffic once a running instance recovers without restarting it", () => + Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const healthy = yield* Ref.make(false); + const api = yield* makeInstance(orchestrator, "api", { + health: recoverableHealth(healthy), + probe: recoverableHealth(healthy), + }); + yield* orchestrator.configure({ + members: [{ id: "api", activation: "eager" }], + dependencies: [], + }); + yield* orchestrator.startComposition.pipe(Effect.flip); + yield* Effect.scoped(orchestrator.acquire("api")).pipe(Effect.flip); + + yield* Ref.set(healthy, true); + yield* Effect.scoped(orchestrator.acquire("api")); + + expect(yield* Ref.get(api.starts)).toHaveLength(1); + expect(yield* api.core.get).toMatchObject({ lifecycle: "running", health: "healthy" }); + yield* orchestrator.stopNamespace; + }), + ), + ); + + it.live("starts a blocked dependent once its running prerequisite recovers", () => + Effect.scoped( + Effect.gen(function* () { + const orchestrator = yield* makeTestOrchestrator(); + const healthy = yield* Ref.make(false); + const database = yield* makeInstance(orchestrator, "database", { + health: recoverableHealth(healthy), + probe: recoverableHealth(healthy), + }); + const rest = yield* makeInstance(orchestrator, "rest"); + yield* orchestrator.configure({ + members: [ + { id: "database", activation: "eager" }, + { id: "rest", activation: "eager" }, + ], + dependencies: [{ from: "database", to: "rest" }], + }); + yield* orchestrator.startComposition.pipe(Effect.flip); + expect(yield* Ref.get(rest.starts)).toEqual([]); + + yield* Ref.set(healthy, true); + yield* orchestrator.startComposition; + + expect(yield* Ref.get(database.starts)).toHaveLength(1); + expect(yield* Ref.get(rest.starts)).toHaveLength(1); + expect((yield* rest.core.get).health).toBe("healthy"); + yield* orchestrator.stopNamespace; + }), + ), + ); +}); diff --git a/packages/stack/src/Orchestrator.ts b/packages/stack/src/Orchestrator.ts index 0f00f78e92..b00639164e 100644 --- a/packages/stack/src/Orchestrator.ts +++ b/packages/stack/src/Orchestrator.ts @@ -1,13 +1,11 @@ import { Cause, Clock, - Context, Data, Deferred, Effect, Exit, FiberMap, - Layer, Match, Ref, Schema, @@ -23,6 +21,7 @@ import { type ServiceObservation, } from "./Service.ts"; import type { ServiceAdmission } from "./Service.ts"; +import { errorChainMessage } from "./internal/error-message.ts"; export type OrchestratorOperation = | "start" @@ -35,7 +34,6 @@ export type OrchestratorOperation = | "close" | "configure" | "register" - | "unregister" | "proxy"; export class OrchestratorError extends Data.TaggedError("OrchestratorError")<{ @@ -50,21 +48,7 @@ export class OrchestratorError extends Data.TaggedError("OrchestratorError")<{ /** Summarizes a failure cause as one line per error, including its nested error causes. */ export const causeMessage = (cause: Cause.Cause<unknown>): string => - Cause.prettyErrors(cause) - .map((error) => { - const parts: Array<string> = []; - const visited = new Set<Error>(); - let current: unknown = error; - while (current instanceof Error && !visited.has(current)) { - visited.add(current); - const text = current.message || current.name; - // Wrappers often copy their cause's message; keep the chain but not the repeat. - if (parts.at(-1) !== text) parts.push(text); - current = current.cause; - } - return parts.join(": "); - }) - .join("; ") || "interrupted"; + Cause.prettyErrors(cause).map(errorChainMessage).join("; ") || "interrupted"; type CoreObservation = ServiceObservation<unknown>; @@ -73,7 +57,6 @@ interface RegisteredCore { readonly ready: Effect.Effect<void, LifecycleError>; readonly stop: Effect.Effect<void, LifecycleError>; readonly destroy: Effect.Effect<void, LifecycleError>; - readonly arm: Effect.Effect<void, LifecycleError>; readonly armAt: ( revision: number, guard?: Effect.Effect<void, ServiceError>, @@ -84,6 +67,8 @@ interface RegisteredCore { export interface RegisteredInstance { readonly id: string; + /** The service kind this instance runs, for wake observability; the id is the graph identity. */ + readonly service: string; readonly core: RegisteredCore; readonly startAt: ( revision: number, @@ -151,27 +136,25 @@ export const CompositionConfig = Schema.Struct({ ), }); -export interface Interface { +/** The lifecycle authority for one stack's registered instances and their composition. */ +export interface Interface<Entry extends RegisteredInstance = RegisteredInstance> { readonly admissionFor: ( id: string, ) => ( operation: ServiceAdmission, transition: Effect.Effect<void, ServiceError>, ) => Effect.Effect<void, ServiceError>; - readonly register: ( - instance: RegisteredInstance, - ) => Effect.Effect<void, OrchestratorError | LifecycleError>; - readonly unregister: (id: string) => Effect.Effect<void, OrchestratorError | LifecycleError>; - readonly get: ( - id: string, - ) => Effect.Effect<RegisteredInstance, OrchestratorError | LifecycleError>; - readonly list: Effect.Effect< - ReadonlyArray<RegisteredInstance>, - OrchestratorError | LifecycleError - >; - readonly configure: ( - configuration: unknown, - ) => Effect.Effect<void, OrchestratorError | LifecycleError>; + readonly register: (entry: Entry) => Effect.Effect<void, OrchestratorError>; + readonly get: (id: string) => Effect.Effect<Entry, OrchestratorError>; + readonly composition: Effect.Effect<CompositionConfig>; + /** + * Validates and installs a composition; a failed `persist` keeps the previous one. + * `persist` runs under the graph gate, so callers take the cross-process state lock first. + */ + readonly configure: <E = never>( + configuration: CompositionConfig, + persist?: Effect.Effect<void, E>, + ) => Effect.Effect<void, OrchestratorError | E>; readonly start: (id: string) => Effect.Effect<void, OrchestratorError | LifecycleError>; readonly stop: (id: string) => Effect.Effect<void, OrchestratorError | LifecycleError>; readonly restart: ( @@ -196,13 +179,10 @@ export interface Interface { readonly acquire: ( id: string, awaitReady?: boolean, + trigger?: string, ) => Effect.Effect<void, OrchestratorError | LifecycleError, Scope.Scope>; } -export class Service extends Context.Service<Service, Interface>()( - "@supabase/stack/Orchestrator", -) {} - interface ActivityState { readonly active: number; readonly lastActivity: number; @@ -246,13 +226,18 @@ const topo = ( return result; }; -const makeOrchestrator = Effect.gen(function* () { +/** Builds an orchestrator whose watchers and idle timers live in the current scope. */ +export const make = Effect.fn("Orchestrator.make")(function* < + Entry extends RegisteredInstance, +>(): Effect.fn.Return<Interface<Entry>, never, Scope.Scope> { const owner = yield* Scope.Scope; const graphGate = yield* Semaphore.make(1); const idleTimers = yield* FiberMap.make<string>(); - const registry = yield* Ref.make<ReadonlyMap<string, RegisteredInstance>>(new Map()); + const registry = yield* Ref.make<ReadonlyMap<string, Entry>>(new Map()); const composition = yield* Ref.make<CompositionConfig>({ members: [], dependencies: [] }); const activity = yield* Ref.make<ReadonlyMap<string, ActivityState>>(new Map()); + /** Tracks members with a wake in progress so only its initiating caller logs the transition. */ + const wakes = yield* Ref.make<ReadonlySet<string>>(new Set()); const withGraph = Effect.fn("Orchestrator.withGraph")(<A, E>(effect: Effect.Effect<A, E>) => Effect.uninterruptibleMask((restore) => @@ -264,7 +249,7 @@ const makeOrchestrator = Effect.gen(function* () { const node = Effect.fn("Orchestrator.node")(function* ( id: string, - ): Effect.fn.Return<RegisteredInstance, OrchestratorError> { + ): Effect.fn.Return<Entry, OrchestratorError> { const nodes = yield* Ref.get(registry); const value = nodes.get(id); if (value === undefined) return yield* graphError("register", `Unknown instance ${id}`); @@ -544,14 +529,11 @@ const makeOrchestrator = Effect.gen(function* () { Effect.gen(function* () { const values = yield* Ref.get(activity); const current = values.get(id); - if ( + return !( current === undefined || current.active > 0 || now - current.lastActivity < timeout - ) - return false; - yield* Ref.set(activity, new Map(values).set(id, { ...current })); - return true; + ); }), ); if (state) { @@ -736,6 +718,9 @@ const makeOrchestrator = Effect.gen(function* () { const next = new Map(values); next.delete(id); yield* Ref.set(registry, next); + const activities = new Map(yield* Ref.get(activity)); + activities.delete(id); + yield* Ref.set(activity, activities); const configured = yield* Ref.get(composition); yield* Ref.set(composition, { members: configured.members.filter((member) => member.id !== id), @@ -778,7 +763,7 @@ const makeOrchestrator = Effect.gen(function* () { }); }); - const orchestrator: Interface = { + const orchestrator: Interface<Entry> = { admissionFor, register: Effect.fn("Orchestrator.register")((instance) => withGraph( @@ -832,66 +817,34 @@ const makeOrchestrator = Effect.gen(function* () { }), ), ), - unregister: Effect.fn("Orchestrator.unregister")((id) => + get: Effect.fn("Orchestrator.get")((id) => node(id)), + composition: Ref.get(composition), + configure: <E = never>(configuration: CompositionConfig, persist?: Effect.Effect<void, E>) => withGraph( Effect.gen(function* () { - const instance = yield* node(id); - const observation = yield* instance.core.get; - if (observation.registered || observation.lifecycle !== "stopped") - return yield* graphError("unregister", `Instance ${id} must be destroyed first`); - const structure = yield* configurationGraph(yield* Ref.get(composition)); - if ((structure.dependents.get(id) ?? []).some((dependentId) => dependentId !== id)) - return yield* graphError( - "unregister", - `Instance ${id} is still referenced by a dependent`, - ); - const values = yield* Ref.get(registry); - const next = new Map(values); - next.delete(id); - yield* Ref.set(registry, next); - const configured = yield* Ref.get(composition); - yield* Ref.set(composition, { - members: configured.members.filter((member) => member.id !== id), - dependencies: configured.dependencies.filter( - (dependency) => dependency.from !== id && dependency.to !== id, - ), - }); + const previous = yield* Ref.get(composition); + yield* configurationGraph(configuration); + const affected = new Set([ + ...previous.members.map((member) => member.id), + ...configuration.members.map((member) => member.id), + ]); + for (const dependency of [...previous.dependencies, ...configuration.dependencies]) { + affected.add(dependency.from); + affected.add(dependency.to); + } + for (const affectedId of affected) { + const instance = yield* node(affectedId); + const observation = yield* instance.core.get; + if (observation.lifecycle !== "stopped" || observation.wakeEnabled) + return yield* graphError( + "configure", + `Instance ${affectedId} must be stopped and wake-disabled`, + ); + } + if (persist !== undefined) yield* persist; + yield* Ref.set(composition, configuration); }), - ), - ), - get: Effect.fn("Orchestrator.get")((id) => node(id)), - list: Ref.get(registry).pipe(Effect.map((values) => [...values.values()])), - configure: Effect.fn("Orchestrator.configure")((configuration) => - Schema.decodeUnknownEffect(CompositionConfig)(configuration).pipe( - Effect.mapError((cause) => graphError("configure", "Invalid composition", cause)), - Effect.flatMap((decoded) => - withGraph( - Effect.gen(function* () { - const previous = yield* Ref.get(composition); - yield* configurationGraph(decoded); - const affected = new Set([ - ...previous.members.map((member) => member.id), - ...decoded.members.map((member) => member.id), - ]); - for (const dependency of [...previous.dependencies, ...decoded.dependencies]) { - affected.add(dependency.from); - affected.add(dependency.to); - } - for (const affectedId of affected) { - const instance = yield* node(affectedId); - const observation = yield* instance.core.get; - if (observation.lifecycle !== "stopped" || observation.wakeEnabled) - return yield* graphError( - "configure", - `Instance ${affectedId} must be stopped and wake-disabled`, - ); - } - yield* Ref.set(composition, decoded); - }), - ), - ), - ), - ), + ).pipe(Effect.withSpan("Orchestrator.configure")), start: Effect.fn("Orchestrator.start")((id) => Effect.gen(function* () { const plan = yield* snapshotPlan(id); @@ -936,12 +889,12 @@ const makeOrchestrator = Effect.gen(function* () { restartComposition: restartComposition(), stopNamespace: stopNamespace(), destroyNamespace: destroyNamespace(), - acquire: Effect.fn("Orchestrator.acquire")((id, awaitReady = true) => + acquire: Effect.fn("Orchestrator.acquire")((id, awaitReady = true, trigger) => Effect.gen(function* () { const instance = yield* node(id); const scope = yield* Scope.Scope; const now = yield* Clock.currentTimeMillis; - const wake = yield* withGraph( + const { wake, initiator } = yield* withGraph( Effect.gen(function* () { const observation = yield* instance.core.get; if (observation.lifecycle === "stopped" && !observation.wakeEnabled) @@ -961,19 +914,57 @@ const makeOrchestrator = Effect.gen(function* () { Effect.ignore, ), ); - return observation.lifecycle !== "running"; + // A "starting" observer is always joining an in-flight start (a wake or an + // eager/explicit start), never its initiator, regardless of the marker below. + if (observation.lifecycle === "running" || observation.lifecycle === "starting") + return { wake: observation.lifecycle === "starting", initiator: false } as const; + const inFlight = yield* Ref.get(wakes); + const initiator = !inFlight.has(id); + if (initiator) yield* Ref.set(wakes, new Set(inFlight).add(id)); + return { wake: true, initiator } as const; }), ); if (wake) { - const plan = yield* snapshotPlan(id); - for (const member of plan.order) yield* (yield* node(member)).bind; - yield* startNode(id, true, awaitReady, plan); + if (initiator) + yield* Effect.logInfo( + `Waking ${instance.service} ${id}${trigger === undefined ? "" : ` (${trigger})`}`, + ); + yield* Effect.gen(function* () { + const plan = yield* snapshotPlan(id); + for (const member of plan.order) yield* (yield* node(member)).bind; + yield* startNode(id, true, false, plan).pipe( + Effect.tapError((cause) => + initiator + ? Effect.logError(`${instance.service} ${id} failed to wake`, cause) + : Effect.void, + ), + ); + }).pipe( + Effect.ensuring( + initiator + ? Ref.update(wakes, (ids) => { + const next = new Set(ids); + next.delete(id); + return next; + }) + : Effect.void, + ), + ); + if (!awaitReady && initiator) + yield* Effect.logInfo(`${instance.service} ${id} started (readiness not awaited)`); + } + if (awaitReady) { + yield* instance.core.ready.pipe( + Effect.tapError((cause) => + wake && initiator + ? Effect.logError(`${instance.service} ${id} failed to become ready`, cause) + : Effect.void, + ), + ); + if (wake && initiator) yield* Effect.logInfo(`${instance.service} ${id} is ready`); } - if (awaitReady) yield* instance.core.ready; }), ), }; return orchestrator; }); - -export const layer = Layer.effect(Service, makeOrchestrator.pipe(Effect.map(Service.of))); diff --git a/packages/stack/src/Owner.integration.test.ts b/packages/stack/src/Owner.integration.test.ts index 3d77a54788..029119d629 100644 --- a/packages/stack/src/Owner.integration.test.ts +++ b/packages/stack/src/Owner.integration.test.ts @@ -1,13 +1,17 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { PgClient } from "@effect/sql-pg"; import { expect, it } from "@effect/vitest"; -import { Context, Effect, FileSystem, Layer, Path, Redacted, Ref } from "effect"; +import { Context, Effect, FileSystem, Layer, Redacted, Ref, Schema } from "effect"; import { HttpClient } from "effect/unstable/http"; import { tmpdir } from "node:os"; +import { RpcTest } from "effect/unstable/rpc"; import * as Owner from "./Owner.ts"; +import { OwnerRpc } from "./Rpc.ts"; import * as State from "./State.ts"; import type { SavedStack } from "./State.ts"; import { DEFAULT_LOCAL_JWT_SECRET } from "./Defaults.ts"; +import { ServiceCreation, type ServiceCreationInput } from "./services/Catalog.ts"; +import { ownerFor } from "../tests/owner-rpc.ts"; const stateFor = (root: string) => Effect.gen(function* () { @@ -15,21 +19,6 @@ const stateFor = (root: string) => return Context.get(context, State.Service); }); -const ownerFor = (options: { - readonly saved: SavedStack; - readonly state: State.Interface; - readonly root: string; - readonly cacheRoot: string; -}) => { - const { state, ...layerOptions } = options; - return Effect.gen(function* () { - const context = yield* Layer.build( - Owner.layer(layerOptions).pipe(Layer.provide(Layer.succeed(State.Service, state))), - ); - return Context.get(context, Owner.Service); - }); -}; - const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const initial = (id: string): SavedStack => ({ @@ -37,6 +26,7 @@ const initial = (id: string): SavedStack => ({ identity: { projectRoot: "/tmp/project", branchContext: "owner-test", stackName: id }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -58,6 +48,42 @@ const query = (url: string, statement: string) => }), ); +it.effect("credential lookup leaves a fresh stack untouched for custom database credentials", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-credentials-" }); + const stack = initial("owner-credentials"); + const state = yield* stateFor(`${root}/state`); + yield* state.save(stack); + const owner = yield* ownerFor({ + saved: stack, + state, + root: `${root}/data`, + cacheRoot, + }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + + const failure = yield* owner.getStackCredentials.pipe(Effect.flip); + expect(failure.message).toContain("have not been established"); + expect((yield* state.read(stack.id))?.credentials).toBeUndefined(); + + const jwtSecret = "custom-owner-credentials-jwt-secret-long-enough"; + yield* owner.rpc.createService({ + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("owner-credentials-database-password"), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }); + expect((yield* state.read(stack.id))?.credentials?.jwtSecret).toBe(jwtSecret); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + it.live("forwards and rotates saved identity across composed services in one owner", () => Effect.scoped( Effect.gen(function* () { @@ -74,7 +100,7 @@ it.live("forwards and rotates saved identity across composed services in one own }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); - const servicesFor = (jwtSecret?: string) => [ + const servicesFor = (jwtSecret?: string): ReadonlyArray<ServiceCreationInput> => [ { service: "database" as const, config: { @@ -87,22 +113,22 @@ it.live("forwards and rotates saved identity across composed services in one own }, { service: "rest" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }, { service: "auth" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }, { service: "storage" as const, - config: { databaseUrl: "postgresql://placeholder", filePath: `${root}/uploads` }, + config: { filePath: `${root}/uploads` }, endpoints: { http: { port: "auto" as const } }, }, { service: "realtime" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }, { @@ -116,20 +142,21 @@ it.live("forwards and rotates saved identity across composed services in one own endpoints: { http: { port: "auto" as const } }, }, ]; - const identity = (suffix: string, keys: string): State.StackIdentityInput => ({ + const stackKeys = (suffix: string, gotrueJwtKeys: string): State.StackKeysInput => ({ publishableKey: `publishable-${suffix}`, secretKey: `secret-${suffix}`, anonKey: `anon-${suffix}`, serviceRoleKey: `service-role-${suffix}`, - gotrueJwtKeys: keys, + gotrueJwtKeys, publicSigningKeys: "[]", anonKeyIsOverride: true, serviceRoleKeyIsOverride: true, }); const customJwtSecret = "custom-owner-rotation-jwt-secret-long-enough"; const services = servicesFor(customJwtSecret); - const first = yield* owner.composition.supabase(services, { - identity: identity("one", "[]"), + const first = yield* owner.rpc.supabaseComposition({ + services: services, + keys: stackKeys("one", "[]"), }); const ids = first.map(({ id }) => id); const creationFor = (entries: typeof first, service: string) => @@ -139,6 +166,7 @@ it.live("forwards and rotates saved identity across composed services in one own .pipe(Effect.map((saved) => saved?.credentials)); if (firstCredentials === undefined) return yield* Effect.die("identity was not persisted"); expect(firstCredentials.jwtSecret).toBe(customJwtSecret); + expect(yield* owner.getStackCredentials).toEqual(firstCredentials); const firstRest = creationFor(first, "rest"); const firstStorage = creationFor(first, "storage"); const firstRealtime = creationFor(first, "realtime"); @@ -176,27 +204,27 @@ it.live("forwards and rotates saved identity across composed services in one own }); expect(firstAuth.config.gotrueJwtKeys).toBe(firstCredentials.gotrueJwtKeys); - const standaloneRest = yield* owner.services.create({ + const standaloneRest = yield* owner.rpc.createService({ service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: {}, }); expect(standaloneRest.creation.config).toMatchObject({ jwtSecret: customJwtSecret, jwks: firstCredentials.jwks, }); - const standaloneAuth = yield* owner.services.create({ + const standaloneAuth = yield* owner.rpc.createService({ service: "auth", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: {}, }); expect(standaloneAuth.creation.config).toMatchObject({ jwtSecret: customJwtSecret, gotrueJwtKeys: firstCredentials.gotrueJwtKeys, }); - const standaloneStorage = yield* owner.services.create({ + const standaloneStorage = yield* owner.rpc.createService({ service: "storage", - config: { databaseUrl: "postgresql://placeholder", filePath: `${root}/standalone-uploads` }, + config: { filePath: `${root}/standalone-uploads` }, endpoints: {}, }); expect(standaloneStorage.creation.config).toMatchObject({ @@ -205,7 +233,7 @@ it.live("forwards and rotates saved identity across composed services in one own anonKey: firstCredentials.anonKey, serviceRoleKey: firstCredentials.serviceRoleKey, }); - const standaloneFunctions = yield* owner.services.create({ + const standaloneFunctions = yield* owner.rpc.createService({ service: "functions", config: { functionsRoot: `${root}/standalone-functions`, @@ -221,13 +249,14 @@ it.live("forwards and rotates saved identity across composed services in one own serviceRoleKey: firstCredentials.serviceRoleKey, }); - yield* owner.composition.stop; + yield* owner.rpc.stopComposition(); const secondServices = servicesFor().filter((creation) => creation.service !== "studio"); const studioId = first.find(({ creation }) => creation.service === "studio")?.id; if (studioId === undefined) return yield* Effect.die("Studio ID is missing"); const retainedIds = ids.filter((id) => id !== studioId); - const second = yield* owner.composition.supabase(secondServices, { - identity: identity("two", "[{}]"), + const second = yield* owner.rpc.supabaseComposition({ + services: secondServices, + keys: stackKeys("two", "[{}]"), reuseIds: retainedIds, }); const secondCredentials = yield* state @@ -258,16 +287,18 @@ it.live("forwards and rotates saved identity across composed services in one own gotrueJwtKeys: secondCredentials.gotrueJwtKeys, }); expect(second.some(({ id }) => id === studioId)).toBe(false); - expect((yield* owner.core.get(studioId)).lifecycle).toBe("stopped"); - const excludedStudio = yield* owner.services.get(studioId); - expect(excludedStudio.creation.service).toBe("studio"); - if (excludedStudio.creation.service === "studio") - expect(excludedStudio.creation.config.anonKey).toBe("anon-one"); - for (const id of retainedIds) expect((yield* owner.core.get(id)).lifecycle).toBe("stopped"); - - yield* owner.composition.stop; - const third = yield* owner.composition.supabase(servicesFor(customJwtSecret), { - identity: identity("three", "[]"), + expect((yield* owner.rpc.status({ id: studioId })).lifecycle).toBe("stopped"); + const excludedStudio = (yield* owner.rpc.status({ id: studioId })).config; + expect(excludedStudio.service).toBe("studio"); + if (excludedStudio.service === "studio") + expect(excludedStudio.config.anonKey).toBe("anon-one"); + for (const id of retainedIds) + expect((yield* owner.rpc.status({ id: id })).lifecycle).toBe("stopped"); + + yield* owner.rpc.stopComposition(); + const third = yield* owner.rpc.supabaseComposition({ + services: servicesFor(customJwtSecret), + keys: stackKeys("three", "[]"), reuseIds: ids, }); const thirdCredentials = yield* state @@ -279,8 +310,11 @@ it.live("forwards and rotates saved identity across composed services in one own publishableKey: "publishable-three", }); - const credentialConflict = yield* owner.composition - .supabase(servicesFor("different-owner-jwt-secret-long-enough"), { reuseIds: ids }) + const credentialConflict = yield* owner.rpc + .supabaseComposition({ + services: servicesFor("different-owner-jwt-secret-long-enough"), + reuseIds: ids, + }) .pipe(Effect.flip); expect(credentialConflict.message).toContain("Credential override jwtSecret conflicts"); expect( @@ -290,32 +324,6 @@ it.live("forwards and rotates saved identity across composed services in one own ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); -it.effect("rejects a malformed persisted composition", () => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-malformed-" }); - const state = yield* stateFor(`${root}/state`); - const saved = initial("owner-malformed"); - yield* state.save(saved); - const malformed = - '{"id":"owner-malformed","identity":{"projectRoot":"/tmp/project","branchContext":"owner-test","stackName":"owner-malformed"},"runtime":"native","instances":[],"composition":{"members":"invalid","dependencies":[]},"ports":[]}'; - yield* fs.writeFileString(path.join(root, "state", saved.id, "state.json"), malformed); - const reopened = yield* state.read(saved.id); - if (reopened === undefined) return yield* Effect.die("saved state disappeared"); - const failure = yield* ownerFor({ - saved: reopened, - state, - root: `${root}/state/${saved.id}/data`, - cacheRoot, - }).pipe(Effect.flip); - expect(failure).toBeInstanceOf(Owner.OwnerError); - expect(failure.operation).toBe("configure"); - }), - ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), -); - it.effect("publishes service removal and composition pruning together", () => Effect.scoped( Effect.gen(function* () { @@ -347,17 +355,17 @@ it.effect("publishes service removal and composition pruning together", () => cacheRoot, }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); - const service = yield* owner.services.create({ + const service = yield* owner.rpc.createService({ service: "mail", config: {}, endpoints: { http: { port: "auto" }, smtp: { port: "auto" }, pop3: { port: "auto" } }, }); - yield* owner.composition.configure({ + yield* owner.rpc.configureComposition({ members: [{ id: service.id, activation: "eager" }], dependencies: [], }); - yield* owner.core.destroy(service.id); + yield* owner.rpc.destroyService({ id: service.id }); const saved = yield* state.read(stack.id); if (saved === undefined) return yield* Effect.die("saved state disappeared"); @@ -385,7 +393,7 @@ it.live( cacheRoot, }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); - const database = yield* owner.services.create({ + const database = yield* owner.rpc.createService({ service: "database", config: { version: "17", @@ -395,12 +403,12 @@ it.live( }, endpoints: { sql: { port: "auto" } }, }); - const rest = yield* owner.services.create({ + const rest = yield* owner.rpc.createService({ service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" } }, }); - const shadow = yield* owner.services.create({ + const shadow = yield* owner.rpc.createService({ service: "database", config: { version: "17", @@ -410,7 +418,7 @@ it.live( }, endpoints: { sql: { port: "auto" } }, }); - yield* owner.composition.configure({ + yield* owner.rpc.configureComposition({ members: [ { id: database.id, activation: "eager" }, { id: rest.id, activation: "lazy", idleMillis: 30_000 }, @@ -423,18 +431,18 @@ it.live( }, ], }); - yield* owner.core.start(shadow.id); - yield* owner.core.ready(shadow.id); - yield* owner.composition.start; - const dbCredentials = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startService({ id: shadow.id }); + yield* owner.rpc.readyService({ id: shadow.id }); + yield* owner.rpc.startComposition(); + const dbCredentials = yield* owner.rpc.credentials({ id: database.id, from: "host" }); const databaseUrl = dbCredentials.databaseUrl; if (databaseUrl === undefined) return yield* Effect.die("database credentials missing"); yield* query(databaseUrl, "CREATE TABLE owner_rows(value text NOT NULL)"); yield* query(databaseUrl, "INSERT INTO owner_rows(value) VALUES ('wake')"); - const restCredentials = yield* owner.credentials(rest.id, "host"); + const restCredentials = yield* owner.rpc.credentials({ id: rest.id, from: "host" }); const restUrl = restCredentials.url; if (restUrl === undefined) return yield* Effect.die("REST credentials missing"); - const restObservation = yield* owner.core.get(rest.id); + const restObservation = yield* owner.rpc.status({ id: rest.id }); const restEndpoint = restObservation.endpoints.find((endpoint) => endpoint.name === "http"); expect(restEndpoint?.host).toBe("127.0.0.1"); expect(restEndpoint?.port).toBe(Number(new URL(restUrl).port)); @@ -443,14 +451,14 @@ it.live( expect(response.status).toBe(200); const body = yield* response.json; expect(body).toEqual([{ value: "wake" }]); - expect((yield* owner.core.get(shadow.id)).lifecycle).toBe("running"); - yield* owner.composition.stop; - expect((yield* owner.core.get(shadow.id)).lifecycle).toBe("running"); + expect((yield* owner.rpc.status({ id: shadow.id })).lifecycle).toBe("running"); + yield* owner.rpc.stopComposition(); + expect((yield* owner.rpc.status({ id: shadow.id })).lifecycle).toBe("running"); const firstPort = new URL(databaseUrl).port; - yield* owner.core.stop(database.id); - yield* owner.core.start(database.id); - yield* owner.core.ready(database.id); - const reopened = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.stopService({ id: database.id }); + yield* owner.rpc.startService({ id: database.id }); + yield* owner.rpc.readyService({ id: database.id }); + const reopened = yield* owner.rpc.credentials({ id: database.id, from: "host" }); if (reopened.databaseUrl === undefined) return yield* Effect.die("reopened credentials missing"); expect(new URL(reopened.databaseUrl).port).toBe(firstPort); @@ -462,8 +470,8 @@ it.live( cacheRoot, }); yield* Effect.addFinalizer(() => reopenedOwner.namespace.destroy.pipe(Effect.ignore)); - expect((yield* reopenedOwner.services.list).length).toBe(3); - expect((yield* reopenedOwner.core.get(database.id)).lifecycle).toBe("stopped"); + expect((yield* state.read(stack.id))?.instances).toHaveLength(3); + expect((yield* reopenedOwner.rpc.status({ id: database.id })).lifecycle).toBe("stopped"); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 180_000 }, @@ -490,23 +498,31 @@ it.effect("isolates owner graphs built in one scope", () => }).pipe(Layer.provide(Layer.succeed(State.Service, state))), memoMap, scope, - ).pipe(Effect.map((context) => Context.get(context, Owner.Service))); + ).pipe( + Effect.map((context) => Context.get(context, Owner.Service)), + Effect.flatMap((owner) => + RpcTest.makeClient(OwnerRpc).pipe( + Effect.provide(OwnerRpc.toLayer(owner.handlers)), + Effect.map((rpc) => ({ rpc, namespace: owner.namespace })), + ), + ), + ); const firstOwner = yield* buildOwner(first); const secondOwner = yield* buildOwner(second); yield* Effect.addFinalizer(() => firstOwner.namespace.destroy.pipe(Effect.ignore)); yield* Effect.addFinalizer(() => secondOwner.namespace.destroy.pipe(Effect.ignore)); - const created = yield* firstOwner.services.create({ + const created = yield* firstOwner.rpc.createService({ service: "mail", config: {}, endpoints: { http: { port: "auto" }, smtp: { port: "auto" }, pop3: { port: "auto" } }, }); - const isolated = yield* secondOwner.composition - .configure({ + const isolated = yield* secondOwner.rpc + .configureComposition({ members: [{ id: created.id, activation: "eager" }], dependencies: [], }) .pipe(Effect.flip); - expect(isolated.operation).toBe("configure"); + expect(isolated.operation).toBe("configureComposition"); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -528,28 +544,30 @@ it.live( cacheRoot, }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); - const created = yield* owner.composition.supabase([ - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("owner-factory-password"), - jwtSecret: Redacted.make("owner-factory-jwt-secret-long-enough"), - jwtExpiry: 3600, + const created = yield* owner.rpc.supabaseComposition({ + services: [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("owner-factory-password"), + jwtSecret: Redacted.make("owner-factory-jwt-secret-long-enough"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, }, - endpoints: { sql: { port: "auto" } }, - }, - { - service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: { port: "auto" } }, - }, - { - service: "mail", - config: {}, - endpoints: { http: { port: "auto" }, smtp: { port: "auto" } }, - }, - ]); + { + service: "rest", + config: {}, + endpoints: { http: { port: "auto" } }, + }, + { + service: "mail", + config: {}, + endpoints: { http: { port: "auto" }, smtp: { port: "auto" } }, + }, + ], + }); const database = created.find((entry) => entry.creation.service === "database"); const rest = created.find((entry) => entry.creation.service === "rest"); const mail = created.find((entry) => entry.creation.service === "mail"); @@ -557,16 +575,16 @@ it.live( return yield* Effect.die("factory did not create all requested services"); if (rest.creation.service !== "rest") return yield* Effect.die("REST service missing"); expect(rest.creation.config.databaseUrl).toContain("authenticator:"); - yield* owner.composition.start; - const databaseCredentials = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startComposition(); + const databaseCredentials = yield* owner.rpc.credentials({ id: database.id, from: "host" }); const databaseUrl = databaseCredentials.databaseUrl; if (databaseUrl === undefined) return yield* Effect.die("database URL missing"); yield* query(databaseUrl, "CREATE TABLE factory_rows(value text NOT NULL)"); yield* query(databaseUrl, "INSERT INTO factory_rows VALUES ('factory-row')"); - const restCredentials = yield* owner.credentials(rest.id, "host"); + const restCredentials = yield* owner.rpc.credentials({ id: rest.id, from: "host" }); const restUrl = restCredentials.url; if (restUrl === undefined) return yield* Effect.die("REST URL missing"); - const mailCredentials = yield* owner.credentials(mail.id, "host"); + const mailCredentials = yield* owner.rpc.credentials({ id: mail.id, from: "host" }); expect(mailCredentials.smtpUrl).toMatch(/^smtp:\/\//u); const client = yield* HttpClient.HttpClient; const response = yield* client.get(`${restUrl}/factory_rows`); @@ -602,25 +620,29 @@ it.effect("validates Supabase composition recipes before creating instances", () }, endpoints: { sql: { port: "auto" as const } }, }; - const duplicate = yield* owner.composition.supabase([database, database]).pipe(Effect.flip); - expect(duplicate.operation).toBe("supabase"); - expect(yield* owner.services.list).toHaveLength(0); - const conflicting = yield* owner.composition - .supabase([ - { - service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: { port: 41_001 } }, - }, - { - service: "auth", - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: { port: 41_002 } }, - }, - ]) + const duplicate = yield* owner.rpc + .supabaseComposition({ services: [database, database] }) .pipe(Effect.flip); - expect(conflicting.operation).toBe("supabase"); - expect(yield* owner.services.list).toHaveLength(0); + expect(duplicate.operation).toBe("supabaseComposition"); + expect((yield* state.read(stack.id))?.instances).toHaveLength(0); + const conflicting = yield* owner.rpc + .supabaseComposition({ + services: [ + { + service: "rest", + config: {}, + endpoints: { http: { port: 41_001 } }, + }, + { + service: "auth", + config: {}, + endpoints: { http: { port: 41_002 } }, + }, + ], + }) + .pipe(Effect.flip); + expect(conflicting.operation).toBe("supabaseComposition"); + expect((yield* state.read(stack.id))?.instances).toHaveLength(0); const badDataRoot = `${root}/data-file`; const badOwner = yield* ownerFor({ @@ -633,29 +655,171 @@ it.effect("validates Supabase composition recipes before creating instances", () yield* fs.makeDirectory(badDataRoot); yield* fs.remove(badDataRoot, { recursive: true }); yield* fs.writeFileString(badDataRoot, "occupied"); - const registrationFailure = yield* badOwner.services.create(database).pipe(Effect.flip); - expect(registrationFailure.operation).toBe("register"); - expect(yield* badOwner.services.list).toHaveLength(0); + const registrationFailure = yield* badOwner.rpc.createService(database).pipe(Effect.flip); + expect(registrationFailure.operation).toBe("createService"); expect((yield* state.read(stack.id))?.instances).toHaveLength(0); - const databaseWithoutSql = yield* owner.services.create({ + const databaseWithoutSql = yield* owner.rpc.createService({ service: "database", config: database.config, endpoints: {}, }); - expect(yield* owner.credentials(databaseWithoutSql.id, "host")).toEqual({}); + expect(yield* owner.rpc.credentials({ id: databaseWithoutSql.id, from: "host" })).toEqual({}); - const missingSql = yield* owner.composition - .supabase([ - { ...database, endpoints: {} }, - { - service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, - endpoints: { http: { port: "auto" } }, - }, - ]) + const missingSql = yield* owner.rpc + .supabaseComposition({ + services: [ + { ...database, endpoints: {} }, + { + service: "rest", + config: {}, + endpoints: { http: { port: "auto" } }, + }, + ], + }) .pipe(Effect.flip); expect(missingSql.message).toBe("database requires configured sql endpoint"); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + +it.effect("lets a retry choose other credentials after the first database creation failed", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-owner-credential-rollback-", + }); + const stack = initial("owner-credential-rollback"); + const state = yield* stateFor(`${root}/state`); + yield* state.save(stack); + const database = (password: string) => ({ + service: "database" as const, + config: { version: "17", databasePassword: Redacted.make(password), jwtExpiry: 3600 }, + endpoints: {}, + }); + const badDataRoot = `${root}/data-file`; + yield* fs.writeFileString(badDataRoot, "occupied"); + const failing = yield* ownerFor({ saved: stack, state, root: badDataRoot, cacheRoot }); + yield* Effect.addFinalizer(() => failing.namespace.destroy.pipe(Effect.ignore)); + + yield* failing.rpc.createService(database("first-password")).pipe(Effect.flip); + + expect((yield* state.read(stack.id))?.credentials).toBeUndefined(); + const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + yield* owner.rpc.createService(database("second-password")); + expect((yield* state.read(stack.id))?.credentials?.databasePassword).toBe("second-password"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.effect("rejects a duplicate instance without releasing the existing instance's claims", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-duplicate-" }); + const state = yield* stateFor(`${root}/state`); + const creation = yield* Schema.decodeEffect(ServiceCreation)({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }); + const instance = { id: "mail", creation }; + const claim = { key: "mail:http", host: "127.0.0.1", port: 54_321 }; + const stack: SavedStack = { + ...initial("owner-duplicate"), + instances: [instance], + ports: [claim], + }; + yield* state.save(stack); + + const failure = yield* ownerFor({ + saved: { ...stack, instances: [instance, instance] }, + state, + root: `${root}/data`, + cacheRoot, + }).pipe(Effect.flip); + + expect(failure.message).toBe("Duplicate instance mail"); + expect((yield* state.read(stack.id))?.ports).toEqual([claim]); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.effect("refuses to generate credentials for a stack whose saved instances consume them", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-credentials-" }); + const stack = initial("owner-credentials"); + const state = yield* stateFor(`${root}/state`); + yield* state.save(stack); + const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + yield* owner.rpc.createService({ + service: "database", + config: { version: "17", jwtExpiry: 3600 }, + endpoints: {}, + }); + const withCredentials = yield* state.read(stack.id); + if (withCredentials?.credentials === undefined) + return yield* Effect.die("database creation did not save credentials"); + const { credentials: _, ...withoutCredentials } = withCredentials; + yield* state.save(withoutCredentials); + + const refused = yield* owner.rpc + .createService({ service: "auth", config: {} }) + .pipe(Effect.flip); + + expect(refused.message).toBe( + "Saved instances have no stack credential record; refusing to infer credentials", + ); + const current = yield* state.read(stack.id); + expect(current?.credentials).toBeUndefined(); + expect(current?.instances).toHaveLength(1); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("rejects a missing required input before starting or stopping the service", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-missing-input-" }); + const stack = initial("owner-missing-input"); + const state = yield* stateFor(`${root}/state`); + yield* state.save(stack); + const owner = yield* ownerFor({ saved: stack, state, root: `${root}/data`, cacheRoot }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + const unbound = yield* owner.rpc.createService({ + service: "rest", + config: {}, + endpoints: {}, + }); + + const startFailure = yield* owner.rpc.startService({ id: unbound.id }).pipe(Effect.flip); + + expect(startFailure.message).toContain("rest requires input databaseUrl"); + expect((yield* owner.rpc.status({ id: unbound.id })).lifecycle).toBe("stopped"); + + const provided = yield* owner.rpc.createService({ + service: "rest", + config: { databaseUrl: "postgresql://authenticator@127.0.0.1:1/postgres" }, + endpoints: {}, + }); + yield* owner.rpc.startService({ id: provided.id }); + const restartFailure = yield* owner.rpc + .restartService({ id: provided.id, config: { service: "rest", config: { maxRows: 5 } } }) + .pipe(Effect.flip); + + expect(restartFailure.message).toContain("rest requires input databaseUrl"); + const kept = yield* owner.rpc.status({ id: provided.id }); + expect(kept.lifecycle).toBe("running"); + expect(kept.config).toMatchObject({ + config: { databaseUrl: "postgresql://authenticator@127.0.0.1:1/postgres" }, + }); + yield* owner.rpc.stopService({ id: provided.id }); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/packages/stack/src/Owner.ts b/packages/stack/src/Owner.ts index c61728f889..b36d9fcfa1 100644 --- a/packages/stack/src/Owner.ts +++ b/packages/stack/src/Owner.ts @@ -1,1435 +1,718 @@ import { Context, Crypto, - Data, Effect, + Fiber, FileSystem, + Layer, + Option, Path, - Redacted, Ref, - Scope, Schema, + Scope, Semaphore, Stream, - Layer, } from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; +import type { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { failureMessage } from "./internal/failure-message.ts"; import * as Network from "./Network.ts"; -import * as Container from "./runtime/Container.ts"; -import type { NetworkBinding, NetworkNamespace } from "./Network.ts"; +import type { NetworkEndpoint, NetworkNamespace } from "./Network.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { CompositionConfig, RegisteredInstance } from "./Orchestrator.ts"; +import type { CompositionConfig } from "./Orchestrator.ts"; import { makeService, ServiceError, - ServiceLaunchError, + type ServiceAdmission, type ServiceInstance, + type ServiceInstanceContext, type ServiceObservation, } from "./Service.ts"; -import { ProxyError, type BackendAddress } from "./Proxy.ts"; -import type { NetworkEndpoint } from "./Network.ts"; +import { ProxyError } from "./Proxy.ts"; import { - apiRoute, + backendAddress, credentialsFor, endpointNames, endpointPort, - EndpointError, + joinRoutes, outputsFor, publicUrl, + sharedRoutes, } from "./host/Endpoints.ts"; +import { + consumesCredentials, + CredentialError, + credentialOverrides, + nextCredentials, + withCredentials, + withoutUnusedCredentials, +} from "./host/Credentials.ts"; import { makeSupabaseComposition, - SupabaseCompositionError, type SupabaseCompositionOptions, } from "./composition/Supabase.ts"; import { makeServiceRecipe, + requireInputs, ServiceCreation, - ServiceCreationInput, - type CatalogLog, - type CatalogRecipe, serviceSchemas, + type CatalogRecipe, + type ServiceCreationInput, } from "./services/Catalog.ts"; +import type { CatalogError } from "./services/Recipe.ts"; +import * as Container from "./runtime/Container.ts"; +import { projectSegmentFor } from "./identity/Identity.ts"; +import { stackError, type OwnerRpc } from "./Rpc.ts"; import * as State from "./State.ts"; -import type { SavedInstance, SavedStack, StackCredentials } from "./State.ts"; -import { resolveStackIdentity } from "./services/ServiceConfig.ts"; -import { - DEFAULT_LOCAL_DATABASE_PASSWORD, - DEFAULT_LOCAL_JWT_SECRET, - DEFAULT_POSTGRES_ROOT_KEY, -} from "./Defaults.ts"; +import type { SavedStack, StackCredentials, StackKeysInput } from "./State.ts"; import { makeDockerHelperRegistry } from "./storage/DockerHelperRegistry.ts"; -export class OwnerError extends Data.TaggedError("OwnerError")<{ - readonly operation: string; - readonly message: string; - readonly cause?: unknown; -}> {} - export interface OwnerOptions { readonly saved: SavedStack; readonly state: State.Interface; readonly root: string; readonly cacheRoot: string; + /** Shares one host-gateway probe with the host's other container runtimes. */ + readonly hostGateway?: Container.HostGateway; } -export class Service extends Context.Service<Service, Interface>()("@supabase/stack/Owner") {} +type OwnerRpcs = RpcGroup.Rpcs<typeof OwnerRpc>; -type OwnerObservation = ServiceObservation<ServiceCreation> & { - readonly endpoints: ReadonlyArray<NetworkBinding>; +/** RPC handlers for the owner's instance and composition operations. */ +type Handlers = { + readonly [Current in OwnerRpcs as Current["_tag"]]: ( + payload: Rpc.Payload<Current>, + ) => Rpc.ResultFrom<Current, never>; }; +/** Removes containers labeled with this stack and data root; native stacks own none. */ +export const sweepContainers = Effect.fn("Owner.sweepContainers")(function* ( + saved: Pick<SavedStack, "id" | "runtime">, + root: string, +) { + if (saved.runtime !== "native") + yield* Container.removeStackContainers({ engine: saved.runtime, stackId: saved.id, root }); +}); + +type NamespaceError = + | Orchestrator.OrchestratorError + | Orchestrator.LifecycleError + | Network.NetworkError + | State.StateError + | Effect.Error<ReturnType<typeof sweepContainers>>; + export interface Interface { - readonly services: { - readonly create: ( - creation: unknown, - ) => Effect.Effect<{ id: string; creation: ServiceCreation }, OwnerError>; - readonly get: ( - id: string, - ) => Effect.Effect<{ id: string; creation: ServiceCreation }, OwnerError>; - readonly list: Effect.Effect< - ReadonlyArray<{ id: string; creation: ServiceCreation }>, - OwnerError - >; - }; - readonly core: { - readonly get: (id: string) => Effect.Effect<OwnerObservation, OwnerError>; - readonly status: (id: string) => Effect.Effect<OwnerObservation, OwnerError>; - readonly followStatus: (id: string) => Stream.Stream<OwnerObservation, OwnerError>; - readonly prepare: (id: string) => Effect.Effect<void, OwnerError>; - readonly logs: (id: string) => Stream.Stream<CatalogLog, OwnerError>; - readonly start: (id: string) => Effect.Effect<void, OwnerError>; - readonly ready: (id: string) => Effect.Effect<void, OwnerError>; - readonly stop: (id: string) => Effect.Effect<void, OwnerError>; - readonly restart: (id: string, creation?: unknown) => Effect.Effect<void, OwnerError>; - readonly destroy: (id: string) => Effect.Effect<void, OwnerError>; - }; - readonly composition: { - readonly supabase: ( - creations: ReadonlyArray<ServiceCreationInput>, - options?: SupabaseCompositionOptions, - ) => Effect.Effect<ReadonlyArray<{ id: string; creation: ServiceCreation }>, OwnerError>; - readonly configure: (configuration: CompositionConfig) => Effect.Effect<void, OwnerError>; - readonly get: Effect.Effect<CompositionConfig, OwnerError>; - readonly start: Effect.Effect<ReadonlyArray<OwnerObservation>, OwnerError>; - readonly stop: Effect.Effect<ReadonlyArray<OwnerObservation>, OwnerError>; - readonly restart: Effect.Effect<ReadonlyArray<OwnerObservation>, OwnerError>; - }; - readonly credentials: ( - id: string, - from: "host" | "runtime", - ) => Effect.Effect<Readonly<Record<string, string>>, OwnerError>; - readonly snapshots: { - readonly saveSnapshot: (id: string, key: string) => Effect.Effect<void, OwnerError>; - readonly restoreSnapshot: (id: string, key: string) => Effect.Effect<boolean, OwnerError>; - readonly resetData: (id: string) => Effect.Effect<void, OwnerError>; - }; + readonly handlers: Handlers; + readonly getStackCredentials: Effect.Effect<StackCredentials, State.StateError | CredentialError>; readonly namespace: { - readonly stop: Effect.Effect<void, OwnerError>; - readonly destroy: Effect.Effect<void, OwnerError>; + /** Stops every instance after in-flight definition changes settle, then removes containers. */ + readonly stop: Effect.Effect<void, NamespaceError>; + /** + * Destroys every instance once in-flight definition changes settle, then releases owned + * containers, claims and saved state. + */ + readonly destroy: Effect.Effect<void, NamespaceError>; }; - readonly setDraining: (draining: boolean) => Effect.Effect<void, OwnerError>; - readonly getServing: Effect.Effect<boolean, OwnerError>; + readonly setDraining: (draining: boolean) => Effect.Effect<void>; + readonly getServing: Effect.Effect<boolean>; } -const errorFor = (operation: string, cause: unknown) => - new OwnerError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); +export class Service extends Context.Service<Service, Interface>()("@supabase/stack/Owner") {} -const supabaseError = (cause: unknown) => - cause instanceof SupabaseCompositionError - ? cause - : new SupabaseCompositionError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); +interface Entry extends Orchestrator.RegisteredInstance { + readonly core: ServiceInstance<ServiceCreation>; + readonly recipe: CatalogRecipe; + /** The saved creation, which composition wiring and launches update. */ + readonly creation: Ref.Ref<ServiceCreation>; + readonly namespace: NetworkNamespace; +} const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === "object" && value !== null && !Array.isArray(value); -const consumesCredentials = (creation: unknown): boolean => { - if (!isRecord(creation) || typeof creation.service !== "string") return true; - if (creation.service === "database") return true; - if (["auth", "realtime", "storage", "functions", "studio", "pooler"].includes(creation.service)) - return true; - if (creation.service !== "rest") return false; - const config = isRecord(creation.config) ? creation.config : {}; - return config.jwks === undefined || config.jwtSecret !== undefined; -}; - -const credentialOverridesFor = (creation: ServiceCreationInput): Record<string, string> => { - const overrides: Record<string, string> = {}; - if (creation.service === "database") { - if (creation.config.jwtSecret !== undefined) - overrides.jwtSecret = Redacted.value(creation.config.jwtSecret); - if (creation.config.rootKey !== undefined) - overrides.postgresRootKey = Redacted.value(creation.config.rootKey); - if (creation.config.databasePassword !== undefined) - overrides.databasePassword = Redacted.value(creation.config.databasePassword); - } else if ("jwtSecret" in creation.config && creation.config.jwtSecret !== undefined) { - overrides.jwtSecret = creation.config.jwtSecret; - } - return overrides; -}; - -const clearUnusedCredentials = (current: SavedStack): SavedStack => { - if ( - current.credentials === undefined || - current.instances.some(({ creation }) => consumesCredentials(creation)) - ) - return current; - const withoutCredentials = { ...current }; - delete withoutCredentials.credentials; - return withoutCredentials; -}; +const serviceError = + (operation: string) => + (cause: unknown): ServiceError => + new ServiceError({ operation, message: failureMessage(cause), cause }); -const creationJson = Schema.toCodecJson(ServiceCreation); -const encodeCreation = (creation: ServiceCreation) => Schema.encodeEffect(creationJson)(creation); +const rpcError = (operation: string) => + Effect.mapError((cause: unknown) => stackError(operation, cause)); -const makeOwnerWithDependencies = ( - options: OwnerOptions, - orchestrator: Orchestrator.Interface, - network: Network.Interface, -) => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const http = yield* HttpClient.HttpClient; - const ownerScope = yield* Scope.Scope; - const helperOwnerId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("identity", cause)), - ); - const helpers = yield* makeDockerHelperRegistry(helperOwnerId); - const recipes = yield* Ref.make(new Map<string, CatalogRecipe>()); - const instances = yield* Ref.make(new Map<string, ServiceInstance<ServiceCreation>>()); - const namespaces = yield* Ref.make(new Map<string, NetworkNamespace>()); - const composition = yield* Ref.make<CompositionConfig>({ - members: [], - dependencies: [], - }); - const registryGate = yield* Semaphore.make(1); - const compositionGate = yield* Semaphore.make(1); - const draining = yield* Ref.make(false); - const runtime = options.saved.runtime; +const mergeInputs = (creation: ServiceCreation, inputs: Record<string, string | undefined>) => + Schema.decodeUnknownEffect(ServiceCreation)({ + ...creation, + config: Object.fromEntries( + Object.entries({ ...creation.config, ...inputs }).filter(([, value]) => value !== undefined), + ), + }).pipe(Effect.mapError(serviceError("inputs"))); - const updateState = Effect.fn("Owner.updateState")(function* ( - update: (current: SavedStack) => Effect.Effect<SavedStack, OwnerError>, - ) { - return yield* options.state - .withLock( - Effect.gen(function* () { - const current = yield* options.state - .read(options.saved.id) - .pipe(Effect.mapError((cause) => errorFor("state", cause))); - if (current === undefined) return yield* errorFor("state", "Saved stack is missing"); - const next = yield* update(current); - yield* options.state - .save(next) - .pipe(Effect.mapError((cause) => errorFor("state", cause))); - return next; - }), - ) - .pipe( - Effect.mapError((cause) => - cause instanceof OwnerError ? cause : errorFor("state", cause), - ), - ); - }); +const restartCreation = (creation: ServiceCreation, candidate: unknown) => + candidate === undefined + ? Effect.succeed(creation) + : Schema.decodeUnknownEffect(ServiceCreation)({ + ...creation, + config: isRecord(candidate) && "config" in candidate ? candidate.config : candidate, + }).pipe(Effect.mapError(serviceError("restart"))); + +const withoutInstance = (current: SavedStack, id: string): SavedStack => + withoutUnusedCredentials({ + ...current, + instances: current.instances.filter((instance) => instance.id !== id), + composition: { + members: current.composition.members.filter((member) => member.id !== id), + dependencies: current.composition.dependencies.filter( + (dependency) => dependency.from !== id && dependency.to !== id, + ), + }, + }); - const persistCreation = Effect.fn("Owner.persistCreation")(function* ( - id: string, - creation: ServiceCreation, - ) { - const encoded = yield* encodeCreation(creation).pipe( - Effect.mapError((cause) => errorFor("state", cause)), - ); - yield* updateState((current) => - Effect.succeed({ - ...current, - instances: current.instances.map((instance) => - instance.id === id ? { ...instance, creation: encoded } : instance, - ), - }), - ); - yield* Ref.update(recipes, (values) => { - const recipe = values.get(id); - if (recipe === undefined) return values; - return new Map(values).set(id, { ...recipe, creation }); - }); - }); +const drainingBlocks: ReadonlyArray<ServiceAdmission> = ["start", "arm", "restart", "storage"]; + +const makeOwner = Effect.fn("Owner.make")(function* (options: OwnerOptions) { + const services = yield* Effect.context< + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner + | HttpClient.HttpClient + | Scope.Scope + >(); + const ownerScope = Context.get(services, Scope.Scope); + const crypto = Context.get(services, Crypto.Crypto); + const path = Context.get(services, Path.Path); + const network = yield* Network.Service; + const orchestrator = yield* Orchestrator.make<Entry>(); + const helpers = yield* makeDockerHelperRegistry(yield* crypto.randomUUIDv4); + const definitionGate = yield* Semaphore.make(1); + const draining = yield* Ref.make(false); + const { id: stackId, runtime } = options.saved; + const project = projectSegmentFor(options.saved.identity, path); + const routeKeys = { + publishableKey: options.saved.credentials?.publishableKey ?? "", + secretKey: options.saved.credentials?.secretKey ?? "", + anonKey: options.saved.credentials?.anonKey ?? "", + serviceRoleKey: options.saved.credentials?.serviceRoleKey ?? "", + }; - const addCreation = Effect.fn("Owner.addCreation")(function* ( - id: string, - creation: ServiceCreation, - ) { - const encoded = yield* encodeCreation(creation).pipe( - Effect.mapError((cause) => errorFor("state", cause)), - ); - yield* updateState((current) => - Effect.succeed({ - ...current, - instances: [...current.instances, { id, creation: encoded } satisfies SavedInstance], - }), - ); - }); + const rejectWhileDraining = Ref.get(draining).pipe( + Effect.flatMap((isDraining) => + isDraining + ? Effect.fail(new ServiceError({ operation: "draining", message: "Owner is draining" })) + : Effect.void, + ), + ); - const routeKeys = { - publishableKey: options.saved.credentials?.publishableKey ?? "", - secretKey: options.saved.credentials?.secretKey ?? "", - anonKey: options.saved.credentials?.anonKey ?? "", - serviceRoleKey: options.saved.credentials?.serviceRoleKey ?? "", - }; - const resolveStackCredentials = Effect.fn("Owner.resolveStackCredentials")(function* ( - overrides: Record<string, string>, - identity?: State.StackIdentityInput, - ) { - const credentials = yield* options.state - .withLock( - Effect.gen(function* () { - const current = yield* options.state - .read(options.saved.id) - .pipe(Effect.mapError((cause) => errorFor("credentials", cause))); - if (current === undefined) - return yield* errorFor("credentials", "Saved stack is missing"); - const saved = current.credentials; - if (saved === undefined) { - const hasCredentialConsumer = current.instances.some(({ creation: value }) => - consumesCredentials(value), - ); - if (hasCredentialConsumer) - return yield* errorFor( - "credentials", - "Saved instances have no stack credential record; refusing to infer credentials", - ); - const jwtSecret = overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET; - const resolvedIdentity = yield* resolveStackIdentity(jwtSecret, identity, undefined); - const initial: StackCredentials = { - jwtSecret, - postgresRootKey: overrides.postgresRootKey ?? DEFAULT_POSTGRES_ROOT_KEY, - databasePassword: overrides.databasePassword ?? DEFAULT_LOCAL_DATABASE_PASSWORD, - ...resolvedIdentity, - }; - yield* options.state - .save({ ...current, credentials: initial }) - .pipe(Effect.mapError((cause) => errorFor("credentials", cause))); - return initial; - } - const conflict = - (overrides.postgresRootKey !== undefined && - overrides.postgresRootKey !== saved.postgresRootKey && - "rootKey") || - (overrides.databasePassword !== undefined && - overrides.databasePassword !== saved.databasePassword && - "databasePassword") || - (identity === undefined && - overrides.jwtSecret !== undefined && - overrides.jwtSecret !== saved.jwtSecret && - "jwtSecret"); - if (conflict !== false && conflict !== undefined) - return yield* errorFor( - "credentials", - `Credential override ${conflict} conflicts with the saved stack value`, - ); - const jwtSecret = - identity === undefined - ? saved.jwtSecret - : (overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET); - const resolvedIdentity = yield* resolveStackIdentity(jwtSecret, identity, saved); - const next: StackCredentials = { - ...saved, - jwtSecret, - ...resolvedIdentity, - }; - const identityChanged = - next.jwtSecret !== saved.jwtSecret || - next.publishableKey !== saved.publishableKey || - next.secretKey !== saved.secretKey || - next.anonKey !== saved.anonKey || - next.serviceRoleKey !== saved.serviceRoleKey || - next.jwks !== saved.jwks || - next.gotrueJwtKeys !== saved.gotrueJwtKeys || - next.remoteJwks !== saved.remoteJwks || - next.anonKeyIsOverride !== saved.anonKeyIsOverride || - next.serviceRoleKeyIsOverride !== saved.serviceRoleKeyIsOverride; - if (identityChanged) { - const savedComposition = yield* Schema.decodeUnknownEffect( - Orchestrator.CompositionConfig, - )(current.composition).pipe( - Effect.mapError((cause) => errorFor("credentials", cause)), - ); - const memberIds = new Set([ - ...savedComposition.members.map(({ id }) => id), - ...savedComposition.dependencies.flatMap(({ from, to }) => [from, to]), - ]); - for (const id of memberIds) { - const { lifecycle, wakeEnabled } = yield* observation(id).pipe( - Effect.mapError((cause) => errorFor("credentials", cause)), - ); - if (lifecycle !== "stopped" || wakeEnabled) - return yield* errorFor( - "credentials", - `Service ${id} must be stopped with wake disabled before identity changes`, - ); - } - yield* options.state - .save({ ...current, credentials: next }) - .pipe(Effect.mapError((cause) => errorFor("credentials", cause))); - } - return next; - }), - ) - .pipe( - Effect.mapError((cause) => - cause instanceof OwnerError ? cause : errorFor("credentials", cause), + // Mask only the permit handoff; admitted definition changes belong to the owner scope. A change + // arriving while draining fails before waiting behind the shutdown that holds the permit. + const definitionChange = <A, E>(work: Effect.Effect<A, E>) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* rejectWhileDraining; + yield* restore(definitionGate.take(1)); + const fiber = yield* Effect.forkIn( + rejectWhileDraining.pipe( + Effect.andThen(work), + Effect.ensuring(definitionGate.release(1)), ), + ownerScope, + { uninterruptible: false }, ); - Object.assign(routeKeys, credentials); - return credentials; - }); - - const resolveCredentials = Effect.fn("Owner.resolveCredentials")(function* (input: unknown) { - const creation = yield* Schema.decodeUnknownEffect(ServiceCreationInput)(input).pipe( - Effect.mapError((cause) => errorFor("config", cause)), - ); - if (!consumesCredentials(creation)) - return yield* Schema.decodeUnknownEffect(ServiceCreation)(creation).pipe( - Effect.mapError((cause) => errorFor("config", cause)), - ); - const overrides = credentialOverridesFor(creation); - const credentials = yield* resolveStackCredentials(overrides); - - const config = { ...creation.config }; - if (creation.service === "database") { - Object.assign(config, { - databasePassword: Redacted.make(credentials.databasePassword), - jwtSecret: Redacted.make(credentials.jwtSecret), - rootKey: Redacted.make(credentials.postgresRootKey), - }); - } else { - Object.assign(config, { jwtSecret: credentials.jwtSecret }); - switch (creation.service) { - case "auth": - Object.assign(config, { - gotrueJwtKeys: creation.config.gotrueJwtKeys ?? credentials.gotrueJwtKeys, - }); - break; - case "rest": - case "realtime": - Object.assign(config, { - jwks: creation.config.jwks ?? credentials.jwks, - }); - break; - case "storage": - Object.assign(config, { - jwks: creation.config.jwks ?? credentials.jwks, - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - }); - break; - case "functions": - Object.assign(config, { - jwks: creation.config.jwks ?? credentials.jwks, - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - publishableKey: creation.config.publishableKey ?? credentials.publishableKey, - secretKey: creation.config.secretKey ?? credentials.secretKey, - }); - break; - case "studio": - Object.assign(config, { - anonKey: creation.config.anonKey ?? credentials.anonKey, - serviceRoleKey: creation.config.serviceRoleKey ?? credentials.serviceRoleKey, - publishableKey: creation.config.publishableKey ?? credentials.publishableKey, - secretKey: creation.config.secretKey ?? credentials.secretKey, - }); - break; - default: - break; - } - } - return yield* Schema.decodeUnknownEffect(ServiceCreation)({ - ...creation, - config, - }).pipe(Effect.mapError((cause) => errorFor("credentials", cause))); - }); + return yield* restore(Fiber.join(fiber)); + }), + ); - const removeCreation = (id: string) => - updateState((current) => - Effect.succeed( - clearUnusedCredentials({ - ...current, - instances: current.instances.filter((instance) => instance.id !== id), - }), - ), - ).pipe(Effect.asVoid); + const readSaved = options.state + .read(stackId) + .pipe( + Effect.flatMap((saved) => + saved === undefined + ? Effect.fail( + new State.StateError({ operation: "read", message: "Saved stack is missing" }), + ) + : Effect.succeed(saved), + ), + ); + const updateState = (update: (current: SavedStack) => SavedStack) => + options.state.withLock( + readSaved.pipe(Effect.flatMap((current) => options.state.save(update(current)))), + ); - const persistComposition = (value: CompositionConfig) => - updateState((current) => Effect.succeed({ ...current, composition: value })).pipe( - Effect.asVoid, - ); - const clearCredentialsIfUnused = () => - updateState((current) => { - return Effect.succeed(clearUnusedCredentials(current)); - }).pipe(Effect.asVoid); - const removeInstance = (id: string) => { - const prune = (current: CompositionConfig): CompositionConfig => ({ - members: current.members.filter((member) => member.id !== id), - dependencies: current.dependencies.filter( - (dependency) => dependency.from !== id && dependency.to !== id, - ), - }); - return updateState((current) => - Schema.decodeUnknownEffect(Orchestrator.CompositionConfig)(current.composition).pipe( - Effect.mapError((cause) => errorFor("state", cause)), - Effect.map((savedComposition) => - clearUnusedCredentials({ - ...current, - instances: current.instances.filter((instance) => instance.id !== id), - composition: prune(savedComposition), - }), + const requireStopped = (configuration: CompositionConfig) => + Effect.forEach( + new Set([ + ...configuration.members.map(({ id }) => id), + ...configuration.dependencies.flatMap(({ from, to }) => [from, to]), + ]), + (id) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => entry.core.get), + Effect.flatMap(({ lifecycle, wakeEnabled }) => + lifecycle === "stopped" && !wakeEnabled + ? Effect.void + : Effect.fail( + new CredentialError({ + message: `Service ${id} must be stopped with wake disabled before stack credentials change`, + }), + ), ), ), - ).pipe( - Effect.tap(() => Ref.update(composition, prune)), - Effect.asVoid, - ); - }; + { discard: true }, + ); - const recipeFor = (input: unknown, id: string) => - makeServiceRecipe(input, { - stackId: options.saved.id, - instanceId: id, - root: options.root, - cacheRoot: options.cacheRoot, - runtime, - helpers, - }).pipe(Effect.mapError((cause) => errorFor("recipe", cause))); - const recipeReady = (input: unknown, id: string) => - recipeFor(input, id).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(HttpClient.HttpClient, http), - Effect.provideService(Scope.Scope, ownerScope), - ); + const resolveStackCredentials = Effect.fn("Owner.resolveStackCredentials")(function* ( + overrides: Effect.Success<ReturnType<typeof credentialOverrides>>, + keys?: StackKeysInput, + ) { + const credentials = yield* options.state.withLock( + Effect.gen(function* () { + const current = yield* readSaved; + const next = yield* nextCredentials(current, overrides, keys); + if (next === current.credentials) return next; + if (current.credentials !== undefined) yield* requireStopped(current.composition); + yield* options.state.save({ ...current, credentials: next }); + return next; + }), + ); + Object.assign(routeKeys, credentials); + return credentials; + }); - const getRecipe = (id: string) => - Ref.get(recipes).pipe( - Effect.flatMap((values) => { - const recipe = values.get(id); - return recipe === undefined - ? Effect.fail(errorFor("get", `Unknown service ${id}`)) - : Effect.succeed(recipe); - }), - ); + const resolveCredentials = Effect.fn("Owner.resolveCredentials")(function* ( + creation: ServiceCreationInput, + ) { + if (!consumesCredentials(creation)) + return yield* Schema.decodeUnknownEffect(ServiceCreation)(creation); + const credentials = yield* resolveStackCredentials(yield* credentialOverrides([creation])); + return yield* withCredentials(creation, credentials); + }); - const getCreation = (id: string) => getRecipe(id).pipe(Effect.map((recipe) => recipe.creation)); + const recipeFor = (creation: ServiceCreation, id: string) => + makeServiceRecipe(creation, { + stackId, + instanceId: id, + project, + root: options.root, + cacheRoot: options.cacheRoot, + runtime, + helpers, + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), + }).pipe(Effect.provideContext(services)); + + const persistCreation = (entry: Pick<Entry, "id" | "creation">, creation: ServiceCreation) => + updateState((current) => ({ + ...current, + instances: current.instances.map((instance) => + instance.id === entry.id ? { ...instance, creation } : instance, + ), + })).pipe(Effect.andThen(Ref.set(entry.creation, creation))); - const mergeInputs = (creation: ServiceCreation, inputs: Record<string, string | undefined>) => - Schema.decodeUnknownEffect(ServiceCreation)({ - ...creation, - config: Object.fromEntries( - Object.entries({ ...creation.config, ...inputs }).filter( - ([, value]) => value !== undefined, + const register = Effect.fn("Owner.register")(function* (id: string, recipe: CatalogRecipe) { + if (Option.isSome(yield* orchestrator.get(id).pipe(Effect.option))) + return yield* new Orchestrator.OrchestratorError({ + operation: "register", + message: `Duplicate instance ${id}`, + }); + const initial = recipe.creation; + const creation = yield* Ref.make(initial); + const namespaceRef = yield* Ref.make<NetworkNamespace | undefined>(undefined); + const core = yield* makeService( + { + ...recipe.definition, + launch: (context) => + persistCreation({ id, creation }, context.config).pipe( + Effect.mapError(serviceError("state")), + Effect.andThen(recipe.definition.launch(context)), ), - ), - }).pipe( - Effect.mapError( - (cause) => new ServiceError({ operation: "inputs", message: String(cause) }), - ), - ); - - const restartCreation = ( - creation: ServiceCreation, - candidate: unknown, - ): Effect.Effect<ServiceCreation, ServiceError> => { - if (candidate === undefined) return Effect.succeed(creation); - const config = isRecord(candidate) && "config" in candidate ? candidate.config : candidate; - return Schema.decodeUnknownEffect(ServiceCreation)({ - ...creation, - config, - }).pipe( - Effect.mapError( - (cause) => new ServiceError({ operation: "restart", message: String(cause) }), - ), - ); - }; - - const register = Effect.fn("Owner.register")(function* (id: string, recipe: CatalogRecipe) { - const initial = recipe.creation; - const namespaceRef = yield* Ref.make<NetworkNamespace | undefined>(undefined); - const instance = yield* makeService( - { - ...recipe.definition, - launch: (context) => - persistCreation(id, context.config).pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "state", - message: cause.message, - cause, - }), - ), - Effect.andThen(recipe.definition.launch(context)), - Effect.map((session) => ({ - ...session, - remove: session.remove.pipe( - Effect.andThen( - Ref.get(namespaceRef).pipe( - Effect.flatMap((value) => value?.close ?? Effect.void), - Effect.mapError( - (cause) => - new ServiceError({ - operation: "close", - message: cause.message, - cause, - }), - ), - ), - ), - ), - })), - Effect.catchTag("ServiceLaunchError", (failure) => - Effect.fail( - new ServiceLaunchError({ - failure: failure.failure, - runtime: { - ...failure.runtime, - remove: failure.runtime.remove.pipe( - Effect.andThen( - Ref.get(namespaceRef).pipe( - Effect.flatMap((value) => value?.close ?? Effect.void), - Effect.mapError( - (cause) => - new ServiceError({ - operation: "close", - message: cause.message, - cause, - }), - ), - ), - ), - ), - }, - }), - ), - ), - ), - removeData: (context) => - recipe.definition.removeData(context).pipe( - Effect.andThen( - Ref.get(namespaceRef).pipe( - Effect.flatMap((value) => value?.release ?? Effect.void), - Effect.mapError( - (cause) => - new ServiceError({ - operation: "release", - message: cause.message, - cause, - }), - ), - ), - ), - Effect.andThen( - removeInstance(id).pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "state", - message: cause.message, - cause, - }), - ), - ), + removeData: (context) => + recipe.definition.removeData(context).pipe( + Effect.andThen( + Ref.get(namespaceRef).pipe( + Effect.flatMap((namespace) => namespace?.release ?? Effect.void), + Effect.mapError(serviceError("release")), ), ), - }, - { - id, - config: initial, - coordinate: (operation, transition) => - Ref.get(draining).pipe( - Effect.flatMap((isDraining) => - isDraining && ["start", "arm", "restart", "storage"].includes(operation) - ? Effect.fail( - new ServiceError({ - operation: "draining", - message: "Owner is draining", - }), - ) - : orchestrator.admissionFor(id)(operation, transition), + Effect.andThen( + updateState((current) => withoutInstance(current, id)).pipe( + Effect.mapError(serviceError("state")), ), ), - }, - ); - const enabled = instance.get.pipe( - Effect.map( - (observation) => - observation.registered && - observation.lifecycle !== "stopped" && - !(observation.exit !== undefined && !observation.wakeEnabled), - ), - ); - const endpointEntries = Object.fromEntries( - endpointNames(initial).map((name) => { - const route = name === "http" ? apiRoute(initial.service) : undefined; - const endpoint: NetworkEndpoint = { - protocol: name === "http" ? ("http" as const) : ("tcp" as const), - port: endpointPort(initial, name), - backend: orchestrator.acquire(id, name !== "inspector").pipe( - Effect.flatMap(() => recipe.endpoint(name)), - Effect.flatMap((address): Effect.Effect<BackendAddress, ProxyError> => { - if (address.kind === "unix") { - return address.path === undefined - ? Effect.fail( - new ProxyError({ - message: "Unix endpoint has no path", - }), - ) - : Effect.succeed({ - path: `${address.path}/.s.PGSQL.${address.port}`, - }); - } - return Effect.succeed({ - host: address.host ?? "127.0.0.1", - port: address.port, - }); - }), + ), + }, + { + id, + config: initial, + coordinate: (operation, transition) => + (drainingBlocks.includes(operation) ? rejectWhileDraining : Effect.void).pipe( + Effect.andThen(orchestrator.admissionFor(id)(operation, transition)), + ), + }, + ).pipe(Effect.provideService(Scope.Scope, ownerScope)); + const enabled = core.get.pipe( + Effect.map( + (observation) => + observation.registered && + observation.lifecycle !== "stopped" && + !(observation.exit !== undefined && !observation.wakeEnabled), + ), + ); + const endpoints = Object.fromEntries( + endpointNames(initial).map((name) => { + const shared = sharedRoutes(initial, name, routeKeys); + const join = joinRoutes(initial, name); + const endpoint: NetworkEndpoint = { + protocol: name === "http" ? "http" : "tcp", + port: endpointPort(initial, name), + backend: orchestrator + .acquire(id, name !== "inspector", `traffic on endpoint ${name}`) + .pipe( + Effect.andThen(recipe.endpoint(name)), + Effect.flatMap(backendAddress), Effect.mapError((cause) => cause instanceof ProxyError ? cause - : new ProxyError({ message: String(cause), cause }), - ), - ), - enabled, - ...(route === undefined - ? {} - : { - shared: - initial.service === "realtime" - ? [ - { - prefix: "/realtime/v1/api", - upstreamPrefix: "/api", - upstreamHost: "realtime-dev", - keyRewrite: { policy: "bearer" as const, keys: routeKeys }, - }, - { - prefix: route, - upstreamPrefix: "/socket", - upstreamHost: "realtime-dev", - keyRewrite: { policy: "query" as const, keys: routeKeys }, - }, - ] - : initial.service === "storage" - ? [ - { - prefix: `${route}/s3`, - upstreamPrefix: "/s3", - }, - { - prefix: route, - upstreamPrefix: "/", - keyRewrite: { policy: "bearer" as const, keys: routeKeys }, - }, - ] - : [ - { - prefix: route, - upstreamPrefix: "/", - ...(initial.service === "rest" || initial.service === "auth" - ? { keyRewrite: { policy: "bearer" as const, keys: routeKeys } } - : initial.service === "functions" - ? { - keyRewrite: { - policy: "sb-api-key" as const, - keys: routeKeys, - }, - } - : {}), - }, - ], - }), - }; - return [name, endpoint]; - }), - ); - const namespace = yield* network - .register({ id, endpoints: endpointEntries }) - .pipe(Effect.mapError((cause) => errorFor("network", cause))); - yield* Ref.set(namespaceRef, namespace); - const endpointAddress = (name: string, from: "host" | "runtime") => - namespace.address(name, from).pipe( - Effect.mapError( - (cause) => - new EndpointError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - ), - ); - const databasePassword = () => - getCreation(id).pipe( - Effect.flatMap((creation) => - creation.service === "database" - ? Effect.succeed(Redacted.value(creation.config.databasePassword)) - : Effect.fail(new EndpointError({ message: "Output requires a database" })), - ), - Effect.mapError((cause) => - cause instanceof EndpointError - ? cause - : new EndpointError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - ), - ); - const outputs = Object.fromEntries( - Object.entries(outputsFor(initial, endpointAddress, databasePassword)).map( - ([name, value]) => [ - name, - value.pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "output", - message: cause.message, - cause, - }), + : new ProxyError({ message: cause.message, cause }), ), ), - ], - ), - ); - const registered: RegisteredInstance = { - id, - core: { - get: instance.get, - ready: instance.ready, - stop: instance.stop, - destroy: instance.destroy, - arm: instance.arm, - armAt: instance.armAt, - sleep: instance.sleep, - observation: instance.observation, - }, - startAt: (revision, inputs, wake, guard) => - getCreation(id).pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "get", - message: cause.message, - cause, - }), - ), - Effect.flatMap((creation) => mergeInputs(creation, inputs)), - Effect.flatMap((candidate) => instance.startAt(revision, candidate, wake, guard)), - ), - restart: (revision, inputs, candidate, guard) => - getCreation(id).pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "get", - message: cause.message, - cause, - }), - ), - Effect.flatMap((creation) => restartCreation(creation, candidate)), - Effect.flatMap((creation) => mergeInputs(creation, inputs)), - Effect.flatMap((next) => instance.restart(next, revision, guard)), - ), - bind: namespace.bind.pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "bind", - message: cause.message, - cause, - }), - ), + enabled, + ...(shared === undefined ? {} : { shared }), + ...(join === undefined ? {} : { join }), + }; + return [name, endpoint]; + }), + ); + const namespace = yield* network.register({ id, endpoints }); + yield* Ref.set(namespaceRef, namespace); + const entry: Entry = { + id, + service: initial.service, + core, + recipe, + creation, + namespace, + startAt: (revision, inputs, wake, guard) => + Ref.get(creation).pipe( + Effect.flatMap((current) => mergeInputs(current, inputs)), + Effect.flatMap(requireInputs), + Effect.flatMap((candidate) => core.startAt(revision, candidate, wake, guard)), ), - close: namespace.close.pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "close", - message: cause.message, - cause, - }), - ), - Effect.tap(() => - instance.get.pipe( - Effect.flatMap((observation) => - observation.registered - ? Effect.void - : Effect.all( - [ - Ref.update(recipes, (values) => { - const next = new Map(values); - next.delete(id); - return next; - }), - Ref.update(instances, (values) => { - const next = new Map(values); - next.delete(id); - return next; - }), - Ref.update(namespaces, (values) => { - const next = new Map(values); - next.delete(id); - return next; - }), - ], - { discard: true }, - ), - ), - ), - ), + restart: (revision, inputs, candidate, guard) => + Ref.get(creation).pipe( + Effect.flatMap((current) => restartCreation(current, candidate)), + Effect.flatMap((next) => mergeInputs(next, inputs)), + Effect.flatMap(requireInputs), + Effect.flatMap((next) => core.restart(next, revision, guard)), ), - hasEndpoint: endpointNames(initial).length > 0, - inputs: Object.keys(serviceSchemas[initial.service].fields), - outputs, - }; - yield* registryGate.withPermits(1)( - orchestrator.register(registered).pipe( - Effect.catch((cause) => namespace.release.pipe(Effect.andThen(Effect.fail(cause)))), - Effect.mapError( - (cause) => - new ServiceError({ - operation: "register", - message: String(cause), - cause, - }), - ), + bind: namespace.bind.pipe(Effect.mapError(serviceError("bind"))), + close: namespace.close.pipe(Effect.mapError(serviceError("close"))), + hasEndpoint: endpointNames(initial).length > 0, + inputs: Object.keys(serviceSchemas[initial.service].fields), + outputs: Object.fromEntries( + Object.entries(outputsFor(initial, Ref.get(creation), namespace.address)).map( + ([name, output]) => [name, output.pipe(Effect.mapError(serviceError("output")))], ), - ); - yield* Ref.update(recipes, (values) => new Map(values).set(id, recipe)); - yield* Ref.update(instances, (values) => new Map(values).set(id, instance)); - yield* Ref.update(namespaces, (values) => new Map(values).set(id, namespace)); - }); - const registerReady = (id: string, recipe: CatalogRecipe) => - register(id, recipe).pipe(Effect.provideService(Scope.Scope, ownerScope)); - - for (const saved of options.saved.instances) { - const creation = yield* Schema.decodeUnknownEffect(creationJson)(saved.creation).pipe( - Effect.mapError((cause) => errorFor("state", cause)), - ); - yield* registerReady(saved.id, yield* recipeReady(creation, saved.id)); - } - const savedComposition = yield* Schema.decodeUnknownEffect(Orchestrator.CompositionConfig)( - options.saved.composition, - ).pipe(Effect.mapError((cause) => errorFor("configure", cause))); + ), + }; yield* orchestrator - .configure(savedComposition) - .pipe(Effect.mapError((cause) => errorFor("configure", cause))); - yield* Ref.set(composition, savedComposition); + .register(entry) + .pipe(Effect.catch((cause) => namespace.release.pipe(Effect.andThen(Effect.fail(cause))))); + }); - const get = Effect.fn("Owner.get")(function* (id: string) { - return yield* getCreation(id).pipe( - Effect.map((creation) => ({ id, creation })), - Effect.mapError((cause) => errorFor("get", cause)), - ); - }); - const enrichObservation = (id: string, value: ServiceObservation<unknown>) => - getCreation(id).pipe( - Effect.mapError((cause) => errorFor("status", cause)), - Effect.flatMap((creation) => - Ref.get(namespaces).pipe( - Effect.mapError((cause) => errorFor("status", cause)), - Effect.flatMap((values) => { - const namespace = values.get(id); - return namespace === undefined - ? Effect.fail(errorFor("status", "Service namespace is missing")) - : namespace.bindings.pipe( - Effect.map((endpoints) => ({ - ...value, - config: creation, - endpoints, - })), - Effect.mapError((cause) => errorFor("status", cause)), - ); - }), + for (const saved of options.saved.instances) + yield* register(saved.id, yield* recipeFor(saved.creation, saved.id)); + yield* orchestrator.configure(options.saved.composition); + + const removeSaved = (id: string) => updateState((current) => withoutInstance(current, id)); + + const addInstance = Effect.fn("Owner.addInstance")(function* (creation: ServiceCreation) { + const id = yield* crypto.randomUUIDv4; + const rollback = <E>(cause: E) => removeSaved(id).pipe(Effect.andThen(Effect.fail(cause))); + const recipe = yield* recipeFor(creation, id); + yield* updateState((current) => ({ + ...current, + instances: [...current.instances, { id, creation }], + })).pipe(Effect.andThen(register(id, recipe)), Effect.catch(rollback), Effect.uninterruptible); + return { id, creation }; + }); + + type ComposeError = + | Effect.Error<ReturnType<typeof addInstance>> + | Orchestrator.LifecycleError + | Network.NetworkError; + + const configure = (configuration: CompositionConfig) => + options.state.withLock( + orchestrator.configure( + configuration, + readSaved.pipe( + Effect.flatMap((current) => + options.state.save({ ...current, composition: configuration }), ), ), - ); - const observation = Effect.fn("Owner.status")(function* (id: string) { - return yield* orchestrator.get(id).pipe( - Effect.flatMap((instance) => instance.core.get), - Effect.flatMap((value) => enrichObservation(id, value)), - Effect.mapError((cause) => - cause instanceof OwnerError ? cause : errorFor("status", cause), - ), - ); - }); - const operation = <A, E>( - name: string, - effect: Effect.Effect<A, E>, - ): Effect.Effect<A, OwnerError> => - effect.pipe(Effect.mapError((cause) => errorFor(name, cause))); - const storage = <A>( - id: string, - action: Effect.Effect<A, OwnerError>, - ): Effect.Effect<A, OwnerError> => - Ref.get(instances).pipe( - Effect.flatMap((values) => { - const instance = values.get(id); - return instance === undefined - ? Effect.fail(errorFor("storage", `Unknown service ${id}`)) - : instance - .storage( - action.pipe( - Effect.mapError( - (cause) => - new ServiceError({ - operation: "storage", - message: String(cause), - }), - ), - ), - ) - .pipe(Effect.mapError((cause) => errorFor("storage", cause))); - }), - ); - const createService = Effect.fn("Owner.createService")(function* (input: unknown) { - yield* Ref.get(draining).pipe( - Effect.flatMap((isDraining) => - isDraining ? Effect.fail(errorFor("create", "Owner is draining")) : Effect.void, - ), - ); - const creation = yield* resolveCredentials(input); - const id = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("identity", cause)), - ); - yield* addCreation(id, creation); - yield* Effect.gen(function* () { - const recipe = yield* recipeReady(creation, id); - yield* registerReady(id, recipe); - }).pipe( - Effect.catch((cause) => removeCreation(id).pipe(Effect.andThen(Effect.fail(cause)))), - Effect.mapError((cause) => errorFor("register", cause)), - ); - return { id, creation }; - }); + ), + ); - const configureComposition = Effect.fn("Owner.configureComposition")(function* ( - input: CompositionConfig, + // A failed cleanup must not replace the failure that triggered it. + const clearUnusedCredentials = updateState(withoutUnusedCredentials).pipe( + Effect.catch((cause) => Effect.logWarning("Unused stack credentials were not cleared", cause)), + ); + const compose = Effect.fn("Owner.compose")( + function* ( + inputs: ReadonlyArray<ServiceCreationInput>, + compositionOptions: SupabaseCompositionOptions, ) { - yield* Ref.get(draining).pipe( - Effect.flatMap((isDraining) => { - if (isDraining) return Effect.fail(errorFor("configure", "Owner is draining")); - return Schema.decodeEffect(Orchestrator.CompositionConfig)(input).pipe( - Effect.mapError((cause) => errorFor("configure", cause)), - ); - }), - Effect.tap((value) => - orchestrator - .configure(value) - .pipe(Effect.mapError((cause) => errorFor("configure", cause))), - ), - Effect.tap((value) => Ref.set(composition, value)), - Effect.tap((value) => persistComposition(value)), - Effect.asVoid, + yield* resolveStackCredentials(yield* credentialOverrides(inputs), compositionOptions.keys); + const creations = yield* Effect.forEach(inputs, resolveCredentials); + return yield* makeSupabaseComposition<ComposeError>( + { + currentComposition: orchestrator.composition, + get: (id) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => Ref.get(entry.creation)), + Effect.map((creation) => ({ id, creation })), + ), + status: (id) => orchestrator.get(id).pipe(Effect.flatMap((entry) => entry.core.get)), + create: addInstance, + destroy: orchestrator.destroy, + bind: (id) => orchestrator.get(id).pipe(Effect.flatMap((entry) => entry.bind)), + address: (id, endpoint, from) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => entry.namespace.address(endpoint, from)), + Effect.map(({ host, port }) => publicUrl(host, port)), + ), + output: (id, name) => + orchestrator + .get(id) + .pipe( + Effect.flatMap( + (entry) => + entry.outputs[name] ?? + Effect.fail( + new ServiceError({ operation: "output", message: `Missing output ${name}` }), + ), + ), + ), + updateCreation: (id, values) => + Effect.gen(function* () { + const entry = yield* orchestrator.get(id); + const creation = yield* mergeInputs(yield* Ref.get(entry.creation), values); + yield* persistCreation(entry, creation); + return { id, creation }; + }), + replaceCreation: (id, creation) => + Effect.gen(function* () { + const entry = yield* orchestrator.get(id); + if (creation.service !== (yield* Ref.get(entry.creation)).service) + return yield* new ServiceError({ + operation: "compose", + message: `Reused service ${id} cannot change service kind`, + }); + yield* persistCreation(entry, creation); + return { id, creation }; + }), + configure, + }, + creations, + compositionOptions, ); - }); + }, + Effect.catch((cause) => clearUnusedCredentials.pipe(Effect.andThen(Effect.fail(cause)))), + ); - const updateCreation = Effect.fn("Owner.updateCreation")(function* ( - id: string, - inputs: Record<string, string | undefined>, - ) { - return yield* getCreation(id).pipe( - Effect.flatMap((creation) => mergeInputs(creation, inputs)), - Effect.mapError((cause) => errorFor("supabase", cause)), - Effect.tap((creation) => persistCreation(id, creation)), - Effect.map((creation) => ({ id, creation })), - ); - }); + const restart = Effect.fn("Owner.restart")(function* ( + id: string, + input: ServiceCreationInput | undefined, + ) { + if (input === undefined) return yield* orchestrator.restart(id); + const previous = yield* Ref.get((yield* orchestrator.get(id)).creation); + const next = yield* resolveCredentials(input); + if (next.service !== previous.service) + return yield* new ServiceError({ + operation: "restart", + message: "Service kind cannot change", + }); + return yield* orchestrator.restart(id, next); + }); - const replaceCreation = Effect.fn("Owner.replaceCreation")(function* ( - id: string, - input: ServiceCreation, - ) { - const creation = yield* Schema.decodeEffect(ServiceCreation)(input).pipe( - Effect.mapError((cause) => errorFor("supabase", cause)), - ); - const current = yield* getCreation(id).pipe( - Effect.mapError((cause) => errorFor("supabase", cause)), + const observe = (entry: Entry, value: ServiceObservation<ServiceCreation>) => + Effect.all({ config: Ref.get(entry.creation), endpoints: entry.namespace.bindings }).pipe( + Effect.map((current) => ({ ...value, ...current })), + ); + const observation = (id: string) => + orchestrator + .get(id) + .pipe( + Effect.flatMap((entry) => + entry.core.get.pipe(Effect.flatMap((value) => observe(entry, value))), + ), ); - if (creation.service !== current.service) - return yield* errorFor("supabase", `Reused service ${id} cannot change service kind`); - yield* persistCreation(id, creation); - return { id, creation }; - }); + const observeAll = (values: ReadonlyArray<{ readonly id: string }>) => + Effect.forEach(values, ({ id }) => observation(id)); - const supabaseComposition = Effect.fn("Owner.supabaseComposition")( - ( - inputs: ReadonlyArray<ServiceCreationInput>, - compositionOptions?: SupabaseCompositionOptions, - ) => - Effect.gen(function* () { - const overrides: Record<string, string> = {}; - for (const input of inputs) { - for (const [key, value] of Object.entries(credentialOverridesFor(input))) { - if (overrides[key] !== undefined && overrides[key] !== value) - return yield* errorFor( - "credentials", - `Credential override ${key} conflicts within the stack composition`, - ); - overrides[key] = value; - } - } - yield* resolveStackCredentials(overrides, compositionOptions?.identity); - return yield* Effect.forEach(inputs, resolveCredentials); - }).pipe( - Effect.flatMap((creations) => - makeSupabaseComposition( - { - currentComposition: Ref.get(composition), - get: (id) => get(id).pipe(Effect.mapError(supabaseError)), - status: (id) => - observation(id).pipe( - Effect.map(({ lifecycle, wakeEnabled }) => ({ - lifecycle, - wakeEnabled, - })), - Effect.mapError(supabaseError), - ), - create: (creation) => createService(creation).pipe(Effect.mapError(supabaseError)), - destroy: (id) => destroy(id).pipe(Effect.mapError(supabaseError)), - bind: (id) => - orchestrator.get(id).pipe( - Effect.flatMap((registered) => registered.bind), - Effect.mapError(supabaseError), - ), - address: (id, endpoint, from) => - Ref.get(namespaces).pipe( - Effect.flatMap((values) => { - const namespace = values.get(id); - return namespace === undefined - ? Effect.fail(supabaseError("Service namespace is missing")) - : namespace.address(endpoint, from).pipe( - Effect.map(({ host, port }) => publicUrl(host, port)), - Effect.mapError(supabaseError), - ); - }), - ), - output: (id, name) => - orchestrator.get(id).pipe( - Effect.flatMap((registered) => { - const output = registered.outputs[name]; - return output === undefined - ? Effect.fail(supabaseError(`Missing output ${name}`)) - : output.pipe(Effect.mapError(supabaseError)); - }), - Effect.mapError(supabaseError), - ), - updateCreation: (id, values) => - updateCreation(id, values).pipe(Effect.mapError(supabaseError)), - replaceCreation: (id, creation) => - replaceCreation(id, creation).pipe(Effect.mapError(supabaseError)), - configure: (configuration) => - configureComposition(configuration).pipe(Effect.mapError(supabaseError)), - }, - creations, - compositionOptions, - ), - ), - Effect.catch((cause) => - clearCredentialsIfUnused().pipe(Effect.andThen(Effect.fail(cause))), + const databaseData = Effect.fn("Owner.databaseData")(function* <A>( + id: string, + select: ( + recipe: CatalogRecipe, + ) => + | ((context: ServiceInstanceContext<ServiceCreation>) => Effect.Effect<A, CatalogError>) + | undefined, + ) { + const entry = yield* orchestrator.get(id); + const action = select(entry.recipe); + if (action === undefined) + return yield* new ServiceError({ + operation: "storage", + message: "Snapshots and data reset are only supported for databases", + }); + return yield* entry.core.storage( + Effect.acquireUseRelease( + Scope.make("parallel"), + (scope) => + Ref.get(entry.creation).pipe( + Effect.flatMap((config) => action({ id, config, scope })), + Effect.mapError(serviceError("storage")), ), - Effect.mapError((cause) => { - if (cause instanceof OwnerError) return cause; - if (cause instanceof SupabaseCompositionError && cause.cause instanceof OwnerError) - return cause.cause; - return errorFor("supabase", cause); - }), - ), + (scope, exit) => Scope.close(scope, exit), + ), ); + }); - const prepare = Effect.fn("Owner.prepare")(function* (id: string) { - return yield* getRecipe(id).pipe( - Effect.flatMap((recipe) => recipe.definition.prepare?.(recipe.creation) ?? Effect.void), - Effect.mapError((cause) => errorFor("prepare", cause)), - ); - }); - const start = Effect.fn("Owner.start")((id: string) => - operation("start", orchestrator.start(id)), - ); - const ready = Effect.fn("Owner.ready")((id: string) => - operation("ready", orchestrator.get(id).pipe(Effect.flatMap((value) => value.core.ready))), - ); - const stop = Effect.fn("Owner.stop")((id: string) => operation("stop", orchestrator.stop(id))); - const restart = Effect.fn("Owner.restart")(function* (id: string, input?: unknown) { - const effect = - input === undefined - ? orchestrator.restart(id) - : Effect.gen(function* () { - const previous = yield* getCreation(id); - const next = yield* resolveCredentials(input); - if (next.service !== previous.service) - return yield* errorFor("restart", "Service kind cannot change"); - return yield* orchestrator.restart(id, next); - }); - return yield* operation("restart", effect); - }); - const destroy = Effect.fn("Owner.destroy")((id: string) => - operation("destroy", orchestrator.destroy(id)), - ); - const credentials = Effect.fn("Owner.credentials")((id: string, from: "host" | "runtime") => - get(id).pipe( - Effect.flatMap(({ creation }) => - Ref.get(namespaces).pipe( - Effect.flatMap((values) => { - const namespace = values.get(id); - if (namespace === undefined) - return Effect.fail(errorFor("credentials", "Service namespace is missing")); - const address = (endpoint: string, source: "host" | "runtime") => - namespace.address(endpoint, source).pipe( - Effect.mapError( - (cause) => - new EndpointError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - ), - ); - const password = () => - getCreation(id).pipe( - Effect.flatMap((value) => - value.service === "database" - ? Effect.succeed(Redacted.value(value.config.databasePassword)) - : Effect.fail( - new EndpointError({ - message: "Credentials require a database", - }), - ), - ), - Effect.mapError((cause) => - cause instanceof EndpointError - ? cause - : new EndpointError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - ), - ); - return credentialsFor(creation, address, password, from).pipe( - Effect.mapError((cause) => errorFor("credentials", cause)), - ); - }), + const handlers: Handlers = { + createService: (input) => + definitionChange( + Effect.gen(function* () { + const introduced = (yield* readSaved).credentials === undefined; + // Credentials a failed creation introduced must not pin a retry to its values. + return yield* resolveCredentials(input).pipe( + Effect.flatMap(addInstance), + Effect.catch((cause) => + (introduced ? clearUnusedCredentials : Effect.void).pipe( + Effect.andThen(Effect.fail(cause)), + ), + ), + ); + }), + ).pipe(rpcError("createService")), + startService: ({ id }) => orchestrator.start(id).pipe(rpcError("startService")), + readyService: ({ id }) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => entry.core.ready), + rpcError("readyService"), + ), + stopService: ({ id }) => orchestrator.stop(id).pipe(rpcError("stopService")), + // A config-changing restart can adopt saved credentials, so it serializes with definition + // changes that introduce or roll them back. + restartService: ({ id, config }) => + (config === undefined ? restart(id, config) : definitionChange(restart(id, config))).pipe( + rpcError("restartService"), + ), + destroyService: ({ id }) => + definitionChange(orchestrator.destroy(id)).pipe(rpcError("destroyService")), + prepareService: ({ id }) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => + Ref.get(entry.creation).pipe( + Effect.flatMap( + (creation) => entry.recipe.definition.prepare?.(creation) ?? Effect.void, + ), ), ), + rpcError("prepareService"), ), - ); - const compose = Effect.fn("Owner.compose")( - (creations: ReadonlyArray<ServiceCreationInput>, options?: SupabaseCompositionOptions) => - compositionGate.withPermits(1)(supabaseComposition(creations, options)), - ); - const configure = Effect.fn("Owner.configure")((input: CompositionConfig) => - compositionGate.withPermits(1)(configureComposition(input)), - ); - const compositionStart = orchestrator.startComposition.pipe( - Effect.mapError((cause) => errorFor("start", cause)), - Effect.flatMap((values) => Effect.forEach(values, (value) => observation(value.id))), - Effect.withSpan("Owner.startComposition"), - ); - const compositionStop = orchestrator.stopComposition.pipe( - Effect.mapError((cause) => errorFor("stop", cause)), - Effect.flatMap((values) => Effect.forEach(values, (value) => observation(value.id))), - Effect.withSpan("Owner.stopComposition"), - ); - const compositionRestart = orchestrator.restartComposition.pipe( - Effect.mapError((cause) => errorFor("restart", cause)), - Effect.flatMap((values) => Effect.forEach(values, (value) => observation(value.id))), - Effect.withSpan("Owner.restartComposition"), - ); - const saveSnapshot = Effect.fn("Owner.saveSnapshot")((id: string, key: string) => - get(id).pipe( - Effect.flatMap(({ creation }) => - creation.service === "database" - ? storage( - id, - Effect.gen(function* () { - const current = yield* getRecipe(id); - const save = current.saveDatabaseSnapshot; - if (save === undefined) - return yield* errorFor("saveSnapshot", "Database snapshots are unavailable"); - return yield* Effect.acquireUseRelease( - Scope.make("parallel"), - (scope) => - save({ id, config: current.creation, scope }, key).pipe( - Effect.mapError((cause) => errorFor("saveSnapshot", cause)), - ), - (scope, exit) => Scope.close(scope, exit), - ); - }), - ) - : Effect.fail(errorFor("saveSnapshot", "Snapshots are only supported for databases")), - ), + status: ({ id }) => observation(id).pipe(rpcError("status")), + followStatus: ({ id }) => + Stream.unwrap( + orchestrator + .get(id) + .pipe( + Effect.map((entry) => + entry.core.observation.pipe(Stream.mapEffect((value) => observe(entry, value))), + ), + ), + ).pipe(Stream.mapError((cause) => stackError("followStatus", cause))), + logs: ({ id }) => + Stream.unwrap(orchestrator.get(id).pipe(Effect.map((entry) => entry.recipe.logs))).pipe( + Stream.map(({ stream, bytes }) => ({ stream, bytes })), + Stream.mapError((cause) => stackError("logs", cause)), ), - ); - const restoreSnapshot = Effect.fn("Owner.restoreSnapshot")((id: string, key: string) => - get(id).pipe( - Effect.flatMap(({ creation }) => - creation.service === "database" - ? storage( - id, - Effect.gen(function* () { - const current = yield* getRecipe(id); - const restore = current.restoreDatabaseSnapshot; - if (restore === undefined) - return yield* errorFor("restoreSnapshot", "Database snapshots are unavailable"); - return yield* Effect.acquireUseRelease( - Scope.make("parallel"), - (scope) => - restore({ id, config: current.creation, scope }, key).pipe( - Effect.mapError((cause) => errorFor("restoreSnapshot", cause)), - ), - (scope, exit) => Scope.close(scope, exit), - ); - }), - ) - : Effect.fail( - errorFor("restoreSnapshot", "Snapshots are only supported for databases"), - ), + credentials: ({ id, from }) => + orchestrator.get(id).pipe( + Effect.flatMap((entry) => + Ref.get(entry.creation).pipe( + Effect.flatMap((creation) => credentialsFor(creation, entry.namespace.address, from)), + ), ), + rpcError("credentials"), ), - ); - const resetData = Effect.fn("Owner.resetData")((id: string) => - get(id).pipe( - Effect.flatMap(({ creation }) => - creation.service === "database" - ? storage( - id, - Effect.gen(function* () { - const current = yield* getRecipe(id); - const reset = current.resetDatabaseData; - if (reset === undefined) - return yield* errorFor("resetData", "Database reset is unavailable"); - return yield* Effect.acquireUseRelease( - Scope.make("parallel"), - (scope) => - reset({ id, config: current.creation, scope }).pipe( - Effect.mapError((cause) => errorFor("resetData", cause)), - ), - (scope, exit) => Scope.close(scope, exit), - ); - }), - ) - : Effect.fail(errorFor("resetData", "Reset is only supported for databases")), - ), + saveSnapshot: ({ id, key, scope = "cache" }) => + databaseData(id, ({ saveDatabaseSnapshot: save }) => + save === undefined ? undefined : (context) => save(context, key, scope), + ).pipe(rpcError("saveSnapshot")), + restoreSnapshot: ({ id, key, scope = "cache" }) => + databaseData(id, ({ restoreDatabaseSnapshot: restore }) => + restore === undefined ? undefined : (context) => restore(context, key, scope), + ).pipe(rpcError("restoreSnapshot")), + resetData: ({ id }) => + databaseData(id, ({ resetDatabaseData }) => resetDatabaseData).pipe(rpcError("resetData")), + supabaseComposition: ({ services, reuseIds, keys, eager }) => + definitionChange(compose(services, { reuseIds, keys, eager })).pipe( + rpcError("supabaseComposition"), ), - ); - const stopNamespace = operation("stopNamespace", orchestrator.stopNamespace).pipe( - Effect.withSpan("Owner.stopNamespace"), - ); - const destroyNamespace = operation( - "destroyNamespace", - orchestrator.destroyNamespace.pipe( - Effect.andThen(network.release), - Effect.andThen( - options.saved.runtime === "native" - ? Effect.void - : Container.removeStackContainers({ - engine: options.saved.runtime, - stackId: options.saved.id, - root: options.root, - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), - ), - Effect.andThen(options.state.remove(options.saved.id)), + configureComposition: (configuration) => + definitionChange(configure(configuration)).pipe(rpcError("configureComposition")), + startComposition: () => + orchestrator.startComposition.pipe(Effect.flatMap(observeAll), rpcError("startComposition")), + stopComposition: () => + orchestrator.stopComposition.pipe(Effect.flatMap(observeAll), rpcError("stopComposition")), + restartComposition: () => + orchestrator.restartComposition.pipe( + Effect.flatMap(observeAll), + rpcError("restartComposition"), ), - ).pipe(Effect.withSpan("Owner.destroyNamespace")); - const setDraining = Effect.fn("Owner.setDraining")((value: boolean) => - Ref.set(draining, value).pipe(Effect.mapError((cause) => errorFor("draining", cause))), - ); - const getServing = Effect.gen(function* () { - return !(yield* Ref.get(draining)); - }).pipe(Effect.withSpan("Owner.getServing")); + }; - const owner: Interface = { - services: { - create: createService, - get, - list: Ref.get(recipes).pipe( - Effect.map((values) => - [...values].map(([id, recipe]) => ({ - id, - creation: recipe.creation, - })), - ), - Effect.mapError((cause) => errorFor("list", cause)), - ), - }, - core: { - get: observation, - status: observation, - followStatus: (id) => - Stream.unwrap( - orchestrator.get(id).pipe( - Effect.map((instance) => instance.core.observation), - Effect.mapError((cause) => errorFor("status", cause)), - ), - ).pipe(Stream.mapEffect((value) => enrichObservation(id, value))), - prepare, - logs: (id) => - Stream.unwrap( - getRecipe(id).pipe( - Effect.map((recipe) => recipe.logs), - Effect.mapError((cause) => errorFor("logs", cause)), - ), - ).pipe(Stream.mapError((cause) => errorFor("logs", cause))), - start, - ready, - stop, - restart, - destroy, - }, - composition: { - supabase: compose, - configure, - get: Ref.get(composition), - start: compositionStart, - stop: compositionStop, - restart: compositionRestart, - }, - credentials, - snapshots: { - saveSnapshot, - restoreSnapshot, - resetData, - }, - namespace: { - stop: stopNamespace, - destroy: destroyNamespace, - }, - setDraining, - getServing, - }; - return owner; - }); + const sweep = sweepContainers(options.saved, options.root).pipe(Effect.provideContext(services)); + const getStackCredentials = readSaved.pipe( + Effect.flatMap(({ credentials }) => + credentials === undefined + ? Effect.fail( + new CredentialError({ message: "Stack credentials have not been established" }), + ) + : Effect.succeed(credentials), + ), + Effect.withSpan("Owner.getStackCredentials"), + ); + + return { + handlers, + getStackCredentials, + namespace: { + stop: orchestrator.stopNamespace.pipe( + Effect.andThen(sweep), + definitionGate.withPermits(1), + Effect.withSpan("Owner.stopNamespace"), + ), + destroy: orchestrator.destroyNamespace.pipe( + Effect.andThen(network.release), + Effect.andThen(sweep), + Effect.andThen(options.state.remove(stackId)), + definitionGate.withPermits(1), + Effect.withSpan("Owner.destroyNamespace"), + ), + }, + setDraining: (value) => Ref.set(draining, value), + getServing: Ref.get(draining).pipe(Effect.map((isDraining) => !isDraining)), + } satisfies Interface; +}); export const layer = (options: Omit<OwnerOptions, "state">) => Layer.effect( Service, Effect.gen(function* () { const state = yield* State.Service; - const orchestrator = yield* Orchestrator.Service; - const network = yield* Network.Service; - return Service.of( - yield* makeOwnerWithDependencies({ ...options, state }, orchestrator, network), - ); + return Service.of(yield* makeOwner({ ...options, state })); }), ).pipe( - Layer.provide(Layer.fresh(Orchestrator.layer)), Layer.provide( Network.layer({ stackId: options.saved.id, diff --git a/packages/stack/src/Ports.integration.test.ts b/packages/stack/src/Ports.integration.test.ts index 87fea5b706..e5c4f9bb0d 100644 --- a/packages/stack/src/Ports.integration.test.ts +++ b/packages/stack/src/Ports.integration.test.ts @@ -1,6 +1,6 @@ import { NodeServices, NodeSocketServer } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Context, Effect, Exit, FileSystem, Layer, Scope } from "effect"; +import { Cause, Context, Effect, Exit, FileSystem, Layer, Option, Ref, Scope } from "effect"; import { makePorts, PortError } from "./Ports.ts"; import * as State from "./State.ts"; @@ -26,6 +26,7 @@ it.live("retains distinct claims for stopped stacks and rebinds the original pub runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -59,6 +60,7 @@ it.live("reports an occupied saved port without moving its assignment", () => runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -73,6 +75,7 @@ it.live("reports an occupied saved port without moving its assignment", () => const failure = yield* ports.acquire(request, bind).pipe(Effect.flip); expect(failure).toBeInstanceOf(PortError); expect(failure.message).toContain(`api at 127.0.0.1:${first.port}`); + expect(failure.message).not.toContain("claims this port"); expect((yield* state.read("stack"))?.ports[0]?.port).toBe(first.port); }), ).pipe(Effect.provide(NodeServices.layer)), @@ -89,6 +92,7 @@ it.live("allocates an auto port outside a contiguous range that refuses to bind" runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -121,6 +125,7 @@ it.live("reassigns the same auto port after its claim is released", () => runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -146,6 +151,7 @@ it.live("names the last bind failure when no public port is available", () => runtime: "native", identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -162,29 +168,256 @@ it.live("names the last bind failure when no public port is available", () => ).pipe(Effect.provide(NodeServices.layer)), ); -it.live("refuses allocation when another stack has unreadable claims", () => +const saveStack = ( + state: State.Interface, + root: string, + id: string, + ports: State.SavedStack["ports"] = [], +) => + state.save({ + id, + runtime: "native", + identity: { projectRoot: root, branchContext: `branch-${id}`, stackName: id }, + instances: [], + lifetime: "detached", + composition: { members: [], dependencies: [] }, + ports, + }); + +it.live("allocates past siblings whose state is unreadable or from a newer format", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped(); const state = yield* makeTestState(root); - yield* state.save({ - id: "healthy", - runtime: "native", - identity: { projectRoot: root, branchContext: "test", stackName: "ports" }, - instances: [], - composition: { members: [], dependencies: [] }, - ports: [], - }); + yield* saveStack(state, root, "healthy"); yield* fs.makeDirectory(`${root}/broken`); yield* fs.writeFileString(`${root}/broken/state.json`, "{broken"); + yield* fs.makeDirectory(`${root}/newer`); + yield* fs.writeFileString( + `${root}/newer/state.json`, + '{"id":"newer","runtime":"future","ports":[]}', + ); const ports = yield* makePorts(state); - const error = yield* ports - .acquire({ stackId: "healthy", key: "sql", host: "127.0.0.1", port: "auto" }, bind) - .pipe(Effect.flip); - expect(error).toBeInstanceOf(State.StateError); - expect((yield* state.read("healthy"))?.ports).toEqual([]); + const acquired = yield* ports.acquire( + { stackId: "healthy", key: "sql", host: "127.0.0.1", port: "auto" }, + bind, + ); + expect((yield* state.read("healthy"))?.ports).toEqual([ + { key: "sql", host: "127.0.0.1", port: acquired.port }, + ]); expect(yield* fs.readFileString(`${root}/broken/state.json`)).toBe("{broken"); }), ).pipe(Effect.provide(NodeServices.layer)), ); + +it.live("keeps auto allocation off ports claimed by a sibling in a newer format", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "stack"); + const ports = yield* makePorts(state); + const request = { stackId: "stack", key: "api", host: "127.0.0.1", port: "auto" as const }; + const accept = (_host: string, port: number) => Effect.succeed(port); + const preferred = yield* ports.acquire(request, accept); + yield* ports.release("stack", "api"); + yield* fs.makeDirectory(`${root}/newer`); + yield* fs.writeFileString( + `${root}/newer/state.json`, + `{"id":"newer","runtime":"future","ports":[{"key":"api","host":"127.0.0.1","port":${preferred.port}}]}`, + ); + const moved = yield* ports.acquire(request, accept); + expect(moved.port).not.toBe(preferred.port); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("lets a stack bind an explicit port that a stopped stack still claims", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "stopped"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state); + const stoppedScope = yield* Scope.make(); + const stopped = yield* ports + .acquire({ stackId: "stopped", key: "db/sql", host: "127.0.0.1", port: "auto" }, bind) + .pipe(Effect.provideService(Scope.Scope, stoppedScope)); + yield* Scope.close(stoppedScope, Exit.void); + + const current = yield* ports.acquire( + { stackId: "current", key: "db/sql", host: "127.0.0.1", port: stopped.port }, + bind, + ); + expect(current.port).toBe(stopped.port); + expect((yield* state.read("stopped"))?.ports).toEqual([ + { key: "db/sql", host: "127.0.0.1", port: stopped.port }, + ]); + expect((yield* state.read("current"))?.ports).toEqual([ + { key: "db/sql", host: "127.0.0.1", port: stopped.port }, + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("names the stack claiming an explicit port that a live listener holds", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "holder"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state); + const held = yield* ports.acquire( + { stackId: "holder", key: "db/sql", host: "127.0.0.1", port: "auto" }, + bind, + ); + + const failure = yield* ports + .acquire({ stackId: "current", key: "db/sql", host: "127.0.0.1", port: held.port }, bind) + .pipe(Effect.flip); + expect(failure).toBeInstanceOf(PortError); + expect(failure.message).toContain(`db/sql at 127.0.0.1:${held.port}`); + expect(failure.message).toContain(`stack "holder" on branch-holder in ${root}`); + expect((yield* state.read("current"))?.ports).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +for (const [held, requested] of [ + ["127.0.0.1", "0.0.0.0"], + ["::1", "127.0.0.1"], +] as const) + it.live(`rejects ${requested} on a port a ${held} listener holds where binds can overlap`, () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "holder"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state, "darwin"); + const listener = yield* ports.acquire( + { stackId: "holder", key: "api", host: held, port: "auto" }, + bind, + ); + const overlappingBinds = yield* Ref.make(0); + + const failure = yield* ports + .acquire({ stackId: "current", key: "api", host: requested, port: listener.port }, () => + Ref.update(overlappingBinds, (count) => count + 1), + ) + .pipe(Effect.flip); + expect(failure.message).toContain("already in use"); + expect(failure.message).toContain(`stack "holder"`); + expect(yield* Ref.get(overlappingBinds)).toBe(0); + expect((yield* state.read("current"))?.ports).toEqual([]); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + +it.live("leaves a fixed port to the bind where overlapping binds are rejected", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "holder"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state, "linux"); + const listener = yield* ports.acquire( + { stackId: "holder", key: "api", host: "127.0.0.1", port: "auto" }, + bind, + ); + const binds = yield* Ref.make(0); + + const acquired = yield* ports.acquire( + { stackId: "current", key: "api", host: "0.0.0.0", port: listener.port }, + () => Ref.update(binds, (count) => count + 1), + ); + expect(acquired.port).toBe(listener.port); + expect(yield* Ref.get(binds)).toBe(1); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("lets a stack bind a port that a running stack claims but does not listen on", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "running"); + yield* saveStack(state, root, "current"); + const ports = yield* makePorts(state); + yield* ports.acquire( + { stackId: "running", key: "admin", host: "127.0.0.1", port: "auto" }, + bind, + ); + const restScope = yield* Scope.make(); + const rest = yield* ports + .acquire({ stackId: "running", key: "api", host: "127.0.0.1", port: "auto" }, bind) + .pipe(Effect.provideService(Scope.Scope, restScope)); + yield* Scope.close(restScope, Exit.void); + + const current = yield* ports.acquire( + { stackId: "current", key: "api", host: "127.0.0.1", port: rest.port }, + bind, + ); + expect(current.port).toBe(rest.port); + expect((yield* state.read("current"))?.ports).toEqual([ + { key: "api", host: "127.0.0.1", port: rest.port }, + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.live("lets exactly one of two stacks sharing a saved port bind it when both start at once", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped(); + const state = yield* makeTestState(root); + yield* saveStack(state, root, "first"); + const ports = yield* makePorts(state); + const request = { stackId: "first", key: "api", host: "127.0.0.1", port: "auto" as const }; + const seedScope = yield* Scope.make(); + const seed = yield* ports + .acquire(request, bind) + .pipe(Effect.provideService(Scope.Scope, seedScope)); + yield* Scope.close(seedScope, Exit.void); + yield* saveStack(state, root, "second", [{ key: "api", host: "127.0.0.1", port: seed.port }]); + + const [first, second] = yield* Effect.all( + [ + Effect.exit(ports.acquire(request, bind)), + Effect.exit(ports.acquire({ ...request, stackId: "second" }, bind)), + ], + { concurrency: "unbounded" }, + ); + const outcomes = [ + { claimant: "second", exit: first }, + { claimant: "first", exit: second }, + ]; + expect(outcomes.filter(({ exit }) => Exit.isSuccess(exit))).toHaveLength(1); + const loser = outcomes.find(({ exit }) => Exit.isFailure(exit)); + const failure = + loser !== undefined && Exit.isFailure(loser.exit) + ? Cause.findErrorOption(loser.exit.cause) + : Option.none(); + if (Option.isNone(failure)) return yield* Effect.die("expected a port conflict"); + expect(failure.value).toBeInstanceOf(PortError); + expect(failure.value.message).toContain(`127.0.0.1:${seed.port}`); + expect(failure.value.message).toContain(`stack "${loser?.claimant}"`); + for (const id of ["first", "second"]) + expect((yield* state.read(id))?.ports).toEqual([ + { key: "api", host: "127.0.0.1", port: seed.port }, + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/packages/stack/src/Ports.ts b/packages/stack/src/Ports.ts index 4109ed6424..d45216416b 100644 --- a/packages/stack/src/Ports.ts +++ b/packages/stack/src/Ports.ts @@ -1,4 +1,5 @@ import { Cause, Data, Effect, Exit, Hash, Option, Scope } from "effect"; +import * as Net from "node:net"; import type * as State from "./State.ts"; const portBase = 20000; @@ -24,112 +25,217 @@ export interface PortRequest { const scanStart = (stack: State.SavedStack, key: string) => Math.abs(Hash.string(`${stack.identity.projectRoot}:${stack.id}:${key}`)) % portSpan; -/** Coordinates durable public claims while retaining each successfully bound listener. */ -export const makePorts = (state: State.Interface) => +/** A wildcard listener is reachable through loopback, where a same-port loopback listener answers too. */ +const probeHost = (host: string) => + host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host; + +/** A refused loopback connect can take seconds on Windows, so silence within the bound counts as vacant. */ +export const accepts = (host: string, port: number) => + Effect.callback<boolean>((resume) => { + const socket = Net.connect({ host: probeHost(host), port }); + const settle = (listening: boolean) => { + socket.destroy(); + resume(Effect.succeed(listening)); + }; + socket.once("connect", () => settle(true)); + socket.on("error", () => settle(false)); + return Effect.sync(() => { + socket.destroy(); + }); + }).pipe(Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed(false) })); + +/** Linux rejects a bind that overlaps a same-family listener on the port; macOS, BSD, and Windows accept it. */ +const bindsCanOverlap = (platform: NodeJS.Platform) => platform !== "linux"; + +const loopbackOccupied = (port: number) => + Effect.forEach(["127.0.0.1", "::1"], (host) => accepts(host, port), { + concurrency: "unbounded", + }).pipe(Effect.map((answers) => answers.some(Boolean))); + +const claimantOf = (stacks: ReadonlyArray<State.StackClaims>, stackId: string, port: number) => + stacks.find((other) => other.id !== stackId && other.ports.some((claim) => claim.port === port)) + ?.id; + +const resolveRequest = ( + stack: State.SavedStack, + request: PortRequest, +): Effect.Effect< + { readonly saved: State.PortClaim | undefined; readonly requested: number | "auto" }, + PortError +> => { + const saved = stack.ports.find((entry) => entry.key === request.key); + if ( + saved !== undefined && + (saved.host !== request.host || (request.port !== "auto" && saved.port !== request.port)) + ) + return Effect.fail( + new PortError({ + key: request.key, + message: "The requested listener differs from its saved assignment", + }), + ); + const requested = saved?.port ?? request.port; + if (requested === "auto") return Effect.succeed({ saved, requested }); + if (!Number.isInteger(requested) || requested < 1 || requested > 65535) + return Effect.fail(new PortError({ key: request.key, message: "Invalid public port" })); + if (stack.ports.some((claim) => claim.key !== request.key && claim.port === requested)) + return Effect.fail( + new PortError({ + key: request.key, + message: `Public port ${requested} is claimed by another listener of this stack`, + }), + ); + return Effect.succeed({ saved, requested }); +}; + +/** Claims steer auto allocation away from saved stacks; live listeners and binds decide conflicts for fixed ports. */ +export const makePorts = (state: State.Interface, platform: NodeJS.Platform = process.platform) => Effect.sync(() => { - const acquire = Effect.fn("Ports.acquire")( - <A, R>( - request: PortRequest, - bind: (host: string, port: number) => Effect.Effect<A, PortError, R | Scope.Scope>, - ) => - state.withLock( - Effect.gen(function* () { - const stack = yield* state.read(request.stackId); - if (stack === undefined) - return yield* new PortError({ key: request.key, message: "Stack is not registered" }); - const saved = stack.ports.find((entry) => entry.key === request.key); - if ( - saved !== undefined && - (saved.host !== request.host || - (request.port !== "auto" && saved.port !== request.port)) - ) - return yield* new PortError({ - key: request.key, - message: "The requested listener differs from its saved assignment", - }); - const requested = saved?.port ?? request.port; + const describe = (id: string) => + state.read(id).pipe( + Effect.map((saved) => + saved === undefined + ? id + : `"${saved.identity.stackName}" on ${saved.identity.branchContext} in ${saved.identity.projectRoot}`, + ), + Effect.orElseSucceed(() => id), + ); + + const claimedBy = (stacks: ReadonlyArray<State.StackClaims>, stackId: string, port: number) => { + const claimant = claimantOf(stacks, stackId, port); + return claimant === undefined + ? Effect.succeed("") + : describe(claimant).pipe(Effect.map((stack) => `; stack ${stack} claims this port`)); + }; + + // A caller may serve several claims of one stack from a listener it acquired here, so that + // listener is not a foreign occupant. + const held = new Map<string, number>(); + const hold = (scope: Scope.Scope, stackId: string, port: number) => { + const key = `${stackId}:${port}`; + return Effect.sync(() => held.set(key, (held.get(key) ?? 0) + 1)).pipe( + Effect.andThen( + Scope.addFinalizer( + scope, + Effect.sync(() => { + const remaining = (held.get(key) ?? 1) - 1; + if (remaining > 0) held.set(key, remaining); + else held.delete(key); + }), + ), + ), + ); + }; + + /** Advisory and outside the registry lock; the bind under the lock still decides. */ + const rejectOccupied = Effect.fn("Ports.rejectOccupied")(function* (request: PortRequest) { + const preview = yield* state.read(request.stackId); + if (preview === undefined) return; + const { requested } = yield* resolveRequest(preview, request); + if ( + requested === "auto" || + !bindsCanOverlap(platform) || + held.has(`${request.stackId}:${requested}`) || + !(yield* loopbackOccupied(requested)) + ) + return; + return yield* new PortError({ + key: request.key, + message: `Public port ${requested} for ${request.key} at ${request.host}:${requested} is already in use${yield* claimedBy(yield* state.claims, request.stackId, requested)}`, + }); + }); + + const acquire = Effect.fn("Ports.acquire")(function* <A, R>( + request: PortRequest, + bind: (host: string, port: number) => Effect.Effect<A, PortError, R | Scope.Scope>, + ) { + yield* rejectOccupied(request); + return yield* state.withLock( + Effect.gen(function* () { + const stack = yield* state.read(request.stackId); + if (stack === undefined) + return yield* new PortError({ key: request.key, message: "Stack is not registered" }); + const { saved, requested } = yield* resolveRequest(stack, request); + const others = yield* state.claims; + const claimed = new Set([ + ...stack.ports.filter((claim) => claim.key !== request.key).map((claim) => claim.port), + ...others + .filter((other) => other.id !== request.stackId) + .flatMap((other) => other.ports.map((claim) => claim.port)), + ]); + + const owner = yield* Scope.Scope; + const start = scanStart(stack, request.key); + let failures = 0; + let lastFailure: PortError | undefined; + for (let attempt = 0; attempt < portSpan && failures < 64; attempt++) { + const port = + requested === "auto" + ? portBase + ((start + attempt * portStride) % portSpan) + : requested; + if (requested === "auto" && claimed.has(port)) continue; + const result = yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const scope = yield* Scope.fork(owner, "sequential"); + return yield* restore( + Effect.gen(function* () { + const listener = yield* bind(request.host, port).pipe( + Effect.mapError( + (cause) => + new PortError({ + key: request.key, + message: `Cannot bind ${request.key} at ${request.host}:${port}: ${cause.message}`, + cause, + }), + ), + Effect.provideService(Scope.Scope, scope), + ); + if (saved === undefined) + yield* state.save({ + ...stack, + ports: [...stack.ports, { key: request.key, host: request.host, port }], + }); + return { port, listener }; + }), + ).pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) + ? Scope.close(scope, exit) + : hold(scope, request.stackId, port), + ), + Effect.exit, + ); + }), + ); + if (Exit.isSuccess(result)) return result.value; + const error = Cause.findErrorOption(result.cause); if ( - requested !== "auto" && - (!Number.isInteger(requested) || requested < 1 || requested > 65535) + Exit.hasInterrupts(result) || + Exit.hasDies(result) || + Option.isNone(error) || + !(error.value instanceof PortError) ) - return yield* new PortError({ key: request.key, message: "Invalid public port" }); - - const claimed = new Set<number>(); - for (const other of yield* state.list) - for (const claim of other.ports) - if (other.id !== request.stackId || claim.key !== request.key) - claimed.add(claim.port); - if (requested !== "auto" && claimed.has(requested)) + return yield* Effect.failCause(result.cause); + if (requested !== "auto") return yield* new PortError({ key: request.key, - message: `Public port ${requested} is claimed by another listener`, + message: `${error.value.message}${yield* claimedBy(others, request.stackId, requested)}`, + cause: error.value.cause, }); - - const owner = yield* Scope.Scope; - const start = scanStart(stack, request.key); - let failures = 0; - let lastFailure: PortError | undefined; - for (let attempt = 0; attempt < portSpan && failures < 64; attempt++) { - const port = - requested === "auto" - ? portBase + ((start + attempt * portStride) % portSpan) - : requested; - if (claimed.has(port)) continue; - const result = yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const scope = yield* Scope.fork(owner, "sequential"); - return yield* restore( - Effect.gen(function* () { - const listener = yield* bind(request.host, port).pipe( - Effect.mapError( - (cause) => - new PortError({ - key: request.key, - message: `Cannot bind ${request.key} at ${request.host}:${port}: ${cause.message}`, - cause, - }), - ), - Effect.provideService(Scope.Scope, scope), - ); - if (saved === undefined) - yield* state.save({ - ...stack, - ports: [...stack.ports, { key: request.key, host: request.host, port }], - }); - return { port, listener }; - }), - ).pipe( - Effect.onExit((exit) => - Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void, - ), - Effect.exit, - ); - }), - ); - if (Exit.isSuccess(result)) return result.value; - const error = Cause.findErrorOption(result.cause); - if ( - requested !== "auto" || - Exit.hasInterrupts(result) || - Exit.hasDies(result) || - Option.isNone(error) || - !(error.value instanceof PortError) - ) - return yield* Effect.failCause(result.cause); - failures++; - lastFailure = error.value; - } - return yield* new PortError({ - key: request.key, - message: - lastFailure === undefined - ? "No public port is available" - : `No public port is available: ${lastFailure.message}`, - cause: lastFailure, - }); - }), - ), - ); + failures++; + lastFailure = error.value; + } + return yield* new PortError({ + key: request.key, + message: + lastFailure === undefined + ? "No public port is available" + : `No public port is available: ${lastFailure.message}`, + cause: lastFailure, + }); + }), + ); + }); const release = Effect.fn("Ports.release")(function* (stackId: string, key: string) { yield* state.withLock( diff --git a/packages/stack/src/PromiseClient.ts b/packages/stack/src/PromiseClient.ts new file mode 100644 index 0000000000..e834f6cb37 --- /dev/null +++ b/packages/stack/src/PromiseClient.ts @@ -0,0 +1,432 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { + Cause, + Effect, + Exit, + Layer, + ManagedRuntime, + Redacted, + Schema, + Scope, + Stream, +} from "effect"; +import type * as StackEffect from "./effect.ts"; +import { failureMessage } from "./internal/failure-message.ts"; +import { StackError } from "./Rpc.ts"; +import { ServiceCreationInput as CreationSchema } from "./services/Catalog.ts"; +import type { InitializationCommand, PgProveOptions, PostgresCommand } from "./Commands.ts"; + +/** Optional cancellation ends the caller's wait, or its attached command job. */ +export interface CallOptions { + readonly signal?: AbortSignal; +} + +/** Replaces `Redacted` secrets with their plain values. */ +export type Plain<T> = + T extends Redacted.Redacted<infer A> + ? A + : T extends (...args: never) => unknown + ? T + : T extends object + ? { [K in keyof T]: Plain<T[K]> } + : T; +type PlainArguments<P extends ReadonlyArray<unknown>> = { [K in keyof P]: Plain<P[K]> }; + +type Effectful = Effect.Effect<unknown, unknown, unknown>; +/** + * The Promise form of an Effect handle: an Effect becomes a call, an Effect-returning function + * gains trailing call options and plain inputs, and a Stream becomes an async iterable. Results + * are data; operations that return handles are adapted explicitly. + */ +export type Promised<T> = T extends Effectful + ? (options?: CallOptions) => Promise<Effect.Success<T>> + : T extends Stream.Stream<infer A, unknown, unknown> + ? () => AsyncIterable<A> + : T extends (...args: infer P) => infer R + ? R extends Effectful + ? (...args: [...PlainArguments<P>, callOptions?: CallOptions]) => Promise<Effect.Success<R>> + : T + : T extends object + ? { readonly [K in keyof T]: Promised<T[K]> } + : T; + +type Kind = StackEffect.ServiceCreationInput["service"]; +type Flatten<T> = { [P in keyof T]: T[P] }; +/** Handles by kind: always present for a required kind, possibly absent for an optional one. */ +export type ServiceHandles< + Required extends Kind, + Optional extends Kind, + Instances extends { readonly [P in Kind]: unknown }, +> = Flatten< + { readonly [P in Required]: Instances[P] } & { + readonly [P in Exclude<Optional, Required>]?: Instances[P]; + } +>; +/** Plain service configuration accepted by non-Effect callers. */ +export type ServiceCreationInput = Plain<StackEffect.ServiceCreationInput>; +/** A database instance with stopped-data snapshot operations. */ +export interface DatabaseInstance extends Promised<StackEffect.DatabaseInstance> {} +/** Maps creation discriminators to their supported instance operations. */ +export type ServiceInstances = { + readonly [K in Kind]: K extends "database" + ? DatabaseInstance + : Promised<StackEffect.ServiceInstances[K]>; +}; +/** An individual service; closing the client does not stop this process. */ +export type ServiceInstance<K extends Kind = Kind> = ServiceInstances[K]; +/** Streaming inputs and awaited output sinks for a PostgreSQL command. */ +export interface PostgresCommandOptions { + readonly args?: ReadonlyArray<string>; + readonly env?: Readonly<Record<string, string>>; + readonly pgProve?: PgProveOptions; + readonly stdin?: AsyncIterable<Uint8Array>; + readonly stdout: (bytes: Uint8Array) => void | Promise<void>; + readonly stderr: (bytes: Uint8Array) => void | Promise<void>; +} +/** Output sinks for a finite service initialization command. */ +export interface InitializationCommandOptions { + readonly stdout?: (bytes: Uint8Array) => void | Promise<void>; + readonly stderr?: (bytes: Uint8Array) => void | Promise<void>; +} +type CommandOptions = Partial<PostgresCommandOptions>; +type CommandResult = Effect.Success<ReturnType<StackEffect.Stack["commands"]["run"]>>; +/** Runs a finite PostgreSQL or service initialization command. */ +interface CommandRunner { + ( + command: PostgresCommand, + options: PostgresCommandOptions, + callOptions?: CallOptions, + ): Promise<CommandResult>; + ( + command: InitializationCommand, + options?: InitializationCommandOptions, + callOptions?: CallOptions, + ): Promise<CommandResult>; +} +type DerivedStack = Promised<StackEffect.Stack>; +/** A Promise client; closing the creating client of a session stack destroys the stack. */ +export interface Stack extends Omit<DerivedStack, "services" | "composition" | "commands"> { + readonly services: { + readonly create: <Input extends ServiceCreationInput>( + creation: Input, + options?: CallOptions, + ) => Promise<ServiceInstances[Input["service"]]>; + readonly get: ( + ...args: Parameters<DerivedStack["services"]["get"]> + ) => Promise<ServiceInstance>; + readonly list: (options?: CallOptions) => Promise<ReadonlyArray<ServiceInstance>>; + }; + readonly composition: Omit<DerivedStack["composition"], "supabase"> & { + readonly supabase: ( + ...args: Parameters<DerivedStack["composition"]["supabase"]> + ) => Promise<ReadonlyArray<ServiceInstance>>; + }; + readonly commands: Omit<DerivedStack["commands"], "run"> & { readonly run: CommandRunner }; + /** Disposes this client and ends its active observation iterators. */ + readonly close: () => Promise<void>; +} + +const clientLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); +/** Every failure and defect of a cause, so none is hidden behind the first. */ +const causeErrors = (cause: Cause.Cause<unknown>): ReadonlyArray<unknown> => + cause.reasons.flatMap((reason) => + Cause.isFailReason(reason) ? [reason.error] : Cause.isDieReason(reason) ? [reason.defect] : [], + ); +type ClientServices = Layer.Success<typeof clientLayer>; + +/** Runs Effects for one Promise client and owns the scope its handles live in. */ +export interface Client { + readonly run: <A, E>( + effect: Effect.Effect<A, E, ClientServices>, + options?: CallOptions, + ) => Promise<A>; + readonly iterable: <A, E>(stream: Stream.Stream<A, E>) => AsyncIterable<A>; + readonly close: () => Promise<void>; +} + +const makeClient = ( + runtime: ManagedRuntime.ManagedRuntime<ClientServices, never>, + scope: Scope.Closeable, +): Client => { + const activeIterators = new Set<() => Promise<void>>(); + let clientClosePromise: Promise<void> | undefined; + const iterable = <A, E>(stream: Stream.Stream<A, E>): AsyncIterable<A> => ({ + [Symbol.asyncIterator]() { + if (clientClosePromise !== undefined) throw new Error("Stack client is closed"); + const iterator = runtime + .runSync(Stream.toAsyncIterableEffect(stream)) + [Symbol.asyncIterator](); + const iteratorReturn = iterator.return; + const iteratorThrow = iterator.throw; + let iteratorClosePromise: Promise<IteratorResult<A>> | undefined; + let dispose: () => Promise<void>; + const close = (): Promise<IteratorResult<A>> => { + if (iteratorClosePromise !== undefined) return iteratorClosePromise; + const promise: Promise<IteratorResult<A>> = ( + iteratorReturn === undefined + ? Promise.resolve<IteratorResult<A>>({ done: true, value: undefined }) + : iteratorReturn(undefined) + ).finally(() => activeIterators.delete(dispose)); + iteratorClosePromise = promise; + return promise; + }; + dispose = () => close().then(() => undefined); + activeIterators.add(dispose); + const settle = (result: Promise<IteratorResult<A>>) => + result.then( + (next) => { + if (next.done) activeIterators.delete(dispose); + return next; + }, + (error: unknown) => { + activeIterators.delete(dispose); + return Promise.reject(error); + }, + ); + return { + next: (value?: unknown) => settle(iterator.next(value)), + return: close, + ...(iteratorThrow === undefined + ? {} + : { throw: (error?: unknown) => settle(iteratorThrow(error)) }), + }; + }, + }); + const failures: Array<unknown> = []; + const record = (error: unknown) => { + failures.push(error); + }; + return { + run: (effect, options) => runtime.runPromise(effect, options), + iterable, + close: () => { + if (clientClosePromise !== undefined) return clientClosePromise; + clientClosePromise = Promise.allSettled([...activeIterators].map((dispose) => dispose())) + .then((iterators) => { + for (const result of iterators) if (result.status === "rejected") record(result.reason); + return runtime.runPromiseExit(Scope.close(scope, Exit.void)); + }) + .then((exit) => { + if (Exit.isFailure(exit)) failures.push(...causeErrors(exit.cause)); + }) + .then(() => runtime.dispose()) + .catch(record) + .then(() => { + if (failures.length === 1) return Promise.reject(failures[0]); + if (failures.length > 1) + return Promise.reject(new AggregateError(failures, "Stack client close failed")); + }); + return clientClosePromise; + }, + }; +}; + +/** Runs a scoped acquisition whose handles stay usable until the returned client closes. */ +export const acquire = <A, E>( + effect: Effect.Effect<A, E, ClientServices | Scope.Scope>, + options?: CallOptions, +): Promise<{ readonly value: A; readonly client: Client }> => { + const runtime = ManagedRuntime.make(clientLayer); + const scope = runtime.runSync(Scope.make()); + const client = makeClient(runtime, scope); + return runtime.runPromise(effect.pipe(Scope.provide(scope)), options).then( + (value) => ({ value, client }), + (error: unknown) => + client.close().then( + () => Promise.reject(error), + (closeError: unknown) => + Promise.reject( + new AggregateError([error, closeError], "Stack acquisition and its cleanup failed"), + ), + ), + ); +}; + +/** Runs one unscoped operation with the client services. */ +export const runOnce = <A, E>( + effect: Effect.Effect<A, E, ClientServices>, + options?: CallOptions, +): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(clientLayer)), options); + +const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => + typeof value === "object" && value !== null && !Array.isArray(value); +const isFunction = (value: unknown): value is (...args: ReadonlyArray<unknown>) => unknown => + typeof value === "function"; +// Handle operations and streams need no services and fail with `StackError`. +const isOperation = (value: unknown): value is Effect.Effect<unknown, StackError> => + Effect.isEffect(value); +const isStream = (value: unknown): value is Stream.Stream<unknown, StackError> => + Stream.isStream(value); +/** No Effect operation takes a signal-only object, so one in last position is call options. */ +const isCallOptions = (value: unknown): value is CallOptions => + isRecord(value) && Object.keys(value).every((key) => key === "signal"); + +/** Adapts the operations of an Effect handle; their results pass through as data. */ +const makeAdapter = (client: Client) => { + const call = (result: unknown, options: CallOptions | undefined) => + isOperation(result) ? client.run(result, options) : result; + function promised<T>(value: T): Promised<T>; + function promised(value: unknown): unknown { + if (isStream(value)) return () => client.iterable(value); + if (isOperation(value)) return (options?: CallOptions) => call(value, options); + if (isFunction(value)) + return (...args: ReadonlyArray<unknown>) => { + const last = args.at(-1); + return isCallOptions(last) + ? call(value(...args.slice(0, -1)), last) + : call(value(...args), undefined); + }; + if (isRecord(value)) + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, promised(item)])); + return value; + } + return promised; +}; + +const creationJson = Schema.toCodecJson(CreationSchema); +const decodeCreation = (operation: string, creation: unknown) => + Schema.decodeUnknownEffect(creationJson)(creation).pipe( + Effect.mapError((cause) => new StackError({ operation, message: cause.message })), + ); +const decodeCreations = (operation: string, creations: ReadonlyArray<unknown>) => + Effect.forEach(creations, (creation) => decodeCreation(operation, creation)); +const sinkError = (cause: unknown) => + new StackError({ + operation: "command-stream", + message: failureMessage(cause), + }); +const sink = (write?: (bytes: Uint8Array) => void | Promise<void>) => (bytes: Uint8Array) => + write === undefined + ? Effect.void + : Effect.tryPromise({ try: () => Promise.resolve(write(bytes)), catch: sinkError }); + +/** Builds the Promise forms of a client's stack and service handles. */ +export const stackAdapter = (client: Client) => { + const promised = makeAdapter(client); + function instance<K extends Kind>(service: StackEffect.ServiceInstances[K]): ServiceInstances[K]; + function instance(service: { + readonly service: Kind; + readonly restart: (input?: StackEffect.ServiceCreationInput) => Effect.Effect<void, StackError>; + }): unknown { + return { + ...promised(service), + restart: (input?: { readonly config: unknown }, options?: CallOptions) => + client.run( + input === undefined + ? service.restart() + : decodeCreation( + "restart", + "service" in input ? input : { service: service.service, config: input.config }, + ).pipe( + Effect.flatMap((creation) => + creation.service === service.service + ? service.restart(creation) + : Effect.fail( + new StackError({ + operation: "restart", + message: "Service kind cannot change", + }), + ), + ), + ), + options, + ), + }; + } + function services<Required extends Kind, Optional extends Kind>( + handles: ServiceHandles<Required, Optional, StackEffect.ServiceInstances>, + ): ServiceHandles<Required, Optional, ServiceInstances>; + function services( + handles: Readonly<Record<string, StackEffect.ServiceInstances[Kind]>>, + ): Readonly<Record<string, unknown>> { + return Object.fromEntries( + Object.entries(handles).map(([kind, handle]) => [kind, instance(handle)]), + ); + } + const stack = (handle: StackEffect.Stack): Stack => { + const derived = promised(handle); + const instances = (handles: ReadonlyArray<StackEffect.ServiceInstances[Kind]>) => + handles.map((service) => instance(service)); + function create<Input extends ServiceCreationInput>( + creation: Input, + options?: CallOptions, + ): Promise<ServiceInstances[Input["service"]]>; + function create(creation: ServiceCreationInput, options?: CallOptions): Promise<unknown> { + return client.run( + decodeCreation("createService", creation).pipe( + Effect.flatMap(handle.services.create), + Effect.map((service) => instance(service)), + ), + options, + ); + } + function run( + command: PostgresCommand, + commandOptions: PostgresCommandOptions, + options?: CallOptions, + ): Promise<CommandResult>; + function run( + command: InitializationCommand, + commandOptions?: InitializationCommandOptions, + options?: CallOptions, + ): Promise<CommandResult>; + function run( + command: PostgresCommand | InitializationCommand, + commandOptions?: CommandOptions, + options?: CallOptions, + ): Promise<CommandResult> { + if ("type" in command) + return client.run( + handle.commands.run(command, { + stdout: sink(commandOptions?.stdout), + stderr: sink(commandOptions?.stderr), + }), + options, + ); + return client.run( + handle.commands.run(command, { + args: commandOptions?.args, + env: commandOptions?.env, + pgProve: commandOptions?.pgProve, + ...(commandOptions?.stdin === undefined + ? {} + : { stdin: Stream.fromAsyncIterable(commandOptions.stdin, sinkError) }), + stdout: sink(commandOptions?.stdout), + stderr: sink(commandOptions?.stderr), + }), + options, + ); + } + return { + ...derived, + services: { + create, + get: (id, options) => + client.run( + Effect.map(handle.services.get(id), (service) => instance(service)), + options, + ), + list: (options) => client.run(Effect.map(handle.services.list, instances), options), + }, + composition: { + ...derived.composition, + supabase: (creations, compositionOptions, options) => + client.run( + decodeCreations("supabaseComposition", creations).pipe( + Effect.flatMap((decoded) => handle.composition.supabase(decoded, compositionOptions)), + Effect.map(instances), + ), + options, + ), + plan: (creations, options) => + client.run( + decodeCreations("plan", creations).pipe(Effect.flatMap(handle.composition.plan)), + options, + ), + }, + commands: { ...derived.commands, run }, + close: client.close, + }; + }; + return { stack, services }; +}; diff --git a/packages/stack/src/PromiseClient.unit.test.ts b/packages/stack/src/PromiseClient.unit.test.ts new file mode 100644 index 0000000000..a8055027d5 --- /dev/null +++ b/packages/stack/src/PromiseClient.unit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { acquire } from "./PromiseClient.ts"; +import { StackError } from "./Rpc.ts"; + +const failingFinalizers = Effect.addFinalizer(() => Effect.die(new Error("first finalizer"))).pipe( + Effect.andThen(Effect.addFinalizer(() => Effect.die(new Error("second finalizer")))), +); +const messages = (error: unknown): ReadonlyArray<string> => + error instanceof AggregateError + ? error.errors.flatMap(messages) + : [error instanceof Error ? error.message : String(error)]; + +describe("Promise client cleanup", () => { + it.effect("reports every finalizer failure when a client closes", () => + Effect.gen(function* () { + const { client } = yield* Effect.promise(() => acquire(failingFinalizers)); + + const failure = yield* Effect.promise(() => + client.close().then( + () => undefined, + (error: unknown) => error, + ), + ); + + expect(failure).toBeInstanceOf(AggregateError); + expect([...messages(failure)].sort()).toEqual(["first finalizer", "second finalizer"]); + }), + ); + + it.effect("keeps the acquisition failure and its cleanup failures together", () => + Effect.gen(function* () { + const failure = yield* Effect.promise(() => + acquire( + failingFinalizers.pipe( + Effect.andThen( + Effect.fail(new StackError({ operation: "create", message: "refused" })), + ), + ), + ).then( + () => undefined, + (error: unknown) => error, + ), + ); + + expect(failure).toBeInstanceOf(AggregateError); + expect(failure instanceof AggregateError ? failure.errors[0] : undefined).toBeInstanceOf( + StackError, + ); + expect([...messages(failure)].sort()).toEqual([ + "first finalizer", + "refused", + "second finalizer", + ]); + }), + ); +}); diff --git a/packages/stack/src/Proxy.integration.test.ts b/packages/stack/src/Proxy.integration.test.ts index db73663b91..e08b81eaa2 100644 --- a/packages/stack/src/Proxy.integration.test.ts +++ b/packages/stack/src/Proxy.integration.test.ts @@ -9,7 +9,12 @@ import { } from "effect/unstable/http"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- NodeHttpServer.make requires a native server factory. import * as Http from "node:http"; -import { bindTcp, serveTcp } from "./Proxy.ts"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- raw client and backend fixtures for connection failures. +import { createServer, Socket, type Server } from "node:net"; +import { captureLogs } from "../tests/logs.ts"; +import { bindTcp, serveTcp, ProxyError } from "./Proxy.ts"; + +const captureErrors = captureLogs(["Error"]); it.live( "waits for target readiness and forwards a streamed response through its retained TCP listener", @@ -41,7 +46,7 @@ it.live( const target = Effect.acquireRelease(Deferred.succeed(acquired, undefined), () => Deferred.succeed(released, undefined), ).pipe(Effect.andThen(Deferred.await(ready)), Effect.as(address)); - yield* serveTcp(listener, target).pipe(Effect.forkScoped); + yield* serveTcp(listener, target, "backend").pipe(Effect.forkScoped); const body = new Uint8Array(2 * 1024 * 1024).fill(71); const client = yield* HttpClient.HttpClient; const request = client @@ -64,3 +69,83 @@ it.live( }), ).pipe(Effect.provide(NodeHttpClient.layerNodeHttp)), ); + +const connectAndAwaitClose = (port: number) => + Effect.callback<void, never>((resume) => { + const socket = new Socket(); + socket.once("close", () => resume(Effect.void)); + socket.once("error", () => undefined); + socket.connect(port, "127.0.0.1"); + return Effect.sync(() => socket.destroy()); + }).pipe(Effect.timeout("5 seconds")); + +it.live("logs one error naming the endpoint when its target fails to wake", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const listener = yield* bindTcp("127.0.0.1", 0); + if (!Predicate.isTagged(listener.address, "TcpAddress")) + return yield* Effect.die("Expected TCP listener"); + const port = listener.address.port; + const target = Effect.fail(new ProxyError({ message: "wake failed" })); + yield* serveTcp(listener, target, "db:sql").pipe(Effect.forkScoped); + yield* connectAndAwaitClose(port); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Endpoint db:sql failed"); + }), + ).pipe(Effect.provide(captureErrors(logs))); +}); + +interface ResetBackend { + readonly port: number; + readonly server: Server; +} + +// Resets only after receiving data, so the reset always lands after a completed handshake. +const listenResetBackend = () => + Effect.acquireRelease( + Effect.callback<ResetBackend, never>((resume) => { + const server = createServer((socket) => { + socket.once("data", () => socket.resetAndDestroy()); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + resume( + Effect.succeed({ + port: typeof address === "object" && address !== null ? address.port : 0, + server, + }), + ); + }); + return Effect.void; + }), + ({ server }) => + Effect.callback<void, never>((resume) => { + server.close(() => resume(Effect.void)); + return Effect.void; + }), + ); + +it.live("does not log an error when a backend copy resets after a successful connect", () => { + const logs: Array<string> = []; + return Effect.scoped( + Effect.gen(function* () { + const listener = yield* bindTcp("127.0.0.1", 0); + if (!Predicate.isTagged(listener.address, "TcpAddress")) + return yield* Effect.die("Expected TCP listener"); + const port = listener.address.port; + const backend = yield* listenResetBackend(); + const target = Effect.succeed({ host: "127.0.0.1", port: backend.port }); + yield* serveTcp(listener, target, "db:sql").pipe(Effect.forkScoped); + yield* Effect.callback<void, never>((resume) => { + const socket = new Socket(); + socket.once("close", () => resume(Effect.void)); + socket.once("error", () => undefined); + socket.once("connect", () => socket.write("ping")); + socket.connect(port, "127.0.0.1"); + return Effect.sync(() => socket.destroy()); + }).pipe(Effect.timeout("5 seconds")); + expect(logs).toHaveLength(0); + }), + ).pipe(Effect.provide(captureErrors(logs))); +}); diff --git a/packages/stack/src/Proxy.ts b/packages/stack/src/Proxy.ts index 23006fe87d..9ac7018056 100644 --- a/packages/stack/src/Proxy.ts +++ b/packages/stack/src/Proxy.ts @@ -59,6 +59,7 @@ export const serveTcp = Effect.fn("Proxy.serveTcp")( ( listener: SocketServer.SocketServer["Service"], target: Effect.Effect<BackendAddress, ProxyError, Scope.Scope>, + label: string, ) => listener.run(() => Effect.scoped( @@ -86,8 +87,14 @@ export const serveTcp = Effect.fn("Proxy.serveTcp")( }); }); yield* Effect.gen(function* () { - const address = yield* target; - const backend = yield* connect(address); + const backend = yield* Effect.gen(function* () { + const address = yield* target; + return yield* connect(address); + }).pipe( + Effect.tapError((cause) => + Effect.logError(`Endpoint ${label} failed: ${cause.message}`), + ), + ); yield* Effect.all([copy(incoming, backend), copy(backend, incoming)], { concurrency: "unbounded", discard: true, diff --git a/packages/stack/src/Rpc.ts b/packages/stack/src/Rpc.ts index 6dab524166..9b296509c4 100644 --- a/packages/stack/src/Rpc.ts +++ b/packages/stack/src/Rpc.ts @@ -1,9 +1,11 @@ -import { Data, Schema } from "effect"; +import { Exit, Schema } from "effect"; import { Rpc, RpcGroup } from "effect/unstable/rpc"; import { ServiceCreation, ServiceCreationInput } from "./services/Catalog.ts"; -import { CompositionConfig } from "./Orchestrator.ts"; -import { PgProveOptions, PostgresTool } from "./Tools.ts"; -import { StackIdentityInput } from "./State.ts"; +import { snapshotScopes } from "./services/DatabaseSnapshot.ts"; +import { causeMessage, CompositionConfig, OrchestratorError } from "./Orchestrator.ts"; +import { CommandInvocation } from "./Commands.ts"; +import { StackKeysInput } from "./State.ts"; +import { failureMessage } from "./internal/failure-message.ts"; const Outcome = Schema.Struct({ id: Schema.String, @@ -11,15 +13,36 @@ const Outcome = Schema.Struct({ error: Schema.optionalKey(Schema.String), }); -export const StackErrorSchema = Schema.TaggedStruct("StackError", { +/** A typed failure returned by the stack owner. */ +export class StackError extends Schema.TaggedError<StackError>()("StackError", { operation: Schema.String, message: Schema.String, outcomes: Schema.optionalKey(Schema.Array(Outcome)), -}); -type StackErrorPayload = Omit<Schema.Schema.Type<typeof StackErrorSchema>, "_tag">; + /** + * Why the client could not use an owner: none serves the stack (`owner-unavailable`), one of + * another release does (`release-mismatch`), or its container engine is unreachable + * (`runtime-unavailable`). + */ + reason: Schema.optionalKey( + Schema.Literals(["owner-unavailable", "release-mismatch", "runtime-unavailable"]), + ), +}) {} -/** A typed failure returned by the stack owner. */ -export class StackError extends Data.TaggedError("StackError")<StackErrorPayload> {} +/** Maps an owner failure to the RPC error, preserving per-member composition outcomes. */ +export const stackError = (operation: string, cause: unknown): StackError => { + if (Schema.is(StackError)(cause)) return cause; + if (cause instanceof OrchestratorError && cause.outcomes !== undefined) + return new StackError({ + operation, + message: failureMessage(cause), + outcomes: cause.outcomes.map(({ id, result }) => ({ + id, + succeeded: Exit.isSuccess(result), + ...(Exit.isFailure(result) ? { error: causeMessage(result.cause) } : {}), + })), + }); + return new StackError({ operation, message: failureMessage(cause) }); +}; const ServiceErrorSchema = Schema.TaggedStruct("ServiceError", { operation: Schema.String, @@ -56,89 +79,89 @@ export const Definition = Schema.Struct({ id: Schema.String, creation: ServiceCr export interface Definition extends Schema.Schema.Type<typeof Definition> {} const Instance = { id: Schema.String }; +const SnapshotScope = Schema.Literals(snapshotScopes); const Log = Schema.Struct({ stream: Schema.Literals(["stdout", "stderr"]), bytes: Schema.Uint8ArrayFromBase64, }); -export const ToolEvent = Schema.TaggedUnion({ +export const CommandEvent = Schema.TaggedUnion({ Attached: { attachmentId: Schema.String }, Stdout: { bytes: Schema.Uint8ArrayFromBase64 }, Stderr: { bytes: Schema.Uint8ArrayFromBase64 }, Completed: { jobId: Schema.String, exitCode: Schema.Int }, }); +export const RunCommandPayload = Schema.Struct({ + attachmentId: Schema.String, + command: CommandInvocation, +}).annotate({ parseOptions: { onExcessProperty: "error" } }); +export type RunCommandPayload = Schema.Schema.Type<typeof RunCommandPayload>; -/** The private transport contract; lifecycle admission remains in the owner. */ -export const StackRpc = RpcGroup.make( +/** Instance and composition operations served by the owner. */ +export const OwnerRpc = RpcGroup.make( Rpc.make("createService", { payload: ServiceCreationInput, success: Definition, - error: StackErrorSchema, + error: StackError, }), - Rpc.make("getService", { payload: Instance, success: Definition, error: StackErrorSchema }), - Rpc.make("listServices", { success: Schema.Array(Definition), error: StackErrorSchema }), - Rpc.make("startService", { payload: Instance, error: StackErrorSchema }), - Rpc.make("readyService", { payload: Instance, error: StackErrorSchema }), - Rpc.make("stopService", { payload: Instance, error: StackErrorSchema }), + Rpc.make("startService", { payload: Instance, error: StackError }), + Rpc.make("readyService", { payload: Instance, error: StackError }), + Rpc.make("stopService", { payload: Instance, error: StackError }), Rpc.make("restartService", { payload: { ...Instance, config: Schema.optionalKey(ServiceCreationInput) }, - error: StackErrorSchema, + error: StackError, }), - Rpc.make("destroyService", { payload: Instance, error: StackErrorSchema }), - Rpc.make("prepareService", { payload: Instance, error: StackErrorSchema }), - Rpc.make("status", { payload: Instance, success: Observation, error: StackErrorSchema }), + Rpc.make("destroyService", { payload: Instance, error: StackError }), + Rpc.make("prepareService", { payload: Instance, error: StackError }), + Rpc.make("status", { payload: Instance, success: Observation, error: StackError }), Rpc.make("followStatus", { payload: Instance, success: Observation, - error: StackErrorSchema, + error: StackError, stream: true, }), - Rpc.make("logs", { payload: Instance, success: Log, error: StackErrorSchema, stream: true }), + Rpc.make("logs", { payload: Instance, success: Log, error: StackError, stream: true }), Rpc.make("credentials", { payload: { ...Instance, from: Schema.Literals(["host", "runtime"]) }, success: Schema.Record(Schema.String, Schema.String), - error: StackErrorSchema, + error: StackError, }), Rpc.make("saveSnapshot", { - payload: { ...Instance, key: Schema.String }, - error: StackErrorSchema, + payload: { ...Instance, key: Schema.String, scope: Schema.optionalKey(SnapshotScope) }, + error: StackError, }), Rpc.make("restoreSnapshot", { - payload: { ...Instance, key: Schema.String }, + payload: { ...Instance, key: Schema.String, scope: Schema.optionalKey(SnapshotScope) }, success: Schema.Boolean, - error: StackErrorSchema, + error: StackError, }), - Rpc.make("resetData", { payload: Instance, error: StackErrorSchema }), + Rpc.make("resetData", { payload: Instance, error: StackError }), Rpc.make("supabaseComposition", { payload: { services: Schema.Array(ServiceCreationInput), reuseIds: Schema.optionalKey(Schema.Array(Schema.String)), - identity: Schema.optionalKey(StackIdentityInput), + keys: Schema.optionalKey(StackKeysInput), + eager: Schema.optionalKey(Schema.Boolean), }, success: Schema.Array(Definition), - error: StackErrorSchema, + error: StackError, }), - Rpc.make("configureComposition", { payload: CompositionConfig, error: StackErrorSchema }), - Rpc.make("getComposition", { success: CompositionConfig, error: StackErrorSchema }), - Rpc.make("startComposition", { success: Schema.Array(Observation), error: StackErrorSchema }), - Rpc.make("stopComposition", { success: Schema.Array(Observation), error: StackErrorSchema }), - Rpc.make("restartComposition", { success: Schema.Array(Observation), error: StackErrorSchema }), - Rpc.make("shutdown", { payload: { destroy: Schema.Boolean }, error: StackErrorSchema }), - Rpc.make("runTool", { - payload: { - attachmentId: Schema.String, - tool: PostgresTool, - args: Schema.Array(Schema.String), - env: Schema.Record(Schema.String, Schema.String), - pgProve: Schema.optionalKey(PgProveOptions), - stdin: Schema.Boolean, - }, - success: ToolEvent, - error: StackErrorSchema, + Rpc.make("configureComposition", { payload: CompositionConfig, error: StackError }), + Rpc.make("startComposition", { success: Schema.Array(Observation), error: StackError }), + Rpc.make("stopComposition", { success: Schema.Array(Observation), error: StackError }), + Rpc.make("restartComposition", { success: Schema.Array(Observation), error: StackError }), +); + +/** The private transport contract; lifecycle admission remains in the owner. */ +export const StackRpc = OwnerRpc.add( + Rpc.make("runCommand", { + payload: RunCommandPayload, + success: CommandEvent, + error: StackError, stream: true, }), - Rpc.make("toolInput", { + Rpc.make("commandInput", { payload: { attachmentId: Schema.String, bytes: Schema.NullOr(Schema.Uint8ArrayFromBase64) }, - error: StackErrorSchema, + error: StackError, }), ); diff --git a/packages/stack/src/Rpc.unit.test.ts b/packages/stack/src/Rpc.unit.test.ts new file mode 100644 index 0000000000..7cf057996b --- /dev/null +++ b/packages/stack/src/Rpc.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Schema } from "effect"; +import { OrchestratorError } from "./Orchestrator.ts"; +import { StackError, stackError } from "./Rpc.ts"; +import { ServiceError } from "./Service.ts"; + +const roundTrip = (error: StackError) => + Schema.encodeEffect(StackError)(error).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(StackError)), + ); + +describe("stackError", () => { + it.effect("describes a wrapped error without a message by its cause", () => + Effect.gen(function* () { + const cause = new Cause.UnknownError(new Error("container is gone")); + const failure = stackError( + "stop", + new ServiceError({ operation: "stop", message: cause.message, cause }), + ); + + expect((yield* roundTrip(failure)).message).toBe("container is gone"); + }), + ); + + it.effect("names an error whose causes carry no message", () => + Effect.gen(function* () { + const failure = stackError("stop", new Cause.UnknownError(undefined)); + + expect((yield* roundTrip(failure)).message).toBe("UnknownError"); + }), + ); + + it.effect("keeps composition outcomes when the orchestrator error lacks a message", () => + Effect.gen(function* () { + const cause = new Cause.UnknownError(42); + const failure = stackError( + "startComposition", + new OrchestratorError({ operation: "start", message: cause.message, cause, outcomes: [] }), + ); + + const decoded = yield* roundTrip(failure); + expect(decoded.message).toBe("42"); + expect(decoded.outcomes).toEqual([]); + }), + ); +}); diff --git a/packages/stack/src/Service.integration.test.ts b/packages/stack/src/Service.integration.test.ts index 16b104c005..2769658da9 100644 --- a/packages/stack/src/Service.integration.test.ts +++ b/packages/stack/src/Service.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Queue, Ref, Stream } from "effect"; +import { Cause, Deferred, Effect, Exit, Fiber, Queue, Ref, Scope, Stream } from "effect"; import { makeService, ServiceDestroyed, @@ -1110,3 +1110,244 @@ it.live("keeps a sleeping instance armed when exit observation retries failed re }), ), ); + +const unhealthy = (message: string) => new ServiceError({ operation: "health", message }); + +const makeProbedService = ( + check: (launch: number) => Effect.Effect<void, ServiceError>, + options: { readonly probe: boolean } = { probe: true }, +) => + Effect.gen(function* () { + const launches = yield* Ref.make(0); + const service = yield* makeService<Config>( + { + launch: () => + Ref.updateAndGet(launches, (count) => count + 1).pipe( + Effect.map((launch): RuntimeSession => ({ + health: check(launch), + ...(options.probe ? { probe: check(launch) } : {}), + exit: Effect.never, + stop: Effect.void, + remove: Effect.void, + })), + ), + removeData: () => Effect.void, + }, + { id: "probed", config: { version: 1 } }, + ); + return { service, launches }; + }); + +const startUnhealthy = (service: ServiceInstance<Config>) => + Effect.gen(function* () { + const failed = yield* waitForObservation(service, (value) => value.health === "unhealthy"); + yield* service.start; + yield* Fiber.join(failed); + }); + +describe("service readiness recovery", () => { + it.live("serves readiness once a running launch recovers from a failed health check", () => + Effect.scoped( + Effect.gen(function* () { + const healthy = yield* Ref.make(false); + const { service, launches } = yield* makeProbedService(() => + Ref.get(healthy).pipe( + Effect.flatMap((ok) => (ok ? Effect.void : Effect.fail(unhealthy("still booting")))), + ), + ); + yield* startUnhealthy(service); + expect(yield* Effect.flip(service.ready)).toMatchObject({ message: "still booting" }); + + yield* Ref.set(healthy, true); + yield* service.ready; + + expect(yield* service.get).toMatchObject({ + lifecycle: "running", + health: "healthy", + error: undefined, + launchId: 1, + }); + expect(yield* Ref.get(launches)).toBe(1); + yield* service.stop; + }), + ), + ); + + it.live("reports the initial check's failure to callers waiting on it without re-probing", () => + Effect.scoped( + Effect.gen(function* () { + const checks = yield* Ref.make(0); + const checkStarted = yield* Deferred.make<void>(); + const checkGate = yield* Deferred.make<void>(); + const { service } = yield* makeProbedService(() => + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? open(checkStarted).pipe( + Effect.andThen(Deferred.await(checkGate)), + Effect.andThen(Effect.fail(unhealthy("rest HTTP readiness timed out"))), + ) + : Effect.fail(unhealthy("probe replaced the initial failure")), + ), + ), + ); + yield* service.start; + yield* Deferred.await(checkStarted); + const waiting = yield* Effect.forkChild(Effect.flip(service.ready), { + startImmediately: true, + }); + + yield* open(checkGate); + + expect(yield* Fiber.join(waiting)).toMatchObject({ + message: "rest HTTP readiness timed out", + }); + expect(yield* Ref.get(checks)).toBe(1); + expect(yield* service.get).toMatchObject({ + health: "unhealthy", + error: { message: "rest HTTP readiness timed out" }, + }); + yield* service.stop; + }), + ), + ); + + it.live("keeps a failed check final for a session without a probe", () => + Effect.scoped( + Effect.gen(function* () { + const checks = yield* Ref.make(0); + const { service } = yield* makeProbedService( + () => + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 ? Effect.fail(unhealthy("setup failed")) : Effect.void, + ), + ), + { probe: false }, + ); + yield* startUnhealthy(service); + + expect(yield* Effect.flip(service.ready)).toMatchObject({ message: "setup failed" }); + expect(yield* Effect.flip(service.ready)).toMatchObject({ message: "setup failed" }); + expect(yield* Ref.get(checks)).toBe(1); + yield* service.stop; + }), + ), + ); + + it.live("shares one re-probe among concurrent readiness callers", () => + Effect.scoped( + Effect.gen(function* () { + const checks = yield* Ref.make(0); + const probeStarted = yield* Deferred.make<void>(); + const probeGate = yield* Deferred.make<void>(); + const { service } = yield* makeProbedService(() => + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail(unhealthy("first check failed")) + : count === 2 + ? open(probeStarted).pipe( + Effect.andThen(Deferred.await(probeGate)), + Effect.andThen(Effect.fail(unhealthy("still booting"))), + ) + : Effect.void, + ), + ), + ); + yield* startUnhealthy(service); + + const first = yield* Effect.forkChild(Effect.flip(service.ready), { + startImmediately: true, + }); + const second = yield* Effect.forkChild(Effect.flip(service.ready), { + startImmediately: true, + }); + yield* Deferred.await(probeStarted); + yield* open(probeGate); + + expect(yield* Fiber.join(first)).toMatchObject({ message: "still booting" }); + expect(yield* Fiber.join(second)).toMatchObject({ message: "still booting" }); + expect(yield* Ref.get(checks)).toBe(2); + yield* service.ready; + expect(yield* Ref.get(checks)).toBe(3); + yield* service.stop; + }), + ), + ); + + it.live("interrupts a superseded launch's probe and reports only the new launch", () => + Effect.scoped( + Effect.gen(function* () { + const firstLaunchChecks = yield* Ref.make(0); + const probeStarted = yield* Deferred.make<void>(); + const probeInterrupted = yield* Deferred.make<void>(); + const { service } = yield* makeProbedService((launch) => + launch === 1 + ? Ref.updateAndGet(firstLaunchChecks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail(unhealthy("first launch unhealthy")) + : open(probeStarted).pipe( + Effect.andThen(Effect.never), + Effect.onInterrupt(() => open(probeInterrupted)), + ), + ), + ) + : Effect.fail(unhealthy("second launch unhealthy")), + ); + yield* startUnhealthy(service); + const stale = yield* Effect.forkChild(service.ready, { startImmediately: true }); + yield* Deferred.await(probeStarted); + + const relaunched = yield* waitForObservation( + service, + (value) => value.launchId === 2 && value.health === "unhealthy", + ); + yield* service.restart(); + yield* Fiber.join(relaunched); + + yield* Deferred.await(probeInterrupted); + expect(Exit.isFailure(yield* Fiber.await(stale))).toBe(true); + expect(yield* service.get).toMatchObject({ + launchId: 2, + health: "unhealthy", + error: { message: "second launch unhealthy" }, + }); + expect(yield* Effect.flip(service.ready)).toMatchObject({ + message: "second launch unhealthy", + }); + expect(yield* Ref.get(firstLaunchChecks)).toBe(2); + yield* service.stop; + }), + ), + ); + + it.live("releases readiness waiters when the owner scope closes during a probe", () => + Effect.scoped( + Effect.gen(function* () { + const checks = yield* Ref.make(0); + const probeStarted = yield* Deferred.make<void>(); + const owner = yield* Scope.make(); + const { service } = yield* makeProbedService(() => + Ref.updateAndGet(checks, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Effect.fail(unhealthy("first check failed")) + : open(probeStarted).pipe(Effect.andThen(Effect.never)), + ), + ), + ).pipe(Scope.provide(owner)); + yield* startUnhealthy(service).pipe(Scope.provide(owner)); + const waiting = yield* Effect.forkChild(Effect.flip(service.ready), { + startImmediately: true, + }); + yield* Deferred.await(probeStarted); + + yield* Scope.close(owner, Exit.void); + + expect(yield* Fiber.join(waiting)).toMatchObject({ message: "Session stopped" }); + }), + ), + ); +}); diff --git a/packages/stack/src/Service.ts b/packages/stack/src/Service.ts index 70f3978db5..d711c7faa6 100644 --- a/packages/stack/src/Service.ts +++ b/packages/stack/src/Service.ts @@ -61,8 +61,10 @@ export class ServiceStaleLaunch extends Data.TaggedError("ServiceStaleLaunch")<{ /** The exact runtime resources owned by one service launch. */ export interface RuntimeSession { - /** The one readiness program for this session. */ + /** The initial readiness program for this session. */ readonly health: Effect.Effect<void, ServiceError>; + /** Bounded re-check for a running session whose last check failed; without it the failure is final. */ + readonly probe?: Effect.Effect<void, ServiceError>; /** Resolves with the runtime's terminal result and remains attached to this session. */ readonly exit: Effect.Effect<Exit.Exit<void, ServiceError>>; readonly stop: Effect.Effect<void, ServiceError>; @@ -130,16 +132,21 @@ export interface ServiceInstance<Config> { readonly destroy: Effect.Effect<void, ServiceError | ServiceDestroyed>; } +type HealthCheck = Deferred.Deferred<Exit.Exit<void, ServiceError>>; + interface SessionRecord { readonly launchId: number; readonly runtime: RuntimeSession; readonly scope: Scope.Closeable; readonly healthScope: Scope.Closeable; - readonly health: Deferred.Deferred<Exit.Exit<void, ServiceError>>; + /** The latest readiness check of this launch; a settled failure is replaced by an on-demand probe. */ + readonly health: Ref.Ref<HealthCheck>; readonly stopped: Ref.Ref<boolean>; readonly removed: Ref.Ref<boolean>; } +const sessionStopped = () => new ServiceError({ operation: "health", message: "Session stopped" }); + const contextFor = <Config>( id: string, config: Config, @@ -213,6 +220,56 @@ export const makeService = <Config>( options.coordinate ?? ((_operation: ServiceAdmission, transition: Effect.Effect<void, ServiceError>) => transition); + // Health and exit settlement each write the observation and launchError as one step. + const settlement = yield* Semaphore.make(1); + + const settleCheck = Effect.fn("Service.settleCheck")(function* ( + record: SessionRecord, + exit: Exit.Exit<void, ServiceError>, + ) { + const error = exitError("health", exit); + yield* settlement.withPermit( + Effect.gen(function* () { + const owned = yield* SubscriptionRef.modify( + observations, + (observation): [boolean, ServiceObservation<Config>] => + observation.launchId === record.launchId && observation.lifecycle === "running" + ? [ + true, + { + ...observation, + health: error === undefined ? "healthy" : "unhealthy", + error, + }, + ] + : [false, observation], + ); + if (owned) + yield* Ref.set( + launchError, + error === undefined ? undefined : { launchId: record.launchId, error }, + ); + }), + ); + }); + + /** Runs one readiness check whose result is shared by every `ready` caller awaiting it. */ + const runCheck = Effect.fn("Service.runCheck")( + (record: SessionRecord, check: Effect.Effect<void, ServiceError>, result: HealthCheck) => + Effect.forkIn( + check.pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) + ? Deferred.succeed(result, Exit.fail(sessionStopped())) + : settleCheck(record, exit).pipe(Effect.andThen(Deferred.succeed(result, exit))), + ), + ), + record.healthScope, + // Starting immediately installs the settlement before a closing scope can interrupt it. + { startImmediately: true, uninterruptible: false }, + ), + ); + // Mask only the permit handoff; admitted work belongs to the host scope. const run = Effect.fn("Service.run")(function* < A, @@ -245,10 +302,7 @@ export const makeService = <Config>( } yield* update({ lifecycle: "stopping", health: undefined, wakeEnabled: retainWake }); - yield* Deferred.succeed( - record.health, - Exit.fail(new ServiceError({ operation: "health", message: "Session stopped" })), - ); + yield* Deferred.succeed(yield* Ref.get(record.health), Exit.fail(sessionStopped())); yield* Scope.close(record.healthScope, Exit.void); if (!(yield* Ref.get(record.stopped))) { const halt = @@ -353,7 +407,7 @@ export const makeService = <Config>( runtime, scope: runtimeScope, healthScope: yield* Scope.fork(runtimeScope, "parallel"), - health, + health: yield* Ref.make(health), stopped: yield* Ref.make(false), removed: yield* Ref.make(false), }; @@ -368,55 +422,34 @@ export const makeService = <Config>( config: yield* Ref.get(config), }); - const observeHealth = runtime.health.pipe( - Effect.onExit((exit) => - Effect.gen(function* () { - const error = exitError("health", exit); - const currentObservation = yield* SubscriptionRef.get(observations); - if ( - error !== undefined && - currentObservation.launchId === launchId && - currentObservation.lifecycle === "running" - ) - yield* Ref.set(launchError, { launchId, error }); - yield* SubscriptionRef.update( - observations, - (observation): ServiceObservation<Config> => { - if (observation.launchId !== launchId || observation.lifecycle !== "running") - return observation; - return { - ...observation, - health: Exit.isSuccess(exit) ? "healthy" : "unhealthy", - error, - }; - }, - ); - yield* Deferred.succeed(record.health, exit); - }), - ), - ); const observeExit = record.runtime.exit.pipe( Effect.flatMap((exit) => Effect.gen(function* () { - if ((yield* Ref.get(current)) !== record) return; - const observation = yield* SubscriptionRef.get(observations); - const error = - observation.lifecycle === "stopping" - ? observation.error - : (exitError("exit", exit) ?? - new ServiceError({ - operation: "exit", - message: "Runtime exited unexpectedly", - })); - if (error !== undefined) - yield* Ref.set(launchError, { launchId: record.launchId, error }); - yield* update({ - lifecycle: "stopping", - health: undefined, - error, - exit, - wakeEnabled: observation.lifecycle === "stopping" && observation.wakeEnabled, - }); + const owned = yield* settlement.withPermit( + Effect.gen(function* () { + if ((yield* Ref.get(current)) !== record) return false; + const observation = yield* SubscriptionRef.get(observations); + const error = + observation.lifecycle === "stopping" + ? observation.error + : (exitError("exit", exit) ?? + new ServiceError({ + operation: "exit", + message: "Runtime exited unexpectedly", + })); + if (error !== undefined) + yield* Ref.set(launchError, { launchId: record.launchId, error }); + yield* update({ + lifecycle: "stopping", + health: undefined, + error, + exit, + wakeEnabled: observation.lifecycle === "stopping" && observation.wakeEnabled, + }); + return true; + }), + ); + if (!owned) return; yield* run( Effect.gen(function* () { if ((yield* Ref.get(current)) !== record) return; @@ -430,11 +463,10 @@ export const makeService = <Config>( }), ), ); - if (launchFailure === undefined) - yield* Effect.forkIn(observeHealth, record.healthScope, { uninterruptible: false }); + if (launchFailure === undefined) yield* runCheck(record, runtime.health, health); yield* Effect.forkIn(observeExit, runtimeScope, { uninterruptible: false }); if (launchFailure !== undefined) { - yield* Deferred.succeed(record.health, Exit.fail(launchFailure)); + yield* Deferred.succeed(health, Exit.fail(launchFailure)); yield* stopNow(undefined, observation.wakeEnabled); return yield* launchFailure; } @@ -458,6 +490,7 @@ export const makeService = <Config>( wake = false, guard: Effect.Effect<void, ServiceError> = Effect.void, ) { + yield* Effect.annotateCurrentSpan({ member_id: options.id }); let existing = yield* SubscriptionRef.get(observations); if (!existing.registered) return yield* new ServiceDestroyed({ id: options.id }); if (existing.lifecycle === "running") return; @@ -588,7 +621,32 @@ export const makeService = <Config>( if (owned !== undefined && owned.launchId === launchId) return yield* owned.error; return yield* staleLaunch(launchId, `Service ${options.id} stopped before it was ready`); }); + const isLive = (record: SessionRecord) => + Effect.gen(function* () { + if ((yield* Ref.get(current)) !== record) return false; + return (yield* SubscriptionRef.get(observations)).lifecycle === "running"; + }); + + /** Concurrent callers share one probe per settled failure of a launch. */ + const reprobe = Effect.fn("Service.reprobe")(function* ( + record: SessionRecord, + failed: HealthCheck, + probe: Effect.Effect<void, ServiceError>, + ) { + const next = yield* Deferred.make<Exit.Exit<void, ServiceError>>(); + const check = yield* Ref.modify(record.health, (latest): [HealthCheck, HealthCheck] => + latest === failed ? [next, next] : [latest, latest], + ); + if (check === next) { + // stopNow fails the latest check after leaving "running", so a probe admitted here is always settled. + if (yield* isLive(record)) yield* runCheck(record, probe, next); + else yield* Deferred.succeed(next, Exit.fail(sessionStopped())); + } + return yield* Deferred.await(check); + }); + const ready = Effect.fn("Service.ready")(function* () { + yield* Effect.annotateCurrentSpan({ member_id: options.id }); const initial = yield* SubscriptionRef.get(observations); if (!initial.registered) return yield* new ServiceDestroyed({ id: options.id }); const expectedRevision = yield* Ref.get(revision); @@ -614,14 +672,16 @@ export const makeService = <Config>( if ((yield* SubscriptionRef.get(observations)).lifecycle === "stopping") { return yield* diedBeforeReady(launchId); } - const healthResult = yield* Deferred.await(record.health); - if ( - (yield* Ref.get(current)) !== record || - (yield* SubscriptionRef.get(observations)).lifecycle !== "running" - ) { - return yield* diedBeforeReady(launchId); - } - yield* healthResult; + const check = yield* Ref.get(record.health); + const settledOnArrival = yield* Deferred.isDone(check); + const healthResult = yield* Deferred.await(check); + if (!(yield* isLive(record))) return yield* diedBeforeReady(launchId); + const probe = record.runtime.probe; + if (Exit.isSuccess(healthResult) || !settledOnArrival || probe === undefined) + return yield* healthResult; + const reprobed = yield* reprobe(record, check, probe); + if (!(yield* isLive(record))) return yield* diedBeforeReady(launchId); + return yield* reprobed; }); const storage = Effect.fn("Service.storage")(function* <A>( diff --git a/packages/stack/src/StackHost.container-shutdown.integration.test.ts b/packages/stack/src/StackHost.container-shutdown.integration.test.ts index d663403761..4c0971f97c 100644 --- a/packages/stack/src/StackHost.container-shutdown.integration.test.ts +++ b/packages/stack/src/StackHost.container-shutdown.integration.test.ts @@ -2,12 +2,17 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; import { Context, Crypto, Effect, FileSystem, Layer, Path, Redacted, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as State from "./State.ts"; -import { launchHost, waitForOwnerExit } from "./HostProcess.ts"; -import { StackRpc } from "./Rpc.ts"; +import { + hasReason, + launchHost, + ownerClient, + ownerExitProbe, + waitForOwnerExit, +} from "./HostProcess.ts"; import { makeContainerRuntime } from "./runtime/Container.ts"; import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; +import { shutdownOwner } from "../tests/owner.ts"; const helperImage = "public.ecr.aws/docker/library/debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251"; @@ -73,15 +78,6 @@ const createOwnedContainer = (name: string, stackId: string, dataRoot: string) = helperImage, ]); -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); - const startHost = (stateRoot: string, cacheRoot: string, stackId: string, projectRoot: string) => Effect.gen(function* () { const state = yield* stateFor(stateRoot); @@ -92,6 +88,7 @@ const startHost = (stateRoot: string, cacheRoot: string, stackId: string, projec runtime: "docker", identity: { projectRoot, branchContext: "container-shutdown-test", stackName: stackId }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -123,9 +120,16 @@ it.live.skipIf(process.platform === "win32")( let activeB: { readonly pid: number; readonly port: number } | undefined; let stoppedA = true; let stoppedB = true; + // A signalled owner stops its containers, each within a 10 second grace, before exiting; + // no acknowledgement precedes that cleanup, so the post-acknowledgement exit bound repeats. const signalAndWait = (endpoint: { pid: number }, signal: NodeJS.Signals) => Effect.sync(() => process.kill(endpoint.pid, signal)).pipe( - Effect.andThen(waitForOwnerExit(endpoint.pid).pipe(Effect.timeout("15 seconds"))), + Effect.andThen( + waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( + Effect.retry({ while: hasReason("owner-exit-pending") }), + Effect.timeout("30 seconds"), + ), + ), ); yield* Effect.addFinalizer(() => Effect.gen(function* () { @@ -142,7 +146,8 @@ it.live.skipIf(process.platform === "win32")( stackId, dataA, ); - const endpointA = yield* startHost(rootA, cacheRoot, stackId, `${base}/project-a`); + const accessA = yield* startHost(rootA, cacheRoot, stackId, `${base}/project-a`); + const endpointA = accessA.endpoint; activeA = endpointA; stoppedA = false; expect(staleAtStartup.length).toBeGreaterThan(0); @@ -160,7 +165,8 @@ it.live.skipIf(process.platform === "win32")( stackId, dataB, ); - const endpointB = yield* startHost(rootB, cacheRoot, stackId, `${base}/project-b`); + const accessB = yield* startHost(rootB, cacheRoot, stackId, `${base}/project-b`); + const endpointB = accessB.endpoint; activeB = endpointB; stoppedB = false; expect(staleForA.length).toBeGreaterThan(0); @@ -171,9 +177,9 @@ it.live.skipIf(process.platform === "win32")( "startup sweep removes B stale container", ).toEqual([]); - const clientA = yield* clientFor(endpointA.port); - const clientB = yield* clientFor(endpointB.port); - const createAndStartDatabase = (client: ReturnType<typeof clientFor>, suffix: string) => + const clientA = yield* ownerClient(accessA); + const clientB = yield* ownerClient(accessB); + const createAndStartDatabase = (client: ReturnType<typeof ownerClient>, suffix: string) => client.pipe( Effect.flatMap((rpc) => rpc.createService({ @@ -221,25 +227,27 @@ it.live.skipIf(process.platform === "win32")( expect(yield* containers(stackId, dataB), "SIGINT removes B containers").toEqual([]); expect(yield* (yield* stateFor(rootB)).read(stackId)).toBeDefined(); - const endpointA2 = yield* startHost(rootA, cacheRoot, stackId, `${base}/project-a`); + const accessA2 = yield* startHost(rootA, cacheRoot, stackId, `${base}/project-a`); + const endpointA2 = accessA2.endpoint; activeA = endpointA2; stoppedA = false; - yield* clientFor(endpointA2.port).pipe( - Effect.flatMap((rpc) => rpc.shutdown({ destroy: true })), + yield* shutdownOwner(accessA2, true); + yield* waitForOwnerExit(endpointA2.pid, ownerExitProbe(fs)).pipe( + Effect.timeout("15 seconds"), ); - yield* waitForOwnerExit(endpointA2.pid).pipe(Effect.timeout("15 seconds")); stoppedA = true; activeA = undefined; expect(yield* containers(stackId, dataA), "destroy removes A containers").toEqual([]); expect(yield* (yield* stateFor(rootA)).read(stackId)).toBeUndefined(); - const endpointB2 = yield* startHost(rootB, cacheRoot, stackId, `${base}/project-b`); + const accessB2 = yield* startHost(rootB, cacheRoot, stackId, `${base}/project-b`); + const endpointB2 = accessB2.endpoint; activeB = endpointB2; stoppedB = false; - yield* clientFor(endpointB2.port).pipe( - Effect.flatMap((rpc) => rpc.shutdown({ destroy: true })), + yield* shutdownOwner(accessB2, true); + yield* waitForOwnerExit(endpointB2.pid, ownerExitProbe(fs)).pipe( + Effect.timeout("15 seconds"), ); - yield* waitForOwnerExit(endpointB2.pid).pipe(Effect.timeout("15 seconds")); stoppedB = true; activeB = undefined; expect(yield* containers(stackId, dataB), "destroy removes B containers").toEqual([]); diff --git a/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts b/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts index cea6a7965b..5e893aaa75 100644 --- a/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts +++ b/packages/stack/src/StackHost.container-sweep-retry.integration.test.ts @@ -2,28 +2,17 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; import { Context, Deferred, Effect, FileSystem, Layer, Sink, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { StackRpc } from "./Rpc.ts"; import * as State from "./State.ts"; -import { acquireHost } from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; -import * as ToolRunner from "./host/ToolRunner.ts"; -import { makeRuntime } from "./StackHost.ts"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as CommandRunner from "./host/CommandRunner.ts"; +import { bindControl, makeRuntime } from "./StackHost.ts"; +import { shutdownOwner } from "../tests/owner.ts"; const stateFor = (root: string) => Layer.build(State.layer({ root })).pipe( Effect.map((context) => Context.get(context, State.Service)), ); -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); - it.live("retains saved state when destroy sweep fails and retries after engine recovery", () => Effect.scoped( Effect.gen(function* () { @@ -35,6 +24,7 @@ it.live("retains saved state when destroy sweep fails and retries after engine r runtime: "docker" as const, identity: { projectRoot: root, branchContext: "main", stackName: "sweep-retry" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -81,15 +71,10 @@ it.live("retains saved state when destroy sweep fails and retries after engine r const baseOwner = yield* Layer.build(ownerLayer).pipe( Effect.map((context) => Context.get(context, Owner.Service)), ); - const ownership = { - engine: "docker" as const, - stackId: saved.id, - root: `${root}/data`, - }; const owner = baseOwner; - const acquired = yield* acquireHost(state, saved.id); + const acquired = yield* bindControl(); const runnerLayer = yield* Layer.build( - ToolRunner.layer({ + CommandRunner.layer({ stackId: saved.id, root: `${root}/data`, cacheRoot: `${root}/cache`, @@ -99,22 +84,25 @@ it.live("retains saved state when destroy sweep fails and retries after engine r const runtime = yield* makeRuntime( owner, { - stackId: saved.id, - identity: saved.identity, - pid: process.pid, - port: acquired.port, + endpoint: { + stackId: saved.id, + identity: saved.identity, + pid: process.pid, + port: acquired.port, + release: "test", + }, + secret: "test-secret", }, acquired.server, acquired.closeConnections, - ownership, ).pipe( - Effect.provideService(ToolRunner.Service, Context.get(runnerLayer, ToolRunner.Service)), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, engine), + Effect.provideService( + CommandRunner.Service, + Context.get(runnerLayer, CommandRunner.Service), + ), ); yield* runtime.serve; - const client = yield* clientFor(runtime.endpoint.port); - const failure = yield* client.shutdown({ destroy: true }).pipe(Effect.flip); - expect("operation" in failure ? failure.operation : undefined).toBe("shutdown"); + const failure = yield* shutdownOwner(runtime.access, true).pipe(Effect.flip); expect(listCalls, failure.message).toBe(3); expect(yield* (yield* stateFor(`${root}/state`)).read(saved.id)).toBeDefined(); yield* Deferred.await(runtime.exit).pipe(Effect.timeout("10 seconds")); diff --git a/packages/stack/src/StackHost.integration.test.ts b/packages/stack/src/StackHost.integration.test.ts index 2bff7a3d01..914f877ed2 100644 --- a/packages/stack/src/StackHost.integration.test.ts +++ b/packages/stack/src/StackHost.integration.test.ts @@ -12,22 +12,30 @@ import { Layer, Redacted, Ref, + Schema, Stream, } from "effect"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { Rpc, RpcClient, RpcGroup, RpcSerialization } from "effect/unstable/rpc"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClient from "effect/unstable/http/HttpClient"; -import { ChildProcessSpawner } from "effect/unstable/process"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- integration observes exact listener closure. import * as Net from "node:net"; -import { acquireHost, launchHost } from "./HostProcess.ts"; +import { + launchHost, + ownerAuthorization, + ownerClient, + ownerExitProbe, + waitForOwnerExit, + type HostAccess, +} from "./HostProcess.ts"; import * as Owner from "./Owner.ts"; -import { OwnerError } from "./Owner.ts"; import { OrchestratorError } from "./Orchestrator.ts"; -import { StackRpc } from "./Rpc.ts"; +import { CommandEvent, StackError } from "./Rpc.ts"; import * as State from "./State.ts"; -import { makeRuntime } from "./StackHost.ts"; -import { postgres } from "./Tools.ts"; -import * as ToolRunner from "./host/ToolRunner.ts"; +import { bindControl, makeRuntime } from "./StackHost.ts"; +import { shutdownOwner } from "../tests/owner.ts"; +import { postgres } from "./Commands.ts"; +import * as CommandRunner from "./host/CommandRunner.ts"; class HostTestError extends Data.TaggedError("HostTestError")<{ readonly message: string }> {} @@ -55,11 +63,28 @@ const ownerFor = (options: { }); }; -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( +const permissiveCommandClientFor = (access: HostAccess) => + RpcClient.make( + RpcGroup.make( + Rpc.make("runCommand", { + payload: Schema.Unknown, + success: CommandEvent, + error: StackError, + stream: true, + }), + ), + ).pipe( Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( + RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${access.endpoint.port}/rpc` }).pipe( Layer.provide(RpcSerialization.layerNdjson), + Layer.provide( + Layer.effect( + HttpClient.HttpClient, + HttpClient.HttpClient.pipe( + Effect.map(HttpClient.mapRequest(HttpClientRequest.bearerToken(access.secret))), + ), + ), + ), ), ), ); @@ -112,42 +137,34 @@ const inProcessRuntime = ( owner: Parameters<typeof makeRuntime>[0], state: State.Interface, root: string, - options?: { - readonly container?: { - readonly engine: "docker" | "podman"; - readonly stackId: string; - readonly root: string; - }; - readonly spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"]; - }, ) => Effect.gen(function* () { - const acquired = yield* acquireHost(state, "stack"); + const acquired = yield* bindControl(); const toolContext = yield* Layer.build( - ToolRunner.layer({ + CommandRunner.layer({ stackId: "stack", root, cacheRoot: "/tmp/supabase-stack-artifacts", runtime: "native", }), ); - const runtimeEffect = makeRuntime( + const runtime = yield* makeRuntime( owner, { - stackId: "stack", - identity: { projectRoot: root, branchContext: "main", stackName: "host" }, - pid: process.pid, - port: acquired.port, + endpoint: { + stackId: "stack", + identity: { projectRoot: root, branchContext: "main", stackName: "host" }, + pid: process.pid, + port: acquired.port, + release: "test", + }, + secret: "test-secret", }, acquired.server, acquired.closeConnections, - options?.container, - ).pipe(Effect.provideService(ToolRunner.Service, Context.get(toolContext, ToolRunner.Service))); - const runtime = yield* options?.spawner === undefined - ? runtimeEffect - : runtimeEffect.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, options.spawner), - ); + ).pipe( + Effect.provideService(CommandRunner.Service, Context.get(toolContext, CommandRunner.Service)), + ); yield* runtime.serve; return { runtime, port: acquired.port }; }); @@ -179,6 +196,22 @@ const abortBeforeRpcBody = (port: number) => }); }); +const occupiedPort = Effect.acquireRelease( + Effect.callback<Net.Server, HostTestError>((resume) => { + const server = Net.createServer(); + server.once("error", (cause) => resume(Effect.fail(hostTestError(cause)))); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => Effect.callback<void>((resume) => void server.close(() => resume(Effect.void))), +).pipe( + Effect.flatMap((server) => { + const address = server.address(); + return typeof address === "object" && address !== null + ? Effect.succeed(address.port) + : Effect.fail(new HostTestError({ message: "Occupied listener has no port" })); + }), +); + it.live("preserves composition outcomes over RPC", () => Effect.scoped( Effect.gen(function* () { @@ -190,6 +223,7 @@ it.live("preserves composition outcomes over RPC", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-outcomes" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -200,41 +234,36 @@ it.live("preserves composition outcomes over RPC", () => root: `${root}/data`, cacheRoot: "/tmp/supabase-stack-artifacts", }); - const failed = new OrchestratorError({ - operation: "start", - message: "member failed", - cause: new Error("member failed", { cause: new Error("EACCES: permission denied") }), + const { runtime } = yield* inProcessRuntime(owner, state, root); + const client = yield* ownerClient(runtime.access); + const port = yield* occupiedPort; + const lazy = yield* client.createService({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, }); - const delayedOwner = { - ...owner, - composition: { - ...owner.composition, - start: Effect.fail( - new OwnerError({ - operation: "composition", - message: "Composition start had failures", - cause: new OrchestratorError({ - operation: "start", - message: "Composition start had failures", - outcomes: [ - { id: "healthy", result: Exit.succeed(undefined) }, - { id: "failed", result: Exit.fail(failed) }, - ], - }), - }), - ), - }, - }; - const { runtime } = yield* inProcessRuntime(delayedOwner, state, root); - const client = yield* clientFor(runtime.endpoint.port); + const blocked = yield* client.createService({ + service: "mail", + config: {}, + endpoints: { http: { port } }, + }); + yield* client.configureComposition({ + members: [ + { id: lazy.id, activation: "lazy" }, + { id: blocked.id, activation: "eager" }, + ], + dependencies: [], + }); + const error = yield* client.startComposition().pipe(Effect.flip); + expect("outcomes" in error).toBe(true); if (!("outcomes" in error)) return yield* Effect.die("Missing composition outcomes"); expect(error.outcomes).toEqual([ - { id: "healthy", succeeded: true }, - { id: "failed", succeeded: false, error: "member failed: EACCES: permission denied" }, + { id: lazy.id, succeeded: true }, + { id: blocked.id, succeeded: false, error: expect.stringContaining(String(port)) }, ]); - yield* client.shutdown({ destroy: false }); + yield* shutdownOwner(runtime.access, true); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -250,6 +279,7 @@ it.live("keeps serving when namespace shutdown fails", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-drain-failure" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -264,14 +294,16 @@ it.live("keeps serving when namespace shutdown fails", () => ...owner, namespace: { ...owner.namespace, - stop: Effect.fail(new OwnerError({ operation: "stop", message: "cleanup failed" })), + stop: Effect.fail( + new OrchestratorError({ operation: "stop", message: "cleanup failed" }), + ), }, }; const { runtime } = yield* inProcessRuntime(failedOwner, state, root); - const client = yield* clientFor(runtime.endpoint.port); - const failure = yield* client.shutdown({ destroy: false }).pipe(Effect.flip); - expect("operation" in failure).toBe(true); - expect((yield* client.listServices()).length).toBe(0); + const client = yield* ownerClient(runtime.access); + const failure = yield* shutdownOwner(runtime.access, false).pipe(Effect.flip); + expect(failure.message).toContain("cleanup failed"); + yield* client.configureComposition({ members: [], dependencies: [] }); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); @@ -287,6 +319,7 @@ it.live("reports destroy and fallback stop failures together", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-destroy-failure" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -303,19 +336,15 @@ it.live("reports destroy and fallback stop failures together", () => id: string, reason: string, ) => - new OwnerError({ + new OrchestratorError({ operation, message, - cause: new OrchestratorError({ - operation, - message, - outcomes: [ - { - id, - result: Exit.fail(new OrchestratorError({ operation, message: reason })), - }, - ], - }), + outcomes: [ + { + id, + result: Exit.fail(new OrchestratorError({ operation, message: reason })), + }, + ], }); const failedOwner = { ...owner, @@ -340,8 +369,7 @@ it.live("reports destroy and fallback stop failures together", () => }, }; const { runtime } = yield* inProcessRuntime(failedOwner, state, root); - const client = yield* clientFor(runtime.endpoint.port); - const failure = yield* client.shutdown({ destroy: true }).pipe(Effect.flip); + const failure = yield* shutdownOwner(runtime.access, true).pipe(Effect.flip); expect(failure.message).toContain("Namespace destroy had failures"); expect(failure.message).toContain("database-destroy:"); expect(failure.message).toContain("data removal refused"); @@ -381,6 +409,7 @@ it.live("rejects destroy while stop is in flight", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-stop-join" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -426,6 +455,7 @@ it.live("retains ownership when namespace shutdown defects and retries cleanup", runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-drain-defect" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -448,16 +478,17 @@ it.live("retains ownership when namespace shutdown defects and retries cleanup", }, }; const { runtime } = yield* inProcessRuntime(failedOwner, state, root); - const client = yield* clientFor(runtime.endpoint.port); + const client = yield* ownerClient(runtime.access); const failure = yield* runtime.shutdown(false).pipe(Effect.exit); expect(Exit.isFailure(failure)).toBe(true); if (Exit.isFailure(failure)) expect(Cause.pretty(failure.cause)).toContain("cleanup failed"); const http = yield* HttpClient.HttpClient; - expect((yield* http.get(`http://127.0.0.1:${runtime.endpoint.port}/identity`)).status).toBe( - 200, - ); + const identity = yield* http.get(`http://127.0.0.1:${runtime.endpoint.port}/identity`, { + headers: { authorization: ownerAuthorization(runtime.access.secret) }, + }); + expect(identity.status).toBe(200); expect(yield* owner.getServing).toBe(true); - expect((yield* client.listServices()).length).toBe(0); + yield* client.configureComposition({ members: [], dependencies: [] }); yield* runtime.shutdown(false); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), @@ -476,52 +507,96 @@ it.live( runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "host" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); - const endpoint = yield* launchHost(state, { + const access = yield* launchHost(state, { stateRoot: `${root}/state`, cacheRoot: "/tmp/supabase-stack-artifacts", stackId: "stack", }); + const { endpoint } = access; const http = yield* HttpClient.HttpClient; - const identity = yield* http.get(`http://127.0.0.1:${endpoint.port}/identity`); + const identityUrl = `http://127.0.0.1:${endpoint.port}/identity`; + expect((yield* http.get(identityUrl)).status, "the secret is required").toBe(401); + const identity = yield* http.get(identityUrl, { + headers: { authorization: ownerAuthorization(access.secret) }, + }); expect(identity.status).toBe(200); - const client = yield* clientFor(endpoint.port); + const client = yield* ownerClient(access); + const permissiveClient = yield* permissiveCommandClientFor(access); + for (const override of ["args", "env", "pgProve", "stdin"] as const) { + const rejected = yield* permissiveClient + .runCommand({ + attachmentId: `invalid-${override}`, + command: { type: "auth.initialize", databaseUrl: "postgres://localhost/postgres" }, + [override]: override === "args" ? [] : {}, + }) + .pipe(Stream.runDrain, Effect.exit); + expect(Exit.isFailure(rejected)).toBe(true); + if (Exit.isFailure(rejected)) + expect(Cause.pretty(rejected.cause)).toContain("Expected no excess property"); + } + for (const override of ["args", "env", "pgProve", "stdin"] as const) { + const rejected = yield* permissiveClient + .runCommand({ + attachmentId: `invalid-command-${override}`, + command: { + type: "auth.initialize", + databaseUrl: "postgres://localhost/postgres", + [override]: override === "args" ? [] : {}, + }, + }) + .pipe(Stream.runDrain, Effect.exit); + expect(Exit.isFailure(rejected)).toBe(true); + if (Exit.isFailure(rejected)) + expect(Cause.pretty(rejected.cause)).toContain("Expected no excess property"); + } const stopped = yield* Ref.make(false); yield* Effect.addFinalizer(() => Ref.get(stopped).pipe( Effect.flatMap((value) => - value ? Effect.void : client.shutdown({ destroy: true }).pipe(Effect.ignore), + value + ? Effect.void + : shutdownOwner(access, true).pipe( + // A destroy request is refused once the test started a plain shutdown. + Effect.ignore, + Effect.andThen(waitForOwnerExit(endpoint.pid, ownerExitProbe(fs))), + Effect.ignore, + ), ), ), ); yield* abortBeforeRpcBody(endpoint.port); - expect((yield* client.listServices()).length).toBe(0); + expect(yield* client.startComposition()).toEqual([]); const mail = yield* client.createService({ service: "mail", config: {}, endpoints: { http: { port: "auto" }, smtp: { port: "auto" }, pop3: { port: "auto" } }, }); expect(mail.creation.service).toBe("mail"); - expect((yield* client.listServices()).length).toBe(1); + expect((yield* client.status({ id: mail.id })).lifecycle).toBe("stopped"); const toolAttachment = "early-stdin"; - const toolEvents = yield* client - .runTool({ + const commandEvents = yield* client + .runCommand({ attachmentId: toolAttachment, - tool: postgres.psql({ major: 17 }), - args: ["--version"], - env: {}, - stdin: true, + command: { + type: "postgres", + command: postgres.psql({ major: 17 }), + args: ["--version"], + env: {}, + stdin: true, + }, }) .pipe(Stream.runCollect); - expect(Array.from(toolEvents).some((event) => event._tag === "Completed")).toBe(true); + expect(Array.from(commandEvents).some((event) => event._tag === "Completed")).toBe(true); const closedInput = yield* client - .toolInput({ attachmentId: toolAttachment, bytes: null }) + .commandInput({ attachmentId: toolAttachment, bytes: null }) .pipe(Effect.flip); expect("operation" in closedInput).toBe(true); if (!("operation" in closedInput)) return yield* Effect.die("Unexpected RPC error"); - expect(closedInput.operation).toBe("tool-input-closed"); + expect(closedInput.operation).toBe("command-input-closed"); yield* client.startService({ id: mail.id }); yield* client.readyService({ id: mail.id }); expect((yield* client.status({ id: mail.id })).lifecycle).toBe("running"); @@ -555,20 +630,23 @@ it.live( const ready = yield* Deferred.make<void>(); const drainingTool = yield* Effect.forkScoped( client - .runTool({ + .runCommand({ attachmentId: "draining-stdin", - tool: postgres.psql({ major: 17 }), - args: [ - "-X", - "-t", - "-A", - "-c", - "SELECT 'stack-host-tool-ready'", - "-c", - "SELECT pg_sleep(600)", - ], - env: databaseEnv, - stdin: true, + command: { + type: "postgres", + command: postgres.psql({ major: 17 }), + args: [ + "-X", + "-t", + "-A", + "-c", + "SELECT 'stack-host-command-ready'", + "-c", + "SELECT pg_sleep(600)", + ], + env: databaseEnv, + stdin: true, + }, }) .pipe( Stream.filter((event) => event._tag === "Stdout"), @@ -576,13 +654,15 @@ it.live( Stream.decodeText, Stream.splitLines, Stream.runForEach((line) => - line === "stack-host-tool-ready" ? Deferred.succeed(ready, undefined) : Effect.void, + line === "stack-host-command-ready" + ? Deferred.succeed(ready, undefined) + : Effect.void, ), ), ); yield* Deferred.await(ready); expect(idleSocket.destroyed).toBe(false); - yield* client.shutdown({ destroy: false }); + yield* shutdownOwner(access, false); yield* awaitClosed(idleSocket).pipe( Effect.timeoutOrElse({ duration: "5 seconds", @@ -607,6 +687,8 @@ it.live( expect(Exit.isFailure(drainingToolExit)).toBe(true); if (Exit.isFailure(drainingToolExit)) expect(Cause.pretty(drainingToolExit.cause)).toContain("Stack host is draining"); + // The owner still releases its state files after acknowledging shutdown. + yield* waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)); yield* Ref.set(stopped, true); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), @@ -624,6 +706,7 @@ it.live("finishes detached shutdown after the caller disconnects", () => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "host-abort" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -633,6 +716,7 @@ it.live("finishes detached shutdown after the caller disconnects", () => runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "host" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }, @@ -654,8 +738,7 @@ it.live("finishes detached shutdown after the caller disconnects", () => }; const { runtime } = yield* inProcessRuntime(delayedOwner, state, root); yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); - const client = yield* clientFor(runtime.endpoint.port); - const shutdown = yield* Effect.forkScoped(client.shutdown({ destroy: false })); + const shutdown = yield* Effect.forkScoped(shutdownOwner(runtime.access, false)); yield* Deferred.await(entered); yield* Fiber.interrupt(shutdown); yield* Deferred.succeed(allow, undefined); @@ -675,6 +758,7 @@ it.live("withdraws a command waiting for its prerequisite", () => runtime: "native" as const, identity: { projectRoot: root, branchContext: "main", stackName: "host-command-abort" }, instances: [], + lifetime: "detached" as const, composition: { members: [], dependencies: [] }, ports: [], }; @@ -690,12 +774,12 @@ it.live("withdraws a command waiting for its prerequisite", () => const allow = yield* Deferred.make<void>(); const delayedOwner = { ...owner, - core: { - ...owner.core, - start: (id: string) => + handlers: { + ...owner.handlers, + startService: (payload: { readonly id: string }) => Deferred.succeed(entered, undefined).pipe( Effect.andThen(Deferred.await(allow)), - Effect.andThen(owner.core.start(id)), + Effect.andThen(owner.handlers.startService(payload)), Effect.ensuring(Deferred.succeed(cancelled, undefined)), ), }, @@ -706,7 +790,7 @@ it.live("withdraws a command waiting for its prerequisite", () => Effect.andThen(runtime.shutdown(false).pipe(Effect.ignore)), ), ); - const client = yield* clientFor(runtime.endpoint.port); + const client = yield* ownerClient(runtime.access); const mail = yield* client.createService({ service: "mail", config: {}, @@ -718,7 +802,257 @@ it.live("withdraws a command waiting for its prerequisite", () => yield* Deferred.await(cancelled).pipe(Effect.timeout("5 seconds")); yield* Deferred.succeed(allow, undefined); expect((yield* client.status({ id: mail.id })).lifecycle).toBe("stopped"); - yield* client.shutdown({ destroy: false }); + yield* shutdownOwner(runtime.access, false); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +const disconnectFixture = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix }); + const state = yield* stateFor(`${root}/state`); + const saved: State.SavedStack = { + id: "stack", + runtime: "native", + identity: { projectRoot: root, branchContext: "main", stackName: prefix }, + instances: [], + lifetime: "detached", + composition: { members: [], dependencies: [] }, + ports: [], + }; + yield* state.save(saved); + return { root, state, saved }; + }); + +it.live("finishes a service creation after its caller disconnects", () => + Effect.scoped( + Effect.gen(function* () { + const { root, state, saved } = yield* disconnectFixture("stack-host-create-abort-"); + const persisted = yield* Deferred.make<void>(); + const allow = yield* Deferred.make<void>(); + yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); + const owner = yield* ownerFor({ + saved, + state: { + ...state, + save: (next) => + state + .save(next) + .pipe( + Effect.andThen( + next.instances.length === 0 + ? Effect.void + : Deferred.succeed(persisted, undefined).pipe( + Effect.andThen(Deferred.await(allow)), + ), + ), + ), + }, + root: `${root}/data`, + cacheRoot: "/tmp/supabase-stack-artifacts", + }); + const interrupted = yield* Deferred.make<void>(); + const { runtime } = yield* inProcessRuntime( + { + ...owner, + handlers: { + ...owner.handlers, + createService: (input: Parameters<typeof owner.handlers.createService>[0]) => + owner.handlers + .createService(input) + .pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + }, + }, + state, + root, + ); + const client = yield* ownerClient(runtime.access); + const creation = yield* Effect.forkScoped( + client.createService({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }), + ); + yield* Deferred.await(persisted); + yield* Fiber.interrupt(creation); + yield* Deferred.await(interrupted); + yield* Deferred.succeed(allow, undefined); + // Definition changes are serialized, so this returns after the abandoned creation settles. + yield* client.configureComposition({ members: [], dependencies: [] }); + + const [instance, ...others] = (yield* state.read(saved.id))?.instances ?? []; + if (instance === undefined) return yield* Effect.die("creation was not persisted"); + expect(others).toEqual([]); + expect((yield* client.status({ id: instance.id })).lifecycle).toBe("stopped"); + yield* client.destroyService({ id: instance.id }); + yield* shutdownOwner(runtime.access, true); + yield* Deferred.await(runtime.exit).pipe(Effect.timeout("5 seconds")); + expect(yield* state.read(saved.id)).toBeUndefined(); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("persists a composition change after its caller disconnects", () => + Effect.scoped( + Effect.gen(function* () { + const { root, state, saved } = yield* disconnectFixture("stack-host-configure-abort-"); + const entered = yield* Deferred.make<void>(); + const allow = yield* Deferred.make<void>(); + yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); + const owner = yield* ownerFor({ + saved, + state: { + ...state, + save: (next) => + (next.composition.members.length === 0 + ? Effect.void + : Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(allow))) + ).pipe(Effect.andThen(state.save(next))), + }, + root: `${root}/data`, + cacheRoot: "/tmp/supabase-stack-artifacts", + }); + const interrupted = yield* Deferred.make<void>(); + const { runtime } = yield* inProcessRuntime( + { + ...owner, + handlers: { + ...owner.handlers, + configureComposition: ( + input: Parameters<typeof owner.handlers.configureComposition>[0], + ) => + owner.handlers + .configureComposition(input) + .pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + }, + }, + state, + root, + ); + const client = yield* ownerClient(runtime.access); + const mail = yield* client.createService({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }); + const members = [{ id: mail.id, activation: "eager" as const }]; + const configure = yield* Effect.forkScoped( + client.configureComposition({ members, dependencies: [] }), + ); + yield* Deferred.await(entered); + yield* Fiber.interrupt(configure); + yield* Deferred.await(interrupted); + yield* Deferred.succeed(allow, undefined); + yield* client.createService({ service: "mail", config: {}, endpoints: {} }); + + expect((yield* state.read(saved.id))?.composition).toEqual({ members, dependencies: [] }); + yield* shutdownOwner(runtime.access, true); + yield* Deferred.await(runtime.exit).pipe(Effect.timeout("5 seconds")); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +const abandonedComposition = (prefix: string, destroy: boolean) => + Effect.gen(function* () { + const { root, state, saved } = yield* disconnectFixture(prefix); + const persisted = yield* Deferred.make<void>(); + const allow = yield* Deferred.make<void>(); + yield* Effect.addFinalizer(() => Deferred.succeed(allow, undefined)); + const owner = yield* ownerFor({ + saved, + state: { + ...state, + save: (next) => + state + .save(next) + .pipe( + Effect.andThen( + next.instances.length === 0 + ? Effect.void + : Deferred.succeed(persisted, undefined).pipe( + Effect.andThen(Deferred.await(allow)), + ), + ), + ), + }, + root: `${root}/data`, + cacheRoot: "/tmp/supabase-stack-artifacts", + }); + const interrupted = yield* Deferred.make<void>(); + const draining = yield* Deferred.make<void>(); + const { runtime } = yield* inProcessRuntime( + { + ...owner, + setDraining: (value: boolean) => + owner + .setDraining(value) + .pipe(Effect.andThen(value ? Deferred.succeed(draining, undefined) : Effect.void)), + handlers: { + ...owner.handlers, + supabaseComposition: (input: Parameters<typeof owner.handlers.supabaseComposition>[0]) => + owner.handlers + .supabaseComposition(input) + .pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + }, + }, + state, + root, + ); + const client = yield* ownerClient(runtime.access); + const composition = yield* Effect.forkScoped( + client.supabaseComposition({ + services: [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("abandoned-composition-password"), + jwtSecret: Redacted.make("abandoned-composition-jwt-secret-at-least-32-chars"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }, + ], + }), + ); + yield* Deferred.await(persisted); + yield* Fiber.interrupt(composition); + yield* Deferred.await(interrupted); + const shutdown = yield* Effect.forkScoped(shutdownOwner(runtime.access, destroy)); + yield* Deferred.await(draining); + yield* Deferred.succeed(allow, undefined); + yield* Fiber.join(shutdown); + yield* Deferred.await(runtime.exit).pipe(Effect.timeout("5 seconds")); + return { root, state, saved }; + }); + +it.live("stops a stack only after an abandoned composition settles", () => + Effect.scoped( + Effect.gen(function* () { + const { state, saved } = yield* abandonedComposition("stack-host-compose-stop-", false); + + const current = yield* state.read(saved.id); + const instanceIds = current?.instances.map(({ id }) => id) ?? []; + expect(instanceIds).toHaveLength(1); + expect(current?.composition.members.map(({ id }) => id)).toEqual(instanceIds); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); + +it.live("destroys a stack only after an abandoned composition settles", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { root, state, saved } = yield* abandonedComposition( + "stack-host-compose-destroy-", + true, + ); + + expect(yield* state.read(saved.id)).toBeUndefined(); + const data = `${root}/data`; + expect((yield* fs.exists(data)) ? yield* fs.readDirectory(data) : []).toEqual([]); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); diff --git a/packages/stack/src/StackHost.ts b/packages/stack/src/StackHost.ts index 44c1c5b4e1..e2571409c3 100644 --- a/packages/stack/src/StackHost.ts +++ b/packages/stack/src/StackHost.ts @@ -1,8 +1,15 @@ -import { NodeHttpServerRequest, NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { + NodeHttpClient, + NodeHttpServer, + NodeHttpServerRequest, + NodeServices, +} from "@effect/platform-node"; import { Context, Cause, + Crypto, Data, + DateTime, Deferred, Effect, Exit, @@ -14,7 +21,6 @@ import { Ref, Scope, Semaphore, - Stream, Path, } from "effect"; import * as HttpServer from "effect/unstable/http/HttpServer"; @@ -22,64 +28,93 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; -import { acquireHost, HostEndpoint } from "./HostProcess.ts"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- NodeHttpServer.make requires a native server factory. +import * as Http from "node:http"; +import { + currentRelease, + authorizes, + ShutdownRequest, + type HostAccess, + type HostEndpoint, + type ShutdownFailure, +} from "./HostProcess.ts"; +import { projectSegmentFor } from "./identity/Identity.ts"; import * as Owner from "./Owner.ts"; -import { OwnerError } from "./Owner.ts"; -import * as Orchestrator from "./Orchestrator.ts"; -import { StackError, StackRpc } from "./Rpc.ts"; +import { StackError, stackError, StackRpc, type RunCommandPayload } from "./Rpc.ts"; +import { makeHostGateway } from "./runtime/Container.ts"; import * as State from "./State.ts"; -import { makeToolAttachments, type ToolAttachmentPayload } from "./host/ToolAttachments.ts"; -import * as ToolRunner from "./host/ToolRunner.ts"; -import * as Container from "./runtime/Container.ts"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import type { CatalogLog } from "./services/Catalog.ts"; +import { sweepOrphans } from "./Sweep.ts"; +import { makeCommandAttachments } from "./host/CommandAttachments.ts"; +import * as CommandRunner from "./host/CommandRunner.ts"; export interface StackHostOptions { readonly stateRoot: string; readonly cacheRoot: string; readonly stackId: string; - readonly onReady?: (endpoint: HostEndpoint) => Effect.Effect<void, StackHostError>; + /** Registers this definition once the owner holds the lease; the stack must not exist. */ + readonly register?: State.SavedStack; + readonly release?: string; + readonly onReady?: (access: HostAccess) => Effect.Effect<void, StackHostError>; } export class StackHostError extends Data.TaggedError("StackHostError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; + readonly reason?: "lease-held" | "exists" | "runtime-unavailable"; }> {} -const hostError = (operation: string, cause: unknown) => +const hostError = (operation: string, cause: unknown, reason?: "runtime-unavailable") => new StackHostError({ operation, message: cause instanceof Error ? cause.message : String(cause), cause, + ...(reason === undefined ? {} : { reason }), }); -const stackError = (operation: string, cause: unknown): StackError => { - if (cause instanceof StackError) return cause; - const orchestration = - cause instanceof Orchestrator.OrchestratorError - ? cause - : cause instanceof OwnerError && cause.cause instanceof Orchestrator.OrchestratorError - ? cause.cause - : undefined; - if (orchestration?.outcomes !== undefined) { - return new StackError({ - operation, - message: orchestration.message, - outcomes: orchestration.outcomes.map(({ id, result }) => ({ - id, - succeeded: Exit.isSuccess(result), - ...(Exit.isFailure(result) ? { error: Orchestrator.causeMessage(result.cause) } : {}), - })), - }); - } - return new StackError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - }); +/** Binds the owner's loopback control listener on an OS-assigned port. */ +export const bindControl = Effect.fn("StackHost.bindControl")(function* () { + let rawServer: Http.Server | undefined; + const server = yield* NodeHttpServer.make( + () => { + rawServer = Http.createServer(); + return rawServer; + }, + { host: "127.0.0.1", port: 0 }, + ).pipe(Effect.mapError((cause) => hostError("control", cause))); + if (server.address._tag !== "TcpAddress") + return yield* hostError("control", "Control listener has no TCP address"); + return { + port: server.address.port, + server, + closeConnections: Effect.sync(() => { + rawServer?.closeAllConnections(); + rawServer?.closeIdleConnections(); + }), + }; +}); + +const shutdownFailure = (cause: unknown): ShutdownFailure => { + const failure = stackError("shutdown", cause); + return { + message: failure.message, + ...(failure.outcomes === undefined ? {} : { outcomes: failure.outcomes }), + }; }; -const log = (value: CatalogLog) => ({ stream: value.stream, bytes: value.bytes }); +const creatorGone = Effect.callback<void>((resume) => { + const done = () => resume(Effect.void); + process.stdin.once("end", done); + process.stdin.once("close", done); + process.stdin.once("error", done); + process.stdin.resume(); + return Effect.sync(() => { + process.stdin.off("end", done); + process.stdin.off("close", done); + process.stdin.off("error", done); + process.stdin.pause(); + }); +}); const isOpen = (value: boolean): Effect.Effect<void, StackError> => value @@ -115,6 +150,7 @@ const responseClosed = (response: ReturnType<typeof NodeHttpServerRequest.toServ export interface StackHostRuntime { readonly endpoint: HostEndpoint; + readonly access: HostAccess; readonly serve: Effect.Effect<void, never, Scope.Scope>; readonly closeConnections: Effect.Effect<void>; readonly shutdown: ( @@ -127,29 +163,36 @@ export interface StackHostRuntime { export const makeRuntime = Effect.fn("StackHost.makeRuntime")( ( owner: Owner.Interface, - endpoint: HostEndpoint, + access: HostAccess, server: HttpServer.HttpServer["Service"], closeConnections: Effect.Effect<void>, - container?: { - readonly engine: "docker" | "podman"; - readonly stackId: string; - readonly root: string; - }, - ): Effect.Effect< - StackHostRuntime, - never, - Scope.Scope | ToolRunner.Service | ChildProcessSpawner.ChildProcessSpawner - > => + ): Effect.Effect<StackHostRuntime, never, Scope.Scope | CommandRunner.Service> => Effect.gen(function* () { const scope = yield* Scope.Scope; - const runner = yield* ToolRunner.Service; - const childSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const attachments = yield* makeToolAttachments({ - admit: owner.getServing.pipe( - Effect.flatMap(isOpen), - Effect.mapError((cause) => stackError("host", cause)), - ), - run: runner.run, + const runner = yield* CommandRunner.Service; + const attachments = yield* makeCommandAttachments({ + admit: owner.getServing.pipe(Effect.flatMap(isOpen)), + run: (input) => + "stdin" in input + ? runner.run({ + command: input.command, + stdin: input.stdin, + stdout: input.stdout, + stderr: input.stderr, + }) + : owner.getStackCredentials.pipe( + Effect.mapError( + (cause) => new CommandRunner.CommandError({ message: cause.message, cause }), + ), + Effect.flatMap((credentials) => + runner.run({ + command: input.command, + credentials, + stdout: input.stdout, + stderr: input.stderr, + }), + ), + ), toError: stackError, }); const exit = yield* Deferred.make<void>(); @@ -188,21 +231,10 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( if (response !== undefined && responseClosedSignal !== undefined) yield* watchResponse(response, responseClosedSignal); let retiringAfterDestroyFailure = false; - const removeOwned = - container === undefined - ? Effect.void - : Container.removeStackContainers(container).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - childSpawner, - ), - Effect.mapError((cause) => stackError("shutdown", cause)), - ); const stopOwned = Effect.gen(function* () { yield* attachments.stopAll; yield* runner.cleanup; yield* owner.namespace.stop; - yield* removeOwned; }); const finish = Effect.gen(function* () { if (response !== undefined) { @@ -277,11 +309,12 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( Effect.catchCause((cause) => retiringAfterDestroyFailure ? Effect.failCause(cause) - : owner.setDraining(false).pipe( - Effect.mapError((reset) => stackError("shutdown", reset)), - Effect.andThen(gate.withPermits(1)(Ref.set(current, undefined))), - Effect.andThen(Effect.failCause(cause)), - ), + : owner + .setDraining(false) + .pipe( + Effect.andThen(gate.withPermits(1)(Ref.set(current, undefined))), + Effect.andThen(Effect.failCause(cause)), + ), ), ); const fiber = yield* Effect.forkIn(cleanup, scope); @@ -293,118 +326,17 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( yield* Fiber.join(fiber); }).pipe(Effect.mapError((cause) => stackError("shutdown", cause))), ); - const handlers = { - createService: (creation: unknown) => - owner.services - .create(creation) - .pipe(Effect.mapError((cause) => stackError("createService", cause))), - getService: ({ id }: { readonly id: string }) => - owner.services.get(id).pipe(Effect.mapError((cause) => stackError("getService", cause))), - listServices: () => - owner.services.list.pipe(Effect.mapError((cause) => stackError("listServices", cause))), - startService: ({ id }: { readonly id: string }) => - owner.core.start(id).pipe(Effect.mapError((cause) => stackError("startService", cause))), - readyService: ({ id }: { readonly id: string }) => - owner.core.ready(id).pipe(Effect.mapError((cause) => stackError("readyService", cause))), - stopService: ({ id }: { readonly id: string }) => - owner.core.stop(id).pipe(Effect.mapError((cause) => stackError("stopService", cause))), - restartService: ({ id, config }: { readonly id: string; readonly config?: unknown }) => - owner.core - .restart(id, config) - .pipe(Effect.mapError((cause) => stackError("restartService", cause))), - destroyService: ({ id }: { readonly id: string }) => - owner.core - .destroy(id) - .pipe(Effect.mapError((cause) => stackError("destroyService", cause))), - prepareService: ({ id }: { readonly id: string }) => - owner.core - .prepare(id) - .pipe(Effect.mapError((cause) => stackError("prepareService", cause))), - status: ({ id }: { readonly id: string }) => - owner.core.status(id).pipe(Effect.mapError((cause) => stackError("status", cause))), - followStatus: ({ id }: { readonly id: string }) => - owner.core - .followStatus(id) - .pipe(Stream.mapError((cause) => stackError("followStatus", cause))), - logs: ({ id }: { readonly id: string }) => - owner.core.logs(id).pipe( - Stream.map(log), - Stream.mapError((cause) => stackError("logs", cause)), - ), - credentials: ({ id, from }: { readonly id: string; readonly from: "host" | "runtime" }) => - owner - .credentials(id, from) - .pipe(Effect.mapError((cause) => stackError("credentials", cause))), - saveSnapshot: ({ id, key }: { readonly id: string; readonly key: string }) => - owner.snapshots - .saveSnapshot(id, key) - .pipe(Effect.mapError((cause) => stackError("saveSnapshot", cause))), - restoreSnapshot: ({ id, key }: { readonly id: string; readonly key: string }) => - owner.snapshots - .restoreSnapshot(id, key) - .pipe(Effect.mapError((cause) => stackError("restoreSnapshot", cause))), - resetData: ({ id }: { readonly id: string }) => - owner.snapshots - .resetData(id) - .pipe(Effect.mapError((cause) => stackError("resetData", cause))), - supabaseComposition: ({ - services, - reuseIds, - identity, - }: { - readonly services: Parameters<Owner.Interface["composition"]["supabase"]>[0]; - readonly reuseIds?: NonNullable< - Parameters<Owner.Interface["composition"]["supabase"]>[1] - >["reuseIds"]; - readonly identity?: NonNullable< - Parameters<Owner.Interface["composition"]["supabase"]>[1] - >["identity"]; - }) => - owner.composition - .supabase(services, { reuseIds, identity }) - .pipe(Effect.mapError((cause) => stackError("supabaseComposition", cause))), - configureComposition: ( - configuration: Parameters<Owner.Interface["composition"]["configure"]>[0], - ) => - owner.composition - .configure(configuration) - .pipe(Effect.mapError((cause) => stackError("configureComposition", cause))), - getComposition: () => - owner.composition.get.pipe( - Effect.mapError((cause) => stackError("getComposition", cause)), - ), - startComposition: () => - owner.composition.start.pipe( - Effect.mapError((cause) => stackError("startComposition", cause)), - ), - stopComposition: () => - owner.composition.stop.pipe( - Effect.mapError((cause) => stackError("stopComposition", cause)), - ), - restartComposition: () => - owner.composition.restart.pipe( - Effect.mapError((cause) => stackError("restartComposition", cause)), - ), - shutdown: ({ destroy }: { readonly destroy: boolean }) => - Effect.gen(function* () { - const request = yield* Effect.serviceOption(HttpServerRequest.HttpServerRequest); - yield* Option.match(request, { - onNone: () => - Effect.fail( - new StackError({ operation: "shutdown", message: "Request context is missing" }), - ), - onSome: (value) => shutdown(destroy, NodeHttpServerRequest.toServerResponse(value)), - }); - }).pipe(Effect.mapError((cause) => stackError("shutdown", cause))), - runTool: (input: ToolAttachmentPayload) => attachments.run(input), - toolInput: ({ + const handlers = StackRpc.of({ + ...owner.handlers, + runCommand: (input: RunCommandPayload) => attachments.run(input), + commandInput: ({ attachmentId, bytes, }: { readonly attachmentId: string; readonly bytes: Uint8Array | null; - }) => attachments.input(attachmentId, true, bytes), - }; + }) => attachments.input(attachmentId, bytes), + }); const rpc = yield* RpcServer.toHttpEffect(StackRpc, { streamBufferSize: 16 }).pipe( Effect.provide(Layer.merge(StackRpc.toLayer(handlers), RpcSerialization.layerNdjson)), ); @@ -414,8 +346,28 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( HttpServerRequest.HttpServerRequest | Scope.Scope > = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; + if (!authorizes(request.headers.authorization, access.secret)) + return HttpServerResponse.empty({ status: 401 }); if (request.method === "GET" && request.url === "/identity") { - return HttpServerResponse.jsonUnsafe(endpoint); + return HttpServerResponse.jsonUnsafe(access.endpoint); + } + if (request.method === "POST" && request.url === "/shutdown") { + const body = yield* HttpServerRequest.schemaBodyJson(ShutdownRequest).pipe(Effect.option); + if (Option.isNone(body)) return HttpServerResponse.empty({ status: 400 }); + const result = yield* shutdown( + body.value.destroy, + NodeHttpServerRequest.toServerResponse(request), + ).pipe(Effect.exit); + return Exit.isSuccess(result) + ? HttpServerResponse.empty({ status: 204 }) + : HttpServerResponse.jsonUnsafe( + shutdownFailure( + Option.getOrElse(Cause.findErrorOption(result.cause), () => + Cause.pretty(result.cause), + ), + ), + { status: 500 }, + ); } if (request.method === "POST" && request.url.startsWith("/rpc")) return yield* rpc.pipe(Effect.interruptible); @@ -423,51 +375,84 @@ export const makeRuntime = Effect.fn("StackHost.makeRuntime")( }); const serve: Effect.Effect<void, never, Scope.Scope> = server.serve(application); - return { endpoint, serve, shutdown, closeConnections, exit }; + return { endpoint: access.endpoint, access, serve, shutdown, closeConnections, exit }; }), ); +type HostEvent = "SIGTERM" | "SIGINT" | "creator-gone"; + export const runStackHost = Effect.fn("StackHost.run")( (options: StackHostOptions): Effect.Effect<void, StackHostError, never> => Effect.scoped( Effect.gen(function* () { - const signals = yield* Queue.bounded<"SIGTERM" | "SIGINT">(8); + const events = yield* Queue.bounded<HostEvent>(8); yield* Effect.forkScoped( Effect.forever( requestSignal().pipe( - Effect.flatMap((signal) => Queue.offer(signals, signal).pipe(Effect.asVoid)), + Effect.flatMap((signal) => Queue.offer(events, signal).pipe(Effect.asVoid)), ), ), ); + const stateContext = yield* Layer.build(State.layer({ root: options.stateRoot })); + const state = Context.get(stateContext, State.Service); + const id = options.stackId; + // The lease is released last, after every owned process and listener has closed. + if (!(yield* state.lease(id))) + return yield* new StackHostError({ + operation: "lease", + message: `Another owner holds the lease of stack ${id}`, + reason: "lease-held", + }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.truncate(state.ownerLog(id)).pipe(Effect.ignore); + yield* state.retractHolder(id); + const register = options.register; + if (register !== undefined) + yield* state.withLock( + Effect.gen(function* () { + if ((yield* state.read(id)) !== undefined) + return yield* new StackHostError({ + operation: "register", + message: "Stack already exists; use open", + reason: "exists", + }); + yield* state.save(register); + }), + ); const started = yield* Effect.gen(function* () { - const stateContext = yield* Layer.build(State.layer({ root: options.stateRoot })); - const state = Context.get(stateContext, State.Service); - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const saved = yield* state.read(options.stackId); + const saved = yield* state.read(id); if (saved === undefined) return yield* hostError("startup", "Stack is not registered"); - const acquired = yield* acquireHost(state, options.stackId); + const control = yield* bindControl(); const dataRootPath = path.join(options.stateRoot, saved.id, "data"); yield* fs.makeDirectory(dataRootPath, { recursive: true }); const dataRoot = yield* fs.realPath(dataRootPath); - if (saved.runtime !== "native") - yield* Container.removeStackContainers({ - engine: saved.runtime, - stackId: saved.id, - root: dataRoot, - }).pipe(Effect.mapError((cause) => hostError("startup-cleanup", cause))); + const project = projectSegmentFor(saved.identity, path); + yield* Owner.sweepContainers(saved, dataRoot).pipe( + Effect.mapError((cause) => + hostError( + "startup-cleanup", + cause, + cause.reason === "engine-unavailable" ? "runtime-unavailable" : undefined, + ), + ), + ); + const hostGateway = yield* makeHostGateway; const services = yield* Layer.build( Layer.merge( Owner.layer({ saved, root: dataRoot, cacheRoot: options.cacheRoot, + hostGateway, }), - ToolRunner.layer({ + CommandRunner.layer({ stackId: saved.id, + project, root: dataRoot, cacheRoot: options.cacheRoot, runtime: saved.runtime, + hostGateway, }), ).pipe(Layer.provide(Layer.succeed(State.Service, state))), ); @@ -476,35 +461,81 @@ export const runStackHost = Effect.fn("StackHost.run")( stackId: saved.id, identity: saved.identity, pid: process.pid, - port: acquired.port, + port: control.port, + release: options.release ?? (yield* currentRelease), }; + const crypto = yield* Crypto.Crypto; + const secret = Array.from(yield* crypto.randomBytes(32), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + const access: HostAccess = { endpoint, secret }; const runtime = yield* makeRuntime( owner, - endpoint, - acquired.server, - acquired.closeConnections, - saved.runtime === "native" - ? undefined - : { engine: saved.runtime, stackId: saved.id, root: dataRoot }, + access, + control.server, + control.closeConnections, ).pipe( - Effect.provideService(ToolRunner.Service, Context.get(services, ToolRunner.Service)), + Effect.provideService( + CommandRunner.Service, + Context.get(services, CommandRunner.Service), + ), ); yield* runtime.serve; - yield* options.onReady?.(endpoint) ?? Effect.void; + yield* Effect.addFinalizer(() => state.retractHolder(id).pipe(Effect.ignore)); + yield* state.publishHolder(id, { + role: "owner", + secret, + port: endpoint.port, + pid: endpoint.pid, + release: endpoint.release, + lifetime: saved.lifetime, + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + if (saved.lifetime === "session") + yield* Effect.forkScoped( + creatorGone.pipe(Effect.andThen(Queue.offer(events, "creator-gone"))), + ); + yield* options.onReady?.(access) ?? Effect.void; + yield* Effect.forkScoped( + sweepOrphans({ + state, + stateRoot: options.stateRoot, + cacheRoot: options.cacheRoot, + ownerId: id, + }), + ); return runtime; }).pipe( Effect.raceFirst( - Queue.take(signals).pipe( - Effect.flatMap((signal) => hostError("startup", `Received ${signal} during startup`)), + Queue.take(events).pipe( + Effect.flatMap((event) => + hostError( + "startup", + event === "creator-gone" + ? "The session stack's creator exited during startup" + : `Received ${event} during startup`, + ), + ), ), ), + // A stack registered by this owner must not outlive a failed start. + Effect.onError(() => + register === undefined ? Effect.void : state.remove(id).pipe(Effect.ignore), + ), ); while (true) { const event = yield* Deferred.await(started.exit).pipe( Effect.map(() => "done" as const), - Effect.raceFirst(Queue.take(signals).pipe(Effect.map(() => "signal" as const))), + Effect.raceFirst(Queue.take(events)), ); if (event === "done") break; + if (event === "creator-gone") { + yield* started.shutdown(true).pipe( + Effect.tapCause((cause) => Effect.logError("Session stack destroy failed", cause)), + Effect.ignore, + ); + break; + } const shutdownSucceeded = yield* started.shutdown(false).pipe( Effect.tapCause((cause) => Effect.logError("Stack shutdown failed", cause)), Effect.matchCause({ onSuccess: () => true, onFailure: () => false }), diff --git a/packages/stack/src/State.integration.test.ts b/packages/stack/src/State.integration.test.ts index a4a4627b91..19a1548de3 100644 --- a/packages/stack/src/State.integration.test.ts +++ b/packages/stack/src/State.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { TestClock } from "effect/testing"; import { Context, + DateTime, Deferred, Effect, Exit, @@ -13,6 +14,7 @@ import { PlatformError, Ref, Schema, + Scope, } from "effect"; import * as State from "./State.ts"; import type { SavedStack } from "./State.ts"; @@ -31,6 +33,7 @@ const initial: SavedStack = { }, runtime: "docker", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }; @@ -58,7 +61,7 @@ describe("durable stack state", () => { ...current, instances: [ ...current.instances, - { id, creation: { service: "database", config: { version: "17" } } }, + { id, creation: { service: "mail", config: {} } }, ], }); }), @@ -298,6 +301,208 @@ describe("durable stack state", () => { ), ); + const listingWithReadFailures = (options: { + readonly root: string; + readonly platform: NodeJS.Platform; + readonly code: string; + readonly failures: number; + }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const target = (yield* Path.Path).join(options.root, initial.id, "state.json"); + const remaining = yield* Ref.make(options.failures); + const firstFailure = yield* Deferred.make<void>(); + const reported = yield* Ref.make<ReadonlyArray<string>>([]); + const injectedFs = Layer.succeed(FileSystem.FileSystem, { + ...fs, + readFileString: (file: string, encoding?: string) => + Effect.gen(function* () { + if (file === target && (yield* Ref.get(remaining)) > 0) { + yield* Ref.update(remaining, (count) => count - 1); + yield* Deferred.succeed(firstFailure, undefined); + return yield* PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: file, + cause: Object.assign(new Error("injected read failure"), { code: options.code }), + }); + } + return yield* fs.readFileString(file, encoding); + }), + }); + const store = yield* Layer.build( + State.layer({ + root: options.root, + platform: options.platform, + onInvalidState: (id) => Ref.update(reported, (ids) => [...ids, id]), + }).pipe(Layer.provide(injectedFs)), + ).pipe(Effect.map((context) => Context.get(context, State.Service))); + return { store, firstFailure, reported }; + }); + + it.effect("lists a stack after a transient Windows read failure clears", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-list-retry-" }); + yield* (yield* makeTestState(root)).save(initial); + const { store, firstFailure, reported } = yield* listingWithReadFailures({ + root, + platform: "win32", + code: "EBUSY", + failures: 1, + }); + + const listing = yield* store.list.pipe(Effect.forkScoped); + yield* Deferred.await(firstFailure); + yield* TestClock.adjust("10 millis"); + expect((yield* Fiber.join(listing)).map(({ id }) => id)).toEqual([initial.id]); + expect(yield* Ref.get(reported)).toEqual([]); + }), + ), + ); + + it.effect("skips and reports a stack whose state stays unreadable after retries", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const [platform, code] of [ + ["win32", "EBUSY"], + ["linux", "EACCES"], + ] as const) { + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-list-skip-" }); + yield* (yield* makeTestState(root)).save(initial); + const { store, firstFailure, reported } = yield* listingWithReadFailures({ + root, + platform, + code, + failures: Number.POSITIVE_INFINITY, + }); + + const listing = yield* store.list.pipe(Effect.forkScoped); + yield* Deferred.await(firstFailure); + yield* TestClock.adjust("950 millis"); + expect(yield* Fiber.join(listing)).toEqual([]); + expect(yield* Ref.get(reported)).toEqual([initial.id]); + } + }), + ), + ); + + it.live.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "lists and claims readable stacks past a sibling directory it cannot access", + () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-list-eacces-" }); + const reported: Array<string> = []; + const store = yield* Layer.build( + State.layer({ root, onInvalidState: (id) => Effect.sync(() => reported.push(id)) }), + ).pipe(Effect.map((context) => Context.get(context, State.Service))); + yield* store.save(initial); + const locked = path.join(root, "locked"); + yield* fs.makeDirectory(locked, { mode: 0o700 }); + yield* fs.writeFileString(path.join(locked, "state.json"), "{}"); + yield* Effect.acquireRelease(fs.chmod(locked, 0o000), () => + fs.chmod(locked, 0o700).pipe(Effect.orDie), + ); + + expect((yield* store.list).map(({ id }) => id)).toEqual([initial.id]); + expect(reported).toEqual(["locked"]); + expect((yield* store.claims).map(({ id }) => id)).toEqual([initial.id]); + }), + ), + ); + + it.live("skips and reports malformed, mismatched, and non-file entries while listing", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-list-invalid-" }); + const reported: Array<string> = []; + const store = yield* Layer.build( + State.layer({ + root, + onInvalidState: (id) => Effect.sync(() => reported.push(id)), + }), + ).pipe(Effect.map((context) => Context.get(context, State.Service))); + yield* store.save(initial); + yield* fs.makeDirectory(path.join(root, "malformed")); + yield* fs.writeFileString(path.join(root, "malformed", "state.json"), "{broken"); + yield* fs.makeDirectory(path.join(root, "mismatched")); + yield* fs.writeFileString( + path.join(root, "mismatched", "state.json"), + yield* Schema.encodeEffect(Schema.fromJsonString(State.SavedStack))(initial), + ); + yield* fs.makeDirectory(path.join(root, "directory", "state.json"), { recursive: true }); + yield* fs.writeFileString(path.join(root, "stray-file"), ""); + + expect((yield* store.list).map(({ id }) => id)).toEqual([initial.id]); + // Windows resolves a path below a regular file as missing rather than unreadable. + expect(reported.toSorted()).toEqual( + process.platform === "win32" + ? ["directory", "malformed", "mismatched"] + : ["directory", "malformed", "mismatched", "stray-file"], + ); + }), + ), + ); + + it.live("decodes saved compositions, creations, and instance ids strictly", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-typed-" }); + const reported: Array<string> = []; + const store = yield* Layer.build( + State.layer({ + root, + onInvalidState: (id) => Effect.sync(() => reported.push(id)), + }), + ).pipe(Effect.map((context) => Context.get(context, State.Service))); + yield* store.save(initial); + const mail = { service: "mail", config: {} }; + const invalid = { + "bad-composition": { composition: { members: "none", dependencies: [] } }, + "bad-creation": { instances: [{ id: "one", creation: { service: "unknown" } }] }, + "duplicate-ids": { + instances: [ + { id: "one", creation: mail }, + { id: "one", creation: mail }, + ], + }, + }; + for (const [id, override] of Object.entries(invalid)) { + yield* fs.makeDirectory(path.join(root, id)); + yield* fs.writeFileString( + path.join(root, id, "state.json"), + yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + ...initial, + id, + ...override, + }), + ); + } + + expect((yield* store.list).map(({ id }) => id)).toEqual([initial.id]); + expect(reported.toSorted()).toEqual(Object.keys(invalid)); + for (const id of Object.keys(invalid)) { + const failure = yield* store.read(id).pipe(Effect.flip); + expect(failure.operation).toBe("decode"); + expect(failure.message).toContain("Unable to decode state"); + expect(failure.message).toContain(`for stack ${id}`); + } + const duplicate = yield* store.read("duplicate-ids").pipe(Effect.flip); + expect(duplicate.message).toContain("Expected unique instance ids"); + }), + ), + ); + it.effect("cleans up a cancelled Windows replacement and releases the state lock", () => run( Effect.gen(function* () { @@ -360,6 +565,31 @@ describe("durable stack state", () => { ), ); + it.live("reaps write leftovers of dead writers and keeps recent ones on open", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-state-reap-" }); + const abandoned = path.join(root, ".state-write-abandoned"); + const inProgress = path.join(root, ".state-write-in-progress"); + for (const directory of [abandoned, inProgress]) { + yield* fs.makeDirectory(directory); + yield* fs.writeFileString(path.join(directory, "state.json"), "{}"); + } + const twoDaysAgo = DateTime.toDateUtc(DateTime.subtract(yield* DateTime.now, { days: 2 })); + yield* fs.utimes(abandoned, twoDaysAgo, twoDaysAgo); + + const store = yield* makeTestState(root); + + expect(yield* fs.exists(abandoned)).toBe(false); + expect(yield* fs.exists(path.join(inProgress, "state.json"))).toBe(true); + yield* store.save(initial); + expect(yield* store.read(initial.id)).toEqual(initial); + }), + ), + ); + it.live("removes empty stack parents without deleting unowned data", () => run( Effect.gen(function* () { @@ -490,3 +720,61 @@ describe("durable stack state", () => { ), ); }); + +describe("stack owner lease", () => { + it.live("hands the lease of a removed stack to a waiter on a live lease file", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-lease-handoff-" }); + const holder = yield* makeTestState(root); + const contended = yield* Deferred.make<void>(); + const waiter = Context.get( + yield* Layer.build( + State.layer({ + root, + onLeaseContended: () => Deferred.succeed(contended, undefined).pipe(Effect.asVoid), + }), + ), + State.Service, + ); + const observer = yield* makeTestState(root); + const holderScope = yield* Scope.make(); + expect(yield* holder.lease("gone").pipe(Scope.provide(holderScope))).toBe(true); + + const waiterScope = yield* Scope.make(); + const waiting = yield* waiter + .lease("gone") + .pipe(Scope.provide(waiterScope), Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(contended); + yield* Scope.close(holderScope, Exit.void); + + expect(yield* Fiber.join(waiting)).toBe(true); + expect(yield* observer.leased("gone"), "the path names the file the waiter locked").toBe( + true, + ); + yield* Scope.close(waiterScope, Exit.void); + expect(yield* observer.leased("gone")).toBe(false); + expect(yield* fs.exists(`${root}/gone`), "the last holder removes the directory").toBe( + false, + ); + }), + ), + ); + + it.live("reports a free lease without creating a lease file", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-lease-probe-" }); + const state = yield* makeTestState(root); + yield* state.save(initial); + expect(yield* state.leased(initial.id)).toBe(false); + expect(yield* state.readHolder(initial.id), "a retracted record reads as absent").toBe( + undefined, + ); + expect(yield* fs.exists(`${root}/${initial.id}/owner.lock`)).toBe(false); + }), + ), + ); +}); diff --git a/packages/stack/src/State.process.integration.test.ts b/packages/stack/src/State.process.integration.test.ts index 458911e7ec..90dec13572 100644 --- a/packages/stack/src/State.process.integration.test.ts +++ b/packages/stack/src/State.process.integration.test.ts @@ -14,6 +14,7 @@ import { } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { fileURLToPath } from "node:url"; +import { ownerExitProbe, waitForOwnerExit } from "./HostProcess.ts"; import * as State from "./State.ts"; class FixtureError extends Data.TaggedError("StateLockFixtureError")<{ @@ -70,6 +71,7 @@ for (const compiled of [false, true]) { identity: { projectRoot: root, branchContext: "test", stackName: "lock" }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }; @@ -178,7 +180,9 @@ for (const compiled of [false, true]) { Effect.ignore, Effect.andThen(Fiber.join(output)), Effect.andThen(Fiber.join(diagnostics)), - Effect.timeout("5 seconds"), + // Windows keeps the compiled child's image in the temp root locked until it exits. + Effect.andThen(waitForOwnerExit(childPid, ownerExitProbe(fs)).pipe(Effect.ignore)), + Effect.timeout("10 seconds"), Effect.orDie, ), ); diff --git a/packages/stack/src/State.ts b/packages/stack/src/State.ts index 5f941e6edf..7b738ac908 100644 --- a/packages/stack/src/State.ts +++ b/packages/stack/src/State.ts @@ -1,20 +1,27 @@ import { + Clock, Data, Duration, Effect, + Exit, FileSystem, Context, Layer, + Option, Path, Predicate, Schedule, Schema, + Scope, } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no non-recursive directory removal operation. import { rmdir } from "node:fs/promises"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no OS-owned cross-process lock primitive. import { DatabaseSync } from "node:sqlite"; +import { pathToFileURL } from "node:url"; +import { CompositionConfig } from "./Orchestrator.ts"; import { restrictDirectoryToOwner } from "./runtime/postgres-user.ts"; +import { ServiceCreation } from "./services/Catalog.ts"; const SafeId = Schema.String.pipe( Schema.refine((value): value is string => /^[a-zA-Z0-9_-]+$/u.test(value), { @@ -23,13 +30,14 @@ const SafeId = Schema.String.pipe( }), ); -export const SavedInstance = Schema.Struct({ +const SavedInstance = Schema.Struct({ id: SafeId, - creation: Schema.Unknown, + creation: Schema.toCodecJson(ServiceCreation), }); -export interface SavedInstance extends Schema.Schema.Type<typeof SavedInstance> {} +interface SavedInstance extends Schema.Schema.Type<typeof SavedInstance> {} -export const StackIdentityInput = Schema.Struct({ +/** API and JWT signing keys that override the keys a stack derives by default. */ +export const StackKeysInput = Schema.Struct({ publishableKey: Schema.optionalKey(Schema.String), secretKey: Schema.optionalKey(Schema.String), anonKey: Schema.optionalKey(Schema.String), @@ -40,7 +48,7 @@ export const StackIdentityInput = Schema.Struct({ publicSigningKeys: Schema.optionalKey(Schema.String), remoteJwks: Schema.optionalKey(Schema.String), }); -export interface StackIdentityInput extends Schema.Schema.Type<typeof StackIdentityInput> {} +export interface StackKeysInput extends Schema.Schema.Type<typeof StackKeysInput> {} export const StackCredentials = Schema.Struct({ jwtSecret: Schema.String, @@ -58,27 +66,61 @@ export const StackCredentials = Schema.Struct({ }); export interface StackCredentials extends Schema.Schema.Type<typeof StackCredentials> {} +const PortClaim = Schema.Struct({ + key: Schema.String, + host: Schema.String, + port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })), +}); +export interface PortClaim extends Schema.Schema.Type<typeof PortClaim> {} + +/** A session stack is destroyed when its creator exits; a detached stack outlives it. */ +export const StackLifetime = Schema.Literals(["session", "detached"]); +export type StackLifetime = Schema.Schema.Type<typeof StackLifetime>; + export const SavedStack = Schema.Struct({ id: SafeId, + lifetime: StackLifetime, identity: Schema.Struct({ projectRoot: Schema.String, branchContext: Schema.String, stackName: Schema.String, }), runtime: Schema.Literals(["native", "docker", "podman"]), - instances: Schema.Array(SavedInstance), - composition: Schema.Unknown, - credentials: Schema.optionalKey(StackCredentials), - ports: Schema.Array( - Schema.Struct({ - key: Schema.String, - host: Schema.String, - port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })), - }), + instances: Schema.Array(SavedInstance).check( + Schema.makeFilter((instances) => + new Set(instances.map(({ id }) => id)).size === instances.length + ? undefined + : "Expected unique instance ids", + ), ), + composition: CompositionConfig, + credentials: Schema.optionalKey(StackCredentials), + ports: Schema.Array(PortClaim), }); export interface SavedStack extends Schema.Schema.Type<typeof SavedStack> {} +const ClaimsDocument = Schema.Struct({ ports: Schema.Array(PortClaim) }); + +/** What the current lease holder publishes: an owner's control endpoint, or a sweeper's marker. */ +const LeaseHolder = Schema.Union([ + Schema.Struct({ + role: Schema.Literal("owner"), + port: PortClaim.fields.port, + pid: Schema.Int, + release: Schema.String, + lifetime: StackLifetime, + startedAt: Schema.String, + secret: Schema.String, + }), + Schema.Struct({ role: Schema.Literal("sweeper"), pid: Schema.Int, startedAt: Schema.String }), +]); +type LeaseHolder = Schema.Schema.Type<typeof LeaseHolder>; + +export interface StackClaims { + readonly id: string; + readonly ports: ReadonlyArray<PortClaim>; +} + export class StateError extends Data.TaggedError("StateError")<{ readonly operation: string; readonly message: string; @@ -87,13 +129,30 @@ export class StateError extends Data.TaggedError("StateError")<{ export interface Interface { readonly read: (id: string) => Effect.Effect<SavedStack | undefined, StateError>; + /** Skips each stack entry that stays unreadable after transient retries, reporting it to `onInvalidState`. */ readonly list: Effect.Effect<ReadonlyArray<SavedStack>, StateError>; + /** Decodes only each stack's port claims and silently skips entries that cannot provide them. */ + readonly claims: Effect.Effect<ReadonlyArray<StackClaims>, StateError>; readonly save: (state: SavedStack) => Effect.Effect<void, StateError>; readonly remove: (id: string) => Effect.Effect<void, StateError>; /** Not reentrant; wrap metadata updates here, while Ports operations acquire this lock themselves. */ readonly withLock: <A, E, R>( effect: Effect.Effect<A, E, R>, ) => Effect.Effect<A, E | StateError, R>; + /** + * Holds the stack's owner lease for the enclosing scope, or returns `false` while another + * process holds it. Releasing the lease of a removed stack deletes its directory. + */ + readonly lease: (id: string) => Effect.Effect<boolean, StateError, Scope.Scope>; + /** Reports whether any process currently holds the stack's owner lease. */ + readonly leased: (id: string) => Effect.Effect<boolean, StateError>; + /** Only meaningful while the lease is held; a record left by a dead holder is stale. */ + readonly readHolder: (id: string) => Effect.Effect<LeaseHolder | undefined, StateError>; + /** Written by the lease holder with owner-only permissions, since it carries the owner secret. */ + readonly publishHolder: (id: string, record: LeaseHolder) => Effect.Effect<void, StateError>; + readonly retractHolder: (id: string) => Effect.Effect<void, StateError>; + /** The file that receives the stack owner's stdout and stderr. */ + readonly ownerLog: (id: string) => string; } export class Service extends Context.Service<Service, Interface>()("@supabase/stack/State") {} @@ -130,8 +189,36 @@ interface Options { readonly root: string; readonly platform?: NodeJS.Platform; readonly onInvalidState?: (id: string, error: StateError) => Effect.Effect<void>; + /** Observes a lease request that found the lease held and is waiting for it. */ + readonly onLeaseContended?: (id: string) => Effect.Effect<void>; } +const stateWritePrefix = ".state-write-"; +/** A state write takes milliseconds, so a temporary directory this old belongs to a dead writer. */ +const staleWriteAgeMillis = 24 * 60 * 60 * 1000; + +/** Best-effort removal of temporary write directories a killed writer left behind. */ +const reapStaleWrites = (fs: FileSystem.FileSystem, path: Path.Path, root: string) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const entries = yield* fs.readDirectory(root); + yield* Effect.forEach( + entries.filter((entry) => entry.startsWith(stateWritePrefix)), + (entry) => { + const candidate = path.join(root, entry); + return fs.stat(candidate).pipe( + Effect.flatMap((info) => + Option.exists(info.mtime, (mtime) => now - mtime.getTime() > staleWriteAgeMillis) + ? fs.remove(candidate, { recursive: true, force: true }) + : Effect.void, + ), + Effect.ignore, + ); + }, + { discard: true }, + ); + }).pipe(Effect.ignore); + const makeState = ( options: Options, ): Effect.Effect<Interface, StateError, FileSystem.FileSystem | Path.Path> => @@ -147,34 +234,43 @@ const makeState = ( yield* restrictDirectoryToOwner(fs, root).pipe( Effect.mapError((cause) => stateError("root", cause)), ); + yield* reapStaleWrites(fs, path, root); const stackRoot = (id: string) => path.join(root, id); const statePath = (id: string) => path.join(stackRoot(id), "state.json"); + // Only lease code opens a lease file, for the same reason as the registry lock. + const leasePath = (id: string) => path.join(stackRoot(id), "owner.lock"); + const ownerPath = (id: string) => path.join(stackRoot(id), "owner.json"); + const ownerLog = (id: string) => path.join(stackRoot(id), "owner.log"); const publishRetrySchedule = Schedule.exponential("10 millis", 2).pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, Duration.millis(100))), ), Schedule.upTo({ times: 12 }), ); - const renameErrorCode = (error: unknown): string | undefined => { + const errorCode = (error: unknown): string | undefined => { if (!Predicate.hasProperty(error, "cause")) return undefined; return Predicate.hasProperty(error.cause, "code") && typeof error.cause.code === "string" ? error.cause.code : undefined; }; + /** Windows reports a file that another process is replacing as a transient sharing violation. */ + const sharingViolation = (error: unknown) => + (options.platform ?? process.platform) === "win32" && + ["EPERM", "EACCES", "EBUSY"].includes(errorCode(error) ?? ""); + const retryTransientRead = <A, E, R>(effect: Effect.Effect<A, E, R>) => + effect.pipe( + Effect.retry({ schedule: publishRetrySchedule, while: sharingViolation }), + Effect.mapError((cause) => stateError("read", cause)), + ); const publish = Effect.fn("State.publish")(function* (temporary: string, target: string) { yield* fs.rename(temporary, target).pipe( - Effect.retry({ - schedule: publishRetrySchedule, - while: (error) => - (options.platform ?? process.platform) === "win32" && - ["EPERM", "EACCES", "EBUSY"].includes(renameErrorCode(error) ?? ""), - }), + Effect.retry({ schedule: publishRetrySchedule, while: sharingViolation }), Effect.mapError( (cause) => new StateError({ operation: "publish", - message: `Unable to publish state to ${target}${renameErrorCode(cause) ? ` (${renameErrorCode(cause)})` : ""}: ${cause instanceof Error ? cause.message : String(cause)}`, + message: `Unable to publish state to ${target}${errorCode(cause) ? ` (${errorCode(cause)})` : ""}: ${cause instanceof Error ? cause.message : String(cause)}`, cause, }), ), @@ -198,117 +294,303 @@ const makeState = ( const read = Effect.fn("State.read")(function* (id: string) { yield* checkId(id); const target = statePath(id); - const exists = yield* fs - .exists(target) - .pipe(Effect.mapError((cause) => stateError("read", cause))); + const exists = yield* fs.exists(target).pipe(retryTransientRead); if (!exists) return undefined; - const text = yield* fs - .readFileString(target) - .pipe(Effect.mapError((cause) => stateError("read", cause))); + const text = yield* fs.readFileString(target).pipe(retryTransientRead); const state = yield* decodeState(text, id, target); if (state.id !== id) { return yield* stateError("identity", "State document identity does not match its path"); } return state; }); - const list = Effect.fn("State.list")(function* () { - const entries = yield* fs - .readDirectory(root) - .pipe(Effect.mapError((cause) => stateError("list", cause))); - const states: Array<SavedStack> = []; - for (const entry of entries) { - if (!Schema.is(SafeId)(entry)) continue; - const id = entry; - const value = yield* read(id).pipe( - Effect.catch((error) => - options.onInvalidState !== undefined && - (error.operation === "decode" || error.operation === "identity") - ? options.onInvalidState(id, error).pipe(Effect.as(undefined)) - : Effect.fail(error), - ), + const stackIds = fs.readDirectory(root).pipe( + Effect.map((entries) => entries.filter(Schema.is(SafeId))), + Effect.mapError((cause) => stateError("list", cause)), + ); + const readEntries = <A>( + readEntry: (id: string) => Effect.Effect<A | undefined, StateError>, + onSkipped: (id: string, error: StateError) => Effect.Effect<void>, + ) => + Effect.gen(function* () { + const entries: Array<A> = []; + for (const id of yield* stackIds) { + const value = yield* readEntry(id).pipe( + Effect.catch((error) => onSkipped(id, error).pipe(Effect.as(undefined))), + ); + if (value !== undefined) entries.push(value); + } + return entries; + }); + const list = Effect.fn("State.list")(() => + readEntries(read, (id, error) => options.onInvalidState?.(id, error) ?? Effect.void), + ); + const decodeClaims = Schema.decodeEffect(Schema.fromJsonString(ClaimsDocument)); + const readClaims = (id: string) => + fs.readFileString(statePath(id)).pipe( + retryTransientRead, + Effect.flatMap((text) => + decodeClaims(text).pipe(Effect.mapError((cause) => stateError("decode", cause))), + ), + Effect.map(({ ports }): StackClaims => ({ id, ports })), + ); + const claims = Effect.fn("State.claims")(() => readEntries(readClaims, () => Effect.void)); + const writeAtomically = (id: string, target: string, serialized: string) => + Effect.gen(function* () { + yield* fs + .makeDirectory(stackRoot(id), { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => stateError("write", cause))); + yield* Effect.acquireUseRelease( + fs + .makeTempDirectory({ directory: root, prefix: stateWritePrefix }) + .pipe(Effect.mapError((cause) => stateError("write", cause))), + (directory) => + Effect.gen(function* () { + const temporary = path.join(directory, path.basename(target)); + yield* fs + .writeFileString(temporary, serialized, { mode: 0o600 }) + .pipe(Effect.mapError((cause) => stateError("write", cause))); + yield* publish(temporary, target); + }), + (directory) => + fs + .remove(directory, { recursive: true, force: true }) + .pipe(Effect.mapError((cause) => stateError("cleanup", cause))), ); - if (value !== undefined) states.push(value); - } - return states; - }); + }); const save = Effect.fn("State.save")(function* (state: SavedStack) { yield* checkId(state.id); - const target = statePath(state.id); const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(SavedStack))(state).pipe( Effect.mapError((cause) => stateError("encode", cause)), ); - yield* fs - .makeDirectory(stackRoot(state.id), { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((cause) => stateError("write", cause))); - yield* Effect.acquireUseRelease( - fs - .makeTempDirectory({ directory: root, prefix: ".state-write-" }) - .pipe(Effect.mapError((cause) => stateError("write", cause))), - (directory) => - Effect.gen(function* () { - const temporary = path.join(directory, "state.json"); - yield* fs - .writeFileString(temporary, serialized, { mode: 0o600 }) - .pipe(Effect.mapError((cause) => stateError("write", cause))); - yield* publish(temporary, target); - }), - (directory) => - fs - .remove(directory, { recursive: true, force: true }) - .pipe(Effect.mapError((cause) => stateError("cleanup", cause))), - ); + yield* writeAtomically(state.id, statePath(state.id), serialized); }); const remove = Effect.fn("State.remove")(function* (id: string) { yield* checkId(id); - yield* fs - .remove(statePath(id), { force: true }) - .pipe(Effect.mapError((cause) => stateError("remove", cause))); + for (const file of [statePath(id), ownerPath(id), ownerLog(id)]) + yield* fs + .remove(file, { force: true }) + .pipe(Effect.mapError((cause) => stateError("remove", cause))); yield* removeEmptyDirectory(path.join(stackRoot(id), "data")); yield* removeEmptyDirectory(stackRoot(id)); }); + const openLock = (file: string) => + Effect.try({ + try: () => new DatabaseSync(file), + catch: (cause) => stateError("lock", cause), + }); + /** Opens an existing lock file without creating a stray one. */ + const openExistingLock = (file: string) => + Effect.try({ + try: () => new DatabaseSync(new URL(`${pathToFileURL(file).href}?mode=rw`)), + catch: (cause) => stateError("lock", cause), + }); + const closeLock = (connection: DatabaseSync) => + Effect.try({ + try: () => connection.close(), + catch: (cause) => stateError("unlock", cause), + }); + const hasErrcode = (code: number) => (error: StateError) => + Predicate.hasProperty(error.cause, "errcode") && error.cause.errcode === code; + const isBusy = hasErrcode(5); + const isMissing = hasErrcode(14); + /** An open file that was unlinked: SQLite IOERR_VNODE on macOS, IOERR_FSTAT on Linux. */ + const isMoved = (error: StateError) => hasErrcode(6922)(error) || hasErrcode(1802)(error); + /** Takes the file's SQLite write lock on this connection, retrying contention on `schedule`. */ + const takeLock = ( + connection: DatabaseSync, + schedule: Schedule.Schedule<unknown, StateError>, + onContended: Effect.Effect<void> = Effect.void, + ) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => connection.exec("PRAGMA busy_timeout = 0"), + catch: (cause) => stateError("lock", cause), + }); + let contended = false; + return yield* Effect.try({ + try: () => connection.exec("BEGIN IMMEDIATE"), + catch: (cause) => stateError("lock", cause), + }).pipe( + Effect.tapError((error) => + isBusy(error) && !contended + ? Effect.sync(() => { + contended = true; + }).pipe(Effect.andThen(onContended)) + : Effect.void, + ), + Effect.retry({ schedule, while: isBusy }), + Effect.as(true), + Effect.catchIf(isBusy, () => Effect.succeed(false)), + ); + }); const withLock = Effect.fn("State.withLock")(<A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.acquireUseRelease( - Effect.try({ - try: () => new DatabaseSync(lock), - catch: (cause) => stateError("lock", cause), - }), + openLock(lock), (connection) => Effect.gen(function* () { - yield* Effect.try({ - try: () => connection.exec("PRAGMA busy_timeout = 0"), - catch: (cause) => stateError("lock", cause), - }); - yield* Effect.try({ - try: () => connection.exec("BEGIN IMMEDIATE"), - catch: (cause) => stateError("lock", cause), - }).pipe( - Effect.retry({ - schedule: Schedule.spaced("50 millis").pipe( - Schedule.upTo({ duration: "5 seconds" }), - ), - while: (error) => - Predicate.hasProperty(error.cause, "errcode") && error.cause.errcode === 5, - }), - Effect.mapError((error) => - Predicate.hasProperty(error.cause, "errcode") && error.cause.errcode === 5 - ? new StateError({ - operation: "lock", - message: "Stack registry is locked by another operation; retry shortly", - cause: error.cause, - }) - : error, - ), + const held = yield* takeLock( + connection, + Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "5 seconds" })), ); + if (!held) + return yield* new StateError({ + operation: "lock", + message: "Stack registry is locked by another operation; retry shortly", + }); return yield* effect; }), - (connection) => - Effect.try({ - try: () => connection.close(), - catch: (cause) => stateError("unlock", cause), - }), + closeLock, ), ); - return { read, list: list(), save, remove, withLock }; + /** Deletes the lease file of an unregistered stack; `false` means it is still in place. */ + const removeLeaseFile = (id: string) => + fs.exists(statePath(id)).pipe( + Effect.flatMap((registered) => + registered ? Effect.void : fs.remove(leasePath(id), { force: true }), + ), + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + const fileIdentity = (file: string) => + fs.stat(file).pipe( + Effect.map((info) => `${info.dev}:${Option.getOrElse(info.ino, () => "")}`), + Effect.option, + ); + /** + * A waiter can open the lease file just before its holder unlinks it, and then lock the + * unlinked file; comparing the path's file before opening and after locking rejects that. + * Unlinking under the lock keeps new openers off a doomed file. Windows refuses to unlink an + * open file, so only there does a second attempt follow the close. + */ + const lease = Effect.fn("State.lease")(function* (id: string) { + yield* checkId(id); + const target = leasePath(id); + const scope = yield* Scope.Scope; + let held = false; + let unlinked = false; + yield* Effect.addFinalizer(() => + held + ? (unlinked ? Effect.void : removeLeaseFile(id)).pipe( + Effect.andThen(removeEmptyDirectory(stackRoot(id))), + Effect.ignore, + ) + : Effect.void, + ); + const onContended = options.onLeaseContended?.(id) ?? Effect.void; + for (let attempt = 0; attempt < 8; attempt++) { + // A releasing holder may remove the empty stack directory at any moment. + yield* fs + .makeDirectory(stackRoot(id), { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => stateError("lease", cause))); + const before = yield* fileIdentity(target); + const attemptScope = yield* Scope.fork(scope, "sequential"); + const connection = yield* Effect.acquireRelease(openLock(target), (connection) => + closeLock(connection).pipe( + Effect.catch((error) => + Effect.logWarning(`Unable to release stack lease ${id}`, error), + ), + ), + ).pipe( + Scope.provide(attemptScope), + Effect.map(Option.some), + Effect.catchIf(isMissing, () => Effect.succeed(Option.none<DatabaseSync>())), + ); + if (Option.isNone(connection)) { + yield* Scope.close(attemptScope, Exit.void); + continue; + } + const acquired = yield* takeLock( + connection.value, + Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "500 millis" })), + onContended, + ).pipe( + Effect.map((held) => (held ? "held" : "busy")), + Effect.catchIf(isMoved, () => Effect.succeed("moved" as const)), + ); + if (acquired === "busy") { + yield* Scope.close(attemptScope, Exit.void); + return false; + } + const after = acquired === "moved" ? Option.none() : yield* fileIdentity(target); + if (Option.isSome(before) && Option.isSome(after) && before.value === after.value) { + held = true; + yield* Scope.addFinalizer( + attemptScope, + removeLeaseFile(id).pipe( + Effect.map((removed) => { + unlinked = removed; + }), + ), + ); + return true; + } + yield* Scope.close(attemptScope, Exit.void); + } + return yield* stateError("lease", `The lease file of stack ${id} keeps changing`); + }); + const leased = Effect.fn("State.leased")(function* (id: string) { + yield* checkId(id); + return yield* Effect.acquireUseRelease( + openExistingLock(leasePath(id)), + (connection) => + takeLock(connection, Schedule.recurs(0)).pipe(Effect.map((acquired) => !acquired)), + closeLock, + ).pipe( + // A lease file that is missing, or that its holder unlinked meanwhile, is not held. + Effect.catchIf( + (error) => isMissing(error) || isMoved(error), + () => Effect.succeed(false), + ), + ); + }); + const decodeHolder = Schema.decodeEffect(Schema.fromJsonString(LeaseHolder)); + const readHolder = Effect.fn("State.readHolder")(function* (id: string) { + yield* checkId(id); + const target = ownerPath(id); + // The holder may retract its record at any moment, so a missing record is not an error. + const text = yield* fs.readFileString(target).pipe( + Effect.map(Option.some), + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(Option.none<string>()), + ), + retryTransientRead, + ); + if (Option.isNone(text)) return undefined; + return yield* decodeHolder(text.value).pipe( + Effect.mapError((cause) => stateError("decode", `Unable to decode ${target}: ${cause}`)), + ); + }); + const publishHolder = Effect.fn("State.publishHolder")(function* ( + id: string, + record: LeaseHolder, + ) { + yield* checkId(id); + const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(LeaseHolder))( + record, + ).pipe(Effect.mapError((cause) => stateError("encode", cause))); + yield* writeAtomically(id, ownerPath(id), serialized); + }); + const retractHolder = Effect.fn("State.retractHolder")(function* (id: string) { + yield* checkId(id); + yield* fs + .remove(ownerPath(id), { force: true }) + .pipe(Effect.mapError((cause) => stateError("remove", cause))); + }); + return { + read, + list: list(), + claims: claims(), + save, + remove, + withLock, + lease, + leased, + readHolder, + publishHolder, + retractHolder, + ownerLog, + }; }); export const layer = (options: Options) => diff --git a/packages/stack/src/State.windows.integration.test.ts b/packages/stack/src/State.windows.integration.test.ts index 9e723affbe..cc43a6ca78 100644 --- a/packages/stack/src/State.windows.integration.test.ts +++ b/packages/stack/src/State.windows.integration.test.ts @@ -20,6 +20,7 @@ const saved: State.SavedStack = { identity: { projectRoot: "C:\\project", branchContext: "test", stackName: "windows" }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }; @@ -95,11 +96,18 @@ const testSharingViolation = () => ), ); const ready = yield* Deferred.make<void>(); + const observed = yield* Ref.make<ReadonlyArray<string>>([]); const output = yield* holder.stdout.pipe( Stream.decodeText, Stream.splitLines, Stream.runForEach((line) => - line === "ready" ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) : Effect.void, + Ref.update(observed, (lines) => [...lines, line]).pipe( + Effect.andThen( + line === "ready" + ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), ), Effect.forkScoped, ); @@ -109,15 +117,32 @@ const testSharingViolation = () => Stream.runForEach((chunk) => Ref.update(stderr, (text) => text + chunk)), Effect.forkScoped, ); + const holderFailure = (reason: string) => + Effect.all([Ref.get(observed), Ref.get(stderr)]).pipe( + Effect.flatMap(([lines, text]) => + Effect.fail(new HolderError({ message: `${reason}: ${lines.join("\n")}\n${text}` })), + ), + ); + const exitFailure = (reason: string) => + Effect.all([Fiber.join(output), Fiber.join(diagnostics)]).pipe( + // The exit event can fire before the holder's stdio is drained. + Effect.timeout("5 seconds"), + Effect.ignore, + Effect.andThen(holderFailure(reason)), + ); yield* Deferred.await(ready).pipe( + Effect.raceFirst( + holder.exitCode.pipe( + Effect.matchEffect({ + onFailure: (cause) => exitFailure(`Holder exited before readiness: ${String(cause)}`), + onSuccess: (code) => exitFailure(`Holder exited before readiness (${code})`), + }), + ), + ), Effect.timeoutOrElse({ - duration: "10 seconds", - orElse: () => - Ref.get(stderr).pipe( - Effect.flatMap((text) => - Effect.fail(new HolderError({ message: `Holder did not become ready: ${text}` })), - ), - ), + // A guard only, not a readiness assertion. + duration: "30 seconds", + orElse: () => holderFailure("Holder did not become ready"), }), ); yield* Ref.set(armed, true); @@ -153,5 +178,5 @@ const testSharingViolation = () => it.live.skipIf(process.platform !== "win32")( "retries a real Windows sharing violation after the open state handle is released", () => testSharingViolation(), - 30_000, + 60_000, ); diff --git a/packages/stack/src/Sweep.integration.test.ts b/packages/stack/src/Sweep.integration.test.ts new file mode 100644 index 0000000000..70c1754af0 --- /dev/null +++ b/packages/stack/src/Sweep.integration.test.ts @@ -0,0 +1,210 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { + Clock, + Context, + Crypto, + Data, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Path, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { launchHost } from "./HostProcess.ts"; +import { makeContainerRuntime } from "./runtime/Container.ts"; +import * as State from "./State.ts"; +import { makeDockerDatabaseRoot } from "../tests/docker-fixture.ts"; +import { shutdownOwner, watchLeaseRelease } from "../tests/owner.ts"; + +class SweepTestError extends Data.TaggedError("SweepTestError")<{ readonly message: string }> {} + +const helperImage = + "public.ecr.aws/docker/library/debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251"; + +const docker = Effect.fn("SweepTest.docker")((args: ReadonlyArray<string>) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("docker", args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }), + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(child.stdout)), + Stream.mkString(Stream.decodeText(child.stderr)), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if (Number(code) !== 0) + return yield* Effect.die(`docker ${args.join(" ")} failed: ${stderr}`); + return stdout.trim(); + }), + ), +); + +const labels = (stackId: string, dataRoot: string) => [ + `label=com.supabase.stack=${stackId}`, + `label=com.supabase.stack-root=${dataRoot}`, +]; + +const containers = (stackId: string, dataRoot: string) => + docker([ + "ps", + "--all", + "--quiet", + "--no-trunc", + ...labels(stackId, dataRoot).flatMap((label) => ["--filter", label]), + ]).pipe(Effect.map((value) => value.split("\n").filter((id) => id.length > 0))); + +const createOwnedContainer = (stackId: string, dataRoot: string) => + Effect.acquireRelease( + docker([ + "create", + ...labels(stackId, dataRoot).flatMap((label) => ["--label", label.slice("label=".length)]), + helperImage, + ]), + (id) => docker(["rm", "--force", id]).pipe(Effect.ignore), + ); + +/** Completes when Docker reports the container destroyed, including events before subscription. */ +const awaitDestroyed = (id: string, since: number) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const events = yield* spawner.spawn( + ChildProcess.make( + "docker", + [ + "events", + "--since", + String(since), + "--filter", + `container=${id}`, + "--filter", + "event=destroy", + "--format", + "{{.Actor.ID}}", + ], + { stdin: "ignore", stdout: "pipe", stderr: "ignore" }, + ), + ); + const destroyed = yield* events.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.filter((line) => line.trim() === id), + Stream.runHead, + ); + if (Option.isNone(destroyed)) + return yield* new SweepTestError({ message: "Docker event stream ended" }); + }), + ); + +const awaitRemoval = (directory: string, entry: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(directory, entry); + yield* fs.watch(directory).pipe( + Stream.filter((event) => event.path === entry || event.path === target), + Stream.mapEffect(() => fs.exists(target)), + Stream.takeUntil((exists) => !exists), + Stream.runDrain, + ); + }); + +const saved = ( + id: string, + projectRoot: string, + runtime: State.SavedStack["runtime"], + lifetime: State.StackLifetime, +): State.SavedStack => ({ + id, + runtime, + lifetime, + identity: { projectRoot, branchContext: "sweep-test", stackName: id }, + instances: [], + composition: { members: [], dependencies: [] }, + ports: [], +}); + +it.live.skipIf(process.platform === "win32")( + "removes a dead owner's containers and session stacks at the next owner start in its root", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const suffix = (yield* crypto.randomUUIDv4).replaceAll("-", ""); + const deadId = `dead-${suffix}`; + const dataA = yield* makeDockerDatabaseRoot("stack-sweep-a-", deadId).pipe( + Effect.flatMap(fs.realPath), + ); + const dataB = yield* makeDockerDatabaseRoot("stack-sweep-b-", deadId).pipe( + Effect.flatMap(fs.realPath), + ); + const rootA = path.dirname(path.dirname(dataA)); + const cacheRoot = `${path.dirname(rootA)}/cache`; + const helper = yield* makeContainerRuntime({ engine: "docker", root: dataA }); + yield* helper.prepare(helperImage); + const state = Context.get(yield* Layer.build(State.layer({ root: rootA })), State.Service); + + yield* state.save(saved(deadId, `${rootA}/dead`, "docker", "detached")); + const dead = (yield* launchHost(state, { stateRoot: rootA, cacheRoot, stackId: deadId })) + .endpoint; + const orphan = yield* createOwnedContainer(deadId, dataA); + const otherRoot = yield* createOwnedContainer(deadId, dataB); + const deadReleased = yield* watchLeaseRelease(rootA, deadId); + yield* Effect.sync(() => process.kill(dead.pid, "SIGKILL")); + yield* deadReleased; + expect(yield* containers(deadId, dataA)).toEqual([orphan]); + + const sessionId = `session-${suffix}`; + yield* state.save(saved(sessionId, `${rootA}/session`, "native", "session")); + const nextId = `next-${suffix}`; + yield* state.save(saved(nextId, `${rootA}/next`, "native", "detached")); + + const since = Math.floor((yield* Clock.currentTimeMillis) / 1000) - 1; + const swept = yield* Effect.all( + [ + awaitDestroyed(orphan, since), + awaitRemoval(rootA, sessionId), + awaitRemoval(path.join(rootA, deadId), "owner.json"), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild({ startImmediately: true })); + const next = yield* Effect.acquireRelease( + launchHost(state, { stateRoot: rootA, cacheRoot, stackId: nextId }), + (access) => shutdownOwner(access, true).pipe(Effect.ignore), + ); + yield* Fiber.join(swept).pipe( + Effect.timeoutOrElse({ + duration: "1 minute", + orElse: () => + Effect.fail(new SweepTestError({ message: "The next owner did not sweep orphans" })), + }), + ); + + // The sweeper retracts its marker, then releases the lease. + yield* Effect.scoped( + Effect.gen(function* () { + yield* yield* watchLeaseRelease(rootA, deadId); + }), + ); + expect(next.endpoint.pid).not.toBe(dead.pid); + expect(yield* containers(deadId, dataA)).toEqual([]); + expect(yield* containers(deadId, dataB), "another root is never swept").toEqual([ + otherRoot, + ]); + expect(yield* state.read(deadId), "detached stacks keep their state").toBeDefined(); + expect(yield* state.read(sessionId)).toBeUndefined(); + expect(yield* state.leased(deadId), "the sweeper released the dead stack").toBe(false); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 180_000 }, +); diff --git a/packages/stack/src/Sweep.ts b/packages/stack/src/Sweep.ts new file mode 100644 index 0000000000..6817993f21 --- /dev/null +++ b/packages/stack/src/Sweep.ts @@ -0,0 +1,79 @@ +import { Context, DateTime, Effect, FileSystem, Layer, Path } from "effect"; +import { sweepTimeout } from "./HostProcess.ts"; +import * as Owner from "./Owner.ts"; +import * as State from "./State.ts"; + +/** Runs a dead session stack's own destroy path in this process while holding its lease. */ +const destroyStack = Effect.fn("Sweep.destroyStack")(function* ( + state: State.Interface, + saved: State.SavedStack, + dataRoot: string, + cacheRoot: string, +) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dataRoot, { recursive: true }); + const context = yield* Layer.build( + Owner.layer({ saved, root: dataRoot, cacheRoot }).pipe( + Layer.provide(Layer.succeed(State.Service, state)), + ), + ); + yield* Context.get(context, Owner.Service).namespace.destroy; +}); + +/** + * Cleans up a stack whose lease is free, holding that lease meanwhile and marking the hold so + * clients wait instead of mistaking it for a starting owner. Removes the stack's labelled + * containers, and destroys the stack when its lifetime is `session`. Returns `false` when + * another process holds the lease. + */ +export const reclaimStack = Effect.fn("Sweep.reclaimStack")(function* (options: { + readonly state: State.Interface; + readonly stateRoot: string; + readonly cacheRoot: string; + readonly id: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { state, id } = options; + return yield* Effect.scoped( + Effect.gen(function* () { + if (!(yield* state.lease(id))) return false; + yield* Effect.addFinalizer(() => state.retractHolder(id).pipe(Effect.ignore)); + yield* state.publishHolder(id, { + role: "sweeper", + pid: process.pid, + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + const saved = yield* state.read(id); + if (saved === undefined) return true; + const dataRoot = path.join(yield* fs.realPath(options.stateRoot), id, "data"); + if (saved.lifetime === "session") + yield* destroyStack(state, saved, dataRoot, options.cacheRoot); + else yield* Owner.sweepContainers(saved, dataRoot); + return true; + }), + ).pipe(Effect.timeout(sweepTimeout)); +}); + +/** + * Keeps stack-labelled containers and session stacks alive only while their lease is held: every + * other stack of this state root whose lease is free is reclaimed. + */ +export const sweepOrphans = Effect.fn("Sweep.orphans")( + function* (options: { + readonly state: State.Interface; + readonly stateRoot: string; + readonly cacheRoot: string; + readonly ownerId: string; + }) { + for (const { id } of yield* options.state.list) { + if (id === options.ownerId) continue; + yield* reclaimStack({ ...options, id }).pipe( + Effect.catchCause((cause) => + Effect.logWarning(`Orphan sweep of stack ${id} failed`, cause), + ), + ); + } + }, + Effect.catchCause((cause) => Effect.logWarning("Orphan sweep failed", cause)), +); diff --git a/packages/stack/src/Tools.ts b/packages/stack/src/Tools.ts deleted file mode 100644 index 3df6c0d646..0000000000 --- a/packages/stack/src/Tools.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Schema } from "effect"; - -const PgProveMount = Schema.Struct({ - source: Schema.String, - target: Schema.String, -}); - -export const PgProveOptions = Schema.Struct({ - mounts: Schema.Array(PgProveMount), - cwd: Schema.optional(Schema.String), - workingDir: Schema.optional(Schema.String), -}); -export interface PgProveOptions extends Schema.Schema.Type<typeof PgProveOptions> {} - -export const PostgresTool = Schema.Struct({ - command: Schema.Literals(["pg_dump", "pg_dumpall", "pg_prove", "psql"]), - major: Schema.Literals([15, 17]), -}); -export interface PostgresTool extends Schema.Schema.Type<typeof PostgresTool> {} - -/** Describes finite PostgreSQL clients without managing a database service. */ -export const postgres = { - pgDump: ({ major }: { readonly major: 15 | 17 }): PostgresTool => ({ command: "pg_dump", major }), - pgDumpAll: ({ major }: { readonly major: 15 | 17 }): PostgresTool => ({ - command: "pg_dumpall", - major, - }), - pgProve: ({ major }: { readonly major: 15 | 17 }): PostgresTool => ({ - command: "pg_prove", - major, - }), - psql: ({ major }: { readonly major: 15 | 17 }): PostgresTool => ({ command: "psql", major }), -}; diff --git a/packages/stack/src/composition/Supabase.native.integration.test.ts b/packages/stack/src/composition/Supabase.native.integration.test.ts index b26166ff85..fffc8cc9b5 100644 --- a/packages/stack/src/composition/Supabase.native.integration.test.ts +++ b/packages/stack/src/composition/Supabase.native.integration.test.ts @@ -16,9 +16,9 @@ import { PgClient } from "@effect/sql-pg"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { SignJWT } from "jose"; import { tmpdir } from "node:os"; -import * as Owner from "../Owner.ts"; import * as State from "../State.ts"; import type { SavedStack } from "../State.ts"; +import { ownerFor } from "../../tests/owner-rpc.ts"; import { makeSupabaseComposition, SupabaseCompositionError, @@ -36,21 +36,6 @@ const stateFor = (root: string) => return Context.get(context, State.Service); }); -const ownerFor = (options: { - readonly saved: SavedStack; - readonly state: State.Interface; - readonly root: string; - readonly cacheRoot: string; -}) => { - const { state, ...layerOptions } = options; - return Effect.gen(function* () { - const context = yield* Layer.build( - Owner.layer(layerOptions).pipe(Layer.provide(Layer.succeed(State.Service, state))), - ); - return Context.get(context, Owner.Service); - }); -}; - const query = (url: string, statement: string) => Effect.scoped( Effect.gen(function* () { @@ -73,6 +58,7 @@ const initial = (id: string): SavedStack => ({ identity: { projectRoot: "/tmp/project", branchContext: "catalog-native", stackName: id }, runtime: "native", instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); @@ -117,27 +103,33 @@ it.live("removes a managed SMTP binding when Mail is excluded", () => const auth = { service: "auth" as const, config: { - databaseUrl: "postgresql://placeholder", jwtSecret: "exclude-mail-jwt-secret-with-32-chars", jwtExpiry: 3600, }, endpoints: { http: { port: "auto" as const } }, }; - const members = yield* owner.composition.supabase([ - auth, - { service: "mail", config: {}, endpoints: { smtp: { port: "auto" } } }, - ]); + const members = yield* owner.rpc.supabaseComposition({ + services: [ + auth, + { service: "mail", config: {}, endpoints: { smtp: { port: "auto" } } }, + ], + }); const instance = members.find(({ creation }) => creation.service === "auth"); if (instance?.creation.service !== "auth") return yield* Effect.die("Auth missing"); expect(instance.creation.config.smtpUrl).toBeDefined(); - const selected = yield* owner.composition.supabase([auth], { reuseIds: [instance.id] }); + const selected = yield* owner.rpc.supabaseComposition({ + services: [auth], + reuseIds: [instance.id], + }); const reused = selected.find(({ id }) => id === instance.id); if (reused?.creation.service !== "auth") return yield* Effect.die("Reused Auth missing"); expect(reused.creation.config.smtpUrl).toBeUndefined(); - const external = yield* owner.composition.supabase( - [{ ...auth, config: { ...auth.config, smtpUrl: "smtp://mail.example:2525" } }], - { reuseIds: [instance.id] }, - ); + const external = yield* owner.rpc.supabaseComposition({ + services: [ + { ...auth, config: { ...auth.config, smtpUrl: "smtp://mail.example:2525" } }, + ], + reuseIds: [instance.id], + }); const configured = external.find(({ id }) => id === instance.id); if (configured?.creation.service !== "auth") return yield* Effect.die("Auth missing"); expect(configured.creation.config.smtpUrl).toBe("smtp://mail.example:2525"); @@ -213,44 +205,44 @@ it.live( yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); const jwtSecret = "catalog-native-auth-storage-secret-with-at-least-32-chars"; - const created = yield* owner.composition.supabase([ - { - service: "database", - config: { - version: "17", - databasePassword: Redacted.make("postgres"), - jwtSecret: Redacted.make(jwtSecret), - jwtExpiry: 3600, + const created = yield* owner.rpc.supabaseComposition({ + services: [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, }, - endpoints: { sql: { port: "auto" } }, - }, - { - service: "auth", - config: { - databaseUrl: "postgresql://placeholder", - jwtSecret, - jwtExpiry: 3600, + { + service: "auth", + config: { + jwtSecret, + jwtExpiry: 3600, + }, + endpoints: { http: { port: "auto" } }, }, - endpoints: { http: { port: "auto" } }, - }, - { - service: "storage", - config: { - databaseUrl: "postgresql://placeholder", - filePath: storageRoot, - jwtSecret, + { + service: "storage", + config: { + filePath: storageRoot, + jwtSecret, + }, + endpoints: { http: { port: "auto" } }, }, - endpoints: { http: { port: "auto" } }, - }, - ]); + ], + }); const database = created.find((entry) => entry.creation.service === "database"); const auth = created.find((entry) => entry.creation.service === "auth"); const storage = created.find((entry) => entry.creation.service === "storage"); if (database === undefined || auth === undefined || storage === undefined) return yield* Effect.die("Native composition members missing"); - yield* owner.composition.start; - const databaseCredentials = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startComposition(); + const databaseCredentials = yield* owner.rpc.credentials({ id: database.id, from: "host" }); const databaseUrl = databaseCredentials.databaseUrl; if (databaseUrl === undefined) return yield* Effect.die("Native database URL missing"); expect(databaseUrl).toMatch(/^postgresql:\/\/supabase_admin:/u); @@ -265,8 +257,8 @@ it.live( }), ); yield* Context.get(databaseServices, PgClient.PgClient).unsafe("SELECT 1"); - const authCredentials = yield* owner.credentials(auth.id, "host"); - const storageCredentials = yield* owner.credentials(storage.id, "host"); + const authCredentials = yield* owner.rpc.credentials({ id: auth.id, from: "host" }); + const storageCredentials = yield* owner.rpc.credentials({ id: storage.id, from: "host" }); if (authCredentials.url === undefined || storageCredentials.url === undefined) return yield* Effect.die("Native public service URL missing"); @@ -363,34 +355,34 @@ it.live( }, endpoints: { sql: { port: "auto" as const } }, }; - const databaseOnly = yield* owner.composition.supabase([databaseCreation]); + const databaseOnly = yield* owner.rpc.supabaseComposition({ services: [databaseCreation] }); const database = databaseOnly[0]; if (database === undefined) return yield* Effect.die("Database member missing"); - yield* owner.composition.start; - const credentials = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startComposition(); + const credentials = yield* owner.rpc.credentials({ id: database.id, from: "host" }); if (credentials.databaseUrl === undefined) return yield* Effect.die("Database credentials missing"); const port = new URL(credentials.databaseUrl).port; yield* query(credentials.databaseUrl, "CREATE TABLE reuse_rows(value text NOT NULL)"); yield* query(credentials.databaseUrl, "INSERT INTO reuse_rows VALUES ('preserved')"); - yield* owner.composition.stop; + yield* owner.rpc.stopComposition(); - const expanded = yield* owner.composition.supabase( - [ + const expanded = yield* owner.rpc.supabaseComposition({ + services: [ databaseCreation, { service: "rest", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" } }, }, ], - { reuseIds: [database.id] }, - ); + reuseIds: [database.id], + }); const reused = expanded.find((entry) => entry.creation.service === "database"); if (reused === undefined) return yield* Effect.die("Reused database missing"); expect(reused.id).toBe(database.id); - yield* owner.composition.start; - const reopened = yield* owner.credentials(database.id, "host"); + yield* owner.rpc.startComposition(); + const reopened = yield* owner.rpc.credentials({ id: database.id, from: "host" }); if (reopened.databaseUrl === undefined) return yield* Effect.die("Reopened database credentials missing"); expect(new URL(reopened.databaseUrl).port).toBe(port); @@ -398,29 +390,28 @@ it.live( expect(rows).toEqual([{ value: "preserved" }]); const rest = expanded.find((entry) => entry.creation.service === "rest"); if (rest === undefined) return yield* Effect.die("REST missing"); - const before = yield* owner.credentials(rest.id, "host"); - yield* owner.composition.stop; - const withAuth = yield* owner.composition.supabase( - [ + const before = yield* owner.rpc.credentials({ id: rest.id, from: "host" }); + yield* owner.rpc.stopComposition(); + const withAuth = yield* owner.rpc.supabaseComposition({ + services: [ databaseCreation, rest.creation, { service: "auth", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: Redacted.value(databaseCreation.config.jwtSecret), jwtExpiry: 3600, }, endpoints: { http: { port: "auto" } }, }, ], - { reuseIds: [database.id, rest.id] }, - ); - yield* owner.composition.start; + reuseIds: [database.id, rest.id], + }); + yield* owner.rpc.startComposition(); const auth = withAuth.find((entry) => entry.creation.service === "auth"); if (auth === undefined) return yield* Effect.die("Auth missing"); - const restAddress = yield* owner.credentials(rest.id, "host"); - const authAddress = yield* owner.credentials(auth.id, "host"); + const restAddress = yield* owner.rpc.credentials({ id: rest.id, from: "host" }); + const authAddress = yield* owner.rpc.credentials({ id: auth.id, from: "host" }); expect(restAddress.url).toBe(before.url); if (restAddress.url === undefined || authAddress.url === undefined) return yield* Effect.die("Shared API URLs missing"); @@ -462,26 +453,25 @@ it.live( }; const rest = { service: "rest" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }; const pgmeta = { service: "pgmeta" as const, - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" as const } }, }; const studio = { service: "studio" as const, config: { - pgmetaUrl: "http://placeholder", analyticsApiKey: "studio-reuse-analytics-key", - apiUrl: "http://placeholder", - publicApiUrl: "http://placeholder", jwtSecret: "studio-reuse-jwt-secret-with-32-chars", }, endpoints: { http: { port: FIXED_STUDIO_PORT } }, }; - const initialMembers = yield* owner.composition.supabase([database, rest, pgmeta, studio]); + const initialMembers = yield* owner.rpc.supabaseComposition({ + services: [database, rest, pgmeta, studio], + }); const databaseId = initialMembers.find( (entry) => entry.creation.service === "database", )?.id; @@ -495,21 +485,23 @@ it.live( studioId === undefined ) return yield* Effect.die("Studio composition members missing"); - yield* owner.composition.start; - yield* owner.composition.stop; - const reduced = yield* owner.composition.supabase([database, rest, pgmeta], { + yield* owner.rpc.startComposition(); + yield* owner.rpc.stopComposition(); + const reduced = yield* owner.rpc.supabaseComposition({ + services: [database, rest, pgmeta], reuseIds: [databaseId, restId, pgmetaId], }); expect(reduced.map((entry) => entry.id).sort()).toEqual( [databaseId, restId, pgmetaId].sort(), ); - yield* owner.composition.start; - yield* owner.composition.stop; - const restored = yield* owner.composition.supabase([database, rest, pgmeta, studio], { + yield* owner.rpc.startComposition(); + yield* owner.rpc.stopComposition(); + const restored = yield* owner.rpc.supabaseComposition({ + services: [database, rest, pgmeta, studio], reuseIds: [databaseId, restId, pgmetaId, studioId], }); expect(restored.find((entry) => entry.creation.service === "studio")?.id).toBe(studioId); - const studioCredentials = yield* owner.credentials(studioId, "host"); + const studioCredentials = yield* owner.rpc.credentials({ id: studioId, from: "host" }); if (studioCredentials.url === undefined) return yield* Effect.die("Studio URL missing"); expect(new URL(studioCredentials.url).port).toBe(String(FIXED_STUDIO_PORT)); }), @@ -544,16 +536,18 @@ it.live( }, endpoints: { sql: { port: "auto" as const } }, }; - const created = yield* owner.composition.supabase([databaseCreation]); + const created = yield* owner.rpc.supabaseComposition({ services: [databaseCreation] }); const database = created[0]; if (database === undefined) return yield* Effect.die("Database member missing"); - yield* owner.composition.start; - const failure = yield* owner.composition - .supabase([databaseCreation], { reuseIds: [database.id] }) + yield* owner.rpc.startComposition(); + const failure = yield* owner.rpc + .supabaseComposition({ services: [databaseCreation], reuseIds: [database.id] }) .pipe(Effect.flip); expect(failure.message).toContain("stopped with wake disabled"); - expect((yield* owner.services.list).map((entry) => entry.id)).toEqual([database.id]); - yield* owner.composition.stop; + expect((yield* state.read(stack.id))?.instances.map((entry) => entry.id)).toEqual([ + database.id, + ]); + yield* owner.rpc.stopComposition(); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 180_000 }, @@ -577,13 +571,13 @@ it.live( }); yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); const fixedPort = FIXED_MAIL_PORT; - const standaloneMail = yield* owner.services.create({ + const standaloneMail = yield* owner.rpc.createService({ service: "mail", config: {}, endpoints: { http: { port: fixedPort } }, }); - yield* owner.core.start(standaloneMail.id); - yield* owner.core.ready(standaloneMail.id); + yield* owner.rpc.startService({ id: standaloneMail.id }); + yield* owner.rpc.readyService({ id: standaloneMail.id }); const database = { service: "database" as const, config: { @@ -594,15 +588,19 @@ it.live( }, endpoints: { sql: { port: "auto" as const } }, }; - const failure = yield* owner.composition - .supabase([ - database, - { service: "mail", config: {}, endpoints: { http: { port: fixedPort } } }, - ]) + const failure = yield* owner.rpc + .supabaseComposition({ + services: [ + database, + { service: "mail", config: {}, endpoints: { http: { port: fixedPort } } }, + ], + }) .pipe(Effect.flip); - expect(failure.operation).toBe("supabase"); - expect((yield* owner.services.list).map((entry) => entry.id)).toEqual([standaloneMail.id]); - yield* owner.core.stop(standaloneMail.id); + expect(failure.operation).toBe("supabaseComposition"); + expect((yield* state.read(stack.id))?.instances.map((entry) => entry.id)).toEqual([ + standaloneMail.id, + ]); + yield* owner.rpc.stopService({ id: standaloneMail.id }); }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 180_000 }, diff --git a/packages/stack/src/composition/Supabase.ts b/packages/stack/src/composition/Supabase.ts index 76147d0670..19fb3eeaf9 100644 --- a/packages/stack/src/composition/Supabase.ts +++ b/packages/stack/src/composition/Supabase.ts @@ -1,10 +1,172 @@ -import { Data, Effect, Exit, Schema } from "effect"; +import { Data, Effect, Exit, Redacted, Schema } from "effect"; +import { postgresVersion } from "../Artifacts.ts"; import { causeMessage, type CompositionConfig } from "../Orchestrator.ts"; import type { Observation } from "../Rpc.ts"; -import { ServiceCreation } from "../services/Catalog.ts"; -import type { StackIdentityInput } from "../State.ts"; +import { ServiceCreation, type ServiceCreationInput } from "../services/Catalog.ts"; +import type { SavedStack, StackKeysInput } from "../State.ts"; +import { credentialInputNames } from "../host/Credentials.ts"; import { apiRoute, endpointNames, endpointPort } from "../host/Endpoints.ts"; +const DEFAULT_IDLE_MILLIS = 60_000; +/** Studio idles slower than its peers: a background tab shouldn't cold-start it every minute. */ +const STUDIO_IDLE_MILLIS = 300_000; +const idleMillisFor = (service: ServiceCreation["service"]): number => + service === "studio" ? STUDIO_IDLE_MILLIS : DEFAULT_IDLE_MILLIS; + +const managedBindings: ReadonlyArray<{ + readonly sourceKind: ServiceCreation["service"]; + readonly sourceEndpoint: string; + readonly output: string; + readonly targetKind: ServiceCreation["service"]; + readonly input: string; +}> = [ + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "authenticatorUrl", + targetKind: "rest", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "authDatabaseUrl", + targetKind: "auth", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "storageDatabaseUrl", + targetKind: "storage", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "databaseUrl", + targetKind: "storage", + input: "vectorDatabaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "databaseUrl", + targetKind: "realtime", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "databaseUrl", + targetKind: "pgmeta", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "databaseUrl", + targetKind: "studio", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "internalDatabaseUrl", + targetKind: "analytics", + input: "databaseUrl", + }, + { + sourceKind: "database", + sourceEndpoint: "sql", + output: "internalDatabaseUrl", + targetKind: "pooler", + input: "databaseUrl", + }, + { + sourceKind: "imgproxy", + sourceEndpoint: "http", + output: "url", + targetKind: "storage", + input: "imgproxyUrl", + }, + { + sourceKind: "pgmeta", + sourceEndpoint: "http", + output: "url", + targetKind: "studio", + input: "pgmetaUrl", + }, + { + sourceKind: "analytics", + sourceEndpoint: "http", + output: "url", + targetKind: "studio", + input: "analyticsUrl", + }, + { + sourceKind: "analytics", + sourceEndpoint: "http", + output: "url", + targetKind: "vector", + input: "analyticsUrl", + }, + { + sourceKind: "functions", + sourceEndpoint: "http", + output: "url", + targetKind: "studio", + input: "functionsUrl", + }, + { + sourceKind: "mail", + sourceEndpoint: "smtp", + output: "smtpUrl", + targetKind: "auth", + input: "smtpUrl", + }, +]; + +/** Inputs the composition derives, each from its shared API endpoint or a sibling member kind. */ +const derivedInputs: Partial< + Record<ServiceCreation["service"], Readonly<Record<string, "api" | ServiceCreation["service"]>>> +> = { + auth: { externalApiUrl: "api" }, + studio: { + apiUrl: "api", + publicApiUrl: "api", + analyticsApiKey: "analytics", + analyticsBackend: "analytics", + functionsRoot: "functions", + }, + functions: { apiUrl: "api", databaseUrl: "database" }, +}; + +/** Derived inputs a project may set itself; a plan compares one whenever the request sets it. */ +const overridableInputs: Partial<Record<ServiceCreation["service"], ReadonlyArray<string>>> = { + studio: ["publicApiUrl"], +}; + +/** Names every config input the package supplies to a composition member of this kind. */ +const managedInputs = (service: ServiceCreation["service"]): ReadonlySet<string> => + new Set([ + ...managedBindings.filter(({ targetKind }) => targetKind === service).map(({ input }) => input), + ...Object.keys(derivedInputs[service] ?? {}), + ...credentialInputNames(service), + ]); + +/** Inputs that sibling member kinds supply to a member of this kind, with their source kind. */ +const siblingInputs = ( + service: ServiceCreation["service"], +): ReadonlyArray<readonly [string, ServiceCreation["service"]]> => [ + ...managedBindings + .filter(({ targetKind }) => targetKind === service) + .map(({ input, sourceKind }) => [input, sourceKind] as const), + ...Object.entries(derivedInputs[service] ?? {}).flatMap(([input, source]) => + source === "api" ? [] : [[input, source] as const], + ), +]; + export class SupabaseCompositionError extends Data.TaggedError("SupabaseCompositionError")<{ readonly message: string; readonly cause?: unknown; @@ -18,42 +180,182 @@ interface SupabaseCompositionEntry { readonly creation: ServiceCreation; } -export interface SupabaseCompositionOperations { +/** Owner operations the composition drives; their failures surface as composition errors. */ +export interface SupabaseCompositionOperations<E = SupabaseCompositionError> { readonly currentComposition: Effect.Effect<CompositionConfig>; - readonly get: (id: string) => Effect.Effect<SupabaseCompositionEntry, SupabaseCompositionError>; - readonly status: ( - id: string, - ) => Effect.Effect<Pick<Observation, "lifecycle" | "wakeEnabled">, SupabaseCompositionError>; - readonly create: ( - creation: ServiceCreation, - ) => Effect.Effect<SupabaseCompositionEntry, SupabaseCompositionError>; - readonly destroy: (id: string) => Effect.Effect<void, SupabaseCompositionError>; - readonly bind: (id: string) => Effect.Effect<void, SupabaseCompositionError>; + readonly get: (id: string) => Effect.Effect<SupabaseCompositionEntry, E>; + readonly status: (id: string) => Effect.Effect<Pick<Observation, "lifecycle" | "wakeEnabled">, E>; + readonly create: (creation: ServiceCreation) => Effect.Effect<SupabaseCompositionEntry, E>; + readonly destroy: (id: string) => Effect.Effect<void, E>; + readonly bind: (id: string) => Effect.Effect<void, E>; readonly address: ( id: string, endpoint: string, from: "host" | "runtime", - ) => Effect.Effect<string, SupabaseCompositionError>; - readonly output: (id: string, name: string) => Effect.Effect<string, SupabaseCompositionError>; + ) => Effect.Effect<string, E>; + readonly output: (id: string, name: string) => Effect.Effect<string, E>; readonly updateCreation: ( id: string, inputs: Record<string, string | undefined>, - ) => Effect.Effect<SupabaseCompositionEntry, SupabaseCompositionError>; + ) => Effect.Effect<SupabaseCompositionEntry, E>; readonly replaceCreation: ( id: string, creation: ServiceCreation, - ) => Effect.Effect<SupabaseCompositionEntry, SupabaseCompositionError>; - readonly configure: ( - configuration: CompositionConfig, - ) => Effect.Effect<void, SupabaseCompositionError>; + ) => Effect.Effect<SupabaseCompositionEntry, E>; + readonly configure: (configuration: CompositionConfig) => Effect.Effect<void, E>; } export interface SupabaseCompositionOptions { /** Reuses stopped instances; inputs declare desired bindings, not previous resolved creations. */ readonly reuseIds?: ReadonlyArray<string>; - readonly identity?: StackIdentityInput; + readonly keys?: StackKeysInput; + /** + * Starts every member with the composition. By default the database and members without an + * endpoint start eagerly, and other members start on their first connection. + */ + readonly eager?: boolean; } +/** How a saved instance differs from a requested creation once package-managed inputs are set aside. */ +export type CreationChange = + | { readonly change: "unchanged" } + | { readonly change: "changed"; readonly paths: ReadonlyArray<string> } + | { + /** The instance cannot adopt the request: its endpoints, artifact or data version differ. */ + readonly change: "incompatible"; + readonly paths: ReadonlyArray<string>; + }; + +/** A saved instance of a requested service kind, compared with that request. */ +export type PlannedInstance = CreationChange & { + readonly id: string; + readonly service: ServiceCreation["service"]; + /** Whether the instance belongs to the saved composition. */ + readonly member: boolean; +}; + +const differences = (left: unknown, right: unknown, path: string): ReadonlyArray<string> => { + if (Redacted.isRedacted(left) || Redacted.isRedacted(right)) + return Redacted.isRedacted(left) && + Redacted.isRedacted(right) && + Redacted.value(left) === Redacted.value(right) + ? [] + : [path]; + if (Array.isArray(left) || Array.isArray(right)) + return Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => differences(value, right[index], path).length === 0) + ? [] + : [path]; + if (isRecord(left) && isRecord(right)) + return [...new Set([...Object.keys(left), ...Object.keys(right)])] + .toSorted() + .flatMap((key) => differences(left[key], right[key], path === "" ? key : `${path}.${key}`)); + return Object.is(left, right) ? [] : [path]; +}; + +const sharesApiEndpoint = (creation: ServiceCreationInput): boolean => + apiRoute(creation.service) !== undefined && endpointNames(creation).includes("http"); + +/** Fixed ports requested for the shared API endpoint; composition accepts at most one. */ +const fixedApiPorts = (creations: ReadonlyArray<ServiceCreationInput>): ReadonlySet<number> => + new Set( + creations + .filter(sharesApiEndpoint) + .map((creation) => endpointPort(creation, "http")) + .filter((port): port is number => port !== "auto"), + ); + +/** Endpoint intents with an automatic shared API port replaced by the fixed one. */ +const withSharedApiPort = (creation: ServiceCreationInput, sharedPort: number | undefined) => + sharedPort === undefined || + !sharesApiEndpoint(creation) || + endpointPort(creation, "http") !== "auto" + ? creation.endpoints + : { ...(isRecord(creation.endpoints) ? creation.endpoints : {}), http: { port: sharedPort } }; + +const comparable = ( + creation: ServiceCreationInput, + sharedPort: number | undefined, + compared: ReadonlySet<string>, +) => { + const managed = managedInputs(creation.service); + const config: Record<string, unknown> = Object.fromEntries( + Object.entries(creation.config).filter(([key]) => compared.has(key) || !managed.has(key)), + ); + if (creation.service === "database") config.version = postgresVersion(creation.config.version); + return { version: creation.version, endpoints: withSharedApiPort(creation, sharedPort), config }; +}; + +/** Compares a requested creation with a saved one, ignoring inputs the package supplies. */ +const compareCreation = ( + saved: ServiceCreation, + requested: ServiceCreationInput, + sharedPort: number | undefined, + memberKinds: ReadonlySet<ServiceCreation["service"]>, +): CreationChange => { + const overridable = overridableInputs[requested.service] ?? []; + // Without its supplying member, the composition keeps the project's own value of an input. + const compared = new Set([ + ...Object.entries(requested.config) + .filter(([key, value]) => value !== undefined && overridable.includes(key)) + .map(([key]) => key), + ...siblingInputs(requested.service) + .filter(([, source]) => !memberKinds.has(source)) + .map(([input]) => input), + ]); + const paths = differences( + comparable(saved, undefined, compared), + comparable(requested, sharedPort, compared), + "", + ); + const incompatible = paths.filter( + (path) => + path === "version" || + path === "endpoints" || + path.startsWith("endpoints.") || + (saved.service === "database" && path === "config.version"), + ); + return incompatible.length > 0 + ? { change: "incompatible", paths: incompatible } + : paths.length > 0 + ? { change: "changed", paths } + : { change: "unchanged" }; +}; + +/** Compares each saved instance of a requested kind with its request, without changing state. */ +export const planSupabaseComposition = ( + saved: Pick<SavedStack, "instances" | "composition">, + requested: ReadonlyArray<ServiceCreationInput>, +): ReadonlyArray<PlannedInstance> => { + const members = new Set(saved.composition.members.map(({ id }) => id)); + const memberKinds = new Set( + saved.instances.filter(({ id }) => members.has(id)).map(({ creation }) => creation.service), + ); + const requestedKinds = new Set(requested.map(({ service }) => service)); + const ports = fixedApiPorts([ + ...requested, + ...saved.instances + .filter(({ id, creation }) => members.has(id) && requestedKinds.has(creation.service)) + .map(({ creation }) => creation), + ]); + const sharedPort = ports.size === 1 ? [...ports][0] : undefined; + return saved.instances.flatMap(({ id, creation }) => { + const request = requested.find(({ service }) => service === creation.service); + return request === undefined + ? [] + : [ + { + id, + service: creation.service, + member: members.has(id), + ...compareCreation(creation, request, sharedPort, memberKinds), + }, + ]; + }); +}; + const compositionError = (message: string, cause?: unknown) => new SupabaseCompositionError({ message, cause }); @@ -63,8 +365,8 @@ const compositionErrorFrom = (cause: unknown) => : compositionError(cause instanceof Error ? cause.message : String(cause), cause); export const makeSupabaseComposition = Effect.fn("Supabase.compose")( - ( - operations: SupabaseCompositionOperations, + <E>( + operations: SupabaseCompositionOperations<E>, inputs: ReadonlyArray<ServiceCreation>, options: SupabaseCompositionOptions = {}, ) => @@ -141,31 +443,20 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( return entries; }); - const fixedPorts = new Set( - [...decoded, ...reusedEntries.map(({ creation }) => creation)] - .filter( - (creation) => - apiRoute(creation.service) !== undefined && endpointNames(creation).includes("http"), - ) - .map((creation) => endpointPort(creation, "http")) - .filter((port): port is number => port !== "auto"), - ); + const fixedPorts = fixedApiPorts([ + ...decoded, + ...reusedEntries.map(({ creation }) => creation), + ]); if (fixedPorts.size > 1) return yield* compositionError("Shared HTTP endpoints must use one port"); const sharedPort = [...fixedPorts][0]; const normalized = yield* Effect.forEach(decoded, (creation) => { - if ( - sharedPort === undefined || - apiRoute(creation.service) === undefined || - !endpointNames(creation).includes("http") || - endpointPort(creation, "http") !== "auto" - ) - return Effect.succeed(creation); - const endpoints = isRecord(creation.endpoints) ? creation.endpoints : {}; - return Schema.decodeUnknownEffect(ServiceCreation)({ - ...creation, - endpoints: { ...endpoints, http: { port: sharedPort } }, - }).pipe(Effect.mapError(compositionErrorFrom)); + const endpoints = withSharedApiPort(creation, sharedPort); + return endpoints === creation.endpoints + ? Effect.succeed(creation) + : Schema.decodeUnknownEffect(ServiceCreation)({ ...creation, endpoints }).pipe( + Effect.mapError(compositionErrorFrom), + ); }); const reusedByKind = new Map(reusedEntries.map((entry) => [entry.creation.service, entry])); @@ -180,112 +471,6 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( ) return yield* compositionError("API URL bindings require a configured HTTP endpoint"); - const managedBindings: ReadonlyArray<{ - readonly sourceKind: ServiceCreation["service"]; - readonly sourceEndpoint: string; - readonly output: string; - readonly targetKind: ServiceCreation["service"]; - readonly input: string; - }> = [ - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "authenticatorUrl", - targetKind: "rest", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "authDatabaseUrl", - targetKind: "auth", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "storageDatabaseUrl", - targetKind: "storage", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "databaseUrl", - targetKind: "storage", - input: "vectorDatabaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "databaseUrl", - targetKind: "realtime", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "databaseUrl", - targetKind: "pgmeta", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "internalDatabaseUrl", - targetKind: "analytics", - input: "databaseUrl", - }, - { - sourceKind: "database", - sourceEndpoint: "sql", - output: "internalDatabaseUrl", - targetKind: "pooler", - input: "databaseUrl", - }, - { - sourceKind: "imgproxy", - sourceEndpoint: "http", - output: "url", - targetKind: "storage", - input: "imgproxyUrl", - }, - { - sourceKind: "pgmeta", - sourceEndpoint: "http", - output: "url", - targetKind: "studio", - input: "pgmetaUrl", - }, - { - sourceKind: "analytics", - sourceEndpoint: "http", - output: "url", - targetKind: "studio", - input: "analyticsUrl", - }, - { - sourceKind: "analytics", - sourceEndpoint: "http", - output: "url", - targetKind: "vector", - input: "analyticsUrl", - }, - { - sourceKind: "functions", - sourceEndpoint: "http", - output: "url", - targetKind: "studio", - input: "functionsUrl", - }, - { - sourceKind: "mail", - sourceEndpoint: "smtp", - output: "smtpUrl", - targetKind: "auth", - input: "smtpUrl", - }, - ]; for (const { sourceKind, sourceEndpoint, targetKind } of managedBindings) { if (byKind.has(sourceKind) && byKind.has(targetKind)) { const source = byKind.get(sourceKind); @@ -332,7 +517,10 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( .bind(entry.id) .pipe( Effect.mapError((cause) => - compositionError(`${entry.creation.service} ${entry.id}: ${cause.message}`, cause), + compositionError( + `${entry.creation.service} ${entry.id}: ${compositionErrorFrom(cause).message}`, + cause, + ), ), ); } @@ -407,11 +595,12 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( const values = { ...configInputs.get(entry.id), ...extra }; if (entry.creation.service === "studio") { const analytics = entriesByKind.get("analytics"); - if ( - analytics?.creation.service === "analytics" && - analytics.creation.config.apiKey !== undefined - ) - values.analyticsApiKey = analytics.creation.config.apiKey; + if (analytics?.creation.service === "analytics") { + if (analytics.creation.config.apiKey !== undefined) + values.analyticsApiKey = analytics.creation.config.apiKey; + if (analytics.creation.config.backend !== undefined) + values.analyticsBackend = analytics.creation.config.backend; + } const functions = entriesByKind.get("functions"); if (functions?.creation.service === "functions") values.functionsRoot = functions.creation.config.functionsRoot; @@ -430,11 +619,14 @@ export const makeSupabaseComposition = Effect.fn("Supabase.compose")( }), ); const members = configured.map(({ id, creation }) => { - const lazy = creation.service !== "database" && endpointNames(creation).length > 0; + const lazy = + options.eager !== true && + creation.service !== "database" && + endpointNames(creation).length > 0; return lazy ? creation.service === "functions" ? { id, activation: "lazy" as const } - : { id, activation: "lazy" as const, idleMillis: 60_000 } + : { id, activation: "lazy" as const, idleMillis: idleMillisFor(creation.service) } : { id, activation: "eager" as const }; }); yield* operations.configure({ diff --git a/packages/stack/src/composition/Supabase.unit.test.ts b/packages/stack/src/composition/Supabase.unit.test.ts new file mode 100644 index 0000000000..8d0e259c90 --- /dev/null +++ b/packages/stack/src/composition/Supabase.unit.test.ts @@ -0,0 +1,169 @@ +import { expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, Redacted, Ref, Scope, Stream } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import * as Orchestrator from "../Orchestrator.ts"; +import { makeService, ServiceError } from "../Service.ts"; +import type { ServiceCreation } from "../services/Catalog.ts"; +import { makeSupabaseComposition, type SupabaseCompositionOperations } from "./Supabase.ts"; + +/** A registered instance with no runtime behavior beyond an immediate healthy start and stop. */ +const makeInstance = ( + orchestrator: Orchestrator.Interface, + id: string, + options: { + readonly inputs?: ReadonlyArray<string>; + readonly outputs?: Readonly<Record<string, Effect.Effect<string, ServiceError>>>; + readonly hasEndpoint?: boolean; + } = {}, +) => + Effect.gen(function* () { + const core = yield* makeService<Record<string, string>>( + { + launch: () => + Effect.gen(function* () { + const exited = yield* Deferred.make<Exit.Exit<void, ServiceError>>(); + return { + health: Effect.void, + exit: Deferred.await(exited), + stop: Deferred.succeed(exited, Exit.void).pipe(Effect.asVoid), + remove: Effect.void, + }; + }), + removeData: () => Effect.void, + }, + { id, config: {}, coordinate: orchestrator.admissionFor(id) }, + ); + const instance: Orchestrator.RegisteredInstance = { + id, + service: id, + core, + startAt: (revision, inputs, wake, guard) => core.startAt(revision, inputs, wake, guard), + restart: (revision, inputs, config, guard) => core.restart(inputs, revision, guard), + bind: Effect.void, + close: Effect.void, + hasEndpoint: options.hasEndpoint ?? true, + inputs: options.inputs ?? [], + outputs: options.outputs ?? {}, + }; + yield* orchestrator.register(instance); + return instance; + }); + +/** Merges optional config bindings onto a creation; the config union can't express this generically. */ +const withValues = <C extends ServiceCreation>( + creation: C, + values: Record<string, string | undefined>, +): C => ({ ...creation, config: { ...creation.config, ...values } }) as C; + +const stopped = (instance: Orchestrator.RegisteredInstance) => + instance.core.observation.pipe( + Stream.filter((state) => state.lifecycle === "stopped" && state.currentOperation === undefined), + Stream.take(1), + Stream.runDrain, + ); + +it.live( + "keeps a studio member's pgmeta prerequisite awake past 60s, and sleeps both after studio idles", + () => + Effect.scoped( + Effect.gen(function* () { + const idFor = (service: ServiceCreation["service"]) => `${service}-id`; + const inputs: ReadonlyArray<ServiceCreation> = [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("studio-idle-password"), + jwtSecret: Redacted.make("studio-idle-jwt-secret-with-32-chars"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }, + { + service: "auth", + config: { jwtSecret: "studio-idle-jwt-secret-with-32-chars", jwtExpiry: 3600 }, + endpoints: { http: { port: "auto" } }, + }, + { service: "studio", config: {}, endpoints: { http: { port: "auto" } } }, + { service: "pgmeta", config: {}, endpoints: { http: { port: "auto" } } }, + ]; + const baseById = new Map(inputs.map((creation) => [idFor(creation.service), creation])); + const captured = yield* Ref.make<Orchestrator.CompositionConfig | undefined>(undefined); + const operations: SupabaseCompositionOperations = { + currentComposition: Effect.succeed({ members: [], dependencies: [] }), + get: () => Effect.die("get is unused"), + status: () => Effect.die("status is unused"), + create: (creation) => Effect.succeed({ id: idFor(creation.service), creation }), + destroy: () => Effect.die("destroy is unused"), + bind: () => Effect.void, + address: (id, endpoint, from) => Effect.succeed(`${from}://${id}/${endpoint}`), + output: (id, name) => Effect.succeed(`${name}::${id}`), + updateCreation: (id, values) => { + const base = baseById.get(id); + return base === undefined + ? Effect.die(`Unknown instance ${id}`) + : Effect.succeed({ id, creation: withValues(base, values) }); + }, + replaceCreation: () => Effect.die("replaceCreation is unused"), + configure: (configuration) => Ref.set(captured, configuration), + }; + yield* makeSupabaseComposition(operations, inputs); + const configuration = yield* Ref.get(captured); + if (configuration === undefined) return yield* Effect.die("Composition was not configured"); + + const studioId = idFor("studio"); + const pgmetaId = idFor("pgmeta"); + // pgmeta must be a declared prerequisite of studio for the dependent-blocks-sleep rule below to apply. + expect( + configuration.dependencies.some( + (dependency) => dependency.from === pgmetaId && dependency.to === studioId, + ), + ).toBe(true); + + const orchestrator = yield* Orchestrator.make<Orchestrator.RegisteredInstance>(); + const database = yield* makeInstance(orchestrator, idFor("database"), { + outputs: { + authDatabaseUrl: Effect.succeed("postgres://auth"), + databaseUrl: Effect.succeed("postgres://pgmeta"), + }, + }); + yield* makeInstance(orchestrator, idFor("auth"), { inputs: ["databaseUrl"] }); + const pgmeta = yield* makeInstance(orchestrator, pgmetaId, { + inputs: ["databaseUrl"], + outputs: { url: Effect.succeed("http://pgmeta") }, + }); + const studio = yield* makeInstance(orchestrator, studioId, { + inputs: ["databaseUrl", "pgmetaUrl", "analyticsUrl", "functionsUrl"], + }); + yield* orchestrator.configure(configuration); + yield* orchestrator.startComposition; + + const requestScope = yield* Scope.make(); + yield* orchestrator.acquire(studioId).pipe(Scope.provide(requestScope)); + yield* TestClock.adjust("1 second"); + expect((yield* database.core.get).lifecycle).toBe("running"); + expect((yield* pgmeta.core.get).lifecycle).toBe("running"); + expect((yield* studio.core.get).lifecycle).toBe("running"); + + // The request finished; studio itself now idles on its own 5-minute timer. + yield* Scope.close(requestScope, Exit.void); + + // Well past pgmeta's own 60s idle mark but within studio's 5-minute window: studio + // isn't idle yet, so the dependent-blocks-sleep rule keeps pgmeta running too. + yield* TestClock.adjust("90 seconds"); + expect((yield* studio.core.get).lifecycle).toBe("running"); + expect((yield* pgmeta.core.get).lifecycle).toBe("running"); + + // One second before studio's 5-minute idle deadline, measured from the request. + yield* TestClock.adjust("208 seconds"); + expect((yield* studio.core.get).lifecycle).toBe("running"); + expect((yield* pgmeta.core.get).lifecycle).toBe("running"); + + const studioStopped = yield* stopped(studio).pipe(Effect.forkChild); + const pgmetaStopped = yield* stopped(pgmeta).pipe(Effect.forkChild); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(studioStopped); + yield* Fiber.join(pgmetaStopped); + }), + ).pipe(Effect.provide(TestClock.layer())), +); diff --git a/packages/stack/src/create.owner-rollback.integration.test.ts b/packages/stack/src/create.owner-rollback.integration.test.ts new file mode 100644 index 0000000000..34ab4ff186 --- /dev/null +++ b/packages/stack/src/create.owner-rollback.integration.test.ts @@ -0,0 +1,101 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Effect, Exit, Fiber, FileSystem, Layer, Path } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { create, discover } from "./effect.ts"; +import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; + +const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); + +it.live( + "removes a stack it just registered when the owner fails to launch, and lets a retry succeed", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-create-rollback-" }); + const binDir = path.join(root, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + const dockerShim = path.join(binDir, "docker"); + yield* fs.writeFileString( + dockerShim, + "#!/bin/sh\necho 'docker daemon unreachable' >&2\nexit 1\n", + ); + yield* fs.chmod(dockerShim, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters<typeof create>[0]; + const identity = yield* resolveStackIdentity(options); + const id = yield* deriveStackId(identity); + + const launchFailure = yield* Effect.flip(create({ ...options, startOwner: true })); + expect(launchFailure.message).toContain("docker daemon unreachable"); + + expect(yield* discover({ stateRoot: options.stateRoot })).toEqual([]); + const stackDirExit = yield* Effect.exit(fs.access(path.join(options.stateRoot, id))); + expect(Exit.isFailure(stackDirExit)).toBe(true); + + const retried = yield* create({ ...options, runtime: "native" }); + expect(retried.id).toBe(id); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("removes a stack it just registered when its owner launch is interrupted", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-create-interrupt-" }); + // The shim signals through a FIFO once the owner's startup sweep is running, then blocks. + const started = path.join(root, "sweep-started"); + yield* Effect.scoped( + spawner + .spawn(ChildProcess.make("mkfifo", [started])) + .pipe(Effect.flatMap((child) => child.exitCode)), + ); + const binDir = path.join(root, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + const dockerShim = path.join(binDir, "docker"); + yield* fs.writeFileString( + dockerShim, + `#!/bin/sh\nif [ "$1" = "ps" ]; then echo started > '${started}'; exec sleep 60; fi\nexit 0\n`, + ); + yield* fs.chmod(dockerShim, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters<typeof create>[0]; + const creating = yield* create({ ...options, startOwner: true }).pipe(Effect.forkChild); + yield* fs.readFileString(started); + yield* Fiber.interrupt(creating); + + expect(yield* discover({ stateRoot: options.stateRoot })).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/packages/stack/src/credentials.integration.test.ts b/packages/stack/src/credentials.integration.test.ts index e5679f6954..c66ce6dfc6 100644 --- a/packages/stack/src/credentials.integration.test.ts +++ b/packages/stack/src/credentials.integration.test.ts @@ -61,7 +61,7 @@ it.live("resolves composition credentials before creating services", () => const created = yield* stack.composition.supabase([ { service: "auth", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: { port: "auto" } }, }, { diff --git a/packages/stack/src/defaults.integration.test.ts b/packages/stack/src/defaults.integration.test.ts index 822b35604d..af71143ea4 100644 --- a/packages/stack/src/defaults.integration.test.ts +++ b/packages/stack/src/defaults.integration.test.ts @@ -2,17 +2,9 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Redacted } from "effect"; import { tmpdir } from "node:os"; -import { - create as createEffect, - DEFAULT_LOCAL_JWT_SECRET, - DEFAULT_POSTGRES_ROOT_KEY, - StackError, -} from "./effect.ts"; -import { - create as createPromise, - DEFAULT_LOCAL_JWT_SECRET as PROMISE_DEFAULT_JWT_SECRET, - DEFAULT_POSTGRES_ROOT_KEY as PROMISE_DEFAULT_ROOT_KEY, -} from "./index.ts"; +import { DEFAULT_LOCAL_JWT_SECRET, DEFAULT_POSTGRES_ROOT_KEY } from "./Defaults.ts"; +import { create as createEffect, StackError } from "./effect.ts"; +import { create as createPromise } from "./index.ts"; const artifactCacheRoot = `${tmpdir()}/supabase-stack-artifacts`; const effectLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); @@ -134,10 +126,8 @@ describe("database configuration defaults", { timeout: 180_000 }, () => { expect(initial.config.service).toBe("database"); if (initial.config.service !== "database") return yield* Effect.die("Expected a database status"); - expect(redactedValue(initial.config.config.jwtSecret)).toBe( - PROMISE_DEFAULT_JWT_SECRET, - ); - expect(redactedValue(initial.config.config.rootKey)).toBe(PROMISE_DEFAULT_ROOT_KEY); + expect(redactedValue(initial.config.config.jwtSecret)).toBe(DEFAULT_LOCAL_JWT_SECRET); + expect(redactedValue(initial.config.config.rootKey)).toBe(DEFAULT_POSTGRES_ROOT_KEY); yield* promise(() => database.restart({ config: promiseDatabaseConfig })); yield* promise(() => database.ready()); @@ -146,9 +136,11 @@ describe("database configuration defaults", { timeout: 180_000 }, () => { if (restarted.config.service !== "database") return yield* Effect.die("Expected a restarted database status"); expect(redactedValue(restarted.config.config.jwtSecret)).toBe( - PROMISE_DEFAULT_JWT_SECRET, + DEFAULT_LOCAL_JWT_SECRET, + ); + expect(redactedValue(restarted.config.config.rootKey)).toBe( + DEFAULT_POSTGRES_ROOT_KEY, ); - expect(redactedValue(restarted.config.config.rootKey)).toBe(PROMISE_DEFAULT_ROOT_KEY); const conflict = yield* Effect.flip( promise(() => @@ -173,10 +165,10 @@ describe("database configuration defaults", { timeout: 180_000 }, () => { if (composedStatus.config.service !== "database") return yield* Effect.die("Expected a composed database status"); expect(redactedValue(composedStatus.config.config.jwtSecret)).toBe( - PROMISE_DEFAULT_JWT_SECRET, + DEFAULT_LOCAL_JWT_SECRET, ); expect(redactedValue(composedStatus.config.config.rootKey)).toBe( - PROMISE_DEFAULT_ROOT_KEY, + DEFAULT_POSTGRES_ROOT_KEY, ); }), (current) => diff --git a/packages/stack/src/destroy.runtime-unavailable.integration.test.ts b/packages/stack/src/destroy.runtime-unavailable.integration.test.ts new file mode 100644 index 0000000000..595b75a6a5 --- /dev/null +++ b/packages/stack/src/destroy.runtime-unavailable.integration.test.ts @@ -0,0 +1,311 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { create, discover } from "./effect.ts"; + +const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); + +const shimDocker = Effect.fn("shimDocker")(function* ( + root: string, + script: string, + engine: "docker" | "podman" = "docker", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(root, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + const dockerShim = path.join(binDir, engine); + yield* fs.writeFileString(dockerShim, script); + yield* fs.chmod(dockerShim, 0o755); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${binDir}:${originalPath ?? ""}`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); +}); + +it.live( + "removes a stack's registration and data when destroy finds no owner and its engine is unreachable", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-offline-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at tcp://127.0.0.1:1. Is the docker daemon running?' >&2\nexit 1\n", + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const dataRoot = `${options.stateRoot}/${stack.id}/data`; + yield* fs.makeDirectory(`${dataRoot}/db-instance`, { recursive: true }); + yield* fs.writeFileString( + `${dataRoot}/db-instance/.supabase-database-storage.json`, + `{"backend":"docker","volume":"supabase-db-0123456789abcdef","namespace":"instance-${stack.id}-db-instance","cacheNamespace":"cache-${"0".repeat(32)}","daemonId":"daemon","initialized":true}`, + ); + const resolvedDataRoot = yield* fs.realPath(dataRoot); + + const result = yield* stack.destroy; + expect(result).toEqual({ + runtimeCleanup: "skipped", + engine: "docker", + cleanupCommands: [ + `sh -c 'ids=$(docker ps --all --quiet --no-trunc --filter '\\''label=com.supabase.stack=${stack.id}'\\'' --filter '\\''label=com.supabase.stack-root=${resolvedDataRoot}'\\'') && { [ -z "$ids" ] || docker rm --force $ids; }'`, + expect.stringMatching( + new RegExp( + `^docker run --rm --mount 'type=volume,src=supabase-db-0123456789abcdef,dst=/store' '[^']+' /bin/sh -c 'rm -rf /store/instance-${stack.id}-db-instance'$`, + "u", + ), + ), + ], + }); + + expect(yield* discover({ stateRoot: options.stateRoot })).toEqual([]); + const stackDirExit = yield* Effect.exit(fs.access(`${options.stateRoot}/${stack.id}`)); + expect(Exit.isFailure(stackDirExit)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("removes a stack offline when Windows reports its Docker daemon pipe is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-windows-" }); + yield* shimDocker( + root, + `#!/bin/sh\necho 'error during connect: Get "http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.47/containers/json": open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.' >&2\nexit 1\n`, + ); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + + const result = yield* stack.destroy; + + expect(result.runtimeCleanup).toBe("skipped"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("removes a stack offline when its engine CLI is not installed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-no-cli-" }); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + yield* fs.makeDirectory(`${root}/empty-bin`); + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- the detached host subprocess inherits PATH; this is not application config. + const originalPath = process.env.PATH; + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- see above. + process.env.PATH = `${root}/empty-bin`; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // oxlint-disable-next-line effecttsgo/process-env-in-effect -- restores the mutation made above. + process.env.PATH = originalPath; + }), + ); + + const result = yield* stack.destroy; + + expect(result.runtimeCleanup).toBe("skipped"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("removes a stack offline when Docker reports its API socket is missing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-missing-socket-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'failed to connect to the docker API at unix:///tmp/missing-docker.sock; check if the path is correct and if the daemon is running: dial unix /tmp/missing-docker.sock: connect: no such file or directory' >&2\nexit 1\n", + ); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + + const result = yield* stack.destroy; + + expect(result.runtimeCleanup).toBe("skipped"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live( + "prints a container cleanup command that fails while the engine is down and removes every listed container once it is back", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-command-" }); + const unreachable = + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at tcp://127.0.0.1:1. Is the docker daemon running?' >&2\nexit 1\n"; + yield* shimDocker(root, unreachable); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + const result = yield* stack.destroy; + if (result.runtimeCleanup !== "skipped") return yield* Effect.die("cleanup was not skipped"); + const [containerCommand] = result.cleanupCommands; + if (containerCommand === undefined) return yield* Effect.die("container command missing"); + const run = Effect.scoped( + spawner + .spawn(ChildProcess.make("/bin/sh", ["-c", containerCommand])) + .pipe(Effect.flatMap((child) => child.exitCode)), + ); + + expect(Number(yield* run)).not.toBe(0); + yield* fs.writeFileString( + `${root}/bin/docker`, + '#!/bin/sh\nif [ "$1" = "ps" ]; then exit 0; fi\necho "docker rm requires at least 1 argument" >&2\nexit 1\n', + ); + expect(Number(yield* run)).toBe(0); + // Removal succeeds only when both listed IDs arrive as separate arguments. + yield* fs.writeFileString( + `${root}/bin/docker`, + '#!/bin/sh\nif [ "$1" = "ps" ]; then printf "aaa111\\nbbb222\\n"; exit 0; fi\nif [ "$1" = "rm" ] && [ "$2" = "--force" ] && [ "$#" -eq 4 ] && [ "$3" = "aaa111" ] && [ "$4" = "bbb222" ]; then exit 0; fi\necho "unexpected arguments: $*" >&2\nexit 1\n', + ); + expect(Number(yield* run)).toBe(0); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("keeps a stack registered when Podman rejects its credentials", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-podman-auth-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM' >&2\necho 'Error: unable to connect to Podman socket: ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain' >&2\nexit 125\n", + "podman", + ); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "podman", + }); + + const destroyFailure = yield* Effect.flip(stack.destroy); + + expect(destroyFailure.message).toContain("unable to authenticate"); + expect(yield* discover({ stateRoot })).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live.skipIf(process.getuid?.() === 0)( + "keeps a stack and its data when offline destroy cannot remove container-owned host data", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-host-data-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'Cannot connect to the Docker daemon at tcp://127.0.0.1:1. Is the docker daemon running?' >&2\nexit 1\n", + ); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "docker", + }); + // Stands in for PostgreSQL files a container wrote as its own user. + const instanceRoot = `${stateRoot}/${stack.id}/data/db-instance`; + yield* fs.makeDirectory(`${instanceRoot}/data`, { recursive: true }); + yield* fs.writeFileString(`${instanceRoot}/owned-file`, "owned data"); + yield* fs.chmod(`${instanceRoot}/data`, 0o000); + yield* Effect.addFinalizer(() => fs.chmod(`${instanceRoot}/data`, 0o755).pipe(Effect.ignore)); + + const destroyFailure = yield* Effect.flip(stack.destroy); + + expect(destroyFailure.message).toContain("start Docker and run destroy again"); + expect(yield* discover({ stateRoot })).toHaveLength(1); + expect(yield* fs.exists(`${instanceRoot}/owned-file`)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("keeps a stack registered when its container engine rejects the listing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-permission-" }); + yield* shimDocker( + root, + "#!/bin/sh\necho 'permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock' >&2\nexit 1\n", + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + + const destroyFailure = yield* Effect.flip(stack.destroy); + expect(destroyFailure.message).toContain("permission denied"); + + expect(yield* discover({ stateRoot: options.stateRoot })).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("keeps a stack registered when its container engine is reachable but cleanup fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-destroy-cleanup-failure-" }); + yield* shimDocker( + root, + [ + "#!/bin/sh", + 'if [ "$1" = "ps" ]; then', + ' echo "abc123def456"', + " exit 0", + "fi", + 'if [ "$1" = "rm" ]; then', + " echo 'docker: Error response from daemon: container removal failed' >&2", + " exit 1", + "fi", + "exit 0", + "", + ].join("\n"), + ); + + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "docker", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + + const destroyFailure = yield* Effect.flip(stack.destroy); + expect(destroyFailure.message).toContain("container removal failed"); + + expect(yield* discover({ stateRoot: options.stateRoot })).toHaveLength(1); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/packages/stack/src/effect.integration.test.ts b/packages/stack/src/effect.integration.test.ts index 7bb6174bee..723bb24fc1 100644 --- a/packages/stack/src/effect.integration.test.ts +++ b/packages/stack/src/effect.integration.test.ts @@ -1,18 +1,42 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, expectTypeOf, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Redacted, Schema } from "effect"; +import { + Cause, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Redacted, + Schema, + Scope, + Stream, +} from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the test binds a dead owner's exact port. +import * as Net from "node:net"; import { tmpdir } from "node:os"; import { create, discover, + find, open, - postgres, type DatabaseInstance, type ServiceInstance, } from "./effect.ts"; +import { initialization, postgres } from "./Commands.ts"; +import { fileURLToPath } from "node:url"; +import { launchHost } from "./HostProcess.ts"; +import * as PromiseApi from "./index.ts"; +import * as State from "./State.ts"; +import { assertOwnerExited, watchLeaseRelease } from "../tests/owner.ts"; +import { foreignRelease } from "../tests/release-owner-fixture.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; +import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); +// Below every OS ephemeral range, so another test's outbound socket cannot already hold it. +const FIXED_API_PORT = 24_393; const databaseOwnerMarker = Schema.fromJsonString( Schema.Struct({ stackId: Schema.String, instanceId: Schema.String }), ); @@ -61,6 +85,41 @@ it.live("registers and discovers saved definitions without inventing live observ }).pipe(Effect.scoped, Effect.provide(layer)), ); +it.live("discovers readable stacks past invalid siblings and reports each skipped entry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-invalid-" }); + const stateRoot = `${root}/state`; + const stack = yield* create({ + projectRoot: root, + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + yield* fs.makeDirectory(`${stateRoot}/malformed`); + yield* fs.writeFileString(`${stateRoot}/malformed/state.json`, "{broken"); + yield* fs.makeDirectory(`${stateRoot}/unreadable/state.json`, { recursive: true }); + + const silent = yield* discover({ stateRoot }); + expect(silent.map(({ definition }) => definition.id)).toEqual([stack.id]); + + const reported: Array<string> = []; + const observed = yield* discover({ + stateRoot, + onInvalidState: (id) => Effect.sync(() => reported.push(id)), + }); + expect(observed.map(({ definition }) => definition.id)).toEqual([stack.id]); + expect(reported.toSorted()).toEqual(["malformed", "unreadable"]); + + const promiseReported: Array<string> = []; + const promiseObserved = yield* Effect.promise(() => + PromiseApi.discover({ stateRoot, onInvalidState: (id) => promiseReported.push(id) }), + ); + expect(promiseObserved.map(({ definition }) => definition.id)).toEqual([stack.id]); + expect(promiseReported.toSorted()).toEqual(["malformed", "unreadable"]); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + it.live("starts the owner on opt-in reopen without starting saved services", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -221,6 +280,17 @@ const resetDataStory = (runtime: "native" | "docker") => if (targetUrl === undefined || siblingUrl === undefined) return yield* Effect.die("database credentials missing"); + if (runtime === "native") { + const existingServices = yield* current.services.list; + const initialized = yield* current.commands.run( + initialization.realtime({ databaseUrl: targetUrl }), + ); + expect(initialized.exitCode).toBe(0); + expect((yield* current.services.list).map((service) => service.id)).toEqual( + existingServices.map((service) => service.id), + ); + } + const runSql = Effect.fn("ResetData.runSql")(( client: typeof current, url: string, @@ -228,7 +298,7 @@ const resetDataStory = (runtime: "native" | "docker") => ) => { const stdout: Array<string> = []; const stderr: Array<string> = []; - return client.tools + return client.commands .run(postgres.psql({ major: 17 }), { args: ["--dbname", url, "-Atc", sql], stdout: (bytes) => Effect.sync(() => stdout.push(new TextDecoder().decode(bytes))), @@ -330,3 +400,668 @@ it.live("resets native database data through the public RPC", () => resetDataSto it.live("resets Docker database data through the public RPC", () => resetDataStory("docker"), { timeout: 15 * 60_000, }); + +it.live("stops an instance without starting an owner when none is live", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-idle-stop-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + yield* Effect.ensuring( + Effect.gen(function* () { + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* stack.stop; + expect((yield* discover(options))[0]?.host).toBeUndefined(); + + yield* mail.stop; + expect(yield* stack.composition.stop).toEqual([]); + yield* stack.stop; + expect((yield* discover(options))[0]?.host).toBeUndefined(); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("rejects an owner of another release while stop and destroy still reach it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-release-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + const startForeignOwner = launchHost(state, { + ...options, + stackId: stack.id, + entrypoint: fileURLToPath(new URL("../tests/release-owner-fixture.ts", import.meta.url)), + }); + + const stale = yield* startForeignOwner; + expect(stale.endpoint.release).toBe(foreignRelease); + const rejected = yield* Effect.flip(stack.composition.start); + expect(rejected.reason).toBe("release-mismatch"); + expect(rejected.message).toContain(`served by release ${foreignRelease}`); + expect(rejected.message).toContain("stop or destroy the stack"); + yield* stack.stop; + yield* assertOwnerExited(stale.endpoint.pid); + + const doomed = yield* startForeignOwner; + yield* stack.destroy; + yield* assertOwnerExited(doomed.endpoint.pid); + expect(yield* state.read(stack.id)).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("treats a sweeper's hold as no owner and starts one once the sweep ends", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-sweeping-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + yield* Effect.ensuring( + Effect.gen(function* () { + const sweep = yield* Scope.make(); + expect(yield* state.lease(stack.id).pipe(Scope.provide(sweep))).toBe(true); + yield* state.publishHolder(stack.id, { + role: "sweeper", + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + }); + yield* stack.stop; + expect(yield* stack.composition.stop).toEqual([]); + expect((yield* discover(options))[0]?.host).toBeUndefined(); + + const starting = yield* stack.composition.start.pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* state.retractHolder(stack.id); + yield* Scope.close(sweep, Exit.void); + expect(yield* Fiber.join(starting)).toEqual([]); + expect((yield* discover(options))[0]?.host).toBeDefined(); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("interrupts a call waiting for an owner while a sweeper holds the stack", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-interrupt-launch-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + yield* Effect.ensuring( + Effect.gen(function* () { + const sweep = yield* Scope.make(); + expect(yield* state.lease(stack.id).pipe(Scope.provide(sweep))).toBe(true); + yield* state.publishHolder(stack.id, { + role: "sweeper", + pid: process.pid, + startedAt: "2026-01-01T00:00:00.000Z", + }); + + const waited = yield* stack.composition.start.pipe(Effect.timeoutOption("200 millis")); + + expect(Option.isNone(waited), "the wait for the sweeper ends at the timeout").toBe(true); + yield* state.retractHolder(stack.id); + yield* Scope.close(sweep, Exit.void); + expect(yield* stack.composition.start, "the handle still launches afterwards").toEqual([]); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("reports a stopped owner as unavailable to attach-only calls", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-unavailable-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* stack.stop; + + const unavailable = yield* Effect.flip(mail.status); + + expect(unavailable.reason).toBe("owner-unavailable"); + yield* destroyTestStack(stack); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +/** Binds a loopback port and resets every connection it accepts, counting them. */ +const countConnectionsOn = (port: number) => + Effect.acquireRelease( + Effect.callback<{ readonly server: Net.Server; readonly accepted: { count: number } }>( + (resume) => { + const accepted = { count: 0 }; + const server = Net.createServer((socket) => { + accepted.count++; + socket.destroy(); + }); + server.once("error", (cause) => resume(Effect.die(cause))); + server.listen(port, "127.0.0.1", () => resume(Effect.succeed({ server, accepted }))); + }, + ), + ({ server }) => + Effect.callback<void>((resume) => { + server.close(() => resume(Effect.void)); + }), + ).pipe( + Effect.map( + ({ accepted }) => + () => + accepted.count, + ), + ); + +it.live("sends no call to a process that took a dead owner's port", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-dead-owner-port-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* mail.status; + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + const holder = yield* state.readHolder(stack.id); + if (holder?.role !== "owner") return yield* Effect.die("Expected a live owner record"); + const released = yield* watchLeaseRelease(options.stateRoot, stack.id); + yield* Effect.sync(() => process.kill(holder.pid, "SIGKILL")); + yield* released; + const accepted = yield* countConnectionsOn(holder.port); + + const unavailable = yield* Effect.flip(mail.status); + + expect(accepted(), "connections reaching the dead owner's port").toBe(0); + expect(unavailable.reason).toBe("owner-unavailable"); + yield* destroyTestStack(stack); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("sends no call to a process that took the port of a replaced owner", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-replaced-owner-port-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* mail.status; + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + const replaced = yield* state.readHolder(stack.id); + if (replaced?.role !== "owner") return yield* Effect.die("Expected a live owner record"); + const released = yield* watchLeaseRelease(options.stateRoot, stack.id); + yield* Effect.sync(() => process.kill(replaced.pid, "SIGKILL")); + yield* released; + const accepted = yield* countConnectionsOn(replaced.port); + const other = yield* open({ ...options, id: stack.id }); + expect(yield* other.composition.start).toEqual([]); + + const status = yield* Effect.exit(mail.status); + + expect(accepted(), "connections reaching the replaced owner's port").toBe(0); + expect(Exit.isSuccess(status) && status.value.id).toBe(mail.id); + yield* destroyTestStack(stack); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live( + "releases each call's owner connection in the call's scope, not the handle's", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-call-scope-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${tmpdir()}/supabase-stack-artifacts`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const handleScope = yield* Scope.make(); + const stack = yield* create(options).pipe(Scope.provide(handleScope)); + const handleFinalizers = () => + handleScope.state._tag === "Open" + ? (handleScope.state.finalizers?.size ?? 0) + + (handleScope.state.finalizerKey === undefined ? 0 : 1) + : 0; + const useHandle = (mail: ServiceInstance<"mail">) => + Effect.gen(function* () { + yield* mail.status; + yield* mail.followStatus.pipe(Stream.take(1), Stream.runDrain); + const version = yield* stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--version"], + stdout: () => Effect.void, + stderr: () => Effect.void, + }); + expect(version.exitCode).toBe(0); + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const mail = yield* stack.services.create({ service: "mail", config: {} }); + yield* useHandle(mail); + const settled = handleFinalizers(); + + yield* useHandle(mail); + yield* useHandle(mail); + expect(handleFinalizers(), "repeated calls add nothing to the handle").toBe(settled); + + const other = yield* open({ ...options, id: stack.id }); + yield* other.stop; + expect(yield* other.composition.start).toEqual([]); + yield* useHandle(mail); + expect(handleFinalizers(), "the retired connection closed after its last use").toBe( + settled, + ); + }), + destroyTestStack(stack).pipe(Effect.andThen(Scope.close(handleScope, Exit.void))), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), + { timeout: 120_000 }, +); + +it.live("confirms owner exit after shutdown even while a stray handle keeps its loop alive", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-owner-exit-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + const owner = yield* launchHost(state, { + ...options, + stackId: stack.id, + entrypoint: fileURLToPath(new URL("../tests/lingering-owner-fixture.ts", import.meta.url)), + }); + + yield* stack.stop; + + yield* assertOwnerExited(owner.endpoint.pid); + yield* destroyTestStack(stack); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("replaces a dead session stack that holds the requested identity", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-replace-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + lifetime: "session", + } satisfies Parameters<typeof create>[0]; + const identity = yield* resolveStackIdentity(options); + const id = yield* deriveStackId(identity); + const state = yield* State.Service.pipe( + Effect.provide(State.layer({ root: options.stateRoot })), + ); + yield* state.save({ + id, + identity, + lifetime: "session", + runtime: "native", + instances: [{ id: "abandoned", creation: { service: "mail", config: {} } }], + composition: { members: [], dependencies: [] }, + ports: [], + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* create(options); + expect(stack.id).toBe(id); + expect(yield* stack.services.list).toEqual([]); + }), + ); + expect(yield* state.read(id)).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live( + "re-resolves a restarted owner for calls and commands on a long-lived handle", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-reconnect-" }); + const options = { + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${tmpdir()}/supabase-stack-artifacts`, + runtime: "native", + } satisfies Parameters<typeof create>[0]; + const stack = yield* create(options); + const version = (stack: Effect.Success<ReturnType<typeof open>>) => + stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--version"], + stdout: () => Effect.void, + stderr: () => Effect.void, + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const mail = yield* stack.services.create({ service: "mail", config: {} }); + expect((yield* version(stack)).exitCode).toBe(0); + const first = (yield* discover(options))[0]?.host; + + const other = yield* open({ ...options, id: stack.id }); + yield* other.stop; + expect(yield* other.composition.start).toEqual([]); + const second = (yield* discover(options))[0]?.host; + expect(second?.port).toBeDefined(); + expect(second?.pid).not.toBe(first?.pid); + + expect( + (yield* version(stack)).exitCode, + "a command-only call follows the new owner", + ).toBe(0); + yield* other.stop; + expect(yield* other.composition.start).toEqual([]); + expect((yield* mail.status).lifecycle, "a call follows the new owner").toBe("stopped"); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), + { timeout: 120_000 }, +); + +it.live("finds one saved stack by identity or id and fails on unreadable state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-find-" }); + const stateRoot = `${root}/state`; + expect(Option.isNone(yield* find({ stateRoot, projectRoot: root }))).toBe(true); + const stack = yield* create({ + projectRoot: root, + name: "feature", + stateRoot, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + + const byIdentity = Option.getOrUndefined( + yield* find({ stateRoot, projectRoot: root, name: "feature" }), + ); + expect(byIdentity?.definition.id).toBe(stack.id); + expect(byIdentity?.host).toBeUndefined(); + expect(Option.isNone(yield* find({ stateRoot, projectRoot: root }))).toBe(true); + const byId = yield* Effect.promise(() => PromiseApi.find({ stateRoot, id: stack.id })); + expect(byId?.definition.identity.stackName).toBe("feature"); + + yield* fs.writeFileString(`${stateRoot}/${stack.id}/state.json`, "{broken"); + const failure = yield* find({ stateRoot, projectRoot: root, name: "feature" }).pipe( + Effect.flip, + ); + expect(failure.operation).toBe("find"); + expect(failure.message).toContain(stack.id); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("plans requested creations against the saved composition and honours eager", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-" }); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const database = { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("plan-database-password"), + jwtSecret: Redacted.make("plan-database-jwt-secret-at-least-32-chars"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + } as const; + const rest = { + service: "rest", + config: { maxRows: 100 }, + endpoints: { http: { port: FIXED_API_PORT } }, + } as const; + const auth = { + service: "auth", + config: {}, + endpoints: { http: { port: "auto" } }, + } as const; + const members = yield* stack.composition.supabase([database, rest, auth]); + const databaseId = members.find(({ service }) => service === "database")?.id; + const restId = members.find(({ service }) => service === "rest")?.id; + const authId = members.find(({ service }) => service === "auth")?.id; + expect((yield* stack.composition.describe).members).toEqual([ + { id: databaseId, activation: "eager" }, + { id: restId, activation: "lazy", idleMillis: 60_000 }, + { id: authId, activation: "lazy", idleMillis: 60_000 }, + ]); + + expect( + yield* stack.composition.plan([ + database, + { ...rest, config: { maxRows: 500 } }, + auth, + { service: "mail", config: {} }, + ]), + ).toEqual([ + { id: databaseId, service: "database", member: true, change: "unchanged" }, + { + id: restId, + service: "rest", + member: true, + change: "changed", + paths: ["config.maxRows"], + }, + { id: authId, service: "auth", member: true, change: "unchanged" }, + ]); + const client = yield* Effect.promise(() => + PromiseApi.open({ id: stack.id, stateRoot: `${root}/state`, cacheRoot: `${root}/cache` }), + ); + const promisePlan = yield* Effect.promise(() => + client.composition + .plan([{ ...rest, config: { maxRows: 100 } }]) + .finally(() => client.close()), + ); + expect(promisePlan).toEqual([ + { id: restId, service: "rest", member: true, change: "unchanged" }, + ]); + expect( + yield* stack.composition.plan([ + { ...database, config: { ...database.config, version: "15" } }, + { ...rest, endpoints: { http: { port: 54_999 } } }, + ]), + ).toEqual([ + { + id: databaseId, + service: "database", + member: true, + change: "incompatible", + paths: ["config.version"], + }, + { + id: restId, + service: "rest", + member: true, + change: "incompatible", + paths: ["endpoints.http.port"], + }, + ]); + + yield* stack.composition.supabase([database, rest], { + reuseIds: [databaseId, restId].filter((id) => id !== undefined), + eager: true, + }); + expect((yield* stack.composition.describe).members).toEqual([ + { id: databaseId, activation: "eager" }, + { id: restId, activation: "eager" }, + ]); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("plans a Studio public API URL the project sets but not the one the stack derives", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-studio-" }); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const studio = { + service: "studio", + config: {}, + endpoints: { http: { port: "auto" } }, + } as const; + const members = yield* stack.composition.supabase([ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("plan-database-password"), + jwtSecret: Redacted.make("plan-database-jwt-secret-at-least-32-chars"), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }, + { service: "rest", config: {}, endpoints: { http: { port: FIXED_API_PORT } } }, + studio, + ]); + const studioId = members.find(({ service }) => service === "studio")?.id; + const planStudio = (config: { readonly publicApiUrl?: string }) => + stack.composition.plan([{ ...studio, config }]); + + expect(yield* planStudio({})).toEqual([ + { id: studioId, service: "studio", member: true, change: "unchanged" }, + ]); + expect(yield* planStudio({ publicApiUrl: "https://studio-api.example.test" })).toEqual([ + { + id: studioId, + service: "studio", + member: true, + change: "changed", + paths: ["config.publicApiUrl"], + }, + ]); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("plans a project's own URL for an input whose supplying member is absent", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-api-plan-unbound-" }); + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot: `${root}/cache`, + runtime: "native", + }); + yield* Effect.ensuring( + Effect.gen(function* () { + const external = "postgresql://postgres@external.example.test:5432/postgres"; + const moved = "postgresql://postgres@moved.example.test:5432/postgres"; + const rest = { + service: "rest", + config: { databaseUrl: external }, + endpoints: { http: { port: "auto" } }, + } as const; + const functions = { + service: "functions", + config: { functionsRoot: `${root}/functions`, databaseUrl: external }, + endpoints: { http: { port: "auto" } }, + } as const; + const members = yield* stack.composition.supabase([rest, functions]); + const restId = members.find(({ service }) => service === "rest")?.id; + const functionsId = members.find(({ service }) => service === "functions")?.id; + + expect(yield* stack.composition.plan([rest, functions])).toEqual([ + { id: restId, service: "rest", member: true, change: "unchanged" }, + { id: functionsId, service: "functions", member: true, change: "unchanged" }, + ]); + expect( + yield* stack.composition.plan([ + { ...rest, config: { databaseUrl: moved } }, + { ...functions, config: { ...functions.config, databaseUrl: moved } }, + ]), + ).toEqual([ + { + id: restId, + service: "rest", + member: true, + change: "changed", + paths: ["config.databaseUrl"], + }, + { + id: functionsId, + service: "functions", + member: true, + change: "changed", + paths: ["config.databaseUrl"], + }, + ]); + }), + destroyTestStack(stack), + ); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index dfe5b48fcc..40e09273f6 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -1,67 +1,88 @@ import { Cause, + Context, Crypto, + DateTime, Deferred, Effect, Exit, + FileSystem, Fiber, - Layer, Match, Option, + Path, + Predicate, Ref, Scope, Schema, + Semaphore, Stream, } from "effect"; import { HttpClient } from "effect/unstable/http"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import { RpcClientError } from "effect/unstable/rpc/RpcClientError"; -import { connectHost, launchHost, waitForOwnerExit } from "./HostProcess.ts"; -import type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; +import { + connectHost, + hasReason, + HostProcessError, + launchHost, + observeHost, + ownerClient, + ownerExitProbe, + shutdownHost, + waitForOwnerExit, + type HostAccess, + type HostEndpoint, +} from "./HostProcess.ts"; +import { + planSupabaseComposition, + type PlannedInstance, + type SupabaseCompositionOptions, +} from "./composition/Supabase.ts"; +import { removeStackContainersCommand } from "./runtime/Container.ts"; +import { volumeDataCleanupCommands } from "./storage/DockerDatabaseStorage.ts"; import { deriveStackId, resolveStackIdentity } from "./identity/Identity.ts"; +import { failureMessage } from "./internal/failure-message.ts"; import * as State from "./State.ts"; -import type { SavedStack, StackCredentials, StackIdentityInput } from "./State.ts"; +import type { SavedStack, StackCredentials, StackKeysInput } from "./State.ts"; +import { StackError, type Definition, type Observation } from "./Rpc.ts"; +import { reclaimStack } from "./Sweep.ts"; import { - StackError, - StackErrorSchema, - StackRpc, - type Definition, - type Observation, -} from "./Rpc.ts"; -import { - ServiceCreation as ServiceCreationSchema, ServiceCreationInput as ServiceCreationInputSchema, type ServiceCreation, type ServiceCreationInput as CatalogServiceCreationInput, } from "./services/Catalog.ts"; +import type { SnapshotScope } from "./services/DatabaseSnapshot.ts"; import * as Orchestrator from "./Orchestrator.ts"; -import type { PgProveOptions, PostgresTool } from "./Tools.ts"; -export { - DEFAULT_LOCAL_DATABASE_PASSWORD, - DEFAULT_LOCAL_JWT_SECRET, - DEFAULT_LOCAL_PUBLISHABLE_KEY, - DEFAULT_LOCAL_S3_ACCESS_KEY_ID, - DEFAULT_LOCAL_S3_REGION, - DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, - DEFAULT_LOCAL_SECRET_KEY, - DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, - DEFAULT_POOLER_VAULT_ENCRYPTION_KEY, - DEFAULT_POSTGRES_ROOT_KEY, - DEFAULT_REALTIME_DB_ENCRYPTION_KEY, - DEFAULT_SIGNING_KEY, -} from "./Defaults.ts"; +import type { + InitializationCommand, + PgProveOptions, + PostgresCommand, + CommandInvocation, +} from "./Commands.ts"; -export { postgres } from "./Tools.ts"; +export { initialization, postgres } from "./Commands.ts"; export { resolveNativePostgresUser } from "./runtime/postgres-user.ts"; +export { apiRoute } from "./host/Endpoints.ts"; export { StackError } from "./Rpc.ts"; export type { ServiceCreation } from "./services/Catalog.ts"; +/** A service creation as `services.create` accepts it, before stack credentials fill its inputs. */ export type ServiceCreationInput = CatalogServiceCreationInput; export type { CompositionConfig } from "./Orchestrator.ts"; -export type { SupabaseCompositionOptions } from "./composition/Supabase.ts"; -export type { StackCredentials, StackIdentityInput }; +export type { + CreationChange, + PlannedInstance, + SupabaseCompositionOptions, +} from "./composition/Supabase.ts"; +export { StackIdSchema as StackId } from "./identity/StackId.ts"; +export type { SavedStack } from "./State.ts"; +export type { StackCredentials, StackKeysInput }; export type { Observation } from "./Rpc.ts"; -export type { PgProveOptions } from "./Tools.ts"; +export type { + Command, + InitializationCommand, + PgProveOptions, + PostgresCommand, +} from "./Commands.ts"; const stateFor = (root: string) => State.Service.pipe(Effect.provide(State.layer({ root }))); @@ -75,6 +96,16 @@ export interface CreateOptions extends StackLocations { readonly projectRoot: string; readonly name?: string; readonly runtime: "native" | "docker" | "podman"; + /** + * A `session` stack starts its owner at creation and is destroyed when the creating handle's + * scope closes or its process exits; a `detached` stack (the default) outlives its creator. + */ + readonly lifetime?: State.StackLifetime; + /** + * Starts the owner at creation and lets it register the stack under its lease, so a failed or + * interrupted launch leaves no registration behind. Session stacks always do this. + */ + readonly startOwner?: boolean; } /** Opens a previously registered stack. */ export interface OpenOptions extends StackLocations { @@ -82,16 +113,26 @@ export interface OpenOptions extends StackLocations { readonly startOwner?: boolean; } +/** No owner serves the stack: nothing holds its lease, or a sweeper is cleaning it up. */ +const ownerAbsent = hasReason("not-running", "sweeping"); +/** The stack is no longer registered: it was destroyed or swept. */ +const stackGone = hasReason("unregistered"); + const failure = (operation: string, cause: unknown): StackError => - Schema.is(StackErrorSchema)(cause) - ? new StackError(cause) + Schema.is(StackError)(cause) + ? cause : new StackError({ operation, message: Schema.is(RpcClientError)(cause) ? `Owner response unavailable; the request outcome is uncertain: ${cause.message}` - : cause instanceof Error - ? cause.message - : String(cause), + : failureMessage(cause), + ...(ownerAbsent(cause) || stackGone(cause) + ? { reason: "owner-unavailable" as const } + : hasReason("release-mismatch")(cause) + ? { reason: "release-mismatch" as const } + : hasReason("runtime-unavailable")(cause) + ? { reason: "runtime-unavailable" as const } + : {}), }); type Kind = ServiceCreation["service"]; @@ -107,6 +148,7 @@ export interface ServiceInstance<K extends Kind = Kind> { readonly stop: Effect.Effect<void, StackError>; readonly restart: (input?: ServiceCreationRestartInput<K>) => Effect.Effect<void, StackError>; readonly destroy: Effect.Effect<void, StackError>; + /** Ensures the service artifact or image is available without starting the service. */ readonly prepare: Effect.Effect<void, StackError>; readonly status: Effect.Effect<Observation, StackError>; readonly followStatus: Stream.Stream<Observation, StackError>; @@ -118,10 +160,24 @@ export interface ServiceInstance<K extends Kind = Kind> { readonly from?: "host" | "runtime"; }) => Effect.Effect<Readonly<Record<string, string>>, StackError>; } +/** Snapshot placement; `cache` is the default. */ +export interface DatabaseSnapshotOptions { + /** + * `cache` shares bounded retention with every stack under the cache root and outlives the + * instance; `instance` is never evicted and is removed when the database instance is destroyed. + */ + readonly scope?: SnapshotScope; +} /** A database instance with stopped-data snapshot operations. */ export interface DatabaseInstance extends ServiceInstance<"database"> { - readonly saveSnapshot: (key: string) => Effect.Effect<void, StackError>; - readonly restoreSnapshot: (key: string) => Effect.Effect<boolean, StackError>; + readonly saveSnapshot: ( + key: string, + options?: DatabaseSnapshotOptions, + ) => Effect.Effect<void, StackError>; + readonly restoreSnapshot: ( + key: string, + options?: DatabaseSnapshotOptions, + ) => Effect.Effect<boolean, StackError>; /** Removes database-owned data while preserving the instance registration. */ readonly resetData: Effect.Effect<void, StackError>; } @@ -130,8 +186,21 @@ export type ServiceInstances = { [K in Kind]: K extends "database" ? DatabaseInstance : ServiceInstance<K>; }; type AnyInstance = ServiceInstances[Kind]; -/** An attached finite command with backpressured byte streams. */ -export interface ToolOptions<E, R> { +/** + * The outcome of {@link Stack.destroy}. `skipped` means the stack's registration and host data + * were removed without its container engine, because the engine was unreachable; its containers + * and any database data in engine volumes remain, and `cleanupCommands` remove them once the + * engine is running. + */ +export type DestroyResult = + | { readonly runtimeCleanup: "complete" } + | { + readonly runtimeCleanup: "skipped"; + readonly engine: "docker" | "podman"; + readonly cleanupCommands: ReadonlyArray<string>; + }; +/** Options for streaming PostgreSQL command input and output. */ +export interface PostgresCommandOptions<E, R> { readonly args?: ReadonlyArray<string>; readonly env?: Readonly<Record<string, string>>; readonly pgProve?: PgProveOptions; @@ -139,7 +208,31 @@ export interface ToolOptions<E, R> { readonly stdout: (bytes: Uint8Array) => Effect.Effect<void, E, R>; readonly stderr: (bytes: Uint8Array) => Effect.Effect<void, E, R>; } -/** A client handle; its lifetime does not own service processes. */ +/** Output handlers for a finite service initialization command. */ +export interface InitializationCommandOptions<E, R> { + readonly stdout?: (bytes: Uint8Array) => Effect.Effect<void, E, R>; + readonly stderr?: (bytes: Uint8Array) => Effect.Effect<void, E, R>; +} +interface InternalCommandOptions<E, R> { + readonly args?: ReadonlyArray<string>; + readonly env?: Readonly<Record<string, string>>; + readonly pgProve?: PgProveOptions; + readonly stdin?: Stream.Stream<Uint8Array, E, R>; + readonly stdout?: (bytes: Uint8Array) => Effect.Effect<void, E, R>; + readonly stderr?: (bytes: Uint8Array) => Effect.Effect<void, E, R>; +} +/** Runs a finite PostgreSQL or service initialization command. */ +export interface CommandRunner { + <E, R>( + command: PostgresCommand, + options: PostgresCommandOptions<E, R>, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + <E = never, R = never>( + command: InitializationCommand, + options?: InitializationCommandOptions<E, R>, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; +} +/** A client handle; only the creating handle of a session stack owns its services. */ export interface Stack { readonly id: string; readonly services: { @@ -157,6 +250,13 @@ export interface Stack { services: ReadonlyArray<ServiceCreationInput>, options?: SupabaseCompositionOptions, ) => Effect.Effect<ReadonlyArray<AnyInstance>, StackError>; + /** + * Compares the requested creations with every saved instance of the same kinds, ignoring + * inputs the composition supplies, without changing state or contacting the owner. + */ + readonly plan: ( + services: ReadonlyArray<ServiceCreationInput>, + ) => Effect.Effect<ReadonlyArray<PlannedInstance>, StackError>; readonly configure: (config: Orchestrator.CompositionConfig) => Effect.Effect<void, StackError>; readonly describe: Effect.Effect<Orchestrator.CompositionConfig, StackError>; readonly start: Effect.Effect<ReadonlyArray<Observation>, StackError>; @@ -164,79 +264,340 @@ export interface Stack { readonly restart: Effect.Effect<ReadonlyArray<Observation>, StackError>; }; readonly stop: Effect.Effect<void, StackError>; - readonly destroy: Effect.Effect<void, StackError>; - readonly tools: { - readonly run: <E, R>( - tool: PostgresTool, - options: ToolOptions<E, R>, - ) => Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + readonly destroy: Effect.Effect<DestroyResult, StackError>; + readonly commands: { + readonly run: CommandRunner; }; } -type Client = Effect.Success<ReturnType<typeof clientFor>>; -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); +type Client = Effect.Success<ReturnType<typeof ownerClient>>; + +const causeCode = (cause: unknown): string | undefined => { + if (typeof cause !== "object" || cause === null) return undefined; + if ("code" in cause && typeof cause.code === "string") return cause.code; + if ("cause" in cause) return causeCode(cause.cause); + if ("reason" in cause) return causeCode(cause.reason); + return undefined; +}; +/** + * A refused connection, or a listener that rejects the owner secret, proves the request never + * reached this stack's owner: the owner is gone or another process holds its port. Resending + * after re-resolving the owner is safe. Other transport failures leave the connection in place. + */ +const ownerGone = (cause: unknown) => + Schema.is(RpcClientError)(cause) && + (["ECONNREFUSED", "ConnectionRefused"].includes(causeCode(cause) ?? "") || + (cause.reason._tag === "HttpError" && + Predicate.hasProperty(cause.reason.cause, "response") && + Predicate.hasProperty(cause.reason.cause.response, "status") && + cause.reason.cause.response.status === 401)); + +interface Connection { + readonly access: HostAccess; + readonly rpc: Client; + readonly scope: Scope.Closeable; + /** Calls and streams currently using this client. */ + active: number; + /** Replaced by a newer connection; closes once its last user finishes. */ + retired: boolean; +} +/** Finds a directory below `directory` that the current user cannot empty and delete. */ +const firstUnremovableDirectory = ( + fs: FileSystem.FileSystem, + path: Path.Path, + directory: string, +): Effect.Effect<string | undefined> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(directory).pipe(Effect.option); + const writable = yield* fs.access(directory, { writable: true }).pipe(Effect.isSuccess); + if (Option.isNone(entries) || !writable) return directory; + for (const entry of entries.value) { + const child = path.join(directory, entry); + const info = yield* fs.stat(child).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "Directory") continue; + if (yield* fs.readLink(child).pipe(Effect.isSuccess)) continue; + const blocked = yield* firstUnremovableDirectory(fs, path, child); + if (blocked !== undefined) return blocked; + } + return undefined; + }); + +/** + * Destroys a stack whose owner cannot start because its container engine is unreachable: under + * the stack's lease it removes the registration and host data, and returns the commands that + * remove the containers and engine-volume data left behind. + */ +const destroyWithoutEngine = Effect.fn("Stack.destroyWithoutEngine")(function* ( + state: State.Interface, + saved: SavedStack, + locations: StackLocations, + engine: "docker" | "podman", +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const id = saved.id; + const dataRoot = path.join(locations.stateRoot, id, "data"); + return yield* Effect.scoped( + Effect.gen(function* () { + if (!(yield* state.lease(id))) + return yield* failure( + "destroy", + "An owner for this stack started during destroy; run destroy again", + ); + yield* Effect.addFinalizer(() => state.retractHolder(id).pipe(Effect.ignore)); + yield* state.publishHolder(id, { + role: "sweeper", + pid: process.pid, + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + const current = yield* state.read(id); + // Container-written host data, such as database files below Docker 26, can belong to the + // container user; only the engine can delete it, so refuse before deleting anything. + const blocked = + current !== undefined && (yield* fs.exists(dataRoot)) + ? yield* firstUnremovableDirectory(fs, path, dataRoot) + : undefined; + if (blocked !== undefined) { + const engineName = engine === "docker" ? "Docker" : "Podman"; + return yield* failure( + "destroy", + `Stack data at ${blocked} can only be removed by ${engineName}; start ${engineName} and run destroy again`, + ); + } + // Containers are labelled with the resolved data root the owner ran with. + const root = yield* fs.realPath(dataRoot).pipe(Effect.orElseSucceed(() => dataRoot)); + const cleanupCommands = [ + removeStackContainersCommand({ engine, stackId: id, root }), + ...(yield* volumeDataCleanupCommands({ engine, root, fs, path })), + ]; + if (current !== undefined) { + yield* fs.remove(dataRoot, { recursive: true, force: true }); + yield* state.withLock(state.remove(id)); + } + return { runtimeCleanup: "skipped", engine, cleanupCommands } as const; + }), + ).pipe(Effect.mapError((cause) => failure("destroy", cause))); +}); + +/** `launch` starts an owner when none is live; `attach` requires a live one. */ +type Reach = "launch" | "attach"; const makeHandle = Effect.fn("Stack.makeHandle")(function* ( state: State.Interface, saved: SavedStack, locations: StackLocations, + seed: { readonly access?: HostAccess; readonly creator?: boolean } = {}, ) { - const http = yield* HttpClient.HttpClient; + // Calls and streams release what they borrow in their own scope, not in the handle's. + const services = Context.omit(Scope.Scope)( + yield* Effect.context< + HttpClient.HttpClient | FileSystem.FileSystem | Path.Path | Crypto.Crypto | Scope.Scope + >(), + ); const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const endpointFor = (live: boolean) => - (live - ? launchHost(state, { ...locations, stackId: saved.id }) - : connectHost(state, saved.id) - ).pipe( - Effect.provideService(HttpClient.HttpClient, http), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + const fs = yield* FileSystem.FileSystem; + const handleScope = yield* Scope.Scope; + const session = saved.lifetime === "session"; + const launchOptions = { ...locations, stackId: saved.id, lifeline: session }; + const cached = yield* Ref.make<Connection | undefined>(undefined); + const gate = yield* Semaphore.make(1); + const connectTo = (access: HostAccess) => + Effect.gen(function* () { + const scope = yield* Scope.fork(handleScope, "sequential"); + const rpc = yield* ownerClient(access).pipe(Scope.provide(scope)); + const connection: Connection = { access, rpc, scope, active: 0, retired: false }; + return connection; + }); + const resolve = (reach: Reach) => + // A session stack's owner is spawned only by its creator, which holds the lifeline. + reach === "launch" && (!session || seed.creator === true) + ? launchHost(state, launchOptions) + : connectHost(state, saved.id).pipe( + Effect.mapError((cause) => + session && ownerAbsent(cause) && reach === "launch" + ? new HostProcessError({ + operation: "connect", + message: `Session stack ${saved.id} has no live owner; it ends when its creator exits`, + reason: "not-running", + }) + : cause, + ), + ); + const closeIfIdle = (connection: Connection) => + Effect.suspend(() => + connection.retired && connection.active === 0 + ? Scope.close(connection.scope, Exit.void) + : Effect.void, + ); + /** Stops lending a connection; calls and streams already using it run to completion. */ + const retire = (current: Connection) => + Ref.set(cached, undefined).pipe( + Effect.andThen( + Effect.suspend(() => { + current.retired = true; + return closeIfIdle(current); + }), + ), ); - const client = (live: boolean) => + /** Whether the lease is held by the owner this connection reached, not a successor or sweeper. */ + const stillOwned = ({ access }: Connection) => Effect.gen(function* () { - const endpoint = yield* endpointFor(live); - return yield* clientFor(endpoint.port); - }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + if (!(yield* state.leased(saved.id))) return false; + const holder = yield* state.readHolder(saved.id); + return ( + holder?.role === "owner" && + holder.pid === access.endpoint.pid && + holder.port === access.endpoint.port && + holder.secret === access.secret + ); + }); + const connection = (reach: Reach) => + Effect.gen(function* () { + const existing = yield* Ref.get(cached); + if (existing !== undefined) { + // Another process can bind a departed owner's port, so reuse needs that owner's lease. + if (yield* stillOwned(existing)) return existing; + yield* retire(existing); + } + // A spawned session owner's lifeline belongs to the handle, not to this call. + const access = yield* resolve(reach).pipe(Effect.provideService(Scope.Scope, handleScope)); + return yield* connectTo(access).pipe( + Effect.tap((connected) => Ref.set(cached, connected)), + Effect.uninterruptible, + ); + }); + /** + * Uses the cached connection for the enclosing scope, resolving the owner when there is none. + * Waiting for the gate or the owner stays interruptible. + */ + const borrow = (reach: Reach) => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + return yield* gate.withPermits(1)( + connection(reach).pipe( + Effect.tap((current) => + Effect.sync(() => { + current.active++; + }).pipe( + Effect.andThen( + Scope.addFinalizer( + scope, + Effect.suspend(() => { + current.active--; + return closeIfIdle(current); + }), + ), + ), + Effect.uninterruptible, + ), + ), + ), + ); + }); + /** Drops the cached connection; calls and streams already using it run to completion. */ + const invalidate = (connection?: Connection) => + gate.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(cached); + if (current === undefined || (connection !== undefined && current !== connection)) return; + yield* retire(current); + }), + ); + const dropIfGone = (connection: Connection) => (cause: unknown) => + ownerGone(cause) ? invalidate(connection) : Effect.void; + if (seed.access !== undefined) yield* Ref.set(cached, yield* connectTo(seed.access)); + const invoke = <A, E, R>(run: (rpc: Client) => Effect.Effect<A, E, R>, reach: Reach) => + Effect.scoped( + Effect.gen(function* () { + const current = yield* borrow(reach); + return yield* run(current.rpc).pipe(Effect.tapError(dropIfGone(current))); + }), + ).pipe(Effect.retry({ times: 1, while: ownerGone }), Effect.provideContext(services)); const call = <A, E, R>( operation: string, run: (rpc: Client) => Effect.Effect<A, E, R>, - live = true, + reach: Reach = "launch", + ) => invoke(run, reach).pipe(Effect.mapError((cause) => failure(operation, cause))); + /** Without a live owner, or with a destroyed stack, nothing runs, so the request is already satisfied. */ + const whileRunning = <A, E, R>( + operation: string, + run: (rpc: Client) => Effect.Effect<A, E, R>, + idle: A, ) => - Effect.scoped(Effect.flatMap(client(live), run)).pipe( + invoke(run, "attach").pipe( + Effect.catchIf( + (cause) => ownerAbsent(cause) || stackGone(cause), + () => Effect.succeed(idle), + ), Effect.mapError((cause) => failure(operation, cause)), ); const stream = <A, E>(operation: string, run: (rpc: Client) => Stream.Stream<A, E>) => - Stream.unwrap(Effect.map(client(false), run)).pipe( - Stream.mapError((cause) => failure(operation, cause)), - ); + Stream.unwrap( + borrow("attach").pipe( + Effect.map((current) => run(current.rpc).pipe(Stream.tapError(dropIfGone(current)))), + Effect.provideContext(services), + ), + ).pipe(Stream.mapError((cause) => failure(operation, cause))); const shutdown = Effect.fn("Stack.shutdown")(function* (destroy: boolean) { const operation = destroy ? "destroy" : "shutdown"; - const { endpoint, shutdownExit } = yield* Effect.scoped( + yield* invalidate(); + const engine = saved.runtime === "native" ? undefined : saved.runtime; + // Shutdown uses the release-stable endpoint, so it reaches owners of any release. + const { endpoint, refusal, engineUnavailable } = yield* Effect.scoped( Effect.gen(function* () { - const endpoint = yield* endpointFor(destroy); - const shutdownExit = yield* clientFor(endpoint.port).pipe( - Effect.provideService(HttpClient.HttpClient, http), - Effect.flatMap((rpc) => rpc.shutdown({ destroy })), + const idle = { endpoint: undefined, refusal: Exit.void, engineUnavailable: false }; + const live = yield* connectHost(state, saved.id, { anyRelease: true }).pipe( + Effect.map(Option.some), + Effect.catchIf(ownerAbsent, () => + destroy + ? launchHost(state, launchOptions).pipe(Effect.map(Option.some)) + : Effect.succeed(Option.none<HostAccess>()), + ), + Effect.catchIf(stackGone, () => Effect.succeed(Option.none<HostAccess>())), + // The owner's startup sweep reports an unreachable engine before any owner serves. + Effect.catchIf( + (cause) => engine !== undefined && hasReason("runtime-unavailable")(cause), + () => Effect.succeed("engine-unavailable" as const), + ), + ); + if (live === "engine-unavailable") return { ...idle, engineUnavailable: true }; + if (Option.isNone(live)) return idle; + const access = live.value; + const endpoint = access.endpoint; + const refusal = yield* shutdownHost(access, destroy).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (rejected) => + Effect.fail( + new StackError({ + operation: "shutdown", + message: rejected.message, + ...(rejected.outcomes === undefined ? {} : { outcomes: rejected.outcomes }), + }), + ), + }), + ), Effect.exit, ); - return { endpoint, shutdownExit }; + return { endpoint, refusal, engineUnavailable: false }; }), - ).pipe(Effect.mapError((cause) => failure(operation, cause))); - if (Exit.isFailure(shutdownExit)) { - const shutdownFailure = Option.match(Cause.findErrorOption(shutdownExit.cause), { - onNone: () => failure(operation, Cause.pretty(shutdownExit.cause)), + ).pipe( + Effect.provideContext(services), + Effect.mapError((cause) => failure(operation, cause)), + ); + if (engineUnavailable && engine !== undefined) + return yield* destroyWithoutEngine(state, saved, locations, engine).pipe( + Effect.provideContext(services), + ); + if (endpoint === undefined) return { runtimeCleanup: "complete" } as const; + if (Exit.isFailure(refusal)) { + const shutdownFailure = Option.match(Cause.findErrorOption(refusal.cause), { + onNone: () => failure(operation, Cause.pretty(refusal.cause)), onSome: (cause) => failure(operation, cause), }); if (!destroy) return yield* shutdownFailure; - const exitResult = yield* waitForOwnerExit(endpoint.pid).pipe( + const exitResult = yield* waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( Effect.mapError((cause) => failure("shutdown-exit", cause)), Effect.exit, ); @@ -255,17 +616,20 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( } return yield* shutdownFailure; } - yield* waitForOwnerExit(endpoint.pid).pipe( + yield* waitForOwnerExit(endpoint.pid, ownerExitProbe(fs)).pipe( Effect.mapError((cause) => failure("shutdown-exit", cause)), ); + return { runtimeCleanup: "complete" } as const; }); + const snapshotScope = (options: DatabaseSnapshotOptions | undefined) => + options?.scope === undefined ? {} : { scope: options.scope }; const common = <K extends Kind>(id: string, service: K): ServiceInstance<K> => ({ id, service, start: call("start", (rpc) => rpc.startService({ id })), - ready: call("ready", (rpc) => rpc.readyService({ id }), false), - stop: call("stop", (rpc) => rpc.stopService({ id })), + ready: call("ready", (rpc) => rpc.readyService({ id }), "attach"), + stop: whileRunning("stop", (rpc) => rpc.stopService({ id }), undefined), restart: (input) => input === undefined ? call("restart", (rpc) => rpc.restartService({ id })) @@ -279,7 +643,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( ), destroy: call("destroy", (rpc) => rpc.destroyService({ id })), prepare: call("prepare", (rpc) => rpc.prepareService({ id })), - status: call("status", (rpc) => rpc.status({ id }), false), + status: call("status", (rpc) => rpc.status({ id }), "attach"), followStatus: stream("followStatus", (rpc) => rpc.followStatus({ id })), logs: stream("logs", (rpc) => rpc.logs({ id })), credentials: (options) => @@ -295,9 +659,12 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( case "database": return { ...common(id, "database"), - saveSnapshot: (key) => call("saveSnapshot", (rpc) => rpc.saveSnapshot({ id, key })), - restoreSnapshot: (key) => - call("restoreSnapshot", (rpc) => rpc.restoreSnapshot({ id, key })), + saveSnapshot: (key, options) => + call("saveSnapshot", (rpc) => rpc.saveSnapshot({ id, key, ...snapshotScope(options) })), + restoreSnapshot: (key, options) => + call("restoreSnapshot", (rpc) => + rpc.restoreSnapshot({ id, key, ...snapshotScope(options) }), + ), resetData: call("resetData", (rpc) => rpc.resetData({ id })), }; case "rest": @@ -332,96 +699,147 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( function create(creation: ServiceCreationInput): Effect.Effect<AnyInstance, StackError> { return call("createService", (rpc) => rpc.createService(creation)).pipe(Effect.map(instance)); } - const run = Effect.fn("Stack.runTool")(function* <E, R>( - tool: PostgresTool, - options: ToolOptions<E, R>, + const runInvocation = Effect.fn("Stack.runCommand")(function* <E, R>( + command: CommandInvocation, + stdin: Stream.Stream<Uint8Array, E, R> | undefined, + stdout: (bytes: Uint8Array) => Effect.Effect<void, E, R>, + stderr: (bytes: Uint8Array) => Effect.Effect<void, E, R>, ) { + // A request that never reached this stack's owner is resent once, as `invoke` does. + let resend = false; return yield* Effect.scoped( Effect.gen(function* () { - const rpc = yield* client(true).pipe(Effect.mapError((cause) => failure("tool", cause))); + resend = false; + let started = false; + const current = yield* borrow("launch").pipe( + Effect.provideContext(services), + Effect.mapError((cause) => failure("command", cause)), + ); + const { rpc } = current; const attachmentId = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => failure("tool", cause)), + Effect.mapError((cause) => failure("command", cause)), ); const scope = yield* Scope.Scope; const inputFailure = yield* Deferred.make<never, E | StackError>(); const result = yield* Ref.make<{ jobId: string; exitCode: number } | undefined>(undefined); const sender = yield* Ref.make<Fiber.Fiber<void, E | StackError> | undefined>(undefined); - yield* rpc - .runTool({ - attachmentId, - tool, - args: options.args ?? [], - env: options.env ?? {}, - ...(options.pgProve === undefined ? {} : { pgProve: options.pgProve }), - stdin: options.stdin !== undefined, - }) - .pipe( - Stream.mapError((cause) => failure("tool", cause)), - Stream.runForEach((event): Effect.Effect<void, E | StackError, R> => - Match.valueTags(event, { - Attached: () => - options.stdin === undefined - ? Effect.void - : options.stdin.pipe( - Stream.runForEach((bytes) => - Effect.forEach( - Array.from({ length: Math.ceil(bytes.length / 65536) }, (_, index) => - bytes.subarray(index * 65536, (index + 1) * 65536), - ), - (chunk) => - rpc - .toolInput({ attachmentId, bytes: chunk }) - .pipe(Effect.mapError((cause) => failure("stdin", cause))), - { discard: true }, + const encodedCommand = + command.type === "postgres" && command.pgProve !== undefined + ? { + ...command, + pgProve: { + ...command.pgProve, + mounts: command.pgProve.mounts.map(({ source, target }) => ({ source, target })), + }, + } + : command; + yield* rpc.runCommand({ attachmentId, command: encodedCommand }).pipe( + Stream.tapError((cause) => + ownerGone(cause) && !started + ? invalidate(current).pipe( + Effect.andThen( + Effect.sync(() => { + resend = true; + }), + ), + ) + : Effect.void, + ), + Stream.tap(() => + Effect.sync(() => { + started = true; + }), + ), + Stream.mapError((cause) => failure("command", cause)), + Stream.runForEach((event): Effect.Effect<void, E | StackError, R> => + Match.valueTags(event, { + Attached: () => + stdin === undefined + ? Effect.void + : stdin.pipe( + Stream.runForEach((bytes) => + Effect.forEach( + Array.from({ length: Math.ceil(bytes.length / 65536) }, (_, index) => + bytes.subarray(index * 65536, (index + 1) * 65536), ), + (chunk) => + rpc + .commandInput({ attachmentId, bytes: chunk }) + .pipe(Effect.mapError((cause) => failure("stdin", cause))), + { discard: true }, ), - Effect.andThen( - rpc - .toolInput({ attachmentId, bytes: null }) - .pipe(Effect.mapError((cause) => failure("stdin", cause))), - ), - Effect.catchIf( - (cause) => - Schema.is(StackErrorSchema)(cause) && - cause.operation === "tool-input-closed", - () => Effect.void, - ), - Effect.tapCause((cause) => Deferred.failCause(inputFailure, cause)), - Effect.forkIn(scope), - Effect.flatMap((fiber) => Ref.set(sender, fiber)), ), - Stdout: (output) => options.stdout(output.bytes), - Stderr: (output) => options.stderr(output.bytes), - Completed: (completed) => - Ref.set(result, { jobId: completed.jobId, exitCode: completed.exitCode }), - }), - ), - Effect.raceFirst(Deferred.await(inputFailure)), - ); + Effect.andThen( + rpc + .commandInput({ attachmentId, bytes: null }) + .pipe(Effect.mapError((cause) => failure("stdin", cause))), + ), + Effect.catchIf( + (cause) => + Schema.is(StackError)(cause) && + cause.operation === "command-input-closed", + () => Effect.void, + ), + Effect.tapCause((cause) => Deferred.failCause(inputFailure, cause)), + Effect.forkIn(scope), + Effect.flatMap((fiber) => Ref.set(sender, fiber)), + ), + Stdout: (output) => stdout(output.bytes), + Stderr: (output) => stderr(output.bytes), + Completed: (completed) => + Ref.set(result, { jobId: completed.jobId, exitCode: completed.exitCode }), + }), + ), + Effect.raceFirst(Deferred.await(inputFailure)), + ); const input = yield* Ref.get(sender); if (input !== undefined) yield* Fiber.interrupt(input); const completed = yield* Ref.get(result); if (completed === undefined) - return yield* failure("tool", "Tool attachment ended without an exit result"); + return yield* failure("command", "Command attachment ended without an exit result"); return completed; }), - ); + ).pipe(Effect.retry({ times: 1, while: () => resend })); }); + function run<E, R>( + command: PostgresCommand, + options: PostgresCommandOptions<E, R>, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + function run<E = never, R = never>( + command: InitializationCommand, + options?: InitializationCommandOptions<E, R>, + ): Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | StackError, R>; + function run<E, R>( + command: PostgresCommand | InitializationCommand, + options?: InternalCommandOptions<E, R>, + ) { + if ("type" in command) + return runInvocation( + command, + undefined, + options?.stdout ?? (() => Effect.void), + options?.stderr ?? (() => Effect.void), + ); + return runInvocation( + { + type: "postgres", + command, + args: options?.args ?? [], + env: options?.env ?? {}, + ...(options?.pgProve === undefined ? {} : { pgProve: options.pgProve }), + stdin: options?.stdin !== undefined, + }, + options?.stdin, + options?.stdout ?? (() => Effect.void), + options?.stderr ?? (() => Effect.void), + ); + } const savedDefinition = Effect.gen(function* () { const current = yield* state.read(saved.id); if (current === undefined) return yield* failure("definition", "Stack does not exist"); return current; }).pipe(Effect.mapError((cause) => failure("definition", cause))); - const definitions = savedDefinition.pipe( - Effect.flatMap((current) => - Effect.forEach(current.instances, (entry) => - Schema.decodeUnknownEffect(Schema.toCodecJson(ServiceCreationSchema))(entry.creation).pipe( - Effect.map((creation) => ({ id: entry.id, creation })), - Effect.mapError((cause) => failure("definition", cause)), - ), - ), - ), - ); + const definitions = savedDefinition.pipe(Effect.map((current) => current.instances)); return { id: saved.id, services: { @@ -431,7 +849,7 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( Effect.flatMap((entries) => { const definition = entries.find((entry) => entry.id === id); return definition === undefined - ? Effect.fail(failure("getService", `Unknown service ${id}`)) + ? Effect.fail(failure("service", `Unknown service ${id}`)) : Effect.succeed(instance(definition)); }), ), @@ -452,28 +870,38 @@ const makeHandle = Effect.fn("Stack.makeHandle")(function* ( rpc.supabaseComposition({ services, ...(options?.reuseIds === undefined ? {} : { reuseIds: options.reuseIds }), - ...(options?.identity === undefined ? {} : { identity: options.identity }), + ...(options?.keys === undefined ? {} : { keys: options.keys }), + ...(options?.eager === undefined ? {} : { eager: options.eager }), }), ).pipe(Effect.map((definitions) => definitions.map(instance))), + plan: (services: ReadonlyArray<ServiceCreationInput>) => + Effect.forEach(services, (service) => + Schema.decodeEffect(ServiceCreationInputSchema)(service), + ).pipe( + Effect.mapError((cause) => failure("plan", cause)), + Effect.flatMap((requested) => + savedDefinition.pipe( + Effect.map((current) => planSupabaseComposition(current, requested)), + ), + ), + ), configure: (config: Orchestrator.CompositionConfig) => call("configureComposition", (rpc) => rpc.configureComposition(config)), - describe: savedDefinition.pipe( - Effect.flatMap((current) => - Schema.decodeUnknownEffect(Orchestrator.CompositionConfig)(current.composition), - ), - Effect.mapError((cause) => failure("getComposition", cause)), - ), + describe: savedDefinition.pipe(Effect.map((current) => current.composition)), start: call("startComposition", (rpc) => rpc.startComposition()), - stop: call("stopComposition", (rpc) => rpc.stopComposition()), + stop: whileRunning("stopComposition", (rpc) => rpc.stopComposition(), []), restart: call("restartComposition", (rpc) => rpc.restartComposition()), }, - stop: shutdown(false), + stop: shutdown(false).pipe(Effect.asVoid), destroy: shutdown(true), - tools: { run }, + commands: { run }, } satisfies Stack; }); -/** Registers a stack; a matching existing identity must be opened explicitly. */ +/** + * Registers a stack; a matching existing identity must be opened explicitly. The handle's + * connections, and a session stack itself, last until the enclosing scope closes. + */ export const create = Effect.fn("Stack.create")( function* (options: CreateOptions) { const state = yield* stateFor(options.stateRoot); @@ -482,11 +910,36 @@ export const create = Effect.fn("Stack.create")( const saved: SavedStack = { id, identity, + lifetime: options.lifetime ?? "detached", runtime: options.runtime, instances: [], composition: { members: [], dependencies: [] }, ports: [], }; + const locations = { stateRoot: options.stateRoot, cacheRoot: options.cacheRoot }; + const existing = yield* state.read(id); + // A session stack whose owner is gone is disposable, so its identity is free again. + if (existing?.lifetime === "session" && !(yield* state.leased(id))) + yield* reclaimStack({ + state, + stateRoot: options.stateRoot, + cacheRoot: options.cacheRoot, + id, + }); + const session = saved.lifetime === "session"; + if (session || options.startOwner === true) { + if ((yield* state.read(id)) !== undefined) + return yield* failure("create", "Stack already exists; use open"); + // The owner registers the stack under its lease and removes it if its startup fails, so no + // sweep sees a session stack unowned and a failed launch leaves no registration behind. + const access = yield* launchHost(state, { + ...locations, + stackId: id, + register: saved, + lifeline: session, + }); + return yield* makeHandle(state, saved, locations, { access, creator: true }); + } yield* state.withLock( Effect.gen(function* () { if ((yield* state.read(id)) !== undefined) @@ -494,7 +947,7 @@ export const create = Effect.fn("Stack.create")( yield* state.save(saved); }), ); - return yield* makeHandle(state, saved, options); + return yield* makeHandle(state, saved, locations); }, Effect.mapError((cause) => failure("create", cause)), ); @@ -505,14 +958,18 @@ export const open = Effect.fn("Stack.open")( const state = yield* stateFor(options.stateRoot); const saved = yield* state.read(options.id); if (saved === undefined) return yield* failure("open", "Stack does not exist"); - if (options.startOwner) - yield* Effect.scoped(launchHost(state, { ...options, stackId: saved.id })); - return yield* makeHandle(state, saved, options); + const locations = { stateRoot: options.stateRoot, cacheRoot: options.cacheRoot }; + const access = !options.startOwner + ? undefined + : saved.lifetime === "session" + ? yield* connectHost(state, saved.id) + : yield* launchHost(state, { ...locations, stackId: saved.id }); + return yield* makeHandle(state, saved, locations, { access }); }, Effect.mapError((cause) => failure("open", cause)), ); -/** Lists saved resources separately from the availability of their live owners. */ +/** Lists readable saved stacks with their live owners; `onInvalidState` observes skipped entries. */ export const discover = Effect.fn("Stack.discover")( function* ( options: Pick<StackLocations, "stateRoot"> & { @@ -525,12 +982,35 @@ export const discover = Effect.fn("Stack.discover")( ), ); const saved = yield* state.list; - return yield* Effect.forEach(saved, (definition) => - connectHost(state, definition.id).pipe( - Effect.map((host) => ({ definition, host })), - Effect.catchTag("HostProcessError", () => Effect.succeed({ definition, host: undefined })), - ), + return yield* Effect.forEach( + saved, + (definition) => + observeHost(state, definition).pipe(Effect.map((host) => ({ definition, host }))), + { concurrency: 8 }, ); }, Effect.mapError((cause) => failure("discover", cause)), ); + +/** Selects a saved stack by id, or by the identity a project root and stack name derive. */ +export type FindOptions = Pick<StackLocations, "stateRoot"> & + ({ readonly id: string } | { readonly projectRoot: string; readonly name?: string }); + +/** A saved stack with the endpoint of the live owner holding its lease, if any. */ +export interface FoundStack { + readonly definition: SavedStack; + readonly host: HostEndpoint | undefined; +} + +/** Reads the one saved stack a selection names; unreadable state fails instead of being skipped. */ +export const find = Effect.fn("Stack.find")( + function* (options: FindOptions) { + const state = yield* stateFor(options.stateRoot); + const id = + "id" in options ? options.id : yield* deriveStackId(yield* resolveStackIdentity(options)); + const definition = yield* state.read(id); + if (definition === undefined) return Option.none<FoundStack>(); + return Option.some({ definition, host: yield* observeHost(state, definition) }); + }, + Effect.mapError((cause) => failure("find", cause)), +); diff --git a/packages/stack/src/functions/generated/serve-main-bundle.ts b/packages/stack/src/functions/generated/serve-main-bundle.ts new file mode 100644 index 0000000000..9514b4407e --- /dev/null +++ b/packages/stack/src/functions/generated/serve-main-bundle.ts @@ -0,0 +1,3 @@ +// Generated by packages/stack/scripts/generate-functions-bootstrap.ts; run `pnpm generate`. +/** Bundled Edge Runtime main service used when a Functions creation has no bootstrap. */ +export const defaultFunctionsBootstrap = "var m2=Object.defineProperty;var uo=(e,t)=>{for(var n in t)m2(e,n,{get:t[n],enumerable:!0})};var K=(e,t)=>{switch(t.length){case 0:return e;case 1:return t[0](e);case 2:return t[1](t[0](e));case 3:return t[2](t[1](t[0](e)));case 4:return t[3](t[2](t[1](t[0](e))));case 5:return t[4](t[3](t[2](t[1](t[0](e)))));case 6:return t[5](t[4](t[3](t[2](t[1](t[0](e))))));case 7:return t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))));case 8:return t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e))))))));case 9:return t[8](t[7](t[6](t[5](t[4](t[3](t[2](t[1](t[0](e)))))))));default:{let n=e;for(let r=0,o=t.length;r<o;r++)n=t[r](n);return n}}},h2={pipe(){return K(this,arguments)}},di=(function(){function e(){}return e.prototype=h2,e})();var l=function(e,t){if(typeof e==\"function\")return function(){return e(arguments)?t.apply(this,arguments):n=>t(n,...arguments)};switch(e){case 0:case 1:throw new RangeError(`Invalid arity ${e}`);case 2:return function(n,r){return arguments.length>=2?t(n,r):function(o){return t(o,n)}};case 3:return function(n,r,o){return arguments.length>=3?t(n,r,o):function(s){return t(s,n,r)}};default:return function(){if(arguments.length>=e)return t.apply(this,arguments);let n=arguments;return function(r){return t(r,...n)}}}};var _=e=>e;var Le=e=>()=>e,cn=Le(!0),Cu=Le(!1),Cg=Le(null),fo=Le(void 0),_r=fo;function wd(e,...t){return K(e,t)}function Td(e,t,n,r,o,s,i,a,c){switch(arguments.length){case 1:return e;case 2:return function(){return t(e.apply(this,arguments))};case 3:return function(){return n(t(e.apply(this,arguments)))};case 4:return function(){return r(n(t(e.apply(this,arguments))))};case 5:return function(){return o(r(n(t(e.apply(this,arguments)))))};case 6:return function(){return s(o(r(n(t(e.apply(this,arguments))))))};case 7:return function(){return i(s(o(r(n(t(e.apply(this,arguments)))))))};case 8:return function(){return a(i(s(o(r(n(t(e.apply(this,arguments))))))))};case 9:return function(){return c(a(i(s(o(r(n(t(e.apply(this,arguments)))))))))}}}function It(e){let t=new WeakMap;return n=>{let r=t.get(n);if(r!==void 0)return r;let o=e(n);return t.set(n,o),o}}function pi(e){let t=new WeakMap;return n=>{let r=t.get(n);if(r!==void 0)return r;let o=e(n);return t.set(n,o),t.set(o,o),o}}var Ou=e=>{let t=new Set(Reflect.ownKeys(e));if(e.constructor===Object)return t;e instanceof Error&&t.delete(\"stack\");let n=Object.getPrototypeOf(e),r=n;for(;r!==null&&r!==Object.prototype;){let o=Reflect.ownKeys(r);for(let s=0;s<o.length;s++)t.add(o[s]);r=Object.getPrototypeOf(r)}return t.has(\"constructor\")&&typeof e.constructor==\"function\"&&n===e.constructor.prototype&&t.delete(\"constructor\"),t},ja=new WeakSet;function $n(e){return typeof e==\"string\"}function ku(e){return typeof e==\"number\"}function b0(e){return typeof e==\"boolean\"}function S0(e){return typeof e==\"bigint\"}function Og(e){return typeof e==\"symbol\"}function vd(e){return $n(e)||ku(e)||Og(e)}function un(e){return typeof e==\"function\"}function E0(e){return e===void 0}function Lt(e){return e!==void 0}function Ad(e){return e!=null}function I0(e){return!1}function kg(e){return!0}function wt(e){return typeof e==\"object\"&&e!==null&&!Array.isArray(e)}function Ru(e){return typeof e==\"object\"&&e!==null||un(e)}var M=l(2,(e,t)=>Ru(e)&&t in e),$t=l(2,(e,t)=>M(e,\"_tag\")&&e._tag===t);function w0(e){return e instanceof Error}function $o(e){return M(e,Symbol.iterator)||$n(e)}var be=\"~effect/interfaces/Hash\",H=e=>{switch(typeof e){case\"number\":return On(e);case\"bigint\":return Ue(e.toString(10));case\"boolean\":return Ue(String(e));case\"symbol\":return Ue(String(e));case\"string\":return Ue(e);case\"undefined\":return Ue(\"undefined\");case\"function\":case\"object\":{if(e===null)return Ue(\"null\");if(e instanceof Date)return Number.isNaN(e.getTime())?Ue(\"Invalid Date\"):Ue(e.toISOString());if(e instanceof RegExp)return Ue(e.toString());{if(ja.has(e))return Cd(e);if(_g.has(e))return _g.get(e);let t=b2(e,()=>g2(e)?e[be]():typeof e==\"function\"?Cd(e):e instanceof DataView?Ba(new Uint8Array(e.buffer,e.byteOffset,e.byteLength)):Array.isArray(e)||ArrayBuffer.isView(e)?Ba(e):e instanceof Map?x2(e):e instanceof Set?y2(e):Fg(e));return _g.set(e,t),t}}default:throw new Error(`BUG: unhandled typeof ${typeof e} - please report an issue at https://github.com/Effect-TS/effect/issues`)}},Cd=e=>(Rg.has(e)||Rg.set(e,On(Math.floor(Math.random()*Number.MAX_SAFE_INTEGER))),Rg.get(e)),it=l(2,(e,t)=>e*53^t),Ts=e=>e&3221225471|e>>>1&1073741824,g2=e=>M(e,be),On=e=>{if(e!==e)return Ue(\"NaN\");if(e===1/0)return Ue(\"Infinity\");if(e===-1/0)return Ue(\"-Infinity\");let t=e|0;for(t!==e&&(t^=e*4294967295);e>4294967295;)t^=e/=4294967295;return Ts(t)},Ue=e=>{let t=5381,n=e.length;for(;n;)t=t*33^e.charCodeAt(--n);return Ts(t)},Pg=(e,t)=>{let n=12289;for(let r of t)n^=it(H(r),H(e[r]));return Ts(n)},Fg=e=>Pg(e,Ou(e)),Ug=(e,t)=>n=>{let r=e;for(let o of n)r^=t(o);return Ts(r)},Ba=Ug(6151,H),x2=Ug(Ue(\"Map\"),([e,t])=>it(H(e),H(t))),y2=Ug(Ue(\"Set\"),H),Rg=new WeakMap,_g=new WeakMap,Mg=new WeakSet;function b2(e,t){if(Mg.has(e))return Ue(\"[Circular]\");Mg.add(e);let n=t();return Mg.delete(e),n}var Se=\"~effect/interfaces/Equal\";function B(){return arguments.length===1?e=>mi(e,arguments[0]):mi(arguments[0],arguments[1])}function mi(e,t){if(e===t)return!0;if(e==null||t==null)return!1;let n=typeof e;return n!==typeof t?!1:n===\"number\"&&e!==e&&t!==t?!0:n!==\"object\"&&n!==\"function\"||ja.has(e)||ja.has(t)?!1:I2(e,t,E2)}function S2(e,t,n){let r=Dg.has(e),o=Ng.has(t);if(r&&o)return!0;if(r||o)return!1;Dg.add(e),Ng.add(t);let s=n();return Dg.delete(e),Ng.delete(t),s}var Dg=new WeakSet,Ng=new WeakSet;function E2(e,t){if(H(e)!==H(t))return!1;if(e instanceof Date){if(!(t instanceof Date))return!1;let s=e.getTime(),i=t.getTime();return s===i||Number.isNaN(s)&&Number.isNaN(i)}else if(e instanceof RegExp)return t instanceof RegExp?e.toString()===t.toString():!1;let n=v0(e),r=v0(t);if(n!==r)return!1;let o=n&&r;return typeof e==\"function\"&&!o?!1:S2(e,t,()=>{if(o)return e[Se](t);if(Array.isArray(e))return!Array.isArray(t)||e.length!==t.length?!1:w2(e,t);if(ArrayBuffer.isView(e)){let s=e instanceof DataView;if(!ArrayBuffer.isView(t)||e.byteLength!==t.byteLength||s!==t instanceof DataView)return!1;if(s){let i=t;return T0(new Uint8Array(e.buffer,e.byteOffset,e.byteLength),new Uint8Array(i.buffer,i.byteOffset,i.byteLength))}return T0(e,t)}else{if(e instanceof Map)return!(t instanceof Map)||e.size!==t.size?!1:v2(e,t);if(e instanceof Set)return!(t instanceof Set)||e.size!==t.size?!1:A2(e,t)}return T2(e,t)})}function I2(e,t,n){let r=Od.get(e);if(!r)r=new WeakMap,Od.set(e,r);else if(r.has(t))return r.get(t);let o=n(e,t);r.set(t,o);let s=Od.get(t);return s||(s=new WeakMap,Od.set(t,s)),s.set(e,o),o}var Od=new WeakMap;function w2(e,t){for(let n=0;n<e.length;n++)if(!mi(e[n],t[n]))return!1;return!0}function T0(e,t){if(e.length!==t.length)return!1;for(let n=0;n<e.length;n++)if(e[n]!==t[n])return!1;return!0}function T2(e,t){let n=Ou(e),r=Ou(t);if(n.size!==r.size)return!1;for(let o of n)if(!r.has(o)||!mi(e[o],t[o]))return!1;return!0}function kd(e,t){return function(r,o){let s=Array.from(o);for(let[i,a]of r){let c=!1;for(let u=0;u<s.length;u++){let[f,d]=s[u];if(e(i,f)&&t(a,d)){s[u]=s[s.length-1],s.pop(),c=!0;break}}if(!c)return!1}return!0}}var v2=kd(mi,mi);function Rd(e){return function(n,r){let o=Array.from(r);for(let s of n){let i=!1;for(let a=0;a<o.length;a++){let c=o[a];if(e(s,c)){o[a]=o[o.length-1],o.pop(),i=!0;break}}if(!i)return!1}return!0}}var A2=Rd(mi),v0=e=>M(e,Se),Lg=()=>B;var $g=e=>(ja.add(e),e);function or(e,t,n){return{combine:e,initialValue:t,combineAll:n??(r=>{let o=t;for(let s of r)o=e(o,s);return o})}}var Rt=e=>(t,n)=>t===n||e(t,n),C2=(e,t)=>e===t,A0=()=>C2;function C0(e){return Rt((t,n)=>{if(t.length!==n.length)return!1;for(let r=0;r<t.length;r++)if(!e(t[r],n[r]))return!1;return!0})}var jg=e=>e.length>0;var _d=e=>l(3,(t,n,r)=>e(t,o=>({...o,[n]:r(o)}))),Md=e=>l(2,(t,n)=>e(t,r=>({[n]:r}))),Pd=(e,t)=>l(3,(n,r,o)=>t(n,s=>e(o(s),i=>({...s,[r]:i}))));function F(e,t,n){t===\"__proto__\"?Object.defineProperty(e,t,{value:n,writable:!0,enumerable:!0,configurable:!0}):e[t]=n}function Fd(e,t){for(let n of Reflect.ownKeys(t))Object.prototype.propertyIsEnumerable.call(t,n)&&F(e,n,t[n])}var ye={};uo(ye,{Do:()=>IL,all:()=>fL,andThen:()=>oL,as:()=>J0,asVoid:()=>nL,bind:()=>EL,bindTo:()=>bL,composeK:()=>cL,contains:()=>xL,containsWith:()=>Y0,exists:()=>yL,filter:()=>Ci,filterMap:()=>pL,firstSomeOf:()=>eL,flatMap:()=>vi,flatMapNullishOr:()=>sL,flatten:()=>Ai,fromIterable:()=>G2,fromNullOr:()=>Qg,fromNullishOr:()=>qu,fromUndefinedOr:()=>Yg,gen:()=>wL,getFailure:()=>Z2,getOrElse:()=>Bu,getOrNull:()=>Hd,getOrThrow:()=>ex,getOrThrowWith:()=>Xg,getOrUndefined:()=>ks,getSuccess:()=>V2,isNone:()=>ae,isOption:()=>ec,isSome:()=>_e,let:()=>SL,lift2:()=>hL,liftNullishOr:()=>tL,liftPredicate:()=>gL,liftThrowable:()=>Rs,makeCombinerFailFast:()=>Q0,makeEquivalence:()=>tx,makeOrder:()=>mL,makeReducer:()=>TL,makeReducerFailFast:()=>vL,map:()=>Bt,match:()=>fr,none:()=>R,orElse:()=>Y2,orElseResult:()=>X2,orElseSome:()=>Q2,partitionMap:()=>dL,product:()=>G0,productMany:()=>uL,reduceCompact:()=>lL,some:()=>k,tap:()=>K0,toArray:()=>Z0,toRefinement:()=>K2,void:()=>rL,zipLeft:()=>aL,zipRight:()=>iL,zipWith:()=>V0});function Ud(e){return{combine:e}}var _u=Symbol.for(\"~effect/Redactable\"),k2=e=>M(e,_u);function za(e){return k2(e)?qg(e):e}function qg(e){return e[_u](globalThis[Wa]?.context??R2)}var Wa=\"~effect/Fiber/currentFiber\",k0=new Map,R2={\"~effect/Context\":{},base:k0,depth:0,mapUnsafe:k0,pipe(){return K(this,arguments)}};function N(e,t){let n=t?.space??0,r=new WeakSet,o=n?typeof n==\"number\"?\" \".repeat(n):n:\"\",s=u=>o.repeat(u),i=(u,f)=>{let d=u?.constructor;return d&&d!==Object.prototype.constructor&&d.name?`${d.name}(${f})`:f},a=u=>{try{return Reflect.ownKeys(u)}catch{return[\"[ownKeys threw]\"]}};function c(u,f=0){if(typeof u==\"string\")return JSON.stringify(u);if(typeof u==\"number\"||u==null||typeof u==\"boolean\"||typeof u==\"symbol\")return String(u);if(typeof u==\"bigint\")return String(u)+\"n\";if(typeof u==\"object\"||typeof u==\"function\"){if(r.has(u))return M2;r.add(u);let d;if(_u in u)d=c(qg(u),f);else if(Array.isArray(u))d=!o||u.length<=1?`[${u.map(p=>c(p,f)).join(\",\")}]`:`[\n${s(f+1)}${u.map(p=>c(p,f+1)).join(`,\n`+s(f+1))}\n${s(f)}]`;else if(u instanceof Date)d=zg(u);else if(!t?.ignoreToString&&M(u,\"toString\")&&typeof u.toString==\"function\"&&u.toString!==Object.prototype.toString&&u.toString!==Array.prototype.toString){let p=P2(u);d=u instanceof Error&&u.cause?`${p} (cause: ${c(u.cause,f)})`:p}else if(Symbol.iterator in u)d=`${u.constructor.name}(${c(Array.from(u),f)})`;else{let p=a(u);if(!o||p.length<=1){let m=`{${p.map(g=>`${Bo(g)}:${c(u[g],f)}`).join(\",\")}}`;d=i(u,m)}else{let m=`{\n${p.map(g=>`${s(f+1)}${Bo(g)}: ${c(u[g],f+1)}`).join(`,\n`)}\n${s(f)}}`;d=i(u,m)}}return r.delete(u),d}return String(u)}return c(e,0)}var M2=\"[Circular]\";function Bo(e){return typeof e==\"string\"?JSON.stringify(e):String(e)}function Dd(e){return e.map(t=>`[${Bo(t)}]`).join(\"\")}function zg(e){try{return e.toISOString()}catch{return\"Invalid Date\"}}function P2(e){try{let t=e.toString();return typeof t==\"string\"?t:String(t)}catch{return\"[toString threw]\"}}function hi(e,t){let n=[];return JSON.stringify(e,function(r,o){let s=Object.getOwnPropertyDescriptor(this,r)?.value,i=M(s,_u)?za(s):za(o);if(typeof i==\"bigint\")return N(i);if(typeof i!=\"object\"||i===null)return i;for(;n.length>0&&n[n.length-1]!==this;)n.pop();if(!n.includes(i))return n.push(i),i},t?.space)??\"null\"}var Qe=Symbol.for(\"nodejs.util.inspect.custom\"),ht=e=>{try{return e=za(e),M(e,\"toJSON\")&&un(e.toJSON)&&e.toJSON.length===0?e.toJSON():Array.isArray(e)?e.map(ht):e}catch{return\"[toJSON threw]\"}},_0=(e,t=2)=>{if(typeof e==\"string\")return e;try{return typeof e==\"object\"?hi(e,{space:t}):N(e,{space:t})}catch{return String(e)}},jQ={toJSON(){return ht(this)},[Qe](){return this.toJSON()},toString(){return N(this.toJSON())}},R0=class{[Qe](){return this.toJSON()}toString(){return N(this.toJSON())}};var vs=class e{called=!1;self;constructor(t){this.self=t}next(t){return this.called?{value:t,done:!0}:(this.called=!0,{value:this.self,done:!1})}[Symbol.iterator](){return new e(this.self)}},U2=()=>{let e=\"~effect/Utils/internal\",t={[e]:o=>o()},n={[e]:o=>o()};return t[e](()=>new Error().stack)?.includes(e)===!0?t[e]:n[e]},we=U2();var po=\"~effect/Effect\",gi=\"~effect/Exit\",N2={_A:_,_E:_,_R:_},F0=`${po}/identifier`,Z=`${po}/args`,at=`${po}/evaluate`,fn=`${po}/successCont`,sr=`${po}/failureCont`,As=`${po}/ensureCont`,bi=Symbol.for(\"effect/Effect/Yield\"),ln={pipe(){return K(this,arguments)},toJSON(){return{...this}},toString(){return N(this.toJSON(),{ignoreToString:!0,space:2})},[Qe](){return this.toJSON()}},U0={[be](){return Pg(this,Object.keys(this))},[Se](e){let t=Object.keys(this),n=Object.keys(e);if(t.length!==n.length)return!1;for(let r=0;r<t.length;r++)if(t[r]!==n[r]||!B(this[t[r]],e[t[r]]))return!1;return!0}},L2={[po]:N2,...ln,[Symbol.iterator](){return new vs(this)},toJSON(){return{_id:\"Effect\",op:this[F0],...Z in this?{args:this[Z]}:void 0}}},ee=e=>M(e,po),Wg=e=>M(e,gi),Ha=\"~effect/Cause\",Ja=\"~effect/Cause/Reason\",Ga=e=>M(e,Ha),D0=e=>M(e,Ja),lo=class{[Ha];reasons;constructor(t){this[Ha]=Ha,this.reasons=t}pipe(){return K(this,arguments)}toJSON(){return{_id:\"Cause\",failures:this.reasons.map(t=>t.toJSON())}}toString(){return`Cause(${N(this.reasons)})`}[Qe](){return this.toJSON()}[Se](t){return Ga(t)&&this.reasons.length===t.reasons.length&&this.reasons.every((n,r)=>B(n,t.reasons[r]))}[be](){return Ba(this.reasons)}},P0=new WeakMap,Ka=class{[Ja];annotations;_tag;constructor(t,n,r){if(this[Ja]=Ja,this._tag=t,n!==Fu&&typeof r==\"object\"&&r!==null&&n.size>0){let o=P0.get(r);o&&(n=new Map([...o,...n])),P0.set(r,n)}this.annotations=n}annotate(t,n){if(t.mapUnsafe.size===0)return this;let r=new Map(this.annotations);t.mapUnsafe.forEach((s,i)=>{n?.overwrite!==!0&&r.has(i)||r.set(i,s)});let o=Object.assign(Object.create(Object.getPrototypeOf(this)),this);return o.annotations=r,o}pipe(){return K(this,arguments)}toString(){return N(this)}[Qe](){return this.toString()}},Fu=new Map,xi=class extends Ka{error;constructor(t,n=Fu){super(\"Fail\",n,t),this.error=t}toString(){return`Fail(${N(this.error)})`}toJSON(){return{_tag:\"Fail\",error:this.error}}[Se](t){return Cs(t)&&B(this.error,t.error)&&B(this.annotations,t.annotations)}[be](){return it(Ue(this._tag))(it(H(this.error))(H(this.annotations)))}},qo=e=>new lo(e),Nd=new lo([]),Va=e=>new lo([new xi(e)]),Mu=class extends Ka{defect;constructor(t,n=Fu){super(\"Die\",n,t),this.defect=t}toString(){return`Die(${N(this.defect)})`}toJSON(){return{_tag:\"Die\",defect:this.defect}}[Se](t){return Si(t)&&B(this.defect,t.defect)&&B(this.annotations,t.annotations)}[be](){return it(Ue(this._tag))(it(H(this.defect))(H(this.annotations)))}},Hg=e=>new lo([new Mu(e)]),Za=l(e=>Ga(e[0]),(e,t,n)=>t.mapUnsafe.size===0?e:new lo(e.reasons.map(r=>r.annotate(t,n)))),Cs=e=>e._tag===\"Fail\",Si=e=>e._tag===\"Die\",Ei=e=>e._tag===\"Interrupt\";function $2(e){return ar(\"Effect.evaluate: Not implemented\")}var zo=e=>({...L2,[F0]:e.op,[at]:e[at]??$2,[fn]:e[fn],[sr]:e[sr],[As]:e[As]}),ir=e=>{let t=zo(e);return function(){let n=Object.create(t);return n[Z]=e.single===!1?arguments:arguments[0],n}},N0=e=>{let t={[gi]:gi,_tag:e.op,get[e.prop](){return this[Z]},...zo(e),toString(){return`${e.op}(${N(this[Z])})`},toJSON(){return{_id:\"Exit\",_tag:e.op,[e.prop]:this[Z]}},[Se](n){return Wg(n)&&n._tag===this._tag&&B(this[Z],n[Z])},[be](){return it(Ue(e.op),H(this[Z]))}};return function(n){let r=Object.create(t);return r[Z]=n,r}},Oe=N0({op:\"Success\",prop:\"value\",[at](e){let t=e.getCont(fn);return t?t[fn](this[Z],e,this):e.yieldWith(this)}}),Ld={key:\"effect/Cause/StackTrace\"},Jg={key:\"effect/Cause/InterruptorStackTrace\"},Mt=N0({op:\"Failure\",prop:\"cause\",[at](e){let t=this[Z],n=!1;e.currentStackFrame&&(t=Za(t,{mapUnsafe:new Map([[Ld.key,e.currentStackFrame]])}),n=!0);let r=e.getCont(sr);for(;e.interruptible&&e._interruptedCause&&r;)r=e.getCont(sr);return r?r[sr](t,e,n?void 0:this):e.yieldWith(n?Mt(t):this)}}),jn=e=>Mt(Va(e)),ar=e=>Mt(Hg(e)),Y=ir({op:\"WithFiber\",[at](e){return this[Z](e)}}),j2=(function(){class e extends globalThis.Error{}let t=zo({op:\"YieldableError\",[at](){return jn(this)}});return delete t.toString,Object.assign(e.prototype,t),e})(),Uu=(function(){let e=Symbol.for(\"effect/Data/Error/plainArgs\");return class extends j2{constructor(n){super(n?.message,n?.cause?{cause:n.cause}:void 0),n&&(Fd(this,n),Object.defineProperty(this,e,{value:n,enumerable:!1}))}toJSON(){return{...this[e],...this}}}})(),Wo=e=>{class t extends Uu{_tag=e}return t.prototype.name=e,t},Pu=\"~effect/Cause/NoSuchElementError\",$d=e=>M(e,Pu),Mr=class extends Wo(\"NoSuchElementError\"){[Pu]=Pu;constructor(t){super({message:t})}},yi=\"~effect/Cause/Done\",Du=e=>M(e,yi),L0={[yi]:yi,_tag:\"Done\",value:void 0},Ii=e=>e===void 0?L0:{[yi]:yi,_tag:\"Done\",value:e},B2=jn(L0),$0=e=>e===void 0?B2:jn(Ii(e));var j0=\"~effect/data/Option\",B0={[j0]:{_A:e=>e},...ln,[Symbol.iterator](){return new vs(this)}},q2=Object.defineProperty(Object.assign(Object.create(B0),{_tag:\"Some\",_op:\"Some\",[Se](e){return jd(e)&&Kg(e)&&B(this.value,e.value)},[be](){return it(H(this._tag))(H(this.value))},toString(){return`some(${N(this.value)})`},toJSON(){return{_id:\"Option\",_tag:this._tag,value:ht(this.value)}}}),\"valueOrUndefined\",{get(){return this.value}}),z2=H(\"None\"),W2=Object.assign(Object.create(B0),{_tag:\"None\",_op:\"None\",valueOrUndefined:void 0,[Se](e){return jd(e)&&Bd(e)},[be](){return z2},toString(){return\"none()\"},toJSON(){return{_id:\"Option\",_tag:this._tag}}}),jd=e=>M(e,j0),Bd=e=>e._tag===\"None\",Kg=e=>e._tag===\"Some\",Nu=Object.create(W2),Lu=e=>{let t=Object.create(q2);return t.value=e,t};var z0=\"~effect/data/Result\",W0={[z0]:{_A:e=>e,_E:e=>e},...ln,[Symbol.iterator](){return new vs(this)}},H2=Object.assign(Object.create(W0),{_tag:\"Success\",_op:\"Success\",[Se](e){return qd(e)&&Qa(e)&&B(this.success,e.success)},[be](){return it(H(this._tag))(H(this.success))},toString(){return`success(${N(this.success)})`},toJSON(){return{_id:\"Result\",_tag:this._tag,value:ht(this.success)}}}),J2=Object.assign(Object.create(W0),{_tag:\"Failure\",_op:\"Failure\",[Se](e){return qd(e)&&Ya(e)&&B(this.failure,e.failure)},[be](){return it(H(this._tag))(H(this.failure))},toString(){return`failure(${N(this.failure)})`},toJSON(){return{_id:\"Result\",_tag:this._tag,failure:ht(this.failure)}}}),qd=e=>M(e,z0),Ya=e=>e._tag===\"Failure\",Qa=e=>e._tag===\"Success\",zd=e=>{let t=Object.create(J2);return t.failure=e,t},Wd=e=>{let t=Object.create(H2);return t.success=e,t},Gg=e=>Qa(e)?Nu:Lu(e.failure),Vg=e=>Ya(e)?Nu:Lu(e.success);function Ho(e){return(t,n)=>t===n?0:e(t,n)}var Bn=Ho((e,t)=>globalThis.Number.isNaN(e)&&globalThis.Number.isNaN(t)?0:globalThis.Number.isNaN(e)?-1:globalThis.Number.isNaN(t)?1:e<t?-1:1);var ur=Ho((e,t)=>e<t?-1:1);var Zg=l(2,(e,t)=>Ho((n,r)=>e(t(n),t(r)))),wi=Zg(Bn,e=>e.getTime());var Xa=e=>l(2,(t,n)=>e(t,n)===-1),Os=e=>l(2,(t,n)=>e(t,n)===1),$u=e=>l(2,(t,n)=>e(t,n)!==1),ju=e=>l(2,(t,n)=>e(t,n)!==-1);var R=()=>Nu,k=Lu,ec=jd,ae=Bd,_e=Kg,fr=l(2,(e,{onNone:t,onSome:n})=>ae(e)?t():n(e.value)),K2=e=>t=>_e(e(t)),G2=e=>{for(let t of e)return k(t);return R()},V2=Vg,Z2=Gg,Bu=l(2,(e,t)=>ae(e)?t():e.value),Y2=l(2,(e,t)=>ae(e)?t():e),Q2=l(2,(e,t)=>ae(e)?k(t()):e),X2=l(2,(e,t)=>ae(e)?Bt(t(),Wd):Bt(e,zd)),eL=e=>{let t=R();for(t of e)if(_e(t))return t;return t},qu=e=>e==null?R():k(e),Yg=e=>e===void 0?R():k(e),Qg=e=>e===null?R():k(e),tL=e=>(...t)=>qu(e(...t)),Hd=Bu(Cg),ks=Bu(fo),Rs=e=>(...t)=>{try{return k(e(...t))}catch{return R()}},Xg=l(2,(e,t)=>{if(_e(e))return e.value;throw t()}),ex=Xg(()=>new Error(\"getOrThrow called on a None\")),Bt=l(2,(e,t)=>ae(e)?R():k(t(e.value))),J0=l(2,(e,t)=>Bt(e,()=>t)),nL=J0(void 0),rL=k(void 0);var vi=l(2,(e,t)=>ae(e)?R():t(e.value)),oL=l(2,(e,t)=>vi(e,n=>{let r=un(t)?t(n):t;return ec(r)?r:k(r)})),sL=l(2,(e,t)=>ae(e)?R():qu(t(e.value))),Ai=vi(_),iL=l(2,(e,t)=>vi(e,()=>t)),aL=l(2,(e,t)=>K0(e,()=>t)),cL=l(2,(e,t)=>n=>vi(e(n),t)),K0=l(2,(e,t)=>vi(e,n=>Bt(t(n),()=>n))),G0=(e,t)=>_e(e)&&_e(t)?k([e.value,t.value]):R(),uL=(e,t)=>{if(ae(e))return R();let n=[e.value];for(let r of t){if(ae(r))return R();n.push(r.value)}return k(n)},fL=e=>{if(Symbol.iterator in e){let n=[];for(let r of e){if(ae(r))return R();n.push(r.value)}return k(n)}let t={};for(let n of Object.keys(e)){let r=e[n];if(ae(r))return R();F(t,n,r.value)}return k(t)},V0=l(3,(e,t,n)=>Bt(G0(e,t),([r,o])=>n(r,o))),lL=l(3,(e,t,n)=>{let r=t;for(let o of e)_e(o)&&(r=n(r,o.value));return r}),Z0=e=>ae(e)?[]:[e.value],dL=l(2,(e,t)=>{if(ae(e))return[R(),R()];let n=t(e.value);return Ya(n)?[k(n.failure),R()]:[R(),k(n.success)]}),pL=l(2,(e,t)=>{if(ae(e))return R();let n=t(e.value);return Qa(n)?k(n.success):R()}),Ci=l(2,(e,t)=>ae(e)?R():t(e.value)?k(e.value):R()),tx=e=>Rt((t,n)=>ae(t)?ae(n):ae(n)?!1:e(t.value,n.value)),mL=e=>Ho((t,n)=>_e(t)?_e(n)?e(t.value,n.value):1:-1),hL=e=>l(2,(t,n)=>V0(t,n,e)),gL=l(2,(e,t)=>t(e)?k(e):R()),Y0=e=>l(2,(t,n)=>ae(t)?!1:e(t.value,n)),xL=Y0(Lg()),yL=l(2,(e,t)=>ae(e)?!1:t(e.value)),bL=Md(Bt),SL=_d(Bt);var EL=Pd(Bt,vi),IL=k({}),wL=(...e)=>{let n=(e.length===1?e[0]:e[1].bind(e[0]))(),r=n.next();for(;!r.done;){let o=r.value;if(ae(o))return o;r=n.next(o.value)}return k(r.value)};function TL(e){return or((t,n)=>ae(t)?n:ae(n)?t:k(e.combine(t.value,n.value)),R())}function Q0(e){return Ud((t,n)=>ae(t)||ae(n)?R():k(e.combine(t.value,n.value)))}function vL(e){let t=Q0(e).combine,n=k(e.initialValue);return or(t,n,r=>{let o=n;for(let s of r)if(o=t(o,s),ae(o))return o;return o})}var se=Wd,re=zd;var X0=e=>{if(un(e))try{return se(e())}catch(t){return re(t)}else try{return se(e.try())}catch(t){return re(e.catch(t))}};var eT=qd,ie=Ya,Tt=Qa;var tT=(e,t)=>Rt((n,r)=>ie(n)?ie(r)&&t(n.failure,r.failure):Tt(r)&&e(n.success,r.success));var Jd=l(2,(e,t)=>ie(e)?re(t(e.failure)):e),Oi=l(2,(e,t)=>Tt(e)?se(t(e.success)):e),lr=l(2,(e,{onFailure:t,onSuccess:n})=>ie(e)?t(e.failure):n(e.success));var zu=l(2,(e,t)=>ie(e)?t(e.failure):e.success);var Kd=l(2,(e,t)=>ie(e)?re(e.failure):t(e.success));var AL=(e,t)=>{let n=t?.length!==void 0?Math.max(1,Math.floor(t.length)):1/0;return{[Symbol.iterator](){let r=0;return{next(){return r<n?{value:e(r++),done:!1}:{done:!0,value:void 0}}}}}};var CL=l(2,(e,t)=>OL(AL(()=>e,{length:t}))),nT=e=>CL(e,1/0);var rT=e=>{let n=e[Symbol.iterator]().next();if(n.done)throw new Error(\"headUnsafe: empty iterable\");return n.value};var OL=e=>({[Symbol.iterator](){let t=e[Symbol.iterator](),n;function r(){for(;;){if(n===void 0){let s=t.next();if(s.done)return s;n=s.value[Symbol.iterator]()}let o=n.next();if(!o.done)return o;n=void 0}}return{next:r}}});var oT=l(2,(e,t)=>({[Symbol.iterator](){let n=e[Symbol.iterator](),r=0;return{next(){let o=n.next();for(;!o.done;){if(t(o.value,r++))return{done:!1,value:o.value};o=n.next()}return{done:!0,value:void 0}}}}}));var Wu=l(2,(e,t)=>{let n={...e};for(let r of kL(e))F(n,r,t(e[r],r));return n});var kL=e=>Object.keys(e);var Hu=globalThis.Array;var sT=l(2,(e,t)=>{let n=Math.max(1,Math.floor(e)),r=new Hu(n);for(let o=0;o<n;o++)r[o]=t(o);return r}),rx=(e,t)=>e<=t?sT(t-e+1,n=>e+n):[e];var Be=e=>Hu.isArray(e)?e:Hu.from(e),iT=e=>Hu.isArray(e)?e:[e];var ox=l(2,(e,t)=>[...e,t]),sx=l(2,(e,t)=>Be(e).concat(Be(t)));var aT=Hu.isArray;var qt=jg,Me=jg;function RL(e,t){return!Number.isFinite(e)||e<0||e>=t.length}var cT=l(2,(e,t)=>{let n=Math.floor(t);if(RL(n,e))throw new Error(`Index out of bounds: ${n}`);return e[n]});var Vd=e=>e[e.length-1];var uT=l(2,(e,t)=>{let n=0,r=[];for(let o of e){if(!t(o,n))break;r.push(o),n++}return r});var _L=(e,t)=>{let n=H(t),r=e.get(n);if(r===void 0)return e.set(n,[t]),!0;for(let o of r)if(B(o,t))return!1;return r.push(t),!0};var ix=l(2,(e,t)=>{let n=Be(e),r=Be(t);return Me(n)?Me(r)?Zd(sx(n,r)):n:r});var gt=()=>[],Ee=e=>[e],Pr=l(2,(e,t)=>e.map(t));var ax=e=>{let t=[];for(let n of e)_e(n)&&t.push(n.value);return t};var cx=l(2,(e,t)=>{let n=Be(e),r=[];for(let o=0;o<n.length;o++)t(n[o],o)&&r.push(n[o]);return r}),ux=l(2,(e,t)=>{let n=[],r=[],o=0;for(let s of e){let i=t(s,o++);Tt(i)?r.push(i.success):n.push(i.failure)}return[n,r]});var Zd=e=>{let t=Be(e);if(t.length<2)return[...t];let n=new Map,r=[];for(let o of t)_L(n,o)&&r.push(o);return r};var ML=or((e,t)=>e.concat(t),[]);function fT(){return ML}var lT=/^[+-]?\\d+$/,fx=\"~effect/BigDecimal\",NL={[fx]:fx,[be](){let e=lx(this);return it(H(e.value),On(e.scale))},[Se](e){return Qd(e)&&zL(this,e)},toString(){return`BigDecimal(${mo(this)})`},toJSON(){return{_id:\"BigDecimal\",value:String(this.value),scale:this.scale}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},Qd=e=>M(e,fx),Rn=(e,t)=>{let n=Object.create(NL);return n.value=e,n.scale=t,n},pT=(e,t)=>{if(e!==qn&&e%Yd===qn)throw new RangeError(\"Value must be normalized\");let n=Rn(e,t);return n.normalized=n,n},qn=BigInt(0),LL=BigInt(1),$L=BigInt(-1);var Yd=BigInt(10),mT=pT(qn,0);var lx=e=>{if(e.normalized===void 0)if(e.value===qn)e.normalized=mT;else{let t=`${e.value}`,n=0;for(let s=t.length-1;s>=0&&t[s]===\"0\";s--)n++;n===0&&(e.normalized=e);let r=BigInt(t.substring(0,t.length-n)),o=e.scale-n;e.normalized=pT(r,o)}return e.normalized},_s=l(2,(e,t)=>t>e.scale?Rn(e.value*Yd**BigInt(t-e.scale),t):t<e.scale?Rn(e.value/Yd**BigInt(e.scale-t),t):e),hT=l(2,(e,t)=>t.value===qn?e:e.value===qn?t:e.scale>t.scale?Rn(_s(t,e.scale).value+e.value,e.scale):e.scale<t.scale?Rn(_s(e,t.scale).value+t.value,t.scale):Rn(e.value+t.value,e.scale));var Jo=Ho((e,t)=>{let n=Bn(dT(e),dT(t));return n!==0?n:e.scale>t.scale?ur(e.value,_s(t,e.scale).value):e.scale<t.scale?ur(_s(e,t.scale).value,t.value):ur(e.value,t.value)}),jL=Xa(Jo);var BL=Os(Jo);var dT=e=>e.value===qn?0:e.value<qn?-1:1,qL=e=>e.value<qn?Rn(-e.value,e.scale):e;var dx=Rt((e,t)=>e.scale>t.scale?_s(t,e.scale).value===e.value:e.scale<t.scale?_s(e,t.scale).value===t.value:e.value===t.value),zL=l(2,(e,t)=>dx(e,t));var gT=e=>{if(e===\"\")return k(mT);let t,n,r=e.search(/[eE]/);if(r!==-1){let c=e.slice(r+1);if(t=e.slice(0,r),n=Number(c),t===\"\"||!Number.isSafeInteger(n)||!lT.test(c))return R()}else t=e,n=0;let o,s,i=t.search(/\\./);if(i!==-1){let c=t.slice(0,i),u=t.slice(i+1);o=`${c}${u}`,s=u.length}else o=t,s=0;if(!lT.test(o))return R();let a=s-n;return Number.isSafeInteger(a)?k(Rn(BigInt(o),a)):R()};var mo=e=>{let t=lx(e);if(Math.abs(t.scale)>=16)return WL(t);let n=t.value<qn,r=n?`${t.value}`.substring(1):`${t.value}`,o,s;if(t.scale>=r.length)o=\"0\",s=\"0\".repeat(t.scale-r.length)+r;else{let a=r.length-t.scale;if(a>r.length){let c=a-r.length;o=`${r}${\"0\".repeat(c)}`,s=\"\"}else s=r.slice(a),o=r.slice(0,a)}let i=s===\"\"?o:`${o}.${s}`;return n?`-${i}`:i},WL=e=>{if(HL(e))return\"0e+0\";let t=lx(e),n=`${qL(t).value}`,r=n.slice(0,1),o=n.slice(1),s=`${xT(t)?\"-\":\"\"}${r}`;o!==\"\"&&(s+=`.${o}`);let i=o.length-t.scale;return`${s}e${i>=0?\"+\":\"\"}${i}`};var HL=e=>e.value===qn,xT=e=>e.value<qn,JL=e=>e.value>qn,px=e=>Qd(e[0]);var yT=l(px,(e,t=0)=>e.scale<=t?e:Rn(e.value/Yd**BigInt(e.scale-t),t)),mx=l(px,(e,t=0)=>{let n=yT(e,t);return JL(e)&&jL(n,e)?hT(n,Rn(LL,t)):n});var hx=l(px,(e,t=0)=>{let n=yT(e,t);return xT(e)&&BL(n,e)?hT(n,Rn($L,t)):n});var x9=globalThis.Boolean;var bT=or((e,t)=>e||t,!1);var Wt={};uo(Wt,{AsyncFiberError:()=>Bj,AsyncFiberErrorTypeId:()=>$j,Done:()=>cb,DoneTypeId:()=>_j,ExceededCapacityError:()=>ub,ExceededCapacityErrorTypeId:()=>Lj,IllegalArgumentError:()=>vf,IllegalArgumentErrorTypeId:()=>Uj,InterruptorStackTrace:()=>Xy,NoSuchElementError:()=>Rj,NoSuchElementErrorTypeId:()=>kj,ReasonTypeId:()=>dj,StackTrace:()=>Qy,TimeoutError:()=>Fj,TimeoutErrorTypeId:()=>Mj,TypeId:()=>lj,UnknownError:()=>Wj,UnknownErrorTypeId:()=>qj,annotate:()=>Hj,annotations:()=>Kj,combine:()=>xj,die:()=>rb,done:()=>G,empty:()=>nb,fail:()=>hj,filterInterruptors:()=>Aj,findDefect:()=>Ij,findDie:()=>Ej,findError:()=>ib,findErrorOption:()=>bj,findFail:()=>yj,findInterrupt:()=>Tj,fromReasons:()=>Bi,hasDies:()=>Sj,hasFails:()=>sb,hasInterrupts:()=>wj,hasInterruptsOnly:()=>ob,interrupt:()=>gj,interruptors:()=>vj,isAsyncFiberError:()=>jj,isCause:()=>eb,isDieReason:()=>pj,isDone:()=>dc,isExceededCapacityError:()=>Nj,isFailReason:()=>Ef,isIllegalArgumentError:()=>Dj,isInterruptReason:()=>mj,isNoSuchElementError:()=>Oj,isReason:()=>tb,isTimeoutError:()=>Pj,isUnknownError:()=>zj,makeDieReason:()=>wf,makeFailReason:()=>If,makeInterruptReason:()=>Tf,map:()=>ns,pretty:()=>ab,prettyErrors:()=>Cj,reasonAnnotations:()=>Jj,squash:()=>Wp});var Xd=e=>zo({op:e.label,[at]:e.evaluate});var ET=\"~effect/Context/Service\",Vt=function(){function e(){}let t=e;Object.setPrototypeOf(t,KL);let n=(r,o)=>(t.key=r,o?.defaultValue&&(t[bx]=bx,t.defaultValue=o.defaultValue),o?.make&&(t.make=o.make),o?.fiberCached&&IT.add(r),t);return arguments.length>0?n(arguments[0],arguments[1]):n},KL={[ET]:ET,...Xd({label:\"Service\",evaluate(e){return Oe(Xe(e.context,this))}}),toJSON(){return{_id:\"Service\",key:this.key}},of(e){return e},context(e){return ki(this,e)},use(e){return Y(t=>e(Xe(t.context,this)))},useSync(e){return Y(t=>Oe(e(Xe(t.context,this))))}},IT=new Set,bx=\"~effect/Context/Reference\",wT=\"~effect/Context\",GL=8,VL=8,Sx=(e,t,n,r)=>{let o=Object.create(YL);return o.cacheRoot=e??o,o.base=t,o.overlay=n,o.depth=r,o._flat=void 0,o.baseHits=0,o},TT=(e,t)=>{t&&(TT(e,t.parent),e.set(t.key,t.value))},vT=e=>{if(e._flat)return e._flat;if(!e.overlay)return e._flat=e.base;let t=new Map(e.base);return TT(t,e.overlay),e._flat=t},ZL=(e,t)=>{let n=new Map(e.mapUnsafe);return t(n),ho(n)},ep=Symbol(),Ex=(e,t)=>{let n=e;for(let o=n.overlay;o;o=o.parent)if(o.key===t)return o.value;let r=n.base.get(t);return r===void 0&&!n.base.has(t)?ep:(n.overlay&&++n.baseHits>=VL&&(n.base=vT(n),n.overlay=void 0,n.depth=0),r)},ho=e=>Sx(void 0,e,void 0,0),YL={get mapUnsafe(){return vT(this)},...ln,[wT]:{_Services:e=>e},toJSON(){return{_id:\"Context\",services:Array.from(this.mapUnsafe).map(([e,t])=>({key:e,value:t}))}},[Se](e){if(!Ju(e))return!1;let t=this.mapUnsafe,n=e.mapUnsafe;if(t.size!==n.size)return!1;for(let[r,o]of t)if(!n.has(r)||!B(o,n.get(r)))return!1;return!0},[be](){return On(this.mapUnsafe.size)}},AT=(e,t)=>e.cacheRoot===t.cacheRoot,Ju=e=>M(e,wT);var CT=e=>!!e[bx],pn=()=>QL,QL=ho(new Map),ki=(e,t)=>ho(new Map([[e.key,t]])),Pt=l(3,(e,t,n)=>XL(e,t.key,n)),XL=(e,t,n)=>{let r=e,o=IT.has(t)?void 0:r.cacheRoot;if(r.depth>=GL){let s=new Map(r.mapUnsafe);return s.set(t,n),Sx(o,s,void 0,0)}return Sx(o,r.base,{key:t,value:n,parent:r.overlay},r.depth+1)};var tp=l(2,(e,t)=>Ku(e,t.key)),Ku=(e,t)=>{let n=Ex(e,t);return n===ep?void 0:n},Fr=l(2,(e,t)=>{let n=Ex(e,t.key);if(n===ep){if(CT(t))return OT(t);throw e$(t)}return n}),Xe=Fr,yx=\"~effect/Context/defaultValue\",OT=e=>yx in e?e[yx]:e[yx]=e.defaultValue(),e$=e=>{let t=new Error(`Service not found${e.key?`: ${String(e.key)}`:\"\"}`);if(t.stack){let n=t.stack.split(`\n`);n.splice(1,3),t.stack=n.join(`\n`)}return t},Ix=l(2,(e,t)=>{let n=Ex(e,t.key);return n!==ep?k(n):CT(t)?k(OT(t)):R()}),np=l(2,(e,t)=>e.mapUnsafe.size===0?t:t.mapUnsafe.size===0?e:ZL(e,n=>t.mapUnsafe.forEach((r,o)=>n.set(o,r)))),kT=(...e)=>{let t=new Map;for(let n=0;n<e.length;n++)e[n].mapUnsafe.forEach((r,o)=>{t.set(o,r)});return ho(t)};var Ae=Vt;var rp=\"~effect/time/Duration\",Tx=BigInt(0),PT=BigInt(1),t$=BigInt(2),n$=BigInt(10);var r$=BigInt(1e3);var op=e=>BigInt(e<0?Math.ceil(e-.5):Math.floor(e+.5)),FT=e=>op(e*1e6),RT=(e,t)=>{let n=e.indexOf(\".\");if(n===-1)return BigInt(e)*t;let r=e[0]===\"-\",o=e.slice(n+1),s=n$**BigInt(o.length),i=(BigInt(e.slice(r?1:0,n))*s+BigInt(o))*t,a=i/s+(i%s*t$>=s?PT:Tx);return r?-a:a};var o$=/^(-?\\d+(?:\\.\\d+)?)\\s+(nanos?|micros?|millis?|seconds?|minutes?|hours?|days?|weeks?)$/,mt=e=>{switch(typeof e){case\"number\":return go(e);case\"bigint\":return dr(e);case\"string\":{if(e===\"Infinity\")return Ur;if(e===\"-Infinity\")return Ri;let t=o$.exec(e);if(!t)break;let[n,r,o]=t;if(o===\"nano\"||o===\"nanos\")return dr(RT(r,PT));if(o===\"micro\"||o===\"micros\")return dr(RT(r,r$));let s=Number(r);switch(o){case\"milli\":case\"millis\":return go(s);case\"second\":case\"seconds\":return c$(s);case\"minute\":case\"minutes\":return u$(s);case\"hour\":case\"hours\":return f$(s);case\"day\":case\"days\":return l$(s);case\"week\":case\"weeks\":return d$(s)}break}case\"object\":{if(e===null)break;if(rp in e)return e;if(Array.isArray(e))return e.length!==2||!e.every(ku)?_T(e):Number.isNaN(e[0])||Number.isNaN(e[1])?Ms:e[0]===-1/0||e[1]===-1/0?Ri:e[0]===1/0||e[1]===1/0?Ur:mn(op(e[0]*1e9+e[1]));let t=e,n=0;return t.weeks&&(n+=t.weeks*6048e5),t.days&&(n+=t.days*864e5),t.hours&&(n+=t.hours*36e5),t.minutes&&(n+=t.minutes*6e4),t.seconds&&(n+=t.seconds*1e3),t.milliseconds&&(n+=t.milliseconds),!t.microseconds&&!t.nanoseconds?mn(n):mn(op(n*1e6+(t.microseconds??0)*1e3+(t.nanoseconds??0)))}}return _T(e)},_T=e=>{throw new Error(`Invalid Input: ${e}`)},UT=Rs(mt),MT={_tag:\"Millis\",millis:0},s$={_tag:\"Infinity\"},i$={_tag:\"NegativeInfinity\"},a$={[rp]:rp,[be](){switch(this.value._tag){case\"Millis\":{let e=this.value.millis*1e6;return Number.isFinite(e)?H(op(e)):On(this.value.millis)}case\"Nanos\":return H(this.value.nanos);default:return Fg(this.value)}},[Se](e){return vx(e)&&m$(this,e)},toString(){switch(this.value._tag){case\"Infinity\":return\"Infinity\";case\"NegativeInfinity\":return\"-Infinity\";case\"Nanos\":return`${this.value.nanos} nanos`;case\"Millis\":return`${this.value.millis} millis`}},toJSON(){switch(this.value._tag){case\"Millis\":return{_id:\"Duration\",_tag:\"Millis\",millis:this.value.millis};case\"Nanos\":return{_id:\"Duration\",_tag:\"Nanos\",nanos:String(this.value.nanos)};case\"Infinity\":return{_id:\"Duration\",_tag:\"Infinity\"};case\"NegativeInfinity\":return{_id:\"Duration\",_tag:\"NegativeInfinity\"}}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},mn=e=>{let t=Object.create(a$);return typeof e==\"number\"?isNaN(e)||e===0||Object.is(e,-0)?t.value=MT:Number.isFinite(e)?Number.isInteger(e)?t.value={_tag:\"Millis\",millis:e}:t.value={_tag:\"Nanos\",nanos:FT(e)}:t.value=e>0?s$:i$:e===Tx?t.value=MT:t.value={_tag:\"Nanos\",nanos:e},t},vx=e=>M(e,rp),tc=e=>e.value._tag!==\"Infinity\"&&e.value._tag!==\"NegativeInfinity\",Gu=e=>{switch(e.value._tag){case\"Millis\":return e.value.millis===0;case\"Nanos\":return e.value.nanos===Tx;case\"Infinity\":case\"NegativeInfinity\":return!1}};var Ms=mn(0),Ur=mn(1/0),Ri=mn(-1/0),dr=e=>mn(e);var go=e=>mn(e),c$=e=>mn(e*1e3),u$=e=>mn(e*6e4),f$=e=>mn(e*36e5),l$=e=>mn(e*864e5),d$=e=>mn(e*6048e5),_n=e=>p$(mt(e),{onMillis:_,onNanos:t=>Number(t)/1e6,onInfinity:()=>1/0,onNegativeInfinity:()=>-1/0});var wx=e=>{let t=mt(e);switch(t.value._tag){case\"Infinity\":case\"NegativeInfinity\":throw new Error(\"Cannot convert infinite duration to nanos\");case\"Nanos\":return t.value.nanos;case\"Millis\":return FT(t.value.millis)}},DT=Rs(wx);var p$=l(2,(e,t)=>{switch(e.value._tag){case\"Millis\":return t.onMillis(e.value.millis);case\"Nanos\":return t.onNanos(e.value.nanos);case\"Infinity\":return t.onInfinity();case\"NegativeInfinity\":return(t.onNegativeInfinity??t.onInfinity)()}}),NT=l(3,(e,t,n)=>e.value._tag===\"Infinity\"||e.value._tag===\"NegativeInfinity\"||t.value._tag===\"Infinity\"||t.value._tag===\"NegativeInfinity\"?n.onInfinity(e,t):e.value._tag===\"Millis\"?t.value._tag===\"Millis\"?n.onMillis(e.value.millis,t.value.millis):n.onNanos(wx(e),t.value.nanos):n.onNanos(e.value.nanos,wx(t)));var Ax=(e,t)=>NT(e,t,{onMillis:(n,r)=>n===r,onNanos:(n,r)=>n===r,onInfinity:(n,r)=>n.value._tag===r.value._tag});var LT=l(2,(e,t)=>NT(e,t,{onMillis:(n,r)=>mn(n-r),onNanos:(n,r)=>mn(n-r),onInfinity:(n,r)=>{let o=n.value._tag,s=r.value._tag;return o===\"Infinity\"?s===\"Infinity\"?Ms:Ur:o===\"NegativeInfinity\"?s===\"NegativeInfinity\"?Ms:Ri:s===\"Infinity\"?Ri:Ur}}));var m$=l(2,(e,t)=>Ax(e,t));var Cx=l(2,(e,t)=>n=>{let r=e(n);if(ie(r))return re(n);let o=t(r.success);return ie(o)?re(n):o}),$T=e=>t=>{let n=e(t);return ie(n)?R():k(n.success)};var Vu=Ae(\"effect/Scheduler\",{fiberCached:!0,defaultValue:()=>new _i}),BT=\"setImmediate\"in globalThis?e=>{let t=globalThis.setImmediate(e);return()=>globalThis.clearImmediate(t)}:e=>{let t=setTimeout(e,0);return()=>clearTimeout(t)},h$=e=>{let t=!1;return Promise.resolve().then(()=>{t||e()}),()=>{t=!0}},Ox=class{buckets=[];scheduleTask(t,n){let r=this.buckets,o=r.length,s,i=0;for(;i<o&&!(r[i][0]>n);i++)s=r[i];s&&s[0]===n?s[1].push(t):i===o?r.push([n,[t]]):r.splice(i,0,[n,[t]])}drain(){let t=this.buckets;return this.buckets=[],t}},_i=class{executionMode;setImmediate;constructor(t=\"async\",n){this.executionMode=t,this.setImmediate=n??(t===\"sync\"?h$:BT)}shouldYield(t){return t.currentOpCount>=t.maxOpsBeforeYield}makeDispatcher(){return new kx(this.setImmediate)}},kx=class{tasks=new Ox;running=void 0;setImmediate;constructor(t=BT){this.setImmediate=t}scheduleTask(t,n){this.tasks.scheduleTask(t,n),this.running===void 0&&(this.running=this.setImmediate(this.afterScheduled))}afterScheduled=()=>{this.running=void 0,this.runTasks()};runTasks(){let t=this.tasks.drain();for(let n=0;n<t.length;n++){let r=t[n][1];for(let o=0;o<r.length;o++)r[o]()}}flush(){for(;this.tasks.buckets.length>0;)this.running!==void 0&&(this.running(),this.running=void 0),this.runTasks()}},qT=Ae(\"effect/Scheduler/MaxOpsBeforeYield\",{fiberCached:!0,defaultValue:()=>2048}),zT=Ae(\"effect/Scheduler/PreventSchedulerYield\",{fiberCached:!0,defaultValue:()=>!1});var bo={};uo(bo,{Class:()=>sp,Error:()=>WT,TaggedClass:()=>g$,TaggedError:()=>yo,taggedEnum:()=>x$});var sp=class extends di{constructor(e){super(),e&&Fd(this,e)}},g$=e=>class extends sp{_tag=e},x$=()=>new Proxy({},{get(e,t,n){return t===\"$is\"?$t:t===\"$match\"?y$:r=>({...r,_tag:t})}});function y$(){if(arguments.length===1){let n=arguments[0];return function(r){return n[r._tag](r)}}let e=arguments[0];return arguments[1][e._tag](e)}var WT=Uu,yo=Wo;var HT=\"~effect/encoding/EncodingError\",Go=class extends yo(\"EncodingError\"){[HT]=HT};var ap=e=>_x(typeof e==\"string\"?Ux.encode(e):e),rc=e=>{let t=tv(e),n=t.length;if(n%4!==0)return re(new Go({kind:\"Decode\",module:\"Base64\",input:t,message:`Length must be a multiple of 4, but is ${n}`}));let r=t.indexOf(\"=\");if(r!==-1&&(r<n-2||r===n-2&&t[n-1]!==\"=\"))return re(new Go({kind:\"Decode\",module:\"Base64\",input:t,message:\"Found a '=' character, but it is not at the end\"}));try{let o=t.endsWith(\"==\")?2:t.endsWith(\"=\")?1:0,s=new Uint8Array(3*(n/4)-o);for(let i=0,a=0;i<n;i+=4,a+=3){let c=ip(t.charCodeAt(i))<<18|ip(t.charCodeAt(i+1))<<12|ip(t.charCodeAt(i+2))<<6|ip(t.charCodeAt(i+3));s[a]=c>>16,s[a+1]=c>>8&255,s[a+2]=c&255}return se(s)}catch(o){return re(new Go({kind:\"Decode\",module:\"Base64\",input:t,message:o instanceof Error?o.message:\"Invalid input\"}))}},ZT=e=>Oi(rc(e),t=>Dx.decode(t)),YT=e=>KT(typeof e==\"string\"?Ux.encode(e):e),Mx=e=>{let t=tv(e),n=t.length;if(n%4===1)return re(new Go({module:\"Base64Url\",kind:\"Decode\",input:t,message:`Length should be a multiple of 4, but is ${n}`}));if(!/^[-_A-Z0-9]*?={0,2}$/i.test(t))return re(new Go({module:\"Base64Url\",kind:\"Decode\",input:t,message:\"Invalid input\"}));let r=n%4===2?`${t}==`:n%4===3?`${t}=`:t;return r=r.replace(/-/g,\"+\").replace(/_/g,\"/\"),rc(r)},QT=e=>Oi(Mx(e),t=>Dx.decode(t)),XT=e=>GT(typeof e==\"string\"?Ux.encode(e):e),Px=e=>{let t=\"\";for(let n=e>>>3;n>0;n--){let r=Math.random()*4294967296>>>0;t+=nc[r>>>24]+nc[r>>>16&255]+nc[r>>>8&255]+nc[r&255]}return t},Fx=e=>{let t=new TextEncoder().encode(e);if(t.length%2!==0)return re(new Go({module:\"Hex\",kind:\"Decode\",input:e,message:`Length must be a multiple of 2, but is ${t.length}`}));try{let n=t.length/2,r=new Uint8Array(n);for(let o=0;o<n;o++){let s=VT(t[o*2]),i=VT(t[o*2+1]);r[o]=s<<4|i}return se(r)}catch(n){return re(new Go({module:\"Hex\",kind:\"Decode\",input:e,message:n instanceof Error?n.message:\"Invalid input\"}))}},ev=e=>Oi(Fx(e),t=>Dx.decode(t)),Ux=new TextEncoder,Dx=new TextDecoder,tv=e=>e.replace(/[\\n\\r]/g,\"\"),_x=e=>{let t=e.length,n=\"\",r;for(r=2;r<t;r+=3)n+=Ko[e[r-2]>>2],n+=Ko[(e[r-2]&3)<<4|e[r-1]>>4],n+=Ko[(e[r-1]&15)<<2|e[r]>>6],n+=Ko[e[r]&63];return r===t+1&&(n+=Ko[e[r-2]>>2],n+=Ko[(e[r-2]&3)<<4],n+=\"==\"),r===t&&(n+=Ko[e[r-2]>>2],n+=Ko[(e[r-2]&3)<<4|e[r-1]>>4],n+=Ko[(e[r-1]&15)<<2],n+=\"=\"),n};function ip(e){if(e>=JT.length)throw new TypeError(`Invalid character ${String.fromCharCode(e)}`);let t=JT[e];if(t===255)throw new TypeError(`Invalid character ${String.fromCharCode(e)}`);return t}var Ko=[\"A\",\"B\",\"C\",\"D\",\"E\",\"F\",\"G\",\"H\",\"I\",\"J\",\"K\",\"L\",\"M\",\"N\",\"O\",\"P\",\"Q\",\"R\",\"S\",\"T\",\"U\",\"V\",\"W\",\"X\",\"Y\",\"Z\",\"a\",\"b\",\"c\",\"d\",\"e\",\"f\",\"g\",\"h\",\"i\",\"j\",\"k\",\"l\",\"m\",\"n\",\"o\",\"p\",\"q\",\"r\",\"s\",\"t\",\"u\",\"v\",\"w\",\"x\",\"y\",\"z\",\"0\",\"1\",\"2\",\"3\",\"4\",\"5\",\"6\",\"7\",\"8\",\"9\",\"+\",\"/\"],JT=[255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,62,255,255,255,63,52,53,54,55,56,57,58,59,60,61,255,255,255,0,255,255,255,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,255,255,255,255,255,255,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51],KT=e=>_x(e).replace(/=/g,\"\").replace(/\\+/g,\"-\").replace(/\\//g,\"_\"),nc=[];for(let e=0;e<256;e++)nc.push(e.toString(16).padStart(2,\"0\"));var GT=e=>{let t=\"\";for(let n=0;n<e.length;n++)t+=nc[e[n]];return t},VT=e=>{if(48<=e&&e<=57)return e-48;if(97<=e&&e<=102)return e-97+10;if(65<=e&&e<=70)return e-65+10;throw new TypeError(\"Invalid input\")};var Lx=\"effect/Tracer/ParentSpan\",Mi=class extends Vt()(Lx,{fiberCached:!0}){},b$=e=>e;var up=Ae(\"effect/Tracer/DisablePropagation\",{defaultValue:Cu}),nv=Ae(\"effect/Tracer/CurrentTraceLevel\",{defaultValue:()=>\"Info\"}),rv=Ae(\"effect/Tracer/MinimumTraceLevel\",{defaultValue:()=>\"All\"}),$x=\"effect/Tracer\",fp=Ae($x,{fiberCached:!0,defaultValue:()=>b$({span:e=>new Nx(e)})}),Nx=class{_tag=\"Span\";spanId;traceId=\"native\";sampled;name;parent;annotations;links;startTime;kind;status;attributes;events=[];constructor(t){this.name=t.name,this.parent=t.parent,this.annotations=t.annotations,this.links=t.links,this.startTime=t.startTime,this.kind=t.kind,this.sampled=t.sampled,this.status={_tag:\"Started\",startTime:t.startTime},this.attributes=new Map,this.traceId=ks(t.parent)?.traceId??Px(32),this.spanId=Px(16)}end(t,n){this.status={_tag:\"Ended\",endTime:t,exit:n,startTime:this.status.startTime}}attribute(t,n){this.attributes.set(t,n)}event(t,n,r){this.events.push([t,n,r??{}])}addLinks(t){this.links.push(...t)}};var sv=\"effect/observability/Metric/FiberRuntimeMetricsKey\";var iv=Ae(\"effect/ErrorReporter/CurrentErrorReporters\",{defaultValue:()=>new Set}),Zu=Ae(\"effect/References/CurrentStackFrame\",{fiberCached:!0,defaultValue:fo}),lp=Ae(\"effect/References/TracerEnabled\",{defaultValue:cn}),Pi=Ae(\"effect/References/TracerTimingEnabled\",{defaultValue:cn}),Yu=Ae(\"effect/References/TracerSpanAnnotations\",{defaultValue:()=>({})}),Qu=Ae(\"effect/References/TracerSpanLinks\",{defaultValue:()=>[]}),Vo=Ae(\"effect/References/CurrentLogAnnotations\",{defaultValue:()=>({})}),jx=Ae(\"effect/References/CurrentLogLevel\",{fiberCached:!0,defaultValue:()=>\"Info\"}),Bx=Ae(\"effect/References/MinimumLogLevel\",{fiberCached:!0,defaultValue:()=>\"Info\"});var dp=Ae(\"effect/References/CurrentLogSpans\",{defaultValue:()=>[]});var I$=()=>{let e=Object.getOwnPropertyDescriptor(Error,\"stackTraceLimit\");return e===void 0?Object.isExtensible(Error):Object.hasOwn(e,\"writable\")?e.writable===!0:e.set!==void 0},w$=I$(),Ps=()=>Error.stackTraceLimit,zn=e=>{w$&&(Error.stackTraceLimit=e)};var Fs=e=>{if(e?.captureStackTrace===!1)return e;if(e?.captureStackTrace!==void 0&&typeof e.captureStackTrace!=\"boolean\")return e;let t=Ps();zn(3);let n=new Error;return zn(t),{...e,captureStackTrace:T$(()=>n.stack)}},qx=e=>t=>{let n;return()=>{if(n!==void 0)return n;let r=t();if(!r)return;let o=r.split(`\n`);if(o[e]!==void 0)return n=o[e].trim(),n}},T$=qx(3);var pp=class extends Ka{fiberId;constructor(t,n=Fu){super(\"Interrupt\",n,\"Interrupted\"),this.fiberId=t}toString(){return`Interrupt(${this.fiberId})`}toJSON(){return{_tag:\"Interrupt\",fiberId:this.fiberId}}[Se](t){return Ei(t)&&this.fiberId===t.fiberId&&this.annotations===t.annotations}[be](){return it(Ue(`${this._tag}:${this.fiberId}`))(Cd(this.annotations))}},pv=e=>new pp(e),Ns=e=>new lo([new pp(e)]),Qx=e=>e.reasons.some(Cs),Xx=e=>{let t=e.reasons.find(Cs);return t?se(t):re(e)},pr=e=>{for(let t=0;t<e.reasons.length;t++){let n=e.reasons[t];if(n._tag===\"Fail\")return se(n.error)}return re(e)},mv=$T(pr),yp=e=>e.reasons.some(Si),hv=e=>{let t=e.reasons.find(Si);return t?se(t):re(e)},ff=e=>{let t=e.reasons.find(Si);return t?se(t.defect):re(e)},bp=e=>e.reasons.some(Ei),gv=e=>{let t=e.reasons.find(Ei);return t?se(t):re(e)},Sp=e=>{let t;for(let n=0;n<e.reasons.length;n++){let r=e.reasons[n];r._tag===\"Interrupt\"&&(t??=new Set,r.fiberId!==void 0&&t.add(r.fiberId))}return t?se(t):re(e)},xv=e=>{let t=Sp(e);return ie(t)?v$:t.success},v$=new Set,yv=e=>e.reasons.length>0&&e.reasons.every(Ei),bv=e=>ho(e.annotations),Sv=e=>{let t=new Map;for(let n of e.reasons)if(n.annotations.size>0)for(let[r,o]of n.annotations)t.set(r,o);return ho(t)},Ep=l(2,(e,t)=>{if(e.reasons.length===0)return t;if(t.reasons.length===0)return e;let n=new lo(ix(e.reasons,t.reasons));return B(e,n)?e:n}),Ev=l(2,(e,t)=>{let n=!1,r=e.reasons.map(o=>Cs(o)?(n=!0,new xi(t(o.error),o.annotations)):o);return n?qo(r):e}),A$=e=>{let t={Fail:[],Die:[],Interrupt:[]};for(let n=0;n<e.reasons.length;n++)t[e.reasons[n]._tag].push(e.reasons[n]);return t},Ip=e=>{let t=A$(e);return t.Fail.length>0?t.Fail[0].error:t.Die.length>0?t.Die[0].defect:t.Interrupt.length>0?new globalThis.Error(\"All fibers interrupted without error\"):new globalThis.Error(\"Empty cause\")},ey=(e,t)=>{let n=[],r=[];if(e.reasons.length===0)return n;let o=Ps();zn(1);for(let s of e.reasons){if(s._tag===\"Interrupt\"){r.push(s);continue}n.push(Wx(s._tag===\"Die\"?s.defect:s.error,s.annotations,t))}if(n.length===0){let s=new Error(\"The fiber was interrupted by:\");s.name=\"InterruptCause\",s.stack=R$(s,r);let i=new globalThis.Error(\"All fibers interrupted without error\",{cause:s});i.name=\"InterruptError\",i.stack=`${i.name}: ${i.message}`,n.push(Wx(i,r[0].annotations,t))}return zn(o),n},Wx=(e,t,n)=>{let r=typeof e,o;if(e&&r===\"object\"){if(o=new globalThis.Error(C$(e),{cause:e.cause?Wx(e.cause):void 0}),typeof e.name==\"string\"&&(o.name=e.name),typeof e.stack==\"string\")o.stack=k$(e.stack,o,t);else{let s=`${o.name}: ${o.message}`;o.stack=t?Iv(s,t):s}n?.includeCauseInStack&&(o.stack=Tv(o));for(let s of Object.keys(e))s in o||(o[s]=e[s])}else o=new globalThis.Error(e?r===\"string\"?e:hi(e):`Unknown error: ${e}`);return o},C$=e=>{if(typeof e.message==\"string\")return e.message;if(typeof e.toString==\"function\"&&e.toString!==Object.prototype.toString&&e.toString!==Array.prototype.toString)try{return e.toString()}catch{}return hi(e)},O$=/\\((.*)\\)/g,k$=(e,t,n)=>{let r=`${t.name}: ${t.message}`,o=(e.startsWith(r)?e.slice(r.length):e).split(`\n`),s=[r];for(let i=1;i<o.length&&!/(?:Generator\\.next|~effect\\/Effect)/.test(o[i]);i++)s.push(o[i]);return n?Iv(s.join(`\n`),n):s.join(`\n`)},Iv=(e,t)=>{let n=t?.get(Ld.key);return n&&(e=`${e}\n${wv(n)}`),e},R$=(e,t)=>{let n=[`${e.name}: ${e.message}`];for(let r of t){let o=r.fiberId!==void 0?`#${r.fiberId}`:\"unknown\",s=r.annotations.get(Jg.key);n.push(` at fiber (${o})`),s&&n.push(wv(s))}return n.join(`\n`)},wv=e=>{let t=[],n=e,r=0;for(;n&&r<10;){let o=n.stack();if(o){let s=o.matchAll(O$),i=!1;for(let[,a]of s)i=!0,t.push(` at ${n.name} (${a})`);i||t.push(` at ${n.name} (${o.replace(/^at /,\"\")})`)}else t.push(` at ${n.name}`);n=n.parent,r++}return t.join(`\n`)},wp=e=>ey(e).map(Tv).join(`\n`),Tv=e=>e.cause?`${e.stack} {\n${vv(e.cause,\" \")}\n}`:e.stack,vv=(e,t)=>{let n=e.stack.split(`\n`),r=`${t}[cause]: ${n[0]}`;for(let o=1,s=n.length;o<s;o++)r+=`\n${t}${n[o]}`;return e.cause&&(r+=` {\n${vv(e.cause,`${t} `)}\n${t}}`),r},mp=\"~effect/Fiber\",_$={_A:_,_E:_},M$={id:0},Zo=()=>globalThis[Wa],hp=class{constructor(t,n=!0){this[mp]=_$,this.setContext(t),this.id=++M$.id,this.currentOpCount=0,this.interruptible=n,this._stack=[],this._observers=[],this._exit=void 0,this._children=void 0,this._interruptedCause=void 0,this._yielded=void 0,this._running=!1,this._deferredInterrupt=!1,this.runtimeMetrics?.recordFiberStart(this.context)}[mp];id;interruptible;currentOpCount;_stack;_observers;_exit;_children;_interruptedCause;_yielded;_running;_deferredInterrupt;context;currentScheduler;currentTracerContext;currentSpan;currentLogLevel;minimumLogLevel;currentStackFrame;runtimeMetrics;maxOpsBeforeYield;currentPreventYield;_dispatcher=void 0;get currentDispatcher(){return this._dispatcher??=this.currentScheduler.makeDispatcher()}getRef(t){return Xe(this.context,t)}addObserver(t){return this._exit?(t(this._exit),_r):(this._observers.push(t),()=>{if(this._exit)return;let n=this._observers.indexOf(t);n>=0&&this._observers.splice(n,1)})}interruptUnsafe(t,n){if(this._exit)return;let r=Ns(t);this.currentStackFrame&&(r=Za(r,ki(Ld,this.currentStackFrame))),n&&(r=Za(r,n)),this._interruptedCause=this._interruptedCause?Ep(this._interruptedCause,r):r,this.interruptible&&(this._running?this._deferredInterrupt=!0:this.evaluate(Pe(this._interruptedCause)))}pollUnsafe(){return this._exit}evaluate(t){if(this._exit)return;if(this._yielded!==void 0){let o=this._yielded;this._yielded=void 0,o()}let n=this.runLoop(t);if(n===bi)return;let r=Hx.interruptChildren&&Hx.interruptChildren(this);if(r!==void 0)return this.evaluate(q(r,()=>n));this._exit=n,this.runtimeMetrics?.recordFiberEnd(this.context,this._exit);for(let o=0;o<this._observers.length;o++)this._observers[o](n);this._observers.length=0,this._stack.length=0,this._children=void 0,this.context=pn()}runLoop(t){let n=globalThis[Wa];globalThis[Wa]=this;let r=this._running;this._running=!0;let o=!1,s=t;this.currentOpCount=0;try{for(;;){if(this._deferredInterrupt&&(this._deferredInterrupt=!1,s=Pe(this._interruptedCause)),this.currentOpCount++,!o&&!this.currentPreventYield&&this.currentScheduler.shouldYield(this)){o=!0;let i=s;s=q(cc,()=>i)}if(s=this.currentTracerContext?this.currentTracerContext(s,this):s[at](this),s===bi){let i=this._yielded;if(gi in i)return this._deferredInterrupt=!1,this._yielded=void 0,i;if(this._deferredInterrupt){this._yielded=void 0,i();continue}return bi}}}catch(i){return M(s,at)?this.runLoop(ar(i)):ar(`Fiber.runLoop: Not a valid effect: ${String(s)}`)}finally{this._running=r,globalThis[Wa]=n}}getCont(t){if(this._deferredInterrupt)return this._deferredInterrupt=!1,P$;for(;;){let n=this._stack.pop();if(!n)return;let r=n[As]&&n[As](this);if(r)return r[t]=r,r;if(n[t])return n}}yieldWith(t){return this._yielded=t,bi}children(){return this._children??=new Set}pipe(){return K(this,arguments)}setContext(t){let n=this.context;if(this.context=t,n!==void 0&&AT(n,t))return;let r=this.getRef(Vu);r!==this.currentScheduler&&(this.currentScheduler=r,this._dispatcher=void 0),this.currentSpan=Ku(t,Lx),this.currentLogLevel=this.getRef(jx),this.minimumLogLevel=this.getRef(Bx),this.currentStackFrame=this.getRef(Zu),this.maxOpsBeforeYield=this.getRef(qT),this.currentPreventYield=this.getRef(zT),this.runtimeMetrics=Ku(t,sv);let o=Ku(t,$x);this.currentTracerContext=o?o.context:void 0}get currentSpanLocal(){return this.currentSpan?._tag===\"Span\"?this.currentSpan:void 0}},P$={[fn](e,t){return Pe(t._interruptedCause)},[sr](e,t){return Pe(t._interruptedCause)}},Hx={interruptChildren:void 0},Tp=e=>{if(!e.currentStackFrame)return;let t=new Map;return t.set(Jg.key,e.currentStackFrame),ho(t)},F$=e=>{if(!(e._children===void 0||e._children.size===0))return Ds(e._children)},ty=e=>{let t=e;return t._exit?U(t._exit):ct(n=>t._exit?n(U(t._exit)):L(e.addObserver(r=>n(U(r)))))},vp=e=>ct(t=>{let n=e[Symbol.iterator](),r=[],o;function s(){let i=n.next();for(;!i.done;){if(i.value._exit){r.push(i.value._exit),i=n.next();continue}o=i.value.addObserver(a=>{r.push(a),s()});return}t(U(r))}return s(),L(()=>o?.())}),Av=e=>{let t=e;return t._exit?t._exit:ct(n=>t._exit?n(t._exit):L(e.addObserver(n)))},Cv=e=>ct(t=>{let n=Array.from(e);if(n.length===0)return t(U(gt()));let r=new Array(n.length),o=gt(),s=0,i=!1;for(let a=0;a<n.length&&!i;a++)o.push(n[a].addObserver(c=>{if(s++,c._tag===\"Failure\")return i=!0,o.forEach(u=>u()),t(c);r[a]=c.value,s===n.length&&t(U(r))}));return L(()=>{i=!0,o.forEach(a=>a())})}),Ap=e=>Y(t=>Ov(e,t.id)),Ov=l(e=>M(e[0],mp),(e,t,n)=>Y(r=>{let o=Tp(r);return o=o&&n?np(o,n):o??n,e.interruptUnsafe(t,o),lf(ty(e))})),Ds=e=>Y(t=>{let n=Tp(t),r=gt();for(let o of e)o.interruptUnsafe(t.id,n),r.push(o);return lf(vp(r))});var U=Oe,Pe=Mt,qe=jn,L=ir({op:\"Sync\",[at](e){let t=this[Z](),n=e.getCont(fn);return n?n[fn](t,e):e.yieldWith(Oe(t))}}),Q=ir({op:\"Suspend\",[at](e){return this[Z]()}}),ny=l(e=>e.length>=2||ec(e[0]),(e,t)=>ae(e)?qe(t?t():new Mr(\"Effect.fromOption: Option.none\")):U(e.value)),ac=lr({onFailure:qe,onSuccess:U}),kv=e=>e==null?qe(new Mr):U(e),ry=ir({op:\"Yield\",[at](e){let t=!1;return e.currentDispatcher.scheduleTask(()=>{t||e.evaluate(hn)},this[Z]??0),e.yieldWith(()=>{t=!0})}}),cc=ry(0),oy=e=>U(k(e)),Ui=U(R()),Rv=e=>ae(e)?Ui:ke(e.value,k),_v=e=>Q(()=>Pe(we(e))),Ls=e=>ar(e),Cp=e=>Q(()=>qe(we(e))),pe=U(void 0);var Mv=e=>{let t=typeof e==\"function\"?e:e.try,n=typeof e==\"function\"?r=>new ic(r,\"An error occurred in Effect.try\"):e.catch;return Q(()=>{try{return U(we(t))}catch(r){return qe(we(()=>n(r)))}})};var sy=e=>iy(function(t,n){we(()=>e(n)).then(r=>t(U(r)),r=>t(Ls(r)))},e.length!==0),Pv=e=>{let t=typeof e==\"function\"?e:e.try,n=typeof e==\"function\"?r=>new ic(r,\"An error occurred in Effect.tryPromise\"):e.catch;return iy(function(r,o){let s=i=>{try{r(qe(we(()=>n(i))))}catch(a){r(Ls(a))}};try{we(()=>t(o)).then(i=>r(U(i)),s)}catch(i){s(i)}},t.length!==0)},Fv=e=>Y(t=>e(t.id)),Uv=Y(U),Dv=Fv(U),iy=ir({op:\"Async\",single:!1,[at](e){let t=we(()=>this[Z][0].bind(e.currentScheduler)),n=!1,r=!1,o=this[Z][1]?new AbortController:void 0,s=t(i=>{n||(n=!0,r?e.evaluate(i):r=i)},o?.signal);return r!==!1?r:(r=!0,e._yielded=()=>{n=!0},o===void 0&&s===void 0?bi:(e._stack.push(U$(()=>(n=!0,o?.abort(),s??hn))),bi))}}),U$=ir({op:\"AsyncFinalizer\",[As](e){e.interruptible&&(e.interruptible=!1,e._stack.push($p))},[sr](e,t){return bp(e)?q(this[Z](),()=>Pe(e)):Pe(e)}}),ct=e=>iy(e,e.length>=2),Op=ct(_r),Nv=(...e)=>Q(()=>oc(e.length===1?e[0]():e[1].call(e[0].self))),ay=(e,...t)=>{let n=t.length===0?function(){return Q(()=>oc(e.apply(this,arguments)))}:function(){let r=Q(()=>oc(e.apply(this,arguments)));for(let o=0;o<t.length;o++)r=t[o](r,...arguments);return r};return cy(e.length,n)},cy=(e,t)=>Object.defineProperty(t,\"length\",{value:e,configurable:!0}),av=qx(2),Lv=function(){let e=typeof arguments[0]==\"string\",t=e?arguments[0]:\"Effect.fn\",n=e?arguments[1]:void 0,r=Ps();zn(2);let o=new globalThis.Error;return zn(r),e?(s,...i)=>cv(t,s,o,i,e,n):cv(t,arguments[0],o,Array.prototype.slice.call(arguments,1),e,n)},cv=(e,t,n,r,o,s)=>{let i=typeof t==\"function\"?t:r.shift().bind(t.self);return cy(i.length,function(...a){let c=Q(()=>{let d=i.apply(this,arguments);return ee(d)?d:oc(d)});for(let d=0;d<r.length;d++)c=r[d](c,...a);if(!ee(c))return c;let u=Ps();zn(2);let f=new globalThis.Error;return zn(u),Yo(o?tf(e,s,d=>Zx(c,d)):c,Zu,d=>({name:e,stack:av(()=>f.stack),parent:{name:`${e} (definition)`,stack:av(()=>n.stack),parent:d}}))})},$v=(e,...t)=>cy(e.length,t.length===0?function(){return uv(()=>e.apply(this,arguments))}:function(){let n=uv(()=>e.apply(this,arguments));for(let r of t)n=r(n);return n}),uv=e=>{try{let t=e(),n;for(;;){let r=t.next(n);if(r.done)return U(r.value);let o=r.value;if(o&&o._tag===\"Success\"){n=o.value;continue}else{if(o&&o._tag===\"Failure\")return r.value;{let s=!0;return Q(()=>s?(s=!1,q(r.value,i=>oc(t,i))):Q(()=>oc(e())))}}}}catch(t){return Ls(t)}},oc=ir({op:\"Iterator\",single:!1,[fn](e,t){let n=this[Z][0];for(;;){let r=n.next(e);if(r.done)return U(r.value);if(De(r.value)){if(r.value._tag===\"Failure\")return r.value}else return t._stack.push(this),r.value;e=r.value.value}},[at](e){return this[fn](this[Z][1],e)}}),Zt=l(2,(e,t)=>{let n=U(t);return q(e,r=>n)}),uc=e=>ke(e,k),jv=e=>Ni(e,{onFailure:U,onSuccess:qe}),Nr=l(2,(e,t)=>q(e,n=>ee(t)?t:we(()=>t(n)))),Di=l(2,(e,t)=>q(e,n=>Zt(ee(t)?t:we(()=>t(n)),n))),lf=e=>q(e,t=>hn),Bv=e=>Hn(e,qe),uy=(e,t)=>Y(n=>ct(r=>{let o=Be(e),s=o.length,i=0,a=!1,c=new Set,u=[],f=(d,p,m)=>{if(i++,d._tag===\"Failure\"){u.push(...d.cause.reasons),i>=s&&r(Pe(qo(u)));return}let g=!a;a=!0,r(c.size===0?d:q(es(Ds(c)),()=>d)),g&&t?.onWinner&&t.onWinner({fiber:p,index:m,parentFiber:n})};for(let d=0;d<s;d++){let p=$i(n,o[d],!0,!0,!1);if(c.add(p),p.addObserver(m=>{c.delete(p),f(m,p,d)}),a)break}return Ds(c)})),fy=(e,t)=>Y(n=>ct(r=>{let o=!1,s=new Set,i=c=>{o=!0,r(s.size===0?c:q(es(Ds(s)),()=>c))},a=0;for(let c of e){if(o)break;let u=a++,f=$i(n,c,!0,!0,!1);s.add(f),f.addObserver(d=>{s.delete(f);let p=!o;i(d),p&&t?.onWinner&&t.onWinner({fiber:f,index:u,parentFiber:n})})}return Ds(s)})),qv=l(e=>ee(e[1]),(e,t,n)=>uy([e,t],n)),kp=l(e=>ee(e[1]),(e,t,n)=>fy([e,t],n)),q=l(2,(e,t)=>{let n=Object.create(D$);return n[Z]=e,n[fn]=t.length!==1?r=>t(r):t,n}),D$=zo({op:\"OnSuccess\",[at](e){return e._stack.push(this),this[Z]}}),zv=l(2,(e,t)=>De(e)?e._tag===\"Success\"?t.onSuccess(e.value):t.onFailure(e.cause):Dr(e,t)),De=e=>gi in e,Wv=l(2,(e,t)=>De(e)?e._tag===\"Success\"?t(e.value):e:q(e,t)),Hv=e=>q(e,_),ke=l(2,(e,t)=>q(e,n=>U(we(()=>t(n))))),Jv=l(2,(e,t)=>De(e)?py(e,t):ke(e,t)),Kv=l(2,(e,t)=>De(e)?my(e,t):wy(e,t)),Gv=l(2,(e,t)=>De(e)?hy(e,t):Ty(e,t)),Vv=l(2,(e,t)=>{if(De(e)){if(e._tag===\"Success\")return e;let n=pr(e.cause);return ie(n)?e:t(n.success)}return Jn(e,t)}),Rp=e=>Mt(Ns(e)),$s=e=>e._tag===\"Success\",Zv=e=>e._tag===\"Success\"?se(e):re(e),Yv=e=>e._tag===\"Success\"?se(e.value):re(e),ly=e=>e._tag===\"Failure\",Qv=e=>e._tag===\"Failure\"?se(e):re(e),df=e=>e._tag===\"Failure\"?se(e.cause):re(e),dy=Cx(df,pr),Xv=Cx(df,ff),eA=e=>e._tag===\"Failure\"&&bp(e.cause),tA=e=>e._tag===\"Failure\"&&yp(e.cause),nA=e=>e._tag===\"Failure\"&&Qx(e.cause),hn=Oe(void 0),py=l(2,(e,t)=>e._tag===\"Success\"?Oe(t(e.value)):e),my=l(2,(e,t)=>{if(e._tag===\"Success\")return e;let n=pr(e.cause);return ie(n)?e:jn(t(n.success))}),hy=l(2,(e,t)=>{if(e._tag===\"Success\")return Oe(t.onSuccess(e.value));let n=pr(e.cause);return ie(n)?e:jn(t.onFailure(n.success))}),N$=l(2,(e,t)=>$s(e)?Oe(t):e),rA=l(2,(e,t)=>$s(e)?t:e),oA=l(2,(e,t)=>$s(e)?t.onSuccess(e.value):t.onFailure(e.cause)),sA=N$(void 0),gy=e=>{let t=[];for(let n of e)n._tag===\"Failure\"&&t.push(...n.cause.reasons);return t.length===0?hn:Mt(qo(t))},iA=e=>$s(e)?k(e.value):R(),aA=e=>ly(e)?k(e.cause):R(),cA=e=>{let t=dy(e);return ie(t)?R():k(t.success)},uA=e=>e,fA=e=>Y(t=>U(Ix(t.context,e))),L$=e=>Y(t=>ny(Ix(t.context,e))),fc=l(2,(e,t)=>Y(n=>{let r=n.context,o=t(r);return r===o?e:(n.setContext(o),Xo(e,()=>{n.setContext(r)}))})),Yo=l(3,(e,t,n)=>fc(e,r=>{let o=Fr(r,t),s=n(o);return o===s?r:Pt(r,t,s)})),lA=(e,t,n)=>es(Y(r=>{let o=Fr(r.context,e),s=t(o);return r.setContext(Pt(r.context,e,s)),Lr(Fr(r.context,Bs),i=>{let a=Fr(r.context,e),c;if(n?.reset===void 0){if(a!==s)return pe;c=o}else c=n.reset(o,s,a);return r.setContext(Pt(r.context,e,c)),pe})})),xy=()=>$$,$$=Y(e=>U(e.context)),js=e=>Y(t=>e(t.context)),dA=l(2,(e,t)=>fc(e,Le(t))),Qo=l(2,(e,t)=>De(e)?e:fc(e,np(t))),gn=function(){return arguments.length===1?l(2,(e,t)=>fv(e,arguments[0],t)):l(3,(e,t,n)=>fv(e,t,n)).apply(this,arguments)},fv=(e,t,n)=>fc(e,Pt(t,n)),_p=l(3,(e,t,n)=>q(n,r=>gn(e,t,r))),pA=l(e=>ee(e[1]),(e,t,n)=>yy(e,t,(r,o)=>[r,o],n)),yy=l(e=>ee(e[1]),(e,t,n,r)=>r?.concurrent?ke(bf([e,t],{concurrency:2}),([o,s])=>we(()=>n(o,s))):q(e,o=>ke(t,s=>we(()=>n(o,s))))),mA=l(e=>ee(e[0]),(e,t,n)=>By(e,t,n?r=>qe(n(r)):()=>qe(new Mr))),hA=l(2,(e,t)=>q(t,n=>n?uc(e):Ui)),by=l(2,(e,t)=>Array.from({length:t},()=>e)),gA=l(e=>ee(e[0]),(e,t,n)=>bf(by(e,t),n)),Mp=l(e=>ee(e[0]),(e,t)=>So({while:cn,body:Le(t?.disableYield?e:q(e,n=>cc)),step:_r})),Hn=l(2,(e,t)=>{let n=Object.create(j$);return n[Z]=e,n[sr]=t.length!==1?r=>t(r):t,n}),j$=zo({op:\"OnFailure\",[at](e){return e._stack.push(this),this[Z]}}),Sy=l(3,(e,t,n)=>Hn(e,r=>t(r)?we(()=>n(r)):Pe(r))),lc=l(3,(e,t,n)=>Hn(e,r=>{let o=t(r);return ie(o)?Pe(o.failure):we(()=>n(o.success,r))})),Jn=l(2,(e,t)=>lc(e,pr,n=>t(n))),xA=e=>Ni(e,{onFailure:t=>$d(t)?Ui:qe(t),onSuccess:oy}),yA=l(2,(e,t)=>lc(e,ff,t)),bA=l(2,(e,t)=>Hn(e,n=>Nr(we(()=>t(n)),Pe(n)))),SA=l(3,(e,t,n)=>Sy(e,t,r=>Nr(we(()=>n(r)),Pe(r)))),Pp=l(3,(e,t,n)=>Hn(e,r=>{let o=t(r);return ie(o)?Pe(r):Nr(we(()=>n(o.success,r)),Pe(r))})),Ey=l(2,(e,t)=>Pp(e,pr,n=>t(n))),EA=l(3,(e,t,n)=>{let r=Array.isArray(t)?o=>M(o,\"_tag\")&&t.includes(o._tag):$t(t);return Ey(e,o=>r(o)?n(o):pe)}),IA=l(2,(e,t)=>Pp(e,ff,n=>t(n))),pf=l(e=>ee(e[0]),(e,t,n,r)=>Hn(e,o=>{let s=pr(o);return ie(s)?Pe(s.failure):t(s.success)?we(()=>n(s.success)):r?we(()=>r(s.success)):Pe(o)})),Fp=l(e=>ee(e[0]),(e,t,n,r)=>Hn(e,o=>{let s=pr(o);if(ie(s))return Pe(s.failure);let i=t(s.success);return ie(i)?r?we(()=>r(i.failure)):Pe(o):we(()=>n(i.success))})),Iy=l(e=>ee(e[0]),(e,t,n,r)=>{let o=Array.isArray(t)?s=>M(s,\"_tag\")&&t.includes(s._tag):$t(t);return pf(e,o,n,r)}),wA=l(e=>ee(e[0]),(e,t,n)=>{let r;return Fp(e,o=>(r??=Object.keys(t),M(o,\"_tag\")&&$n(o._tag)&&r.includes(o._tag)?se(o):re(o)),o=>we(()=>t[o._tag](o)),n)}),TA=l(e=>ee(e[0]),(e,t,n,r,o)=>pf(e,s=>$t(s,t)&&M(s,\"reason\"),s=>{let i=s.reason;return $t(i,n)?r(i,s):o?we(()=>o(i,s)):qe(s)})),vA=l(e=>ee(e[0]),(e,t,n,r)=>{let o;return pf(e,s=>$t(s,t)&&M(s,\"reason\")&&M(s.reason,\"_tag\")&&$n(s.reason._tag),s=>{let i=s.reason;return o??=Object.keys(n),o.includes(i._tag)?we(()=>n[i._tag](i,s)):r?we(()=>r(i,s)):qe(s)})}),AA=l(2,(e,t)=>Fp(e,n=>$t(n,t)&&M(n,\"reason\")?se(n.reason):re(n),qe)),wy=l(2,(e,t)=>Jn(e,n=>Cp(()=>t(n)))),Ty=l(2,(e,t)=>Ni(e,{onFailure:n=>Cp(()=>t.onFailure(n)),onSuccess:n=>L(()=>t.onSuccess(n))})),vy=e=>Jn(e,Ls),CA=l(2,(e,t)=>Jn(e,n=>L(t))),OA=e=>Q(()=>{let t=e[Symbol.iterator](),n=t.next();if(n.done)return Ls(new Error(\"Received an empty collection of effects\"));function r(o){let s=t.next();return s.done?o.value:Jn(o.value,i=>r(s))}return r(n)}),Ay=e=>Jn(e,t=>q(cc,()=>Ay(e))),kA=l(e=>ee(e[0]),(e,t)=>{if(!t?.log)return Ni(e,{onFailure:r=>pe,onSuccess:r=>pe});let n=Br(t.log===!0?void 0:t.log);return Dr(e,{onFailure(r){let o=Xx(r);return ie(o)?Pe(o.failure):t.message===void 0?n(r):n(t.message,r)},onSuccess:r=>pe})}),Up=l(e=>ee(e[0]),(e,t)=>{if(!t?.log)return Dr(e,{onFailure:r=>pe,onSuccess:r=>pe});let n=Br(t.log===!0?void 0:t.log);return Dr(e,{onFailure:r=>t.message===void 0?n(r):n(t.message,r),onSuccess:r=>pe})}),RA=e=>Dp(e,{onFailure:R,onSuccess:k}),Us=e=>mf(e,{onFailure:re,onSuccess:se}),Dr=l(2,(e,t)=>{let n=Object.create(B$);return n[Z]=e,n[fn]=t.onSuccess.length!==1?r=>t.onSuccess(r):t.onSuccess,n[sr]=t.onFailure.length!==1?r=>t.onFailure(r):t.onFailure,n}),B$=zo({op:\"OnSuccessAndFailure\",[at](e){return e._stack.push(this),this[Z]}}),Cy=l(2,(e,t)=>Dr(e,{onFailure:n=>L(()=>t.onFailure(n)),onSuccess:n=>L(()=>t.onSuccess(n))})),Ni=l(2,(e,t)=>Dr(e,{onFailure:n=>{let r=n.reasons.find(Cs);return r?we(()=>t.onFailure(r.error)):Pe(n)},onSuccess:t.onSuccess})),Dp=l(2,(e,t)=>Ni(e,{onFailure:n=>L(()=>t.onFailure(n)),onSuccess:n=>L(()=>t.onSuccess(n))})),mf=l(2,(e,t)=>{if(De(e)){if(e._tag===\"Success\")return Oe(t.onSuccess(e.value));let n=pr(e.cause);return ie(n)?e:Oe(t.onFailure(n.success))}return Dp(e,t)}),_A=l(2,(e,t)=>De(e)?e._tag===\"Success\"?Oe(t.onSuccess(e.value)):Oe(t.onFailure(e.cause)):Cy(e,t)),hf=e=>De(e)?Oe(e):q$(e),q$=ir({op:\"Exit\",[at](e){return e._stack.push(this),this[Z]},[fn](e,t,n){return U(n??Oe(e))},[sr](e,t,n){return U(n??Mt(e))}}),MA=mf({onFailure:()=>!0,onSuccess:()=>!1}),PA=mf({onFailure:()=>!1,onSuccess:()=>!0}),FA=l(2,(e,t)=>Nr(ji(t),e)),Oy=l(2,(e,t)=>kp(e,q(ji(t.duration),t.orElse))),UA=l(2,(e,t)=>Oy(e,{duration:t,orElse:()=>qe(new rf)})),DA=l(2,(e,t)=>kp(uc(e),Zt(ji(t),R()))),NA=e=>jr(t=>{let n=t.monotonicTimeNanosUnsafe();return ke(e,r=>[dr(t.monotonicTimeNanosUnsafe()-n),r])}),Jx=\"~effect/Scope\",Kx=\"~effect/Scope/Closeable\",Bs=Vt(\"effect/Scope\"),ky=(e,t)=>Q(()=>Np(e,t)??pe),Np=(e,t)=>{if(e.state._tag===\"Closed\")return;let n={_tag:\"Closed\",exit:t};if(e.state._tag===\"Empty\"){e.state=n;return}let r=e.state;if(e.state=n,r.finalizer!==void 0)return r.finalizer(t);let o=r.finalizers;if(!(o===void 0||o.size===0))return o.size===1?o.values().next().value(t):W$(e,o,t)},z$=(e,t)=>$s(e)?t:Hn(t,n=>Pe(Ep(e.cause,n))),W$=ay(function*(e,t,n){let r=[],o=[],s=Array.from(t.values()),i=Zo();for(let a=s.length-1;a>=0;a--){let c=s[a];e.strategy===\"sequential\"?r.push(yield*hf(c(n))):o.push($i(i,c(n),!0,!0,\"inherit\"))}return o.length>0&&(r=yield*vp(o)),yield*gy(r)});var LA=(e,t)=>{let n=gf(t);if(e.state._tag===\"Closed\")return n.state=e.state,n;let r={};return Xu(e,r,o=>ky(n,o)),Xu(n,r,o=>L(()=>Ry(e,r))),n},Lr=(e,t)=>Q(()=>e.state._tag===\"Closed\"?t(e.state.exit):(Xu(e,{},t),pe)),$A=(e,t)=>Lr(e,Le(t)),Xu=(e,t,n)=>{if(e.state._tag===\"Empty\")e.state={_tag:\"Open\",finalizerKey:t,finalizer:n,finalizers:void 0};else if(e.state._tag===\"Open\"){let r=e.state;r.finalizer!==void 0?(r.finalizers=new Map([[r.finalizerKey,r.finalizer]]),r.finalizerKey=void 0,r.finalizer=void 0,r.finalizers.set(t,n)):r.finalizers===void 0?(r.finalizerKey=t,r.finalizer=n):r.finalizers.set(t,n)}},Ry=(e,t)=>{if(e.state._tag===\"Open\"){let n=e.state;n.finalizerKey===t?(n.finalizerKey=void 0,n.finalizer=void 0):n.finalizers!==void 0&&n.finalizers.delete(t)}},jA=e=>e.state._tag!==\"Open\"?0:e.state.finalizer!==void 0?1:e.state.finalizers?.size??0,gf=(e=\"sequential\")=>({[Kx]:Kx,[Jx]:Jx,strategy:e,state:H$}),H$={_tag:\"Empty\"};var xf=Bs,BA=gn(Bs),_y=e=>Y(t=>{let n=t.context,r=gf();return t.setContext(Pt(t.context,Bs,r)),Xo(e,o=>(t.setContext(n),Np(r,o)))});var Lp=e=>Q(()=>{let t=gf();return zt(e(t),n=>Q(()=>Np(t,n)??pe))}),Li=(e,t,n)=>js(r=>$r(o=>q(xf,s=>Di(n?.interruptible?o(e):e,i=>Lr(s,a=>Qo(t(i,a),r)))))),My=e=>q(xf,t=>js(n=>Lr(t,r=>Qo(e(r),n)))),Xo=ir({op:\"OnExit\",single:!1,[at](e){return e._stack.push(this),this[Z][0]},[As](e){e.interruptible&&this[Z][2]!==!0&&(e._stack.push($p),e.interruptible=!1)},[fn](e,t,n){n??=Oe(e);let r=this[Z][1](n);return r?q(r,o=>n):n},[sr](e,t,n){n??=Mt(e);let r=this[Z][1](n);return r?q(z$(n,r),o=>n):n}}),zt=l(2,Xo),qA=l(2,(e,t)=>zt(e,n=>t)),Py=l(3,(e,t,n)=>zt(e,r=>t(r)?n(r):pe)),Fy=l(3,(e,t,n)=>zt(e,r=>{let o=t(r);return ie(o)?pe:n(o.success,r)})),Uy=l(2,(e,t)=>Fy(e,df,t)),zA=l(3,(e,t,n)=>Py(e,r=>r._tag!==\"Failure\"?!1:t(r.cause),r=>n(r.cause))),Dy=l(3,(e,t,n)=>zt(e,r=>{if(r._tag!==\"Failure\")return pe;let o=t(r.cause);return ie(o)?pe:n(o.success,r.cause)})),WA=l(2,(e,t)=>Dy(Sp,t)(e)),yf=(e,t,n)=>$r(r=>q(e,o=>Xo(r(t(o)),s=>n(o,s),!0))),HA=e=>Li(e,t=>M(t,Symbol.asyncDispose)?sy(()=>t[Symbol.asyncDispose]()):L(()=>t[Symbol.dispose]())),Ny=l(2,(e,t)=>L(()=>{let n=_n(mt(t)),r=Number.isFinite(n),o=qp(!1),s=0,i=!1,a,c=q(o.await,()=>a);return[Y(u=>{let f=u.getRef(Kn),d=r?f.currentTimeMillisUnsafe():0;return i||d<s?a??c:(i=!0,o.closeUnsafe(),a=void 0,zt(e,p=>L(()=>{i=!1,s=f.currentTimeMillisUnsafe()+n,a=p,o.openUnsafe()})))}),L(()=>{s=0,o.closeUnsafe(),a=void 0})]})),Ly=l(2,(e,t)=>ke(Ny(e,t),n=>n[0])),JA=e=>Ly(e,Ur),KA=Y(e=>Pe(Ns(e.id))),es=e=>Y(t=>(t.interruptible&&(t.interruptible=!1,t._stack.push($p)),e)),GA=ir({op:\"SetInterruptible\",[As](e){if(e.interruptible=this[Z],e._interruptedCause&&e.interruptible)return()=>Pe(e._interruptedCause)}}),$p=GA(!0),J$=GA(!1),VA=e=>{if(e.interruptible=!0,e._stack.push(J$),e._interruptedCause)return Pe(e._interruptedCause)},$y=e=>Y(t=>t.interruptible?e:VA(t)??e),$r=e=>Y(t=>t.interruptible?(t.interruptible=!1,t._stack.push($p),e($y)):e(_)),ZA=e=>Y(t=>{if(t.interruptible)return e(_);let n=VA(t),r=e(es);return n??r}),YA=ke(Li(L(()=>new AbortController),e=>L(()=>e.abort())),e=>e.signal),bf=(e,t)=>$o(e)?t?.mode===\"result\"?Wn(e,Us,t):Wn(e,_,t):t?.discard?t.mode===\"result\"?Wn(Object.values(e),Us,t):Wn(Object.values(e),_,t):Q(()=>{let n={};return Zt(Wn(Object.entries(e),([r,o])=>ke(t?.mode===\"result\"?Us(o):o,s=>{F(n,r,s)}),{discard:!0,concurrency:t?.concurrency}),n)}),jy=l(e=>$o(e[0])&&!ee(e[0]),(e,t,n)=>ke(Wn(e,(r,o)=>Us(t(r,o)),n),r=>ux(r,_))),QA=l(3,(e,t,n)=>{let r=Be(e);return r.length===0?L(t):Q(()=>{let o=0,s=t();return ke(So({while:()=>o<r.length,body:()=>n(s,r[o],o),step(i){s=i,o++}}),()=>s)})}),XA=l(e=>$o(e[0])&&!ee(e[0]),(e,t,n)=>q(jy(e,t,{concurrency:n?.concurrency}),([r,o])=>qt(r)?qe(r):n?.discard?pe:U(o))),eC=l(e=>$o(e[0])&&!ee(e[0]),(e,t)=>Q(()=>{let n=e[Symbol.iterator](),r=n.next();return r.done?U(R()):tC(n,0,t,r.value)})),tC=(e,t,n,r)=>q(n(r,t),o=>{if(o)return U(k(r));let s=e.next();return s.done?U(R()):tC(e,t+1,n,s.value)}),nC=l(e=>$o(e[0])&&!ee(e[0]),(e,t)=>Q(()=>{let n=e[Symbol.iterator](),r=n.next();return r.done?U(R()):rC(n,0,t,r.value)})),rC=(e,t,n,r)=>q(n(r,t),o=>{if(Tt(o))return U(k(o.success));let s=e.next();return s.done?U(R()):rC(e,t+1,n,s.value)}),So=ir({op:\"While\",[fn](e,t){return this[Z].step(e),this[Z].while()?(t._stack.push(this),this[Z].body()):hn},[at](e){return this[Z].while()?(e._stack.push(this),this[Z].body()):hn}}),Wn=l(e=>typeof e[1]==\"function\",(e,t,n)=>Q(()=>{let r=n?.concurrency??1,o=r===\"unbounded\"?Number.POSITIVE_INFINITY:Math.max(1,r);if(o===1)return K$(e,t,n);let s=Be(e),i=s.length;if(i===0)return n?.discard?pe:U([]);let a=n?.discard?void 0:new Array(i),c=G$({f:t,out:a},s,{concurrency:o});return c?Zt(c,a):U(a)})),oC=e=>q(e,t=>{let n=t[Symbol.iterator]().next();return n.done?qe(new Mr):U(n.value)}),K$=(e,t,n)=>Q(()=>{let r=n?.discard?void 0:[],o=e[Symbol.iterator](),s=o.next(),i=0;return Zt(So({while:()=>!s.done,body:()=>t(s.value,i++),step:a=>{r&&r.push(a),s=o.next()}}),r)}),sC=e=>{let t=e.onItem,n=e.step,r=(o,s,i,a)=>{for(;i<a;i++){let c=s[i],u=t(o,c,i);if(!De(u))return q(hf(u),d=>n(o,c,d,i)??r(o,s,i+1,a)??pe);let f=n(o,c,u,i);if(f)return f._tag===\"Failure\"?f:void 0}};return(o,s,i)=>{let a=0,c=i?.end??s.length,u=i?.concurrency??1;if(u===1)return r(o,s,0,c);let f=i?.orderedStep===!0,d=!1,p,m,g,b=!1,I,w,E=a,h=f?new Array(c):void 0,A=v=>{let C=ar(v);return I=C,d=!0,b=!0,m&&m.size>0?q(es(Ds(Array.from(m))),()=>C):C},y=(v,C,V)=>{if(!f)return n(o,v,C,V);if(I)return I;for(h[V]=C;E<c;){let ce=h[E];if(ce===void 0)return;h[E]=void 0;let W=E++,he=n(o,s[W],ce,W);if(he)return he}},S=()=>{let v=!1;for(;!I&&a<c;a++){let C=s[a],V=w??t(o,C,a);if(De(V)){if(I=y(C,V,a),I)break}else if(p){w=void 0;let ce=$i(p,V,!0,!0,\"inherit\");if(ce._exit){if(I=y(C,ce._exit,a),I)break;continue}m.add(ce);let W=a;if(ce.addObserver(he=>{m.delete(ce);try{if(I){if(!b&&he._tag===\"Failure\")for(let de of he.cause.reasons)de._tag!==\"Interrupt\"&&(I._tag===\"Failure\"?I.cause.reasons.push(de):I=Mt(qo([de])))}else{let de=y(C,he,W);de&&(I=de._tag===\"Failure\"?Mt(qo(de.cause.reasons.slice())):de,S())}if(v){let de=S();de&&g(de)}else d&&m.size===0&&g(I??pe)}catch(de){g(A(de))}}),m.size<u)continue;v=!0,a++;return}else return ct(ce=>{p=Zo(),m=new Set,w=V,g=ce;let W;try{W=S()}catch(he){return ce(A(he))}return W?ce(W):Q(()=>(I=hn,b=!0,m?Ds(m):pe))})}if(d=!0,I){if(m&&m.size>0){let C=Tp(p);m.forEach(V=>V.interruptUnsafe(p.id,C));return}if(g||I._tag===\"Failure\")return I}else if(g)if(m)m.size===0&&g(pe);else return hn};return S()}},Sf=()=>sC,G$=sC({onItem(e,t,n){return e.f(t,n)},step(e,t,n,r){if(n._tag===\"Failure\")return n;e.out&&(e.out[r]=n.value)}}),By=l(3,(e,t,n)=>q(e,r=>t(r)?U(r):n(r))),qy=l(3,(e,t,n)=>q(e,r=>{let o=t(r);return ie(o)?n(o.failure):U(o.success)})),iC=l(e=>ee(e[0]),(e,t,n)=>qy(e,t,n?r=>qe(n(r)):()=>qe(new Mr))),aC=l(e=>$o(e[0])&&!ee(e[0]),(e,t,n)=>Q(()=>{let r=[];return Zt(Wn(e,(o,s)=>{let i=t(o,s);return typeof i==\"boolean\"?(i&&r.push(o),pe):ke(i,a=>{a&&r.push(o)})},{discard:!0,concurrency:n?.concurrency}),r)})),cC=l(e=>$o(e[0])&&!ee(e[0]),(e,t)=>Q(()=>{let n=[];for(let r of e){let o=t(r);Tt(o)&&n.push(o.success)}return U(n)})),uC=l(e=>$o(e[0])&&!ee(e[0]),(e,t,n)=>Q(()=>{let r=[];return Zt(Wn(e,o=>ke(t(o),s=>{Tt(s)&&r.push(s.success)}),{discard:!0,concurrency:n?.concurrency}),r)})),fC=U({}),lC=Md(ke),dC=Pd(ke,q),pC=_d(ke);var mC=l(e=>ee(e[0]),(e,t)=>Y(n=>(uj(),U($i(n,e,t?.startImmediately,!1,t?.uninterruptible??!1))))),$i=(e,t,n=!1,r=!1,o=!1)=>{let s=e,i=o===\"inherit\"?s.interruptible:!o,a=new hp(s.context,i);return n?a.evaluate(t):s.currentDispatcher.scheduleTask(()=>a.evaluate(t),0),!r&&!a._exit&&(s.children().add(a),a.addObserver(()=>s._children.delete(a))),a},hC=l(e=>ee(e[0]),(e,t)=>Y(n=>U($i(n,e,t?.startImmediately,!0,t?.uninterruptible)))),gC=e=>Y(t=>{let n=t._children&&new Set(t._children);return zt(e,r=>{let o=t._children;return o===void 0||o.size===0?pe:(n&&(o=oT(o,s=>!n.has(s))),lf(vp(o)))})}),zy=l(e=>ee(e[0]),(e,t,n)=>Y(r=>{let o=$i(r,e,n?.startImmediately,!0,n?.uninterruptible);if(!o._exit)if(t.state._tag!==\"Closed\"){let s={};Xu(t,s,()=>Fv(a=>a===o.id?pe:Ap(o))),o.addObserver(()=>Ry(t,s))}else o.interruptUnsafe(r.id,Tp(r));return U(o)})),xC=l(e=>ee(e[0]),(e,t)=>q(xf,n=>zy(e,n,t))),ts=e=>(t,n)=>{let r=new hp(n?.scheduler?Pt(e,Vu,n.scheduler):e,n?.uninterruptible!==!0);if(r.evaluate(t),r._exit)return r;if(n?.signal)if(n.signal.aborted)r.interruptUnsafe();else{let o=()=>r.interruptUnsafe();n.signal.addEventListener(\"abort\",o,{once:!0}),r.addObserver(()=>n.signal.removeEventListener(\"abort\",o))}return n?.onFiberStart&&n.onFiberStart(r),r},yC=l(2,(e,t)=>{if(e._exit)return e;if(t.state._tag===\"Closed\")return e.interruptUnsafe(e.id),e;let n={};return Xu(t,n,()=>Ap(e)),e.addObserver(()=>Ry(t,n)),e}),bC=ts(pn()),Wy=e=>{let t=ts(e);return(n,r)=>{let o=t(n,r);return r?.onExit&&o.addObserver(r.onExit),s=>o.interruptUnsafe(s)}},SC=Wy(pn()),jp=e=>{let t=ts(e);return(n,r)=>{let o=t(n,r);return new Promise(s=>{o.addObserver(i=>s(i))})}},EC=jp(pn()),Hy=e=>{let t=jp(e);return(n,r)=>t(n,r).then(o=>{if(o._tag===\"Failure\")throw Ip(o.cause);return o.value})},IC=Hy(pn()),Bp=e=>{let t=ts(e);return n=>{if(De(n))return n;let r=new _i(\"sync\"),o=t(n,{scheduler:r});return o._dispatcher?.flush(),o._exit??ar(new cf(o))}},wC=Bp(pn()),Jy=e=>{let t=Bp(e);return n=>{let r=t(n);if(r._tag===\"Failure\")throw Ip(r.cause);return r.value}},TC=Jy(pn()),lv=U(!0),dv=U(!1),Gx=class{waiters=[];scheduled=void 0;_isOpen;constructor(t){this._isOpen=t}scheduleUnsafe(t){if(this.waiters.length===0)return lv;if(this.scheduled===void 0)this.scheduled=this.waiters,t.currentDispatcher.scheduleTask(this.flushScheduled,0);else for(let n=0;n<this.waiters.length;n++)this.scheduled.push(this.waiters[n]);return this.waiters=[],lv}flushScheduled=()=>{if(this.scheduled===void 0)return;let t=this.scheduled;this.scheduled=void 0;for(let n=0;n<t.length;n++)t[n](hn)};flushWaiters(){let t=this.waiters;this.waiters=[],this.flushScheduled();for(let n=0;n<t.length;n++)t[n](hn)}open=Y(t=>this._isOpen?dv:(this._isOpen=!0,this.scheduleUnsafe(t)));release=Y(t=>this._isOpen?dv:this.scheduleUnsafe(t));openUnsafe(){return this._isOpen?!1:(this._isOpen=!0,this.flushWaiters(),!0)}await=ct(t=>this._isOpen?t(pe):(this.waiters.push(t),L(()=>{let n=this.waiters.indexOf(t);n!==-1?this.waiters.splice(n,1):this.scheduled!==void 0&&(n=this.scheduled.indexOf(t),n!==-1&&this.scheduled.splice(n,1))})));closeUnsafe(){return this._isOpen?(this._isOpen=!1,!0):!1}close=L(()=>this.closeUnsafe());whenOpen=t=>q(this.await,()=>t);isOpen(){return this._isOpen}},qp=e=>new Gx(e??!1),vC=e=>L(()=>qp(e)),AC=Y(e=>U(e.getRef(fp))),CC=l(2,(e,t)=>gn(e,fp,t)),OC=gn(lp),kC=gn(Pi),Vx=BigInt(0),V$={_tag:\"Span\",spanId:\"noop\",traceId:\"noop\",sampled:!1,status:{_tag:\"Ended\",startTime:Vx,endTime:Vx,exit:hn},attributes:new Map,links:[],kind:\"internal\",attribute(){},event(){},end(){},addLinks(){}},Z$=e=>Object.assign(Object.create(V$),e),RC=e=>e?Xe(e.annotations,up)?e._tag===\"Span\"?RC(ks(e.parent)):R():k(e):R(),Ky=(e,t,n)=>{let r=!e.getRef(lp)||n?.annotations&&Xe(n.annotations,up),o=n?.parent!==void 0?k(n.parent):n?.root?R():RC(e.currentSpan),s;if(r)s=Z$({name:t,parent:o,annotations:Pt(n?.annotations??pn(),up,!0)});else{let i=e.getRef(fp),a=e.getRef(Kn),c=e.getRef(Pi),u=e.getRef(Yu),f=e.getRef(Qu),d=n?.level??e.getRef(nv),p=n?.links!==void 0?[...f,...n.links]:f.length===0?[]:f.slice();s=i.span({name:t,parent:o,annotations:n?.annotations??pn(),links:p,startTime:c?a.currentTimeNanosUnsafe():BigInt(0),kind:n?.kind??\"internal\",root:n?.root??ae(o),sampled:n?.sampled??(_e(o)&&o.value.sampled===!1?!1:!GC(e.getRef(rv),d))});for(let m in u)s.attribute(m,u[m]);if(n?.attributes!==void 0)for(let m in n.attributes)s.attribute(m,n.attributes[m])}return s},_C=(e,t)=>Y(n=>U(Ky(n,e,t))),ef=(e,t)=>es(Y(n=>{let r=Fr(n.context,Bs),o=Ky(n,e,t??{}),s=n.getRef(Kn),i=n.getRef(Pi);return Zt(Lr(r,a=>zp(o,a,s,i)),o)})),MC=function(){let e=typeof arguments[0]!=\"string\",t=e?arguments[1]:arguments[0],n=Fs(e?arguments[2]:arguments[1]);if(e){let r=arguments[0];return q(ef(t,n),o=>sc(r,o,n))}return r=>q(ef(t,n),o=>sc(r,o,n))},Y$=(e,t)=>(t=typeof t==\"function\"?t:fo,Yo(Zu,n=>({name:e,stack:t,parent:n}))),PC=Yu,FC=Qu,UC=l(e=>ee(e[0]),(e,t,n={})=>{let o=(Array.isArray(t)?t:[t]).map(s=>({span:s,attributes:n}));return Yo(e,Qu,s=>[...s,...o])}),zp=(e,t,n,r)=>L(()=>{e.status._tag!==\"Ended\"&&e.end(r?n.currentTimeNanosUnsafe():Vx,t)}),tf=(e,...t)=>{let n=t.length===1?void 0:t[0],r=t[t.length-1];return Y(o=>{let s=Ky(o,e,n),i=o.getRef(Kn),a=o.getRef(Pi);return zt(we(()=>r(s)),c=>zp(s,c,i,a))})},Zx=gn(Mi),sc=function(){let e=ee(arguments[0]),t=e?arguments[1]:arguments[0],n=e?arguments[2]:arguments[1],r=_;return t._tag===\"Span\"&&(n=Fs(n),r=Y$(t.name,n?.captureStackTrace)),e?Zx(r(arguments[0]),t):o=>Zx(r(o),t)},DC=function(){let e=typeof arguments[0]!=\"string\",t=e?arguments[1]:arguments[0],n=Fs(arguments[2]);if(e){let s=arguments[0];return tf(t,arguments[2],i=>sc(s,i,n))}let r=typeof arguments[1]==\"function\"?arguments[1]:void 0,o=r?void 0:arguments[1];return(s,...i)=>tf(t,r?r(...i):o,a=>sc(s,a,n))},NC=l(e=>ee(e[0]),(e,...t)=>Yo(e,Yu,n=>{let r=t.length===1?{...n,...t[0]}:{...n};return t.length===1||F(r,t[0],t[1]),r})),LC=(...e)=>Y(t=>{let n=t.currentSpanLocal;if(n)if(e.length===1)for(let[r,o]of Object.entries(e[0]))n.attribute(r,o);else n.attribute(e[0],e[1]);return pe}),$C=Y(e=>{let t=e.currentSpanLocal;return t?U(t):qe(new Mr)}),jC=L$(Mi),Kn=Ae(\"effect/Clock\",{defaultValue:()=>new Yx}),zx=2**31-1,Yx=class{currentTimeMillisUnsafe(){return Date.now()}currentTimeMillis=L(()=>this.currentTimeMillisUnsafe());currentTimeNanosUnsafe(){return Q$()}currentTimeNanos=L(()=>this.currentTimeNanosUnsafe());monotonicTimeNanosUnsafe(){return qC()}monotonicTimeNanos=L(()=>this.monotonicTimeNanosUnsafe());sleep(t){return this.sleepMillis(_n(t))}sleepMillis(t){return t<=0?cc:Number.isFinite(t)?ct(n=>{let r=t>zx?this.sleepMillis(t-zx):pe,o=setTimeout(()=>n(r),Math.min(t,zx));return L(()=>clearTimeout(o))}):Op}},BC=BigInt(1e6),qC=(function(){let e=globalThis.process?.hrtime;if(typeof e?.bigint==\"function\")return()=>e.bigint();if(typeof performance<\"u\"&&typeof performance.now==\"function\")return()=>BigInt(Math.round(performance.now()*1e6));let t=BigInt(0);return()=>{let n=BigInt(Date.now())*BC;return n>t&&(t=n),t}})(),Q$=(function(){let e=BigInt(1e9),t;return()=>{let n=qC(),r=BigInt(Date.now())*BC;if(t===void 0)t=r-n;else{let o=t+n;(r>o?r-o:o-r)>e&&(t=r-n)}return t+n}})(),jr=e=>Y(t=>e(t.getRef(Kn))),ji=e=>jr(t=>t.sleep(mt(e))),Gy=jr(e=>e.currentTimeMillis);var nf=\"~effect/Cause/TimeoutError\",zC=e=>M(e,nf),rf=class extends Wo(\"TimeoutError\"){[nf]=nf;constructor(t){super({message:t})}},of=\"~effect/Cause/IllegalArgumentError\",WC=e=>M(e,of),gp=class extends Wo(\"IllegalArgumentError\"){[of]=of;constructor(t){super({message:t})}},sf=\"~effect/Cause/ExceededCapacityError\",HC=e=>M(e,sf),xp=class extends Wo(\"ExceededCapacityError\"){[sf]=sf;constructor(t){super({message:t})}},af=\"~effect/Cause/AsyncFiberError\",JC=e=>M(e,af),cf=class extends Wo(\"AsyncFiberError\"){[af]=af;constructor(t){super({message:\"An asynchronous Effect was executed with Effect.runSync\",fiber:t})}},uf=\"~effect/Cause/UnknownError\",KC=e=>M(e,uf),ic=class extends Wo(\"UnknownError\"){[uf]=uf;constructor(t,n){super({message:n,cause:t})}},Vy=Ae(\"effect/Console/CurrentConsole\",{defaultValue:()=>globalThis.console}),X$=e=>{switch(e){case\"All\":return Number.MIN_SAFE_INTEGER;case\"Fatal\":return 5e4;case\"Error\":return 4e4;case\"Warn\":return 3e4;case\"Info\":return 2e4;case\"Debug\":return 1e4;case\"Trace\":return 0;case\"None\":return Number.MAX_SAFE_INTEGER}},ej=Zg(Bn,X$),GC=Os(ej),Zy=Ae(\"effect/Loggers/CurrentLoggers\",{defaultValue:()=>new Set([aj,cj])}),tj=Ae(\"effect/Logger/LogToStderr\",{defaultValue:Cu}),VC=function(){let e=typeof arguments[0]==\"string\"?[[arguments[0],arguments[1]]]:Object.entries(arguments[0]);return es(Y(t=>{let n=t.getRef(Vo),r={...n};for(let o=0;o<e.length;o++){let[s,i]=e[o];F(r,s,i)}return t.setContext(Pt(t.context,Vo,r)),Lr(Fr(t.context,Bs),o=>{let s=t.getRef(Vo),i={...s};for(let a=0;a<e.length;a++){let[c,u]=e[a];s[c]===u&&(Object.hasOwn(n,c)?F(i,c,n[c]):delete i[c])}return t.setContext(Pt(t.context,Vo,i)),pe})}))},nj=\"~effect/Logger\",rj={[nj]:{_Message:_,_Output:_},pipe(){return K(this,arguments)}},ZC=e=>{let t=Object.create(rj);return t.log=e,t},oj=e=>e.replace(/[\\s=\"]/g,\"_\"),sj=(e,t)=>`${oj(e[0])}=${t-e[1]}ms`;var Br=e=>(...t)=>{let n;for(let r=0,o=t.length;r<o;r++){let s=t[r];Ga(s)&&(n?t.splice(r,1):t=t.slice(0,r).concat(t.slice(r+1)),n=n?qo(n.reasons.concat(s.reasons)):s,r--)}return n===void 0&&(n=Nd),Y(r=>{let o=e??r.currentLogLevel;if(GC(r.minimumLogLevel,o))return pe;let s=r.getRef(Kn),i=r.getRef(Zy);if(i.size>0){let a=new Date(s.currentTimeMillisUnsafe());for(let c of i)c.log({cause:n,fiber:r,date:a,logLevel:o,message:t})}return pe})};var Fi={bold:\"1\",red:\"31\",green:\"32\",yellow:\"33\",blue:\"34\",cyan:\"36\",white:\"37\",gray:\"90\",black:\"30\",bgBrightRed:\"101\"},H9={None:[],All:[],Trace:[Fi.gray],Debug:[Fi.blue],Info:[Fi.green],Warn:[Fi.yellow],Error:[Fi.red],Fatal:[Fi.bgBrightRed,Fi.black]};var ij=e=>`${e.getHours().toString().padStart(2,\"0\")}:${e.getMinutes().toString().padStart(2,\"0\")}:${e.getSeconds().toString().padStart(2,\"0\")}.${e.getMilliseconds().toString().padStart(3,\"0\")}`;var aj=ZC(({cause:e,date:t,fiber:n,logLevel:r,message:o})=>{let s=Array.isArray(o)?o.slice():[o];e.reasons.length>0&&s.push(wp(e));let i=t.getTime(),a=n.getRef(dp),c=\"\";for(let p of a)c+=` ${sj(p,i)}`;let u=n.getRef(Vo);Object.keys(u).length>0&&s.push(u);let f=n.getRef(Vy);(n.getRef(tj)?f.error:f.log)(`[${ij(t)}] ${r.toUpperCase()} (#${n.id})${c}:`,...s)}),cj=ZC(({cause:e,fiber:t,logLevel:n,message:r})=>{let o=t.getRef(Kn),s=t.getRef(Vo),i=t.currentSpan;if(i===void 0||i._tag===\"ExternalSpan\")return;let a={};for(let[c,u]of Object.entries(s))F(a,c,u);a[\"effect.fiberId\"]=t.id,a[\"effect.logLevel\"]=n.toUpperCase(),e.reasons.length>0&&(a[\"effect.cause\"]=wp(e)),i.event(_0(Array.isArray(r)&&r.length===1?r[0]:r),o.currentTimeNanosUnsafe(),a)});function uj(){Hx.interruptChildren??=F$}var Yy=U(void 0);var YC=l(e=>ee(e[0]),(e,t)=>Uy(e,n=>Y(r=>(fj(r,n,t?.defectsOnly),pe)))),fj=(e,t,n)=>{let r=e.getRef(iv);if(r.size===0||n&&!yp(t))return;let o={cause:t,fiber:e,timestamp:e.getRef(Kn).currentTimeNanosUnsafe()};r.forEach(s=>s.report(o))};var lj=Ha,dj=Ja,eb=Ga,tb=D0,Ef=Cs,pj=Si,mj=Ei,Bi=qo,nb=Nd,hj=Va,rb=Hg,gj=Ns,If=e=>new xi(e),wf=e=>new Mu(e),Tf=pv,ob=yv,ns=Ev,xj=Ep,Wp=Ip,sb=Qx,yj=Xx,ib=pr,bj=mv,Sj=yp,Ej=hv,Ij=ff,wj=bp,Tj=gv,vj=xv,Aj=Sp,Cj=ey,ab=wp,Oj=$d,kj=Pu,Rj=Mr,dc=Du,_j=yi,cb=Ii,G=$0,Mj=nf,Pj=zC,Fj=rf,Uj=of,Dj=WC,vf=gp,Nj=HC,Lj=sf,ub=xp,$j=af,jj=JC,Bj=cf,qj=uf,zj=KC,Wj=ic,Hj=Za,Jj=bv,Kj=Sv,Qy=class extends Vt()(\"effect/Cause/StackTrace\"){},Xy=class extends Vt()(\"effect/Cause/InterruptorStackTrace\"){};var D={};uo(D,{Do:()=>xq,Transaction:()=>yc,TypeId:()=>uq,abortSignal:()=>_z,acquireDisposable:()=>Ez,acquireRelease:()=>Xi,acquireUseRelease:()=>tk,addFinalizer:()=>Iz,all:()=>om,andThen:()=>yn,annotateCurrentSpan:()=>qz,annotateLogs:()=>bW,annotateLogsScoped:()=>SW,annotateSpans:()=>Bz,as:()=>At,asSome:()=>um,asVoid:()=>zs,awaitAllChildren:()=>nW,bind:()=>Sq,bindTo:()=>yq,cached:()=>Cz,cachedInvalidateWithTTL:()=>kz,cachedWithTTL:()=>Oz,callback:()=>Wb,catch:()=>Ws,catchCause:()=>ft,catchCauseFilter:()=>Nq,catchCauseIf:()=>Dq,catchDefect:()=>zf,catchEager:()=>vc,catchFilter:()=>Fq,catchIf:()=>Pq,catchNoSuchElement:()=>Uq,catchReason:()=>Rq,catchReasons:()=>_q,catchTag:()=>Oq,catchTags:()=>kq,clockWith:()=>ta,context:()=>Yi,contextWith:()=>Wf,currentParentSpan:()=>Wz,currentSpan:()=>zz,delay:()=>Vb,die:()=>hr,effectify:()=>MW,ensuring:()=>ea,eventually:()=>qq,exit:()=>gr,fail:()=>P,failCause:()=>tt,failCauseSync:()=>cs,failSync:()=>am,fiber:()=>rW,fiberId:()=>oW,filter:()=>Yb,filterMap:()=>oz,filterMapEffect:()=>Qb,filterMapOrElse:()=>iz,filterMapOrFail:()=>cz,filterOrElse:()=>sz,filterOrFail:()=>az,findFirst:()=>pq,findFirstFilter:()=>mq,firstSuccessOf:()=>Yq,flatMap:()=>T,flatMapEager:()=>lt,flatten:()=>Jb,flip:()=>Aq,fn:()=>lW,fnUntraced:()=>xe,fnUntracedEager:()=>gm,forEach:()=>qs,forever:()=>Ct,forkChild:()=>wc,forkDetach:()=>tW,forkIn:()=>yt,forkScoped:()=>Tc,fromNullishOr:()=>Tq,fromOption:()=>Iq,fromResult:()=>Hb,gen:()=>en,head:()=>hq,ignore:()=>Jq,ignoreCause:()=>Kq,interrupt:()=>vo,interruptible:()=>Rz,interruptibleMask:()=>sS,isEffect:()=>wo,isFailure:()=>pz,isSuccess:()=>mz,let:()=>bq,linkSpans:()=>Kz,log:()=>dW,logDebug:()=>gW,logError:()=>uS,logFatal:()=>pW,logInfo:()=>hW,logTrace:()=>xW,logWarning:()=>mW,logWithLevel:()=>hm,makeSpan:()=>iS,makeSpanScoped:()=>Gz,map:()=>$,mapBoth:()=>Lq,mapBothEager:()=>Ut,mapEager:()=>br,mapError:()=>fm,mapErrorEager:()=>fs,match:()=>Xb,matchCause:()=>fz,matchCauseEager:()=>lz,matchCauseEffect:()=>Zi,matchCauseEffectEager:()=>dz,matchEager:()=>uz,matchEffect:()=>eS,never:()=>Gi,onError:()=>Wr,onErrorFilter:()=>Tz,onErrorIf:()=>wz,onExit:()=>yr,onExitFilter:()=>Az,onExitIf:()=>vz,onExitPrimitive:()=>oS,onInterrupt:()=>Ic,option:()=>vq,orDie:()=>Gb,orElseSucceed:()=>Zq,partition:()=>fq,promise:()=>sm,provide:()=>hz,provideContext:()=>xr,provideService:()=>To,provideServiceEffect:()=>Sz,race:()=>rz,raceAll:()=>Zb,raceAllFirst:()=>nz,raceFirst:()=>Ec,reduce:()=>lq,repeat:()=>Mz,repeatOrElse:()=>Pz,replicate:()=>Fz,replicateEffect:()=>Uz,request:()=>Xz,requestUnsafe:()=>eW,result:()=>cm,retry:()=>zq,retryOrElse:()=>Wq,runCallback:()=>iW,runCallbackWith:()=>sW,runFork:()=>Hf,runForkWith:()=>Ao,runPromise:()=>aS,runPromiseExit:()=>mm,runPromiseExitWith:()=>aW,runPromiseWith:()=>cS,runSync:()=>cW,runSyncExit:()=>Js,runSyncExitWith:()=>fW,runSyncWith:()=>uW,sandbox:()=>Hq,satisfiesErrorType:()=>FW,satisfiesServicesType:()=>UW,satisfiesSuccessType:()=>PW,schedule:()=>Dz,scheduleFrom:()=>nk,scope:()=>Qi,scoped:()=>pm,scopedWith:()=>rS,service:()=>tS,serviceOption:()=>nS,setContext:()=>gz,sleep:()=>Vi,spanAnnotations:()=>Hz,spanLinks:()=>Jz,succeed:()=>x,succeedNone:()=>as,succeedSome:()=>im,suspend:()=>z,sync:()=>oe,tap:()=>bn,tapCause:()=>XO,tapCauseFilter:()=>Bq,tapCauseIf:()=>jq,tapDefect:()=>ek,tapError:()=>lm,tapErrorTag:()=>$q,timed:()=>tz,timeout:()=>Qq,timeoutOption:()=>Xq,timeoutOrElse:()=>ez,tracer:()=>Nz,track:()=>IW,trackDefects:()=>vW,trackDuration:()=>AW,trackErrors:()=>TW,trackSuccesses:()=>wW,transposeOption:()=>wq,try:()=>Sc,tryPromise:()=>bc,tx:()=>CW,txRetry:()=>_W,undefined:()=>gq,uninterruptible:()=>us,uninterruptibleMask:()=>Hs,unwrapReason:()=>Mq,updateContext:()=>xz,updateService:()=>yz,updateServiceScoped:()=>bz,useSpan:()=>Vz,validate:()=>dq,void:()=>te,when:()=>dm,whileLoop:()=>zr,withErrorReporting:()=>Vq,withExecutionPlan:()=>Gq,withFiber:()=>Gn,withLogSpan:()=>EW,withLogger:()=>yW,withParentSpan:()=>Qz,withSpan:()=>Zz,withSpanScoped:()=>Yz,withTracer:()=>Lz,withTracerEnabled:()=>$z,withTracerTiming:()=>jz,yieldNow:()=>qf,yieldNowWith:()=>Eq,zip:()=>Kb,zipWith:()=>Cq});var Xt={};uo(Xt,{asVoid:()=>iB,asVoidAll:()=>aB,die:()=>fb,fail:()=>rs,failCause:()=>Qt,filterCause:()=>eB,filterFailure:()=>Xj,filterSuccess:()=>Yj,filterValue:()=>Qj,findDefect:()=>nB,findError:()=>tB,findErrorOption:()=>fB,getCause:()=>uB,getSuccess:()=>cB,hasDies:()=>Vj,hasFails:()=>Gj,hasInterrupts:()=>Zj,interrupt:()=>lb,isExit:()=>Af,isFailure:()=>os,isSuccess:()=>et,map:()=>rB,mapBoth:()=>sB,mapError:()=>oB,match:()=>db,succeed:()=>Yt,void:()=>ut});var Af=Wg,Yt=Oe,Qt=Mt,rs=jn,fb=ar,lb=Rp,ut=hn;var et=$s,os=ly,Gj=nA,Vj=tA,Zj=eA,Yj=Zv,Qj=Yv,Xj=Qv,eB=df,tB=dy,nB=Xv,db=oA,rB=py,oB=my,sB=hy,iB=sA,aB=gy,cB=iA,uB=aA,fB=cA;var lB=\"~effect/Deferred\";var dB={[lB]:{_A:_,_E:_},pipe(){return K(this,arguments)}},qi=()=>{let e=Object.create(dB);return e.resumes=void 0,e.effect=void 0,e};var zi=e=>ct(t=>e.effect?t(e.effect):(e.resumes??=[],e.resumes.push(t),L(()=>{let n=e.resumes;if(n===void 0)return;let r=n.indexOf(t);r>=0&&n.splice(r,1)})));var pB=l(2,(e,t)=>L(()=>pc(e,t))),pb=pB;var mB=l(2,(e,t)=>pb(e,Mt(t)));var mb=l(2,(e,t)=>mB(e,Ns(t)));var pc=(e,t)=>{if(e.effect)return!1;if(e.effect=t,e.resumes){let n=e.resumes;e.resumes=void 0;for(let r=0;r<n.length;r++)n[r](t)}return!0};var QC=Vo;var XC=dp;var eO=Pi;var xn=Bs;var mr=gf,ss=BA,Eo=Lr,Ht=$A;var vt=LA,Fe=ky;var rO=\"~effect/Layer\",tO=\"~effect/Layer/MemoMap\",gB=(e,t)=>(e.observers++,Nr(Lr(t,n=>e.finalizer(n)),e.effect)),oO=e=>M(e,rO),xB={[rO]:{_ROut:_,_E:_,_RIn:_},pipe(){return K(this,arguments)}},sO=e=>{let t=Object.create(xB);return t.build=e,t},hb=e=>sO((t,n)=>{let r=vt(n);return zt(e(t,r),o=>o._tag===\"Failure\"?Fe(r,o):pe)}),yB=e=>{let t=hb((n,r)=>n.getOrElseMemoize(t,r,e));return t},bB=(e,t,n,r)=>{let o=mr(),s=qi(),i={observers:1,effect:zi(s),finalizer:a=>Q(()=>(i.observers--,i.observers===0?(e.map.delete(t),Fe(o,a)):pe))};return e.map.set(t,i),Lr(n,i.finalizer).pipe(q(()=>r(e,o)),zt(a=>(i.effect=a,pb(s,a))))},Jp=class{get[tO](){return tO}parent;constructor(t){this.parent=t}map=new Map;get(t,n){let r=this.map.get(t);return r?gB(r,n):this.parent?.get(t,n)}getOrElseMemoize(t,n,r){return Q(()=>{let o=this.get(t,n);return o||bB(this,t,n,r)})}},Of=()=>new Jp,SB=e=>new Jp(e);var Cf=class e extends Vt()(\"effect/Layer/CurrentMemoMap\"){static forkOrCreate(t){let n=tp(t,e);return n?SB(n):Of()}},kf=l(3,(e,t,n)=>gn(ke(e.build(t,n),Pt(Cf,t)),Cf,t));var Gp=l(2,(e,t)=>Y(n=>kf(e,Cf.forkOrCreate(n.context),t))),iO=function(){return arguments.length===1?e=>Kp(ki(arguments[0],e)):Kp(ki(arguments[0],arguments[1]))},Kp=e=>sO(Le(U(e)));var gb=function(){return arguments.length===1?e=>nO(arguments[0],e):nO(arguments[0],arguments[1])},nO=(e,t)=>EB(ke(t,n=>ki(e,n))),EB=e=>yB((t,n)=>ss(e,n));var aO=(e,t,n)=>{let r=vt(n,\"parallel\");return Wn(e,o=>o.build(t,vt(r,\"sequential\")),{concurrency:e.length}).pipe(ke(o=>kT(...o)))},cO=(...e)=>hb((t,n)=>aO(e,t,n));var IB=(e,t,n)=>hb((r,o)=>q(Array.isArray(t)?aO(t,r,o):t.build(r,o),s=>e.build(r,o).pipe(Qo(s),ke(i=>n(i,s))))),uO=l(2,(e,t)=>IB(e,t,_));var fO=\"~effect/ExecutionPlan\";var wB={[fO]:fO,get captureRequirements(){let e=this;return js(t=>U(TB(e.steps.map(n=>({...n,provide:oO(n.provide)?uO(n.provide,Kp(t)):n.provide})))))},pipe(){return K(this,arguments)}},TB=e=>{let t=Object.create(wB);return t.steps=e,t};var Vp=Ae(\"effect/ExecutionPlan/CurrentMetadata\",{defaultValue:Le({attempt:0,stepIndex:0})});var _f=Kn;var Zp=\"~effect/time/DateTime\",Yp=\"~effect/time/DateTime/TimeZone\",dO={[Zp]:Zp,pipe(){return K(this,arguments)},[Qe](){return this.toString()},toJSON(){return Uf(this).toJSON()}},AB={...dO,_tag:\"Utc\",[be](){return On(this.epochMilliseconds)},[Se](e){return mc(e)&&e._tag===\"Utc\"&&this.epochMilliseconds===e.epochMilliseconds},toString(){return`DateTime.Utc(${Uf(this).toJSON()})`}},CB={...dO,_tag:\"Zoned\",[be](){return it(On(this.epochMilliseconds))(H(this.zone))},[Se](e){return mc(e)&&e._tag===\"Zoned\"&&this.epochMilliseconds===e.epochMilliseconds&&B(this.zone,e.zone)},toString(){return`DateTime.Zoned(${Rb(this)})`}},pO={[Yp]:Yp,[Qe](){return this.toString()}},OB={...pO,_tag:\"Named\",[be](){return Ue(`Named:${this.id}`)},[Se](e){return Ji(e)&&e._tag===\"Named\"&&this.id===e.id},toString(){return`TimeZone.Named(${this.id})`},toJSON(){return{_id:\"TimeZone\",_tag:\"Named\",id:this.id}}},kB={...pO,_tag:\"Offset\",[be](){return Ue(`Offset:${this.offset}`)},[Se](e){return Ji(e)&&e._tag===\"Offset\"&&this.offset===e.offset},toString(){return`TimeZone.Offset(${Cb(this.offset)})`},toJSON(){return{_id:\"TimeZone\",_tag:\"Offset\",offset:this.offset}}},Io=(e,t,n)=>{let r=Object.create(CB);return r.epochMilliseconds=e,r.zone=t,Object.defineProperty(r,\"partsUtc\",{value:n,enumerable:!1,writable:!0}),Object.defineProperty(r,\"adjustedEpochMillis\",{value:void 0,enumerable:!1,writable:!0}),Object.defineProperty(r,\"partsAdjusted\",{value:void 0,enumerable:!1,writable:!0}),r},mc=e=>M(e,Zp);var Ji=e=>M(e,Yp),mO=e=>Ji(e)&&e._tag===\"Offset\",hO=e=>Ji(e)&&e._tag===\"Named\",gO=e=>e._tag===\"Utc\",Sb=e=>e._tag===\"Zoned\",xO=Rt((e,t)=>e.epochMilliseconds===t.epochMilliseconds),yO=Ho((e,t)=>e.epochMilliseconds<t.epochMilliseconds?-1:e.epochMilliseconds>t.epochMilliseconds?1:0);var bO=e=>{let t=Object.create(AB);return t.epochMilliseconds=e,Object.defineProperty(t,\"partsUtc\",{value:void 0,enumerable:!1,writable:!0}),t},Hi=e=>{let t=e.getTime();if(Number.isNaN(t))throw new vf(\"Invalid date\");return bO(t)},Eb=e=>{if(mc(e))return e;if(e instanceof Date)return Hi(e);if(typeof e==\"object\"){if(\"epochMilliseconds\"in e)return Hi(new Date(e.epochMilliseconds));let t=new Date(0);return NB(t,e),Hi(t)}else if(typeof e==\"string\"&&!RB(e))return Hi(new Date(e+\"Z\"));return Hi(new Date(e))},RB=e=>/Z|GMT|[+-]\\d{2}$|[+-]\\d{2}:?\\d{2}$|\\]$/.test(e),_B=-864e13+720*60*1e3,MB=864e13-840*60*1e3,Ib=(e,t)=>{let n=t?.timeZone;if(n===void 0&&mc(e)&&Sb(e))return e;let r=Eb(e);if(r.epochMilliseconds<_B||r.epochMilliseconds>MB)throw new RangeError(`Epoch millis out of range: ${r.epochMilliseconds}`);n===void 0&&typeof e==\"object\"&&\"timeZoneId\"in e&&(n=e.timeZoneId);let o;if(n===void 0){let s=new Date(r.epochMilliseconds).getTimezoneOffset()*-60*1e3;o=Ff(s)}else if(Ji(n))o=n;else if(typeof n==\"number\")o=Ff(n);else{let s=vb(n);if(ae(s))throw new vf(`Invalid time zone: ${n}`);o=s.value}return t?.adjustForTimeZone!==!0?Io(r.epochMilliseconds,o,r.partsUtc):LB(r.epochMilliseconds,o,t?.disambiguation??\"compatible\")},yb=Rs(Ib),SO=Rs(Eb),PB=/^(.{17,35})\\[(.+)\\]$/,EO=e=>{let t=PB.exec(e);if(t===null){let o=kb(e);return o!==null?yb(e,{timeZone:o}):R()}let[,n,r]=t;return yb(n,{timeZone:r})};var IO=e=>bO(e.epochMilliseconds);var Pf=new Map,FB={day:\"numeric\",month:\"numeric\",year:\"numeric\",hour:\"numeric\",minute:\"numeric\",second:\"numeric\",timeZoneName:\"longOffset\",fractionalSecondDigits:3,hourCycle:\"h23\"},UB=e=>{let t=e.resolvedOptions().timeZone;if(Pf.has(t))return Pf.get(t);let n=Object.create(OB);return n.id=t,n.format=e,Pf.set(t,n),n},wb=e=>{if(Pf.has(e))return Pf.get(e);try{return UB(new Intl.DateTimeFormat(\"en-US\",{...FB,timeZone:e}))}catch{throw new vf(`Invalid time zone: ${e}`)}},Ff=e=>{let t=Object.create(kB);return t.offset=e,t},Tb=Rs(wb);var DB=/^(?:GMT|[+-])/,vb=e=>{if(DB.test(e)){let t=kb(e);return t===null?R():k(Ff(t))}return Tb(e)},wO=e=>e._tag===\"Offset\"?Cb(e.offset):e.id;var Uf=e=>new Date(e.epochMilliseconds),Qp=e=>{if(e._tag===\"Utc\")return new Date(e.epochMilliseconds);if(e.zone._tag===\"Offset\")return new Date(e.epochMilliseconds+e.zone.offset);if(e.adjustedEpochMilliseconds!==void 0)return new Date(e.adjustedEpochMilliseconds);let t=e.zone.format.formatToParts(e.epochMilliseconds).filter(r=>r.type!==\"literal\"),n=new Date(0);return n.setUTCFullYear(Number(t[2].value),Number(t[0].value)-1,Number(t[1].value)),n.setUTCHours(Number(t[3].value),Number(t[4].value),Number(t[5].value),Number(t[6].value)),e.adjustedEpochMilliseconds=n.getTime(),n},Ab=e=>Qp(e).getTime()-Ob(e),Cb=e=>{let t=Math.abs(e),n=Math.floor(t/(3600*1e3)),r=Math.round(t%(3600*1e3)/(60*1e3));return r===60&&(n+=1,r=0),`${e<0?\"-\":\"+\"}${String(n).padStart(2,\"0\")}:${String(r).padStart(2,\"0\")}`},TO=e=>Cb(Ab(e)),Ob=e=>e.epochMilliseconds;var NB=(e,t)=>{if(t.year!==void 0&&e.setUTCFullYear(t.year),t.month!==void 0&&e.setUTCMonth(t.month-1),t.day!==void 0&&e.setUTCDate(t.day),t.weekDay!==void 0){let n=t.weekDay-e.getUTCDay();e.setUTCDate(e.getUTCDate()+n)}t.hour!==void 0&&e.setUTCHours(t.hour),t.minute!==void 0&&e.setUTCMinutes(t.minute),t.second!==void 0&&e.setUTCSeconds(t.second),t.millisecond!==void 0&&e.setUTCMilliseconds(t.millisecond)};var lO=1440*60*1e3,LB=(e,t,n)=>{if(t._tag===\"Offset\")return Io(e-t.offset,t);let r=Mf(e-lO,e,t),o=Mf(e+lO,e,t);if(r===o)return Io(e-r,t);let s=r<o,i=r-o;if(s){if(Mf(e-o,e,t)===o)return Io(e-o,t);let u=Io(e-r,t),f=Qp(u).getTime();if(e!==f)switch(n){case\"reject\":{let d=new Date(e).toISOString();throw new RangeError(`Gap time: ${d} does not exist in time zone ${t.id}`)}case\"earlier\":return Io(e-o,t);case\"compatible\":case\"later\":return u}return u}if(Mf(e-r,e,t)===r){if(n===\"earlier\"||n===\"compatible\"||Mf(e-r+i,e+i,t)===r)return Io(e-r,t);if(n===\"reject\"){let u=new Date(e).toISOString();throw new RangeError(`Ambiguous time: ${u} occurs twice in time zone ${t.id}`)}}return Io(e-o,t)},$B=/([+-])(\\d{2}):(\\d{2})$/,kb=e=>{let t=$B.exec(e);if(t===null)return null;let[,n,r,o]=t;return(n===\"+\"?1:-1)*(Number(r)*60+Number(o))*60*1e3},Mf=(e,t,n)=>{let r=n.format.formatToParts(e).find(s=>s.type===\"timeZoneName\")?.value??\"\";if(r===\"GMT\")return 0;let o=kb(r);return o===null?Ab(Io(t,n)):o};var vO=e=>Uf(e).toISOString();var bb=e=>{let t=Qp(e);return e._tag===\"Utc\"?t.toISOString():`${t.toISOString().slice(0,-1)}${TO(e)}`},Rb=e=>e.zone._tag===\"Offset\"?bb(e):`${bb(e)}[${e.zone.id}]`;var c7=globalThis.Number;var CO=l(2,(e,t)=>{let n=e.toString(),r=t.toString();if(n.includes(\"e\")||r.includes(\"e\"))return!globalThis.Number.isFinite(e)||!globalThis.Number.isFinite(t)||t===0?NaN:BB(e,t);let o=(n.split(\".\")[1]||\"\").length,s=(r.split(\".\")[1]||\"\").length,i=o>s?o:s,a=parseInt(e.toFixed(i).replace(\".\",\"\")),c=parseInt(t.toFixed(i).replace(\".\",\"\"));return a%c/Math.pow(10,i)});function BB(e,t){let[n,r]=AO(e),[o,s]=AO(t),i=Math.min(r,s),a=n*BigInt(10)**BigInt(r-i),c=o*BigInt(10)**BigInt(s-i),u=a%c;if(u===BigInt(0))return e<0||Object.is(e,-0)?-0:0;let f=globalThis.Number(`${u}e${i}`);return f===0?Math.sign(e)*globalThis.Number.MIN_VALUE:f}function AO(e){let t=Math.abs(e).toExponential(),n=t.indexOf(\"e\"),r=t.slice(0,n).replace(\".\",\"\");return[BigInt(r)*(e<0?-BigInt(1):BigInt(1)),globalThis.Number(t.slice(n+1))-r.length+1]}var OO=e=>{let t=Math.ceil(Math.log(e)/Math.log(2));return Math.max(Math.pow(2,t),2)};var kO=or((e,t)=>Math.max(e,t),-1/0),RO=or((e,t)=>Math.min(e,t),1/0);var f7=globalThis.String,Xp=$n;var MO=e=>e.toUpperCase();var PO=e=>e.trim();var qB=(e,t,n,r,o)=>{let s=e;for(let c of t)s=s.replace(c,\"$1\\0$2\");for(let c of n)s=s.replace(c,\"\\0\");let i=0,a=s.length;for(;s.charAt(i)===\"\\0\";)i++;for(;s.charAt(a-1)===\"\\0\";)a--;return s.slice(i,a).split(\"\\0\").map(o).join(r)};var zB=[/([a-z0-9])([A-Z])/g,/([A-Z])([A-Z][a-z])/g],WB=/[^A-Z0-9]+/gi;var FO=e=>qB(e,zB,[WB],\"_\",MO);var Ge=l(2,(e,t)=>lc(e,HB,n=>t(n))),is=e=>e.reasons.some(_b),_b=e=>e._tag===\"Fail\"&&dc(e.error),hc=e=>{let t,n=!1;for(let r of e.reasons)_b(r)?t??=r.error:r._tag!==\"Interrupt\"&&(n=!0);return t===void 0?re(e):n?re(Bi(e.reasons.filter(r=>!_b(r)))):se(t)};var HB=e=>{let t=hc(e);return ie(t)?t:se(t.success.value)},Mb=e=>{let t=hc(e);return ie(t)?Qt(t.failure):Yt(t.success.value)},tm=l(2,(e,t)=>Dr(e,{onSuccess:t.onSuccess,onFailure:n=>{let r=hc(n);return ie(r)?t.onFailure(r.failure):t.onDone(r.success.value)}}));var UO=\"~effect/Schedule\";var Ft=Ae(\"effect/Schedule/CurrentMetadata\",{defaultValue:Le({input:void 0,output:void 0,duration:Ms,attempt:0,start:0,now:0,elapsed:0,elapsedSincePrevious:0})}),JB={[UO]:{_Out:_,_In:_,_Env:_},pipe(){return K(this,arguments)}},Pb=e=>M(e,UO),Fb=e=>{let t=Object.create(JB);return t.step=e,t},Ub=()=>{let e=0,t,n;return(r,o)=>{n===void 0&&(n=r);let s=r-n,i=t===void 0?0:r-t;return t=r,{input:o,attempt:++e,start:n,now:r,elapsed:s,elapsedSincePrevious:i}}},KB=e=>Fb(ke(e,t=>{let n=Ub();return(r,o)=>t(n(r,o))})),Db=e=>Hn(e.step,t=>U(()=>Pe(t))),qr=e=>jr(t=>ke(Db(e),n=>{let r=Ub();return o=>Q(()=>{let s=t.currentTimeMillisUnsafe();return q(n(s,o),([i,a])=>{let c=r(s,o);return c.output=i,c.duration=a,Zt(ji(a),c)})})})),gc=e=>ke(qr(e),t=>n=>ke(t(n),r=>r.output));var Nb=e=>Fb(ke(Db(e),t=>(n,r)=>tm(t(n,r),{onSuccess:o=>U([r,o[1]]),onFailure:Pe,onDone:()=>G(r)}))),DO=e=>Df(Nf,({attempt:t})=>U(t<=e)),Lb=e=>{let t=mt(e);return KB(U(n=>U([n.attempt-1,t])))};var Df=l(2,(e,t)=>Fb(ke(Db(e),n=>{let r=Ub();return(o,s)=>q(n(o,s),i=>{let[a,c]=i,u=t({...r(o,s),output:a,duration:c});return q(ee(u)?u:U(u),f=>f?U(i):G(a))})})));var Nf=Lb(Ms);var GB=(e,t,n)=>Lp(r=>q(n?.local?kf(t,Of(),r):Gp(t,r),o=>Qo(e,o))),nm=l(e=>ee(e[0]),(e,t,n)=>Ju(t)?Qo(e,t):GB(e,Array.isArray(t)?cO(...t):t,n));var $b=l(3,(e,t,n)=>q(qr(t),r=>{let o=Ft.defaultValue();return Jn(Mp(Di(q(Q(()=>gn(e,Ft,o)),r),s=>L(()=>{o=s})),{disableYield:!0}),s=>Du(s)?U(s.value):n(s,o.attempt===0?R():k(o)))})),jb=l(3,(e,t,n)=>q(qr(t),r=>{let o=Ft.defaultValue(),s,i=Jn(Q(()=>gn(e,Ft,o)),a=>(s=a,q(r(a),c=>(o=c,i))));return Ge(i,a=>we(()=>n(s,a)))})),LO=l(2,(e,t)=>{let n=typeof t==\"function\"?t(_):Pb(t)?t:jf(t);return $b(e,n,qe)}),$f=l(2,(e,t)=>{let n=typeof t==\"function\"?t(_):Pb(t)?t:jf(t);return jb(e,n,qe)}),$O=l(3,(e,t,n)=>q(qr(n),r=>{let o=Ft.defaultValue(),s=Q(()=>gn(e,Ft,o));return Jn(q(r(t),i=>{o=i;let a=Le(q(s,r));return So({while:cn,body:a,step(c){o=c}})}),i=>Du(i)?U(i.value):qe(i))})),VB=Nb(Nf),jf=e=>{let t=e.schedule?Nb(e.schedule):VB;return e.while&&(t=Df(t,({input:n})=>{let r=e.while(n);return ee(r)?r:U(r)})),e.until&&(t=Df(t,({input:n})=>{let r=e.until(n);return ee(r)?ke(r,o=>!o):U(!r)})),e.times!==void 0&&(t=Df(t,({attempt:n})=>U(n<=e.times))),t};var Bb=(e,t)=>{let n=-1,r=0,o=s=>Up(e(s));return{begin:jr(s=>Q(()=>{let i=t();i.stepIndex!==n&&(n=i.stepIndex,r=0),r++;let a={attempt:i.attempt,stepAttempt:r,stepIndex:i.stepIndex,startNanos:s.monotonicTimeNanosUnsafe()};return Zt(o({_tag:\"AttemptStart\",attempt:a.attempt,stepAttempt:a.stepAttempt,stepIndex:a.stepIndex}),a)})),end:(s,i)=>jr(a=>{let c=dr(a.monotonicTimeNanosUnsafe()-s.startNanos);return o(i._tag===\"Success\"?{_tag:\"AttemptSuccess\",attempt:s.attempt,stepAttempt:s.stepAttempt,stepIndex:s.stepIndex,duration:c}:{_tag:\"AttemptFailure\",attempt:s.attempt,stepAttempt:s.stepAttempt,stepIndex:s.stepIndex,duration:c,cause:i.cause})})}},BO=l(e=>ee(e[0]),(e,t,n)=>Q(()=>{let r=0,o={attempt:0,stepIndex:0},s=_p(Vp,L(()=>(o={attempt:o.attempt+1,stepIndex:r},o))),i=n?.onEvent===void 0?void 0:Bb(n.onEvent,()=>o),a=i===void 0?_:u=>$r(f=>q(i.begin,d=>zt(f(u),p=>i.end(d,p)))),c;return q(So({while:()=>r<t.steps.length&&(c===void 0||ie(c)),body(){let u=t.steps[r],f=s(a(nm(e,u.provide)));if(c){let d=!1,p=f;f=Q(()=>d?p:(d=!0,ac(c))),f=$f(f,Bf(u,!1))}else{let d=Bf(u,!0);f=d?$f(f,d):f}return Us(f)},step(u){c=u,r++}}),()=>ac(c))})),Bf=(e,t)=>{if(t){if(e.attempts===1||!(e.schedule||e.attempts))return}else return jf({schedule:e.schedule?e.schedule:e.attempts?void 0:ZB,times:e.attempts,while:e.while});return jf({schedule:e.schedule,while:e.while,times:e.attempts?e.attempts-1:void 0})},ZB=DO(1);var YB=\"~effect/Request\",QB=$g({_E:e=>e,_A:e=>e,_R:e=>e}),A7={...U0,[YB]:QB};var zO=e=>e;var HO=l(2,(e,t)=>{let n=r=>ct(o=>{let s=KO(r,e,o,Zo());return eq(r,s)});return ee(t)?q(t,n):n(t)}),JO=(e,t)=>{let n=KO(t.resolver,e,t.onExit,{context:t.context,currentScheduler:Xe(t.context,Vu)});return()=>GO(t.resolver,n)},rm=[],qb=new WeakMap,KO=(e,t,n,r)=>{let o=qb.get(e);o||(o=new Map,qb.set(e,o));let s,i=!1,a=zO({request:t,context:r.context,uninterruptible:!1,completeUnsafe(u){i||(i=!0,n(u),s?.entrySet.delete(a))}});if(e.preCheck!==void 0&&!e.preCheck(a))return a;let c=e.batchKey(a);if(s=o.get(c),!s){if(rm.length>0)s=rm.pop(),s.key=c,s.resolver=e,s.map=o;else{let u={key:c,resolver:e,map:o,entrySet:new Set,entries:new Set,delayEffect:q(Q(()=>u.resolver.delay),f=>WO(u)),run:zt(Q(()=>u.resolver.runAll(Array.from(u.entries),u.key)),f=>{for(let d of u.entrySet)d.completeUnsafe(f._tag===\"Success\"?ar(new Error(\"Effect.request: RequestResolver did not complete request\",{cause:d.request})):f);return u.entries.clear(),rm.length<128&&(u.entrySet.clear(),u.key=void 0,u.fiber=void 0,u.resolver=void 0,u.map=void 0,rm.push(u)),pe})};s=u}o.set(c,s),s.fiber=ts(r.context)(s.delayEffect,{scheduler:r.currentScheduler})}return s.entrySet.add(a),s.entries.add(a),s.resolver.collectWhile(s.entries)||(s.fiber.interruptUnsafe(r.id),s.fiber=ts(r.context)(WO(s),{scheduler:r.currentScheduler})),a},GO=(e,t)=>{if(t.uninterruptible)return;let n=qb.get(e);if(!n)return;let r=e.batchKey(t),o=n.get(r);o&&(o.entries.delete(t),o.entrySet.delete(t),o.entries.size===0&&(n.delete(r),o.fiber?.interruptUnsafe()))},eq=(e,t)=>L(()=>GO(e,t));function WO(e){return e.map.has(e.key)?(e.map.delete(e.key),e.run):pe}var nq=\"effect/Metric/CurrentMetricAttributes\",rq=Ae(nq,{defaultValue:()=>({})}),oq=\"~effect/observability/Metric/MetricRegistryKey\",sq=Ae(oq,{defaultValue:()=>new Map}),VO=\"~effect/observability/Metric\",ZO=class{[VO]=VO;#t=new WeakMap;#e;id;description;attributes;constructor(t,n,r){this.id=t,this.description=n,this.attributes=r}valueUnsafe(t){return this.hook(t).get(t)}modifyUnsafe(t,n){return this.hook(n).modify(t,n)}updateUnsafe(t,n){return this.hook(n).update(t,n)}hook(t){let n=Xe(t,rq);if(Object.keys(n).length===0)return Lt(this.#e)?this.#e.hooks:(this.#e=this.getOrCreate(t,this.attributes),this.#e.hooks);let r=cq(this.attributes,n),o=this.#t.get(r);return Lt(o)||(o=this.getOrCreate(t,r),this.#t.set(r,o)),o.hooks}getOrCreate(t,n){let r=iq(this,n),o=Xe(t,sq);if(o.has(r))return o.get(r);let s=this.createHooks(),i={id:this.id,type:this.type,description:this.description,attributes:zb(n),hooks:s};return o.set(r,i),i}pipe(){return K(this,arguments)}};var xc=l(2,(e,t)=>js(n=>L(()=>e.updateUnsafe(t,n))));function iq(e,t){let n=`${e.type}:${e.id}`;return Lt(e.description)&&(n+=`:${e.description}`),Lt(t)&&(n+=`:${aq(t)}`),n}function aq(e){return JSON.stringify(Array.isArray(e)?e:Object.entries(e))}function cq(e,t){return{...zb(e),...zb(t)}}function zb(e){return Lt(e)&&Array.isArray(e)?e.reduce((t,[n,r])=>(F(t,n,r),t),{}):e}var uq=po,wo=ee,om=bf,fq=jy,lq=QA,dq=XA,pq=eC,mq=nC,qs=Wn,hq=oC,zr=So,sm=sy,bc=Pv,x=U,as=Ui,im=oy,z=Q,oe=L,te=pe;var gq=Yy;var Wb=ct,Gi=Op,xq=fC,yq=lC,bq=pC;var Sq=dC,en=Nv,P=qe,am=Cp,tt=Pe,cs=_v,hr=Ls,Sc=Mv;var qf=cc,Eq=ry,Gn=Y,Hb=ac,Iq=ny,wq=Rv,Tq=kv,T=q,Jb=Hv,yn=Nr,bn=Di,cm=Us,vq=RA,gr=hf,$=ke,At=Zt,um=uc,zs=lf,Aq=jv,Kb=pA,Cq=yy,Ws=Jn;var Oq=Iy,kq=wA,Rq=TA,_q=vA,Mq=AA,ft=Hn,zf=yA,Pq=pf,Fq=Fp,Uq=xA,Dq=Sy,Nq=lc,fm=wy,Lq=Ty,Gb=vy,lm=Ey,$q=EA,XO=bA,jq=SA,Bq=Pp,ek=IA,qq=Ay,zq=$f,Wq=jb,Hq=Bv,Jq=kA,Kq=Up,Gq=BO,Vq=YC,Zq=CA,Yq=OA,Qq=UA,Xq=DA,ez=Oy,Vb=FA,Vi=ji,tz=NA,Zb=uy,nz=fy,rz=qv,Ec=kp,Yb=aC,oz=cC,Qb=uC,sz=By,iz=qy,az=mA,cz=iC,dm=hA,Xb=Dp,uz=mf,fz=Cy,lz=_A,dz=zv,Zi=Dr,eS=Ni,pz=MA,mz=PA,Yi=xy,Wf=js,hz=nm,xr=Qo,gz=dA,tS=uA,nS=fA,xz=fc,yz=Yo,bz=lA,To=gn,Sz=_p,Qi=xf,pm=_y,rS=Lp,Xi=Li,Ez=HA,tk=yf,Iz=My,ea=qA,Wr=Uy,wz=zA,Tz=Dy,oS=Xo,yr=zt,vz=Py,Az=Fy,Cz=JA,Oz=Ly,kz=Ny,vo=KA,Rz=$y,Ic=WA,us=es,Hs=$r,sS=ZA,_z=YA,Ct=Mp,Mz=LO,Pz=$b,Fz=by,Uz=gA,Dz=l(2,(e,t)=>nk(e,void 0,t)),nk=$O,Nz=AC,Lz=CC,$z=OC,jz=kC,Bz=NC,qz=LC,zz=$C,Wz=jC,Hz=PC,Jz=FC,Kz=UC,iS=_C,Gz=ef,Vz=tf,Zz=DC,Yz=MC,Qz=sc,Xz=HO,eW=JO,wc=mC,yt=zy,Tc=xC,tW=hC,nW=gC,rW=Uv,oW=Dv,Hf=bC,Ao=ts,sW=Wy,iW=SC,aS=IC,cS=Hy,mm=EC,aW=jp,cW=TC,uW=Jy,Js=wC,fW=Bp,xe=ay,lW=Lv,ta=jr,hm=Br,dW=Br(),pW=Br(\"Fatal\"),mW=Br(\"Warn\"),uS=Br(\"Error\"),hW=Br(\"Info\"),gW=Br(\"Debug\"),xW=Br(\"Trace\"),yW=l(2,(e,t)=>Yo(e,Zy,n=>new Set([...n,t]))),bW=l(e=>wo(e[0]),(e,...t)=>Yo(e,QC,n=>{let r=t.length===1?{...n,...t[0]}:{...n};return t.length===1||F(r,t[0],t[1]),r})),SW=VC,EW=l(2,(e,t)=>q(Gy,n=>Yo(e,XC,r=>[[t,n],...r]))),IW=l(e=>wo(e[0]),(e,t,n)=>yr(e,r=>{let o=n===void 0?r:we(()=>n(r));return xc(t,o)})),wW=l(e=>wo(e[0]),(e,t,n)=>bn(e,r=>{let o=n===void 0?r:n(r);return xc(t,o)})),TW=l(e=>wo(e[0]),(e,t,n)=>lm(e,r=>{let o=n===void 0?r:we(()=>n(r));return xc(t,o)})),vW=l(e=>wo(e[0]),(e,t,n)=>ek(e,r=>{let o=n===void 0?r:we(()=>n(r));return xc(t,o)})),AW=l(e=>wo(e[0]),(e,t,n)=>ta(r=>{let o=r.monotonicTimeNanosUnsafe();return yr(e,()=>{let s=r.monotonicTimeNanosUnsafe(),i=LT(mt(s),mt(o)),a=n===void 0?i:we(()=>n(i));return xc(t,a)})})),yc=class extends Vt()(\"effect/Effect/Transaction\"){},CW=e=>Gn(t=>{let n=tp(t.context,yc);if(n)return e;n={journal:new Map,retry:!1};let r;return Hs(o=>T(zr({while:()=>!r,body:Le(o(e).pipe(To(yc,n),XO(()=>n.retry?o(kW(n)):te),gr)),step(s){if(n.retry||!OW(n))return QO(n);et(s)?RW(t,n):QO(n),r=s}}),()=>r))}),OW=e=>{for(let[t,{version:n}]of e.journal)if(t.version!==n)return!1;return!0},kW=e=>z(()=>{let t={},n=Array.from(e.journal.keys()),r=()=>{for(let o of n)o.pending.delete(t)};return Wb(o=>{let s=()=>{r(),o(te)};for(let i of n)i.pending.set(t,s);return oe(r)})});function RW(e,t){for(let[n,{value:r}]of t.journal){r!==n.value&&(n.version=n.version+1,n.value=r);for(let o of n.pending.values())e.currentDispatcher.scheduleTask(o,0);n.pending.clear()}}function QO(e){e.retry=!1,e.journal.clear()}var _W=T(yc,e=>(e.retry=!0,vo)),MW=(e,t,n)=>(...r)=>Wb(o=>{try{e(...r,(s,i)=>{o(s?P(t?t(s,r):s):x(i))})}catch(s){o(n?P(n(s,r)):hr(s))}}),PW=()=>e=>e,FW=()=>e=>e,UW=()=>e=>e,br=Jv,fs=Kv,Ut=Gv,lt=Wv,vc=Vv,gm=$v;function Co(e){return e.checks?e.checks[e.checks.length-1].annotations:e.annotations}function Jf(e){return t=>Co(t)?.[e]}var Mn=\"~structural\",na=\"~identifier\",Kf=\"~sentinels\",xm=\"~constructor\",rk=[\"title\",\"description\",\"default\",\"examples\",\"readOnly\",\"writeOnly\",\"format\",\"contentEncoding\",\"contentMediaType\",\"contentSchema\"],Ac=Jf(\"identifier\"),fS=Jf(na),lS=Jf(\"title\");var ok=Jf(\"brands\"),Gf=It(e=>{let t=Co(e)?.identifier;return typeof t==\"string\"?t:e.getExpected(Gf)});var sk=new Set([Kf,Mn,\"representation\",\"arbitrary\",\"brands\",\"toJsonSchema\",\"toCode\",\"toArbitrary\",\"toEquivalence\",\"toFormatter\",\"toCodec\",\"toCodecJson\",\"toCodecStringTree\",\"toCodecIso\"]);var ve=Symbol();var Sr=Yt,Hr=Sr(ve),bt=Sr(ve),ym=e=>e===ve?R():k(e),dS=e=>e._tag===\"None\"?Hr:Sr(e.value);var Zf=\"~effect/SchemaIssue/Issue\";function hS(e){return M(e,Zf)&&e[Zf]===Zf}function DW(e){return Object.hasOwn(e,\"input\")}var Vn=class{[Zf]=Zf;constructor(t,n){n?.reportInput===!0&&t!==ve&&(this.input=t)}},Sm=class extends Vn{_tag=\"Filter\";filter;issue;constructor(t,n,r,o){super(r,o),this.filter=t,this.issue=n}},Em=class extends Vn{_tag=\"Encoding\";ast;issue;constructor(t,n,r,o){super(r,o),this.ast=t,this.issue=n}},Ve=class extends Vn{_tag=\"Pointer\";path;issue;constructor(t,n){super(),this.path=t,this.issue=n}},Gs=class extends Vn{_tag=\"MissingKey\";annotations;constructor(t){super(),this.annotations=t}},Yf=class extends Vn{_tag=\"UnexpectedKey\";ast;constructor(t,n,r){super(n,r),this.ast=t}},nt=class extends Vn{_tag=\"Composite\";ast;issues;constructor(t,n,r,o){super(r,o),this.ast=t,this.issues=n}},He=class extends Vn{_tag=\"InvalidType\";ast;constructor(t,n,r){super(n,r),this.ast=t}},ge=class extends Vn{_tag=\"InvalidValue\";annotations;constructor(t,n,r){super(n,r),this.annotations=t}};function Pn(e,t,n,r,o){return new nt(e,[new Ve([t],n)],r,o)}var oa=class extends Vn{_tag=\"Forbidden\";annotations;constructor(t,n,r){super(n,r),this.annotations=t}},Oc=class extends Vn{_tag=\"AnyOf\";ast;issues;constructor(t,n,r,o){super(r,o),this.ast=t,this.issues=n}},Im=class extends Vn{_tag=\"OneOf\";ast;successes;constructor(t,n,r,o){super(r,o),this.ast=t,this.successes=n}};function mS(e,t,n){if(hS(e))return e;if(typeof e==\"string\")return new ge({message:e},t,n);let r=typeof e.issue==\"string\"?new ge({message:e.issue},t,n):e.issue;return new Ve(e.path,r)}function ik(e,t,n){if(e!==void 0)return typeof e==\"boolean\"?e?void 0:new ge(void 0,t,n):mS(e,t,n)}function ak(e,t,n,r){return Array.isArray(t)?Me(t)?t.length===1?mS(t[0],n,r):new nt(e,Pr(t,o=>mS(o,n,r)),n,r):void 0:ik(t,n,r)}var ck=e=>{let t=kc(e);if(t!==void 0)return t;switch(e._tag){case\"InvalidType\":return Ks(Gf(e.ast),e);case\"InvalidValue\":{let n=gS(e);if(n!==void 0)return Ks(n,e);let r=bm(e);return r===void 0?\"Expected a valid value\":`Invalid data ${r}`}case\"MissingKey\":return\"Missing key\";case\"UnexpectedKey\":{let n=bm(e);return n===void 0?\"Expected no excess property\":`Unexpected key with value ${n}`}case\"Forbidden\":return\"Forbidden operation\";case\"OneOf\":{let n=bm(e);return n===void 0?\"Expected exactly one member to match\":`Expected exactly one member to match the input ${n}`}}},uk=e=>kc(e.issue)??kc(e);function fk(e){return t=>({issues:Cc(t,[],e?.leafHook??ck,e?.checkHook??uk)})}function bm(e){return DW(e)?N(e.input):void 0}function gS(e){let t=e.annotations?.expected;return typeof t==\"string\"?t:void 0}function Ks(e,t){let n=bm(t);return n===void 0?`Expected ${e}`:`Expected ${e}, got ${n}`}function Cc(e,t,n,r){switch(e._tag){case\"Filter\":{let o=r(e);if(o!==void 0)return[{path:t,message:o}];if(e.issue._tag!==\"InvalidValue\")return Cc(e.issue,t,n,r);let s=gS(e.issue);return[{path:t,message:s===void 0?Ks(xS(e.filter),e):Ks(s,e.issue)}]}case\"Encoding\":return Cc(e.issue,t,n,r);case\"Pointer\":return Cc(e.issue,[...t,...e.path],n,r);case\"Composite\":return e.issues.flatMap(o=>Cc(o,t,n,r));case\"AnyOf\":return e.issues.length===0?[{path:t,message:kc(e)??Ks(Gf(e.ast),e)}]:e.issues.flatMap(o=>Cc(o,t,n,r));default:return[{path:t,message:n(e)}]}}function xS(e){let t=e.annotations?.expected;if(typeof t==\"string\")return t;switch(e._tag){case\"Filter\":return\"<filter>\";case\"FilterGroup\":return e.checks.map(n=>xS(n)).join(\" & \")}}function NW(){return e=>Vf(e,\"\")}var lk=NW();function Vf(e,t){let n;switch(e._tag){case\"Filter\":{let r=uk(e);if(r!==void 0)n=r;else{if(e.issue._tag!==\"InvalidValue\")return Vf(e.issue,t);let o=gS(e.issue);n=o===void 0?Ks(xS(e.filter),e):Ks(o,e.issue)}break}case\"Encoding\":return Vf(e.issue,t);case\"Pointer\":return Vf(e.issue,t+Dd(e.path));case\"Composite\":case\"AnyOf\":{if(e._tag===\"Composite\"||e.issues.length>0)return e.issues.map(r=>Vf(r,t)).join(`\n`);n=kc(e)??Ks(Gf(e.ast),e);break}default:n=ck(e);break}return t?`${n}\n at ${t}`:n}function kc(e){if(e._tag===\"Pointer\")return;if(e._tag===\"Encoding\")return kc(e.issue);let n=(e._tag===\"Filter\"?e.filter.annotations:\"annotations\"in e?e.annotations:e.ast.annotations)?.[e._tag===\"MissingKey\"?\"messageMissingKey\":e._tag===\"UnexpectedKey\"?\"messageUnexpectedKey\":\"message\"];if(typeof n==\"string\")return n}function Qf(e){let t;for(let n of e.reasons){if(!Ef(n)||!hS(n.error))return;t??=n.error}return t}function ds(e,t){let n=Qf(e);if(n===void 0)throw new Error(t,{cause:e});return n}var yS=mc,pk=Ji,mk=mO,hk=hO,gk=gO,xk=Sb,bS=xO,SS=yO;var yk=Hi;var bk=Ib;var wm=SO,Sk=EO;var Tm=IO;var ES=wb,Xf=Ff,Ek=Tb;var Ik=vb,Vs=wO;var vm=Uf;var wk=Ob;var Tk=vO;var Am=Rb;var Zs=class e extends di{run;constructor(t){super(),this.run=t}map(t){return new e((n,r)=>this.run(n,r).pipe(br(Bt(t))))}compose(t){return vk(this)?t:vk(t)?this:new e((n,r)=>this.run(n,r).pipe(lt(o=>t.run(o,r))))}};function LW(e){return new Zs((t,n)=>P(e(t,n)))}function IS(e){return LW((t,n)=>{let r={message:e(t)};return _e(t)?new oa(r,t.value,n):new oa(r)})}var Ak=new Zs(x);function vk(e){return e.run===Ak.run}function Sn(){return Ak}function wS(e){return new Zs((t,n)=>ae(t)?as:e(t.value,n))}function ue(e){return Om(Bt(e))}function tn(e){return wS((t,n)=>e(t,n).pipe(br(k)))}function Om(e){return new Zs(t=>x(e(t)))}function TS(){return new Zs(()=>as)}function el(e){return new Zs(t=>{let n=Ci(t,Lt);return _e(n)?x(n):br(e,k)})}function sa(){return ue(globalThis.String)}function km(){return ue(globalThis.Number)}function Ck(){return ue(globalThis.BigInt)}function vS(){return ue(e=>new globalThis.Date(e))}function Ok(){return ue(PO)}function kk(e){return wS((t,n)=>Sc({try:()=>k(JSON.parse(t,e?.reviver)),catch:()=>new ge({expected:\"a valid JSON string\"},t,n)}))}function Rk(e){return wS((t,n)=>Sc({try:()=>{let r=JSON.stringify(t,e?.replacer,e?.space);if(r===void 0)throw new TypeError(\"Value cannot be represented as JSON\");return k(r)},catch:()=>new ge({expected:\"a JSON-serializable value\"},t,n)}))}function _k(e){let t=e?.separator??\",\",n=e?.keyValueSeparator??\"=\";return ue(r=>r.split(t).reduce((o,s)=>{let[i,a]=s.split(n);return i&&a&&F(o,i,a),o},{}))}function Mk(e){let t=e?.separator??\",\",n=e?.keyValueSeparator??\"=\";return ue(r=>Object.entries(r).map(([o,s])=>`${o}${n}${s}`).join(t))}function Pk(e){let t=e?.separator??\",\";return ue(n=>n===\"\"?[]:n.split(t))}function AS(){return ue(ap)}function Rm(){return ue(YT)}function _m(){return ue(XT)}function Fk(){return tn((e,t)=>fs(Hb(rc(e)),()=>new ge({expected:\"a valid Base64 string\"},e,t)))}function Uk(){return tn((e,t)=>lr(ZT(e),{onFailure:()=>P(new ge({expected:\"a valid Base64 string\"},e,t)),onSuccess:x}))}function Dk(){return tn((e,t)=>lr(Mx(e),{onFailure:()=>P(new ge({expected:\"a valid Base64Url string\"},e,t)),onSuccess:x}))}function Nk(){return tn((e,t)=>lr(QT(e),{onFailure:()=>P(new ge({expected:\"a valid Base64Url string\"},e,t)),onSuccess:x}))}function Lk(){return tn((e,t)=>lr(Fx(e),{onFailure:()=>P(new ge({expected:\"a valid hexadecimal string\"},e,t)),onSuccess:x}))}function $k(){return tn((e,t)=>lr(ev(e),{onFailure:()=>P(new ge({expected:\"a valid hexadecimal string\"},e,t)),onSuccess:x}))}function jk(){return ue(encodeURIComponent)}function Bk(){return tn((e,t)=>{try{return x(globalThis.decodeURIComponent(e))}catch{return P(new ge({expected:\"a valid URI component\"},e,t))}})}function CS(){return tn((e,t)=>fr(wm(e),{onNone:()=>P(new ge({message:\"Invalid DateTime input\"},e,t)),onSome:n=>x(Tm(n))}))}function qk(){return ue(e=>Jk(Array.from(e.entries())))}var $W=Kk(e=>typeof e==\"string\"||typeof Blob<\"u\"&&e instanceof Blob);function zk(){return ue(e=>{let t=new FormData;return typeof e==\"object\"&&e!==null&&$W(e).forEach(([r,o])=>{t.append(r,o)}),t})}function Wk(){return ue(e=>Jk(Array.from(e.entries())))}var jW=Kk($n);function Hk(){return ue(e=>typeof e==\"object\"&&e!==null?new URLSearchParams(jW(e)):new URLSearchParams)}var BW=/^\\d+$/;function qW(e){if(e===\"\")return[\"\"];let t=e.replace(/\\[(.*?)\\]/g,\".$1\"),n=t.split(\".\"),r=t.startsWith(\".\")?1:0;return n.slice(r).map(o=>BW.test(o)?globalThis.Number(o):o)}function Jk(e){let t={},n=new WeakSet;function r(o,s,i){let a=Object.hasOwn(o,s)?o[s]:void 0;if(n.has(a)&&Array.isArray(a)===i)return a;let c=i?[]:{};return n.add(c),F(o,s,c),c}return e.forEach(([o,s])=>{let i=qW(o),a=t;i.forEach((c,u)=>{let f=u===i.length-1;if(Array.isArray(a)&&c===\"\")if(f)a.push(s);else{let d=i[u+1],p=typeof d==\"number\"||d===\"\",m=a.length;a=r(a,m,p)}else if(f){let d=Object.hasOwn(a,c);d&&Array.isArray(a[c])?a[c].push(s):d?F(a,c,[a[c],s]):F(a,c,s)}else{let d=i[u+1];a=r(a,c,typeof d==\"number\"||d===\"\")}})}),t}function Kk(e){return t=>{let n=[];function r(o,s){if(e(s))n.push([o,s]);else if(Array.isArray(s))s.every(e)?s.forEach(a=>{n.push([o,a])}):s.forEach((a,c)=>{r(`${o}[${c}]`,a)});else if(typeof s==\"object\"&&s!==null)for(let[i,a]of Object.entries(s))r(`${o}[${i}]`,a)}for(let[o,s]of Object.entries(t))r(o,s);return n}}var rl=class e{_tag=\"Middleware\";decode;encode;constructor(t,n){this.decode=t,this.encode=n}flip(){return new e(this.encode,this.decode)}},nl=\"~effect/SchemaTransformation/Transformation\",Te=class e{[nl]=nl;_tag=\"Transformation\";decode;encode;constructor(t,n){this.decode=t,this.encode=n}flip(){return new e(this.encode,this.decode)}compose(t){return new e(this.decode.compose(t.decode),t.encode.compose(this.encode))}};function zW(e){return M(e,nl)&&e[nl]===nl}var Mm=e=>zW(e)?e:new Te(e.decode,e.encode);function Zn(e){return new Te(tn(e.decode),tn(e.encode))}function We(e){return new Te(ue(e.decode),ue(e.encode))}function Pm(e){return new Te(Om(e.decode),Om(e.encode))}function Gk(){return new Te(Ok(),Sn())}function Vk(e){return new Te(_k(e),Mk(e))}var WW=new Te(Sn(),Sn());function Rc(){return WW}var _c=new Te(km(),sa()),Fm=new Te(Ck(),sa()),OS=new Te(vS(),ue(zg)),Zk=new Te(vS(),ue(e=>e.getTime())),Yk=Zn({decode:(e,t)=>fr(UT(e),{onNone:()=>P(new ge({expected:\"a valid Duration string\"},e,t)),onSome:x}),encode:e=>x(globalThis.String(e))}),Qk=Zn({decode:e=>x(dr(e)),encode:(e,t)=>fr(DT(e),{onNone:()=>P(new ge({expected:\"a Duration representable as a bigint\"},e,t)),onSome:n=>x(n)})}),Xk=We({decode:e=>go(e),encode:e=>_n(e)}),HW=e=>wt(e)&&typeof e.message==\"string\",eR=e=>{let n=Object.hasOwn(e,\"cause\")?new Error(e.message,{cause:nR(e.cause)}):new Error(e.message);return typeof e.name==\"string\"&&e.name!==\"Error\"&&(n.name=e.name),typeof e.stack==\"string\"&&(n.stack=e.stack),n},JW=e=>{try{let t=hi(e);return t===void 0?N(e):JSON.parse(t)}catch{return N(e)}},KW=(e,t,n)=>{let r={name:e.name,message:typeof e.message==\"string\"?e.message:\"\"};return t?.includeStack&&typeof e.stack==\"string\"&&(r.stack=e.stack),!t?.excludeCause&&e.cause!==void 0&&(r.cause=n(e.cause)),r},tR=e=>{let t=new WeakSet,n=r=>{if(w0(r)){if(t.has(r))return\"[Circular]\";t.add(r);let o=KW(r,e,n);return t.delete(r),o}return JW(r)};return n},nR=e=>HW(e)?eR(e):e,rR=e=>We({decode:eR,encode:t=>tR(e)(t)}),oR=e=>We({decode:nR,encode:tR(e)});function sR(){return We({decode:Qg,encode:Hd})}function iR(){return We({decode:Yg,encode:ks})}function aR(e){return We({decode:qu,encode:e?.onNoneEncoding===null?Hd:ks})}function cR(){return Pm({decode:k,encode:Ai})}function uR(){return Pm({decode:e=>e.pipe(Ci(Lt),k),encode:Ai})}var kS=Zn({decode:(e,t)=>URL.canParse(e)?x(new URL(e)):P(new ge({expected:\"a valid URL string\"},e,t)),encode:e=>x(e.href)}),RS=Zn({decode:(e,t)=>{let n=gT(e);return ae(n)?P(new ge({expected:\"a valid BigDecimal string\"},e,t)):x(n.value)},encode:e=>x(mo(e))}),_S=new Te(Fk(),AS()),fR=new Te(Uk(),AS()),lR=new Te(Nk(),Rm()),dR=new Te($k(),_m()),pR=new Te(Bk(),jk());function mR(e){return new Te(kk(e??{}),Rk(e))}var hR=new Te(qk(),zk()),gR=new Te(Wk(),Hk()),xR=We({decode:e=>Xf(e),encode:e=>e.offset}),MS=Zn({decode:(e,t)=>fr(Ek(e),{onNone:()=>P(new ge({expected:\"a valid IANA time zone\"},e,t)),onSome:x}),encode:e=>x(e.id)}),PS=Zn({decode:(e,t)=>fr(Ik(e),{onNone:()=>P(new ge({expected:\"a valid time zone\"},e,t)),onSome:x}),encode:e=>x(Vs(e))}),FS=Zn({decode:(e,t)=>fr(wm(e),{onNone:()=>P(new ge({expected:\"a valid UTC DateTime string\"},e,t)),onSome:n=>x(Tm(n))}),encode:e=>x(Tk(e))}),US=Zn({decode:(e,t)=>fr(Sk(e),{onNone:()=>P(new ge({expected:\"a valid Zoned DateTime string\"},e,t)),onSome:x}),encode:e=>x(Am(e))});function Ys(e){return t=>t._tag===e}var Qs=Ys(\"Declaration\");var GW=Ys(\"Never\");var Mc=Ys(\"Literal\"),DS=Ys(\"UniqueSymbol\");var tE=Ys(\"Arrays\"),VW=Ys(\"Objects\"),AR=Ys(\"Union\"),ZW=Ys(\"Suspend\"),Je=class{to;transformation;constructor(t,n){this.to=t,this.transformation=n}},ua={},Er=class{isOptional;isMutable;constructorDefault;annotations;constructor(t,n,r=void 0,o=void 0){this.isOptional=t,this.isMutable=n,this.constructorDefault=r,this.annotations=o}},yR=\"~effect/Schema\",rt=class{[yR]=yR;annotations;checks;encoding;context;constructor(t=void 0,n=void 0,r=void 0,o=void 0){this.annotations=t,this.checks=n,this.encoding=r,this.context=o}toString(){return`<${this._tag}>`}},ia=class e extends rt{_tag=\"Declaration\";typeParameters;run;encodingChecks;encodingRun;constructor(t,n,r,o,s,i,a,c){super(r,o,s,i),this.typeParameters=t,this.run=n,this.encodingChecks=a,this.encodingRun=c}getParser(){let t;return(n,r)=>n===ve?Hr:(t??=this.run(this.typeParameters))(n,this,r)}_rebuild(t,n,r,o,s){let i=Uc(this.typeParameters,t);return i===this.typeParameters&&n===this.checks&&r===this.encodingChecks&&o===this.run&&s===this.encodingRun?this:new e(i,o,this.annotations,n,void 0,this.context,r,s)}recur(t){return this._rebuild(t,this.checks,this.encodingChecks,this.run,this.encodingRun)}flip(t){return this._rebuild(t,this.encodingChecks,this.checks,this.encodingRun??this.run,this.run)}getExpected(){let t=this.annotations?.expected;return typeof t==\"string\"?t:\"<Declaration>\"}},NS=class extends rt{_tag=\"Null\";getParser(){return Zm(this,null)}getExpected(){return\"null\"}},nE=new NS;var LS=class extends rt{_tag=\"Undefined\";getParser(){return Zm(this,void 0)}toCodecJson(){return Ne(this,[CR])}getExpected(){return\"undefined\"}},CR=new Je(nE,new Te(ue(()=>{}),ue(()=>null))),rE=new LS;var $S=class extends rt{_tag=\"Void\";getParser(){let t=Sr(void 0);return n=>n===ve?Hr:t}toCodecJson(){return Ne(this,[CR])}getExpected(){return\"void\"}},OR=new $S;var jS=class extends rt{_tag=\"Never\";getParser(){return Vr(this,I0)}getExpected(){return\"never\"}},kR=new jS,BS=class extends rt{_tag=\"Any\";getParser(){return Vr(this,kg)}getExpected(){return\"any\"}},RR=new BS,qS=class extends rt{_tag=\"Unknown\";getParser(){return Vr(this,kg)}getExpected(){return\"unknown\"}},Kr=new qS,zS=class extends rt{_tag=\"ObjectKeyword\";getParser(){return Vr(this,Ru)}getExpected(){return\"object | array | function\"}},_R=new zS,Dm=class extends rt{_tag=\"Enum\";enums;constructor(t,n,r,o,s){super(n,r,o,s),this.enums=t}getParser(){let t=new Set(this.enums.map(([,n])=>n));return Vr(this,n=>t.has(n))}toCodecStringTree(){if(this.enums.some(([t,n])=>typeof n==\"number\")){let t=Object.fromEntries(this.enums.map(([n,r])=>[globalThis.String(r),r]));return Ne(this,[new Je(new En(Object.keys(t).map(n=>new Fn(n)),\"anyOf\"),new Te(ue(n=>t[n]),sa()))])}return this}getExpected(){return this.enums.map(([t,n])=>JSON.stringify(n)).join(\" | \")}};function MR(e){switch(e._tag){case\"String\":case\"Number\":case\"BigInt\":return!0;case\"Literal\":case\"TemplateLiteral\":return!e.checks;case\"Union\":return!e.checks&&e.types.every(MR);default:return!1}}var Nm=class extends rt{_tag=\"TemplateLiteral\";parts;encodedParts;literals;suffixLengths;constructor(t,n,r,o,s){super(n,r,o,s);let i=[],a=[];for(let u of t){let f=nn(u);if(MR(f))i.push(f),a.push(f._tag===\"Literal\"?globalThis.String(f.literal):void 0);else throw new Error(`Invalid TemplateLiteral part ${f._tag}`)}let c=new Array(i.length+1);c[i.length]=0;for(let u=i.length-1;u>=0;u--)c[u]=c[u+1]+(a[u]?.length??0);this.parts=t,this.encodedParts=i,this.literals=a,this.suffixLengths=c}getParser(t){let n=t(this.asTemplateLiteralParser());return(r,o)=>{if(r===ve)return Hr;let s=n(r,o);return s._tag===\"Success\"?bt:Ut(s,{onSuccess:()=>r,onFailure:i=>new nt(this,[i],r,o)})}}getExpected(){return\"string\"}matchPart(t,n){return vR(this,t,n)===void 0?void 0:t}asTemplateLiteralParser(){let t=new Gr(!1,this.parts.map(QR),[]);return $c(Oo,t,new Te(tn((n,r)=>{let o=vR(this,n,r);return o?x(o):P(new ge({expected:\"a string matching template literal parts\"},n,r))}),ue(n=>n.join(\"\"))))}},Lm=class extends rt{_tag=\"UniqueSymbol\";symbol;constructor(t,n,r,o,s){super(n,r,o,s),this.symbol=t}getParser(){return Zm(this,this.symbol)}toCodecStringTree(){return Ne(this,[t_])}getExpected(){return globalThis.String(this.symbol)}},Fn=class extends rt{_tag=\"Literal\";literal;constructor(t,n,r,o,s){if(super(n,r,o,s),typeof t==\"number\"&&!globalThis.Number.isFinite(t))throw new Error(`A numeric literal must be finite, got ${N(t)}`);this.literal=t}getParser(){return Zm(this,this.literal)}matchPart(t,n){return t===globalThis.String(this.literal)?this.literal:void 0}toCodecJson(){return typeof this.literal==\"bigint\"?bR(this):this}toCodecStringTree(){return typeof this.literal==\"string\"?this:bR(this)}getExpected(){return typeof this.literal==\"string\"?JSON.stringify(this.literal):globalThis.String(this.literal)}};function bR(e){let t=globalThis.String(e.literal);return Ne(e,[new Je(new Fn(t),new Te(ue(()=>e.literal),ue(()=>t)))])}var WS=class extends rt{_tag=\"String\";getParser(){return Vr(this,$n)}matchPart(t,n){let r=this.checks;return r&&!n.disableChecks&&Zr(r,t,void 0,this,n)?void 0:t}getExpected(){return\"string\"}},Oo=new WS,HS=class extends rt{_tag=\"Number\";getParser(){return Vr(this,ku)}matchKey(t,n){return this._match(cH,t,n)}matchPart(t,n){return this._match(QS,t,n)}_match(t,n,r){if(!t.test(n))return;let o=globalThis.Number(n);return r.disableChecks||!this.checks?o:Zr(this.checks,o,void 0,this,r)?void 0:o}toCodecJson(){return this.checks&&(JS(this.checks,\"effect/schema/isFinite\")||JS(this.checks,\"effect/schema/isInt\"))?this:Ne(this,[nH])}toCodecStringTree(){return this.toCodecJson()===this?Ne(this,[uH]):Ne(this,[fH])}getExpected(){return\"number\"}};function JS(e,t){return e.some(n=>n.annotations?.representation?.id===t||n._tag===\"FilterGroup\"&&JS(n.checks,t))}var oE=new HS,KS=class extends rt{_tag=\"Boolean\";getParser(){return Vr(this,b0)}getExpected(){return\"boolean\"}},PR=new KS,GS=class extends rt{_tag=\"Symbol\";getParser(){return Vr(this,Og)}matchKey(t,n){return n.disableChecks||!this.checks?t:Zr(this.checks,t,void 0,this,n)?void 0:t}toCodecStringTree(){return Ne(this,[t_])}getExpected(){return\"symbol\"}},FR=new GS,VS=class extends rt{_tag=\"BigInt\";getParser(){return Vr(this,S0)}matchPart(t,n){if(!XS.test(t))return;let r=globalThis.BigInt(t);return n.disableChecks||!this.checks?r:Zr(this.checks,r,void 0,this,n)?void 0:r}toCodecStringTree(){return Ne(this,[dH])}getExpected(){return\"bigint\"}},UR=new VS,Gr=class e extends rt{_tag=\"Arrays\";isMutable;elements;rest;encodingChecks;constructor(t,n,r,o,s,i,a,c){super(o,s,i,a),this.isMutable=t,this.elements=n,this.rest=r,this.encodingChecks=c;let u=!1;for(let f=0;f<n.length;f++)if(dt(n[f]))u=!0;else if(u)throw new Error(\"A required element cannot follow an optional element. ts(1257)\");if(u&&r.length>1)throw new Error(\"A required element cannot follow an optional element. ts(1257)\");for(let f=1;f<r.length;f++)if(dt(r[f]))throw new Error(\"An optional element cannot follow a rest element. ts(1266)\")}getParser(t,n=t){let r=this,o,s,i=r.elements.length,a=Math.max(0,r.rest.length-1);function c(u,f){return f<i?o[f]:f>=u?s[f-u+1]:s[0]}return gm(function*(u,f){if(u===ve)return ve;if(!Array.isArray(u))return yield*P(new He(r,u,f));o||(o=r.elements.map(b=>({ast:b,parser:n(b)})),s=r.rest.map(b=>({ast:b,parser:n(b)})));let d=u.length,p={ast:r,getParser:c,input:u,len:d,tailThreshold:Math.max(i,d-a),output:new globalThis.Array(d),issues:void 0,options:f},m=sE(f?.concurrency),g=YW(p,u,{concurrency:m?.concurrency,end:r.rest.length===0?i:Math.max(d,i+a)});if(g&&(yield*g),r.rest.length===0&&d>i)for(let b=i;b<=d-1;b++){let I=new Yf(r,u[b],f),w=new Ve([b],I);if(f.errors===\"all\")p.issues?p.issues.push(w):p.issues=[w];else return yield*P(new nt(r,[w],u,f))}return p.issues?yield*P(new nt(r,p.issues,u,f)):p.output})}_rebuild(t,n,r){let o=Uc(this.elements,t),s=Uc(this.rest,t);return o===this.elements&&s===this.rest&&n===this.checks&&r===this.encodingChecks?this:new e(this.isMutable,o,s,this.annotations,n,void 0,this.context,r)}recur(t){return this._rebuild(t,this.checks,this.encodingChecks)}flip(t){return this._rebuild(t,this.encodingChecks,this.checks)}getExpected(){return\"array\"}},YW=Sf()({onItem(e,t,n){let r=n<e.len?t:ve;return e.getParser(e.tailThreshold,n).parser(r,e.options)},step(e,t,n,r){if(n._tag===\"Failure\")return $m(e,e.ast,r,n);let o=n===bt?t:n[Z];if(o!==ve)e.output[r]=o;else{let s=e.getParser(e.tailThreshold,r);if(dt(s.ast))return;let i=new Ve([r],new Gs(s.ast.context?.annotations));if(e.options.errors===\"all\")e.issues?e.issues.push(i):e.issues=[i];else return rs(new nt(e.ast,[i],e.input,e.options))}}}),sE=e=>(e=e===\"unbounded\"?1/0:e??1,e>1?{concurrency:e}:void 0),$m=(e,t,n,r)=>{if(r.cause.reasons.length===0)return r;let o=Qf(r.cause);if(o===void 0)return Qt(ns(r.cause,i=>new nt(t,[new Ve([n],i)],e.input,e.options)));let s=new Ve([n],o);if(e.options.errors===\"all\")e.issues?e.issues.push(s):e.issues=[s];else return rs(new nt(t,[s],e.input,e.options))},qm=\"[+-]?\\\\d*\\\\.?\\\\d+(?:[Ee][+-]?\\\\d+)?\";function aa(e,t,n=ua){let r,o;function s(i){switch(i._tag){case\"String\":case\"TemplateLiteral\":return(r??=Object.keys(e)).filter(a=>i.matchPart(a,n)!==void 0);case\"Number\":return(r??=Object.keys(e)).filter(a=>i.matchKey(a,n)!==void 0);case\"Symbol\":return(o??=Object.getOwnPropertySymbols(e)).filter(a=>i.matchKey(a,n)!==void 0);case\"Union\":return[...new Set(i.types.flatMap(s))];default:return[]}}return s(hE(nn(t)))}var sl=class{name;type;constructor(t,n){this.name=t,this.type=n}};function ZS(e){switch(e._tag){case\"String\":case\"Number\":case\"Symbol\":case\"TemplateLiteral\":return!0;case\"Union\":return e.types.every(ZS);default:return!1}}function QW(e){return ZS(e)&&ZS(nn(e))}var il=class{parameter;type;constructor(t,n){if(!QW(t))throw new Error(`Invalid index signature parameter ${t._tag}`);if(this.parameter=t,this.type=n,dt(n)&&!ZR(n))throw new Error(\"Cannot use `Schema.optionalKey` with index signatures, use `Schema.optional` instead.\")}},Pc=class e extends rt{_tag=\"Objects\";propertySignatures;indexSignatures;encodingChecks;constructor(t,n,r,o,s,i,a){super(r,o,s,i),this.propertySignatures=t,this.indexSignatures=n,this.encodingChecks=a;let c=t.map(u=>u.name).filter((u,f,d)=>d.indexOf(u)!==f);if(c.length>0)throw new Error(`Duplicate identifiers: ${JSON.stringify(c)}. ts(2300)`)}getParser(t,n=t){let r=this,o=[];for(let w of r.propertySignatures)o.push(w.name);let s=o.length,i=r.indexSignatures.length,a=s&&i?new Set(o):void 0;if(!s&&!i)return Vr(r,Ad);let c,u,f=(w,E,h,A,y)=>{if(y._tag===\"Failure\")return $m(w,r,E,y)??ut;let S=y===bt?A:y[Z];if(h!==ve&&S!==ve){if(s&&(a.has(E)||a.has(h)))return ut;F(w.out,h,S)}return ut},d=(w,E,h,A)=>{if(!A){let C=h.parserKey(E,w.options);if(!De(C))return T(gr(C),V=>d(w,E,h,V));A=C}if(A._tag===\"Failure\")return $m(w,r,E,A)??ut;let y=A===bt?E:A[Z],S=w.input[E],v=h.parserValue(S,w.options);return De(v)?f(w,E,y,S,v):T(gr(v),C=>f(w,E,y,S,C))},p=(w,E,h)=>{let A=w.input[E],y=h.parserValue(A,w.options);return De(y)?f(w,E,E,A,y):T(gr(y),S=>f(w,E,E,A,S))},m=i?Sf()({onItem:(w,[E,h])=>d(w,E,h),step:(w,E,h)=>h._tag===\"Failure\"?h:void 0}):void 0,g=()=>(c||(c=r.propertySignatures.map(w=>({parser:n(w.type),name:w.name,type:w.type})),u=i?r.indexSignatures.map(w=>({is:w,parserKey:t(hE(w.parameter)),parserValue:n(w.type)})):void 0),c),b=gm(function*(w,E){if(w===ve)return ve;if(!(typeof w==\"object\"&&w!==null&&!Array.isArray(w)))return yield*P(new He(r,w,E));g();let h=w,A={},y={ast:r,input:h,out:A,issues:void 0,options:E},S=E.errors===\"all\",v=E.onExcessProperty===\"error\",C=E.onExcessProperty===\"preserve\",V;if(!i&&(v||C)){a??=new Set(o),V=Reflect.ownKeys(h);for(let W=0;W<V.length;W++){let he=V[W];if(!a.has(he))if(v){let de=new Yf(r,h[he],E),Et=new Ve([he],de);if(S){y.issues?y.issues.push(Et):y.issues=[Et];continue}else return yield*P(new nt(r,[Et],w,E))}else F(A,he,h[he])}}let ce=sE(E?.concurrency);if(s){let W=SR(y,c,ce);W&&(yield*W)}if(i&&!ce)for(let W=0;W<i;W++){let he=u[W],de=he.is.parameter===Oo?p:d,Et=he.is.parameter===Oo?Object.keys(h):aa(h,he.is.parameter,E);for(let Cn=0;Cn<Et.length;Cn++){let rr=de(y,Et[Cn],he);if(!De(rr))yield*rr;else if(rr._tag===\"Failure\")return yield*rr}}else if(m){let W=gt();for(let de=0;de<i;de++){let Et=u[de],Cn=aa(h,Et.is.parameter,E);for(let rr=0;rr<Cn.length;rr++)W.push([Cn[rr],Et])}let he=m(y,W,ce);he&&(yield*he)}if(y.issues)return yield*P(new nt(r,y.issues,w,E));if(E.propertyOrder===\"original\"){let W=(V??Reflect.ownKeys(h)).concat(o),he={};for(let de of W)Object.hasOwn(A,de)&&F(he,de,A[de]);return he}return A});if(i)return b;let I=(w,E,h)=>{let A=c[E];return T(gr(h),y=>{let S=YS(w,A,y);if(S)return S;let v=()=>Sr(w.out),C=SR(w,c.slice(E+1));return C?lt(C,v):v()})};return(w,E)=>{if(w===ve)return Hr;if(E.errors===\"all\"||E.onExcessProperty!==void 0||E.propertyOrder===\"original\"||E.concurrency!==void 0)return b(w,E);if(!(typeof w==\"object\"&&w!==null&&!Array.isArray(w)))return P(new He(r,w,E));let h=g(),A=w,y={},S={ast:r,input:A,out:y,issues:void 0,options:E};try{for(let v=0;v<h.length;v++){let C=h[v],V=C.name,ce=Object.hasOwn(A,V),W=ce?A[V]:ve,he=C.parser(W,E);if(!De(he))return I(S,v,he);if(he===bt){ce&&F(y,V,W);continue}let de=YS(S,C,he);if(de)return de}}catch(v){return hr(v)}return Sr(y)}}_rebuild(t,n,r,o){let s=Uc(this.propertySignatures,a=>{let c=t(a.type);return c===a.type?a:new sl(a.name,c)}),i=Uc(this.indexSignatures,a=>{let c=n(a.parameter),u=t(a.type);return c===a.parameter&&u===a.type?a:new il(c,u)});return s===this.propertySignatures&&i===this.indexSignatures&&r===this.checks&&o===this.encodingChecks?this:new e(s,i,this.annotations,r,void 0,this.context,o)}flip(t){return this._rebuild(t,t,this.encodingChecks,this.checks)}recur(t,n=t){return this._rebuild(t,n,this.checks,this.encodingChecks)}getExpected(){return this.propertySignatures.length===0&&this.indexSignatures.length===0?\"object | array\":\"object\"}};function YS(e,t,n){if(n._tag===\"Failure\")return $m(e,e.ast,t.name,n);if(n===bt)return;let r=n[Z];if(r!==ve){F(e.out,t.name,r);return}if(delete e.out[t.name],!dt(t.type)){let o=new Ve([t.name],new Gs(t.type.context?.annotations));if(e.options.errors===\"all\"){e.issues?e.issues.push(o):e.issues=[o];return}else return rs(new nt(e.ast,[o],e.input,e.options))}}var SR=Sf()({onItem(e,t){if(!Object.hasOwn(e.input,t.name))return t.parser(ve,e.options);let n=e.input[t.name];return F(e.out,t.name,n),t.parser(n,e.options)},step:YS});function iE(e,t){return e?t?[...e,...t]:e:t}function zm(e,t,n){return new Pc(Reflect.ownKeys(e).map(r=>new sl(r,e[r].ast)),[],n,t)}function al(e){return e.ast}function aE(e,t=void 0){return new Gr(!1,e.map(n=>n.ast),[],void 0,t)}function Wm(e,t,n){return new En(e.map(al),t,void 0,n)}function DR(e,t){if(e.encoding||t.some(s=>s.encoding))throw new Error(\"StructWithRest does not support encodings\");let n=e.propertySignatures,r=e.indexSignatures,o=e.checks;for(let s of t)n=n.concat(s.propertySignatures),r=r.concat(s.indexSignatures),o=iE(o,s.checks);return new Pc(n,r,void 0,o)}function NR(e,t){if(e.encoding)throw new Error(\"TupleWithRest does not support encodings\");return new Gr(e.isMutable,e.elements,t,void 0,e.checks)}var Hm=pi(e=>{for(;;){if(ZW(e))return Kr;let t=e.encoding;if(!t)return e.recur?.(Hm,_)??e;if(t.some(n=>n.transformation._tag===\"Middleware\"&&n.transformation.decode!==_))return Kr;e=t[t.length-1].to}});function LR(e){switch(e._tag){case\"Null\":return[\"null\"];case\"Undefined\":return[\"undefined\"];case\"String\":case\"TemplateLiteral\":return[\"string\"];case\"Number\":return[\"number\"];case\"Boolean\":return[\"boolean\"];case\"Symbol\":case\"UniqueSymbol\":return[\"symbol\"];case\"BigInt\":return[\"bigint\"];case\"Arrays\":return[\"array\"];case\"ObjectKeyword\":return[\"object\",\"array\",\"function\"];case\"Objects\":return e.propertySignatures.length||e.indexSignatures.length?[\"object\"]:[\"string\",\"number\",\"boolean\",\"symbol\",\"bigint\",\"object\",\"array\",\"function\"];case\"Enum\":return Array.from(new Set(e.enums.map(([,t])=>typeof t)));case\"Literal\":return[typeof e.literal];case\"Union\":return Array.from(new Set(e.types.flatMap(LR)));default:return[\"null\",\"undefined\",\"string\",\"number\",\"boolean\",\"symbol\",\"bigint\",\"object\",\"array\",\"function\"]}}function Fc(e){switch(e._tag){default:return[];case\"Declaration\":{let t=e.annotations?.[Kf];return Array.isArray(t)?t:[]}case\"Objects\":return e.propertySignatures.flatMap(t=>{let n=t.type;if(!dt(n)){if(Mc(n))return[{key:t.name,literal:n.literal}];if(DS(n))return[{key:t.name,literal:n.symbol}]}return[]});case\"Arrays\":return e.elements.flatMap((t,n)=>{if(!dt(t)){if(Mc(t))return[{key:n,literal:t.literal}];if(DS(t))return[{key:n,literal:t.symbol}]}return[]});case\"Union\":{if(e.types.length===0)return[];let t=e.types.map(n=>Fc(Hm(n)));return t[0].filter(n=>t.every(r=>r.some(o=>o.key===n.key&&o.literal===n.literal)))}case\"Suspend\":return Fc(e.thunk())}}var ER=new WeakMap,ol=Object.freeze([]);function XW(e){let t=ER.get(e);if(t)return t;let n,r=0,o,s,i=!0;for(let a=0;a<e.length;a++){let c=e[a],u=Hm(c);if(GW(u))continue;if(i)if(Mc(u)||DS(u)){s??=new Map;let d=Mc(u)?u.literal:u.symbol,p=s.get(d);p||s.set(d,p=[]),p.push(c)}else i=!1;let f=Fc(u);if(f.length){n??=new Map,r++;for(let{key:d,literal:p}of f){let m=n.get(d);m||n.set(d,m=[new Map,new Set]),m[1].add(a);let g=m[0].get(p);g||m[0].set(p,g=new Set),g.add(a)}}else{o??={};let d=LR(u);for(let p of d)(o[p]??=[]).push(a)}}if(i&&s)s.forEach(Object.freeze),t=a=>s.get(a)??ol;else if(n?.size===1&&!o){let[a,[c]]=n.entries().next().value,u=c;for(let[f,d]of c)u.set(f,Object.freeze(Array.from(d,p=>e[p])));t=(f,d)=>{if(Ru(f)){let p=Object.hasOwn(f,a)?f[a]:void 0;if(p!==void 0)return u.get(p)??ol;if(d)return e}return ol}}else if(n){let a;for(let c of n)(!a||c[1][0].size>a[1][0].size)&&c[1][1].size===r&&(a=c);t=(c,u)=>{let f=c===null?\"null\":Array.isArray(c)?\"array\":typeof c,d=o?.[f]??ol;if(!Ru(c))return d.map(g=>e[g]);let p=new Set(d),m;if(a){let[g,[b]]=a,I=Object.hasOwn(c,g),w=I?c[g]:void 0;if(I&&(!u||w!==void 0)){let E=b.get(w);if(!E)return d.map(h=>e[h]);for(let h of E)p.add(h);m=g}}if(m===void 0)for(let[g,[b,I]]of n){let w=Object.hasOwn(c,g),E=w?c[g]:void 0;if(w&&(!u||E!==void 0)){let h=b.get(E);if(h)for(let A of h)p.add(A)}else if(u)for(let h of I)p.add(h)}for(let[g,[b,I]]of n){if(g===m)continue;let w=Object.hasOwn(c,g),E=w?c[g]:void 0;if(w&&(!u||E!==void 0)){let h=b.get(E);for(let A of p)I.has(A)&&!h?.has(A)&&p.delete(A)}}return Array.from(p).sort((g,b)=>g-b).map(g=>e[g])}}else t=a=>{let c=a===null?\"null\":Array.isArray(a)?\"array\":typeof a;return(o?.[c]??ol).map(u=>e[u]).filter(eH(a))};return ER.set(e,t),t}function eH(e){return t=>{let n=Hm(t);return n._tag===\"Literal\"?n.literal===e:n._tag===\"UniqueSymbol\"?n.symbol===e:!0}}function cl(e,t,n=!1){return XW(t)(e,n)}var En=class e extends rt{_tag=\"Union\";types;mode;encodingChecks;constructor(t,n,r,o,s,i,a){super(r,o,s,i),this.types=t,this.mode=n,this.encodingChecks=a}getParser(t,n){let r=this;return(o,s)=>{if(o===ve)return Hr;let i=cl(o,r.types,n!==void 0);if(i.length===1){let f=t(i[0])(o,s);return f._tag===\"Success\"?f:De(f)?IR(r,f.cause,o,s):ft(f,d=>IR(r,d,o,s))}let a={ast:r,compile:t,input:o,out:void 0,successes:r.mode===\"oneOf\"?[]:void 0,issues:void 0,options:s},c=sE(s?.concurrency),u=tH(a,i,c?{...c,orderedStep:!0}:void 0);return u?lt(u,f=>a.out===bt?x(o):a.out?a.out:P(new Oc(r,a.issues??[],o,s))):a.out?a.out:P(new Oc(r,a.issues??[],o,s))}}_rebuild(t,n,r){let o=Uc(this.types,t);return o===this.types&&n===this.checks&&r===this.encodingChecks?this:new e(o,this.mode,this.annotations,n,void 0,this.context,r)}recur(t){return this._rebuild(t,this.checks,this.encodingChecks)}flip(t){return this._rebuild(t,this.encodingChecks,this.checks)}matchPart(t,n){for(let r of this.types){let o=r.matchPart(t,n);if(o!==void 0)return o}}getExpected(t){let n=this.annotations?.expected;if(typeof n==\"string\")return n;if(this.types.length===0)return\"never\";let r=this.types.map(o=>{let s=nn(o);switch(s._tag){case\"Arrays\":{let i=s.elements.filter(Mc);if(i.length>0)return`${wR(s.isMutable)}[ ${i.map(a=>t(a)+TR(a.context?.isOptional)).join(\", \")}, ... ]`;break}case\"Objects\":{let i=s.propertySignatures.filter(a=>Mc(a.type));if(i.length>0)return`{ ${i.map(a=>`${wR(a.type.context?.isMutable)}${Bo(a.name)}${TR(a.type.context?.isOptional)}: ${t(a.type)}`).join(\", \")}, ... }`;break}}return t(s)});return Array.from(new Set(r)).join(\" | \")}};function IR(e,t,n,r){let o=Qf(t);return o?rs(new Oc(e,[o],n,r)):Qt(t)}var tH=Sf()({onItem(e,t){return e.compile(t)(e.input,e.options)},step(e,t,n){if(n._tag===\"Failure\"){let r=Qf(n.cause);if(r===void 0)return n;e.issues?e.issues.push(r):e.issues=[r]}else{if(e.out&&e.successes)return e.successes.push(t),rs(new Im(e.ast,e.successes,e.input,e.options));if(e.out=n,e.successes)e.successes.push(t);else return ut}}}),$R=new En([new Fn(\"Infinity\"),new Fn(\"-Infinity\"),new Fn(\"NaN\")],\"anyOf\");function wR(e){return e?\"\":\"readonly \"}function TR(e){return e?\"?\":\"\"}function fa(e){let t=!1,n;return()=>(t||(n=e(),t=!0),n)}var jm=class e extends rt{_tag=\"Suspend\";thunk;constructor(t,n,r,o,s){if(r)throw new Error(\"Cannot add checks to Suspend\");super(n,void 0,o,s),this.thunk=fa(t)}getParser(t){let n;return(r,o)=>(n??=t(this.thunk()))(r,o)}recur(t){return new e(()=>t(this.thunk()),this.annotations,void 0,void 0,this.context)}getExpected(t){return t(this.thunk())}},Bm=class e extends di{_tag=\"Filter\";run;annotations;aborted;constructor(t,n=void 0,r=!1){super(),this.run=t,this.annotations=n,this.aborted=r}annotate(t){return new e(this.run,{...this.annotations,...t},this.aborted)}abort(){return new e(this.run,this.annotations,!0)}and(t,n){return new ca([this,t],n)}},ca=class e extends di{_tag=\"FilterGroup\";checks;annotations;constructor(t,n=void 0){super(),this.checks=t,this.annotations=n}annotate(t){return new e(this.checks,{...this.annotations,...t})}and(t,n){return new e([this,t],n)}};function Jm(e,t,n=!1){return new Bm((r,o,s)=>ak(o,e(r,o,s),r,s),t,n)}function Km(e,t){return new Bm((n,r,o)=>e(n)?void 0:new ge(void 0,n,o),t,!0)}function cE(e){return Jm(t=>globalThis.Number.isFinite(t),{expected:\"a finite number\",representation:{id:\"effect/schema/isFinite\",payload:null},toJsonSchema:()=>({type:\"number\"}),toCode:()=>({runtime:\"Schema.isFinite()\"}),arbitrary:{constraint:{noInfinity:!0,noNaN:!0}},...e})}var uE=ko(oE,[cE()]),nH=new Je(new En([uE,$R],\"anyOf\"),new Te(km(),ue(e=>globalThis.Number.isFinite(e)?e:globalThis.String(e))));function ul(e,t){let n=e.source,r=new globalThis.RegExp(n,e.flags);return Jm(o=>(r.lastIndex=0,r.test(o)),{expected:`a string matching the RegExp ${n}`,representation:{id:\"effect/schema/isPattern\",payload:{source:n,flags:e.flags}},toJsonSchema:()=>({pattern:n}),arbitrary:{constraint:{patterns:[e.source]}},...t})}function fl(e,t){let n=Object.getOwnPropertyDescriptors(e);return t(n),Object.create(Object.getPrototypeOf(e),n)}var jR=new WeakMap;function fE(e){return jR.get(e)??e}function Ne(e,t){return e.encoding===t?e:fl(e,n=>{n.encoding.value=t})}function ll(e,t){if(e.context===t)return e;let n=fE(e);if(n.context===t)return n;let r=fl(e,o=>{o.context.value=t});return jR.set(r,n),r}function dl(e){return e.encoding?dl(e.encoding[e.encoding.length-1].to):e}function Ir(e,t){if(e.checks){let n=e.checks[e.checks.length-1];return Gm(e,ox(e.checks.slice(0,-1),n.annotate(t)))}return fl(e,n=>{n.annotations.value={...n.annotations.value,...t}})}function Gm(e,t){if(e._tag===\"Suspend\"&&t)throw new Error(\"Cannot add checks to Suspend\");return e.checks===t?e:fl(e,n=>{n.checks.value=t})}function ko(e,t){return Gm(e,iE(e.checks,t))}function Nc(e,t){let n=t(e.to);return n===e.to?e:new Je(n,e.transformation)}function lE(e,t){let n=e,r=n[n.length-1],o=Nc(r,t);return o===r?e:ox(e.slice(0,e.length-1),o)}function dE(e){return t=>t.encoding?Ne(t,lE(t.encoding,e)):t}function Vm(e,t){return dE(n=>ll(n,t))(e)}function BR(e){function t(n){return n.encoding?Ne(n,lE(n.encoding,t)):e(n)}return It(t)}function la(e,t){function n(r){if(r.encoding){let o=r.encoding[r.encoding.length-1];return t?.stopAt?.(o)?r:Ne(r,lE(r.encoding,n))}return e(r)}return pi(n)}function qR(e,t){return pE(e,t,Jt(e))}function zR(e,t){return pE(nn(e),t,e)}function pE(e,t,n){let r=new Je(e,t);return Ne(n,n.encoding?[...n.encoding,r]:[r])}function WR(e,t){let n=ok(e),r=n?[...n,t]:[t];return Ir(e,{brands:r})}function Uc(e,t){let n=!1,r=new Array(e.length);for(let o=0;o<e.length;o++){let s=e[o],i=t(s);i!==s&&(n=!0),r[o]=i}return n?r:e}function pl(e,t){let n=e.context?new Er(e.context.isOptional,e.context.isMutable,e.context.constructorDefault,{...e.context.annotations,...t}):new Er(!1,!1,void 0,t);return ll(e,n)}var Lc=pi(e=>{let t=e.context?e.context.isOptional===!1?new Er(!0,e.context.isMutable,e.context.constructorDefault,e.context.annotations):e.context:new Er(!0,!1);return rH(ll(e,t))}),rH=dE(Lc),HR=It(e=>Lc(new En([e,rE],\"anyOf\"))),mE=pi(e=>{let t=e.context?e.context.isMutable===!1?new Er(e.context.isOptional,!0,e.context.constructorDefault,e.context.annotations):e.context:new Er(!1,!0);return oH(ll(e,t))}),oH=dE(mE);function JR(e,t){let n=new Te(el(t),Sn()),r=new Je(Kr,n),o=e.context?new Er(e.context.isOptional,e.context.isMutable,r,e.context.annotations):new Er(!1,!1,r);return ll(e,o)}function $c(e,t,n){return pE(e,n,t)}function sH(e){let t=[],n=[];function r(o){switch(o._tag){case\"Literal\":vd(o.literal)&&t.push(o.literal);return;case\"UniqueSymbol\":t.push(o.symbol);return;case\"Never\":return;case\"Union\":for(let s=0;s<o.types.length;s++)r(o.types[s]);return;default:n.push(o)}}return r(e),{literals:t,parameters:n}}function KR(e,t){let{literals:n,parameters:r}=sH(e);return new Pc(n.map(o=>new sl(o,t)),r.map(o=>new il(o,t)))}function dt(e){return e.context?.isOptional??!1}function GR(e){return e.context?.isMutable??!1}function VR(e){return e.annotations?.[Mn]===!0||e._tag===\"FilterGroup\"&&e.checks.every(VR)}function iH(e){function t(r){return VR(r)?[r]:r._tag===\"FilterGroup\"?r.checks.flatMap(t):[]}let n=e.flatMap(t);return qt(n)?n:void 0}var Jt=pi(e=>{if(e.encoding)return Jt(Ne(e,void 0));let t=e,n=t.recur?.(Jt)??t,r=n.encodingChecks;if(r){let o=n===e?r:tE(n)||VW(n)||Qs(n)&&n.typeParameters.length>0?iH(r):void 0;return fl(n,s=>{s.encodingChecks.value=void 0,s.checks.value=iE(n.checks,o)})}return n}),nn=pi(e=>Jt(Jr(e)));function aH(e,t){let n=t,r=n.length,o=n[r-1],s=[new Je(Jr(Ne(e,void 0)),n[0].transformation.flip())];for(let a=1;a<r;a++)s.unshift(new Je(Jr(n[a-1].to),n[a].transformation.flip()));let i=Jr(o.to);return i.encoding?Ne(i,[...i.encoding,...s]):Ne(i,s)}var Jr=It(e=>{if(e.encoding)return aH(e,e.encoding);let t=e;return t.flip?.(Jr)??t.recur?.(Jr)??t});function ZR(e){switch(e._tag){case\"Undefined\":return!0;case\"Union\":return e.types.some(ZR);default:return!1}}function Zm(e,t){let n=Sr(t);return(r,o)=>r===ve?Hr:r===t?n:P(new He(e,r,o))}function Vr(e,t){return(n,r)=>n===ve?Hr:t(n)?bt:P(new He(e,n,r))}function vR(e,t,n){let r=e.encodedParts,o=e.literals,s=t.length;for(let u=0;u<o.length;u++){let f=o[u];if(f&&!t.includes(f))return}if(e.suffixLengths[0]>s)return;let i=new Array(r.length),a;function c(u,f){if(u===r.length)return f===s;if(a?.has(u*(s+1)+f))return!1;let d=r[u];if(u===r.length-1){let p=t.slice(f);if(d.matchPart(p,n)!==void 0)return i[u]=p,!0}else if(d._tag===\"Literal\"){let p=o[u];if(t.startsWith(p,f)&&c(u+1,f+p.length))return i[u]=p,!0}else{let p=s-e.suffixLengths[u+1],m=o[u+1],g=m===void 0?p:t.lastIndexOf(m,p);for(;g>=f;){let b=t.slice(f,g);if(d.matchPart(b,n)!==void 0&&c(u+1,g))return i[u]=b,!0;if(g===0)break;g=m===void 0?g-1:t.lastIndexOf(m,g-1)}}return a??=new Set,a.add(u*(s+1)+f),!1}return c(0,0)?i:void 0}var YR=It(e=>new En(e.enums.map(t=>new Fn(t[1],{title:t[0]})),\"anyOf\")),hE=la(e=>{switch(e._tag){default:return e;case\"Number\":return e.toCodecStringTree();case\"Union\":return e.recur(hE)}}),Ym=la(e=>{switch(e._tag){default:return e;case\"Symbol\":case\"UniqueSymbol\":return e.toCodecStringTree();case\"Union\":return e.recur(Ym)}}),QR=la(e=>{switch(e._tag){default:return e;case\"Number\":case\"Literal\":case\"BigInt\":return e.toCodecStringTree();case\"Union\":return e.recur(QR)}}),XR=\"[\\\\s\\\\S]*?\",QS=new globalThis.RegExp(`^${qm}$`),cH=new globalThis.RegExp(`^(?:${qm}|Infinity|-Infinity|NaN)$`);function gE(e){return ul(QS,{expected:\"a string representing a finite number\",representation:{id:\"effect/schema/isStringFinite\",payload:null},toJsonSchema:()=>({pattern:QS.source}),...e})}var e_=ko(Oo,[gE()]),uH=new Je(e_,_c),fH=new Je(new En([e_,$R],\"anyOf\"),_c),lH=\"-?\\\\d+\",XS=new globalThis.RegExp(`^${lH}$`);function xE(e){return ul(XS,{expected:\"a string representing a bigint\",representation:{id:\"effect/schema/isStringBigInt\",payload:null},toJsonSchema:()=>({pattern:XS.source}),...e})}var yE=ko(Oo,[xE({expected:\"a string representing a bigint\"})]),dH=new Je(yE,Fm),pH=\"Symbol\\\\((.*)\\\\)\",eE=new globalThis.RegExp(`^${pH}$`),mH=ko(Oo,[bE()]),t_=new Je(mH,new Te(ue(e=>globalThis.Symbol.for(eE.exec(e)[1])),tn((e,t)=>globalThis.Symbol.keyFor(e)!==void 0?x(globalThis.String(e)):P(new oa({message:\"cannot serialize to string, Symbol is not registered\"},e,t)))));function bE(e){return ul(eE,{expected:\"a string representing a symbol\",representation:{id:\"effect/schema/isStringSymbol\",payload:null},toJsonSchema:()=>({pattern:eE.source}),...e})}function Zr(e,t,n,r,o){for(let s=0;s<e.length;s++){let i=e[s];if(i._tag===\"FilterGroup\"){if(n=Zr(i.checks,t,n,r,o),n&&(o.errors!==\"all\"||n[n.length-1].filter.aborted))return n}else{let a=i.run(t,r,o);if(a){let c=new Sm(i,a,t,o);if(n?n.push(c):n=[c],o.errors!==\"all\"||i.aborted)return n}}}return n}function n_(e,t){let n=Zr(e,t,void 0,Kr,{errors:\"all\"});if(n){let r=new nt(Kr,n);return re(r)}return se(t)}function r_(e){if(!Qs(e))return;let t=e.annotations?.[xm];return un(t)?t(e.typeParameters):void 0}function hH(e){return e===null||typeof e==\"string\"||typeof e==\"boolean\"||typeof e==\"number\"&&globalThis.Number.isFinite(e)}function gH(e){return e===void 0||typeof e==\"string\"}function o_(e,t){let n=new WeakMap,r=[];e:for(;;){if(typeof e!=\"object\"||e===null){if(!t(e))return!1}else{let o=e,s=n.get(o);if(s===!1)return!1;if(s===void 0){let i=Array.isArray(o);if(!i){let a=Object.getPrototypeOf(o);if(a!==null&&a!==Object.prototype&&Object.getPrototypeOf(a)!==null)return!1}n.set(o,!1),r.push({value:o,keys:i?o.length:Object.keys(o),index:0})}}for(;r.length>0;){let o=r[r.length-1],s=o.keys;if(typeof s==\"number\"){if(o.index<s){e=o.value[o.index++];continue e}}else if(o.index<s.length){e=o.value[s[o.index++]];continue e}n.set(o.value,!0),r.pop()}return!0}}function jc(e){return o_(e,hH)}var Dc=new ia([],()=>(e,t,n)=>jc(e)?bt:P(new He(t,e,n)),{representation:{id:\"effect/schema/Json\",payload:null},expected:\"JSON value\",toCodecJson:()=>{},toCodecStringTree:()=>EE,toArbitrary:()=>e=>e.jsonValue()}),s_=Ir(Dc,{representation:{id:\"effect/schema/MutableJson\",payload:null}}),SE=new Je(Dc,Rc()),i_=new Je(new En([new Gr(!1,[],[Dc]),new Pc([],[new il(Oo,Dc)])],\"anyOf\"),Rc());function xH(e){return o_(e,gH)}var yH=new ia([],()=>(e,t,n)=>xH(e)?bt:P(new He(t,e,n)),{expected:\"StringTree\",toCodecStringTree:()=>{}}),EE=new Je(yH,Rc());var a_=\"~effect/collections/MutableHashMap\";var bH={[a_]:a_,[Symbol.iterator](){return this.backing[Symbol.iterator]()},toString(){return`MutableHashMap(${N(Array.from(this))})`},toJSON(){return{_id:\"MutableHashMap\",values:ht(Array.from(this))}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},ml=()=>{let e=Object.create(bH);return e.backing=new Map,e.buckets=new Map,e};var Xs=l(2,(e,t)=>{if(e.backing.has(t))return k(e.backing.get(t));if(IE(t))return R();let n=hl.get(e);if(n!==void 0)return e.backing.has(n)?k(e.backing.get(n)):R();let r=H(t),o=e.buckets.get(r);return o===void 0?R():SH(e,o,t)}),hl=new WeakMap,IE=e=>typeof e!=\"object\"&&typeof e!=\"function\";var SH=(e,t,n)=>{for(let r=0,o=t.length;r<o;r++)if(B(n,t[r])){let s=t[r];return hl.set(n,s),k(e.backing.get(s))}return R()},c_=l(2,(e,t)=>_e(Xs(e,t))),gl=l(3,(e,t,n)=>{if(e.backing.has(t)||IE(t))return e.backing.set(t,n),e;let r=hl.get(e);if(r!==void 0&&e.backing.has(r))return e.backing.set(r,n),e;let o=H(t),s=e.buckets.get(o);return s===void 0?(e.buckets.set(o,[t]),e.backing.set(t,n),e):(r=EH(s,t),r===void 0&&(s.push(t),r=t),e.backing.set(r,n),e)}),EH=(e,t)=>{for(let n=0,r=e.length;n<r;n++)if(B(t,e[n]))return hl.set(t,e[n]),e[n]};var xl=l(2,(e,t)=>{if(IE(t))return e.backing.delete(t),e;let n=hl.get(e)??t,r=H(n),o=e.buckets.get(r);if(o===void 0)return e;for(let s=0,i=o.length;s<i;s++){let a=o[s];if(a===n||B(n,a)){e.backing.delete(a),o.splice(s,1);break}}return o.length===0&&e.buckets.delete(r),e}),Qm=e=>(e.backing.clear(),e.buckets.clear(),e),u_=e=>e.backing.size;var f_=\"~effect/collections/Chunk\";function IH(e,t,n,r,o){for(let s=t;s<Math.min(e.length,t+o);s++)n[r+s-t]=e[s];return n}var l_=[],vE=e=>Rt((t,n)=>t.length===n.length&&yl(t).every((r,o)=>e(r,bl(n,o)))),wH=vE(B),TH={[f_]:{_A:e=>e},toString(){return`Chunk(${N(yl(this))})`},toJSON(){return{_id:\"Chunk\",values:ht(yl(this))}},[Qe](){return this.toJSON()},[Se](e){return Xm(e)&&wH(this,e)},[be](){return Ba(yl(this))},[Symbol.iterator](){switch(this.backing._tag){case\"IArray\":return this.backing.array[Symbol.iterator]();case\"IEmpty\":return l_[Symbol.iterator]();default:return yl(this)[Symbol.iterator]()}},pipe(){return K(this,arguments)}},AE=e=>{let t=Object.create(TH);switch(t.backing=e,e._tag){case\"IEmpty\":{t.length=0,t.depth=0,t.left=t,t.right=t;break}case\"IConcat\":{t.length=e.left.length+e.right.length,t.depth=1+Math.max(e.left.depth,e.right.depth),t.left=e.left,t.right=e.right;break}case\"IArray\":{t.length=e.array.length,t.depth=0,t.left=ps,t.right=ps;break}case\"ISingleton\":{t.length=1,t.depth=0,t.left=ps,t.right=ps;break}case\"ISlice\":{t.length=e.length,t.depth=e.chunk.depth+1,t.left=ps,t.right=ps;break}}return t},Xm=e=>M(e,f_),ps=AE({_tag:\"IEmpty\"}),vH=()=>ps;var AH=e=>AE({_tag:\"ISingleton\",a:e}),eh=e=>Xm(e)?e:OH(Be(e)),TE=(e,t,n)=>{switch(e.backing._tag){case\"IArray\":{IH(e.backing.array,0,t,n,e.length);break}case\"IConcat\":{TE(e.left,t,n),TE(e.right,t,n+e.left.length);break}case\"ISingleton\":{t[n]=e.backing.a;break}case\"ISlice\":{let r=0,o=n;for(;r<e.length;)t[o]=bl(e,r),r+=1,o+=1;break}}};var CH=e=>{switch(e.backing._tag){case\"IEmpty\":return l_;case\"IArray\":return e.backing.array;default:{let t=new Array(e.length);return TE(e,t,0),e.backing={_tag:\"IArray\",array:t},e.left=ps,e.right=ps,e.depth=0,t}}},yl=CH;var OH=e=>e.length===0?vH():e.length===1?AH(e[0]):AE({_tag:\"IArray\",array:e});var bl=l(2,(e,t)=>{let n=Math.floor(t);switch(e.backing._tag){case\"IEmpty\":throw new Error(`Index out of bounds: ${n}`);case\"ISingleton\":{if(t!==0)throw new Error(`Index out of bounds: ${n}`);return e.backing.a}case\"IArray\":{if(n>=e.length||n<0)throw new Error(`Index out of bounds: ${n}`);return e.backing.array[n]}case\"IConcat\":return n<e.left.length?bl(e.left,n):bl(e.right,n-e.left.length);case\"ISlice\":return bl(e.backing.chunk,n+e.backing.offset)}});var d_=e=>e.length;var m_=ty;var Bc=Av,h_=Cv,ei=Ap;var th=Zo,da=yC;var Yn=qp,g_=vC;var x_=\"~effect/MutableRef\",kH={[x_]:x_,...ln,toJSON(){return{_id:\"MutableRef\",current:ht(this.current)}}},El=e=>{let t=Object.create(kH);return t.current=e,t};var y_=e=>e.current;var CE=l(2,(e,t)=>(e.current=t,e));var In=Symbol.for(\"effect/MutableList/Empty\"),hs=()=>({head:void 0,tail:void 0,length:0}),b_=()=>({array:[],mutable:!0,offset:0,next:void 0}),Un=(e,t)=>{e.tail?e.tail.mutable||(e.tail.next=b_(),e.tail=e.tail.next):e.head=e.tail=b_(),e.tail.array.push(t),e.length++},kE=(e,t)=>{e.head={array:[t],mutable:!0,offset:0,next:e.head},e.tail||(e.tail=e.head),e.length++};var rh=(e,t)=>oh(e,Be(t),!Array.isArray(t)),oh=(e,t,n=!1)=>{if(t.length===0)return 0;let r={array:t,mutable:n,offset:0,next:void 0};return e.head?e.tail=e.tail.next=r:e.head=e.tail=r,e.length+=t.length,t.length},ms=e=>{e.head=e.tail=void 0,e.length=0},pa=(e,t)=>{if(t<=0||!e.head)return[];if(t=Math.min(t,e.length),t===e.length&&e.head?.offset===0&&!e.head.next){let s=e.head.array;return ms(e),s}let n=new Array(t),r=0,o=e.head;for(;o;){for(;o.offset<o.array.length;)if(n[r++]=o.array[o.offset],o.mutable&&(o.array[o.offset]=void 0),o.offset++,r===t)return e.head=o,e.length-=t,e.length===0&&ms(e),n;o=o.next}return ms(e),n},Il=(e,t)=>{if(t<=0||!e.head)return;if(t=Math.min(t,e.length),t===e.length&&e.head?.offset===0&&!e.head.next){ms(e);return}let n=0,r=e.head;for(;r;){let o=r.array.length-r.offset;if(n+o>t){r.offset+=t-n,e.head=r,e.length-=t;return}n+=o,r=r.next}ms(e)},ma=e=>pa(e,e.length),Yr=e=>{if(!e.head)return In;let t=e.head.array[e.head.offset];return e.head.mutable&&(e.head.array[e.head.offset]=void 0),e.head.offset++,e.length--,e.head.offset===e.head.array.length&&(e.head.next?e.head=e.head.next:ms(e)),t},S_=(e,t)=>{if(t<=0)return[];let n=Math.min(t,e.length),r=new Array(n),o=0,s=e.head;for(;s;){for(let i=s.offset;i<s.array.length;i++)if(r[o++]=s.array[i],o===n)return r;s=s.next}return r};var RE=(e,t)=>{let n=[],r=e.head;for(;r;){for(let o=r.offset;o<r.array.length;o++)t(r.array[o],o)&&n.push(r.array[o]);r=r.next}if(n.length===0){ms(e);return}e.head=e.tail={array:n,mutable:!0,offset:0,next:void 0},e.length=n.length},E_=(e,t)=>RE(e,n=>n!==t);var RH=\"~effect/PubSub\",_H=\"~effect/PubSub/Subscription\",ch=e=>oe(()=>NH(e.atomicPubSub(),new Map,mr(),Yn(!1),El(!1),e.strategy())),uh=e=>ch({atomicPubSub:()=>zE(e),strategy:()=>new BE}),fh=e=>ch({atomicPubSub:()=>zE(e),strategy:()=>new ih}),lh=e=>ch({atomicPubSub:()=>zE(e),strategy:()=>new qE}),dh=e=>ch({atomicPubSub:()=>MH(e),strategy:()=>new ih}),zE=e=>{let t=typeof e==\"number\"?{capacity:e}:e;LH(t.capacity);let n=t.replay&&t.replay>0?new ah(Math.ceil(t.replay)):void 0;return t.capacity===1?new UE(n):OO(t.capacity)===t.capacity?new PE(t.capacity,n):new _E(t.capacity,n)},MH=e=>{let t=e?.replay;return new NE(t&&t>0?new ah(Math.ceil(t)):void 0)};var wl=e=>us(Gn(t=>(CE(e.shutdownFlag,!0),Fe(e.scope,lb(t.id)).pipe(yn(e.strategy.shutdown),dm(e.shutdownHook.open),zs))));var zc=l(2,(e,t)=>z(()=>e.shutdownFlag.current?x(!1):e.pubsub.publish(t)?(e.strategy.completeSubscribersUnsafe(e.pubsub,e.subscribers),x(!0)):e.strategy.handleSurplus(e.pubsub,e.subscribers,[t],e.shutdownFlag)));var I_=l(2,(e,t)=>z(()=>{if(e.shutdownFlag.current)return x(!1);let n=e.pubsub.publishAll(t);return e.strategy.completeSubscribersUnsafe(e.pubsub,e.subscribers),n.length===0?x(!0):e.strategy.handleSurplus(e.pubsub,e.subscribers,n,e.shutdownFlag)})),Tl=e=>us(Wf(t=>{let n=Xe(t,xn),r=vt(e.scope),o=DH(e.pubsub,e.subscribers,e.strategy);return Ht(r,PH(o)).pipe(yn(Eo(n,s=>Fe(r,s))),At(o))})),PH=e=>us(Gn(t=>(CE(e.shutdownFlag,!0),qs(ma(e.pollers),n=>mb(n,t.id),{discard:!0,concurrency:\"unbounded\"}).pipe(bn(()=>oe(()=>{e.subscribers.delete(e.subscription),e.subscription.unsubscribe(),e.replayWindow.close(),e.strategy.onPubSubEmptySpaceUnsafe(e.pubsub,e.subscribers)})),dm(e.shutdownHook.open),zs)))),ph=e=>z(()=>{if(e.shutdownFlag.current)return vo;if(e.replayWindow.remaining>0){let n=e.replayWindow.take();return x(n)}let t=e.pollers.length===0?e.subscription.poll():In;return t===In?T_(e):(e.strategy.onPubSubEmptySpaceUnsafe(e.pubsub,e.subscribers),x(t))}),w_=e=>z(function t(n){if(e.shutdownFlag.current)return vo;let r=e.pollers.length===0?e.subscription.pollUpTo(Number.POSITIVE_INFINITY):[];return n&&(r=n.concat(r)),e.strategy.onPubSubEmptySpaceUnsafe(e.pubsub,e.subscribers),e.replayWindow.remaining>0?x(e.replayWindow.takeAll().concat(r)):qt(r)?x(r):T(T_(e),o=>t([o]))}),T_=e=>{let t=qi(),n=e.subscribers.get(e.subscription);return n||(n=new Set,e.subscribers.set(e.subscription,n)),n.add(e.pollers),Un(e.pollers,t),e.strategy.completePollersUnsafe(e.pubsub,e.subscribers,e.subscription,e.pollers),Ic(zi(t),()=>(E_(e.pollers,t),te))};var ot=Symbol.for(\"effect/PubSub/AbsentValue\"),FH=(e,t,n)=>{e.has(t)||e.set(t,new Set),e.get(t).add(n)},UH=(e,t,n)=>{if(!e.has(t))return;let r=e.get(t);r.delete(n),r.size===0&&e.delete(t)},DH=(e,t,n)=>new $E(e,t,e.subscribe(),hs(),Yn(!1),El(!1),n,e.replayWindow()),_E=class{array;replayIndices;publisherIndex=0;subscribers;subscriberCount=0;subscribersIndex=0;capacity;replayBuffer;constructor(t,n){this.capacity=t,this.replayBuffer=n,this.array=Array.from({length:t}),this.replayIndices=n?Array.from({length:t}):[],this.subscribers=Array.from({length:t})}replayWindow(){return this.replayBuffer?new qc(this.replayBuffer):mh}isEmpty(){return this.publisherIndex===this.subscribersIndex}isFull(){return this.publisherIndex===this.subscribersIndex+this.capacity}size(){return this.publisherIndex-this.subscribersIndex}publish(t){if(this.isFull())return!1;let n=this.replayBuffer?.offer(t);if(this.subscriberCount!==0){let r=this.publisherIndex%this.capacity;this.array[r]=t,n!==void 0&&(this.replayIndices[r]=n),this.subscribers[r]=this.subscriberCount,this.publisherIndex+=1}return!0}publishAll(t){if(this.subscriberCount===0)return this.replayBuffer&&this.replayBuffer.offerAll(t),[];let n=Be(t),r=n.length,o=this.publisherIndex-this.subscribersIndex,s=this.capacity-o,i=Math.min(r,s);if(i===0)return n;let a=0,c=this.publisherIndex+i;for(;this.publisherIndex!==c;){let u=n[a++],f=this.publisherIndex%this.capacity;this.array[f]=u;let d=this.replayBuffer?.offer(u);d!==void 0&&(this.replayIndices[f]=d),this.subscribers[f]=this.subscriberCount,this.publisherIndex+=1}return n.slice(a)}slide(){if(this.subscribersIndex!==this.publisherIndex){let t=this.subscribersIndex%this.capacity,n=this.array[t];this.array[t]=ot,this.subscribers[t]=0,this.subscribersIndex+=1,this.replayBuffer?.slide(n,this.replayIndices[t])}}subscribe(){return this.subscriberCount+=1,new ME(this,this.publisherIndex,!1)}},ME=class{self;subscriberIndex;unsubscribed;constructor(t,n,r){this.self=t,this.subscriberIndex=n,this.unsubscribed=r}isEmpty(){return this.unsubscribed||this.self.publisherIndex===this.subscriberIndex||this.self.publisherIndex===this.self.subscribersIndex}size(){return this.unsubscribed?0:this.self.publisherIndex-Math.max(this.subscriberIndex,this.self.subscribersIndex)}poll(){if(this.unsubscribed)return In;if(this.subscriberIndex=Math.max(this.subscriberIndex,this.self.subscribersIndex),this.subscriberIndex!==this.self.publisherIndex){let t=this.subscriberIndex%this.self.capacity,n=this.self.array[t];return this.self.subscribers[t]-=1,this.self.subscribers[t]===0&&(this.self.array[t]=ot,this.self.subscribersIndex+=1),this.subscriberIndex+=1,n}return In}pollUpTo(t){if(this.unsubscribed)return[];this.subscriberIndex=Math.max(this.subscriberIndex,this.self.subscribersIndex);let n=this.self.publisherIndex-this.subscriberIndex,r=Math.min(t,n);if(r<=0)return[];let o=[],s=this.subscriberIndex+r;for(;this.subscriberIndex!==s;){let i=this.subscriberIndex%this.self.capacity,a=this.self.array[i];this.self.subscribers[i]-=1,this.self.subscribers[i]===0&&(this.self.array[i]=ot,this.self.subscribersIndex+=1),o.push(a),this.subscriberIndex+=1}return o}unsubscribe(){if(!this.unsubscribed)for(this.unsubscribed=!0,this.self.subscriberCount-=1,this.subscriberIndex=Math.max(this.subscriberIndex,this.self.subscribersIndex);this.subscriberIndex!==this.self.publisherIndex;){let t=this.subscriberIndex%this.self.capacity;this.self.subscribers[t]-=1,this.self.subscribers[t]===0&&(this.self.array[t]=ot,this.self.subscribersIndex+=1),this.subscriberIndex+=1}}},PE=class{array;replayIndices;mask;publisherIndex=0;subscribers;subscriberCount=0;subscribersIndex=0;capacity;replayBuffer;constructor(t,n){this.capacity=t,this.replayBuffer=n,this.array=Array.from({length:t}),this.replayIndices=n?Array.from({length:t}):[],this.mask=t-1,this.subscribers=Array.from({length:t})}replayWindow(){return this.replayBuffer?new qc(this.replayBuffer):mh}isEmpty(){return this.publisherIndex===this.subscribersIndex}isFull(){return this.publisherIndex===this.subscribersIndex+this.capacity}size(){return this.publisherIndex-this.subscribersIndex}publish(t){if(this.isFull())return!1;let n=this.replayBuffer?.offer(t);if(this.subscriberCount!==0){let r=this.publisherIndex&this.mask;this.array[r]=t,n!==void 0&&(this.replayIndices[r]=n),this.subscribers[r]=this.subscriberCount,this.publisherIndex+=1}return!0}publishAll(t){if(this.subscriberCount===0)return this.replayBuffer&&this.replayBuffer.offerAll(t),[];let n=Be(t),r=n.length,o=this.publisherIndex-this.subscribersIndex,s=this.capacity-o,i=Math.min(r,s);if(i===0)return n;let a=0,c=this.publisherIndex+i;for(;this.publisherIndex!==c;){let u=n[a++],f=this.publisherIndex&this.mask;this.array[f]=u;let d=this.replayBuffer?.offer(u);d!==void 0&&(this.replayIndices[f]=d),this.subscribers[f]=this.subscriberCount,this.publisherIndex+=1}return n.slice(a)}slide(){if(this.subscribersIndex!==this.publisherIndex){let t=this.subscribersIndex&this.mask,n=this.array[t];this.array[t]=ot,this.subscribers[t]=0,this.subscribersIndex+=1,this.replayBuffer?.slide(n,this.replayIndices[t])}}subscribe(){return this.subscriberCount+=1,new FE(this,this.publisherIndex,!1)}},FE=class{self;subscriberIndex;unsubscribed;constructor(t,n,r){this.self=t,this.subscriberIndex=n,this.unsubscribed=r}isEmpty(){return this.unsubscribed||this.self.publisherIndex===this.subscriberIndex||this.self.publisherIndex===this.self.subscribersIndex}size(){return this.unsubscribed?0:this.self.publisherIndex-Math.max(this.subscriberIndex,this.self.subscribersIndex)}poll(){if(this.unsubscribed)return In;if(this.subscriberIndex=Math.max(this.subscriberIndex,this.self.subscribersIndex),this.subscriberIndex!==this.self.publisherIndex){let t=this.subscriberIndex&this.self.mask,n=this.self.array[t];return this.self.subscribers[t]-=1,this.self.subscribers[t]===0&&(this.self.array[t]=ot,this.self.subscribersIndex+=1),this.subscriberIndex+=1,n}return In}pollUpTo(t){if(this.unsubscribed)return[];this.subscriberIndex=Math.max(this.subscriberIndex,this.self.subscribersIndex);let n=this.self.publisherIndex-this.subscriberIndex,r=Math.min(t,n);if(r<=0)return[];let o=[],s=this.subscriberIndex+r;for(;this.subscriberIndex!==s;){let i=this.subscriberIndex&this.self.mask,a=this.self.array[i];this.self.subscribers[i]-=1,this.self.subscribers[i]===0&&(this.self.array[i]=ot,this.self.subscribersIndex+=1),o.push(a),this.subscriberIndex+=1}return o}unsubscribe(){if(!this.unsubscribed)for(this.unsubscribed=!0,this.self.subscriberCount-=1,this.subscriberIndex=Math.max(this.subscriberIndex,this.self.subscribersIndex);this.subscriberIndex!==this.self.publisherIndex;){let t=this.subscriberIndex&this.self.mask;this.self.subscribers[t]-=1,this.self.subscribers[t]===0&&(this.self.array[t]=ot,this.self.subscribersIndex+=1),this.subscriberIndex+=1}}},UE=class{publisherIndex=0;subscriberCount=0;subscribers=0;value=ot;replayIndex=0;capacity=1;replayBuffer;constructor(t){this.replayBuffer=t}replayWindow(){return this.replayBuffer?new qc(this.replayBuffer):mh}pipe(){return K(this,arguments)}isEmpty(){return this.subscribers===0}isFull(){return!this.isEmpty()}size(){return this.isEmpty()?0:1}publish(t){if(this.isFull())return!1;let n=this.replayBuffer?.offer(t);return this.subscriberCount!==0&&(this.value=t,n!==void 0&&(this.replayIndex=n),this.subscribers=this.subscriberCount,this.publisherIndex+=1),!0}publishAll(t){if(this.subscriberCount===0)return this.replayBuffer&&this.replayBuffer.offerAll(t),[];let n=Be(t);return n.length===0?n:this.publish(n[0])?n.slice(1):n}slide(){if(this.isFull()){let t=this.value;this.subscribers=0,this.value=ot,this.replayBuffer?.slide(t,this.replayIndex)}}subscribe(){return this.subscriberCount+=1,new DE(this,this.publisherIndex,!1)}},DE=class{self;subscriberIndex;unsubscribed;constructor(t,n,r){this.self=t,this.subscriberIndex=n,this.unsubscribed=r}isEmpty(){return this.unsubscribed||this.self.subscribers===0||this.subscriberIndex===this.self.publisherIndex}size(){return this.isEmpty()?0:1}poll(){if(this.isEmpty())return In;let t=this.self.value;return this.self.subscribers-=1,this.self.subscribers===0&&(this.self.value=ot),this.subscriberIndex+=1,t}pollUpTo(t){if(this.isEmpty()||t<1)return[];let n=this.self.value;return this.self.subscribers-=1,this.self.subscribers===0&&(this.self.value=ot),this.subscriberIndex+=1,[n]}unsubscribe(){this.unsubscribed||(this.unsubscribed=!0,this.self.subscriberCount-=1,this.subscriberIndex!==this.self.publisherIndex&&(this.self.subscribers-=1,this.self.subscribers===0&&(this.self.value=ot)))}},NE=class{publisherHead={value:ot,replayIndex:void 0,subscribers:0,next:null};publisherTail=this.publisherHead;publisherIndex=0;subscribersIndex=0;capacity=Number.MAX_SAFE_INTEGER;replayBuffer;constructor(t){this.replayBuffer=t}replayWindow(){return this.replayBuffer?new qc(this.replayBuffer):mh}isEmpty(){return this.publisherHead===this.publisherTail}isFull(){return!1}size(){return this.publisherIndex-this.subscribersIndex}publish(t){let n=this.replayBuffer?.offer(t),r=this.publisherTail.subscribers;if(r!==0){let o={value:t,replayIndex:n,subscribers:r,next:null};this.publisherTail.next=o,this.publisherTail=this.publisherTail.next,this.publisherIndex+=1}return!0}publishAll(t){if(this.publisherTail.subscribers!==0)for(let n of t)this.publish(n);else this.replayBuffer&&this.replayBuffer.offerAll(t);return[]}slide(){if(this.publisherHead!==this.publisherTail){let t=this.publisherHead.next,n=t.value;this.publisherHead=this.publisherHead.next,this.publisherHead.value=ot,this.subscribersIndex+=1,this.replayBuffer?.slide(n,t.replayIndex)}}subscribe(){return this.publisherTail.subscribers+=1,new LE(this,this.publisherTail,this.publisherIndex,!1)}},LE=class{self;subscriberHead;subscriberIndex;unsubscribed;constructor(t,n,r,o){this.self=t,this.subscriberHead=n,this.subscriberIndex=r,this.unsubscribed=o}isEmpty(){if(this.unsubscribed)return!0;let t=!0,n=!0;for(;n;)this.subscriberHead===this.self.publisherTail?n=!1:this.subscriberHead.next.value!==ot?(t=!1,n=!1):(this.subscriberHead=this.subscriberHead.next,this.subscriberIndex+=1);return t}size(){return this.unsubscribed?0:this.self.publisherIndex-Math.max(this.subscriberIndex,this.self.subscribersIndex)}poll(){if(this.unsubscribed)return In;let t=!0,n=In;for(;t;)if(this.subscriberHead===this.self.publisherTail)t=!1;else{let r=this.subscriberHead.next.value;r!==ot&&(n=r,this.subscriberHead.subscribers-=1,this.subscriberHead.subscribers===0&&(this.self.publisherHead=this.self.publisherHead.next,this.self.publisherHead.value=ot,this.self.subscribersIndex+=1),t=!1),this.subscriberHead=this.subscriberHead.next,this.subscriberIndex+=1}return n}pollUpTo(t){let n=[],r=0;for(;r!==t;){let o=this.poll();o===In?r=t:(n.push(o),r+=1)}return n}unsubscribe(){if(!this.unsubscribed)for(this.unsubscribed=!0,this.self.publisherTail.subscribers-=1;this.subscriberHead!==this.self.publisherTail;)this.subscriberHead.next.value!==ot&&(this.subscriberHead.subscribers-=1,this.subscriberHead.subscribers===0&&(this.self.publisherHead=this.self.publisherHead.next,this.self.publisherHead.value=ot,this.self.subscribersIndex+=1)),this.subscriberHead=this.subscriberHead.next}},$E=class{[_H]={_A:_};pubsub;subscribers;subscription;pollers;shutdownHook;shutdownFlag;strategy;replayWindow;constructor(t,n,r,o,s,i,a,c){this.pubsub=t,this.subscribers=n,this.subscription=r,this.pollers=o,this.shutdownHook=s,this.shutdownFlag=i,this.strategy=a,this.replayWindow=c}pipe(){return K(this,arguments)}},jE=class{[RH]={_A:_};pubsub;subscribers;scope;shutdownHook;shutdownFlag;strategy;constructor(t,n,r,o,s,i){this.pubsub=t,this.subscribers=n,this.scope=r,this.shutdownHook=o,this.shutdownFlag=s,this.strategy=i}pipe(){return K(this,arguments)}},NH=(e,t,n,r,o,s)=>new jE(e,t,n,r,o,s),LH=e=>{if(e<=0)throw new Error(`Cannot construct PubSub with capacity of ${e}`)},BE=class{publishers=hs();get shutdown(){return Gn(t=>qs(ma(this.publishers),([n,r,o])=>o?mb(r,t.id):te,{concurrency:\"unbounded\",discard:!0}))}handleSurplus(t,n,r,o){return z(()=>{let s=qi();return this.offerUnsafe(r,s),this.onPubSubEmptySpaceUnsafe(t,n),this.completeSubscribersUnsafe(t,n),(y_(o)?vo:zi(s)).pipe(Ic(()=>(this.removeUnsafe(s),te)))})}onPubSubEmptySpaceUnsafe(t,n){let r=!0;for(;r&&!t.isFull();){let o=Yr(this.publishers);if(o===In)r=!1;else{let[s,i]=o,a=t.publish(s);a&&o[2]?pc(i,Yt(!0)):a||kE(this.publishers,o),this.completeSubscribersUnsafe(t,n)}}}completePollersUnsafe(t,n,r,o){return WE(this,t,n,r,o)}completeSubscribersUnsafe(t,n){return HE(this,t,n)}offerUnsafe(t,n){let r=t[Symbol.iterator](),o=r.next();if(!o.done)for(;;){let s=o.value;if(o=r.next(),o.done){Un(this.publishers,[s,n,!0]);break}Un(this.publishers,[s,n,!1])}}removeUnsafe(t){RE(this.publishers,([n,r])=>r!==t)}},ih=class{get shutdown(){return te}handleSurplus(t,n,r,o){return x(!1)}onPubSubEmptySpaceUnsafe(t,n){}completePollersUnsafe(t,n,r,o){return WE(this,t,n,r,o)}completeSubscribersUnsafe(t,n){return HE(this,t,n)}},qE=class{get shutdown(){return te}handleSurplus(t,n,r,o){return oe(()=>(this.slidingPublishUnsafe(t,r),this.completeSubscribersUnsafe(t,n),!0))}onPubSubEmptySpaceUnsafe(t,n){}completePollersUnsafe(t,n,r,o){return WE(this,t,n,r,o)}completeSubscribersUnsafe(t,n){return HE(this,t,n)}slidingPublishUnsafe(t,n){let r=n[Symbol.iterator](),o=r.next();if(!o.done&&t.capacity>0){let s=o.value,i=!0;for(;i;){t.slide();let a=t.publish(s);a&&(o=r.next())&&!o.done?s=o.value:a&&(i=!1)}}}},WE=(e,t,n,r,o)=>{let s=!0;for(;s&&!r.isEmpty();){let i=Yr(o);if(i===In)UH(n,r,o),o.length===0?s=!1:FH(n,r,o);else{let a=r.poll();a===In?kE(o,i):(pc(i,Yt(a)),e.onPubSubEmptySpaceUnsafe(t,n))}}},HE=(e,t,n)=>{for(let[r,o]of n)for(let s of o)e.completePollersUnsafe(t,n,r,s)},ah=class{capacity;head={value:ot,index:0,next:null};tail=this.head;slideValues=[];size=0;index=0;publisherIndex=0;constructor(t){this.capacity=t}slide(t,n){this.slideValues[this.index%this.capacity]={value:t,index:n},this.index++}offer(t){let n=this.publisherIndex++;return this.tail.value=t,this.tail.index=n,this.tail.next={value:ot,index:0,next:null},this.tail=this.tail.next,this.size===this.capacity?this.head=this.head.next:this.size+=1,n}offerAll(t){for(let n of t)this.offer(n)}},qc=class{buffer;values;index=0;remaining;slideIndex;newestIndex=-1;constructor(t){this.buffer=t,this.remaining=t.size,this.slideIndex=t.index,this.values=new Array(this.remaining);let n=t.head;for(let r=0;r<this.remaining;r++)this.values[r]=n.value,this.newestIndex=n.index,n=n.next}close(){this.values.length=0,this.remaining=0}sync(){let t=this.buffer.index-this.slideIndex;if(t===0||this.remaining===0)return;let n=Math.min(t,this.buffer.capacity),r=this.buffer.index-n;for(let o=0;o<n;o++){let s=this.buffer.slideValues[(r+o)%this.buffer.capacity];s.index>this.newestIndex&&(this.index=(this.index+1)%this.values.length,this.values[(this.index+this.remaining-1)%this.values.length]=s.value,this.newestIndex=s.index)}this.slideIndex=this.buffer.index}take(){if(this.remaining===0)return;this.sync();let t=this.values[this.index];return this.values[this.index]=ot,this.index=(this.index+1)%this.values.length,this.remaining--,this.remaining===0&&this.close(),t}takeN(t){let n=Math.min(t,this.remaining),r=new Array(n);for(let o=0;o<n;o++)r[o]=this.take();return r}takeAll(){return this.takeN(this.remaining)}},mh={remaining:0,take:()=>{},takeN:()=>[],takeAll:()=>[],close:()=>{}};var $H=\"~effect/Queue\",jH=\"~effect/Queue/Enqueue\",BH=\"~effect/Queue/Dequeue\";var KE={_A:_,_E:_},qH={[$H]:KE,[jH]:KE,[BH]:KE,...ln,toJSON(){return{_id:\"effect/Queue\",state:this.state._tag,size:WH(this)}}},wr=e=>Y(t=>{let n=Object.create(qH);return n.dispatcher=t.currentDispatcher,n.capacity=e?.capacity??Number.POSITIVE_INFINITY,n.strategy=e?.strategy??\"suspend\",n.messages=hs(),n.scheduleRunning=!1,n.state={_tag:\"Open\",takers:new Set,offers:new Set,awaiters:new Set},U(n)}),vl=e=>wr({capacity:e});var v_=()=>wr(),Qn=(e,t)=>Q(()=>{if(e.state._tag!==\"Open\")return gh;if(e.messages.length>=e.capacity)switch(e.strategy){case\"dropping\":return gh;case\"suspend\":return e.capacity<=0&&e.state.takers.size>0?(Un(e.messages,t),GE(e),hh):GH(e,t);case\"sliding\":return Yr(e.messages),Un(e.messages,t),hh}return Un(e.messages,t),xh(e),hh}),A_=(e,t)=>e.state._tag!==\"Open\"?!1:e.messages.length>=e.capacity?e.strategy===\"sliding\"?(Yr(e.messages),Un(e.messages,t),!0):e.capacity<=0&&e.state.takers.size>0?(Un(e.messages,t),GE(e),!0):!1:(Un(e.messages,t),xh(e),!0),ha=(e,t)=>Q(()=>{if(e.state._tag!==\"Open\")return U(Be(t));let n=yh(e,t);return n.length===0?Oe([]):e.strategy===\"dropping\"?U(n):VH(e,n)}),yh=(e,t)=>{if(e.state._tag!==\"Open\")return Be(t);if(e.capacity===Number.POSITIVE_INFINITY||e.strategy===\"sliding\")return rh(e.messages,t),e.strategy===\"sliding\"&&pa(e.messages,e.messages.length-e.capacity),xh(e),[];let n=e.capacity<=0?e.state.takers.size:e.capacity-e.messages.length;if(n===0)return Be(t);let r=[],o=0;for(let s of t)o<n?Un(e.messages,s):r.push(s),o++;return xh(e),r};var Dt=l(2,(e,t)=>L(()=>ti(e,t))),ti=(e,t)=>{if(e.state._tag!==\"Open\")return!1;let n=Mt(t),r=rA(n,HH);return e.state.offers.size===0&&e.messages.length===0?(VE(e,r),!0):(e.state={...e.state,_tag:\"Closing\",exit:r},!0)},C_=e=>Dt(e,Va(Ii())),O_=e=>ti(e,Va(Ii()));var Qr=e=>L(()=>{if(e.state._tag===\"Done\")return!0;ms(e.messages);let t=e.state.offers;if(VE(e,e.state._tag===\"Open\"?JH:e.state.exit),t.size>0){for(let n of t)n._tag===\"Single\"?n.resume(gh):n.resume(Oe(n.remaining.slice(n.offset)));t.clear()}return!0});var Hc=e=>k_(e,1,Number.POSITIVE_INFINITY);var k_=(e,t,n)=>Q(()=>KH(e,t,n)??Nr(R_(e),k_(e,1,n))),gs=e=>Q(()=>zH(e)??Nr(R_(e),gs(e)));var zH=e=>{if(e.state._tag===\"Done\")return e.state.exit;if(e.messages.length>0){let t=Yr(e.messages);return Wc(e),Oe(t)}else if(e.capacity<=0&&e.state.offers.size>0){e.capacity=1,Wc(e),e.capacity=0;let t=Yr(e.messages);return Wc(e),Oe(t)}};var WH=e=>e.state._tag===\"Done\"?0:e.messages.length;var gh=Oe(!1),hh=Oe(!0),HH=jn(Ii()),JH=Rp(),GE=e=>{if(e.scheduleRunning=!1,!(e.state._tag===\"Done\"||e.state.takers.size===0)){for(let t of e.state.takers)if(e.state.takers.delete(t),t(hn),e.messages.length===0)break}},xh=e=>{e.scheduleRunning||e.state._tag===\"Done\"||e.state.takers.size===0||(e.scheduleRunning=!0,e.dispatcher.scheduleTask(()=>GE(e),0))},KH=(e,t,n)=>{if(e.state._tag===\"Done\")return e.state.exit;if(n<=0||t<=0)return Oe([]);if(e.capacity<=0&&e.state.offers.size>0){e.capacity=1,Wc(e),e.capacity=0;let r=[Yr(e.messages)];return Wc(e),Oe(r)}if(t=Math.min(t,e.capacity||1),t<=e.messages.length){let r=pa(e.messages,n);return Wc(e),Oe(r)}},GH=(e,t)=>ct(n=>{if(e.state._tag!==\"Open\")return n(gh);let r={_tag:\"Single\",message:t,resume:n};return e.state.offers.add(r),L(()=>{e.state._tag===\"Open\"&&e.state.offers.delete(r)})}),VH=(e,t)=>ct(n=>{if(e.state._tag!==\"Open\")return n(Oe(t));let r={_tag:\"Array\",remaining:t,offset:0,resume:n};return e.state.offers.add(r),L(()=>{e.state._tag===\"Open\"&&e.state.offers.delete(r)})}),Wc=e=>{if(e.state._tag===\"Done\")return is(e.state.exit.cause);if(e.state.offers.size===0)return e.state._tag===\"Closing\"&&e.messages.length===0?(VE(e,e.state.exit),is(e.state.exit.cause)):!1;let t=e.capacity-e.messages.length;for(let n of e.state.offers){if(t===0)break;if(n._tag===\"Single\")Un(e.messages,n.message),t--,n.resume(hh),e.state.offers.delete(n);else{for(;n.offset<n.remaining.length;n.offset++){if(t===0)return!1;Un(e.messages,n.remaining[n.offset]),t--}n.resume(Oe([])),e.state.offers.delete(n)}}return!1},R_=e=>ct(t=>e.state._tag===\"Done\"?t(e.state.exit):(e.state.takers.add(t),L(()=>{e.state._tag!==\"Done\"&&e.state.takers.delete(t)})));var VE=(e,t)=>{if(e.state._tag===\"Done\")return;let n=e.state;e.state={_tag:\"Done\",exit:t};for(let r of n.takers)r(t);n.takers.clear();for(let r of n.awaiters)r(t);n.awaiters.clear()};var Jc=e=>new YE(e),__=(e,t,n)=>ct(r=>{if(e.free>=t)return r(n);let o=()=>{e.free<t||(e.waiters.delete(o),r(n))};return e.waiters.add(o),L(()=>{e.waiters.delete(o)})}),YE=class{waiters=new Set;taken=0;permits;constructor(t){this.permits=t}get free(){return this.permits-this.taken}take(t){let n=Q(()=>this.free<t?__(this,t,n):(this.taken+=t,U(t)));return n}takeIfAvailable(t){return Q(()=>this.free<t?U(!1):(this.taken+=t,U(!0)))}releaseUnsafe(t,n){return this.taken-=n,this.waiters.size>0&&t.currentDispatcher.scheduleTask(()=>{for(let r of this.waiters){if(this.free<=0)break;r()}},0),this.free}resize(t){return Y(n=>(this.permits=t,this.free<0?pe:(this.releaseUnsafe(n,0),pe)))}release(t){return Y(n=>U(this.releaseUnsafe(n,t)))}get releaseAll(){return Y(t=>U(this.releaseUnsafe(t,this.taken)))}withPermits(t){return n=>$r(r=>{let o=Q(()=>{if(this.free<t){let s=__(this,t,pe);return q(r(s),()=>o)}return this.taken+=t,Xo(r(n),()=>{this.releaseUnsafe(Zo(),t)},!0)});return o})}withPermit=this.withPermits(1);withPermitsIfAvailable(t){return n=>$r(r=>this.free<t?Ui:(this.taken+=t,Xo(r(uc(n)),()=>{this.releaseUnsafe(Zo(),t)},!0)))}};var XE=e=>Af(e)?et(e)?G(e.value):e:x(e);var U_=\"~effect/Channel\",Kt=e=>M(e,U_),ZH={[U_]:{_Env:_,_InErr:_,_InElem:_,_OutErr:_,_OutElem:_},pipe(){return K(this,arguments)}},Ce=e=>{let t=Object.create(ZH);return t.transform=(n,r)=>ft(e(n,r),o=>x(tt(o))),t},eo=(e,t)=>Ce((n,r)=>T(me(e)(n,r),o=>t(o,r))),Nt=e=>Ce((t,n)=>e),to=e=>Ce(xe(function*(t,n){let r=vt(n),o=i=>Fe(r,Mb(i)),s=yield*Wr(e(t,n,r),o);return Wr(s,o)})),me=e=>e.transform,Al=4096,YH=(e,t,n)=>wr({capacity:n?.bufferSize,strategy:n?.strategy}).pipe(bn(r=>Ht(e,Qr(r))),bn(r=>yt(ss(t(r),e),e)));var D_=(e,t)=>Ce((n,r)=>$(YH(r,e,t),Hc)),bh=e=>Ce((t,n)=>z(()=>me(e())(t,n))),QH=(e,t,n)=>to(xe(function*(r,o,s){let i=R();yield*Eo(s,c=>_e(i)?n(i.value,c):te);let a=yield*us(e);return i=k(a),yield*me(t(a))(r,o)}));var eI=e=>Nt(oe(()=>{let t=0;return z(()=>t>=e.length?G():x(e[t++]))}));var tI=(e,t=Al)=>Nt(oe(()=>{let n=e(),r=R();return z(()=>{if(r._tag===\"Some\")return G(r.value);let o=[];for(;o.length<t;){let s=n.next();if(s.done){if(o.length===0)return G(s.value);r=k(s.value);break}o.push(s.value)}return x(o)})}));var N_=(e,t=Al)=>tI(()=>e[Symbol.iterator](),t),Sh=e=>Eh(x(e)),XH=e=>Nt(x(G(e)));var L_=e=>Eh(oe(e)),Kc=Nt(x(G())),$_=Nt(x(Gi)),_o=e=>Nt(x(P(e))),j_=e=>Nt(am(e)),Cl=e=>Nt(tt(e)),B_=e=>Nt(cs(e)),nI=e=>Cl(rb(e)),Eh=e=>Nt(oe(()=>{let t=!1;return z(()=>t?G():(t=!0,e))}));var q_=e=>Nt(T(e,()=>G())),rI=e=>Nt(x(T(e,XE)));var z_=e=>Nt(x(Hc(e)));var oI=e=>Nt(x(Ic(w_(e),()=>G())));var W_=e=>xs($(Tl(e),oI)),H_=e=>xs($(Tl(e),t=>rI(ph(t))));var J_=e=>Ce((t,n)=>eJ({scope:n,readable:e.evaluate(),onError:e.onError,releaseLockOnEnd:e.releaseLockOnEnd}));var eJ=e=>{let t=e.readable.getReader(),n=e.exit??El(void 0),r=z(()=>n.current?n.current:Zi(bc({try:()=>t.read(),catch:e.onError}),{onFailure:o=>n.current??tt(o),onSuccess:({done:o,value:s})=>n.current?n.current:o?G():x(Ee(s))}));return At(Ht(e.scope,e.releaseLockOnEnd?oe(()=>t.releaseLock()):sm(()=>t.cancel().catch(_r))),r)},tJ=(e,t)=>Ce(xe(function*(n,r){let o=e[Symbol.asyncIterator]();return o.return&&(yield*Ht(r,sm(()=>o.return()))),T(bc({try:()=>o.next(),catch:t}),s=>s.done?G(s.value):x(s.value))})),K_=(e,t)=>no(tJ(e,t),Ee),no=l(2,(e,t)=>eo(e,n=>oe(()=>{let r=0;return $(n,o=>t(o,r++))})));var G_=e=>e===void 0||e!==\"unbounded\"&&e<=1,Ih=l(e=>Kt(e[0]),(e,t,n)=>G_(n?.concurrency)?V_(e,t):nJ(e,t,n)),V_=(e,t)=>Ce((n,r)=>{let o=0;return $(me(e)(n,r),T(s=>t(s,o++)))}),nJ=(e,t,n)=>to(xe(function*(r,o,s){let i=0,a=yield*me(e)(r,o),c=n.concurrency===\"unbounded\"?Number.MAX_SAFE_INTEGER:n.concurrency,u=yield*vl(0);yield*Ht(s,Qr(u));let f=Ao(yield*Yi()),d=da(s);if(n.unordered){let p=Jc(c),m=Le(p.release(1)),g=Zi({onFailure:b=>T(Dt(u,b),m),onSuccess:b=>T(Qn(u,b),m)});yield*p.take(1).pipe(T(()=>a),T(b=>(d(f(g(t(b,i++)))),te)),Ct({disableYield:!0}),ft(b=>p.withPermits(c-1)(Dt(u,b))),yt(s))}else{let p=yield*vl(c-2);yield*Ht(s,Qr(p)),yield*gs(p).pipe(Jb,T(b=>Qn(u,b)),Ct({disableYield:!0}),ft(b=>Dt(u,b)),yt(s));let m,g=b=>{b._tag!==\"Success\"&&(m=b.cause,ti(u,b.cause))};yield*a.pipe(T(b=>{if(m)return tt(m);let I=f(t(b,i++));return d(I),I.addObserver(g),Qn(p,Bc(I))}),Ct({disableYield:!0}),ft(b=>Qn(p,Qt(b)).pipe(yn(Dt(p,b)))),yt(s))}return gs(u)}));var Z_=l(e=>Kt(e[0]),(e,t,n)=>G_(n?.concurrency)?rJ(e,t):oJ(e,t,n)),rJ=(e,t)=>Ce((n,r)=>$(me(e)(n,r),o=>{let s,i,a=T(o,u=>(i??=vt(r),lt(me(t(u))(n,i),f=>(s=c(f),s)))),c=Ge(u=>{if(s=void 0,i.state._tag===\"Open\"&&jA(i)===1)return a;let f=Fe(i,ut);return i=void 0,T(f,()=>a)});return z(()=>s??a)})),oJ=(e,t,n)=>e.pipe(no(t),vh(n)),wh=l(2,(e,t)=>Ce((n,r)=>oe(()=>{let o,s=vt(r),i=T(me(e)(n,s),a=>(o=Ge(a,c=>Fe(s,ut).pipe(T(()=>me(t(c))(n,r)),T(u=>(o=u,u)))),o));return z(()=>o??i)})));var sI=l(4,(e,t,n,r)=>Ce(xe(function*(o,s){let i=yield*me(e)(o,s),a=yield*me(t)(o,s),c=n();return z(()=>{let u=r(c,i,a);return $(u,([f,d])=>(c=d,f))})}))),Y_=l(2,(e,t)=>Ce((n,r)=>oe(()=>{let o,s=vt(r),i=T(me(e)(n,s),a=>{let c=a.pipe(bn(()=>(o=a,te)),Ge(u=>Fe(s,Yt(u)).pipe(yn(me(t(u))(n,r)),T(f=>(o=f,f)))));return o=c,c});return z(()=>o??i)})));var rn=e=>eo(e,t=>{let n,r=0,o=z(function s(){if(n===void 0)return T(t,a=>{switch(a.length){case 0:return s();case 1:return x(a[0]);default:return n=a,x(a[r++])}});let i=n[r++];return r>=n.length&&(n=void 0,r=0),x(i)});return x(o)}),Q_=e=>V_(e,XE),X_=e=>eo(e,t=>x(Ct(t,{disableYield:!0}))),eM=l(2,(e,t)=>qr(typeof t==\"function\"?t(_):t).pipe($(n=>{let r=Ft.defaultValue(),o=wh(kh(e,Ft,oe(()=>r)),s=>n(s).pipe($(i=>(r=i,o)),Ge(()=>x(XH(s))),xs));return o}),xs)),iI=e=>wh(e,()=>iI(e)),tM=l(2,(e,t)=>eo(e,(n,r)=>$(gc(t),o=>bn(n,o)))),nM=l(2,(e,t)=>Ce((n,r)=>$(me(e)(n,r),o=>T(o,function s(i){return t(i)?x(i):T(o,s)}))));var rM=l(2,(e,t)=>eo(e,n=>x(T(n,function r(o){let s=[];for(let i=0;i<o.length;i++)t(o[i])&&s.push(o[i]);return Me(s)?x(s):T(n,r)})))),oM=l(2,(e,t)=>eo(e,n=>x(T(n,function r(o){let s=[];for(let i=0;i<o.length;i++){let a=t(o[i]);Tt(a)&&s.push(a.success)}return Me(s)?x(s):T(n,r)})))),sM=l(2,(e,t)=>eo(e,n=>{let r=T(n,o=>Yb(o,t));return x(T(r,function o(s){return Me(s)?x(s):T(r,o)}))})),iM=l(2,(e,t)=>eo(e,n=>x(T(n,function r(o){return T(Qb(o,t),s=>Me(s)?x(s):T(n,r))})))),Gc=l(e=>Kt(e[0]),(e,t,n,r)=>Ce((o,s)=>$(me(e)(o,s),i=>{let a=t(),c,u=0,f,d=Zi(i,{onFailure(m){f=m;let g=r?.onHalt&&r.onHalt(a);return g&&g.length>0?x([a,g]):tt(m)},onSuccess(m){let g=n(a,m);return aT(g)?x(g):g}});return z(function m(){if(c===void 0)return f?tt(f):T(d,([b,I])=>(a=b,I.length===0?m():I.length===1?x(I[0]):(c=I,m())));let g=c[u++];return u>=c.length&&(c=void 0,u=0),x(g)})})));var aM=l(3,(e,t,n)=>Ce((r,o)=>$(me(e)(r,o),s=>{let i=t,a=!0;return z(()=>a?(a=!1,x(i)):$(T(s,c=>n(i,c)),c=>(i=c,i)))}))),Vc=l(2,(e,t)=>Ce((n,r)=>{let o=vt(r);return $(me(e)(n,o),s=>{let i=s.pipe(ft(a=>{if(is(a))return tt(a);let c=o;return o=vt(r),Fe(c,Qt(a)).pipe(yn(me(t(a))(n,o)),T(u=>(i=u,u)))}));return z(()=>i)})})),Th=l(2,(e,t)=>Vc(e,n=>q_(T(t(n),r=>tt(n))))),cM=l(3,(e,t,n)=>Vc(e,r=>t(r)?n(r):Cl(r))),aI=l(3,(e,t,n)=>Vc(e,r=>{let o=t(r);return ie(o)?Cl(o.failure):n(o.success,r)})),Xr=l(2,(e,t)=>aI(e,ib,n=>t(n)));var uM=l(2,(e,t)=>eo(e,n=>x(lm(n,r=>dc(r)?te:zs(t(r)))))),fM=l(e=>Kt(e[0]),(e,t,n,r)=>Xr(e,o=>t(o)?n(o):r?r(o):_o(o))),lM=l(e=>Kt(e[0]),(e,t,n,r)=>Xr(e,o=>{let s=t(o);return ie(s)?r?r(s.failure):_o(s.failure):n(s.success)}));var dM=l(e=>Kt(e[0]),(e,t,n,r,o)=>Xr(e,s=>{if($t(s,t)&&M(s,\"reason\")){let i=s.reason;return $t(i,n)?r(i,s):o?o(i,s):_o(s)}return _o(s)})),pM=l(e=>Kt(e[0]),(e,t,n,r)=>{let o;return Xr(e,s=>{if($t(s,t)&&M(s,\"reason\")&&M(s.reason,\"_tag\")&&Xp(s.reason._tag)){let i=s.reason;return o??=new Set(Object.keys(n)),o.has(i._tag)?n[i._tag](i,s):r?r(i,s):_o(s)}return _o(s)})});var mM=l(2,(e,t)=>Xr(e,n=>_o(t(n)))),hM=e=>Xr(e,nI),gM=l(e=>Kt(e[0]),(e,t)=>{if(!t?.log)return Xr(e,()=>Kc);let n=hm(t.log===!0?void 0:t.log);return Xr(Th(e,r=>sb(r)?n(r):te),()=>Kc)}),P_=e=>Vc(e,()=>Kc),xM=l(e=>Kt(e[0]),(e,t)=>{if(!t?.log)return P_(e);let n=hm(t.log===!0?void 0:t.log);return P_(Th(e,r=>n(r)))}),yM=l(2,(e,t)=>bh(()=>{let n,r=Ft.defaultValue(),o=kh(e,Ft,oe(()=>r)),s=fI(o,()=>(n=void 0,te)),i=typeof t==\"function\"?t(_):t,a=Xr(s,xe(function*(c){return n||(n=yield*qr(i)),r=yield*n(c),a},(c,u)=>Ge(c,()=>x(_o(u))),xs));return a})),bM=l(e=>Kt(e[0]),(e,t,n)=>e.pipe(no(t),vh({...n,concurrency:n?.concurrency??1,switch:!0}))),vh=l(2,(e,{bufferSize:t=16,concurrency:n,switch:r=!1})=>to(xe(function*(o,s,i){let a=n===\"unbounded\"?Number.MAX_SAFE_INTEGER:Math.max(1,n),c=r?void 0:Jc(a),u=yield*g_(!0),f=new Set,d=yield*vl(t);yield*Ht(i,Qr(d));let p=yield*me(e)(o,s);return yield*en(function*(){for(;;){let m;c&&(f.size<a?yield*c.take(1):(m=yield*wc(p),yield*Ec(c.take(1),yn(Bc(m),Gi))));let g=m===void 0?yield*p:yield*Bc(m),b=vt(i),I=yield*me(g)(o,b);for(;f.size>=a;){let E=rT(f);f.delete(E),f.size===0&&(yield*u.open),yield*ei(E)}let w=yield*I.pipe(bn(()=>qf),T(E=>Qn(d,E)),Ct({disableYield:!0}),Wr(xe(function*(E){let h=hc(E);if(yield*gr(Fe(b,ie(h)?Qt(h.failure):Yt(h.success.value))),!!f.has(w)&&(f.delete(w),c&&(yield*c.release(1)),f.size===0&&(yield*u.open),!Tt(h)))return yield*Dt(d,E)})),wc);u.closeUnsafe(),f.add(w)}}).pipe(ft(m=>{let g=hc(m);return Tt(g)?u.whenOpen(Dt(d,m)):Dt(d,m)}),yt(i)),gs(d)}))),Ah=l(e=>Kt(e[0])&&Kt(e[1]),(e,t,n)=>to(xe(function*(r,o,s){let i=n?.haltStrategy??\"both\",a=yield*vl(0);yield*Ht(s,Qr(a));let c=0;function u(d,p){if(c++,!is(p))return Dt(a,p);switch(i){case\"both\":return c===2?Dt(a,p):te;case\"left\":case\"right\":return d===i?Dt(a,p):te;case\"either\":return Dt(a,p)}}let f=(d,p,m)=>me(p)(r,m).pipe(T(g=>g.pipe(T(b=>Qn(a,b)),Ct)),Wr(g=>yn(Fe(m,Mb(g)),u(d,g))),yt(s));return yield*f(\"left\",e,vt(s)),yield*f(\"right\",t,vt(s)),gs(a)}))),SM=l(2,(e,t)=>Ah(e,q_(t),{haltStrategy:\"left\"})),EM=()=>Ce((e,t)=>oe(()=>{let n=\"\",r=!1,o=R();function s(c){let u=[];function f(d){n.length===0?u.push(d):(u.push(n+d),n=\"\")}for(let d=0;d<c.length;d++){let p=c[d];if(p.length!==0){let m=0,g=p.indexOf(\"\\r\"),b=p.indexOf(`\n`);for(r&&(b===0?(f(\"\"),m=1,b=p.indexOf(`\n`,m)):f(\"\"),r=!1);g!==-1||b!==-1;)g===-1||b!==-1&&b<g?(f(p.substring(m,b)),m=b+1,b=p.indexOf(`\n`,m)):p.length===g+1?(r=!0,g=-1):(f(p.substring(m,g)),m=g+(b===g+1?2:1),g=p.indexOf(\"\\r\",m),b=p.indexOf(`\n`,m));n=n+p.substring(m,p.length-(r?1:0))}}return Me(u)?u:null}let i=z(()=>o._tag===\"Some\"?G(o.value):tm(e,{onSuccess:a,onFailure:tt,onDone:c=>{if(o=k(c),n.length>0||r){let u=n;return n=\"\",r=!1,x([u])}return G(c)}}));function a(c){let u=s(c);return u!==null?x(u):i}return i}));var cI=l(2,(e,t)=>Ce((n,r)=>T(me(e)(n,r),o=>me(t)(o,r)))),uI=l(2,(e,t)=>Ce((n,r)=>T(me(e)(n,r),o=>{let s=ft(o,i=>is(i)?tt(i):hr(cb(i)));return $(me(t)(s,r),i=>zf(i,a=>dc(a)?tt(a.value):hr(a)))}))),xs=e=>Ce((t,n)=>{let r;return x(z(()=>r||e.pipe(ss(n),T(o=>me(o)(t,n)),T(o=>r=o))))}),IM=e=>to((t,n,r)=>$(ss(me(e)(t,n),r),ss(r)));var wM=l(2,(e,t)=>Ce(xe(function*(n,r){let o=yield*me(e)(n,r),s=yield*wr({capacity:t.capacity===\"unbounded\"?void 0:t.capacity,strategy:t.capacity===\"unbounded\"?void 0:t.strategy});return yield*Ht(r,Qr(s)),yield*o.pipe(T(i=>Qn(s,i)),Ct({disableYield:!0}),Wr(i=>Dt(s,i)),yt(r)),gs(s)}))),TM=l(2,(e,t)=>Ce(xe(function*(n,r){let o=yield*me(e)(n,r),s=yield*wr({capacity:t.capacity===\"unbounded\"?void 0:t.capacity,strategy:t.capacity===\"unbounded\"?void 0:t.strategy});return yield*Ht(r,Qr(s)),yield*o.pipe(T(i=>ha(s,i)),Ct({disableYield:!0}),Wr(i=>Dt(s,i)),yt(r)),Hc(s)}))),vM=l(2,(e,t)=>Ah(e,Nt(x(T(t,G))),{haltStrategy:\"either\"})),AM=l(2,(e,t)=>to(xe(function*(n,r,o){let s=yield*me(e)(n,r),i=yield*yt(t,o,{startImmediately:!0});return z(()=>{let a=i.pollUnsafe();return a===void 0?s:db(a,{onFailure:tt,onSuccess:G})})}))),CM=l(2,(e,t)=>Ol(e,n=>os(n)?t(n.cause):te)),Ol=l(2,(e,t)=>to((n,r,o)=>Eo(o,t).pipe(yn(me(e)(n,r))))),OM=l(2,(e,t)=>xs(At(t,e))),fI=l(2,(e,t)=>eo(e,n=>oe(()=>{let r=!0,o=bn(n,s=>(r=!1,t(s)));return z(()=>r?o:n)}))),kM=l(2,(e,t)=>eo(e,n=>x(Ge(n,r=>T(t,()=>G(r)))))),RM=l(2,(e,t)=>Ol(e,n=>t)),ga=(e,t,n)=>z(()=>{let r=mr(),o=me(e)(G(),r);return Ge(T(o,t),n||x).pipe(yr(s=>Fe(r,s)))});var Ch=l(2,(e,t)=>Ce((n,r)=>$(xr(me(e)(n,r),t),xr(t)))),Oh=l(3,(e,t,n)=>Ce((r,o)=>$(To(me(e)(r,o),t,n),To(t,n)))),kh=l(3,(e,t,n)=>Ce((r,o)=>T(n,s=>me(Oh(e,t,s))(r,o)))),_M=l(e=>Kt(e[0]),(e,t,n)=>Ju(t)?Ch(e,t):Ce((r,o)=>T(n?.local?kf(t,Of(),o):Gp(t,o),s=>$(xr(me(e)(r,o),s),xr(s))))),MM=l(2,(e,t)=>Ce((n,r)=>Wf(o=>{let s=t(o);return me(Ch(e,s))(n,r)})));var lI=function(){let e=Kt(arguments[0]),t=e?arguments[1]:arguments[0],n=Fs(e?arguments[2]:arguments[1]);if(e){let r=arguments[0];return F_(r,t,n)}return r=>F_(r,t,n)},F_=(e,t,n)=>QH(iS(t,n),r=>Oh(e,Mi,r),(r,o)=>Gn(s=>{let i=s.getRef(Kn),a=s.getRef(eO);return zp(r,o,i,a)}));var PM=e=>ga(e,t=>Ct(t,{disableYield:!0})),xa=l(2,(e,t)=>ga(e,n=>Ct(T(n,t),{disableYield:!0}))),FM=l(2,(e,t)=>ga(e,n=>n.pipe(T(t),T(r=>r?te:G()),Ct({disableYield:!0})))),dI=e=>$(ya(e,()=>({bytes:0,arrays:[]}),(t,n)=>{for(let r=0;r<n.length;r++)t.bytes+=n[r].length,t.arrays.push(n[r]);return t}),({arrays:t,bytes:n})=>{let r=new Uint8Array(n),o=0;for(let s=0;s<t.length;s++){let i=t[s];r.set(i,o),o+=i.length}return r});var UM=e=>z(()=>{let t=R();return ga(e,n=>n.pipe(um,T(r=>(t=r,G()))),()=>x(t))}),DM=e=>z(()=>{let t=Symbol(),n=t;return ga(e,r=>Ct(T(r,o=>(n=o,te)),{disableYield:!0}),()=>n===t?as:im(n))}),ya=l(3,(e,t,n)=>z(()=>{let r=t();return ga(e,o=>zr({while:cn,body:()=>o,step:s=>{r=n(r,s)}}),()=>x(r))})),NM=l(3,(e,t,n)=>z(()=>{let r=t();return ga(e,o=>zr({while:cn,body:Le(o.pipe(T(s=>n(r,s)),$(s=>{r=s}))),step:_r}),()=>x(r))})),pI=xe(function*(e){let t=Jc(1),n=yield*Yi(),r=Xe(n,xn);return(yield*me(e)(G(),r)).pipe(xr(n),t.withPermits(1))},ft(e=>x(tt(e)))),Mo=(e,t)=>me(e)(G(),t);var mI=l(e=>Kt(e[0]),(e,t)=>Hs(n=>xa(e,r=>ha(t,r)).pipe(n,gr,T(r=>(et(r)?O_(t):ti(t,r.cause),te)))));var LM=l(e=>Kt(e[0]),xe(function*(e,t){let n=yield*Qi,r=yield*wr({capacity:typeof t.capacity==\"number\"?t.capacity:void 0,strategy:typeof t.capacity==\"number\"?t.strategy:void 0});return yield*Ht(n,Qr(r)),yield*yt(mI(e,r),n),r}));var $M=e=>Xi(e.capacity===\"unbounded\"?dh(e):e.strategy===\"dropping\"?fh(e):e.strategy===\"sliding\"?lh(e):uh(e),wl),jM=l(2,xe(function*(e,t){let n=yield*$M(t);return yield*Tc(hI(e,n,{shutdownOnEnd:t.shutdownOnEnd!==!1})),n})),hI=l(e=>Kt(e[0]),(e,t,n)=>xa(e,r=>I_(t,r)).pipe(n?.shutdownOnEnd===!0?ea(wl(t)):_)),BM=l(2,xe(function*(e,t){let n=yield*$M(t);return yield*xa(e,r=>zc(n,r)).pipe(yr(r=>zc(n,r)),Tc),n}));var le={};uo(le,{Any:()=>d4,Array:()=>Ye,ArrayEnsure:()=>w4,BigDecimal:()=>gw,BigDecimalFromString:()=>SG,BigDecimalReviver:()=>bG,BigInt:()=>Fo,BigIntFromString:()=>RG,Boolean:()=>ng,BooleanFromBit:()=>NG,Cause:()=>Yh,CauseReason:()=>Zh,CauseReasonReviver:()=>z3,CauseReviver:()=>W3,Char:()=>R3,Chunk:()=>gD,ChunkReviver:()=>oG,Class:()=>_D,Date:()=>er,DateFromMillis:()=>fG,DateFromString:()=>uG,DateReviver:()=>cG,DateTimeUtc:()=>sd,DateTimeUtcFromDate:()=>zG,DateTimeUtcFromMillis:()=>HG,DateTimeUtcFromString:()=>WG,DateTimeUtcReviver:()=>qG,DateTimeZoned:()=>Iw,DateTimeZonedFromString:()=>QG,DateTimeZonedReviver:()=>YG,Defect:()=>G3,Duration:()=>rd,DurationFromMillis:()=>mG,DurationFromNanos:()=>pG,DurationFromString:()=>hw,DurationReviver:()=>lG,Enum:()=>f4,Error:()=>MD,ErrorInstance:()=>aD,ErrorInstanceReviver:()=>K3,Exit:()=>cD,ExitReviver:()=>V3,File:()=>xw,FileReviver:()=>wG,Finite:()=>Xn,FiniteFromString:()=>kG,FormData:()=>yw,FormDataReviver:()=>TG,Graph:()=>dD,GraphReviver:()=>eG,HashMap:()=>pD,HashMapReviver:()=>tG,HashSet:()=>hD,HashSetReviver:()=>rG,Int:()=>Uo,Json:()=>ad,JsonObject:()=>N6,JsonReviver:()=>L6,Literal:()=>ze,Literals:()=>io,MutableJson:()=>zD,MutableJsonReviver:()=>j6,Natural:()=>Gt,Never:()=>l4,NonEmptyArray:()=>I4,NonEmptyString:()=>pw,Null:()=>Re,NullOr:()=>ow,NullishOr:()=>nU,Number:()=>uu,NumberFromString:()=>OG,ObjectKeyword:()=>m4,Opaque:()=>N4,Option:()=>si,OptionFromNullOr:()=>M3,OptionFromNullishOr:()=>F3,OptionFromOptional:()=>D3,OptionFromOptionalKey:()=>U3,OptionFromOptionalNullOr:()=>N3,OptionFromUndefinedOr:()=>P3,OptionReviver:()=>_3,PropertyKey:()=>QI,ReadonlyMap:()=>lD,ReadonlyMapReviver:()=>Z3,ReadonlySet:()=>mD,ReadonlySetReviver:()=>nG,Record:()=>Gl,Redacted:()=>td,RedactedFromValue:()=>q3,RedactedReviver:()=>B3,RegExp:()=>xD,RegExpReviver:()=>sG,Result:()=>QU,ResultReviver:()=>L3,SchemaError:()=>bs,StandardSchemaV1FailureResult:()=>DG,String:()=>X,StringFromBase64:()=>MG,StringFromBase64Url:()=>PG,StringFromHex:()=>FG,StringFromUriComponent:()=>UG,Struct:()=>J,StructWithRest:()=>S4,Symbol:()=>XF,TaggedClass:()=>n6,TaggedError:()=>r6,TaggedStruct:()=>og,TaggedUnion:()=>D4,TemplateLiteral:()=>c4,TemplateLiteralParser:()=>u4,TimeZone:()=>Ew,TimeZoneFromString:()=>ZG,TimeZoneNamed:()=>Sw,TimeZoneNamedFromString:()=>GG,TimeZoneNamedReviver:()=>KG,TimeZoneOffset:()=>AD,TimeZoneOffsetReviver:()=>JG,TimeZoneReviver:()=>VG,Tree:()=>D6,Trim:()=>_G,Trimmed:()=>TD,Tuple:()=>ou,TupleWithRest:()=>E4,URL:()=>nd,URLFromString:()=>aG,URLReviver:()=>iG,URLSearchParams:()=>bw,URLSearchParamsReviver:()=>AG,Uint8Array:()=>od,Uint8ArrayFromBase64:()=>$G,Uint8ArrayFromBase64Url:()=>jG,Uint8ArrayFromHex:()=>BG,Uint8ArrayReviver:()=>LG,Undefined:()=>nw,UndefinedOr:()=>rg,Union:()=>st,UniqueArray:()=>T4,UniqueSymbol:()=>h4,Unknown:()=>tw,UnknownFromJsonString:()=>IG,Void:()=>p4,annotate:()=>NK,annotateEncoded:()=>LK,annotateKey:()=>$K,asserts:()=>zK,brand:()=>oU,catchDecoding:()=>k4,catchDecodingWithContext:()=>aU,catchEncoding:()=>R4,catchEncodingWithContext:()=>cU,check:()=>A4,declare:()=>Cr,declareConstructor:()=>Dn,decode:()=>_4,decodeEffect:()=>WK,decodeExit:()=>HK,decodeOption:()=>KK,decodePromise:()=>VK,decodeResult:()=>GK,decodeSync:()=>ZK,decodeTo:()=>ne,decodeUnknownEffect:()=>Xh,decodeUnknownExit:()=>zF,decodeUnknownOption:()=>JK,decodeUnknownPromise:()=>HF,decodeUnknownResult:()=>WF,decodeUnknownSync:()=>JF,encode:()=>M4,encodeEffect:()=>KF,encodeExit:()=>YK,encodeKeys:()=>y4,encodeOption:()=>XK,encodePromise:()=>t4,encodeResult:()=>e4,encodeSync:()=>n4,encodeTo:()=>sw,encodeUnknownEffect:()=>eg,encodeUnknownExit:()=>GF,encodeUnknownOption:()=>QK,encodeUnknownPromise:()=>ZF,encodeUnknownResult:()=>VF,encodeUnknownSync:()=>YF,extendTo:()=>b4,fieldsAssign:()=>g4,flip:()=>ZI,fromBrand:()=>O4,fromFormData:()=>vG,fromJsonString:()=>wD,fromURLSearchParams:()=>CG,instanceOf:()=>oi,is:()=>LF,isBase64:()=>cw,isBase64Reviver:()=>V4,isBase64Url:()=>bU,isBase64UrlReviver:()=>Z4,isBetween:()=>fu,isBetweenBigDecimal:()=>y3,isBetweenBigInt:()=>qU,isBetweenBigIntReviver:()=>R6,isBetweenDate:()=>NU,isBetweenDateReviver:()=>v6,isBetweenReviver:()=>u3,isCapitalized:()=>vU,isCapitalizedReviver:()=>n3,isEndsWith:()=>EU,isEndsWithReviver:()=>Q4,isFinite:()=>CU,isFiniteReviver:()=>o3,isGUID:()=>xU,isGUIDReviver:()=>K4,isGreaterThan:()=>kU,isGreaterThanBigDecimal:()=>m3,isGreaterThanBigInt:()=>LU,isGreaterThanBigIntReviver:()=>A6,isGreaterThanDate:()=>PU,isGreaterThanDateReviver:()=>E6,isGreaterThanOrEqualTo:()=>uw,isGreaterThanOrEqualToBigDecimal:()=>h3,isGreaterThanOrEqualToBigInt:()=>$U,isGreaterThanOrEqualToBigIntReviver:()=>C6,isGreaterThanOrEqualToDate:()=>FU,isGreaterThanOrEqualToDateReviver:()=>I6,isGreaterThanOrEqualToReviver:()=>i3,isGreaterThanReviver:()=>s3,isIncludes:()=>IU,isIncludesReviver:()=>X4,isInt:()=>ed,isInt32:()=>d3,isIntReviver:()=>l3,isLengthBetween:()=>lw,isLengthBetweenReviver:()=>E3,isLessThan:()=>RU,isLessThanBigDecimal:()=>g3,isLessThanBigInt:()=>jU,isLessThanBigIntReviver:()=>O6,isLessThanDate:()=>UU,isLessThanDateReviver:()=>w6,isLessThanOrEqualTo:()=>_U,isLessThanOrEqualToBigDecimal:()=>x3,isLessThanOrEqualToBigInt:()=>BU,isLessThanOrEqualToBigIntReviver:()=>k6,isLessThanOrEqualToDate:()=>DU,isLessThanOrEqualToDateReviver:()=>T6,isLessThanOrEqualToReviver:()=>c3,isLessThanReviver:()=>a3,isLowercased:()=>TU,isLowercasedReviver:()=>t3,isMaxLength:()=>WU,isMaxLengthReviver:()=>S3,isMaxProperties:()=>VU,isMaxPropertiesReviver:()=>A3,isMaxSize:()=>JU,isMaxSizeReviver:()=>w3,isMinLength:()=>fw,isMinLengthReviver:()=>b3,isMinProperties:()=>GU,isMinPropertiesReviver:()=>v3,isMinSize:()=>HU,isMinSizeReviver:()=>I3,isMultipleOf:()=>MU,isMultipleOfReviver:()=>f3,isNonEmpty:()=>zU,isPattern:()=>Ra,isPatternReviver:()=>B4,isPropertiesLengthBetween:()=>ZU,isPropertiesLengthBetweenReviver:()=>C3,isPropertyNames:()=>YU,isPropertyNamesReviver:()=>O3,isSchema:()=>tg,isSchemaError:()=>NF,isSizeBetween:()=>KU,isSizeBetweenReviver:()=>T3,isStartsWith:()=>SU,isStartsWithReviver:()=>Y4,isStringBigInt:()=>mU,isStringBigIntReviver:()=>z4,isStringFinite:()=>pU,isStringFiniteReviver:()=>q4,isStringSymbol:()=>hU,isStringSymbolReviver:()=>W4,isTrimmed:()=>aw,isTrimmedReviver:()=>$4,isULID:()=>yU,isULIDReviver:()=>G4,isUUID:()=>gU,isUUIDReviver:()=>J4,isUint32:()=>p3,isUncapitalized:()=>AU,isUncapitalizedReviver:()=>r3,isUnique:()=>dw,isUniqueReviver:()=>k3,isUppercased:()=>wU,isUppercasedReviver:()=>e3,link:()=>$e,make:()=>j,makeFilter:()=>je,makeFilterGroup:()=>L4,makeIsBetween:()=>Xl,makeIsGreaterThan:()=>Vl,makeIsGreaterThanOrEqualTo:()=>Zl,makeIsLessThan:()=>Yl,makeIsLessThanOrEqualTo:()=>Ql,makeIsMultipleOf:()=>OU,middlewareDecoding:()=>sU,middlewareEncoding:()=>iU,mutable:()=>v4,mutableKey:()=>s4,optional:()=>Ar,optionalKey:()=>Oa,overrideToCodecIso:()=>F6,overrideToEquivalence:()=>a6,overrideToFormatter:()=>s6,readonlyKey:()=>i4,refine:()=>C4,required:()=>o4,requiredKey:()=>r4,resolveAnnotations:()=>B6,resolveAnnotationsKey:()=>q6,revealBottom:()=>DK,revealCodec:()=>jK,suspend:()=>rU,tag:()=>ka,tagDefaultOmit:()=>U4,toArbitrary:()=>o6,toCodecArrayFromSingle:()=>p6,toCodecIso:()=>ND,toCodecJson:()=>FD,toCodecJsonAST:()=>ig,toCodecStringTree:()=>lu,toDifferJsonPatch:()=>U6,toEncoded:()=>cu,toEncoderXml:()=>m6,toEquivalence:()=>c6,toFormatter:()=>i6,toIso:()=>_6,toIsoFocus:()=>P6,toIsoSource:()=>M6,toJsonSchemaDocument:()=>PD,toRepresentation:()=>u6,toStandardJSONSchemaV1:()=>qK,toStandardSchemaV1:()=>BK,toTaggedUnion:()=>dU,toType:()=>Tn,withConstructorDefault:()=>uU,withDecodingDefault:()=>lU,withDecodingDefaultKey:()=>iw,withDecodingDefaultType:()=>F4,withDecodingDefaultTypeKey:()=>P4});var Rh=\"~effect/collections/Graph\",xI=e=>e;var sJ=(e,t,n)=>(e===\"directed\"?t.source===n.source&&t.target===n.target:t.source===n.source&&t.target===n.target||t.source===n.target&&t.target===n.source)&&B(t.data,n.data),iJ=(e,t)=>e===\"directed\"?H(t):Ts(H(t.data)^H(t.source)+H(t.target)),aJ={[Rh]:{_N:e=>e,_E:e=>e},[Symbol.iterator](){return this.nodes[Symbol.iterator]()},[Qe](){return this.toJSON()},[Se](e){if(M(e,Rh)){let t=xI(e);if(this.nodes.size!==t.nodes.size||this.edges.size!==t.edges.size||this.type!==t.type)return!1;for(let[n,r]of this.nodes)if(!t.nodes.has(n)||!B(r,t.nodes.get(n)))return!1;for(let[n,r]of this.edges){let o=t.edges.get(n);if(o===void 0||!sJ(this.type,r,o))return!1}return!0}return!1},[be](){let e=Ue(\"Graph\");e=e^Ue(this.type),e=e^On(this.nodes.size),e=e^On(this.edges.size);for(let[t,n]of this.nodes)e=e^H(t)+H(n);for(let[t,n]of this.edges)e=e^H(t)+iJ(this.type,n);return e},toJSON(){return{_id:\"Graph\",nodeCount:this.nodes.size,edgeCount:this.edges.size,type:this.type}},toString(){return`Graph(${this.type}, ${this.nodes.size}, ${this.edges.size})`},pipe(){return K(this,arguments)}},qM=(e,t)=>{let n=Object.create(aJ);return n.type=e,n.mutable=t,n.transforming=!1,n.nodes=new Map,n.edges=new Map,n.adjacency=new Map,n.reverseAdjacency=new Map,n.nextNodeIndex=0,n.nextEdgeIndex=0,n.acyclic=k(!0),n};var kl=e=>{let t=xI(e);return{type:e.type,nodes:Array.from(t.nodes,([n,r])=>({index:n,data:r})).sort((n,r)=>n.index-r.index),edges:Array.from(t.edges,([n,r])=>({index:n,source:r.source,target:r.target,data:r.data})).sort((n,r)=>n.index-r.index)}},Rl=e=>{let t=qM(e.type,!1);for(let n of e.nodes)t.nodes.set(n.index,n.data),t.adjacency.set(n.index,[]),t.reverseAdjacency.set(n.index,[]);for(let n of e.edges)t.edges.set(n.index,{source:n.source,target:n.target,data:n.data}),t.adjacency.get(n.source).push(n.index),t.reverseAdjacency.get(n.target).push(n.index),e.type===\"undirected\"&&(t.adjacency.get(n.target).push(n.index),t.reverseAdjacency.get(n.source).push(n.index));return t.nextNodeIndex=e.nodes.length===0?0:e.nodes[e.nodes.length-1].index+1,t.nextEdgeIndex=e.edges.length===0?0:e.edges[e.edges.length-1].index+1,t.acyclic=R(),t};var cJ=Rh;var yI=e=>M(e,cJ);var WM=class e{type;source;target;identity;constructor(t,n,r,o){this.type=t,this.source=n,this.target=r,this.identity=o}[Se](t){return!(t instanceof e)||this.type!==t.type||!B(this.identity,t.identity)?!1:this.type===\"directed\"?B(this.source,t.source)&&B(this.target,t.target):B(this.source,t.source)&&B(this.target,t.target)||B(this.source,t.target)&&B(this.target,t.source)}[be](){let t=H(this.identity);return this.type===\"directed\"?it(H(this.target))(it(H(this.source))(t)):Ts(t^H(this.source)+H(this.target))}};var Mh=\"~effect/collections/HashMap\",ro=5,Ml=1<<ro,uJ=Ml/4,JM=Ml/2,fJ=Ml-1,lJ=e=>(e=e-(e>>>1&1431655765),e=(e&858993459)+(e>>>2&858993459),(e+(e>>>4)&252645135)*16843009>>>24),_l=(e,t)=>e>>>t&fJ,ni=(e,t)=>1<<_l(e,t),_h=(e,t)=>lJ(e&t-1);function KM(e,t,n,r,o,s){if(t>32)throw new Error(\"HashMap: max depth exceeded\");let i=ni(n,t),a=ni(o,t);if(i===a){let f=KM(e,t+ro,n,r,o,s);return new Ea(e,i,[f])}let c=i|a,u=i>>>0<a>>>0?[r,s]:[s,r];return new Ea(e,c,u)}var ba=class{canEdit(t){return this.edit===t}},bI=class extends ba{_tag=\"EmptyNode\";edit=0;get size(){return 0}get(t,n,r){return R()}has(t,n,r){return!1}set(t,n,r,o,s,i){return i.value=!0,new Sa(t,r,o,s)}remove(t,n,r,o,s){return this}iterator(){return[][Symbol.iterator]()}[Symbol.iterator](){return this.iterator()}canEdit(t){return!1}},Sa=class e extends ba{_tag=\"LeafNode\";edit;hash;key;value;constructor(t,n,r,o){super(),this.edit=t,this.hash=n,this.key=r,this.value=o}get size(){return 1}get(t,n,r){return this.hash===n&&B(this.key,r)?k(this.value):R()}has(t,n,r){return this.hash===n&&B(this.key,r)}set(t,n,r,o,s,i){if(this.hash===r&&B(this.key,o))return B(this.value,s)?this:this.canEdit(t)?(this.value=s,this):new e(t,r,o,s);if(i.value=!0,this.hash===r)return new SI(t,r,[[this.key,this.value],[o,s]]);let a=ni(r,n),c=ni(this.hash,n);if(a===c)return new Ea(t,a,[this.set(t,n+ro,r,o,s,i)]);let u=a|c,f=a>>>0<c>>>0?[new e(t,r,o,s),this]:[this,new e(t,r,o,s)];return new Ea(t,u,f)}remove(t,n,r,o,s){if(this.hash===r&&B(this.key,o)){s.value=!0;return}return this}iterator(){return[[this.key,this.value]][Symbol.iterator]()}[Symbol.iterator](){return this.iterator()}},SI=class e extends ba{_tag=\"CollisionNode\";edit;hash;entries;constructor(t,n,r){super(),this.edit=t,this.hash=n,this.entries=r}get size(){return this.entries.length}get(t,n,r){if(this.hash!==n)return R();for(let[o,s]of this.entries)if(B(o,r))return k(s);return R()}has(t,n,r){if(this.hash!==n)return!1;for(let[o]of this.entries)if(B(o,r))return!0;return!1}set(t,n,r,o,s,i){if(this.hash!==r)return i.value=!0,KM(t,n,this.hash,this,r,new Sa(t,r,o,s));for(let a=0;a<this.entries.length;a++)if(B(this.entries[a][0],o)){if(B(this.entries[a][1],s))return this;if(this.canEdit(t))return this.entries[a]=[o,s],this;let c=[...this.entries];return c[a]=[o,s],new e(t,this.hash,c)}return i.value=!0,this.canEdit(t)?(this.entries.push([o,s]),this):new e(t,this.hash,[...this.entries,[o,s]])}remove(t,n,r,o,s){if(this.hash!==r)return this;let i=this.entries.findIndex(([c])=>B(c,o));if(i===-1)return this;if(s.value=!0,this.entries.length===1)return;if(this.entries.length===2){let c=this.entries[i===0?1:0];return new Sa(t,this.hash,c[0],c[1])}if(this.canEdit(t))return this.entries.splice(i,1),this;let a=[...this.entries];return a.splice(i,1),new e(t,this.hash,a)}iterator(){return this.entries[Symbol.iterator]()}[Symbol.iterator](){return this.iterator()}},Ea=class e extends ba{_tag=\"IndexedNode\";edit;_size;bitmap;children;constructor(t,n,r){super(),this.edit=t,this.bitmap=n,this.children=r}get size(){return this._size===void 0&&(this._size=this.children.reduce((t,n)=>t+n.size,0)),this._size}get(t,n,r){let o=ni(n,t);if((this.bitmap&o)===0)return R();let s=_h(this.bitmap,o);return this.children[s].get(t+ro,n,r)}has(t,n,r){let o=ni(n,t);if((this.bitmap&o)===0)return!1;let s=_h(this.bitmap,o);return this.children[s].has(t+ro,n,r)}set(t,n,r,o,s,i){let a=ni(r,n),c=_h(this.bitmap,a);if((this.bitmap&a)!==0){let u=this.children[c],f=u.set(t,n+ro,r,o,s,i);if(u===f)return this;if(this.canEdit(t))return this.children[c]=f,this;let d=[...this.children];return d[c]=f,new e(t,this.bitmap,d)}else{i.value=!0;let u=new Sa(t,r,o,s),f=this.bitmap|a;if(this.canEdit(t))return this.children.splice(c,0,u),this.bitmap=f,this._size=void 0,this.children.length>JM?this.expand(t,f,this.children):this;let d=[...this.children];return d.splice(c,0,u),d.length>JM?this.expand(t,f,d):new e(t,f,d)}}remove(t,n,r,o,s){let i=ni(r,n);if((this.bitmap&i)===0)return this;let a=_h(this.bitmap,i),c=this.children[a],u=c.remove(t,n+ro,r,o,s);if(!s.value)return this;if(u===void 0){let d=this.bitmap^i;if(d===0)return;if(this.children.length===2){let m=this.children[a===0?1:0];if(m._tag===\"LeafNode\")return m}if(this.canEdit(t))return this.children.splice(a,1),this.bitmap=d,this._size=void 0,this;let p=[...this.children];return p.splice(a,1),new e(t,d,p)}if(c===u)return this;if(this.canEdit(t))return this.children[a]=u,this;let f=[...this.children];return f[a]=u,new e(t,this.bitmap,f)}expand(t,n,r){let o=new globalThis.Array(Ml),s=0;for(let i=0;i<Ml;i++)(n&1<<i)!==0&&(o[i]=r[s++]);return new EI(t,r.length,o)}iterator(){let t=0,n;return{next:()=>{for(;t<this.children.length;){n||(n=this.children[t].iterator());let r=n.next();if(!r.done)return r;n=void 0,t++}return{done:!0,value:void 0}}}}[Symbol.iterator](){return this.iterator()}},EI=class e extends ba{_tag=\"ArrayNode\";edit;_size;count;children;constructor(t,n,r){super(),this.edit=t,this.count=n,this.children=r}get size(){return this._size===void 0&&(this._size=this.children.reduce((t,n)=>t+(n?.size??0),0)),this._size}get(t,n,r){let o=_l(n,t),s=this.children[o];return s?s.get(t+ro,n,r):R()}has(t,n,r){let o=_l(n,t),s=this.children[o];return s?s.has(t+ro,n,r):!1}set(t,n,r,o,s,i){let a=_l(r,n),c=this.children[a];if(c){let u=c.set(t,n+ro,r,o,s,i);if(c===u)return this;if(this.canEdit(t))return this.children[a]=u,this;let f=[...this.children];return f[a]=u,new e(t,this.count,f)}else{i.value=!0;let u=new Sa(t,r,o,s);if(this.canEdit(t))return this.children[a]=u,this.count++,this._size=void 0,this;let f=[...this.children];return f[a]=u,new e(t,this.count+1,f)}}remove(t,n,r,o,s){let i=_l(r,n),a=this.children[i];if(!a)return this;let c=a.remove(t,n+ro,r,o,s);if(!s.value)return this;let u=this.count-(c?0:1);if(u<uJ)return this.pack(t,i,c);if(a===c)return this;if(this.canEdit(t))return this.children[i]=c,c||(this.count=u),this._size=void 0,this;let f=[...this.children];return f[i]=c,new e(t,u,f)}pack(t,n,r){let o=[],s=0,i=1;for(let a=0;a<this.children.length;a++){let c=a===n?r:this.children[a];c&&(o.push(c),s|=i),i<<=1}return new Ea(t,s,o)}iterator(){let t=0,n;return{next:()=>{for(;t<this.children.length;){let r=this.children[t];if(!r){t++;continue}n||(n=r.iterator());let o=n.next();if(!o.done)return o;n=void 0,t++}return{done:!0,value:void 0}}}}[Symbol.iterator](){return this.iterator()}},Pl=class{[Mh]=Mh;_editable;_edit;_root;_size;constructor(t,n,r,o){this._editable=t,this._edit=n,this._root=r,this._size=o}get size(){return this._size}[Symbol.iterator](){return this._root.iterator()}[Se](t){if(II(t)){let n=t;if(this.size!==n.size)return!1;for(let[r,o]of this){let s=wd(t,ZM(r));if(ae(s)||!B(o,s.value))return!1}return!0}return!1}[be](){let t=Ue(\"HashMap\");for(let[n,r]of this)t=t^H(n)+H(r);return t}[Qe](){return ht(this)}toString(){return`HashMap(${N(Array.from(this))})`}toJSON(){return{_id:\"HashMap\",values:Array.from(this).map(([t,n])=>[ht(t),ht(n)])}}pipe(){return K(this,arguments)}},GM=new bI,II=e=>M(e,Mh),wI=()=>new Pl(!1,0,GM,0);var VM=e=>{let t=GM,n=0,r={value:!1};for(let[o,s]of e){let i=H(o);r.value=!1,t=t.set(NaN,0,i,o,s,r),r.value&&n++}return new Pl(!1,0,t,n)};var ZM=l(2,(e,t)=>e._root.get(0,H(t),t));var TI=l(2,(e,t)=>e._root.has(0,H(t),t));var dJ=(e,t,n,r)=>{let o=e,s={value:!1},i=o._editable?o._edit:NaN,a=o._root.set(i,0,n,t,r,s);return o._editable?(o._root=a,s.value&&o._size++,e):o._root===a?e:new Pl(!1,o._edit,a,o._size+(s.value?1:0))},vI=l(3,(e,t,n)=>dJ(e,t,H(t),n)),AI=e=>{let t=e[Symbol.iterator]();return{[Symbol.iterator](){return this},next(){let n=t.next();return n.done?{done:!0,value:void 0}:{done:!1,value:n.value[0]}}}};var YM=e=>{let t=e[Symbol.iterator]();return{[Symbol.iterator](){return this},next(){return t.next()}}},Ph=e=>e.size;var XM=II;var Fh=VM;var hJ=YM,Uh=e=>Array.from(hJ(e)),eP=Ph;var Fl=\"~effect/collections/HashSet\",gJ={[be](){return H(Fl)},[Se](e){return CI(e)&&Dh(this)===Dh(e)&&oP(this,t=>rP(e,t))},[Symbol.iterator](){return AI(OI(this))},toString(){return`HashSet(${N(Array.from(this))})`},toJSON(){return{_id:\"HashSet\",values:ht(Array.from(this))}},[Qe](){return this.toJSON()},pipe(){return K(this,arguments)}},xJ=e=>{let t=Object.create(gJ);return t[Fl]=Fl,t.keyMap=e,t},CI=e=>M(e,Fl),OI=e=>e.keyMap;var nP=e=>{let t=wI();for(let n of e)t=vI(t,n,!0);return xJ(t)},rP=(e,t)=>TI(OI(e),t);var Dh=e=>Ph(OI(e));var oP=(e,t)=>{for(let n of e)if(!t(n))return!1;return!0};var Nh=nP,sP=CI;var iP=Dh;function Lh(e){let t=bP(jh,Jt(e.ast));return(n,r)=>t(n,r?.disableChecks?r?.parseOptions?{...r.parseOptions,disableChecks:!0}:{disableChecks:!0}:r?.parseOptions)}function $h(e){let t=Lh(e);return(n,r)=>{let o=Js(t(n,r));return et(o)?k(o.value):(ds(o.cause,\"Option adapter can only return none for schema issues\"),R())}}function uP(e){let t=Lh(e);return(n,r)=>{let o=Js(t(n,r));if(et(o))return o.value;let s=ds(o.cause,\"Constructor adapter can only throw schema issues\");throw new Error(\"Schema validation failed\",{cause:s})}}function fP(e){return Ia(e.ast)}function Ia(e){let t=wa(Dl(Jt(e)));return n=>{let r=t(n,ua);return et(r)?!0:(ds(r.cause,\"Type guard adapter can only return false for schema issues\"),!1)}}function lP(e){let t=Dl(e);return(n,r)=>{let o=Js(t(n,r));if(!et(o))return ds(o.cause,\"Issue adapter can only return schema issues\")}}function dP(e,t){let r=wa(Dl(Jt(e.ast)))(t,ua);if(os(r)){let o=ds(r.cause,\"Assertion adapter can only throw schema issues\");throw new Error(\"Schema validation failed\",{cause:o})}}function Ze(e,t){let n=Dl(e.ast);return t===void 0?n:(r,o)=>n(r,Ul(t,o))}var kI=Ze;function pP(e,t){return wa(Ze(e,t))}function RI(e,t){return SP(Ze(e,t))}var mP=RI;function hP(e,t){return EP(Ze(e,t))}function bJ(e,t){return IP(Ze(e,t))}var _I=bJ;function Zc(e,t){let n=Dl(Jr(e.ast));return t===void 0?n:(r,o)=>n(r,Ul(t,o))}function gP(e,t){return wa(Zc(e,t))}function MI(e,t){return SP(Zc(e,t))}var xP=MI;function yP(e,t){return EP(Zc(e,t))}function SJ(e,t){return IP(Zc(e,t))}var PI=SJ,Ul=(e,t)=>t?{...e,...t}:e,cP=e=>e===ve?P(new ge):x(e);function Dl(e){return bP(wP,e)}function bP(e,t){let n;return(r,o)=>{let s=(n??=e(t))(r,o??ua);return s===bt?x(r):De(s)?s[Z]===ve?cP(ve):s:lt(s,cP)}}function wa(e){return(t,n)=>Js(e(t,n))}function SP(e){let t=wa(e);return(n,r)=>{let o=t(n,r);return et(o)?k(o.value):(ds(o.cause,\"Option adapter can only return none for schema issues\"),R())}}function EP(e){let t=wa(e);return(n,r)=>{let o=t(n,r);return et(o)?se(o.value):re(ds(o.cause,\"Result adapter can only return schema issues\"))}}function IP(e){let t=wa(e);return(n,r)=>{let o=t(n,r);if(et(o))return o.value;let s=ds(o.cause,\"Sync adapter can only throw schema issues\");throw new Error(\"Schema validation failed\",{cause:s})}}var wP=It(e=>FI(e,wP)),jh=It(e=>FI(e,jh,TP)),EJ=It(e=>FI(e,jh,TP,e.context?.constructorDefault));function TP(e){return e.context?.constructorDefault?EJ(e):jh(e)}function vP(e,t,n,r){let o;if(De(e)&&e._tag===\"Success\"){let s=ym(e===bt?t:e[Z]);o=n._tag===\"Transformation\"?n.decode.run(s,r):n.decode(Sr(s),r)}else n._tag===\"Transformation\"?o=lt(e,s=>n.decode.run(ym(s),r)):o=n.decode(br(e,ym),r);return De(o)&&o._tag===\"Success\"?dS(o[Z]):lt(o,dS)}function IJ(e,t){let n;return(r,o)=>{if(r===ve)return Hr;if(e.isConstructed(r))return bt;let s=(n??=t(e.link.to))(r,o);return vP(s,r,e.link.transformation,o)}}function FI(e,t,n,r){let o=n?r_(e):void 0,s=o?IJ(o,t):e.getParser(t,n),i=e.checks,a=r?e.encoding?[...e.encoding,r]:[r]:e.encoding,c=e.encodingChecks,u=(i?i[i.length-1].annotations:e.annotations)?.parseOptions;if(!a&&!i&&!c)return u?(p,m)=>s(p,Ul(m,u)):s;let f,d=(p,m)=>{let g=s(p,m);if(c&&!m.disableChecks)if(De(g)){if(g._tag===\"Success\"){let b=g===bt?p:g[Z];if(p!==ve&&b!==ve){let I=Zr(c,p,void 0,e,m);I&&(g=P(new nt(e,I,p,m)))}}}else g=T(g,b=>{if(p!==ve&&b!==ve){let I=Zr(c,p,void 0,e,m);if(I)return P(new nt(e,I,p,m))}return x(b)});if(i&&!m.disableChecks)if(De(g)){if(g._tag===\"Success\"){let b=g===bt?p:g[Z];if(b===ve)return g;let I=Zr(i,b,void 0,e,m);I&&(g=P(new nt(e,I,b,m)))}}else g=T(g,b=>{if(b!==ve){let I=Zr(i,b,void 0,e,m);if(I)return P(new nt(e,I,b,m))}return x(b)});return g};return a?(p,m)=>{u&&(m=Ul(m,u));let g=f??=a.map(w=>t(w.to)),b=p,I=g[g.length-1](p,m);for(let w=a.length-1;w>=0;w--)if(I=vP(I,b,a[w].transformation,m),w!==0){let E=g[w-1];I._tag===\"Success\"?(b=I[Z],I=E(b,m)):I=lt(I,h=>{let A=E(h,m);return A===bt?Sr(h):A})}if(I._tag===\"Success\"){let w=I[Z],E=d(w,m);return E===bt?I:E}return I=ft(I,w=>cs(()=>ns(w,E=>new Em(e,E,p,m)))),lt(I,w=>{let E=d(w,m);return E===bt?Sr(w):E})}:u?(p,m)=>d(p,Ul(m,u)):d}var Bh=\"~effect/Schema/Schema\";function wn(e,t,n){return{id:e,payloadSchema:t,revive:n}}function fe(e,t,n){return{id:e,payloadSchema:t,revive:n}}var wJ={[Bh]:Bh,pipe(){return K(this,arguments)},annotate(e){return this.rebuild(Ir(this.ast,e))},annotateKey(e){return this.rebuild(pl(this.ast,e))},check(...e){return this.rebuild(ko(this.ast,e))}};function Ll(e,t){function n(){}let r=Object.defineProperties(Object.setPrototypeOf(n,wJ),Object.getOwnPropertyDescriptors({...t}));return r.ast=e,r.rebuild=o=>Ll(o,t),r.makeEffect=Lh(r),r.make=uP(r),r.makeOption=$h(r),r}var AP=l(2,(e,t)=>DI(e,(n,r)=>t.includes(n)?[n,r]:void 0)),CP=l(2,(e,t)=>DI(e,(n,r)=>t.includes(n)?void 0:[n,r])),OP=l(2,(e,t)=>({...e,...t}));var UI=l(2,(e,t)=>DI(e,(n,r)=>[Object.hasOwn(t,n)?t[n]:n,r]));var on=e=>e;function DI(e,t){let n={};for(let r of Reflect.ownKeys(e)){if(!Object.prototype.propertyIsEnumerable.call(e,r))continue;let o=t(r,e[r]);if(o){let[s,i]=o;F(n,s,i)}}return n}function kP(e,t){let n=t?.omitKeyWhen??(()=>!1);return Ud((r,o)=>{let s=Reflect.ownKeys(e),i={};for(let a of s){let c=e[a].combine(r[a],o[a]);n(c)||F(i,a,c)}return i})}function $l(e){return or((t,n)=>t===void 0?n:n===void 0?t:e.combine(t,n),void 0)}function Po(e,t){return t.length>0&&(e+=`\n at ${Dd(t)}`),new Error(e)}var _P=new WeakMap,MP=new WeakMap,PP=[];function ys(e){return new Error(`Unable to derive an arbitrary for ${e}`)}var FP=([e],[t])=>B(e,t);function vJ(e,t,n){return t.reduce((r,o)=>r.filter(s=>o.run(s,e,ua)===void 0),n)}function WP(e,t){if(e?.minLength!==void 0&&e.maxLength!==void 0&&e.minLength>e.maxLength)throw ys(`${t} constraints`)}function HP(e){return e===void 0||e.minLength===void 0&&e.maxLength===void 0?void 0:{...e.minLength!==void 0?{minLength:e.minLength}:{},...e.maxLength!==void 0?{maxLength:e.maxLength}:{}}}function NI(e,t,n,r){return r?e.uniqueArray(t,{...n,comparator:r}):e.array(t,n)}function UP(e,t,n,r=!1){let o=t.constraint,s=HP(o);return WP(s,\"array\"),NI(e,n,r?{...s,maxLength:s?.minLength??0}:s,o?.unique?B:void 0)}function zh(e,t,n,r){return t.chain(o=>o.length<n?e.constant(o):r.map(s=>[...o,...s]))}function DP(e,t){return e.chain(n=>t.map(r=>({...Object.fromEntries(r),...n})))}var AJ=$l(kO),CJ=$l(RO),Wh=$l(bT),OJ=$l(fT()),kJ=kP({integer:Wh,maxLength:CJ,minLength:AJ,noInfinity:Wh,noNaN:Wh,patterns:OJ,unique:Wh},{omitKeyWhen:E0});function NP(e,t,n,r,o,s){if(r===void 0||t===void 0)return r===void 0?[t,n]:[r,o];let i=e(t,r);return i===s?[r,o]:i===0?[t,n||o]:[t,n]}function RJ(e,t){if(e===void 0)return t;if(e.order!==t.order)throw new Error(\"Cannot merge ordered arbitrary constraints with different Order instances\");let[n,r]=NP(e.order,e.minimum,e.exclusiveMinimum,t.minimum,t.exclusiveMinimum,-1),[o,s]=NP(e.order,e.maximum,e.exclusiveMaximum,t.maximum,t.exclusiveMaximum,1);return{order:e.order,...n!==void 0?{minimum:n}:{},...r!==void 0?{exclusiveMinimum:r}:{},...o!==void 0?{maximum:o}:{},...s!==void 0?{exclusiveMaximum:s}:{}}}function _J(e,t){let{ordered:n,...r}=e??{},{ordered:o,...s}=t,i=o===void 0?n:RJ(n,o);return{...kJ.combine(r,s),...i===void 0?{}:{ordered:i}}}function LP(e){let t=[],n=[];function r(o){if(o.annotations?.arbitrary&&n.push(o.annotations.arbitrary),o._tag!==\"Filter\")for(let s of o.checks)r(s);else t.push(o)}return e?.forEach(r),{filters:t,arbitraries:n}}function MJ(e){let t=e.map(({constraint:n})=>n).filter(Lt);return n=>{let r=t.reduce((o,s)=>_J(o,s),n.constraint);return{...n,constraint:r}}}function jl(e){return{...e,constraint:void 0}}function $P(e,t,n){if(t===void 0||t.minLength===void 0&&t.maxLength===void 0)return;if(t.minLength!==void 0&&e.indexSignatures.length===0&&t.minLength>e.propertySignatures.length)throw ys(\"object property constraints\");let r={};if(t.minLength!==void 0&&(r.minLength=Math.max(0,t.minLength-n)),t.maxLength!==void 0&&(r.maxLength=t.maxLength-n,r.maxLength<0))throw ys(\"object property constraints\");return WP(r,\"object property\"),r}function PJ(e,t,n,r,o,s){let i=r.length;if(s.maxLength!==void 0&&s.maxLength<i)throw ys(\"object property constraints\");let a=s.minLength===void 0?0:Math.max(0,s.minLength-i),c=s.maxLength===void 0?o.length:Math.min(o.length,s.maxLength-i);if(a>c)throw ys(\"object property constraints\");let u=e.record(t,{requiredKeys:[...r,...o]}),f=e.shuffledSubarray([...o],{minLength:a,maxLength:c});return e.tuple(u,f).map(([d,p])=>{let m=new Set([...r,...p]),g={};for(let b of n)m.has(b)&&F(g,b,d[b]);return g})}function JP(e,t,n,r){let o={};if(e?.minimum!==void 0&&(o.min=t(e.minimum,e.exclusiveMinimum===!0)),e?.maximum!==void 0&&(o.max=n(e.maximum,e.exclusiveMaximum===!0)),o.min!==void 0&&o.max!==void 0&&o.min>o.max)throw ys(r);return o}function FJ(e){return JP(e,(t,n)=>n?Math.floor(t)+1:Math.ceil(t),(t,n)=>n?Math.ceil(t)-1:Math.floor(t),\"integer constraints\")}function UJ(e,t){let n={...e?.noInfinity?{noDefaultInfinity:!0}:{},...e?.noNaN?{noNaN:!0}:{},...t?.minimum!==void 0?{min:t.minimum}:{},...t?.exclusiveMinimum!==void 0?{minExcluded:t.exclusiveMinimum}:{},...t?.maximum!==void 0?{max:t.maximum}:{},...t?.exclusiveMaximum!==void 0?{maxExcluded:t.exclusiveMaximum}:{}};if(n.min!==void 0&&n.max!==void 0&&(n.min>n.max||n.min===n.max&&(n.minExcluded||n.maxExcluded)))throw ys(\"number constraints\");return n}function DJ(e){return JP(e,(t,n)=>n?t+BigInt(1):t,(t,n)=>n?t-BigInt(1):t,\"the ordered bigint constraints\")}function Yc(e,t){let n=(r,o,s=PP)=>e(r,o,s);return n.terminal=(r,o,s=PP)=>t(r,o,s),n}function Tr(e){return Yc(e,e)}function jP(e,t){let n=MP.get(t)??e.createDepthIdentifier();return MP.set(t,n),{maxDepth:2,depthIdentifier:n}}function BP(e,t){return t.length===0?void 0:t.length===1?t[0]:e.oneof(...t)}var NJ={noInfinity:!0,noNaN:!0};function LJ(e){return{...e,constraint:NJ}}function $J(e,t,n,r){let o=r===void 0?[]:[{arbitrary:r,weight:1}];for(let{candidate:s}of n){if(!s)continue;let i=s.make(e,t);if(i===void 0)continue;let a=s.weight??1;if(!globalThis.Number.isInteger(a)||a<=0)throw ys(\"a candidate with an invalid weight\");o.push({arbitrary:i,weight:a})}return o.length===0?void 0:o.length===1?o[0].arbitrary:e.oneof(...o)}function qP(e,t,n,r,o){let s=$J(n,r,t.arbitraries,o);return s===void 0?void 0:vJ(e,t.filters,s)}function jJ(e,t){if(!(typeof e==\"object\"&&e!==null&&\"arbitrary\"in e))return{arbitrary:e,terminal:t?void 0:e};let n=\"terminal\"in e?e.terminal:t?void 0:e.arbitrary;return{arbitrary:e.arbitrary,terminal:n}}function BJ(e,t,n,r,o){return e.map(s=>({arbitrary:o?t.constant(null).chain(()=>s(t,n,r)):s(t,n,r),terminal:s.terminal(t,n,r)}))}function zP(e,t,n,r){let o=MJ(t.arbitraries);return Yc((s,i,a)=>{let c=o(i);return qP(e,t,s,c,n(s,i,c,a))},(s,i,a)=>{let c=o(i);return qP(e,t,s,c,r(s,i,c,a))})}var KP=It(e=>vr(e,[]));function vr(e,t){let n=Co(e)?.toArbitrary;if(n){let r=Qs(e)?e.typeParameters.map(i=>vr(i,t)):[],o=LP(e.checks),s=i=>(a,c,u,f)=>jJ(n(BJ(r,a,jl(c),f,i))(a,u),r.length>0)[i?\"terminal\":\"arbitrary\"];return zP(e,o,s(!1),s(!0))}if(e.checks){let r=LP(e.checks),o=vr(Gm(e,void 0),t);return zP(e,r,(s,i,a,c)=>o(s,a,c),(s,i,a,c)=>o.terminal(s,a,c))}return qJ(e,t)}function qJ(e,t){switch(e._tag){case\"Never\":case\"Declaration\":throw Po(`Unsupported AST ${e._tag}`,t);case\"Null\":return Tr(n=>n.constant(null));case\"Void\":case\"Undefined\":return Tr(n=>n.constant(void 0));case\"Unknown\":case\"Any\":return Tr(n=>n.anything());case\"String\":return Tr((n,r)=>{let o=r.constraint,s=o?.patterns;return s?n.oneof(...s.map(i=>n.stringMatching(new RegExp(i)))):n.string(HP(o))});case\"Number\":return Tr((n,r)=>{let o=r.constraint,s=o?.ordered?.order===Bn?o.ordered:void 0;return o?.integer?n.integer(FJ(s)):n.float(UJ(o,s))});case\"Boolean\":return Tr(n=>n.boolean());case\"BigInt\":return Tr((n,r)=>{let o=r.constraint?.ordered?.order===ur?r.constraint.ordered:void 0;return n.bigInt(DJ(o))});case\"Symbol\":return Tr(n=>n.string().map(Symbol.for));case\"Literal\":return Tr(n=>n.constant(e.literal));case\"UniqueSymbol\":return Tr(n=>n.constant(e.symbol));case\"ObjectKeyword\":return Tr(n=>n.oneof(n.object(),n.array(n.anything())));case\"Enum\":return vr(YR(e),t);case\"TemplateLiteral\":{let n=e.parts.map((r,o)=>vr(nn(r),[...t,o]));return Tr((r,o,s)=>r.tuple(...n.map(i=>i(r,LJ(o),s))).map(i=>i.map(a=>globalThis.String(a)).join(\"\")))}case\"Arrays\":{let n=e.elements.map((i,a)=>({ast:i,arbitrary:vr(i,[...t,a])})),r=e.elements.length,o=e.rest.map((i,a)=>({ast:i,arbitrary:vr(i,[...t,r+a])})),s=(i,a,c)=>{let u=jl(a),f=[],d=[],p=0;for(let E of n){let h=E.arbitrary.terminal(i,u,c);if(dt(E.ast)){d.push(h);continue}if(h===void 0)return;p++,f.push(h.map(k))}let m=a.constraint?.minLength??0,b=Me(o)&&m>p+d.length?d.length:Math.max(0,m-p),I=0;for(let E of d){if(I>=b||E===void 0){f.push(i.constant(R()));continue}I++,p++,f.push(E.map(k))}if(I<b)return;let w=i.tuple(...f).map(ax);if(Me(o)){let[E,...h]=o,A=e.elements.length===0?a:u,y=Math.max(0,m-p-h.length),S=y===0?void 0:E.arbitrary.terminal(i,u,c);if(y>0&&S===void 0)return;let v=y===0?i.constant([]):UP(i,{...A,constraint:{...A.constraint,minLength:y}},S,!0);if(w=zh(i,w,r,v),h.length>0){let C=[];for(let ce of h){let W=ce.arbitrary.terminal(i,u,c);if(W===void 0)return;C.push(W)}let V=i.tuple(...C);w=zh(i,w,r,V)}}return w};return Yc((i,a,c)=>{let u=jl(a),f=n.map(({ast:p,arbitrary:m})=>{let g=m(i,u,c);return dt(p)?g.chain(b=>i.boolean().map(I=>I?k(b):R())):g.map(k)}),d=i.tuple(...f).map(p=>ax(uT(p,_e)));if(Me(o)){let[p,...m]=o.map(({arbitrary:b})=>b(i,u,c)),g=UP(i,e.elements.length===0?a:u,p);if(d=zh(i,d,r,g),m.length>0){let b=i.tuple(...m);d=zh(i,d,r,b)}}if(a.recursion){let p=s(i,a,c);if(p!==void 0)return i.oneof(a.recursion,p,d)}return d},s)}case\"Objects\":{let n=e.propertySignatures.map(s=>({ps:s,arbitrary:vr(s.type,[...t,s.name])})),r=e.indexSignatures.map(s=>({is:s,parameter:vr(s.parameter,t),type:vr(s.type,t)}));return Yc((s,i,a)=>{let c=jl(i),u={},f=[],d=[],p=[];for(let{ps:I,arbitrary:w}of n){let E=I.name;f.push(E),dt(I.type)?p.push(E):d.push(E),F(u,E,w(s,c,a))}let m=i.constraint;if(p.length>0&&r.length===0&&m!==void 0&&(m.minLength!==void 0||m.maxLength!==void 0))return PJ(s,u,f,d,p,m);let g=s.record(u,{requiredKeys:d}),b=$P(e,i.constraint,d.length);for(let{parameter:I,type:w}of r){let E=s.tuple(I(s,c,a),w(s,c,a)),h=NI(s,E,b,FP);g=DP(g,h)}return g},(s,i,a)=>{let c=jl(i),u={},f=[],d=[];for(let{ps:I,arbitrary:w}of n){let E=I.name,h=w.terminal(s,c,a);if(dt(I.type)){h!==void 0&&d.push([E,h]);continue}if(h===void 0)return;f.push(E),F(u,E,h)}let p=Math.max(0,(i.constraint?.minLength??0)-f.length);for(let[I,w]of d){if(p===0)break;p--,f.push(I),F(u,I,w)}if(p>0&&e.indexSignatures.length===0)return;let m=s.record(u,{requiredKeys:f}),g=$P(e,i.constraint,f.length),b=g?.minLength??0;for(let{parameter:I,type:w}of r){let E;if(b===0)E=s.constant([]);else{let h=I.terminal(s,c,a),A=w.terminal(s,c,a);if(h===void 0||A===void 0)return;E=NI(s,s.tuple(h,A),{...g,maxLength:b},FP)}m=DP(m,E)}return m})}case\"Union\":{let n=e.types.map(o=>vr(o,t)),r=(o,s,i)=>BP(o,n.map(a=>a.terminal(o,s,i)).filter(Lt));return Yc((o,s,i)=>{let a=n.map(u=>u(o,s,i));if(s.recursion){let u=r(o,s,i);if(u!==void 0)return o.oneof(s.recursion,u,...a)}let c=BP(o,a);if(c===void 0)throw ys(\"a union with no members\");return c},r)}case\"Suspend\":{let n=_P.get(e);if(n)return n;let r=fa(()=>vr(e.thunk(),t)),o=Yc((s,i,a)=>{let c=jP(s,e),u={...i,recursion:c},f=a.includes(e)?a:[...a,e],d=r().terminal(s,u,f);if(d===void 0)throw Po(\"Unable to derive an arbitrary for a recursive schema without a finite generation path\",t);return s.oneof(c,d,s.constant(null).chain(()=>r()(s,u,f)))},(s,i,a)=>{if(a.includes(e))return;let c=jP(s,e);return r().terminal(s,{...i,recursion:c},[...a,e])});return _P.set(e,o),o}}}var GP=It(e=>ri(e,[]));function ri(e,t){let n=Co(e)?.toEquivalence;if(n)return n(Qs(e)?e.typeParameters.map(r=>ri(r,t)):[]);switch(e._tag){case\"Never\":return A0();case\"Declaration\":case\"Null\":case\"Undefined\":case\"Void\":case\"Unknown\":case\"Any\":case\"String\":case\"Number\":case\"Boolean\":case\"BigInt\":case\"Symbol\":case\"Literal\":case\"UniqueSymbol\":case\"ObjectKeyword\":case\"Enum\":case\"TemplateLiteral\":return B;case\"Arrays\":{let r=e.elements.map((i,a)=>ri(i,[...t,a])),o=e.elements.length,s=e.rest.map((i,a)=>ri(i,[...t,o+a]));return Rt((i,a)=>{if(!Array.isArray(i)||!Array.isArray(a))return!1;let c=i.length;if(c!==a.length)return!1;let u=0;for(;u<Math.min(c,e.elements.length);u++)if(!r[u](i[u],a[u]))return!1;if(s.length>0){let[f,...d]=s;for(;u<c-d.length;u++)if(!f(i[u],a[u]))return!1;for(let p=0;p<d.length;p++)if(!d[p](i[u+p],a[u+p]))return!1}return!0})}case\"Objects\":{if(e.propertySignatures.length===0&&e.indexSignatures.length===0)return B;let r=e.propertySignatures.map(s=>ri(s.type,[...t,s.name])),o=e.indexSignatures.map(s=>ri(s.type,t));return Rt((s,i)=>{if(!wt(s)||!wt(i))return!1;for(let a=0;a<r.length;a++){let c=e.propertySignatures[a],u=c.name,f=Object.hasOwn(s,u),d=Object.hasOwn(i,u);if(dt(c.type)&&f!==d||f&&d&&!r[a](s[u],i[u]))return!1}for(let a=0;a<o.length;a++){let c=e.indexSignatures[a],u=aa(s,c.parameter),f=aa(i,c.parameter);if(u.length!==f.length)return!1;for(let d=0;d<u.length;d++){let p=u[d];if(!Object.hasOwn(i,p)||!o[a](s[p],i[p]))return!1}}return!0})}case\"Union\":{let r=Jt(e).types,o=new Map(r.map((s,i)=>[s,[Ia(s),ri(e.types[i],t)]]));return Rt((s,i)=>{let a=cl(s,r);for(let c=0;c<a.length;c++){let[u,f]=o.get(a[c]);if(u(s)&&u(i))return f(s,i)}return!1})}case\"Suspend\":{let r=fa(()=>ri(e.thunk(),t));return Rt((o,s)=>r()(o,s))}}}function Ta(e){return e.replace(/~/g,\"~0\").replace(/\\//g,\"~1\")}function Qc(e){return e.replace(/~1/g,\"/\").replace(/~0/g,\"~\")}var JJ=\"http://json-schema.org/draft-07/schema#\",VP=\"https://json-schema.org/draft/2020-12/schema\";function ZP(e,t){return e===t||e===(t.endsWith(\"#\")?t.slice(0,-1):`${t}#`)}function XP(e){return{dialect:\"draft-07\",...XJ(e,uK)}}function KJ(e,t){return n(e,!1,!0);function n(r,o,s=!1){if(!wt(r))return r;let i=o||!s&&nF(r.$id),a={};for(let c of Object.keys(r)){let u=r[c],f=u;switch(c){case\"$defs\":case\"properties\":case\"patternProperties\":case\"dependentSchemas\":f=$I(u,d=>n(d,i))??u;break;case\"allOf\":case\"anyOf\":case\"oneOf\":case\"prefixItems\":f=Array.isArray(u)?u.map(d=>n(d,i)):u;break;case\"not\":case\"additionalProperties\":case\"propertyNames\":case\"unevaluatedProperties\":case\"items\":case\"contains\":case\"unevaluatedItems\":case\"if\":case\"then\":case\"else\":case\"contentSchema\":f=n(u,i)}F(a,c,f)}return t(a,i),a}}function eF(e,t){return KJ(e,n=>{GJ(n,t)})}function GJ(e,t){typeof e.$ref==\"string\"&&F(e,\"$ref\",t(e.$ref,\"$ref\")),typeof e.$dynamicRef==\"string\"&&F(e,\"$dynamicRef\",t(e.$dynamicRef,\"$dynamicRef\"))}function $I(e,t){if(!wt(e))return;let n={};for(let r of Object.keys(e))F(n,r,t(e[r],r));return n}function VJ(e,t,n){let r=new Map,o=[],s=LI;function i(p,m=[],g=[]){if(m.length===0&&t?.trackIds){let b=wt(p)?f(p):void 0;s=YP(b,LI)??LI}return u(p,m,g,{sourceRoot:[],targetRoot:[],uri:s})}function a(){for(let[p,m,g]of o){let b=m,I=tF(m,g);if(I!==void 0){let w=ZJ(I.hash);if(I.hash=\"\",w!==void 0){let E=r.get(QP(I.href,w));E!==void 0&&(b=YJ(m,E))}}F(p,\"$ref\",b)}}let c=n(i);return a(),c;function u(p,m,g,b){if(typeof p==\"boolean\")return d(m,g,b),t?.booleanAdapter?.(p)??p;if(!wt(p))return p;let I=b,w=f(p);if(m.length>0&&t?.trackIds&&nF(w)){let A=YP(w,b.uri);A!==void 0&&(I={parent:b,sourceRoot:m,targetRoot:g,uri:A})}d(m,g,I);let E=I.uri,h={isDocumentRoot:m.length===0,schema(A,y,S=y){return u(A,[...m,y],[...g,S],I)},schemaAt(A,y,S){return u(A,[...m,...y],[...g,...S],I)},schemaArray(A,y,S=y){return Array.isArray(A)?A.map((v,C)=>u(v,[...m,y,String(C)],[...g,S,String(C)],I)):A},schemaMap(A,y,S=y){return wt(A)?$I(A,(v,C)=>u(v,[...m,y,C],[...g,S,C],I)):A},reference(A,y){typeof y==\"string\"?o.push([A,y,E]):F(A,\"$ref\",y)}};return e(p,h)}function f(p){return t?.ignoreRefSiblings===!0&&typeof p.$ref==\"string\"?void 0:p.$id}function d(p,m,g){g.parent!==void 0&&d(p,m,g.parent),r.set(QP(g.uri,p.slice(g.sourceRoot.length)),m.slice(g.targetRoot.length))}}var LI=\"https://effect.invalid/.json-schema/\";function tF(e,t){return URL.canParse(e,t)?new URL(e,t):void 0}function YP(e,t){if(typeof e!=\"string\")return;let n=tF(e,t);if(n!==void 0)return n.hash=\"\",n.href}function ZJ(e){if(e.length===0)return[];let t;try{t=decodeURIComponent(e.slice(1))}catch{return}if(t.startsWith(\"/\"))return/~(?:[^01]|$)/.test(t)?void 0:t.slice(1).split(\"/\").map(Qc)}function YJ(e,t){let n=e.indexOf(\"#\");return n===-1&&t.length===0?e:`${n===-1?e:e.slice(0,n)}${QJ(t)}`}function QJ(e){return e.length===0?\"#\":`#/${e.map(t=>encodeURI(Ta(t)).replace(/#/g,\"%23\")).join(\"/\")}`}function QP(e,t){return`${e}\\0${JSON.stringify(t)}`}function nF(e){return typeof e==\"string\"&&e.length>0&&e[0]!==\"#\"}function XJ(e,t,n){return VJ(t,{...n,trackIds:!0},r=>({schema:r(e.schema),definitions:$I(e.definitions,(o,s)=>r(o,[\"$defs\",s],[\"definitions\",s]))}))}var eK=new Set([\"properties\",\"patternProperties\"]),tK=new Set([\"allOf\",\"anyOf\",\"oneOf\"]),nK=new Set([\"not\",\"additionalProperties\",\"propertyNames\",\"contains\",\"if\",\"then\",\"else\",\"contentSchema\"]);function rK(e,t,n,r,o,s=eK){let i;if(s.has(t))i=r.schemaMap(n,t);else if(tK.has(t))i=r.schemaArray(n,t);else if(o.has(t))i=r.schema(n,t);else return!1;return F(e,t,i),!0}var rF=[\"$anchor\",\"$defs\",\"$dynamicAnchor\",\"$dynamicRef\",\"$vocabulary\",\"contentSchema\",\"dependentRequired\",\"dependentSchemas\",\"maxContains\",\"minContains\",\"prefixItems\",\"unevaluatedItems\",\"unevaluatedProperties\"],Xte=[...rF,\"deprecated\"],ene=[...rF,\"$comment\",\"$id\",\"$schema\",\"const\",\"contains\",\"contentEncoding\",\"contentMediaType\",\"else\",\"examples\",\"if\",\"patternProperties\",\"propertyNames\",\"then\"],oK=/^[A-Za-z_][-A-Za-z0-9._]*$/,sK=/^[A-Za-z][-A-Za-z0-9._:]*$/;function va(e,t,n){throw new Error(`Cannot convert JSON Schema keyword \"${e}\" to ${t}: ${n}`)}function iK(e,t,n,r){for(let o of t)Object.hasOwn(e,o)&&va(o,n,`it is not active in ${r} but would become active in the target`)}var aK=[\"additionalItems\",\"definitions\",\"dependencies\"];function cK(e,t,n,r,o){n.isDocumentRoot?F(e,\"$schema\",ZP(t,VP)?r:t):ZP(t,VP)||va(\"$schema\",o,\"an embedded resource cannot declare a different dialect\")}function uK(e,t){iK(e,aK,\"Draft-07\",\"Draft 2020-12\");let n={},r,o,s;for(let i of Object.keys(e)){let a=e[i];if(!rK(n,i,a,t,nK))switch(i){case\"$ref\":r=a;break;case\"$schema\":cK(n,a,t,JJ,\"Draft-07\");break;case\"$id\":case\"$anchor\":break;case\"$defs\":F(n,\"definitions\",t.schemaMap(a,i,\"definitions\"));break;case\"prefixItems\":o=a;break;case\"items\":s=a;break;case\"dependentRequired\":case\"dependentSchemas\":case\"minContains\":case\"maxContains\":break;case\"$dynamicRef\":case\"$dynamicAnchor\":case\"$vocabulary\":case\"unevaluatedProperties\":case\"unevaluatedItems\":va(i,\"Draft-07\",\"the target dialect has no equivalent\");case\"required\":if(Array.isArray(a)&&a.length===0)break;F(n,i,a);break;default:F(n,i,a)}}if(fK(n,o,s,t),Object.hasOwn(e,\"contains\")){let i=e.minContains,a=e.maxContains;(i!==void 0&&i!==1||a!==void 0)&&va(\"minContains/maxContains\",\"Draft-07\",\"contains cardinality cannot be represented\"),Object.hasOwn(e,\"minContains\")&&F(n,\"minContains\",i)}else Object.hasOwn(e,\"minContains\")&&F(n,\"minContains\",e.minContains),Object.hasOwn(e,\"maxContains\")&&F(n,\"maxContains\",e.maxContains);return dK(e,n,t,\"draft-07\"),mK(e,n,\"$id\",\"Draft-07\"),lK(n,r,t),n}function fK(e,t,n,r){if(t===void 0){n!==void 0&&F(e,\"items\",r.schema(n,\"items\"));return}F(e,\"items\",r.schemaArray(t,\"prefixItems\",\"items\")),n!==void 0&&F(e,\"additionalItems\",r.schema(n,\"items\",\"additionalItems\"))}function lK(e,t,n){if(t!==void 0)if(typeof t==\"string\"&&Object.keys(e).length>0){let r={};n.reference(r,t),pK(e,r)}else n.reference(e,t)}function dK(e,t,n,r){let o=wt(e.dependentRequired)?e.dependentRequired:void 0,s=wt(e.dependentSchemas)?e.dependentSchemas:void 0;if(o===void 0&&s===void 0)return;let i={},a=new Set([...Object.keys(o??{}),...Object.keys(s??{})]);for(let c of a){let u=o?.[c],f=s?.[c],d=r===\"draft-04\"&&Array.isArray(u)&&u.length===0;f===void 0?d||F(i,c,u):u===void 0||d?F(i,c,n.schemaAt(f,[\"dependentSchemas\",c],[\"dependencies\",c])):F(i,c,{allOf:[n.schemaAt(f,[\"dependentSchemas\",c],[\"dependencies\",c,\"allOf\",\"0\"]),{required:u}]})}Object.keys(i).length>0&&F(t,\"dependencies\",i)}function pK(e,t){Array.isArray(e.allOf)?e.allOf.push(t):F(e,\"allOf\",[t])}function mK(e,t,n,r){let o=e.$id,s=e.$anchor;if(s===void 0){o!==void 0&&F(t,n,o);return}(typeof s!=\"string\"||!oK.test(s))&&va(\"$anchor\",r,\"the anchor is not valid\"),sK.test(s)||va(\"$anchor\",r,\"the anchor cannot be represented as a plain-name fragment identifier\"),o===void 0?F(t,n,`#${s}`):va(\"$anchor\",r,\"it cannot be combined with the schema $id\")}var tne=globalThis.RegExp;var Xc=e=>e.replace(/[/\\\\^$*+?.()|[\\]{}]/g,\"\\\\$&\");var hK=new Set([...sk,na,...rk]);function Bl(e,t){if(e===void 0)return;let n={},r=e.title;typeof r==\"string\"&&(n.title=r);let o=e.description,s=e.expected;typeof o==\"string\"?n.description=o:t?.generateDescriptions===!0&&typeof s==\"string\"&&(n.description=s);let i=e.default;jc(i)&&(n.default=i);let a=e.examples;Array.isArray(a)&&jc(a)&&(n.examples=a);let c=e.readOnly;typeof c==\"boolean\"&&(n.readOnly=c);let u=e.writeOnly;typeof u==\"boolean\"&&(n.writeOnly=u);let f=e.format;typeof f==\"string\"&&(n.format=f);let d=e.contentEncoding;typeof d==\"string\"&&(n.contentEncoding=d);let p=e.contentMediaType;typeof p==\"string\"&&(n.contentMediaType=p);let m=e.contentSchema;if(jc(m)&&(n.contentSchema=m),t?.includeAnnotationKey!==void 0)for(let[g,b]of Object.entries(e))hK.has(g)||!t.includeAnnotationKey(g)||jc(b)&&F(n,g,b);return Object.keys(n).length===0?void 0:n}function iF(e){let t=e.type===\"number\"||e.type===\"integer\"?e.type:void 0,n=e;if(t!==void 0&&(n={...e},delete n.type),Array.isArray(n.allOf)){let r=[],o=!1;for(let s of n.allOf){let i=iF(s);i.type!==void 0&&(o=!0,(t===void 0||i.type===\"integer\")&&(t=i.type)),Object.keys(i.schema).length>0&&r.push(i.schema)}if(o){let{allOf:s,...i}=n;n=r.length===0?i:{...i,allOf:r}}}return{type:t,schema:n}}function gK(e){return Array.isArray(e.anyOf)&&e.anyOf.length===4&&e.anyOf[0]?.type===\"number\"&&e.anyOf.slice(1).every(t=>t.type===\"string\")}var aF=\"|type|format|pattern|multipleOf|minimum|maximum|exclusiveMinimum|exclusiveMaximum|minLength|maxLength|minItems|maxItems|uniqueItems|minProperties|maxProperties|propertyNames|\";function BI(e,t){return Object.keys(e).every(n=>t.includes(`|${n}|`))}function sF(e,t){return typeof e.$ref!=\"string\"&&t.every(n=>!Object.hasOwn(e,n))}var qI=\"|title|description|default|examples|readOnly|writeOnly|\",xK=aF+qI;function ql(e,t,n){if(Object.keys(e).length===0)return t;let r=Object.keys(t);if(r.length===0)return e;let o=e.type===\"number\"||e.type===\"integer\"?e.type:void 0,s=gK(e);if(o!==void 0||s){let a=iF(t);if(a.type!==void 0){let c=o===\"integer\"||a.type===\"integer\"?\"integer\":\"number\",u={...e,type:c};s&&delete u.anyOf;let f=Object.keys(a.schema);return f.length===0?u:BI(a.schema,qI)&&sF(u,f)?{...u,...a.schema}:ql(u,a.schema,n)}}if(n&&sF(e,r))return{...e,...t};let i=Array.isArray(t.allOf)&&r.length===1?t.allOf:[t];return Array.isArray(e.allOf)?{...e,allOf:[...e.allOf,...i]}:typeof e.$ref==\"string\"?{allOf:[e,...i]}:{...e,allOf:i}}function yK(e,t,n,r){let o=new Map,s=new WeakMap,i=new Map,a=!1,c=Object.keys(n);for(let h of c)d(h,[\"references\",h]);let u=Pr(e,(h,A)=>p(I(h,t[A]))),f={};for(let h of c){let A=o.get(h);typeof A!=\"string\"&&F(f,h,p(A))}return{dialect:\"draft-2020-12\",schemas:u,definitions:f};function d(h,A){let y=o.get(h);if(y!==void 0)return typeof y==\"string\"?y:h;if(!Object.hasOwn(n,h))throw Po(`Invalid reference ${h}`,[...A,\"$ref\"]);o.set(h,null);let S=n[h],v=I(S,[\"references\",h]),C=m(S);if(C!==void 0){let V=i.get(C),ce=V?.find(W=>B(o.get(W),v));if(ce===void 0)V===void 0?i.set(C,[h]):V.push(h);else return a=!0,o.set(h,ce),ce}return o.set(h,v),h}function p(h){return a?eF(h,A=>A.replace(/^#\\/\\$defs\\/([^/]*)/,(y,S)=>{let v=o.get(Qc(S));return typeof v==\"string\"?`#/$defs/${Ta(v)}`:y})):h}function m(h){if(h._tag===\"Reference\")return;let A=h.checks.length===0?h.annotations:h.checks[h.checks.length-1].annotations;return typeof A?.identifier!=\"string\"&&typeof A?.[na]==\"string\"?A[na]:void 0}function g(h,A){return h?.schemas?.map((y,S)=>I(y,[...A,\"schemas\",S]))??[]}function b(h,A,y){let S=h.annotations,v=S?.toJsonSchema;if(v!==void 0){let W=g(h.representation,[...y,\"representation\"]),he=v({type:A,schemas:W}),de=Bl(S,r),Et=de===void 0?he:{...he,...de},Cn=de===void 0?aF:xK;return h._tag===\"Filter\"&&BI(Et,Cn)&&(de===void 0||BI(de,qI))?[Et,!0]:[Et]}if(h._tag===\"Filter\")return;let C=h.checks.map((W,he)=>b(W,A,[...y,\"checks\",he])).filter(W=>W!==void 0);if(C.length===0)return;let V=Bl(S,r),ce=C.map(([W])=>W);return[V===void 0?{allOf:ce}:{allOf:ce,...V}]}function I(h,A){if(h._tag===\"Reference\"){let C=d(h.$ref,A);return{$ref:`#/$defs/${Ta(C)}`}}let y=s.get(h);if(y!==void 0)return y;let S=w(h,A),v=Bl(h.annotations,r);v!==void 0&&(S={...S,...v});for(let C=0;C<h.checks.length;C++){let V=typeof S.type==\"string\"&&bK(S.type)?S.type:void 0,ce=b(h.checks[C],V,[...A,\"checks\",C]);ce!==void 0&&(S=ql(S,...ce))}return s.set(h,S),S}function w(h,A){switch(h._tag){case\"Any\":case\"Unknown\":return{};case\"ObjectKeyword\":return{anyOf:[{type:\"object\"},{type:\"array\"}]};case\"Void\":case\"Undefined\":case\"Null\":return{type:\"null\"};case\"BigInt\":return{type:\"string\",allOf:[{pattern:\"^-?\\\\d+$\"}]};case\"Symbol\":case\"UniqueSymbol\":return{type:\"string\",allOf:[{pattern:\"^Symbol\\\\((.*)\\\\)$\"}]};case\"Declaration\":return{};case\"Suspend\":return I(h.thunk,[...A,\"thunk\"]);case\"Never\":return{not:{}};case\"String\":return{type:\"string\"};case\"Number\":return{anyOf:[{type:\"number\"},{type:\"string\",enum:[\"NaN\"]},{type:\"string\",enum:[\"Infinity\"]},{type:\"string\",enum:[\"-Infinity\"]}]};case\"Boolean\":return{type:\"boolean\"};case\"Literal\":{let y=h.literal;return typeof y==\"bigint\"?{type:\"string\",enum:[globalThis.String(y)]}:{type:typeof y,enum:[y]}}case\"Enum\":{let y=h.enums.map(([S,v])=>typeof v==\"number\"&&!globalThis.Number.isFinite(v)?{type:\"string\",enum:[globalThis.String(v)],title:S}:{type:typeof v,enum:[v],title:S});return y.length===0?{not:{}}:{anyOf:y}}case\"TemplateLiteral\":return{type:\"string\",pattern:`^${h.parts.map(Hh).join(\"\")}$`};case\"Arrays\":{if(h.rest.length>1)throw Po(\"Invalid schema representation document\",[...A,\"rest\"]);let y={type:\"array\"},S=h.elements.length,v=h.elements.map((C,V)=>{C.isOptional&&S--;let ce=I(C.type,[...A,\"elements\",V,\"type\"]),W=Bl(C.annotations,r);return W===void 0?ce:ql(ce,W)});if(v.length>0?(y.prefixItems=v,y.maxItems=h.elements.length,S>0&&(y.minItems=S)):y.items=!1,h.rest.length===1){delete y.maxItems;let C=I(h.rest[0],[...A,\"rest\",0]);Object.keys(C).length>0?y.items=C:delete y.items}return y}case\"Objects\":{if(h.propertySignatures.length===0&&h.indexSignatures.length===0)return{anyOf:[{type:\"object\"},{type:\"array\"}]};let y={type:\"object\"},S={},v=[];for(let W=0;W<h.propertySignatures.length;W++){let he=h.propertySignatures[W];if(typeof he.name!=\"string\")throw Po(\"Invalid schema representation document\",[...A,\"propertySignatures\",W,\"name\"]);let de=he.name,Et=I(he.type,[...A,\"propertySignatures\",W,\"type\"]),Cn=Bl(he.annotations,r);F(S,de,Cn===void 0?Et:ql(Et,Cn)),he.isOptional||v.push(de)}h.propertySignatures.length>0&&(y.properties=S),v.length>0&&(y.required=v);let C={},V=[];for(let W=0;W<h.indexSignatures.length;W++){let he=h.indexSignatures[W],de=I(he.type,[...A,\"indexSignatures\",W,\"type\"]);Object.keys(de).length===1&&\"not\"in de&&(de=!1);let Et=E(he.parameter,[...A,\"indexSignatures\",W,\"parameter\"],new Set);if(Et.length===0)V.push(de);else for(let Cn of Et){let rr=C[Cn];F(C,Cn,rr===void 0?de:rr===!1||de===!1?!1:ql(rr,de))}}let ce=Object.keys(C).length>0;return ce&&(y.patternProperties=C),h.indexSignatures.length===0?y.additionalProperties=r?.additionalProperties??!1:V.length===1&&h.propertySignatures.length===0&&!ce?y.additionalProperties=V[0]:V.length>0&&(y.allOf=V.map(W=>({type:\"object\",additionalProperties:W}))),typeof y.additionalProperties==\"object\"&&y.additionalProperties!==null&&Object.keys(y.additionalProperties).length===0&&delete y.additionalProperties,y}case\"Union\":{let y=h.types.map((S,v)=>I(S,[...A,\"types\",v]));if(y.length===0)return{not:{}};if(h.mode===\"anyOf\"&&y.length>1){let S=SK(y);if(S!==void 0)return S}return h.mode===\"anyOf\"?{anyOf:y}:{oneOf:y}}}}function E(h,A,y){switch(h._tag){case\"Reference\":{if(!Object.hasOwn(n,h.$ref))throw Po(`Invalid reference ${h.$ref}`,[...A,\"$ref\"]);if(d(h.$ref,A),y.has(h.$ref))return[];let S=new Set(y).add(h.$ref);return E(n[h.$ref],[\"references\",h.$ref],S)}case\"String\":return cF(I(h,A));case\"TemplateLiteral\":return[`^${h.parts.map(Hh).join(\"\")}$`];case\"Union\":return h.types.flatMap((S,v)=>E(S,[...A,\"types\",v],y));default:throw Po(\"Invalid schema representation document\",A)}}}function bK(e){return e===\"string\"||e===\"number\"||e===\"boolean\"||e===\"array\"||e===\"object\"||e===\"null\"||e===\"integer\"}function SK(e){let t,n=[];for(let r of e){if(Object.keys(r).length!==2||r.type===void 0||!Array.isArray(r.enum)||r.enum.length===0)return;if(t===void 0)t=r.type;else if(r.type!==t)return;n.push(...r.enum)}return{type:t,enum:n}}function cF(e){let t=[];typeof e.pattern==\"string\"&&t.push(e.pattern);for(let n of[\"allOf\",\"anyOf\",\"oneOf\"]){let r=e[n];if(Array.isArray(r))for(let o of r)typeof o==\"object\"&&o!==null&&!Array.isArray(o)&&t.push(...cF(o))}return t}function Hh(e){switch(e._tag){case\"Literal\":return Xc(globalThis.String(e.literal));case\"String\":return XR;case\"Number\":return qm;case\"TemplateLiteral\":return e.parts.map(Hh).join(\"\");case\"Union\":return e.types.map(Hh).join(\"|\");default:throw Po(\"Invalid schema representation document\",[])}}function uF(e,t){let n=yK([e.representation],[[\"representation\"]],e.references,t);return{dialect:n.dialect,schema:n.schemas[0],definitions:n.definitions}}var IK=({identifier:e})=>e;function oo(e){return e===void 0?void 0:{annotations:e}}function zI(e,t){let{references:n,representations:r}=wK([e],t);return{representation:r[0],references:n}}function wK(e,t){let n={},r=new Map,o=new Set,s=new Map,i=new Set;for(let y of e)m(y);let a=t?.referencePolicy??IK;for(let y of s.values())for(let S of y.values()){let v=a({ast:S.ast,occurrences:S.occurrences,identifier:S.identifier});if(v!==void 0){let C=v===S.identifier||!v.endsWith(\"_\")?\"_\":\"\";S.reference=u(v,S,C)}else S.isRecursive&&(S.reference=u(`${S.ast._tag}_`,S,\"\"))}return{representations:Pr(e,y=>b(y)),references:n};function u(y,S,v=\"_\"){let C=y,V=0;for(;r.has(C);){if(r.get(C)===S)return C;C=`${y}${v}${++V}`}return r.set(C,S),C}function f(y,S,v){let C=S.fallback;return C!==void 0?fS(y)===C?y:Ir(y,{[na]:C}):v===S.identifier?y:Ir(y,{identifier:v})}function d(y,S){if(!Object.hasOwn(n,y)&&!o.has(y)){o.add(y);let v=I(S);o.delete(y),F(n,y,v)}return{_tag:\"Reference\",$ref:y}}function p(y){let S=dl(y),v=fE(S),C=Ac(S),V=C===void 0?(S!==y?Ac(y):void 0)??fS(S):void 0;V!==void 0&&(C=`${V}Encoded`);let ce=s.get(v);ce===void 0&&(ce=new Map,s.set(v,ce));let W=ce.get(C);return W===void 0&&(W={ast:v,identifier:C,fallback:V,occurrences:0,isRecursive:!1,reference:void 0},ce.set(C,W)),W}function m(y){let S=p(y),v=S.ast;if(S.occurrences++,i.has(S)){S.isRecursive=!0;return}if(!(S.occurrences>1)){switch(i.add(S),g(v.checks),v._tag){case\"Declaration\":case\"Arrays\":case\"Objects\":case\"Union\":v.recur(C=>(m(C),C));break;case\"TemplateLiteral\":v.parts.forEach(m);break;case\"Suspend\":m(v.thunk());break}i.delete(S)}}function g(y){y?.forEach(S=>{S.annotations?.representation?.schemas?.forEach(v=>m(Jt(v))),S._tag===\"FilterGroup\"&&g(S.checks)})}function b(y){let S=p(y),v=S.ast,C=S.reference;if(C!==void 0){let V=S.identifier===void 0?v:f(v,S,C);return d(C,V)}return I(v)}function I(y){let S=w(y.checks);switch(y._tag){case\"Declaration\":return{_tag:\"Declaration\",typeParameters:y.typeParameters.map(v=>b(v)),checks:S,...h(y.annotations)};case\"Null\":case\"Undefined\":case\"Void\":case\"Never\":case\"Unknown\":case\"Any\":case\"String\":case\"Boolean\":case\"Number\":case\"BigInt\":case\"Symbol\":case\"ObjectKeyword\":return{_tag:y._tag,checks:S,...oo(y.annotations)};case\"Literal\":return{_tag:\"Literal\",literal:y.literal,checks:S,...oo(y.annotations)};case\"UniqueSymbol\":return{_tag:\"UniqueSymbol\",symbol:y.symbol,checks:S,...oo(y.annotations)};case\"Enum\":return{_tag:\"Enum\",enums:y.enums,checks:S,...oo(y.annotations)};case\"TemplateLiteral\":return{_tag:\"TemplateLiteral\",parts:y.parts.map(v=>b(v)),checks:S,...oo(y.annotations)};case\"Arrays\":return{_tag:\"Arrays\",elements:y.elements.map(v=>{let C=dl(v),V=C.context?.annotations;return{isOptional:dt(C),type:b(v),...oo(V)}}),rest:y.rest.map(v=>b(v)),checks:S,...oo(y.annotations)};case\"Objects\":return{_tag:\"Objects\",propertySignatures:y.propertySignatures.map(v=>{let C=dl(v.type),V=C.context?.annotations;return{name:v.name,type:b(v.type),isOptional:dt(C),isMutable:GR(C),...oo(V)}}),indexSignatures:y.indexSignatures.map(v=>({parameter:b(v.parameter),type:b(v.type)})),checks:S,...oo(y.annotations)};case\"Union\":return{_tag:\"Union\",types:y.types.map(v=>b(v)),mode:y.mode,checks:S,...oo(y.annotations)};case\"Suspend\":return{_tag:\"Suspend\",checks:[],thunk:b(y.thunk()),...oo(y.annotations)}}}function w(y){return y?.map(E)??[]}function E(y){switch(y._tag){case\"Filter\":return{_tag:\"Filter\",aborted:y.aborted,...A(y.annotations)};case\"FilterGroup\":return{_tag:\"FilterGroup\",checks:Pr(y.checks,E),...A(y.annotations)}}}function h(y){if(y===void 0)return;let{representation:S,...v}=y;return{...S===void 0?void 0:{representation:S},...Object.keys(v).length===0?void 0:{annotations:v}}}function A(y){if(y===void 0)return;let{representation:S,...v}=y,C=S===void 0?void 0:S.schemas===void 0?S:{...S,schemas:S.schemas.map(V=>b(Jt(V)))};return{...C===void 0?void 0:{representation:C},...Object.keys(v).length===0?void 0:{annotations:v}}}}function lF(e,t){let n=[];return WI(e,t,\"\",n),n}function WI(e,t,n,r){if(!Object.is(e,t)){if(Array.isArray(e)&&Array.isArray(t)){let o=e.length,s=t.length,i=Math.min(o,s);for(let a=0;a<i;a++)WI(e[a],t[a],`${n}/${a}`,r);for(let a=o-1;a>=s;a--)r.push({op:\"remove\",path:`${n}/${a}`});for(let a=o;a<s;a++)r.push({op:\"add\",path:`${n}/${a}`,value:t[a]});return}if(Jh(e)&&Jh(t)){let o=Object.keys(e),s=Object.keys(t),i=Array.from(new Set([...o,...s])).sort();for(let a of i){let c=`${n}/${Ta(a)}`,u=Object.hasOwn(e,a),f=Object.hasOwn(t,a);u&&f?WI(e[a],t[a],c,r):!u&&f?r.push({op:\"add\",path:c,value:t[a]}):r.push({op:\"remove\",path:c})}return}r.push({op:\"replace\",path:n,value:t})}}function dF(e,t){let n=t;for(let r of e)n=AK(n,r);return n}function Jh(e){return typeof e==\"object\"&&e!==null&&!Array.isArray(e)}function vK(e){if(e===\"\")return[];if(e.charCodeAt(0)!==47)throw new Error(`Invalid JSON Pointer, it must start with \"/\": ${JSON.stringify(e)}`);return e.split(\"/\").slice(1).map(Qc)}function pF(e){if(!/^(0|[1-9]\\d*)$/.test(e))throw new Error(`Invalid array index: \"${e}\"`);return Number(e)}function AK(e,t){if(t.path===\"\"){if(t.op===\"remove\")throw new Error(\"Unsupported operation at the root\");return t.value}let n=CK(e,t.path);if(n===null)throw new Error(`Cannot ${t.op} at \"${t.path}\" (parent not found or not a container).`);let{lastToken:r,parent:o,stack:s}=n;if(Array.isArray(o)){if(r===\"-\"&&t.op!==\"add\")throw new Error(`\"-\" is not valid for ${t.op} at \"${t.path}\".`);let i=r===\"-\"?o.length:pF(r),a=t.op===\"add\"?o.length:o.length-1;if(i>a)throw new Error(`Array index out of bounds at \"${t.path}\".`);let c=o.slice();return t.op===\"add\"?c.splice(i,0,t.value):t.op===\"remove\"?c.splice(i,1):c[i]=t.value,fF(s,c)}if(Jh(o)){if(t.op!==\"add\"&&!Object.hasOwn(o,r))throw new Error(`Property \"${r}\" does not exist at \"${t.path}\".`);let i={...o};return t.op===\"remove\"?delete i[r]:F(i,r,t.value),fF(s,i)}throw new Error(`Cannot ${t.op} at \"${t.path}\" (parent not found or not a container).`)}function CK(e,t){let n=vK(t);if(n.length===0)return null;let r=n[n.length-1],o=[],s=e;for(let i=0;i<n.length-1;i++){let a=n[i];if(Array.isArray(s)){let c=pF(a);if(c>=s.length)return null;o.push({container:s,token:c}),s=s[c];continue}if(Jh(s)){if(!Object.hasOwn(s,a))return null;o.push({container:s,token:a}),s=s[a];continue}return null}return{stack:o,parent:s,lastToken:r}}function fF(e,t){let n=t;for(let r=e.length-1;r>=0;r--){let{container:o,token:s}=e[r];if(Array.isArray(o)){let i=o.slice();i[s]=n,n=i}else{let i={...o};F(i,s,n),n=i}}return n}function xF(e,t){return so(eu(\"Iso\",e,t))}function hF(e,t){return so(eu(\"Lens\",e,t))}function eu(e,t,n){return[{_tag:\"PrimitiveNode\",kind:e,get:t,set:n}]}var OK={kind:\"Iso\",get:_,set:_},Kh=class{_tag=\"PathNode\";kind=\"Lens\";path;get;set;constructor(t){this.path=t,this.get=n=>{let r=n;for(let o=0;o<t.length;o++)r=r[t[o]];return r},this.set=(n,r)=>{let o=Gh(r),s=o,i=0;for(;i<t.length-1;i++){let a=t[i];F(s,a,Gh(s[a])),s=s[a]}return F(s,t[i],n),o}}},zl=class{_tag=\"CheckNode\";kind=\"Prism\";checks;get;set=_;constructor(t){this.checks=t,this.get=n=>n_(t,n)}};function Aa(e,t){if(e.length===0)return t;if(t.length===0)return e;let n=e.slice();for(let r=0;r<t.length;r++){let o=t[r],s=n[n.length-1];s._tag===\"PathNode\"&&o._tag===\"PathNode\"?n[n.length-1]=new Kh([...s.path,...o.path]):s._tag===\"CheckNode\"&&o._tag===\"CheckNode\"?n[n.length-1]=new zl([...s.checks,...o.checks]):n.push(o)}return n}function kK(e,t){return so(eu(\"Optional\",e,t))}var tu=class{node;getResult;replaceResult;constructor(t,n,r){this.node=t,this.getResult=n,this.replaceResult=r}replace(t,n){return zu(this.replaceResult(t,n),()=>n)}modify(t){return n=>zu(Kd(this.getResult(n),r=>this.replaceResult(t(r),n)),()=>n)}compose(t){return so(Aa(this.node,t.node))}key(t){return so(Aa(this.node,[new Kh([t])]))}optionalKey(t){return so(Aa(this.node,eu(\"Lens\",n=>n[t],(n,r)=>{let o=Gh(r);return n===void 0?Array.isArray(o)&&typeof t==\"number\"?o.splice(t,1):delete o[t]:F(o,t,n),o})))}check(...t){return so(Aa(this.node,[new zl(t)]))}refine(t,n){return so(Aa(this.node,[new zl([Km(t,n)])]))}tag(t){let n=re(new ge({expected:`${JSON.stringify(t)} tag`}));return so(Aa(this.node,eu(\"Prism\",r=>r._tag===t?se(r):n,_)))}at(t,...n){let r=re(new Ve([t],new Gs(void 0)));return so(Aa(this.node,eu(\"Optional\",o=>Object.hasOwn(o,t)?se(o[t]):r,(o,s)=>{if(Object.hasOwn(s,t)){let i=Gh(s);return F(i,t,o),se(i)}else return r})))}pick(t){return this.compose(hF(AP(t),(n,r)=>({...r,...n})))}omit(t){return this.compose(hF(CP(t),(n,r)=>({...r,...n})))}notUndefined(){return this.refine(Lt,{expected:\"a value other than `undefined`\"})}forEach(t){let n=t(Vh());return kK(r=>Oi(this.getResult(r),o=>{let s=[];for(let i=0;i<o.length;i++){let a=n.getResult(o[i]);Tt(a)&&s.push(a.success)}return s}),(r,o)=>Kd(this.getResult(o),s=>{let i=[];for(let c=0;c<s.length;c++)Tt(n.getResult(s[c]))&&i.push(c);if(r.length!==i.length)return re(new ge({message:`each: replacement length mismatch: ${r.length} !== ${i.length}`}));let a=s.slice();for(let c=0;c<i.length;c++){let u=i[c],f=n.replaceResult(r[c],s[u]);if(ie(f))return re(new Ve([u],f.failure));a[u]=f.success}return this.replaceResult(a,o)}))}modifyAll(t){return n=>zu(Kd(this.getResult(n),r=>this.replaceResult(r.map(t),n)),()=>n)}},HI=class extends tu{get;set;constructor(t,n,r){super(t,o=>se(n(o)),o=>se(r(o))),this.get=n,this.set=r}replace(t,n){return this.set(t)}modify(t){return n=>this.set(t(this.get(n)))}},JI=class extends tu{get;constructor(t,n,r){super(t,o=>se(n(o)),(o,s)=>se(r(o,s))),this.get=n,this.replace=r}modify(t){return n=>this.replace(t(this.get(n)),n)}},KI=class extends tu{set;constructor(t,n,r){super(t,n,(o,s)=>se(r(o))),this.set=r}replace(t,n){return this.set(t)}modify(t){return n=>zu(Oi(this.getResult(n),r=>this.set(t(r))),()=>n)}};function so(e){let t=e[0]??OK;if(e.length>1){let n=e.reduce((r,o)=>MK(r,o.kind),\"Iso\");t={kind:n,get:RK(e,n),set:_K(e,n)}}switch(t.kind){case\"Iso\":return new HI(e,t.get,t.set);case\"Lens\":return new JI(e,t.get,t.set);case\"Prism\":return new KI(e,t.get,t.set);case\"Optional\":return new tu(e,t.get,t.set)}}function Gh(e){if(Array.isArray(e))return e.slice();if(typeof e==\"object\"&&e!==null){let t=Object.getPrototypeOf(e);if(t!==Object.prototype&&t!==null)throw new Error(\"Cannot clone object with non-Object constructor or null prototype\");return{...e}}return e}function RK(e,t){return n=>{for(let r=0;r<e.length;r++){let o=e[r],s=o.get(n);if(GI(o.kind)){if(ie(s))return s;n=s.success}else n=s}return GI(t)?se(n):n}}function _K(e,t){return gF(t)?n=>{for(let r=e.length-1;r>=0;r--)n=e[r].set(n);return n}:(n,r)=>{let o=e.length,s=new Array(o);for(let i=0;i<o;i++){s[i]=r;let a=e[i];if(GI(a.kind)){let c=a.get(r);if(ie(c))return c;r=c.success}else r=a.get(r)}for(let i=o-1;i>=0;i--){let a=e[i];if(gF(a.kind))n=a.set(n);else if(a.kind===\"Lens\")n=a.set(n,s[i]);else{let c=a.set(n,s[i]);if(ie(c))return c;n=c.success}}return t===\"Optional\"?se(n):n}}function GI(e){return e===\"Prism\"||e===\"Optional\"}function gF(e){return e===\"Iso\"||e===\"Prism\"}function MK(e,t){return e===\"Iso\"?t:t===\"Iso\"||e===t?e:\"Optional\"}var PK=so([]);function Vh(){return PK}var Ca=new WeakMap,bF=e=>{if(Ca.has(e))return Ca.get(e);throw new Error(\"Unable to get redacted value\"+(e.label?` with label: \"${e.label}\"`:\"\"))};var SF=\"~effect/data/Redacted\",VI=e=>M(e,SF),Wl=(e,t)=>{let n=Object.create(UK);return t?.label&&(n.label=t.label),Ca.set(n,e),n},UK={[SF]:{_A:e=>e},label:void 0,...ln,toJSON(){return this.toString()},toString(){return`<redacted${$n(this.label)?\":\"+this.label:\"\"}>`},[be](){return H(Ca.get(this))},[Se](e){return VI(e)&&B(Ca.get(this),Ca.get(e))}},nu=bF;var EF=e=>Rt((t,n)=>e(nu(t),nu(n)));var Hl=Bh;function Dn(){return(e,t,n)=>j(new ia(e.map(al),r=>t(r.map(o=>j(o))),n))}function Cr(e,t){return Dn()([],()=>(n,r,o)=>e(n)?x(n):P(new He(r,n,o)),t)}function DK(e){return e}function NK(e){return t=>t.annotate(e)}function LK(e){return t=>ZI(ZI(t).annotate(e))}function $K(e){return t=>t.rebuild(pl(t.ast,e))}function jK(e){return e}var Jl=\"~effect/SchemaError/SchemaError\",bs=class extends yo(\"SchemaError\"){[Jl]=Jl;constructor(t){let n=Ps();zn(0);try{super({issue:t})}finally{zn(n)}}get message(){return lk(this.issue)}toString(){return`SchemaError(${this.message})`}};function NF(e){return M(e,Jl)&&e[Jl]===Jl}function wF(e){return et(e)?e.value:{issues:[{message:ab(e.cause)}]}}function BK(e,t){let n=Ze(e),r={errors:\"all\",...t?.parseOptions},o=fk(t),s=i=>{let a=new _i(\"sync\"),c=Hf(Xb(n(i,r),{onFailure:o,onSuccess:f=>({value:f})}),{scheduler:a});c.currentDispatcher?.flush();let u=c.pollUnsafe();return u?wF(u):new Promise(f=>{c.addObserver(d=>{f(wF(d))})})};if(\"~standard\"in e){let i=e;return\"validate\"in i[\"~standard\"]||Object.assign(i[\"~standard\"],{validate:s}),i}else return Object.assign(e,{\"~standard\":{version:1,vendor:\"effect\",validate:s}})}function TF(e,t){let n=PD(e);if(t===\"draft-2020-12\"){let r=n.schema;return Object.keys(n.definitions).length>0&&(r.$defs=n.definitions),r}else if(t===\"draft-07\"){let r=XP(n),o=r.schema;return Object.keys(r.definitions).length>0&&(o.definitions=r.definitions),o}throw new globalThis.Error(`Unsupported target: ${t}`)}function qK(e){let t={input(n){return TF(e,n.target)},output(n){return TF(Tn(e),n.target)}};if(\"~standard\"in e){let n=e;return\"jsonSchema\"in n[\"~standard\"]||Object.assign(n[\"~standard\"],{jsonSchema:t}),n}else return Object.assign(e,{\"~standard\":{version:1,vendor:\"effect\",jsonSchema:t}})}var LF=fP,zK=dP;function Xh(e,t){let n=Ze(e,t);return(r,o)=>$F(n(r,o))}function $F(e){return De(e)?ew(e):ft(e,t=>cs(()=>ns(t,n=>new bs(n))))}var WK=Xh;function jF(e,t){let n;for(let r of e.reasons){if(!Ef(r)||!NF(r.error))throw new globalThis.Error(t,{cause:e});n??=r.error}if(n===void 0)throw new globalThis.Error(t,{cause:e});return n}function BF(e){return mm(e).then(t=>{if(et(t))return t.value;throw jF(t.cause,\"Promise adapter can only reject schema errors\")})}function qF(e){let t=Js(e);if(et(t))return t.value;throw jF(t.cause,\"Sync adapter can only throw schema errors\")}function zF(e,t){let n=pP(e,t);return(r,o)=>ew(n(r,o))}function ew(e){return et(e)?e:Qt(ns(e.cause,t=>new bs(t)))}var HK=zF,JK=RI,KK=mP;function WF(e,t){let n=hP(e,t);return(r,o)=>Jd(n(r,o),s=>new bs(s))}var GK=WF;function HF(e,t){let n=Xh(e,t);return(r,o)=>BF(n(r,o))}var VK=HF;function JF(e,t){let n=Xh(e,t);return(r,o)=>qF(n(r,o))}var ZK=JF;function eg(e,t){let n=Zc(e,t);return(r,o)=>$F(n(r,o))}var KF=eg;function GF(e,t){let n=gP(e,t);return(r,o)=>ew(n(r,o))}var YK=GF,QK=MI,XK=xP;function VF(e,t){let n=yP(e,t);return(r,o)=>Jd(n(r,o),s=>new bs(s))}var e4=VF;function ZF(e,t){let n=eg(e,t);return(r,o)=>BF(n(r,o))}var t4=ZF;function YF(e,t){let n=eg(e,t);return(r,o)=>qF(n(r,o))}var n4=YF,j=Ll;function tg(e){return M(e,Hl)&&e[Hl]===Hl}var Oa=on(e=>j(Lc(e.ast),{schema:e})),r4=on(e=>e.schema),Ar=on(e=>{let t=rg(e);return j(HR(e.ast),{schema:t})}),o4=on(e=>e.schema.members[0]),s4=on(e=>j(mE(e.ast),{schema:e})),i4=on(e=>e.schema),Tn=on(e=>j(Jt(e.ast),{schema:e})),cu=on(e=>j(nn(e.ast),{schema:e})),Kl=\"~effect/Schema/flip\";function a4(e){return M(e,Kl)&&e[Kl]===Kl}function ZI(e){return a4(e)?e.schema.rebuild(Jr(e.ast)):j(Jr(e.ast),{[Kl]:Kl,schema:e})}function ze(e){let t=j(new Fn(e),{literal:e,transform(n){return t.pipe(ne(ze(n),{decode:ue(()=>n),encode:ue(()=>e)}))}});return t}function QF(e){return new Nm(e.map(t=>tg(t)?t.ast:new Fn(t)))}function c4(e){return j(QF(e),{parts:e})}function u4(e){return j(QF(e).asTemplateLiteralParser(),{parts:e})}function f4(e){return j(new Dm(Object.keys(e).filter(t=>typeof e[e[t]]!=\"number\").map(t=>[t,e[t]])),{enums:e})}var l4=j(kR),d4=j(RR),tw=j(Kr),Re=j(nE),nw=j(rE),X=j(Oo),uu=j(oE),ng=j(PR),XF=j(FR),Fo=j(UR),p4=j(OR),m4=j(_R);function h4(e){return j(new Lm(e))}function rw(e,t){return j(e,{fields:t,mapFields(n,r){let o=n(this.fields);return rw(zm(o,r?.unsafePreserveChecks?this.ast.checks:void 0),o)}})}function J(e){return rw(zm(e,void 0),e)}function g4(e){return on(t=>t.mapFields(OP(e)))}var x4=e=>typeof e==\"symbol\"?e:globalThis.String(e);function y4(e){return function(t){let n={},r=Object.create(null),o=Object.create(null),s=new Set;for(let i of Reflect.ownKeys(t.fields)){let a=cu(t.fields[i]),c=Object.hasOwn(e,i),u=c?e[i]:i,f=x4(u);if(s.has(f))throw new globalThis.Error(`Duplicate encoded keys: ${Bo(u)}`);s.add(f),F(n,u,a),c&&(r[i]=u,o[u]=i)}return J(n).pipe(ne(t,We({decode:UI(o),encode:UI(r)})))}}function b4(e,t){return n=>{let r=Wu(n.fields,Tn),o=J({...r,...e});return n.pipe(ne(o,We({decode:s=>{let i={...s};for(let a in e){let c=t[a],u=c(s);_e(u)&&F(i,a,u.value)}return i},encode:s=>{let i={...s};for(let a in e)delete i[a];return i}})))}}function Gl(e,t){return j(KR(e.ast,t.ast),{key:e,value:t})}function S4(e,t){return j(DR(e.ast,t.map(al)),{schema:e,records:t})}function eU(e,t){return j(e,{elements:t,mapElements(n,r){let o=n(this.elements);return eU(aE(o,r?.unsafePreserveChecks?this.ast.checks:void 0),o)}})}function ou(e){return eU(aE(e),e)}function E4(e,t){return j(NR(e.ast,t.map(al)),{schema:e,rest:t})}var Ye=on(e=>j(new Gr(!1,[],[e.ast]),{value:e}));var I4=on(e=>j(new Gr(!1,[e.ast],[e.ast]),{value:e}));function w4(e){return st([e,Ye(e)]).pipe(ne(Ye(Tn(e)),We({decode:iT,encode:t=>t.length===1?t[0]:t})))}function T4(e){return Ye(e).check(dw())}var v4=on(e=>j(new Gr(!0,e.ast.elements,e.ast.rest),{schema:e}));function tU(e,t){return j(e,{members:t,mapMembers(n,r){let o=n(this.members);return tU(Wm(o,this.ast.mode,r?.unsafePreserveChecks?this.ast.checks:void 0),o)}})}function st(e,t){return tU(Wm(e,t?.mode??\"anyOf\",void 0),e)}function io(e){let t=e.map(ze);return j(Wm(t,\"anyOf\",void 0),{literals:e,members:t,mapMembers(n){return st(n(this.members))},pick(n){return io(n)},transform(n){return st(t.map((r,o)=>r.transform(n[o])))}})}var ow=on(e=>st([e,Re])),rg=on(e=>st([e,nw])),nU=on(e=>st([e,Re,nw]));function rU(e){return j(new jm(()=>e().ast))}function A4(...e){return t=>t.check(...e)}function C4(e,t){return n=>j(ko(n.ast,[Km(e,t)]),{schema:n})}function oU(e){return t=>j(WR(t.ast,e),{schema:t,identifier:e})}function O4(e,t){return n=>(t.checks?n.check(...t.checks):n).pipe(oU(e))}function sU(e){return t=>j(qR(t.ast,new rl(e,_)),{schema:t})}function iU(e){return t=>j(zR(t.ast,new rl(_,e)),{schema:t})}function k4(e){return aU(e)}function aU(e){return t=>sU(vc(e))(t)}function R4(e){return cU(e)}function cU(e){return t=>iU(vc(e))(t)}function ne(e,t){return n=>j($c(n.ast,e.ast,t?Mm(t):Rc()),{from:n,to:e})}function _4(e){return t=>ne(Tn(t),e)(t)}function sw(e,t){return n=>t?ne(n,t)(e):ne(n)(e)}function M4(e){return t=>ne(t,e)(cu(t))}function uU(e){return t=>j(JR(t.ast,e),{schema:t})}function fU(e){return ft(e,t=>cs(()=>ns(t,n=>n.issue)))}function iw(e,t){let n=t?.encodingStrategy===\"omit\"?TS():Sn();return r=>Oa(cu(r)).pipe(ne(r,{decode:el(fU(e)),encode:n}))}function P4(e,t){return n=>Tn(n).pipe(iw(e,t),sw(Oa(n)))}function lU(e,t){let n=t?.encodingStrategy===\"omit\"?TS():Sn();return r=>Ar(cu(r)).pipe(ne(r,{decode:el(fU(e)),encode:n}))}function F4(e,t){return n=>Tn(n).pipe(lU(e,t),sw(Ar(n)))}function ka(e){return ze(e).pipe(uU(x(e)))}function U4(e){return ka(e).pipe(iw(x(e),{encodingStrategy:\"omit\"}))}function og(e,t){return J({_tag:ka(e),...t})}function dU(e){return t=>{let n={},r=[],o=new Set,s={},i=f=>d=>f.includes(d[e]);return a(t),Object.assign(t,{cases:n,discriminants:r,isAnyOf:i,guards:s,match:c,matchOrElse:u});function a(f){let d=f.ast;if(AR(d)&&\"members\"in f&&globalThis.Array.isArray(f.members)&&f.members.every(tg))return f.members.forEach(a);let p=Fc(d);if(p.length>0){let m=p.find(g=>g.key===e)?.literal;if(vd(m)){let g=typeof m==\"number\"?globalThis.String(m):m;if(o.has(g))throw new globalThis.Error(`Duplicate discriminant: ${globalThis.String(m)}`);o.add(g),r.push(m),F(n,m,f),F(s,m,LF(Tn(f)));return}}throw new globalThis.Error(\"No literal or unique symbol found\")}function c(){if(arguments.length===1){let g=arguments[0];return function(b){let I=b[e];return(Object.hasOwn(g,I)?g[I]:void 0)(b)}}let f=arguments[0],d=arguments[1],p=f[e];return(Object.hasOwn(d,p)?d[p]:void 0)(f)}function u(){if(arguments.length===2){let b=arguments[0],I=arguments[1];return function(w){let E=w[e];return(Object.hasOwn(b,E)?b[E]??I:I)(w)}}let f=arguments[0],d=arguments[1],p=arguments[2],m=f[e];return(Object.hasOwn(d,m)?d[m]??p:p)(f)}}}function D4(e){let t={},n=[];for(let c of Object.keys(e)){let u=og(c,e[c]);F(t,c,u),n.push(u)}let r=st(n),{guards:o,isAnyOf:s,match:i,matchOrElse:a}=dU(\"_tag\")(r);return j(r.ast,{cases:t,isAnyOf:s,guards:o,match:i,matchOrElse:a})}function N4(){return e=>e}function oi(e,t){return Cr(n=>n instanceof e,t)}function $e(){return(e,t)=>new Je(e.ast,Mm(t))}var je=Jm;function L4(e,t=void 0){return new ca(e,t)}function sn(e,t){return wn(e,Re,({annotations:n})=>n===void 0?t:t.annotate(n))}var vF=\"^\\\\S[\\\\s\\\\S]*\\\\S$|^\\\\S$|^$\";function aw(e){let t=new globalThis.RegExp(vF);return je(n=>n.trim()===n,{expected:\"a string with no leading or trailing whitespace\",representation:{id:\"effect/schema/isTrimmed\",payload:null},toJsonSchema:()=>({pattern:t.source}),toCode:()=>({runtime:\"Schema.isTrimmed()\"}),arbitrary:{constraint:{patterns:[vF]}},...e})}var $4=fe(\"effect/schema/isTrimmed\",Re,({annotations:e})=>aw(e));function Ra(e,t){let n=e.source,r=e.flags,o=r===\"\"?`new RegExp(${N(n)})`:`new RegExp(${N(n)}, ${N(r)})`;return ul(e,{toCode:()=>({runtime:`Schema.isPattern(${o})`}),...t})}var j4=J({source:X,flags:X}).check(je(e=>{let t=X0(()=>new globalThis.RegExp(e.source,e.flags));return Tt(t)&&t.success.source===e.source&&t.success.flags===e.flags})),B4={id:\"effect/schema/isPattern\",payloadSchema:j4,revive:({annotations:e,payload:t})=>Ra(new globalThis.RegExp(t.source,t.flags),e)};function pU(e){return gE({toCode:()=>({runtime:\"Schema.isStringFinite()\"}),...e})}var q4=fe(\"effect/schema/isStringFinite\",Re,({annotations:e})=>pU(e));function mU(e){return xE({toCode:()=>({runtime:\"Schema.isStringBigInt()\"}),...e})}var z4=fe(\"effect/schema/isStringBigInt\",Re,({annotations:e})=>mU(e));function hU(e){return bE({toCode:()=>({runtime:\"Schema.isStringSymbol()\"}),...e})}var W4=fe(\"effect/schema/isStringSymbol\",Re,({annotations:e})=>hU(e)),H4=e=>e?new globalThis.RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${e}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`):/^([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|[fF]{8}-[fF]{4}-[fF]{4}-[fF]{4}-[fF]{12})$/;function gU(e,t){let n=H4(e);return Ra(n,{expected:e?`a UUID v${e}`:\"a UUID\",representation:{id:\"effect/schema/isUUID\",payload:{version:e??null}},toJsonSchema:()=>({pattern:n.source,format:\"uuid\"}),toCode:()=>({runtime:e===void 0?\"Schema.isUUID()\":`Schema.isUUID(${e})`}),...t})}var J4=fe(\"effect/schema/isUUID\",J({version:st([io([1,2,3,4,5,6,7,8]),Re])}),({annotations:e,payload:t})=>gU(t.version??void 0,e)),AF=/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/;function xU(e){return Ra(AF,{expected:\"a GUID\",representation:{id:\"effect/schema/isGUID\",payload:null},toJsonSchema:()=>({pattern:AF.source}),toCode:()=>({runtime:\"Schema.isGUID()\"}),...e})}var K4=fe(\"effect/schema/isGUID\",Re,({annotations:e})=>xU(e));function yU(e){let t=/^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/;return Ra(t,{representation:{id:\"effect/schema/isULID\",payload:null},toJsonSchema:()=>({pattern:t.source}),toCode:()=>({runtime:\"Schema.isULID()\"}),...e})}var G4=fe(\"effect/schema/isULID\",Re,({annotations:e})=>yU(e));function cw(e){let t=/^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;return Ra(t,{expected:\"a base64 encoded string\",representation:{id:\"effect/schema/isBase64\",payload:null},toJsonSchema:()=>({pattern:t.source}),toCode:()=>({runtime:\"Schema.isBase64()\"}),...e})}var V4=fe(\"effect/schema/isBase64\",Re,({annotations:e})=>cw(e));function bU(e){let t=/^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/;return Ra(t,{expected:\"a base64url encoded string\",representation:{id:\"effect/schema/isBase64Url\",payload:null},toJsonSchema:()=>({pattern:t.source}),toCode:()=>({runtime:\"Schema.isBase64Url()\"}),...e})}var Z4=fe(\"effect/schema/isBase64Url\",Re,({annotations:e})=>bU(e));function SU(e,t){let n=JSON.stringify(e),r=new globalThis.RegExp(`^${Xc(e)}`);return je(o=>o.startsWith(e),{expected:`a string starting with ${n}`,representation:{id:\"effect/schema/isStartsWith\",payload:{startsWith:e}},toJsonSchema:()=>({pattern:r.source}),toCode:()=>({runtime:`Schema.isStartsWith(${N(e)})`}),arbitrary:{constraint:{patterns:[r.source]}},...t})}var Y4=fe(\"effect/schema/isStartsWith\",J({startsWith:X}),({annotations:e,payload:t})=>SU(t.startsWith,e));function EU(e,t){let n=JSON.stringify(e),r=new globalThis.RegExp(`${Xc(e)}$`);return je(o=>o.endsWith(e),{expected:`a string ending with ${n}`,representation:{id:\"effect/schema/isEndsWith\",payload:{endsWith:e}},toJsonSchema:()=>({pattern:r.source}),toCode:()=>({runtime:`Schema.isEndsWith(${N(e)})`}),arbitrary:{constraint:{patterns:[r.source]}},...t})}var Q4=fe(\"effect/schema/isEndsWith\",J({endsWith:X}),({annotations:e,payload:t})=>EU(t.endsWith,e));function IU(e,t){let n=JSON.stringify(e),r=new globalThis.RegExp(Xc(e));return je(o=>o.includes(e),{expected:`a string including ${n}`,representation:{id:\"effect/schema/isIncludes\",payload:{includes:e}},toJsonSchema:()=>({pattern:r.source}),toCode:()=>({runtime:`Schema.isIncludes(${N(e)})`}),arbitrary:{constraint:{patterns:[r.source]}},...t})}var X4=fe(\"effect/schema/isIncludes\",J({includes:X}),({annotations:e,payload:t})=>IU(t.includes,e)),CF=\"^[^a-z]*$\";function wU(e){let t=new globalThis.RegExp(CF);return je(n=>n.toUpperCase()===n,{expected:\"a string with all characters in uppercase\",representation:{id:\"effect/schema/isUppercased\",payload:null},toJsonSchema:()=>({pattern:t.source}),toCode:()=>({runtime:\"Schema.isUppercased()\"}),arbitrary:{constraint:{patterns:[CF]}},...e})}var e3=fe(\"effect/schema/isUppercased\",Re,({annotations:e})=>wU(e)),OF=\"^[^A-Z]*$\";function TU(e){let t=new globalThis.RegExp(OF);return je(n=>n.toLowerCase()===n,{expected:\"a string with all characters in lowercase\",representation:{id:\"effect/schema/isLowercased\",payload:null},toJsonSchema:()=>({pattern:t.source}),toCode:()=>({runtime:\"Schema.isLowercased()\"}),arbitrary:{constraint:{patterns:[OF]}},...e})}var t3=fe(\"effect/schema/isLowercased\",Re,({annotations:e})=>TU(e)),kF=\"^[^a-z]?.*$\";function vU(e){let t=new globalThis.RegExp(kF);return je(n=>n.charAt(0).toUpperCase()===n.charAt(0),{expected:\"a string with the first character in uppercase\",representation:{id:\"effect/schema/isCapitalized\",payload:null},toJsonSchema:()=>({pattern:t.source}),toCode:()=>({runtime:\"Schema.isCapitalized()\"}),arbitrary:{constraint:{patterns:[kF]}},...e})}var n3=fe(\"effect/schema/isCapitalized\",Re,({annotations:e})=>vU(e)),RF=\"^[^A-Z]?.*$\";function AU(e){let t=new globalThis.RegExp(RF);return je(n=>n.charAt(0).toLowerCase()===n.charAt(0),{expected:\"a string with the first character in lowercase\",representation:{id:\"effect/schema/isUncapitalized\",payload:null},toJsonSchema:()=>({pattern:t.source}),toCode:()=>({runtime:\"Schema.isUncapitalized()\"}),arbitrary:{constraint:{patterns:[RF]}},...e})}var r3=fe(\"effect/schema/isUncapitalized\",Re,({annotations:e})=>AU(e)),Xn=j(uE),CU=cE,o3=fe(\"effect/schema/isFinite\",Re,({annotations:e})=>CU(e));function Vl(e){let t=Os(e.order),n=e.formatter??N;return(r,o)=>je(s=>t(s,r),{expected:`a value greater than ${n(r)}`,arbitrary:{constraint:{ordered:{order:e.order,minimum:r,exclusiveMinimum:!0}}},...e.annotate?.(r),...o})}function Zl(e){let t=ju(e.order),n=e.formatter??N;return(r,o)=>je(s=>t(s,r),{expected:`a value greater than or equal to ${n(r)}`,arbitrary:{constraint:{ordered:{order:e.order,minimum:r}}},...e.annotate?.(r),...o})}function Yl(e){let t=Xa(e.order),n=e.formatter??N;return(r,o)=>je(s=>t(s,r),{expected:`a value less than ${n(r)}`,arbitrary:{constraint:{ordered:{order:e.order,maximum:r,exclusiveMaximum:!0}}},...e.annotate?.(r),...o})}function Ql(e){let t=$u(e.order),n=e.formatter??N;return(r,o)=>je(s=>t(s,r),{expected:`a value less than or equal to ${n(r)}`,arbitrary:{constraint:{ordered:{order:e.order,maximum:r}}},...e.annotate?.(r),...o})}function Xl(e){let t=ju(e.order),n=Os(e.order),r=$u(e.order),o=Xa(e.order),s=e.formatter??N;return(i,a)=>{let c=i.exclusiveMinimum?n:t,u=i.exclusiveMaximum?o:r;return je(f=>c(f,i.minimum)&&u(f,i.maximum),{expected:`a value between ${s(i.minimum)}${i.exclusiveMinimum?\" (excluded)\":\"\"} and ${s(i.maximum)}${i.exclusiveMaximum?\" (excluded)\":\"\"}`,arbitrary:{constraint:{ordered:{order:e.order,minimum:i.minimum,maximum:i.maximum,...i.exclusiveMinimum&&{exclusiveMinimum:!0},...i.exclusiveMaximum&&{exclusiveMaximum:!0}}}},...e.annotate?.(i),...a})}}function OU(e){return(t,n)=>{let r=e.formatter??N;return je(o=>e.remainder(o,t)===e.zero,{expected:`a value that is a multiple of ${r(t)}`,...e.annotate?.(t),...n})}}function su(e){if(!globalThis.Number.isFinite(e))throw new globalThis.RangeError(`Expected a finite number, got ${N(e)}`);return e}var kU=Vl({order:Bn,annotate:e=>({representation:{id:\"effect/schema/isGreaterThan\",payload:{exclusiveMinimum:su(e)}},toJsonSchema:()=>({exclusiveMinimum:e}),toCode:()=>({runtime:`Schema.isGreaterThan(${N(e)})`})})}),s3=fe(\"effect/schema/isGreaterThan\",J({exclusiveMinimum:Xn}),({annotations:e,payload:t})=>kU(t.exclusiveMinimum,e)),uw=Zl({order:Bn,annotate:e=>({representation:{id:\"effect/schema/isGreaterThanOrEqualTo\",payload:{minimum:su(e)}},toJsonSchema:()=>({minimum:e}),toCode:()=>({runtime:`Schema.isGreaterThanOrEqualTo(${N(e)})`})})}),i3=fe(\"effect/schema/isGreaterThanOrEqualTo\",J({minimum:Xn}),({annotations:e,payload:t})=>uw(t.minimum,e)),RU=Yl({order:Bn,annotate:e=>({representation:{id:\"effect/schema/isLessThan\",payload:{exclusiveMaximum:su(e)}},toJsonSchema:()=>({exclusiveMaximum:e}),toCode:()=>({runtime:`Schema.isLessThan(${N(e)})`})})}),a3=fe(\"effect/schema/isLessThan\",J({exclusiveMaximum:Xn}),({annotations:e,payload:t})=>RU(t.exclusiveMaximum,e)),_U=Ql({order:Bn,annotate:e=>({representation:{id:\"effect/schema/isLessThanOrEqualTo\",payload:{maximum:su(e)}},toJsonSchema:()=>({maximum:e}),toCode:()=>({runtime:`Schema.isLessThanOrEqualTo(${N(e)})`})})}),c3=fe(\"effect/schema/isLessThanOrEqualTo\",J({maximum:Xn}),({annotations:e,payload:t})=>_U(t.maximum,e)),fu=Xl({order:Bn,annotate:e=>{let t=e.exclusiveMinimum?!0:void 0,n=e.exclusiveMaximum?!0:void 0;return{representation:{id:\"effect/schema/isBetween\",payload:{minimum:su(e.minimum),maximum:su(e.maximum),...t&&{exclusiveMinimum:t},...n&&{exclusiveMaximum:n}}},toJsonSchema:()=>({[t?\"exclusiveMinimum\":\"minimum\"]:e.minimum,[n?\"exclusiveMaximum\":\"maximum\"]:e.maximum}),toCode:()=>({runtime:`Schema.isBetween({ minimum: ${N(e.minimum)}, maximum: ${N(e.maximum)}, exclusiveMinimum: ${N(t)}, exclusiveMaximum: ${N(n)} })`})}}}),u3=fe(\"effect/schema/isBetween\",J({minimum:Xn,maximum:Xn,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>fu(t,e)),MU=OU({remainder:CO,zero:0,annotate:e=>({expected:`a value that is a multiple of ${e}`,representation:{id:\"effect/schema/isMultipleOf\",payload:{divisor:e}},toJsonSchema:()=>({multipleOf:e}),toCode:()=>({runtime:`Schema.isMultipleOf(${N(e)})`})})}),f3=fe(\"effect/schema/isMultipleOf\",J({divisor:Xn}),({annotations:e,payload:t})=>MU(t.divisor,e));function ed(e){return je(t=>globalThis.Number.isSafeInteger(t),{expected:\"an integer\",representation:{id:\"effect/schema/isInt\",payload:null},toJsonSchema:()=>({type:\"integer\"}),toCode:()=>({runtime:\"Schema.isInt()\"}),arbitrary:{constraint:{integer:!0}},...e})}var l3=fe(\"effect/schema/isInt\",Re,({annotations:e})=>ed(e)),Uo=uu.check(ed()),Gt=Uo.check(uw(0));function d3(e){return new ca([ed(),fu({minimum:-2147483648,maximum:2147483647})],{expected:\"a 32-bit integer\",...e})}function p3(e){return new ca([ed(),fu({minimum:0,maximum:4294967295})],{expected:\"a 32-bit unsigned integer\",...e})}function iu(e){if(globalThis.Number.isNaN(e.getTime()))throw new globalThis.RangeError(`Expected a valid Date, got ${N(e)}`);return e.toISOString()}function au(e){return`new Date(${N(e.getTime())})`}var PU=Vl({order:wi,annotate:e=>({representation:{id:\"effect/schema/isGreaterThanDate\",payload:{exclusiveMinimum:iu(e)}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isGreaterThanDate(${au(e)})`})})}),FU=Zl({order:wi,annotate:e=>({representation:{id:\"effect/schema/isGreaterThanOrEqualToDate\",payload:{minimum:iu(e)}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isGreaterThanOrEqualToDate(${au(e)})`})})}),UU=Yl({order:wi,annotate:e=>({representation:{id:\"effect/schema/isLessThanDate\",payload:{exclusiveMaximum:iu(e)}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isLessThanDate(${au(e)})`})})}),DU=Ql({order:wi,annotate:e=>({representation:{id:\"effect/schema/isLessThanOrEqualToDate\",payload:{maximum:iu(e)}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isLessThanOrEqualToDate(${au(e)})`})})}),NU=Xl({order:wi,annotate:e=>{let t=e.exclusiveMinimum?!0:void 0,n=e.exclusiveMaximum?!0:void 0;return{representation:{id:\"effect/schema/isBetweenDate\",payload:{minimum:iu(e.minimum),maximum:iu(e.maximum),...t&&{exclusiveMinimum:t},...n&&{exclusiveMaximum:n}}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isBetweenDate({ minimum: ${au(e.minimum)}, maximum: ${au(e.maximum)}, exclusiveMinimum: ${N(t)}, exclusiveMaximum: ${N(n)} })`})}}}),LU=Vl({order:ur,annotate:e=>({representation:{id:\"effect/schema/isGreaterThanBigInt\",payload:{exclusiveMinimum:e.toString(10)}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isGreaterThanBigInt(${N(e)})`})})}),$U=Zl({order:ur,annotate:e=>({representation:{id:\"effect/schema/isGreaterThanOrEqualToBigInt\",payload:{minimum:e.toString(10)}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isGreaterThanOrEqualToBigInt(${N(e)})`})})}),jU=Yl({order:ur,annotate:e=>({representation:{id:\"effect/schema/isLessThanBigInt\",payload:{exclusiveMaximum:e.toString(10)}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isLessThanBigInt(${N(e)})`})})}),BU=Ql({order:ur,annotate:e=>({representation:{id:\"effect/schema/isLessThanOrEqualToBigInt\",payload:{maximum:e.toString(10)}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isLessThanOrEqualToBigInt(${N(e)})`})})}),qU=Xl({order:ur,annotate:e=>{let t=e.exclusiveMinimum?!0:void 0,n=e.exclusiveMaximum?!0:void 0;return{representation:{id:\"effect/schema/isBetweenBigInt\",payload:{minimum:e.minimum.toString(10),maximum:e.maximum.toString(10),...t&&{exclusiveMinimum:t},...n&&{exclusiveMaximum:n}}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isBetweenBigInt({ minimum: ${N(e.minimum)}, maximum: ${N(e.maximum)}, exclusiveMinimum: ${N(t)}, exclusiveMaximum: ${N(n)} })`})}}}),m3=Vl({order:Jo,formatter:e=>mo(e)}),h3=Zl({order:Jo,formatter:e=>mo(e)}),g3=Yl({order:Jo,formatter:e=>mo(e)}),x3=Ql({order:Jo,formatter:e=>mo(e)}),y3=Xl({order:Jo,formatter:e=>mo(e)});function fw(e,t){return e=Math.max(0,Math.floor(e)),je(n=>n.length>=e,{expected:`a value with a length of at least ${e}`,representation:{id:\"effect/schema/isMinLength\",payload:{minLength:e}},toJsonSchema:({type:n})=>n===\"array\"?{minItems:e}:{minLength:e},toCode:()=>({runtime:`Schema.isMinLength(${e})`}),[Mn]:!0,arbitrary:{constraint:{minLength:e}},...t})}var b3=fe(\"effect/schema/isMinLength\",J({minLength:Gt}),({annotations:e,payload:t})=>fw(t.minLength,e));function zU(e){return fw(1,e)}function WU(e,t){return e=Math.max(0,Math.floor(e)),je(n=>n.length<=e,{expected:`a value with a length of at most ${e}`,representation:{id:\"effect/schema/isMaxLength\",payload:{maxLength:e}},toJsonSchema:({type:n})=>n===\"array\"?{maxItems:e}:{maxLength:e},toCode:()=>({runtime:`Schema.isMaxLength(${e})`}),[Mn]:!0,arbitrary:{constraint:{maxLength:e}},...t})}var S3=fe(\"effect/schema/isMaxLength\",J({maxLength:Gt}),({annotations:e,payload:t})=>WU(t.maxLength,e));function lw(e,t,n){return e=Math.max(0,Math.floor(e)),t=Math.max(0,Math.floor(t)),je(r=>r.length>=e&&r.length<=t,{expected:e===t?`a value with a length of ${e}`:`a value with a length between ${e} and ${t}`,representation:{id:\"effect/schema/isLengthBetween\",payload:{minimum:e,maximum:t}},toJsonSchema:({type:r})=>r===\"array\"?{allOf:[{minItems:e},{maxItems:t}]}:{allOf:[{minLength:e},{maxLength:t}]},toCode:()=>({runtime:`Schema.isLengthBetween(${e}, ${t})`}),[Mn]:!0,arbitrary:{constraint:{minLength:e,maxLength:t}},...n})}var E3=fe(\"effect/schema/isLengthBetween\",J({minimum:Gt,maximum:Gt}),({annotations:e,payload:t})=>lw(t.minimum,t.maximum,e));function HU(e,t){return e=Math.max(0,Math.floor(e)),je(n=>n.size>=e,{expected:`a value with a size of at least ${e}`,representation:{id:\"effect/schema/isMinSize\",payload:{minSize:e}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isMinSize(${e})`}),[Mn]:!0,arbitrary:{constraint:{minLength:e}},...t})}var I3=fe(\"effect/schema/isMinSize\",J({minSize:Gt}),({annotations:e,payload:t})=>HU(t.minSize,e));function JU(e,t){return e=Math.max(0,Math.floor(e)),je(n=>n.size<=e,{expected:`a value with a size of at most ${e}`,representation:{id:\"effect/schema/isMaxSize\",payload:{maxSize:e}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isMaxSize(${e})`}),[Mn]:!0,arbitrary:{constraint:{maxLength:e}},...t})}var w3=fe(\"effect/schema/isMaxSize\",J({maxSize:Gt}),({annotations:e,payload:t})=>JU(t.maxSize,e));function KU(e,t,n){return e=Math.max(0,Math.floor(e)),t=Math.max(0,Math.floor(t)),je(r=>r.size>=e&&r.size<=t,{expected:e===t?`a value with a size of ${e}`:`a value with a size between ${e} and ${t}`,representation:{id:\"effect/schema/isSizeBetween\",payload:{minimum:e,maximum:t}},toJsonSchema:()=>({}),toCode:()=>({runtime:`Schema.isSizeBetween(${e}, ${t})`}),[Mn]:!0,arbitrary:{constraint:{minLength:e,maxLength:t}},...n})}var T3=fe(\"effect/schema/isSizeBetween\",J({minimum:Gt,maximum:Gt}),({annotations:e,payload:t})=>KU(t.minimum,t.maximum,e));function GU(e,t){return e=Math.max(0,Math.floor(e)),je(n=>Reflect.ownKeys(n).length>=e,{expected:`a value with at least ${e===1?\"1 entry\":`${e} entries`}`,representation:{id:\"effect/schema/isMinProperties\",payload:{minProperties:e}},toJsonSchema:()=>({minProperties:e}),toCode:()=>({runtime:`Schema.isMinProperties(${e})`}),[Mn]:!0,arbitrary:{constraint:{minLength:e}},...t})}var v3=fe(\"effect/schema/isMinProperties\",J({minProperties:Gt}),({annotations:e,payload:t})=>GU(t.minProperties,e));function VU(e,t){return e=Math.max(0,Math.floor(e)),je(n=>Reflect.ownKeys(n).length<=e,{expected:`a value with at most ${e===1?\"1 entry\":`${e} entries`}`,representation:{id:\"effect/schema/isMaxProperties\",payload:{maxProperties:e}},toJsonSchema:()=>({maxProperties:e}),toCode:()=>({runtime:`Schema.isMaxProperties(${e})`}),[Mn]:!0,arbitrary:{constraint:{maxLength:e}},...t})}var A3=fe(\"effect/schema/isMaxProperties\",J({maxProperties:Gt}),({annotations:e,payload:t})=>VU(t.maxProperties,e));function ZU(e,t,n){return e=Math.max(0,Math.floor(e)),t=Math.max(0,Math.floor(t)),je(r=>Reflect.ownKeys(r).length>=e&&Reflect.ownKeys(r).length<=t,{expected:e===t?`a value with exactly ${e===1?\"1 entry\":`${e} entries`}`:`a value with between ${e} and ${t} entries`,representation:{id:\"effect/schema/isPropertiesLengthBetween\",payload:{minimum:e,maximum:t}},toJsonSchema:()=>({minProperties:e,maxProperties:t}),toCode:()=>({runtime:`Schema.isPropertiesLengthBetween(${e}, ${t})`}),[Mn]:!0,arbitrary:{constraint:{minLength:e,maxLength:t}},...n})}var C3=fe(\"effect/schema/isPropertiesLengthBetween\",J({minimum:Gt,maximum:Gt}),({annotations:e,payload:t})=>ZU(t.minimum,t.maximum,e));function YU(e,t){let n=cu(e),r=lP(n.ast);return je((o,s,i)=>{let a=Reflect.ownKeys(o),c=[];for(let u of a){let f=r(u,i);if(f!==void 0&&(c.push(new Ve([u],f)),i.errors===\"first\"))break}return qt(c)?new nt(s,c,o,i):!0},{expected:\"an object with property names matching the schema\",representation:{id:\"effect/schema/isPropertyNames\",payload:null,schemas:[n.ast]},toJsonSchema:({schemas:o})=>({propertyNames:o[0]}),toCode:({schemas:o})=>({runtime:`Schema.isPropertyNames(${o[0].runtime})`}),[Mn]:!0,...t})}var O3=fe(\"effect/schema/isPropertyNames\",Re,({annotations:e,schemas:t})=>YU(t[0],e));function dw(e){return je(t=>Zd(t).length===t.length,{expected:\"an array with unique items\",representation:{id:\"effect/schema/isUnique\",payload:null},toJsonSchema:()=>({uniqueItems:!0}),toCode:()=>({runtime:\"Schema.isUnique()\"}),arbitrary:{constraint:{unique:!0}},...e})}var k3=fe(\"effect/schema/isUnique\",Re,({annotations:e})=>dw(e)),pw=X.check(zU()),R3=X.check(lw(1,1));function si(e){let t=Dn()([e],([n])=>(r,o,s)=>ec(r)?ae(r)?as:Ut(Ze(n)(r.value,s),{onSuccess:k,onFailure:i=>Pn(o,\"value\",i,r,s)}):P(new He(o,r,s)),{representation:{id:\"effect/schema/Option\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.Option(${n[0].runtime})`,Type:`Option.Option<${n[0].Type}>`,importDeclarations:['import * as Option from \"effect/Option\"']}),expected:\"Option\",toCodec:([n])=>$e()(st([J({_tag:ze(\"Some\"),value:n}),J({_tag:ze(\"None\")})]),We({decode:r=>r._tag===\"None\"?R():k(r.value),encode:r=>_e(r)?{_tag:\"Some\",value:r.value}:{_tag:\"None\"}})),toArbitrary:([n])=>(r,o)=>{let s=r.constant(R()),i=r.oneof(s,n.arbitrary.map(k));return _a(r,o,s,i)},toEquivalence:([n])=>tx(n),toFormatter:([n])=>fr({onNone:()=>\"none()\",onSome:r=>`some(${n(r)})`})});return j(t.ast,{value:e})}var _3=wn(\"effect/schema/Option\",Re,({annotations:e,typeParameters:t})=>{let n=si(t[0]);return e===void 0?n:n.annotate(e)});function M3(e){return ow(e).pipe(ne(si(Tn(e)),sR()))}function P3(e){return rg(e).pipe(ne(si(Tn(e)),iR()))}function F3(e,t){return nU(e).pipe(ne(si(Tn(e)),aR(t)))}function U3(e){return Oa(e).pipe(ne(si(Tn(e)),cR()))}function D3(e){return Ar(e).pipe(ne(si(Tn(e)),uR()))}function N3(e,t){let n=t===void 0?\"omit\":t.onNoneEncoding,r=n===null?null:void 0;return Ar(ow(e)).pipe(ne(si(Tn(e)),Pm({decode:o=>o.pipe(Ci(Ad),k),encode:n===\"omit\"?Ai:o=>k(Bu(Ai(o),()=>r))})))}function QU(e,t){let n=Dn()([e,t],([r,o])=>(s,i,a)=>{if(!eT(s))return P(new He(i,s,a));switch(s._tag){case\"Success\":return Ut(kI(r)(s.success,a),{onSuccess:se,onFailure:c=>Pn(i,\"success\",c,s,a)});case\"Failure\":return Ut(kI(o)(s.failure,a),{onSuccess:re,onFailure:c=>Pn(i,\"failure\",c,s,a)})}},{representation:{id:\"effect/schema/Result\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.Result(${r[0].runtime}, ${r[1].runtime})`,Type:`Result.Result<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Result from \"effect/Result\"']}),expected:\"Result\",toCodec:([r,o])=>$e()(st([J({_tag:ze(\"Success\"),success:r}),J({_tag:ze(\"Failure\"),failure:o})]),We({decode:s=>s._tag===\"Success\"?se(s.success):re(s.failure),encode:s=>Tt(s)?{_tag:\"Success\",success:s.success}:{_tag:\"Failure\",failure:s.failure}})),toArbitrary:([r,o])=>(s,i)=>{let a=uD(s,r.terminal?.map(u=>se(u)),o.terminal?.map(u=>re(u))),c=s.oneof(r.arbitrary.map(u=>se(u)),o.arbitrary.map(u=>re(u)));return _a(s,i,a,c)},toEquivalence:([r,o])=>tT(r,o),toFormatter:([r,o])=>lr({onSuccess:s=>`success(${r(s)})`,onFailure:s=>`failure(${o(s)})`})});return j(n.ast,{success:e,failure:t})}var L3=wn(\"effect/schema/Result\",Re,({annotations:e,typeParameters:t})=>{let n=QU(t[0],t[1]);return e===void 0?n:n.annotate(e)}),$3=Cr(e=>{if(!wt(e))return!1;let t=globalThis.Object.keys(e);return t.length>0&&t.every(n=>{switch(n){case\"label\":return typeof e[n]==\"string\";case\"disallowJsonEncode\":return e[n]===!0;default:return!1}})}),j3=st([Re,$3]);function td(e,t){let n=typeof t?.label==\"string\"?t.label:void 0,r=t?.disallowJsonEncode===!0,o=n!==void 0?r?{label:n,disallowJsonEncode:!0}:{label:n}:r?{disallowJsonEncode:!0}:void 0,s=n!==void 0?Ze(ze(n)):void 0,i=Dn()([e],([a])=>(c,u,f)=>{if(VI(c)){let d=s!==void 0?fs(s(c.label,f),p=>new Ve([\"label\"],p)):te;return lt(d,()=>Ut(Ze(a)(nu(c),f),{onSuccess:()=>c,onFailure:()=>new nt(u,[new Ve([\"value\"],new ge(void 0,c,f))],c,f)}))}return P(new He(u,c,f))},{representation:{id:\"effect/schema/Redacted\",payload:o??null},toCode:({typeParameters:a})=>({runtime:o!==void 0?`Schema.Redacted(${a[0].runtime}, ${N(o)})`:`Schema.Redacted(${a[0].runtime})`,Type:`Redacted.Redacted<${a[0].Type}>`,importDeclarations:['import * as Redacted from \"effect/Redacted\"']}),expected:\"Redacted\",toCodecJson:([a])=>$e()(a,{decode:ue(c=>Wl(c,{label:n})),encode:r?IS(c=>\"Cannot serialize Redacted\"+(_e(c)&&typeof c.value.label==\"string\"?` with label: \"${c.value.label}\"`:\"\")):ue(nu)}),toArbitrary:([a])=>()=>({arbitrary:a.arbitrary.map(c=>Wl(c,{label:n})),terminal:a.terminal?.map(c=>Wl(c,{label:n}))}),toFormatter:()=>globalThis.String,toEquivalence:([a])=>EF(a)});return j(i.ast,{value:e})}var B3=wn(\"effect/schema/Redacted\",j3,({annotations:e,payload:t,typeParameters:n})=>{let r=td(n[0],t??void 0);return e===void 0?r:r.annotate(e)});function q3(e,t){return ne(td(Tn(e),{label:t?.label,disallowJsonEncode:t?.disallowEncode}),{decode:ue(n=>Wl(n,{label:t?.label})),encode:t?.disallowEncode?IS(n=>\"Cannot encode Redacted\"+(_e(n)&&typeof n.value.label==\"string\"?` with label: \"${n.value.label}\"`:\"\")):ue(nu)})(e)}function Zh(e,t){let n=Dn()([e,t],([r,o])=>(s,i,a)=>{if(!tb(s))return P(new He(i,s,a));switch(s._tag){case\"Fail\":return Ut(Ze(r)(s.error,a),{onSuccess:If,onFailure:c=>Pn(i,\"error\",c,s,a)});case\"Die\":return Ut(Ze(o)(s.defect,a),{onSuccess:wf,onFailure:c=>Pn(i,\"defect\",c,s,a)});case\"Interrupt\":return x(s)}},{representation:{id:\"effect/schema/CauseReason\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.CauseReason(${r[0].runtime}, ${r[1].runtime})`,Type:`Cause.Failure<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Cause from \"effect/Cause\"']}),expected:\"Cause.Failure\",toCodec:([r,o])=>$e()(st([J({_tag:ze(\"Fail\"),error:r}),J({_tag:ze(\"Die\"),defect:o}),J({_tag:ze(\"Interrupt\"),fiberId:rg(Xn)})]),We({decode:s=>{switch(s._tag){case\"Fail\":return If(s.error);case\"Die\":return wf(s.defect);case\"Interrupt\":return Tf(s.fiberId)}},encode:_})),toArbitrary:([r,o])=>XU(r,o),toEquivalence:([r,o])=>eD(r,o),toFormatter:([r,o])=>tD(r,o)});return j(n.ast,{error:e,defect:t})}var z3=wn(\"effect/schema/CauseReason\",Re,({annotations:e,typeParameters:t})=>{let n=Zh(t[0],t[1]);return e===void 0?n:n.annotate(e)});function XU(e,t){return(n,r)=>{let o=n.constant(Tf()),s=n.oneof(o,n.integer({min:1}).map(Tf),e.arbitrary.map(i=>If(i)),t.arbitrary.map(i=>wf(i)));return _a(n,r,o,s)}}function eD(e,t){return(n,r)=>{if(n._tag!==r._tag)return!1;switch(n._tag){case\"Fail\":return e(n.error,r.error);case\"Die\":return t(n.defect,r.defect);case\"Interrupt\":return n.fiberId===r.fiberId}}}function tD(e,t){return n=>{switch(n._tag){case\"Fail\":return`Fail(${e(n.error)})`;case\"Die\":return`Die(${t(n.defect)})`;case\"Interrupt\":return\"Interrupt\"}}}function Yh(e,t){let n=Dn()([e,t],([r,o])=>{let s=Ye(Zh(r,o));return(i,a,c)=>eb(i)?Ut(Ze(s)(i.reasons,c),{onSuccess:Bi,onFailure:u=>Pn(a,\"failures\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/Cause\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.Cause(${r[0].runtime}, ${r[1].runtime})`,Type:`Cause.Cause<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as Cause from \"effect/Cause\"']}),expected:\"Cause\",toCodec:([r,o])=>$e()(Ye(Zh(r,o)),We({decode:Bi,encode:({reasons:s})=>s})),toArbitrary:([r,o])=>nD(r,o),toEquivalence:([r,o])=>rD(r,o),toFormatter:([r,o])=>oD(r,o)});return j(n.ast,{error:e,defect:t})}var W3=wn(\"effect/schema/Cause\",Re,({annotations:e,typeParameters:t})=>{let n=Yh(t[0],t[1]);return e===void 0?n:n.annotate(e)});function nD(e,t){return(n,r)=>{let o=XU(e,t)(n,r),s=n.constant(nb),i=n.array(o.arbitrary).map(Bi);return _a(n,r,s,i)}}function rD(e,t){let n=C0(eD(e,t));return(r,o)=>n(r.reasons,o.reasons)}function oD(e,t){let n=tD(e,t);return r=>`Cause([${r.reasons.map(n).join(\", \")}])`}var H3=Cr(e=>{if(!wt(e))return!1;let t=globalThis.Object.keys(e);return t.length>0&&t.every(n=>(n===\"includeStack\"||n===\"excludeCause\")&&e[n]===!0)}),J3=st([Re,H3]),sD=e=>(e?.includeStack===!0?1:0)|(e?.excludeCause===!0?2:0),iD=e=>{switch(e){case 0:return;case 1:return{includeStack:!0};case 2:return{excludeCause:!0};case 3:return{includeStack:!0,excludeCause:!0}}},_F=[];function aD(e){let t=sD(e),n=_F[t];if(n!==void 0)return n;let r=iD(t),o=oi(globalThis.Error,{representation:{id:\"effect/schema/Error\",payload:r??null},toCode:()=>({runtime:r!==void 0?`Schema.ErrorInstance(${N(r)})`:\"Schema.ErrorInstance()\",Type:\"globalThis.Error\"}),expected:\"Error\",toCodecJson:()=>$e()($6,rR(r)),toArbitrary:()=>s=>s.string().map(i=>new globalThis.Error(i))});return _F[t]=o,o}var K3=wn(\"effect/schema/Error\",J3,({annotations:e,payload:t})=>{let n=aD(t??void 0);return e===void 0?n:n.annotate(e)}),MF=[];function G3(e){let t=sD(e),n=MF[t];if(n!==void 0)return n;let r=ad.pipe(ne(tw,oR(iD(t))));return MF[t]=r,r}function cD(e,t,n){let r=Dn()([e,t,n],([o,s,i])=>{let a=Yh(s,i);return(c,u,f)=>{if(!Af(c))return P(new He(u,c,f));switch(c._tag){case\"Success\":return Ut(Ze(o)(c.value,f),{onSuccess:Yt,onFailure:d=>Pn(u,\"value\",d,c,f)});case\"Failure\":return Ut(Ze(a)(c.cause,f),{onSuccess:Qt,onFailure:d=>Pn(u,\"cause\",d,c,f)})}}},{representation:{id:\"effect/schema/Exit\",payload:null},toCode:({typeParameters:o})=>({runtime:`Schema.Exit(${o[0].runtime}, ${o[1].runtime}, ${o[2].runtime})`,Type:`Exit.Exit<${o[0].Type}, ${o[1].Type}, ${o[2].Type}>`,importDeclarations:['import * as Exit from \"effect/Exit\"']}),expected:\"Exit\",toCodec:([o,s,i])=>$e()(st([J({_tag:ze(\"Success\"),value:o}),J({_tag:ze(\"Failure\"),cause:Yh(s,i)})]),We({decode:a=>a._tag===\"Success\"?Yt(a.value):Qt(a.cause),encode:a=>et(a)?{_tag:\"Success\",value:a.value}:{_tag:\"Failure\",cause:a.cause}})),toArbitrary:([o,s,i])=>(a,c)=>{let u=nD(s,i)(a,c),f=uD(a,o.terminal?.map(p=>Yt(p)),u.terminal?.map(p=>Qt(p))),d=a.oneof(o.arbitrary.map(p=>Yt(p)),u.arbitrary.map(p=>Qt(p)));return _a(a,c,f,d)},toEquivalence:([o,s,i])=>{let a=rD(s,i);return(c,u)=>{if(c._tag!==u._tag)return!1;switch(c._tag){case\"Success\":return o(c.value,u.value);case\"Failure\":return a(c.cause,u.cause)}}},toFormatter:([o,s,i])=>{let a=oD(s,i);return c=>{switch(c._tag){case\"Success\":return`Exit.Success(${o(c.value)})`;case\"Failure\":return`Exit.Failure(${a(c.cause)})`}}}});return j(r.ast,{value:e,error:t,defect:n})}var V3=wn(\"effect/schema/Exit\",Re,({annotations:e,typeParameters:t})=>{let n=cD(t[0],t[1],t[2]);return e===void 0?n:n.annotate(e)});function uD(e,t,n){return t===void 0?n:n===void 0?t:e.oneof(t,n)}function _a(e,t,n,r){return{arbitrary:n===void 0||t.recursion===void 0?r:e.oneof(t.recursion,n,r),terminal:n}}function PF(e,t,n,r){return r===void 0?e.array(t,n):e.uniqueArray(t,{...n,comparator:r})}function sg(e,t,n,r,o,s){let i=t.constraint,a=i===void 0||i.minLength===void 0&&i.maxLength===void 0?void 0:{...i.minLength!==void 0?{minLength:i.minLength}:{},...i.maxLength!==void 0?{maxLength:i.maxLength}:{}};if(a?.minLength!==void 0&&a.maxLength!==void 0&&a.minLength>a.maxLength)throw new globalThis.Error(\"Unable to derive an arbitrary for size constraints\");let c=a?.minLength??0,u=c===0?e.constant([]):r===void 0?void 0:PF(e,r,{...a,maxLength:c},s),f=_a(e,t,u,PF(e,n,a,s));return{arbitrary:f.arbitrary.map(o),terminal:f.terminal?.map(o)}}function fD(e,t,n,r,o){return sg(e,t,e.tuple(n.arbitrary,r.arbitrary),n.terminal===void 0||r.terminal===void 0?void 0:e.tuple(n.terminal,r.terminal),o,([s],[i])=>B(s,i))}function lD(e,t){let n=Dn()([e,t],([r,o])=>{let s=Ye(ou([r,o]));return(i,a,c)=>i instanceof globalThis.Map?Ut(Ze(s)([...i],c),{onSuccess:u=>new globalThis.Map(u),onFailure:u=>Pn(a,\"entries\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/ReadonlyMap\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.ReadonlyMap(${r[0].runtime}, ${r[1].runtime})`,Type:`globalThis.ReadonlyMap<${r[0].Type}, ${r[1].Type}>`}),expected:\"ReadonlyMap\",toCodec:([r,o])=>$e()(Ye(ou([r,o])),We({decode:s=>new globalThis.Map(s),encode:s=>[...s.entries()]})),toArbitrary:([r,o])=>(s,i)=>fD(s,i,r,o,a=>new globalThis.Map(a)),toEquivalence:([r,o])=>kd(r,o),toFormatter:([r,o])=>s=>{let i=s.size;if(i===0)return\"ReadonlyMap(0) {}\";let a=globalThis.Array.from(s.entries()).sort().map(([c,u])=>`${r(c)} => ${o(u)}`);return`ReadonlyMap(${i}) { ${a.join(\", \")} }`}});return j(n.ast,{key:e,value:t})}var Z3=wn(\"effect/schema/ReadonlyMap\",Re,({annotations:e,typeParameters:t})=>{let n=lD(t[0],t[1]);return e===void 0?n:n.annotate(e)});function FF(e,t,n){return J({type:ze(e),nodes:Ye(J({index:Gt,data:t})),edges:Ye(J({index:Gt,source:Gt,target:Gt,data:n}))})}function UF(e,t){let n=-1,r=new Set;for(let o=0;o<e.nodes.length;o++){let s=e.nodes[o].index;if(s<=n)return P(new Ve([\"nodes\",o,\"index\"],new ge({expected:\"a strictly increasing node index\"},s,t)));n=s,r.add(s)}n=-1;for(let o=0;o<e.edges.length;o++){let s=e.edges[o];if(s.index<=n)return P(new Ve([\"edges\",o,\"index\"],new ge({expected:\"a strictly increasing edge index\"},s.index,t)));if(n=s.index,!r.has(s.source))return P(new Ve([\"edges\",o,\"source\"],new ge({expected:\"an encoded node index\"},s.source,t)));if(!r.has(s.target))return P(new Ve([\"edges\",o,\"target\"],new ge({expected:\"an encoded node index\"},s.target,t)))}return x(Rl(e))}function Y3(e,t,n){return!yI(e)||e.mutable||e.type!==t?P(new ge({expected:`an immutable ${t} Graph`},e,n)):x(kl(e))}function Q3(e,t){return(n,r)=>{let o=kl(n),s=kl(r);if(o.type!==s.type||o.nodes.length!==s.nodes.length||o.edges.length!==s.edges.length)return!1;for(let i=0;i<o.nodes.length;i++)if(o.nodes[i].index!==s.nodes[i].index||!e(o.nodes[i].data,s.nodes[i].data))return!1;for(let i=0;i<o.edges.length;i++){let a=o.edges[i],c=s.edges[i],u=o.type===\"directed\"?a.source===c.source&&a.target===c.target:a.source===c.source&&a.target===c.target||a.source===c.target&&a.target===c.source;if(a.index!==c.index||!u||!t(a.data,c.data))return!1}return!0}}function X3(e,t,n){return(r,o)=>{let s=Rl({type:e,nodes:[],edges:[]}),i=r.constant(s),a=r.array(t.arbitrary).chain(c=>{let u=c.map((d,p)=>({index:p,data:d}));if(u.length===0)return i;let f=r.integer({min:0,max:u.length-1});return r.array(r.tuple(f,f,n.arbitrary)).map(d=>Rl({type:e,nodes:u,edges:d.map(([p,m,g],b)=>({index:b,source:p,target:m,data:g}))}))});return _a(r,o,i,a)}}function dD(e,t,n){let r=Dn()([t,n],([o,s])=>{let i=FF(e,o,s);return(a,c,u)=>!yI(a)||a.mutable||a.type!==e?P(new He(c,a,u)):T(Ze(i)(kl(a),u),f=>UF(f,u))},{representation:{id:\"effect/schema/Graph\",payload:e},toCode:({typeParameters:o})=>({runtime:`Schema.Graph(${N(e)}, ${o[0].runtime}, ${o[1].runtime})`,Type:`Graph.Graph<${o[0].Type}, ${o[1].Type}, ${N(e)}>`,importDeclarations:['import * as Graph from \"effect/Graph\"']}),expected:`an immutable ${e} Graph`,toCodec:([o,s])=>$e()(FF(e,o,s),Zn({decode:UF,encode:(i,a)=>Y3(i,e,a)})),toArbitrary:([o,s])=>X3(e,o,s),toEquivalence:([o,s])=>Q3(o,s),toFormatter:()=>globalThis.String});return j(r.ast,{type:e,node:t,edge:n})}var eG=wn(\"effect/schema/Graph\",io([\"directed\",\"undirected\"]),({annotations:e,payload:t,typeParameters:n})=>{let r=dD(t,n[0],n[1]);return e===void 0?r:r.annotate(e)});function pD(e,t){let n=Dn()([e,t],([r,o])=>{let s=Ye(ou([r,o]));return(i,a,c)=>XM(i)?Ut(Ze(s)(Uh(i),c),{onSuccess:Fh,onFailure:u=>Pn(a,\"entries\",u,i,c)}):P(new He(a,i,c))},{representation:{id:\"effect/schema/HashMap\",payload:null},toCode:({typeParameters:r})=>({runtime:`Schema.HashMap(${r[0].runtime}, ${r[1].runtime})`,Type:`HashMap.HashMap<${r[0].Type}, ${r[1].Type}>`,importDeclarations:['import * as HashMap from \"effect/HashMap\"']}),expected:\"HashMap\",toCodec:([r,o])=>$e()(Ye(ou([r,o])),We({decode:Fh,encode:Uh})),toArbitrary:([r,o])=>(s,i)=>fD(s,i,r,o,Fh),toEquivalence:([r,o])=>kd(r,o),toFormatter:([r,o])=>s=>{let i=eP(s);if(i===0)return\"HashMap(0) {}\";let a=Uh(s).sort().map(([c,u])=>`${r(c)} => ${o(u)}`);return`HashMap(${i}) { ${a.join(\", \")} }`}});return j(n.ast,{key:e,value:t})}var tG=wn(\"effect/schema/HashMap\",Re,({annotations:e,typeParameters:t})=>{let n=pD(t[0],t[1]);return e===void 0?n:n.annotate(e)});function mD(e){let t=Dn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>o instanceof globalThis.Set?Ut(Ze(r)([...o],i),{onSuccess:a=>new globalThis.Set(a),onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/ReadonlySet\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.ReadonlySet(${n[0].runtime})`,Type:`globalThis.ReadonlySet<${n[0].Type}>`}),expected:\"ReadonlySet\",toCodec:([n])=>$e()(Ye(n),We({decode:r=>new globalThis.Set(r),encode:r=>[...r.values()]})),toArbitrary:([n])=>(r,o)=>sg(r,o,n.arbitrary,n.terminal,s=>new globalThis.Set(s),B),toEquivalence:([n])=>Rd(n),toFormatter:([n])=>r=>{let o=r.size;if(o===0)return\"ReadonlySet(0) {}\";let s=globalThis.Array.from(r.values()).sort().map(i=>`${n(i)}`);return`ReadonlySet(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var nG=wn(\"effect/schema/ReadonlySet\",Re,({annotations:e,typeParameters:t})=>{let n=mD(t[0]);return e===void 0?n:n.annotate(e)});function hD(e){let t=Dn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>sP(o)?Ut(Ze(r)(Be(o),i),{onSuccess:Nh,onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/HashSet\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.HashSet(${n[0].runtime})`,Type:`HashSet.HashSet<${n[0].Type}>`}),expected:\"HashSet\",toCodec:([n])=>$e()(Ye(n),We({decode:Nh,encode:Be})),toArbitrary:([n])=>(r,o)=>sg(r,o,n.arbitrary,n.terminal,Nh,B),toEquivalence:([n])=>Rd(n),toFormatter:([n])=>r=>{let o=iP(r);if(o===0)return\"HashSet(0) {}\";let s=globalThis.Array.from(r).sort().map(i=>`${n(i)}`);return`HashSet(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var rG=wn(\"effect/schema/HashSet\",Re,({annotations:e,typeParameters:t})=>{let n=hD(t[0]);return e===void 0?n:n.annotate(e)});function gD(e){let t=Dn()([e],([n])=>{let r=Ye(n);return(o,s,i)=>Xm(o)?Ut(Ze(r)(Be(o),i),{onSuccess:eh,onFailure:a=>Pn(s,\"values\",a,o,i)}):P(new He(s,o,i))},{representation:{id:\"effect/schema/Chunk\",payload:null},toCode:({typeParameters:n})=>({runtime:`Schema.Chunk(${n[0].runtime})`,Type:`Chunk.Chunk<${n[0].Type}>`}),expected:\"Chunk\",toCodec:([n])=>$e()(Ye(n),We({decode:eh,encode:Be})),toArbitrary:([n])=>(r,o)=>sg(r,o,n.arbitrary,n.terminal,eh),toEquivalence:([n])=>vE(n),toFormatter:([n])=>r=>{let o=d_(r);if(o===0)return\"Chunk(0) {}\";let s=globalThis.Array.from(r).sort().map(i=>`${n(i)}`);return`Chunk(${o}) { ${s.join(\", \")} }`}});return j(t.ast,{value:e})}var oG=wn(\"effect/schema/Chunk\",Re,({annotations:e,typeParameters:t})=>{let n=gD(t[0]);return e===void 0?n:n.annotate(e)}),xD=oi(globalThis.RegExp,{representation:{id:\"effect/schema/RegExp\",payload:null},toCode:()=>({runtime:\"Schema.RegExp\",Type:\"globalThis.RegExp\"}),expected:\"RegExp\",toCodecJson:()=>$e()(J({source:X,flags:X}),Zn({decode:(e,t)=>Sc({try:()=>new globalThis.RegExp(e.source,e.flags),catch:()=>new ge({expected:\"valid RegExp source and flags\"},e,t)}),encode:e=>x({source:e.source,flags:e.flags})})),toArbitrary:()=>e=>e.tuple(e.constantFrom(\".\",\".*\",\"\\\\d+\",\"\\\\w+\",\"[a-z]+\",\"[A-Z]+\",\"[0-9]+\",\"^[a-zA-Z0-9]+$\",\"^\\\\d{4}-\\\\d{2}-\\\\d{2}$\"),e.uniqueArray(e.constantFrom(\"g\",\"i\",\"m\",\"s\",\"u\",\"y\"),{minLength:0,maxLength:6}).map(t=>t.join(\"\"))).map(([t,n])=>new globalThis.RegExp(t,n)),toEquivalence:()=>(e,t)=>e.source===t.source&&e.flags===t.flags}),sG=sn(\"effect/schema/RegExp\",xD),yD=X.annotate({expected:\"a string that will be decoded as a URL\"}),nd=oi(globalThis.URL,{representation:{id:\"effect/schema/URL\",payload:null},toCode:()=>({runtime:\"Schema.URL\",Type:\"globalThis.URL\"}),expected:\"URL\",toCodecJson:()=>$e()(yD,kS),toArbitrary:()=>e=>e.webUrl().map(t=>new globalThis.URL(t)),toEquivalence:()=>(e,t)=>e.toString()===t.toString()}),iG=sn(\"effect/schema/URL\",nd),aG=yD.pipe(ne(nd,kS));function mw(e,t,n){let r={...t};if(e?.minimum!==void 0){let o=n===void 0?e.minimum:n(e.minimum),s=e.exclusiveMinimum?new globalThis.Date(o.getTime()+1):o;(r.min===void 0||s.getTime()>r.min.getTime())&&(r.min=s)}if(e?.maximum!==void 0){let o=n===void 0?e.maximum:n(e.maximum),s=e.exclusiveMaximum?new globalThis.Date(o.getTime()-1):o;(r.max===void 0||s.getTime()<r.max.getTime())&&(r.max=s)}return r}var bD=X.annotate({expected:\"a string that will be decoded as a Date\"}),er=Cr(e=>e instanceof globalThis.Date&&!globalThis.Number.isNaN(e.getTime()),{representation:{id:\"effect/schema/Date\",payload:null},toCode:()=>({runtime:\"Schema.Date\",Type:\"globalThis.Date\"}),expected:\"a valid Date\",toCodecJson:()=>$e()(bD,OS),toArbitrary:()=>(e,t)=>e.date(mw(t?.constraint?.ordered?.order===wi?t.constraint.ordered:void 0,{noInvalidDate:!0}))}),cG=sn(\"effect/schema/Date\",er),uG=bD.pipe(ne(er,OS)),fG=Uo.pipe(ne(er,Zk)),rd=Cr(vx,{representation:{id:\"effect/schema/Duration\",payload:null},toCode:()=>({runtime:\"Schema.Duration\",Type:\"Duration.Duration\",importDeclarations:['import * as Duration from \"effect/Duration\"']}),expected:\"Duration\",toCodecJson:()=>$e()(st([J({_tag:ze(\"Infinity\")}),J({_tag:ze(\"NegativeInfinity\")}),J({_tag:ze(\"Nanos\"),value:Fo}),J({_tag:ze(\"Millis\"),value:Uo})]),We({decode:e=>{switch(e._tag){case\"Infinity\":return Ur;case\"NegativeInfinity\":return Ri;case\"Nanos\":return dr(e.value);case\"Millis\":return go(e.value)}},encode:e=>{switch(e.value._tag){case\"Infinity\":return{_tag:\"Infinity\"};case\"NegativeInfinity\":return{_tag:\"NegativeInfinity\"};case\"Nanos\":return{_tag:\"Nanos\",value:e.value.nanos};case\"Millis\":return{_tag:\"Millis\",value:e.value.millis}}}})),toArbitrary:()=>e=>e.oneof(e.constant(Ur),e.constant(Ri),e.bigInt().map(dr),e.maxSafeInteger().map(go)),toFormatter:()=>globalThis.String,toEquivalence:()=>Ax}),lG=sn(\"effect/schema/Duration\",rd),dG=X.annotate({expected:\"a string that will be decoded as a Duration\"}),hw=dG.pipe(ne(rd,Yk)),pG=Fo.pipe(ne(rd,Qk)),mG=uu.pipe(ne(rd,Xk)),SD=X.annotate({expected:\"a string that will be decoded as a BigDecimal\"}),ED=20,ID=\"Unable to derive an arbitrary for the ordered BigDecimal constraints\";function Qh(e,t){return _s(e,t).value}function hG(e,t,n){return n?Qh(hx(e,t),t)+globalThis.BigInt(1):Qh(mx(e,t),t)}function gG(e,t,n){return n?Qh(mx(e,t),t)-globalThis.BigInt(1):Qh(hx(e,t),t)}function xG(e){return Math.max(ED,e.minimum?.scale??0,e.maximum?.scale??0,e.exclusiveMinimum&&e.minimum!==void 0?e.minimum.scale+1:0,e.exclusiveMaximum&&e.maximum!==void 0?e.maximum.scale+1:0)}function YI(e,t){let n={};if(e.minimum!==void 0&&(n.min=hG(e.minimum,t,e.exclusiveMinimum===!0)),e.maximum!==void 0&&(n.max=gG(e.maximum,t,e.exclusiveMaximum===!0)),!(n.min!==void 0&&n.max!==void 0&&n.min>n.max))return n}function yG(e){let t=xG(e);if(YI(e,t)===void 0)throw new globalThis.Error(ID);let n=0,r=t;for(;n<r;){let o=n+Math.floor((r-n)/2);YI(e,o)===void 0?n=o+1:r=o}return{min:n,max:t}}var gw=Cr(Qd,{representation:{id:\"effect/schema/BigDecimal\",payload:null},toCode:()=>({runtime:\"Schema.BigDecimal\",Type:\"BigDecimal.BigDecimal\",importDeclarations:['import * as BigDecimal from \"effect/BigDecimal\"']}),expected:\"BigDecimal\",toCodecJson:()=>$e()(SD,RS),toArbitrary:()=>(e,t)=>{let n=t.constraint?.ordered?.order===Jo?t.constraint.ordered:void 0;return n===void 0?e.tuple(e.bigInt(),e.integer({min:0,max:ED})).map(([r,o])=>Rn(r,o)):e.integer(yG(n)).chain(r=>{let o=YI(n,r);if(o===void 0)throw new globalThis.Error(ID);return e.bigInt(o).map(s=>Rn(s,r))})},toFormatter:()=>e=>mo(e),toEquivalence:()=>dx}),bG=sn(\"effect/schema/BigDecimal\",gw),SG=SD.pipe(ne(gw,RS)),EG=X.annotate({expected:\"a string that will be decoded as JSON\",contentMediaType:\"application/json\"});function wD(e,t){return EG.pipe(ne(e,mR(t)))}var IG=wD(tw),xw=oi(globalThis.File,{representation:{id:\"effect/schema/File\",payload:null},toCode:()=>({runtime:\"Schema.File\",Type:\"globalThis.File\"}),expected:\"File\",toCodecJson:()=>$e()(J({data:X.check(cw()),type:X,name:X,lastModified:Uo}),Zn({decode:(e,t)=>lr(rc(e.data),{onFailure:()=>P(new ge({expected:\"a valid Base64 string\"},e.data,t)),onSuccess:n=>{let r=new globalThis.Uint8Array(n);return x(new globalThis.File([r],e.name,{type:e.type,lastModified:e.lastModified}))}}),encode:(e,t)=>bc({try:async()=>{let n=new globalThis.Uint8Array(await e.arrayBuffer());return{data:ap(n),type:e.type,name:e.name,lastModified:e.lastModified}},catch:()=>new ge({expected:\"a readable File\"},e,t)})}))}),wG=sn(\"effect/schema/File\",xw),yw=oi(globalThis.FormData,{representation:{id:\"effect/schema/FormData\",payload:null},toCode:()=>({runtime:\"Schema.FormData\",Type:\"globalThis.FormData\"}),expected:\"FormData\",toCodecJson:()=>$e()(Ye(ou([X,st([J({_tag:ka(\"String\"),value:X}),J({_tag:ka(\"File\"),value:xw})])])),Zn({decode:e=>{let t=new globalThis.FormData;for(let[n,r]of e)t.append(n,r.value);return x(t)},encode:e=>x(globalThis.Array.from(e.entries()).map(([t,n])=>typeof n==\"string\"?[t,{_tag:\"String\",value:n}]:[t,{_tag:\"File\",value:n}]))}))}),TG=sn(\"effect/schema/FormData\",yw);function vG(e){return yw.pipe(ne(e,hR))}var bw=oi(globalThis.URLSearchParams,{representation:{id:\"effect/schema/URLSearchParams\",payload:null},toCode:()=>({runtime:\"Schema.URLSearchParams\",Type:\"globalThis.URLSearchParams\"}),expected:\"URLSearchParams\",toCodecJson:()=>$e()(X.annotate({expected:\"a query string that will be decoded as URLSearchParams\"}),We({decode:e=>new globalThis.URLSearchParams(e),encode:e=>e.toString()}))}),AG=sn(\"effect/schema/URLSearchParams\",bw);function CG(e){return bw.pipe(ne(e,gR))}var OG=X.annotate({expected:\"a string that will be decoded as a number\"}).pipe(ne(uu,_c)),kG=X.annotate({expected:\"a string that will be decoded as a finite number\"}).pipe(ne(Xn,_c)),RG=j(yE).pipe(ne(Fo,Fm)),TD=X.check(aw()),_G=X.annotate({expected:\"a string that will be decoded as a trimmed string\"}).pipe(ne(TD,Gk())),MG=X.annotate({expected:\"a base64 encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,fR)),PG=X.annotate({expected:\"a base64 (URL) encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,lR)),FG=X.annotate({expected:\"a hex encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,dR)),UG=X.annotate({expected:\"a URI component encoded string that will be decoded as a UTF-8 string\"}).pipe(ne(X,pR)),QI=st([Xn,XF,X]),DG=J({issues:Ye(J({message:X,path:Ar(Ye(st([QI,J({key:QI})])))}))}),NG=io([0,1]).pipe(ne(ng,We({decode:e=>e===1,encode:e=>e?1:0}))),vD=X.annotate({expected:\"a base64 encoded string that will be decoded as Uint8Array\",format:\"byte\",contentEncoding:\"base64\"}),od=oi(globalThis.Uint8Array,{representation:{id:\"effect/schema/Uint8Array\",payload:null},toCode:()=>({runtime:\"Schema.Uint8Array\",Type:\"globalThis.Uint8Array\"}),expected:\"Uint8Array\",toCodecJson:()=>$e()(vD,_S),toArbitrary:()=>e=>e.uint8Array()}),LG=sn(\"effect/schema/Uint8Array\",od),$G=vD.pipe(ne(od,_S)),jG=X.annotate({expected:\"a base64 (URL) encoded string that will be decoded as a Uint8Array\"}).pipe(ne(od,{decode:Dk(),encode:Rm()})),BG=X.annotate({expected:\"a hex encoded string that will be decoded as a Uint8Array\"}).pipe(ne(od,{decode:Lk(),encode:_m()})),sd=Cr(e=>yS(e)&&gk(e),{representation:{id:\"effect/schema/DateTimeUtc\",payload:null},toCode:()=>({runtime:\"Schema.DateTimeUtc\",Type:\"DateTime.Utc\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.Utc\",toCodecJson:()=>$e()(X,FS),toArbitrary:()=>(e,t)=>e.date(mw(t?.constraint?.ordered?.order===SS?t.constraint.ordered:void 0,{noInvalidDate:!0},vm)).map(n=>yk(n)),toFormatter:()=>e=>e.toString(),toEquivalence:()=>bS}),qG=sn(\"effect/schema/DateTimeUtc\",sd),zG=er.pipe(ne(sd,{decode:CS(),encode:ue(vm)})),WG=X.annotate({expected:\"a string that will be decoded as a DateTime.Utc\"}).pipe(ne(sd,FS)),HG=Uo.pipe(ne(sd,{decode:CS(),encode:ue(wk)})),AD=Cr(mk,{representation:{id:\"effect/schema/TimeZoneOffset\",payload:null},toCode:()=>({runtime:\"Schema.TimeZoneOffset\",Type:\"DateTime.TimeZone.Offset\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone.Offset\",toCodecJson:()=>$e()(Uo,xR),toArbitrary:()=>e=>e.integer({min:-720*60*1e3,max:840*60*1e3}).map(t=>Xf(t)),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>e.offset===t.offset}),JG=sn(\"effect/schema/TimeZoneOffset\",AD),CD=X.annotate({expected:\"an IANA time zone identifier\"}),Sw=Cr(hk,{representation:{id:\"effect/schema/TimeZoneNamed\",payload:null},toCode:()=>({runtime:\"Schema.TimeZoneNamed\",Type:\"DateTime.TimeZone.Named\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone.Named\",toCodecJson:()=>$e()(CD,MS),toArbitrary:()=>e=>e.constantFrom(...[\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\"].map(ES)),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>e.id===t.id}),KG=sn(\"effect/schema/TimeZoneNamed\",Sw),GG=CD.pipe(ne(Sw,MS)),OD=X.annotate({expected:\"a time zone string (IANA identifier or offset like +03:00)\"}),Ew=Cr(pk,{representation:{id:\"effect/schema/TimeZone\",payload:null},toCode:()=>({runtime:\"Schema.TimeZone\",Type:\"DateTime.TimeZone\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.TimeZone\",toCodecJson:()=>$e()(OD,PS),toArbitrary:()=>e=>e.oneof(e.integer({min:-720*60*1e3,max:840*60*1e3}).map(t=>Xf(t)),e.constantFrom(...[\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\"].map(ES))),toFormatter:()=>e=>Vs(e),toEquivalence:()=>(e,t)=>Vs(e)===Vs(t)}),VG=sn(\"effect/schema/TimeZone\",Ew),ZG=OD.pipe(ne(Ew,PS)),kD=X.annotate({expected:\"a zoned DateTime string (e.g. 2024-01-01T00:00:00.000+00:00[Europe/London])\"}),Iw=Cr(e=>yS(e)&&xk(e),{representation:{id:\"effect/schema/DateTimeZoned\",payload:null},toCode:()=>({runtime:\"Schema.DateTimeZoned\",Type:\"DateTime.Zoned\",importDeclarations:['import * as DateTime from \"effect/DateTime\"']}),expected:\"DateTime.Zoned\",toCodecJson:()=>$e()(kD,US),toArbitrary:()=>(e,t)=>e.tuple(e.date(mw(t?.constraint?.ordered?.order===SS?t.constraint.ordered:void 0,{max:new globalThis.Date(864e13-840*60*1e3),min:new globalThis.Date(-864e13+840*60*1e3),noInvalidDate:!0},vm)),e.constantFrom(\"UTC\",\"Europe/London\",\"America/New_York\",\"Asia/Tokyo\",\"Australia/Sydney\")).map(([n,r])=>bk(n,{timeZone:r})),toFormatter:()=>e=>Am(e),toEquivalence:()=>bS}),YG=sn(\"effect/schema/DateTimeZoned\",Iw),QG=kD.pipe(ne(Iw,US)),XG=globalThis.Symbol.for(\"immer-draftable\"),XI={};function ww(e,t,n,r,o){let s=t6(n,t,r),i=RD(t),a=class extends e{constructor(...[c,u]){let d=u?.[\"~payload\"],p=d?.token===XI?d.value:n.make(c??{},u);super(p,{...u,disableChecks:!0,\"~payload\":{token:XI,value:p}})}static[Hl]=Hl;get[i](){return i}static[XG]=!0;static identifier=t;static fields=n.fields;static get ast(){return s(this).ast}static pipe(){return K(this,arguments)}static rebuild(c){return s(this).rebuild(c)}static make(c,u){return new this(c,u)}static makeOption(c,u){return $h(s(this))(c??{},u)}static makeEffect(c,u){return s(this).makeEffect(c??{},u)}static annotate(c){return this.rebuild(Ir(this.ast,c))}static annotateKey(c){return this.rebuild(pl(this.ast,c))}static check(...c){return this.rebuild(ko(this.ast,c))}static extend(c){return(u,f)=>{let d=id(u)?u:J(u),p={...n.fields,...d.fields},m=zm(p,n.ast.checks,{identifier:c});return ww(this,c,rw(ko(m,d.ast.checks),p),f,o)}}static mapFields(c,u){return n.mapFields(c,u)}};return o!==void 0&&Object.assign(a.prototype,o(t)),a}function e6(e){return new Te(ue(t=>new e(t,{\"~payload\":{token:XI,value:t}})),Sn())}function RD(e){return`~effect/Schema/Class/${e}`}function t6(e,t,n){let r;return o=>{if(r!==void 0)return r;let s=RD(t),i=u=>u instanceof o||M(u,s),a=e6(o),c=j(new ia([e.ast],()=>(u,f,d)=>i(u)?x(u):P(new He(f,u,d)),{identifier:t,[xm]:([u])=>({isConstructed:i,link:new Je(u,a)}),toCodec:([u])=>new Je(u.ast,a),toArbitrary:([u])=>()=>({arbitrary:u.arbitrary.map(f=>new o(f)),terminal:u.terminal?.map(f=>new o(f))}),toFormatter:([u])=>f=>`${o.identifier}(${u(f)})`,[Kf]:Fc(e.ast),...n}));return r=ne(c,a)(e)}}function id(e){return tg(e)}var _D=e=>(t,n)=>{let r=id(t)?t:J(t);return ww(sp,e,r,n,o=>({toString(){return`${o}(${N({...this})})`}}))},n6=e=>(t,n,r)=>{let o=id(n)?n.mapFields(s=>({_tag:ka(t),...s}),{unsafePreserveChecks:!0}):og(t,n);return _D(e??t)(o,r)},MD=e=>(t,n)=>{let r=id(t)?t:J(t);return ww(Uu,e,r,n,s=>({name:s}))},r6=e=>(t,n,r)=>{let o=id(n)?n.mapFields(s=>({_tag:ka(t),...s}),{unsafePreserveChecks:!0}):og(t,n);return MD(e??t)(o,r)};function o6(e){let t=KP(e.ast);return n=>t(n,{})}function s6(e){return t=>t.annotate({toFormatter:e})}function i6(e,t){return n(e.ast);function n(o){let s=Co(o)?.toFormatter;if(typeof s==\"function\")return s(Qs(o)?o.typeParameters.map(n):[]);if(t?.onBefore){let i=t.onBefore(o,n);if(i!==void 0)return i}return r(o)}function r(o){switch(o._tag){default:return N;case\"Never\":return()=>\"never\";case\"Void\":return()=>\"void\";case\"Arrays\":{let s=o.elements.map(n),i=o.rest.map(n);return a=>{let c=[],u=0;for(;u<s.length;u++)if(a.length<u+1){if(dt(o.elements[u]))continue}else c.push(s[u](a[u]));if(i.length>0){let[f,...d]=i;for(;u<a.length-d.length;u++)c.push(f(a[u]));for(let p=0;p<d.length;p++)c.push(d[p](a[u+p]))}return\"[\"+c.join(\", \")+\"]\"}}case\"Objects\":{let s=o.propertySignatures.map(a=>n(a.type)),i=o.indexSignatures.map(a=>n(a.type));return o.propertySignatures.length===0&&o.indexSignatures.length===0?N:a=>{let c=[],u=new Set;for(let f=0;f<s.length;f++){let d=o.propertySignatures[f],p=d.name;u.add(p),!(dt(d.type)&&!Object.hasOwn(a,p))&&c.push(`${Bo(p)}: ${s[f](a[p])}`)}for(let f=0;f<i.length;f++){let d=aa(a,o.indexSignatures[f].parameter);for(let p of d)u.has(p)||(u.add(p),c.push(`${Bo(p)}: ${i[f](a[p])}`))}return c.length>0?\"{ \"+c.join(\", \")+\" }\":\"{}\"}}case\"Union\":{let s=Jt(o).types,i=c=>cl(c,s),a=new Map(s.map((c,u)=>[c,[Ia(c),n(o.types[u])]]));return c=>{let u=i(c);for(let f=0;f<u.length;f++){let[d,p]=a.get(u[f]);if(d(c))return p(c)}return N(c)}}case\"Suspend\":{let s=fa(()=>n(o.thunk()));return i=>s()(i)}}}}function a6(e){return t=>t.annotate({toEquivalence:e})}function c6(e){return GP(e.ast)}function u6(e,t){return zI(e.ast,t)}function PD(e,t){let n=zI(ig(e.ast),t);return uF(n,t)}function FD(e){return j(ig(e.ast),{schema:e})}var ig=la(e=>{let t=l6(e,ig),n=e.context;return t===e||n===void 0?t:Vm(t,Tw(n))});function Tw(e){return e.constructorDefault===void 0?e:new Er(e.isOptional,e.isMutable,void 0,e.annotations)}function UD(e){if(e.propertySignatures.some(t=>typeof t.name!=\"string\"))throw new globalThis.Error(\"Objects property names must be strings\",{cause:e})}function DD(e){return t=>{let n=new Map;for(let s=0;s<t.length;s++)n.set(nn(t[s]),s);let r=[...t].sort((s,i)=>{s=nn(s),i=nn(i);let a=e(s),c=e(i);return a!==c?a-c:n.get(s)-n.get(i)});return r.some((s,i)=>s!==t[i])?r:t}}var f6=DD(e=>{switch(e._tag){case\"BigInt\":case\"Symbol\":case\"UniqueSymbol\":return 0;default:return 1}});function l6(e,t){switch(e._tag){case\"Declaration\":{let n=e.annotations?.toCodecJson??e.annotations?.toCodec;if(!un(n))return Ne(e,[SE]);let r=e.typeParameters.map(s=>Ll(nn(s))),o=n(r);return o===void 0?e:Ne(e,[Nc(o,t)])}case\"Unknown\":return Ne(e,[SE]);case\"ObjectKeyword\":return Ne(e,[i_]);case\"Undefined\":case\"Void\":case\"Literal\":case\"Number\":return e.toCodecJson();case\"UniqueSymbol\":case\"Symbol\":case\"BigInt\":return e.toCodecStringTree();case\"Objects\":return UD(e),e.recur(t,Ym);case\"Union\":{let n=f6(e.types);return n!==e.types?new En(n,e.mode,e.annotations,e.checks,e.encoding,e.context,e.encodingChecks).recur(t):e.recur(t)}case\"Arrays\":case\"Suspend\":return e.recur(t)}return e}function ND(e){return j(LD(Jt(e.ast)))}var LD=It(e=>{let t=d6(e,LD);return t!==e&&e.context!==void 0?Vm(t,Tw(e.context)):t});function d6(e,t){switch(e._tag){case\"Declaration\":{let n=e.annotations?.toCodecIso??e.annotations?.toCodec;if(un(n)){let r=n(e.typeParameters.map(o=>Ll(o)));return Ne(e,[Nc(r,t)])}return e}case\"Arrays\":case\"Objects\":case\"Union\":case\"Suspend\":return e.recur(t)}return e}function lu(e){return j(BD(e.ast),{schema:e})}function p6(e){return j(qD(e.ast))}function m6(e,t){let n=Ac(e.ast)??lS(e.ast),r=KF(lu(e));return o=>r(o).pipe($(s=>h6(s,{rootName:n,...t})))}function h6(e,t){let n=t.rootName??\"root\",r=t.arrayItemName??\"item\",o=t.pretty??!0,s=t.indent??\" \",i=t.sortKeys??!0,a=new Set,c=[];return f(n,e,0),c.join(o?`\n`:\"\");function u(d,p){c.push(o?s.repeat(d)+p:p)}function f(d,p,m,g){let{attrs:b,safe:I}=ru.tagInfo(d,g);if(p===void 0)u(m,`<${I}${b}/>`);else if(typeof p==\"string\")u(m,`<${I}${b}>${ru.escapeText(p)}</${I}>`);else if(typeof p!=\"object\"||p===null)u(m,`<${I}${b}>${ru.escapeText(N(p))}</${I}>`);else{if(a.has(p))throw new globalThis.Error(\"Cycle detected while serializing to XML.\",{cause:p});a.add(p);try{if(globalThis.globalThis.Array.isArray(p)){if(p.length===0){u(m,`<${I}${b}/>`);return}u(m,`<${I}${b}>`);for(let h of p)f(r,h,m+1);u(m,`</${I}>`);return}let w=p,E=Object.keys(w);if(i&&E.sort(),E.length===0){u(m,`<${I}${b}/>`);return}u(m,`<${I}${b}>`);for(let h of E)f(ru.parseTagName(h).safe,w[h],m+1,h);u(m,`</${I}>`)}finally{a.delete(p)}}}}var ru={escapeText(e){return e.replace(/&/g,\"&\").replace(/</g,\"<\").replace(/>/g,\">\")},escapeAttribute(e){return e.replace(/&/g,\"&\").replace(/\"/g,\""\").replace(/</g,\"<\").replace(/>/g,\">\")},parseTagName(e){let t=e,n=e;return/^[A-Za-z_]/.test(n)||(n=\"_\"+n),n=n.replace(/[^A-Za-z0-9._-]/g,\"_\"),/^xml/i.test(n)&&(n=\"_\"+n),{safe:n,changed:n!==t}},tagInfo(e,t){let{changed:n,safe:r}=ru.parseTagName(e),s=n||t&&t!==e?` data-name=\"${ru.escapeAttribute(t??e)}\"`:\"\";return{safe:r,attrs:s}}},g6=DD(e=>{switch(e._tag){case\"Null\":case\"Boolean\":case\"Number\":case\"BigInt\":case\"Symbol\":case\"UniqueSymbol\":return 0;default:return 1}});function x6(e,t,n){switch(e._tag){case\"Declaration\":{let r=e.typeParameters.map(u=>j(t(nn(u)))),o=e.annotations?.toCodecStringTree;if(un(o)){let u=o(r);return u===void 0?e:Ne(e,[Nc(u,t)])}let s=e.annotations?.toCodecJson,i=un(s)?s(r):void 0,a=i===void 0?e.annotations?.toCodec:void 0,c=i??(un(a)?a(r):void 0);return c===void 0?n(e):Ne(e,[Nc(c,t)])}case\"Null\":return Ne(e,[y6]);case\"Boolean\":return Ne(e,[b6]);case\"Unknown\":case\"ObjectKeyword\":return Ne(e,[EE]);case\"Enum\":case\"Number\":case\"Literal\":case\"UniqueSymbol\":case\"Symbol\":case\"BigInt\":return e.toCodecStringTree();case\"Objects\":return UD(e),e.recur(t,Ym);case\"Union\":{let r=g6(e.types);return r!==e.types?new En(r,e.mode,e.annotations,e.checks,e.encoding,e.context,e.encodingChecks).recur(t):e.recur(t)}case\"Arrays\":case\"Suspend\":return e.recur(t)}return e}var y6=new Je(new Fn(\"null\"),new Te(ue(()=>null),ue(()=>\"null\"))),b6=new Je(new En([new Fn(\"true\"),new Fn(\"false\")],\"anyOf\"),new Te(ue(e=>e===\"true\"),sa())),$D=new Te(ue(e=>typeof e==\"string\"?[e]:e),Sn()),jD=e=>e.transformation===$D,BD=la(e=>{let t=x6(e,BD,n=>{throw new globalThis.Error(\"Missing structural codec for StringTree\",{cause:n})});return t!==e&&e.context!==void 0?Vm(t,Tw(e.context)):t},{stopAt:jD}),DF=e=>dt(e)?Lc(Kr):Kr,qD=la(e=>{let t=S6(e);if(tE(t)){let n=$c(new En([new Gr(t.isMutable,t.elements.map(DF),t.rest.map(DF)),Oo],\"anyOf\"),t,$D);return dt(e)?Lc(n):n}return t},{stopAt:jD});function S6(e){return e._tag===\"Declaration\"||e._tag===\"Arrays\"||e._tag===\"Objects\"||e._tag===\"Union\"||e._tag===\"Suspend\"?e.recur(qD):e}var E6=fe(\"effect/schema/isGreaterThanDate\",J({exclusiveMinimum:er}),({annotations:e,payload:t})=>PU(t.exclusiveMinimum,e)),I6=fe(\"effect/schema/isGreaterThanOrEqualToDate\",J({minimum:er}),({annotations:e,payload:t})=>FU(t.minimum,e)),w6=fe(\"effect/schema/isLessThanDate\",J({exclusiveMaximum:er}),({annotations:e,payload:t})=>UU(t.exclusiveMaximum,e)),T6=fe(\"effect/schema/isLessThanOrEqualToDate\",J({maximum:er}),({annotations:e,payload:t})=>DU(t.maximum,e)),v6=fe(\"effect/schema/isBetweenDate\",J({minimum:er,maximum:er,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>NU(t,e)),A6=fe(\"effect/schema/isGreaterThanBigInt\",J({exclusiveMinimum:Fo}),({annotations:e,payload:t})=>LU(t.exclusiveMinimum,e)),C6=fe(\"effect/schema/isGreaterThanOrEqualToBigInt\",J({minimum:Fo}),({annotations:e,payload:t})=>$U(t.minimum,e)),O6=fe(\"effect/schema/isLessThanBigInt\",J({exclusiveMaximum:Fo}),({annotations:e,payload:t})=>jU(t.exclusiveMaximum,e)),k6=fe(\"effect/schema/isLessThanOrEqualToBigInt\",J({maximum:Fo}),({annotations:e,payload:t})=>BU(t.maximum,e)),R6=fe(\"effect/schema/isBetweenBigInt\",J({minimum:Fo,maximum:Fo,exclusiveMinimum:Ar(ze(!0)),exclusiveMaximum:Ar(ze(!0))}),({annotations:e,payload:t})=>qU(t,e));function _6(e){let t=ND(e);return xF(PI(t),_I(t))}function M6(e){return Vh()}function P6(e){return Vh()}function F6(e,t){return n=>j(Ir(n.ast,{toCodecIso:()=>new Je(e.ast,Mm(t))}),{schema:n})}function U6(e){let t=FD(e),n=PI(t),r=_I(t);return{empty:[],diff:(o,s)=>lF(n(o),n(s)),combine:(o,s)=>[...o,...s],patch:(o,s)=>{let i=n(o),a=dF(s,i);return Object.is(a,i)?o:r(a)}}}function D6(e){let t=rU(()=>n),n=st([e,Ye(t),Gl(X,t)]);return n}var ad=j(Ir(Dc,{toCode:()=>({runtime:\"Schema.Json\",Type:\"Schema.Json\"})})),N6=Gl(X,ad),L6=sn(\"effect/schema/Json\",ad),$6=J({message:X,name:Oa(X),stack:Oa(X),cause:Oa(ad)}),zD=j(Ir(s_,{toCode:()=>({runtime:\"Schema.MutableJson\",Type:\"Schema.MutableJson\"})})),j6=sn(\"effect/schema/MutableJson\",zD);function B6(e){return Co(e.ast)}function q6(e){return e.ast.context?.annotations}var An={};uo(An,{Array:()=>J8,Boolean:()=>y1,ConfigError:()=>xu,FalseValues:()=>x1,LogLevel:()=>S1,Port:()=>b1,Record:()=>H8,TrueValues:()=>g1,all:()=>l1,boolean:()=>nY,date:()=>cY,duration:()=>rY,fail:()=>K8,finite:()=>Q8,int:()=>X8,isConfig:()=>u1,literal:()=>eY,literals:()=>tY,logLevel:()=>sY,map:()=>f1,mapOrFail:()=>L8,nested:()=>uY,nonEmptyString:()=>Z8,number:()=>Y8,option:()=>q8,orElse:()=>$8,port:()=>oY,redacted:()=>iY,schema:()=>vn,string:()=>V8,succeed:()=>G8,unwrap:()=>p1,url:()=>aY,withDefault:()=>d1});var gu={};uo(gu,{ConfigProvider:()=>Fa,SourceError:()=>lg,constantCase:()=>x8,fromDir:()=>P8,fromDotEnv:()=>M8,fromDotEnvContents:()=>r1,fromEnv:()=>n1,fromEnvRecord:()=>Kw,fromUnknown:()=>E8,layer:()=>b8,layerAdd:()=>S8,make:()=>pg,makeArray:()=>Hw,makeRecord:()=>dg,makeValue:()=>md,mapInput:()=>Jw,nested:()=>y8,orElse:()=>Ww});var WD=\"~effect/platform/PlatformError\",du=class extends yo(\"BadArgument\"){get message(){return`${this.module}.${this.method}${this.description?`: ${this.description}`:\"\"}`}};var HD=class extends yo(\"PlatformError\"){constructor(t){\"cause\"in t?super({reason:t,cause:t.cause}):super({reason:t})}[WD]=WD;get message(){return this.reason.message}};var W6=\"~effect/Stream\",H6={_R:_,_E:_,_A:_},J6={[W6]:H6,pipe(){return K(this,arguments)}},JD=e=>{let t=Object.create(J6);return t.channel=e,t};var G6=\"~effect/Sink\";var V6={_A:_,_In:_,_L:_,_E:_,_R:_},Z6={[G6]:V6,pipe(){return K(this,arguments)}};var Y6=e=>{let t=Object.create(Z6);return t.transform=e,t},KD=e=>Ce((t,n)=>x(T(e.transform(t,n),G)));var GD=e=>Y6(t=>{let n=[];if(e<=0)return x([n]);let r;return t.pipe(T(o=>{if(n.length+o.length<=e)return n.push(...o),n.length===e?G():te;for(let s=0;s<o.length;s++)if(n.push(o[s]),n.length===e)return s+1<o.length&&(r=o.slice(s+1)),G();return te}),Ct({disableYield:!0}),Ge(()=>x([n,r])))});var Do={};uo(Do,{DefaultChunkSize:()=>iN,Do:()=>FZ,TypeId:()=>sN,accumulate:()=>xZ,aggregate:()=>uZ,aggregateWithin:()=>Dw,bind:()=>DZ,bindEffect:()=>NZ,bindTo:()=>LZ,broadcast:()=>PN,broadcastN:()=>fZ,buffer:()=>bV,bufferArray:()=>SV,callback:()=>uN,catch:()=>dd,catchCause:()=>yN,catchCauseFilter:()=>CV,catchCauseIf:()=>AV,catchFilter:()=>EN,catchIf:()=>SN,catchReason:()=>TV,catchReasons:()=>vV,catchTag:()=>IV,catchTags:()=>wV,changes:()=>yZ,changesWith:()=>FN,changesWithEffect:()=>bZ,chunks:()=>ON,collect:()=>gZ,combine:()=>JV,combineArray:()=>KV,concat:()=>ud,cross:()=>Q5,crossWith:()=>gN,debounce:()=>XV,decodeText:()=>SZ,die:()=>d5,drain:()=>L5,drainFork:()=>$5,drop:()=>Uw,dropRight:()=>zV,dropUntil:()=>jV,dropUntilEffect:()=>BV,dropWhile:()=>AN,dropWhileEffect:()=>CN,dropWhileFilter:()=>qV,empty:()=>ai,encodeText:()=>EZ,ensuring:()=>kZ,fail:()=>cd,failCause:()=>fN,failCauseSync:()=>p5,failSync:()=>l5,filter:()=>lV,filterEffect:()=>pV,filterMap:()=>dV,filterMapEffect:()=>mV,flatMap:()=>hu,flatten:()=>Rw,flattenArray:()=>N5,flattenEffect:()=>_5,flattenIterable:()=>H5,flattenTake:()=>J5,forever:()=>W5,fromArray:()=>fd,fromArrayEffect:()=>x5,fromArrays:()=>y5,fromAsyncIterable:()=>E5,fromChannel:()=>O,fromEffect:()=>ag,fromEffectDrain:()=>aN,fromEffectRepeat:()=>cN,fromEffectSchedule:()=>a5,fromEventListener:()=>T5,fromIterable:()=>Ma,fromIterableEffect:()=>h5,fromIterableEffectRepeat:()=>g5,fromIteratorSucceed:()=>m5,fromPubSub:()=>b5,fromPubSubTake:()=>lN,fromPull:()=>ao,fromQueue:()=>mu,fromReadableStream:()=>S5,fromSchedule:()=>I5,fromSubscription:()=>w5,groupAdjacentBy:()=>aZ,groupBy:()=>sZ,groupByKey:()=>iZ,grouped:()=>rZ,groupedWithin:()=>oZ,haltWhen:()=>AZ,ignore:()=>_V,ignoreCause:()=>MV,interleave:()=>TZ,interleaveWith:()=>DN,interruptWhen:()=>vZ,intersperse:()=>UN,intersperseAffixes:()=>wZ,isStream:()=>Ie,iterate:()=>A5,let:()=>UZ,limitBytes:()=>DV,make:()=>u5,map:()=>nr,mapAccum:()=>GV,mapAccumArray:()=>Pa,mapAccumArrayEffect:()=>ZV,mapAccumEffect:()=>VV,mapArray:()=>pN,mapArrayEffect:()=>M5,mapBoth:()=>R5,mapEffect:()=>ld,mapError:()=>IN,merge:()=>_w,mergeAll:()=>Y5,mergeEffect:()=>cg,mergeLeft:()=>V5,mergeResult:()=>G5,mergeRight:()=>Z5,mkArrayBuffer:()=>VZ,mkString:()=>GZ,mkUint8Array:()=>ZZ,never:()=>O5,onEnd:()=>OZ,onError:()=>CZ,onExit:()=>NN,onFirst:()=>$N,onStart:()=>LN,orDie:()=>RV,orElseIfEmpty:()=>OV,orElseSucceed:()=>kV,paginate:()=>v5,partition:()=>gV,partitionEffect:()=>hV,partitionQueue:()=>Fw,peel:()=>yV,pipeThrough:()=>hZ,pipeThroughChannel:()=>pZ,pipeThroughChannelOrFail:()=>mZ,prepend:()=>K5,provide:()=>jN,provideContext:()=>RZ,provideService:()=>_Z,provideServiceEffect:()=>Nw,race:()=>fV,raceAll:()=>xN,range:()=>C5,rechunk:()=>kN,repeat:()=>j5,repeatElements:()=>z5,result:()=>P5,retry:()=>PV,run:()=>qN,runCollect:()=>zN,runCount:()=>$Z,runDrain:()=>fg,runFold:()=>BZ,runFoldEffect:()=>qZ,runForEach:()=>HZ,runForEachArray:()=>WN,runForEachWhile:()=>JZ,runHead:()=>zZ,runIntoPubSub:()=>t8,runIntoQueue:()=>o8,runLast:()=>WZ,runSum:()=>jZ,scan:()=>YV,scanEffect:()=>QV,schedule:()=>B5,scoped:()=>k5,service:()=>s5,serviceOption:()=>i5,share:()=>dZ,sliding:()=>WV,slidingSize:()=>RN,split:()=>HV,splitLines:()=>IZ,succeed:()=>pu,suspend:()=>tr,switchMap:()=>D5,sync:()=>f5,take:()=>UV,takeRight:()=>NV,takeUntil:()=>wN,takeUntilEffect:()=>TN,takeWhile:()=>vN,takeWhileEffect:()=>$V,takeWhileFilter:()=>LV,tap:()=>mN,tapBoth:()=>F5,tapCause:()=>EV,tapError:()=>bN,tapSink:()=>U5,throttle:()=>nZ,throttleEffect:()=>_N,tick:()=>c5,timeout:()=>q5,timeoutOrElse:()=>hN,toAsyncIterable:()=>e8,toAsyncIterableEffect:()=>XZ,toAsyncIterableWith:()=>$w,toChannel:()=>Or,toPubSub:()=>n8,toPubSubTake:()=>HN,toPull:()=>KZ,toQueue:()=>r8,toReadableStream:()=>YZ,toReadableStreamEffect:()=>QZ,toReadableStreamWith:()=>Lw,transduce:()=>cZ,transformPull:()=>St,transformPullBracket:()=>kw,unfold:()=>dN,unwrap:()=>ii,updateContext:()=>BN,updateService:()=>MZ,when:()=>xV,withExecutionPlan:()=>FV,withSpan:()=>PZ,zip:()=>eV,zipFlatten:()=>rV,zipLatest:()=>cV,zipLatestAll:()=>Pw,zipLatestWith:()=>uV,zipLeft:()=>tV,zipRight:()=>nV,zipWith:()=>Mw,zipWithArray:()=>ug,zipWithIndex:()=>oV,zipWithNext:()=>sV,zipWithPrevious:()=>iV,zipWithPreviousAndNext:()=>aV});var ZD=\"~effect/RcMap\",Q6=e=>({[ZD]:ZD,lookup:e.lookup,context:e.context,scope:e.scope,idleTimeToLive:e.idleTimeToLive,capacity:e.capacity,state:{_tag:\"Open\",map:ml()},pipe(){return K(this,arguments)}}),YD=e=>Gn(t=>{let n=t.context,r=Xe(n,xn),o=Q6({lookup:e.lookup,context:n,scope:r,idleTimeToLive:typeof e.idleTimeToLive==\"function\"?Td(e.idleTimeToLive,mt):Le(mt(e.idleTimeToLive??Ms)),capacity:Math.max(e.capacity??Number.POSITIVE_INFINITY,0)});return At(Eo(r,()=>{if(o.state._tag===\"Closed\")return te;let s=o.state.map;return o.state={_tag:\"Closed\"},qs(s,([,i])=>gr(Fe(i.scope,ut))).pipe(bn(()=>oe(()=>{Qm(s)})))}),o)}),vw=l(2,(e,t)=>Hs(n=>{if(e.state._tag===\"Closed\")return vo;let r=e.state,o=th(),s=Xs(r.map,t),i;if(s._tag===\"Some\")i=s.value,i.refCount++;else{if(Number.isFinite(e.capacity)&&u_(e.state.map)>=e.capacity)return P(new ub(`RcMap attempted to exceed capacity of ${e.capacity}`));{i={deferred:qi(),scope:mr(),idleTimeToLive:e.idleTimeToLive(t),finalizer:void 0,fiber:void 0,expiresAt:0,refCount:1},i.finalizer=X6(e,t,i),gl(r.map,t,i);let c=new Map(e.context.mapUnsafe);o.context.mapUnsafe.forEach((u,f)=>{c.set(f,u)}),c.set(xn.key,i.scope),e.lookup(t).pipe(Ao(ho(c)),da(i.scope)).addObserver(u=>pc(i.deferred,u))}}let a=Fr(o.context,xn);return Ht(a,i.finalizer).pipe(yn(n(zi(i.deferred))))}));var X6=(e,t,n)=>Gn(r=>{if(n.refCount--,n.refCount>0)return te;if(e.state._tag===\"Closed\"||!c_(e.state.map,t)||Gu(n.idleTimeToLive))return e.state._tag===\"Open\"&&xl(e.state.map,t),Fe(n.scope,ut);if(!tc(n.idleTimeToLive))return te;let o=r.getRef(_f);return n.expiresAt=o.currentTimeMillisUnsafe()+_n(n.idleTimeToLive),n.fiber?te:(n.fiber=sS(function s(i){let a=o.currentTimeMillisUnsafe(),c=n.expiresAt-a;return c<=0?e.state._tag===\"Closed\"||n.refCount>0?te:(xl(e.state.map,t),i(Fe(n.scope,ut))):T(o.sleep(go(c)),()=>s(i))}).pipe(ea(oe(()=>{n.fiber=void 0})),Ao(r.context),da(e.scope)),te)});var Aw=l(2,(e,t)=>ta(n=>{if(e.state._tag===\"Closed\")return te;let r=Xs(e.state.map,t);if(r._tag===\"None\"||Gu(r.value.idleTimeToLive))return te;let o=r.value;return o.expiresAt=n.currentTimeMillisUnsafe()+_n(o.idleTimeToLive),te}));var e5=\"~effect/RcRef\",Cw={_tag:\"Empty\"},t5={_tag:\"Closed\"},n5={_A:_,_E:_},Ow=class{[e5]=n5;pipe(){return K(this,arguments)}state=Cw;semaphore=Jc(1);acquire;context;scope;idleTimeToLive;constructor(t,n,r,o){this.acquire=t,this.context=n,this.scope=r,this.idleTimeToLive=o}},XD=e=>Gn(t=>{let n=t.context,r=Xe(n,xn),o=new Ow(e.acquire,n,r,e.idleTimeToLive?mt(e.idleTimeToLive):void 0);return At(Eo(r,()=>{let s=o.state._tag===\"Acquired\"?Fe(o.state.scope,ut):te;return o.state=t5,s}),o)}),r5=e=>Hs(function t(n){switch(e.state._tag){case\"Closed\":return vo;case\"Acquired\":return e.state.refCount++,e.state.fiber?At(ei(e.state.fiber),e.state):x(e.state);case\"Empty\":{let r=mr();return e.semaphore.withPermit(z(()=>e.state._tag!==\"Empty\"?t(n):n(xr(e.acquire,Pt(e.context,xn,r))).pipe($(o=>{let s={_tag:\"Acquired\",value:o,scope:r,fiber:void 0,refCount:1,invalidated:!1};return e.state=s,s}),yr(o=>os(o)?Fe(r,o):te))))}}}),eN=xe(function*(e){let t=e,n=yield*r5(t),r=yield*Qi,o=t.idleTimeToLive!==void 0&&tc(t.idleTimeToLive);return yield*Eo(r,()=>(n.refCount--,n.refCount>0?te:t.idleTimeToLive===void 0?(t.state=Cw,Fe(n.scope,ut)):n.invalidated?Fe(n.scope,ut):o?(n.fiber=Vi(t.idleTimeToLive).pipe(T(()=>t.state._tag===\"Acquired\"&&t.state.refCount===0?(t.state=Cw,Fe(n.scope,ut)):te),ea(oe(()=>{n.fiber=void 0})),Ao(t.context),da(t.scope)),te):te)),n.value});var tN=XD,nN=eN;var sN=\"~effect/Stream\",Ie=e=>M(e,sN),iN=Al,O=JD,ag=e=>O(Eh($(e,Ee))),s5=e=>ag(tS(e)),i5=e=>ag(nS(e)),aN=e=>ao(x(T(e,()=>G()))),cN=e=>ao(x($(e,Ee))),a5=(e,t)=>ao(en(function*(){let n=yield*qr(t),r=yield*To(e,Ft,Ft.defaultValue()),o=!0,s=z(()=>n(r)).pipe(T(i=>To(e,Ft,i)),$(i=>(r=i,Ee(i))));return z(()=>o?(o=!1,x(Ee(r))):s)})),c5=e=>ao(oe(()=>{let t=!0,n=x(Ee(void 0)),r=Vb(n,e);return z(()=>t?(t=!1,n):r)})),ao=e=>O(Nt(e)),St=(e,t)=>O(Ce((n,r)=>T(Mo(e.channel,r),o=>t(o,r)))),kw=(e,t)=>O(to((n,r,o)=>T(Mo(e.channel,r),s=>t(s,r,o)))),Or=e=>e.channel,uN=(e,t)=>O(D_(e,t)),ai=O(Kc),pu=e=>O(Sh(Ee(e))),u5=(...e)=>fd(e),f5=e=>O(L_(()=>Ee(e()))),tr=e=>O(bh(()=>e().channel)),cd=e=>O(_o(e)),l5=e=>O(j_(e)),fN=e=>O(Cl(e)),d5=e=>O(nI(e)),p5=e=>O(B_(e)),m5=(e,t)=>O(tI(()=>e,t)),Ma=(e,t)=>Array.isArray(e)&&t?.chunkSize===void 0?fd(e):O(N_(e,t?.chunkSize)),h5=e=>ii($(e,Ma)),g5=e=>hu(cN(e),Ma),fd=e=>Me(e)?O(Sh(e)):ai,x5=e=>ii($(e,fd)),y5=(...e)=>O(eI(cx(e,Me))),mu=e=>O(z_(e)),b5=e=>O(W_(e)),lN=e=>O(H_(e)),S5=e=>O(J_(e)),E5=(e,t)=>O(K_(e,t)),I5=e=>ao($(gc(e),t=>Ge($(t(void 0),Ee),()=>G()))),w5=e=>O(oI(e)),T5=(e,t,n)=>uN(r=>{function o(s){A_(r,s)}return Xi(oe(()=>e.addEventListener(t,o,n)),()=>oe(()=>e.removeEventListener(t,o,n)))},{bufferSize:typeof n==\"object\"?n.bufferSize:void 0}),dN=(e,t)=>ao(oe(()=>{let n=e;return T(z(()=>t(n)),r=>r===void 0?G():(n=r[1],x(Ee(r[0]))))})),v5=(e,t)=>ao(oe(()=>{let n=e,r=!1;return z(function o(){return r?G():T(t(n),([s,i])=>(ae(i)?r=!0:n=i.value,Me(s)?x(s):o()))})})),A5=(e,t)=>dN(e,n=>x([n,t(n)])),C5=(e,t,n=Al)=>e>t?ai:ao(oe(()=>{let r=Math.max(1,n),o=e,s=!1;return z(()=>{if(s)return G();let i=t-o+1;if(i>r){let c=rx(o,o+r-1);return o+=r,x(c)}let a=rx(o,o+i-1);return s=!0,x(a)})})),O5=O($_),ii=e=>O(xs($(e,Or))),k5=e=>O(IM(e.channel)),nr=l(2,(e,t)=>tr(()=>{let n=0;return O(no(e.channel,Pr(r=>t(r,n++))))})),R5=l(2,(e,t)=>e.pipe(nr(t.onSuccess),IN(t.onFailure))),pN=l(2,(e,t)=>O(no(e.channel,t))),ld=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,Ih(t,n),no(Ee),O)),_5=l(e=>Ie(e[0]),(e,t)=>ld(e,_,t)),M5=l(2,(e,t)=>O(Ih(e.channel,t))),P5=e=>e.pipe(nr(se),dd(t=>pu(re(t)))),mN=l(e=>Ie(e[0]),(e,t,n)=>ld(e,r=>At(t(r),r),n)),F5=l(2,(e,t)=>e.pipe(bN(t.onError),mN(t.onElement,{concurrency:t.concurrency}))),U5=l(2,(e,t)=>kw(e,xe(function*(n,r,o){let s=Yn(),i=Yn(),a,c,u=!1,f=!1,d=s.whenOpen(z(()=>{if(a){let m=a;return a=void 0,f||s.closeUnsafe(),At(i.open,m)}return G()}));yield*z(()=>t.transform(d,o)).pipe(m=>oS(m,g=>(u=!0,os(g)&&(c=g.cause),i.open),!0),yt(o));let p=n.pipe(T(m=>(a=m,i.closeUnsafe(),s.openUnsafe(),At(i.await,m))),Ge(()=>(f=!0,i.closeUnsafe(),s.openUnsafe(),T(i.await,()=>G()))));return z(()=>c?tt(c):u?n:p)}))),hu=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,Z_(r=>t(r).channel,n),O)),D5=l(e=>Ie(e[0]),(e,t,n)=>e.channel.pipe(rn,bM(r=>t(r).channel,n),O)),Rw=l(e=>Ie(e[0]),(e,t)=>hu(e,_,t)),N5=e=>O(rn(e.channel)),L5=e=>O(X_(e.channel)),$5=l(2,(e,t)=>cg(e,fg(t))),j5=l(2,(e,t)=>O(eM(e.channel,t))),B5=l(2,(e,t)=>e.channel.pipe(rn,tM(t),no(Ee),O)),q5=l(2,(e,t)=>hN(e,{duration:t,orElse:()=>ai})),hN=l(2,(e,t)=>{let n=mt(t.duration);if(!tc(n))return e;if(Gu(n))return tr(t.orElse);let r=Symbol();return yN(tr(()=>{let s=th().getRef(_f),i=_n(n),a,c=Yn(!1);return _w(St(e,(u,f)=>z(()=>(a=s.currentTimeMillisUnsafe()+i,c.openUnsafe(),u)).pipe($(d=>(c.closeUnsafe(),a=void 0,d)),x)),aN(en(function*(){for(;;)if(yield*c.await,!(a===void 0||(yield*Vi(a-s.currentTimeMillisUnsafe()),a===void 0)||a-s.currentTimeMillisUnsafe()>0))return yield*hr(r)})),{haltStrategy:\"left\"})}),o=>o.reasons.find(i=>i._tag===\"Die\"&&i.defect===r)?t.orElse():fN(o))}),z5=l(2,(e,t)=>O(Ce((n,r)=>$(me(rn(e.channel))(n,r),o=>{let s,i=en(function*(){let a=yield*o,c=Ee(a);return s=(yield*gc(t))(a).pipe(At(c),Ge(f=>(s=void 0,i))),c});return z(()=>s??i)})))),W5=e=>O(iI(e.channel)),H5=e=>hu(e,Ma),J5=e=>e.channel.pipe(rn,Q_,O),ud=l(2,(e,t)=>Rw(fd([e,t]))),K5=l(2,(e,t)=>ud(Ma(t),e)),_w=l(e=>Ie(e[0])&&Ie(e[1]),(e,t,n)=>O(Ah(Or(e),Or(t),n))),cg=l(2,(e,t)=>e.channel.pipe(SM(t),O)),G5=l(2,(e,t)=>_w(nr(e,se),nr(t,re))),V5=l(2,(e,t)=>cg(e,fg(t))),Z5=l(2,(e,t)=>cg(t,fg(e))),Y5=l(2,(e,t)=>Rw(Ma(e),t)),Q5=l(2,(e,t)=>gN(e,t,(n,r)=>[n,r])),gN=l(3,(e,t,n)=>hu(e,r=>nr(t,o=>n(r,o)))),Mw=l(3,(e,t,n)=>ug(e,t,X5(n))),X5=e=>(t,n)=>{let r=Math.min(t.length,n.length),o=[];for(let s=0;s<r;s++)o.push(e(t[s],n[s]));return[o,t.slice(r),n.slice(r)]},ug=l(3,(e,t,n)=>O(to(xe(function*(r,o){let s=yield*Mo(e.channel,o),i=yield*Mo(t.channel,o),a=en(function*(){let f=yield*yt(s,o),d=yield*yt(i,o);return yield*h_([f,d])}),c={_tag:\"PullBoth\"};return en(function*(){let[f,d]=c._tag===\"PullBoth\"?yield*a:c._tag===\"PullLeft\"?[yield*s,c.rightArray]:[c.leftArray,yield*i],p=n(f,d);return Me(p[1])?c={_tag:\"PullRight\",leftArray:p[1]}:Me(p[2])?c={_tag:\"PullLeft\",rightArray:p[2]}:c={_tag:\"PullBoth\"},p[0]})})))),eV=l(2,(e,t)=>Mw(e,t,(n,r)=>[n,r])),tV=l(2,(e,t)=>ug(e,t,(n,r)=>{let o=Math.min(n.length,r.length),s=n.slice(0,o),i=n.slice(o),a=r.slice(o);return[s,i,a]})),nV=l(2,(e,t)=>ug(e,t,(n,r)=>{let o=Math.min(n.length,r.length),s=r.slice(0,o),i=n.slice(o),a=r.slice(o);return[s,i,a]})),rV=l(2,(e,t)=>Mw(e,t,(n,r)=>[...n,r])),oV=e=>nr(e,(t,n)=>[t,n]),sV=e=>Pa(e,R,(t,n)=>{let r=0;t._tag===\"None\"&&(r=1,t=k(n[0]));let o=gt();for(;r<n.length;r++){let s=t.value;t=k(n[r]),o.push([s,t])}return[t,o]},{onHalt(t){return t._tag===\"Some\"?[[t.value,R()]]:[]}}),iV=e=>Pa(e,R,(t,n)=>{let r=gt();for(let o=0;o<n.length;o++){let s=n[o];r.push([t,s]),t=k(n[o])}return[t,r]}),aV=e=>Pa(e,()=>({prev:R(),current:R()}),(t,n)=>{let r=0,o;t.current._tag===\"None\"?(r=1,o=n[0],t.current=k(o)):o=t.current.value;let s=gt();for(;r<n.length;r++){let i=n[r];t.current=k(i),s.push([t.prev,o,t.current]),t.prev=k(o),o=i}return[t,s]},{onHalt(t){return t.current._tag===\"Some\"?[[t.prev,t.current.value,R()]]:[]}}),Pw=(...e)=>O(bh(()=>{let t=[],n=new Set,r=Yn();return vh(eI(e.map((o,s)=>o.channel.pipe(rn,Ih(i=>(t[s]=i,n.has(s)?x(Ee(t.slice())):(n.add(s),n.size<e.length?r.await:At(r.open,Ee(t.slice()))))),nM(Lt)))),{concurrency:\"unbounded\",bufferSize:0})})),cV=l(2,(e,t)=>Pw(e,t)),uV=l(3,(e,t,n)=>nr(Pw(e,t),([r,o])=>n(r,o))),xN=(...e)=>O(Ce((t,n)=>oe(()=>{let r,o=Zb(e.map(s=>{let i=vt(n);return Mo(s.channel,i).pipe(T(a=>Kb(x(a),a)),yr(a=>a._tag===\"Success\"?r?Fe(i,a):(r=a.value[0],te):Fe(i,a)),$(([,a])=>a))}));return z(()=>r??o)}))),fV=l(2,(e,t)=>xN(e,t)),lV=l(2,(e,t)=>O(rM(Or(e),t))),dV=l(2,(e,t)=>O(oM(Or(e),t))),pV=l(2,(e,t)=>O(sM(Or(e),t))),mV=l(2,(e,t)=>O(iM(Or(e),t))),Fw=l(e=>Ie(e[0]),xe(function*(e,t,n){let r=yield*Qi,o=yield*Mo(e.channel,r),s=n?.capacity===\"unbounded\"?void 0:n?.capacity??iN,i=yield*wr({capacity:s}),a=yield*wr({capacity:s});return yield*en(function*(){for(;;){let c=yield*o,u=[],f=[];for(let p=0;p<c.length;p++){let m=t(c[p]);ie(m)?u.push(m.failure):f.push(m.success)}let d;if(f.length>0){let p=yh(i,f);p.length>0&&(d=yield*wc(ha(i,p)))}if(u.length>0){let p=yh(a,u);p.length>0&&(yield*ha(a,p))}d&&(yield*Bc(d))}}).pipe(Wr(c=>(ti(i,c),ti(a,c),te)),yt(r)),[i,a]})),hV=l(e=>Ie(e[0]),(e,t,n)=>$(Fw(ld(e,r=>t(r),n),r=>r,n),([r,o])=>[mu(r),mu(o)])),gV=l(e=>Ie(e[0]),(e,t,n)=>$(Fw(e,t,{capacity:n?.bufferSize??16}),([r,o])=>[mu(o),mu(r)])),xV=l(2,(e,t)=>t.pipe($(n=>n?e:ai),ii)),yV=l(2,xe(function*(e,t){let n,r=yield*pI(e.channel),o=ft(r,a=>(n=a,tt(a))),s=ao(x(o)),i=yield*qN(s,t);return n?[i,ai]:(s=ao(x(r)),[i,s])})),bV=l(2,(e,t)=>O(TM(e.channel,t))),SV=l(2,(e,t)=>O(wM(e.channel,t))),yN=l(2,(e,t)=>e.channel.pipe(Vc(n=>t(n).channel),O)),EV=l(2,(e,t)=>e.channel.pipe(Th(t),O)),dd=l(2,(e,t)=>O(Xr(e.channel,n=>t(n).channel)));var bN=l(2,(e,t)=>e.channel.pipe(uM(t),O)),SN=l(e=>Ie(e[0]),(e,t,n,r)=>O(fM(Or(e),t,o=>n(o).channel,r&&(o=>r(o).channel)))),EN=l(e=>Ie(e[0]),(e,t,n,r)=>O(lM(Or(e),t,o=>n(o).channel,r&&(o=>r(o).channel)))),IV=l(e=>Ie(e[0]),(e,t,n,r)=>{let o=Array.isArray(t)?s=>M(s,\"_tag\")&&t.includes(s._tag):$t(t);return SN(e,o,n,r)}),wV=l(e=>Ie(e[0]),(e,t,n)=>{let r;return EN(e,o=>(r??=Object.keys(t),M(o,\"_tag\")&&Xp(o._tag)&&r.includes(o._tag)?se(o):re(o)),o=>t[o._tag](o),n)}),TV=l(e=>Ie(e[0]),(e,t,n,r,o)=>O(dM(Or(e),t,n,(s,i)=>r(s,i).channel,o&&((s,i)=>o(s,i).channel)))),vV=l(e=>Ie(e[0]),(e,t,n,r)=>{let o=Object.create(null);for(let i of Object.keys(n)){let a=n[i];o[i]=(c,u)=>a(c,u).channel}let s=r&&((i,a)=>r(i,a).channel);return O(pM(e.channel,t,o,s))}),IN=l(2,(e,t)=>O(mM(e.channel,t))),AV=l(3,(e,t,n)=>O(cM(e.channel,t,r=>n(r).channel))),CV=l(3,(e,t,n)=>O(aI(e.channel,t,(r,o)=>n(r,o).channel))),OV=l(2,(e,t)=>O(Y_(e.channel,n=>Or(t())))),kV=l(2,(e,t)=>dd(e,n=>pu(t(n)))),RV=e=>O(hM(e.channel)),_V=l(e=>Ie(e[0]),(e,t)=>O(gM(e.channel,t))),MV=l(e=>Ie(e[0]),(e,t)=>O(xM(e.channel,t))),PV=l(2,(e,t)=>O(yM(e.channel,t))),oN=(e,t)=>ii($(qr(t),n=>{let r=Ft.defaultValue(),o=()=>dd(Nw(e,Ft,oe(()=>r)),s=>ii(Ge($(n(s),i=>(r=i,ii(At(qf,o())))),()=>x(cd(s)))));return o()})),FV=l(e=>Ie(e[0]),(e,t,n)=>tr(()=>{let r=n?.preventFallbackOnPartialStream??!1,o=0,s={attempt:0,stepIndex:0},i=Nw(Vp,oe(()=>(s={attempt:s.attempt+1,stepIndex:o},s))),a=n?.onEvent===void 0?void 0:Bb(n.onEvent,()=>s),c,u=a===void 0?_:p=>NN(LN(p,$(a.begin,m=>{c=m})),m=>z(()=>{if(c===void 0)return te;let g=c;return c=void 0,a.end(g,m)})),f=R(),d=tr(()=>{let p=t.steps[o];if(!p)return cd(ex(f));let m=i(u(jN(e,p.provide))),g=!1;if(_e(f)){let b=f.value,I=!1,w=m;m=tr(()=>I?w:(I=!0,cd(b))),m=oN(m,Bf(p,!1))}else{let b=Bf(p,!0);m=b?oN(m,b):m}return dd(r?$N(m,b=>(g=!0,te)):m,b=>(o++,r&&g?cd(b):(f=k(b),d)))});return d})),UV=l(2,(e,t)=>t<1?ai:wN(e,(n,r)=>r===t-1)),DV=l(3,(e,t,n)=>tr(()=>{let r=BigInt(t),o=BigInt(0),s=!1;return ud(vN(e,i=>{let a=o+BigInt(i.length);return a>r?(s=!0,!1):(o=a,!0)}),tr(()=>s?n():ai))})),NV=l(2,(e,t)=>Pa(e,hs,(n,r)=>(rh(n,r),n.length>t&&Il(n,n.length-t),[n,Ss]),{onHalt(n){return ma(n)}})),wN=l(e=>Ie(e[0]),(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=0,i=!1;return T(z(()=>i?G():r),c=>{let u=c.findIndex(f=>t(f,s++));if(u>=0){i=!0;let f=c.slice(0,n?.excludeLast?u:u+1);return Me(f)?x(f):G()}return x(c)})}))),TN=l(e=>Ie(e[0]),(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=0,i=!1;return en(function*(){if(i)return yield*G();let a=yield*r;for(let c=0;c<a.length;c++)if(yield*t(a[c],s++)){i=!0;let u=a.slice(0,n?.excludeLast?c:c+1);return Me(u)?u:yield*G()}return a})}))),vN=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=0,s=!1,i=T(z(()=>s?G():n),a=>{let c=[];for(let u=0;u<a.length;u++){if(!t(a[u],o++)){s=!0;break}c.push(a[u])}return Me(c)?x(c):s?G():i});return i}))),LV=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!1,s=T(z(()=>o?G():n),i=>{let a=[];for(let c=0;c<i.length;c++){let u=t(i[c]);if(ie(u)){o=!0;break}a.push(u.success)}return Me(a)?x(a):o?G():s});return s}))),$V=l(2,(e,t)=>TN(e,(n,r)=>$(t(n,r),o=>!o),{excludeLast:!0})),Uw=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=0,s=n.pipe(T(i=>o>=t?x(i):(o+=i.length,o<=t?s:x(i.slice(t-o)))));return s}))),jV=l(2,(e,t)=>Uw(AN(e,(n,r)=>!t(n,r)),1)),BV=l(2,(e,t)=>Uw(CN(e,(n,r)=>$(t(n,r),o=>!o)),1)),AN=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=0,i=T(n,a=>{let c=a.findIndex(u=>!t(u,s++));return c===-1?i:(o=!1,x(a.slice(c)))});return z(()=>o?i:n)}))),qV=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=T(n,i=>{let a=i.findIndex(c=>ie(t(c)));return a===-1?s:(o=!1,x(i.slice(a)))});return z(()=>o?s:n)}))),CN=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s=0,i=en(function*(){for(;;){let a=yield*n;for(let c=0;c<a.length;c++)if(!(yield*t(a[c],s++)))return o=!1,a.slice(c)}});return z(()=>o?i:n)}))),zV=l(2,(e,t)=>t<=0?e:St(e,(n,r)=>oe(()=>{let o=hs(),s=T(n,i=>{oh(o,i);let a=o.length-t,c=pa(o,a);return qt(c)?x(c):s});return s}))),ON=e=>e.channel.pipe(no(Ee),O),kN=l(2,(e,t)=>(t=Math.max(1,t),St(e,(n,r)=>oe(()=>{let o=gt(),s=0,i,a=!1;return z(function c(){if(a)return G();if(i===void 0)return T(n,u=>o.length===0&&u.length===t?x(u):o.length+u.length<t?(o.push(...u),c()):(i=u,c()));for(;s<i.length;)if(o.push(i[s++]),o.length===t){let u=o;return o=[],x(u)}return s=0,i=void 0,c()}).pipe(Ge(()=>{if(o.length===0)return G();let c=o;return a=!0,o=[],x(c)}))})))),WV=l(2,(e,t)=>RN(e,t,1)),RN=l(3,(e,t,n)=>St(e,(r,o)=>oe(()=>{let s=null,i=hs(),a=!1,c=0,u=Zi(r,{onSuccess(f){if(oh(i,f),c>0){let p=i.length;Il(i,c),c=Math.max(0,c-p)}if(i.length<t)return u;a=!0;let d=[];for(;i.length>=t;)if(t===n)d.push(pa(i,t));else if(d.push(S_(i,t)),t===1&&n<=0)Yr(i);else{let p=i.length;Il(i,n),c=Math.max(0,n-p)}return x(d)},onFailure(f){return a&&Il(i,t-n),i.length===0?tt(f):(s=f,x(Ee(ma(i))))}});return z(()=>s?tt(s):u)}))),HV=l(2,(e,t)=>Pa(e,gt,(n,r)=>{let o=gt();for(let s=0;s<r.length;s++)t(r[s])?qt(n)&&(o.push(n),n=[]):n.push(r[s]);return[n,o]},{onHalt(n){return qt(n)?Ee(n):Ss}})),JV=l(4,(e,t,n,r)=>sI(rn(e.channel),rn(t.channel),n,r).pipe(no(Ee),O)),KV=l(4,(e,t,n,r)=>O(sI(e.channel,t.channel,n,r))),GV=l(e=>Ie(e[0]),(e,t,n,r)=>O(Gc(e.channel,t,(o,s)=>{let i=gt();for(let a=0;a<s.length;a++){let[c,u]=n(o,s[a]);o=c,i.push(...u)}return[o,qt(i)?Ee(i):Ss]},r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0))),Pa=l(e=>Ie(e[0]),(e,t,n,r)=>O(Gc(e.channel,t,(o,s)=>{let[i,a]=n(o,s);return o=i,[o,Me(a)?Ee(a):Ss]},r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0))),Ss=gt(),VV=l(e=>Ie(e[0]),(e,t,n,r)=>e.channel.pipe(rn,Gc(t,(o,s)=>$(n(o,s),([i,a])=>[i,Me(a)?Ee(a):gt()]),r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0),O)),ZV=l(e=>Ie(e[0]),(e,t,n,r)=>e.channel.pipe(Gc(t,(o,s)=>$(n(o,s),([i,a])=>[i,Me(a)?Ee(a):Ss]),r?.onHalt?{onHalt(o){let s=r.onHalt(o);return Me(s)?Ee(s):Ss}}:void 0),O)),YV=l(3,(e,t,n)=>tr(()=>{let r=!0;return O(Gc(e.channel,Le(t),(o,s)=>{let i=gt();r&&(r=!1,i.push(o));for(let a=0;a<s.length;a++)o=n(o,s[a]),i.push(o);return[o,Ee(i)]}))})),QV=l(3,(e,t,n)=>e.channel.pipe(rn,aM(t,n),no(Ee),O)),XV=l(2,(e,t)=>St(e,xe(function*(n,r){let o=yield*_f,s=_n(mt(t)),i,a,c=1/0,u=Yn(),f=Yn(),d=Yn();yield*n.pipe(u.whenOpen,T(m=>(f.openUnsafe(),i=m,c=o.currentTimeMillisUnsafe()+s,te)),Ct({disableYield:!0}),Wr(m=>(a=m,c=o.currentTimeMillisUnsafe(),f.openUnsafe(),d.openUnsafe(),te)),yt(r));let p=z(function m(){let g=o.currentTimeMillisUnsafe(),b=c<g?s:Math.min(s,c-g);return T(Ec(Vi(b),d.await),()=>{if(o.currentTimeMillisUnsafe()<c)return m();if(i){f.closeUnsafe(),u.closeUnsafe();let w=x(Ee(Vd(i)));return i=void 0,w}else if(a)return tt(a);return m()})});return z(()=>{if(a){if(i){let m=x(Ee(Vd(i)));return i=void 0,m}return tt(a)}return u.openUnsafe(),f.whenOpen(p)})}))),_N=l(2,(e,t)=>{let n=t.burst??0;return t.strategy===\"enforce\"?eZ(e,t.cost,t.units,t.duration,n):tZ(e,t.cost,t.units,t.duration,n)}),eZ=(e,t,n,r,o)=>St(e,s=>ta(i=>{let a=_n(mt(r)),c=n+o<0?Number.POSITIVE_INFINITY:n+o,u=n,f=i.currentTimeMillisUnsafe();return x(T(s,function d(p){return T(t(p),m=>{let g=i.currentTimeMillisUnsafe(),I=(g-f)/a,w=u+I*n,E=w<0?c:Math.min(w,c);return m<=E?(u=E-m,f=g,x(p)):T(s,d)})}))})),tZ=(e,t,n,r,o)=>St(e,s=>ta(i=>{let a=_n(mt(r)),c=n+o<0?Number.POSITIVE_INFINITY:n+o,u=n,f=i.currentTimeMillisUnsafe();return x(T(s,d=>T(t(d),p=>{let m=i.currentTimeMillisUnsafe(),b=(m-f)/a,I=u+b*n,E=(I<0?c:Math.min(I,c))-p;if(E>=0)return u=E,f=m,x(d);let h=-E/n,A=Math.max(0,h*a);return A>0?T(Vi(A),()=>(u=E,f=m,x(d))):(u=E,f=m,x(d))})))})),nZ=l(2,(e,t)=>_N(e,{...t,cost:n=>x(t.cost(n))})),rZ=l(2,(e,t)=>ON(kN(e,t))),oZ=l(3,(e,t,n)=>Dw(e,GD(t),Lb(n))),sZ=l(e=>Ie(e[0]),(e,t,n)=>MN(e,xe(function*(r,o,s){for(let i=0;i<r.length;i++){let[a,c]=yield*t(r[i]),u=Xs(s,a),f=_e(u)?u.value:yield*pm(vw(o,a));yield*Aw(o,a),yield*Qn(f,c)}}),n)),iZ=l(e=>Ie(e[0]),(e,t,n)=>tr(()=>{let r=ml();return MN(e,xe(function*(o,s,i){for(let a=0;a<o.length;a++){let c=t(o[a]),u=Xs(r,c);ae(u)?gl(r,c,[o[a]]):u.value.push(o[a])}for(let[a,c]of r){let u=Xs(i,a),f=_e(u)?u.value:yield*pm(vw(s,a));yield*Aw(s,a),yield*ha(f,c)}Qm(r)}),n)})),MN=(e,t,n)=>kw(e,xe(function*(r,o,s){let i=yield*v_();yield*Ht(o,Qr(i));let a=ml(),c=yield*YD({lookup:u=>Xi(wr({capacity:n?.bufferSize??4096}).pipe(bn(f=>(gl(a,u,f),Qn(i,[u,mu(f)])))),f=>(xl(a,u),C_(f))),idleTimeToLive:n?.idleTimeToLive??Ur}).pipe(ss(s));return yield*zr({while:cn,body:Le(T(r,u=>t(u,c,a))),step:_r}).pipe(ft(u=>Dt(i,u)),yt(o)),Hc(i)})),aZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o,s,i=gt(),a=n.pipe(T(u=>{for(let f=0;f<u.length;f++){let d=u[f],p=t(d);if(s===void 0){o=p,s=[d];continue}else if(B(p,o)){s.push(d);continue}i.push([o,s]),o=p,s=[d]}if(qt(i)){let f=i;return i=[],x(f)}return a})),c=!1;return Ge(z(()=>c?G():a),()=>{c=!0;let u=s;return s=void 0,u&&qt(u)?x(Ee([o,u])):G()})}))),cZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o,s,i=z(()=>{if(s!==void 0){let c=s;return s=void 0,x(c)}return n}).pipe(Ws(c=>(o=rs(c),G()))),a=$(z(()=>t.transform(i,r)),([c,u])=>(s=u,Ee(c)));return z(()=>o||a)}))),uZ=l(2,(e,t)=>Dw(e,t,Nf)),Dw=l(3,(e,t,n)=>O(to(xe(function*(r,o,s){let i=yield*Mo(e.channel,o),a=Yn(!1),c=Symbol(),u=yield*wr({capacity:0});yield*i.pipe(a.whenOpen,T(h=>(a.closeUnsafe(),Qn(u,h))),Ct,ft(h=>Dt(u,h)),yt(s));let f=R(),d,p=!1,m=yield*gc(n),b=z(function h(){return T(m(f),()=>p?Qn(u,c):h())}).pipe(T(()=>Gi),Ge(()=>G())),I=gs(u).pipe(T(h=>h===c?G():(p=!0,x(h)))),w=z(()=>{if(d!==void 0){let h=d;return d=void 0,p=!0,x(h)}return a.openUnsafe(),I}),E=T(([h,A])=>!p&&u.state._tag===\"Done\"?G():(f=k(h),d=A,x(Ee(h))));return z(()=>u.state._tag===\"Done\"&&d===void 0?u.state.exit:(p=d!==void 0,x(z(()=>t.transform(w,s))))).pipe(T(h=>Ec(E(h),b)))})))),fZ=l(2,xe(function*(e,t){let n=yield*lZ(t),r=new Array(t.n),o=yield*xn;for(let s=0;s<t.n;s++){let i=vt(o),a=yield*Tl(n).pipe(To(xn,i));r[s]=rI(ph(a)).pipe(Ol(c=>Fe(i,c)),O)}return yield*xa(e.channel,s=>zc(n,s)).pipe(yr(s=>zc(n,s)),Tc),r})),lZ=e=>Xi(e.capacity===\"unbounded\"?dh(e):e.strategy===\"dropping\"?fh(e):e.strategy===\"sliding\"?lh(e):uh(e),wl),PN=l(2,(e,t)=>$(HN(e,t),lN)),dZ=l(2,(e,t)=>$(tN({acquire:PN(e,t),idleTimeToLive:t.idleTimeToLive}),n=>ii(nN(n)))),pZ=l(2,(e,t)=>O(cI(e.channel,t))),mZ=l(2,(e,t)=>O(uI(e.channel,t))),hZ=l(2,(e,t)=>e.channel.pipe(uI(KD(t)),wh(([n,r])=>r?Sh(r):Kc),O)),gZ=e=>ag(zN(e)),xZ=e=>Pa(e,gt,(t,n)=>{let r=sx(t,n);return[r,[r]]}),yZ=e=>FN(e,B),FN=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s;return T(n,function i(a){let c=[],u=0;for(o&&(o=!1,s=a[0],u=1,c.push(s));u<a.length;u++){let f=a[u];t(f,s)||(s=f,c.push(f))}return qt(c)?x(c):T(n,i)})}))),bZ=l(2,(e,t)=>St(e,(n,r)=>oe(()=>{let o=!0,s;return T(n,xe(function*i(a){let c=[],u=0;for(o&&(o=!1,s=a[0],u=1,c.push(s));u<a.length;u++){let f=a[u];(yield*t(f,s))||(s=f,c.push(f))}return qt(c)?c:yield*T(n,xe(i))}))}))),SZ=l(e=>Ie(e[0]),(e,t)=>tr(()=>{let n=new TextDecoder(t?.encoding);return nr(e,r=>n.decode(r,{stream:!0}))})),EZ=e=>tr(()=>{let t=new TextEncoder;return nr(e,n=>t.encode(n))}),IZ=e=>e.channel.pipe(cI(EM()),O),UN=l(2,(e,t)=>pN(e,(n,r)=>{let o=r===0?[]:[t],s=n.length-1;for(let i=0;i<n.length;i++)i===s?o.push(n[i]):o.push(n[i],t);return o})),wZ=l(2,(e,t)=>pu(t.start).pipe(ud(UN(e,t.middle)),ud(pu(t.end)))),TZ=l(2,(e,t)=>DN(e,t,Ma(nT([!0,!1])))),DN=l(3,(e,t,n)=>O(Ce(xe(function*(r,o){let s=yield*me(rn(n.channel))(r,o),i=Symbol(),a=!1,c=!1,u=(yield*me(rn(e.channel))(r,o)).pipe(Ge(()=>(a=!0,x(i)))),f=(yield*me(rn(t.channel))(r,o)).pipe(Ge(()=>(c=!0,x(i))));return en(function*(){for(;;){if(a&&c)return yield*G();let d=yield*s;if(d&&a||!d&&c)continue;let p=yield*d?u:f;if(p!==i)return Ee(p)}})})))),vZ=l(2,(e,t)=>O(vM(e.channel,t))),AZ=l(2,(e,t)=>O(AM(e.channel,t))),NN=l(2,(e,t)=>O(Ol(e.channel,t))),CZ=l(2,(e,t)=>O(CM(e.channel,t))),LN=l(2,(e,t)=>O(OM(e.channel,t))),$N=l(2,(e,t)=>O(fI(e.channel,n=>t(n[0])))),OZ=l(2,(e,t)=>O(kM(e.channel,t))),kZ=l(2,(e,t)=>O(RM(e.channel,t))),jN=l(e=>Ie(e[0]),(e,t,n)=>O(_M(e.channel,t,n))),RZ=l(2,(e,t)=>O(Ch(e.channel,t))),_Z=l(3,(e,t,n)=>O(Oh(e.channel,t,n))),Nw=l(3,(e,t,n)=>O(kh(e.channel,t,n))),BN=l(2,(e,t)=>O(MM(e.channel,t))),MZ=l(3,(e,t,n)=>BN(e,r=>Pt(r,t,n(Xe(r,t))))),PZ=function(){let e=Ie(arguments[0]),t=e?arguments[1]:arguments[0],n=Fs(e?arguments[2]:arguments[1]);if(e){let r=arguments[0];return O(lI(r.channel,t,n))}return r=>O(lI(r.channel,t,n))},FZ=pu({}),UZ=l(3,(e,t,n)=>nr(e,r=>({...r,[t]:n(r)})));var DZ=l(e=>Ie(e[0]),(e,t,n,r)=>hu(e,o=>nr(n(o),s=>({...o,[t]:s})),r)),NZ=l(e=>Ie(e[0]),(e,t,n,r)=>ld(e,o=>$(n(o),s=>({...o,[t]:s})),r)),LZ=l(2,(e,t)=>nr(e,n=>({[t]:n}))),qN=l(2,(e,t)=>rS(n=>Mo(e.channel,n).pipe(T(r=>t.transform(r,n)),$(([r])=>r)))),zN=e=>ya(e.channel,()=>[],(t,n)=>{for(let r=0;r<n.length;r++)t.push(n[r]);return t}),$Z=e=>ya(e.channel,()=>0,(t,n)=>t+n.length),jZ=e=>ya(e.channel,()=>0,(t,n)=>{for(let r=0;r<n.length;r++)t+=n[r];return t}),BZ=l(3,(e,t,n)=>ya(e.channel,t,(r,o)=>{for(let s=0;s<o.length;s++)r=n(r,o[s]);return r})),qZ=l(3,(e,t,n)=>NM(e.channel,t,(r,o)=>{let s=0,i=r;return $(zr({while:()=>s<o.length,body:()=>n(i,o[s]),step(a){i=a,s++}}),()=>i)})),zZ=e=>$(UM(e.channel),Bt(cT(0))),WZ=e=>$(DM(e.channel),Bt(Vd)),HZ=l(2,(e,t)=>xa(e.channel,n=>{let r=0;return zr({while:()=>r<n.length,body:()=>t(n[r++]),step:_r})})),JZ=l(2,(e,t)=>FM(e.channel,n=>{let r=!1,o=0;return $(zr({while:()=>!r&&o<n.length,body:()=>t(n[o]),step(s){o++,s||(r=!0)}}),()=>!r)})),WN=l(2,(e,t)=>xa(e.channel,t)),fg=e=>PM(e.channel),KZ=e=>pI(e.channel),GZ=e=>ya(e.channel,()=>\"\",(t,n)=>t+n.join(\"\")),VZ=e=>$(dI(e.channel),t=>t.buffer),ZZ=e=>dI(e.channel),Lw=l(e=>Ie(e[0]),(e,t,n)=>{let r,o,s=Yn(!1);return new ReadableStream({start(i){o=Hf(xr(WN(e,a=>s.whenOpen(oe(()=>{s.closeUnsafe();for(let c=0;c<a.length;c++)i.enqueue(a[c]);r(),r=void 0}))),t)),o.addObserver(a=>{a._tag===\"Failure\"?i.error(Wp(a.cause)):i.close()})},pull(){return new Promise(i=>{r=i,s.openUnsafe()})},cancel(){if(o)return aS(zs(ei(o)))}},n?.strategy)}),YZ=l(e=>Ie(e[0]),(e,t)=>Lw(e,pn(),t)),QZ=l(e=>Ie(e[0]),(e,t)=>$(Yi(),n=>Lw(e,n,t))),$w=l(2,(e,t)=>({[Symbol.asyncIterator](){let n=cS(t),r=Ao(t),o=mr(),s,i,a,c,u=d=>{if(c)return c;let p=a;return c=n(At(yn(p?ei(p):te,Fe(o,d)),{done:!0,value:void 0})),c},f=async d=>{try{await u(d)}catch(p){await n(uS(\"Suppressed error while closing Stream async iterator\",p))}};return{async next(){if(c)return c;if(i){let m=i.next();if(!m.done)return m;i=void 0}let d=r(s??T(Mo(e.channel,o),m=>(s=m,m)));a=d;let p=await n(m_(d));if(a===d&&(a=void 0),et(p))return i=p.value[Symbol.iterator](),i.next();if(is(p.cause))return u(ut);if(c&&ob(p.cause))return c;throw await f(p),Wp(p.cause)},return(){return u(ut)},async throw(d){throw await f(fb(d)),d}}}})),XZ=e=>$(Yi(),t=>$w(e,t)),e8=e=>$w(e,pn()),t8=l(e=>Ie(e[0]),(e,t,n)=>hI(e.channel,t,n)),n8=l(2,(e,t)=>jM(e.channel,t)),HN=l(2,(e,t)=>BM(e.channel,t)),r8=l(2,(e,t)=>LM(e.channel,t)),o8=l(2,(e,t)=>mI(e.channel,t));var pd=BigInt(1024),Pne=pd*pd*pd*pd*pd;var jw=Vt(\"effect/platform/FileSystem\");var KN=\"~effect/platform/Path\",qw=Vt(\"effect/Path\");function GN(e,t){let n=\"\",r=0,o=-1,s=0,i;for(let a=0;a<=e.length;++a){if(a<e.length)i=e.charCodeAt(a);else{if(i===47)break;i=47}if(i===47){if(!(o===a-1||s===1))if(o!==a-1&&s===2){if(n.length<2||r!==2||n.charCodeAt(n.length-1)!==46||n.charCodeAt(n.length-2)!==46){if(n.length>2){let c=n.lastIndexOf(\"/\");if(c!==n.length-1){c===-1?(n=\"\",r=0):(n=n.slice(0,c),r=n.length-1-n.lastIndexOf(\"/\")),o=a,s=0;continue}}else if(n.length===2||n.length===1){n=\"\",r=0,o=a,s=0;continue}}t&&(n.length>0?n+=\"/..\":n=\"..\",r=2)}else n.length>0?n+=\"/\"+e.slice(o+1,a):n=e.slice(o+1,a),r=a-o-1;o=a,s=0}else i===46&&s!==-1?++s:s=-1}return n}function s8(e,t){let n=t.dir||t.root,r=t.base||(t.name||\"\")+(t.ext||\"\");return n?n===t.root?n+r:n+e+r:r}function i8(e){if(e.protocol!==\"file:\")return P(new du({module:\"Path\",method:\"fromFileUrl\",description:\"URL must be of scheme file\"}));if(e.hostname!==\"\")return P(new du({module:\"Path\",method:\"fromFileUrl\",description:\"Invalid file URL host\"}));let t=e.pathname;for(let n=0;n<t.length;n++)if(t[n]===\"%\"){let r=t.codePointAt(n+2)|32;if(t[n+1]===\"2\"&&r===102)return P(new du({module:\"Path\",method:\"fromFileUrl\",description:\"must not include encoded / characters\"}))}return x(decodeURIComponent(t))}var VN=function(){let t=\"\",n=!1,r;for(let o=arguments.length-1;o>=-1&&!n;o--){let s;if(o>=0)s=arguments[o];else{let i=globalThis.process;r===void 0&&\"process\"in globalThis&&typeof i==\"object\"&&i!==null&&typeof i.cwd==\"function\"&&(r=i.cwd()),s=r}s.length!==0&&(t=s+\"/\"+t,n=s.charCodeAt(0)===47)}return t=GN(t,!n),n?t.length>0?\"/\"+t:\"/\":t.length>0?t:\".\"},a8=47;function c8(e){let t=new URL(\"file://\"),n=VN(e);return e.charCodeAt(e.length-1)===a8&&n[n.length-1]!==\"/\"&&(n+=\"/\"),t.pathname=m8(n),x(t)}var u8=/%/g,f8=/\\\\/g,l8=/\\n/g,d8=/\\r/g,p8=/\\t/g;function m8(e){return e.includes(\"%\")&&(e=e.replace(u8,\"%25\")),e.includes(\"\\\\\")&&(e=e.replace(f8,\"%5C\")),e.includes(`\n`)&&(e=e.replace(l8,\"%0A\")),e.includes(\"\\r\")&&(e=e.replace(d8,\"%0D\")),e.includes(\"\t\")&&(e=e.replace(p8,\"%09\")),e}var Bw=qw.of({[KN]:KN,resolve:VN,normalize(e){if(e.length===0)return\".\";let t=e.charCodeAt(0)===47,n=e.charCodeAt(e.length-1)===47;return e=GN(e,!t),e.length===0&&!t&&(e=\".\"),e.length>0&&n&&(e+=\"/\"),t?\"/\"+e:e},isAbsolute(e){return e.length>0&&e.charCodeAt(0)===47},join(){if(arguments.length===0)return\".\";let e;for(let t=0;t<arguments.length;++t){let n=arguments[t];n.length>0&&(e===void 0?e=n:e+=\"/\"+n)}return e===void 0?\".\":Bw.normalize(e)},relative(e,t){if(e===t||(e=Bw.resolve(e),t=Bw.resolve(t),e===t))return\"\";let n=1;for(;n<e.length&&e.charCodeAt(n)===47;++n);let r=e.length,o=r-n,s=1;for(;s<t.length&&t.charCodeAt(s)===47;++s);let a=t.length-s,c=o<a?o:a,u=-1,f=0;for(;f<=c;++f){if(f===c){if(a>c){if(t.charCodeAt(s+f)===47)return t.slice(s+f+1);if(f===0)return t.slice(s+f)}else o>c&&(e.charCodeAt(n+f)===47?u=f:f===0&&(u=0));break}let p=e.charCodeAt(n+f),m=t.charCodeAt(s+f);if(p!==m)break;p===47&&(u=f)}let d=\"\";for(f=n+u+1;f<=r;++f)(f===r||e.charCodeAt(f)===47)&&(d.length===0?d+=\"..\":d+=\"/..\");return d.length>0?d+t.slice(s+u):(s+=u,t.charCodeAt(s)===47&&++s,t.slice(s))},dirname(e){if(e.length===0)return\".\";let t=e.charCodeAt(0),n=t===47,r=-1,o=!0;for(let s=e.length-1;s>=1;--s)if(t=e.charCodeAt(s),t===47){if(!o){r=s;break}}else o=!1;return r===-1?n?\"/\":\".\":n&&r===1?\"//\":e.slice(0,r)},basename(e,t){let n=0,r=-1,o=!0,s;if(t!==void 0&&t.length>0&&t.length<=e.length){if(t.length===e.length&&t===e)return\"\";let i=t.length-1,a=-1;for(s=e.length-1;s>=0;--s){let c=e.charCodeAt(s);if(c===47){if(!o){n=s+1;break}}else a===-1&&(o=!1,a=s+1),i>=0&&(c===t.charCodeAt(i)?--i===-1&&(r=s):(i=-1,r=a))}return n===r?r=a:r===-1&&(r=e.length),e.slice(n,r)}else{for(s=e.length-1;s>=0;--s)if(e.charCodeAt(s)===47){if(!o){n=s+1;break}}else r===-1&&(o=!1,r=s+1);return r===-1?\"\":e.slice(n,r)}},extname(e){let t=-1,n=0,r=-1,o=!0,s=0;for(let i=e.length-1;i>=0;--i){let a=e.charCodeAt(i);if(a===47){if(!o){n=i+1;break}continue}r===-1&&(o=!1,r=i+1),a===46?t===-1?t=i:s!==1&&(s=1):t!==-1&&(s=-1)}return t===-1||r===-1||s===0||s===1&&t===r-1&&t===n+1?\"\":e.slice(t,r)},format:function(t){if(t===null||typeof t!=\"object\")throw new TypeError('The \"pathObject\" argument must be of type Object. Received type '+typeof t);return s8(\"/\",t)},parse(e){let t={root:\"\",dir:\"\",base:\"\",ext:\"\",name:\"\"};if(e.length===0)return t;let n=e.charCodeAt(0),r=n===47,o;r?(t.root=\"/\",o=1):o=0;let s=-1,i=0,a=-1,c=!0,u=e.length-1,f=0;for(;u>=o;--u){if(n=e.charCodeAt(u),n===47){if(!c){i=u+1;break}continue}a===-1&&(c=!1,a=u+1),n===46?s===-1?s=u:f!==1&&(f=1):s!==-1&&(f=-1)}return s===-1||a===-1||f===0||f===1&&s===a-1&&s===i+1?a!==-1&&(i===0&&r?t.base=t.name=e.slice(1,a):t.base=t.name=e.slice(i,a)):(i===0&&r?(t.name=e.slice(1,s),t.base=e.slice(1,a)):(t.name=e.slice(i,s),t.base=e.slice(i,a)),t.ext=e.slice(s,a)),i>0?t.dir=e.slice(0,i-1):r&&(t.dir=\"/\"),t},sep:\"/\",fromFileUrl:i8,toFileUrl:c8,toNamespacedPath:_});function md(e){return{_tag:\"Value\",value:e}}function dg(e,t){return{_tag:\"Record\",keys:e,value:t}}function Hw(e,t){return{_tag:\"Array\",length:e,value:t}}var lg=class extends yo(\"SourceError\"){},Fa=Ae(\"effect/ConfigProvider\",{defaultValue:()=>n1()}),h8={...ln,toJSON(){return{_id:\"ConfigProvider\"}}},g8=e=>e;function YN(e,t){let n=Object.create(h8);return n.load=e,n.mapInput=t,n}function QN(e,t){return YN(n=>e(t(n)),n=>QN(e,Td(t,n)))}function XN(e,t){return YN(n=>T(e.load(n),r=>r!==void 0?x(r):t.load(n)),n=>XN(e.mapInput(n),t.mapInput(n)))}function pg(e){return QN(e,g8)}var Ww=l(2,(e,t)=>XN(e,t)),Jw=l(2,(e,t)=>e.mapInput(t)),x8=Jw(e=>e.map(t=>typeof t==\"number\"?t:FO(t))),y8=l(2,(e,t)=>{let n=typeof t==\"string\"?[t]:t;return Jw(e,r=>[...n,...r])}),b8=e=>wo(e)?gb(Fa)(e):iO(Fa)(e),S8=(e,t)=>gb(Fa)(en(function*(){let n=yield*Fa,r=wo(e)?yield*e:e;return t?.asPrimary?Ww(r,n):Ww(n,r)}));function E8(e,t){let n=t?.preserveEmptyStrings===!0;return pg(r=>x(I8(e,r,n)))}function I8(e,t,n){let r=e;for(let o of t){if(r==null)return;if(Array.isArray(r)){if(typeof o!=\"number\"||!Number.isInteger(o)||o<0||o>=r.length)return;r=r[o];continue}if(wt(r)){if(typeof o!=\"string\"||!Object.hasOwn(r,o))return;r=r[o];continue}return}return w8(r,n)}function w8(e,t){if(e!=null)return typeof e==\"string\"?e1(e,t):typeof e==\"number\"||typeof e==\"boolean\"||typeof e==\"bigint\"?md(String(e)):Array.isArray(e)?Hw(e.length):wt(e)?dg(new Set(Object.keys(e))):md(N(e))}function e1(e,t){let n=t1(e,t);return n===void 0?void 0:md(n)}function t1(e,t){return e===\"\"&&!t?void 0:e}function Kw(e,t){let n=t?.preserveEmptyStrings===!0,r=T8(e);return pg(o=>x(A8(r,e,o,n)))}function n1(e){let t=e?.env??{...globalThis.process?.env,...import.meta?.env};return Kw(t,{preserveEmptyStrings:e?.preserveEmptyStrings})}function T8(e){let t={};for(let[n,r]of Object.entries(e)){if(r===void 0)continue;let o=n.split(\"_\"),s=t;for(let i of o){let a=s.children??=Object.create(null);s=a[i]??={}}}return t}var v8=/^(0|[1-9][0-9]*)$/;function A8(e,t,n,r){let o=n.map(String).join(\"_\"),s=t1(Object.hasOwn(t,o)?t[o]:void 0,r),i=C8(e,n),a=i?.children?Object.keys(i.children):[];if(a.length===0)return s===void 0?void 0:md(s);if(a.every(u=>v8.test(u))){let u=Math.max(...a.map(f=>parseInt(f,10)))+1;return Hw(u,s)}return dg(new Set(a),s)}function C8(e,t){if(t.length===0)return e;let n=e;for(let r of t)if(n=n?.children?.[String(r)],!n)return;return n}function r1(e,t){let n=k8(e);return t?.expandVariables&&(n=R8(n)),Kw(n,{preserveEmptyStrings:t?.preserveEmptyStrings})}var O8=/(?:^|^)\\s*(?:export\\s+)?([\\w.-]+)(?:\\s*=\\s*?|:\\s+?)(\\s*'(?:\\\\'|[^'])*'|\\s*\"(?:\\\\\"|[^\"])*\"|\\s*`(?:\\\\`|[^`])*`|[^#\\r\\n]+)?\\s*(?:#.*)?(?:$|$)/mg;function k8(e){let t=Object.create(null);e=e.replace(/\\r\\n?/gm,`\n`);let n;for(;(n=O8.exec(e))!=null;){let r=n[1],o=n[2]||\"\";o=o.trim();let s=o[0];o=o.replace(/^(['\"`])([\\s\\S]*)\\1$/gm,\"$2\"),s==='\"'&&(o=o.replace(/\\\\n/g,`\n`),o=o.replace(/\\\\r/g,\"\\r\")),t[r]=o}return t}function R8(e){let t=Object.create(null);for(let n of Object.keys(e))t[n]=o1(e[n],e).replace(/\\\\\\$/g,\"$\");return t}function o1(e,t){let n=_8(e,/(?!(?<=\\\\))\\$/g);if(n===-1)return e;let r=e.slice(n),o=/((?!(?<=\\\\))\\${?([\\w]+)(?::-([^}\\\\]*))?}?)/,s=r.match(o);if(s!==null){let[i,a,c,u]=s,f=Object.hasOwn(t,c)&&t[c]!==\"\"?t[c]:u??\"\";return o1(e.replace(a,f),t)}return e}function _8(e,t){let n=Array.from(e.matchAll(t));return n.length>0?n.slice(-1)[0].index:-1}var M8=xe(function*(e){let n=yield*(yield*jw).readFileString(e?.path??\".env\");return r1(n,e)}),P8=xe(function*(e){let t=yield*qw,n=yield*jw,r=e?.rootPath??\"/\",o=e?.preserveEmptyStrings===!0;return pg(s=>{let i=t.join(r,...s.map(String)),a=n.readFileString(i).pipe($(u=>e1(u.trim(),o))),c=n.readDirectory(i).pipe($(u=>dg(new Set(u.map(f=>t.basename(f))))));return a.pipe(Ws(u=>c.pipe(Ws(f=>zw(u)&&zw(f)?x(void 0):P(zw(u)?f:u)))),fm(u=>new lg({message:`Failed to read file at ${t.join(r,...s.map(String))}`,cause:u})))})}),zw=e=>e.reason._tag===\"NotFound\";var s1=[\"All\",\"Fatal\",\"Error\",\"Warn\",\"Info\",\"Debug\",\"Trace\",\"None\"];var Gw=\"~effect/Config\",u1=e=>M(e,Gw),xu=class{_tag=\"ConfigError\";name=\"ConfigError\";cause;constructor(t){this.cause=t}get message(){return this.cause.toString()}toString(){return`ConfigError(${this.message})`}},F8={...Xd({label:\"Config\",evaluate(e){return this.parse(e.getRef(Fa))}}),[Gw]:Gw,toJSON(){return{_id:\"Config\"}}};function No(e){let t=Object.create(F8);return t.evaluator=e,t.parse=n=>e(n,[]).pipe(fs(r=>r.error),lt(r=>r._tag===\"Resolved\"?x(r.value):P(r.error))),t}var Es=(e,t,n)=>e.evaluator(t,n),ci=(e,t)=>({_tag:\"Resolved\",value:e,hasInput:t}),U8=e=>({_tag:\"Absent\",error:e}),Lo=(e,t)=>({error:e,hasInput:t}),D8=e=>$t(e,\"SourceError\"),a1=(e,t)=>e.pipe(zf(n=>D8(n)?P(Lo(new xu(n),t)):hr(n))),N8=(e,t)=>t?e.pipe(fs(n=>Lo(n.error,!0)),lt(n=>n._tag===\"Resolved\"?x(ci(n.value,!0)):P(Lo(n.error,!0)))):e,f1=l(2,(e,t)=>No((n,r)=>$(Es(e,n,r),o=>o._tag===\"Resolved\"?ci(t(o.value),o.hasInput):o))),L8=l(2,(e,t)=>No((n,r)=>T(Es(e,n,r),o=>o._tag===\"Resolved\"?t(o.value).pipe(br(s=>ci(s,o.hasInput)),fs(s=>Lo(s,o.hasInput))):x(o)))),$8=l(2,(e,t)=>No((n,r)=>eS(Es(e,n,r),{onFailure:o=>N8(Es(t(o.error),n,r),o.hasInput),onSuccess:o=>o._tag===\"Absent\"?Es(t(o.error),n,r):x(o)})));function l1(e){let t=Array.isArray(e)?e:Symbol.iterator in e?[...e]:e;return Array.isArray(t)?No((n,r)=>lt(om(t.map(o=>cm(Es(o,n,r)))),j8)):No((n,r)=>lt(om(Wu(t,o=>cm(Es(o,n,r)))),B8))}var j8=e=>{let t=[],n,r,o=!1;for(let s of e){if(ie(s)){n??=s.failure,o=o||s.failure.hasInput;continue}let i=s.success;i._tag===\"Absent\"?r??=i:(t.push(i.value),o=o||i.hasInput)}return n!==void 0?P(Lo(n.error,o)):r!==void 0?o?P(Lo(r.error,!0)):x(r):x(ci(t,o))},B8=e=>{let t={},n,r,o=!1;for(let s in e){let i=e[s];if(ie(i)){n??=i.failure,o=o||i.failure.hasInput;continue}let a=i.success;a._tag===\"Absent\"?r??=a:(F(t,s,a.value),o=o||a.hasInput)}return n!==void 0?P(Lo(n.error,o)):r!==void 0?o?P(Lo(r.error,!0)):x(r):x(ci(t,o))},d1=l(2,(e,t)=>No((n,r)=>br(Es(e,n,r),o=>o._tag===\"Absent\"?ci(t,!1):o))),q8=e=>e.pipe(f1(k),d1(R())),p1=e=>u1(e)?e:l1(Wu(e,t=>p1(t))),z8=()=>\"<configuration>\",m1=(e,t)=>e.load(t).pipe(Gb,br(n=>({provider:e,path:t,node:n,toString:z8}))),c1=(e,t)=>m1(e.provider,[...e.path,t]),Vw=e=>e?.value,mg=(e,t)=>$c(Kr,e,new Te(tn(n=>t(n)),Sn())),h1=e=>{switch(e._tag){case\"Union\":return e.types.every(h1);case\"Objects\":case\"Arrays\":case\"Suspend\":return!1;default:return!0}},Zw=(e,t)=>{switch(e._tag){case\"Objects\":return t?._tag===\"Record\";case\"Arrays\":return t?._tag===\"Array\";case\"Union\":return e.types.some(n=>Zw(n,t));case\"Suspend\":return Zw(e.thunk(),t);default:return Vw(t)!==void 0}},W8=It(e=>{let t=new WeakSet,n=BR(r=>{switch(t.add(r),r._tag){case\"Objects\":{let o=r.indexSignatures.map(i=>Ia(i.parameter)),s=xe(function*(i){if(i.node?._tag!==\"Record\")return;let a=i.node,c=new Set;for(let f of r.propertySignatures)typeof f.name==\"string\"&&c.add(f.name);if(o.length>0)for(let f of a.keys)o.some(d=>d(f))&&c.add(f);let u={};for(let f of c){let d=yield*c1(i,f);d.node!==void 0&&F(u,f,d)}return u});return mg(r.recur(n,i=>i),s)}case\"Arrays\":{let o=xe(function*(s){if(s.node?._tag!==\"Array\")return;let i=[];for(let a=0;a<s.node.length;a++)i.push(yield*c1(s,a));return i});return mg(r.recur(n),o)}case\"Union\":for(let o of r.types)n(o);return h1(r)?mg(r,o=>x(Vw(o.node))):r.recur(n);case\"Suspend\":{let o=r.thunk();return t.has(o)||n(o),r.recur(n)}case\"Declaration\":case\"Any\":throw new globalThis.Error(\"Config.schema does not support opaque StringTree encodings\",{cause:r});default:return mg(r,o=>x(Vw(o.node)))}});return n(e)});function vn(e,t){let n=lu(e),r=nn(n.ast),o=Ze(j(W8(n.ast))),s=typeof t==\"string\"?[t]:t??[];return No((i,a)=>{let c=[...a,...s];return a1(m1(i,c),!1).pipe(lt(u=>{let f=Zw(r,u.node);return a1(o(u).pipe(br(d=>ci(d,f)),vc(d=>{let p=new xu(new bs(c.length>0?new Ve(c,d):d));return f?P(Lo(p,!0)):x(U8(p))})),f)}))})}var g1=io([\"true\",\"yes\",\"on\",\"1\",\"y\"]),x1=io([\"false\",\"no\",\"off\",\"0\",\"n\"]),y1=io([...g1.literals,...x1.literals]).pipe(ne(ng,We({decode:e=>e===\"true\"||e===\"yes\"||e===\"on\"||e===\"1\"||e===\"y\",encode:e=>e?\"true\":\"false\"}))),b1=Uo.check(fu({minimum:1,maximum:65535})),S1=io(s1),H8=(e,t,n)=>{let r=Gl(e,t),o=Vk(n),s=X.pipe(ne(lu(r),{decode:o.decode,encode:Sn({strict:!1}).compose(o.encode)}));return st([r,s])},J8=(e,t)=>{let n=Ye(e),r=t?.separator??\",\",o=X.pipe(ne(lu(n),{decode:Pk(t),encode:Sn({strict:!1}).compose(ue(s=>s.join(r)))}));return st([o,n])};function K8(e){return No(()=>P(Lo(new xu(e),!1)))}function G8(e){return No(()=>x(ci(e,!1)))}function V8(e){return vn(X,e)}function Z8(e){return vn(pw,e)}function Y8(e){return vn(uu,e)}function Q8(e){return vn(Xn,e)}function X8(e){return vn(Uo,e)}function eY(e,t){return vn(ze(e),t)}function tY(e,t){return vn(io(e),t)}function nY(e){return vn(y1,e)}function rY(e){return vn(hw,e)}function oY(e){return vn(b1,e)}function sY(e){return vn(S1,e)}function iY(e){return vn(td(X),e)}function aY(e){return vn(nd,e)}function cY(e){return vn(er,e)}var uY=l(2,(e,t)=>No((n,r)=>Es(e,n,[...r,t])));var Ua={};uo(Ua,{Console:()=>E1,assert:()=>fY,clear:()=>lY,consoleWith:()=>Ot,count:()=>dY,countReset:()=>pY,debug:()=>mY,dir:()=>hY,dirxml:()=>gY,error:()=>xY,group:()=>yY,info:()=>bY,log:()=>SY,table:()=>EY,time:()=>IY,timeLog:()=>wY,trace:()=>TY,warn:()=>vY,withGroup:()=>AY,withTime:()=>CY});var E1=Vy,Ot=e=>Y(t=>e(t.getRef(E1))),fY=(e,...t)=>Ot(n=>L(()=>{n.assert(e,...t)})),lY=Ot(e=>L(()=>{e.clear()})),dY=e=>Ot(t=>L(()=>{t.count(e)})),pY=e=>Ot(t=>L(()=>{t.countReset(e)})),mY=(...e)=>Ot(t=>L(()=>{t.debug(...e)})),hY=(e,t)=>Ot(n=>L(()=>{n.dir(e,t)})),gY=(...e)=>Ot(t=>L(()=>{t.dirxml(...e)})),xY=(...e)=>Ot(t=>L(()=>{t.error(...e)})),yY=e=>Ot(t=>Li(L(()=>{e?.collapsed?t.groupCollapsed(e.label):t.group(e?.label)}),()=>L(()=>{t.groupEnd()}))),bY=(...e)=>Ot(t=>L(()=>{t.info(...e)})),SY=(...e)=>Ot(t=>L(()=>{t.log(...e)})),EY=(e,t)=>Ot(n=>L(()=>{n.table(e,t)})),IY=e=>Ot(t=>Li(L(()=>{t.time(e)}),()=>L(()=>{t.timeEnd(e)}))),wY=(e,...t)=>Ot(n=>L(()=>{n.timeLog(e,...t)})),TY=(...e)=>Ot(t=>L(()=>{t.trace(...e)})),vY=(...e)=>Ot(t=>L(()=>{t.warn(...e)})),AY=l(e=>ee(e[0]),(e,t)=>Ot(n=>yf(L(()=>{t?.collapsed?n.groupCollapsed(t.label):n.group(t?.label)}),()=>e,()=>L(()=>{n.groupEnd()})))),CY=l(e=>ee(e[0]),(e,t)=>Ot(n=>yf(L(()=>{n.time(t)}),()=>e,()=>L(()=>{n.timeEnd(t)}))));var Nn={OK:200,Unauthorized:401,NotFound:404,InternalServerError:500,ServiceUnavailable:503},Yw={[Nn.OK]:\"OK\",[Nn.Unauthorized]:\"Unauthorized\",[Nn.NotFound]:\"Not Found\",[Nn.InternalServerError]:\"Internal Server Error\",[Nn.ServiceUnavailable]:\"Service Unavailable\"},OY=e=>{let t=e.startsWith(\"/\"),n=[];for(let o of e.split(\"/\"))if(!(o===\"\"||o===\".\")){if(o===\"..\"){n.length>0&&n.at(-1)!==\"..\"?n.pop():t||n.push(\"..\");continue}n.push(o)}let r=n.join(\"/\");return t?`/${r}`:r||\".\"},ui=(...e)=>OY(e.filter(Boolean).join(\"/\")),yu=e=>{if(e.length===0)return\".\";let t=e.length;for(;t>1&&e[t-1]===\"/\";)t-=1;let n=e.slice(0,t),r=n.lastIndexOf(\"/\");return r<0?\".\":r===0?\"/\":n.slice(0,r)},I1=e=>{if(!e.startsWith(\"/\"))throw new TypeError(\"Path must be absolute\");let t=new URL(\"file:///\");return t.pathname=e.replace(/%/g,\"%25\").replace(/\\\\/g,\"%5C\"),t};var kY=/^[A-Za-z0-9_-]+$/u,w1=/[*?[{]/u,hg=(e,t)=>t===e||t.startsWith(`${e.replace(/\\/+$/u,\"\")}/`),bu=class extends bo.TaggedError(\"FunctionFileSystemError\"){},kr=(e,t)=>e.lstat(t).pipe(D.catch(()=>D.undefined)),Da=(e,t,n)=>D.all([e.realPath(t),e.realPath(n)],{concurrency:2}).pipe(D.map(([r,o])=>hg(r,o)),D.orElseSucceed(()=>!1)),T1=(e,t,n)=>D.gen(function*(){let r=yield*kr(e,n);if(r===void 0)return!0;if(r.isSymbolicLink||!(yield*Da(e,t,n)))return!1;if(!r.isDirectory)return!0;let o=yield*e.readDirectory(n).pipe(D.catch(()=>D.undefined));if(o===void 0)return!1;for(let s of o)if(!(yield*T1(e,t,ui(n,s))))return!1;return!0}),hd=(e,t)=>t.length===0?\"\":t.startsWith(\"/\")?t:ui(e,t),Qw=[\"deno.json\",\"deno.jsonc\"],v1=e=>Qw.includes(e.slice(e.lastIndexOf(\"/\")+1)),RY=(e,t,n)=>D.gen(function*(){let r=yu(ui(n));for(;hg(t,r);){for(let s of Qw){let i=ui(r,s),a=yield*kr(e,i);if(a!==void 0)return a.isFile&&!a.isSymbolicLink?i:void 0}let o=yu(r);if(o===r)break;r=o}}),_Y=(e,t,n)=>D.all([e.realPath(t),e.realPath(n)],{concurrency:2}).pipe(D.map(([r,o])=>r===o),D.orElseSucceed(()=>!1)),A1=D.fn(\"Functions.resolveFunctionConfig\")(function*(e){let{root:t,slug:n,overrides:r,fs:o}=e;if(!t.startsWith(\"/\")||!kY.test(n)||n===\"_shared\"||(yield*kr(o,t))===void 0)return;let i=yield*o.realPath(t).pipe(D.orElseSucceed(()=>\"\"));if(!i.startsWith(\"/\"))return;let a=yield*kr(o,i);if(a===void 0||!a.isDirectory)return;let c=e.filesRoot===void 0?i:yield*o.realPath(e.filesRoot).pipe(D.orElseSucceed(()=>\"\"));if(!c.startsWith(\"/\")||!hg(c,i))return;let u=r.$default,f=r[n],d={...u,...f};if(d.enabled===!1)return;let p=ui(i,n),m=d?.entrypointPath&&d.entrypointPath.length>0?d.entrypointPath:d.entrypoint&&d.entrypoint.length>0?d.entrypoint:\"index.ts\";if(!m.startsWith(\"/\")){let S=yield*kr(o,p);if(S===void 0||!S.isDirectory||!(yield*Da(o,i,p)))return}let g=hd(p,m);if(!(yield*Da(o,c,g)))return;let b=yield*kr(o,g);if(b===void 0||!b.isFile||b.isSymbolicLink)return;let I=f?.importMapPath??f?.import_map,w=u?.importMapRoot??u?.import_map_root,E=I!==void 0?hd(p,I):w!==void 0?hd(i,w):hd(p,\"\");if(E.length>0){if(!(yield*Da(o,c,E)))return;let S=yield*kr(o,E);if(S===void 0||!S.isFile||S.isSymbolicLink)return}else for(let S of Qw){let v=ui(p,S),C=yield*kr(o,v);if(C!==void 0){if(!C.isFile||C.isSymbolicLink||!(yield*Da(o,c,v)))return;E=v;break}}let h=E.length===0?void 0:yield*RY(o,c,g),A=h!==void 0&&(yield*_Y(o,h,E)),y=(d.staticFiles??d.static_files??[]).map(S=>hd(p,S));for(let S of y){if(!hg(c,S))return;let v=S.search(w1),C=v<0?S:S.slice(0,v),V=v<0?yu(S):C.slice(0,Math.max(0,C.lastIndexOf(\"/\")))||i;if(!(yield*T1(o,c,V)))return;if(!w1.test(S)){let ce=yield*kr(o,S);if(ce!==void 0&&(!(yield*Da(o,c,S))||ce.isSymbolicLink))return}}return{entrypointPath:g,importMapPath:E,importMapDiscoveredByRuntime:A,staticFiles:y,verifyJWT:d.verifyJWT??d.verify_jwt??!0,env:d.env}}),MY=e=>ui(yu(e.entrypointPath),\"package.json\"),C1=e=>{let t=new Map,n=new Map;return(r,o)=>{let s=n.get(r);if(s!==void 0)return s;let i=yu(o.entrypointPath),a=t.get(i),c=a===void 0||a===r?i:e();return a===void 0&&t.set(i,r),n.set(r,c),c}},O1=D.fn(\"Functions.packageJsonContainedFor\")(function*(e){if(!e.root.startsWith(\"/\"))return!1;let t=yield*kr(e.fs,e.root);if(t===void 0||!t.isDirectory&&!t.isSymbolicLink)return!1;let n=yield*e.fs.realPath(e.root).pipe(D.orElseSucceed(()=>\"\"));if(!n.startsWith(\"/\"))return!1;let r=yield*kr(e.fs,n);if(r===void 0||!r.isDirectory)return!1;let o=MY(e.config),s=yield*kr(e.fs,o);return s===void 0||!s.isFile||s.isSymbolicLink?!1:yield*Da(e.fs,n,o)});var Xw=new TextEncoder,gd=new TextDecoder,gg=new TextDecoder(\"utf-8\",{fatal:!0}),Jre=2**32;function k1(...e){let t=e.reduce((o,{length:s})=>o+s,0),n=new Uint8Array(t),r=0;for(let o of e)n.set(o,r),r+=o.length;return n}function Su(e){let t=new Uint8Array(e.length);for(let n=0;n<e.length;n++){let r=e.charCodeAt(n);if(r>127)throw new TypeError(\"non-ASCII string encountered in encode()\");t[n]=r}return t}var xg=(e,t=\"algorithm.name\")=>new TypeError(`CryptoKey does not support this operation, its ${t} must be ${e}`);function PY(e,t){if(t&&!e.usages.includes(t))throw new TypeError(`CryptoKey does not support this operation, its usages must include ${t}.`)}function R1(e,t){let{modulusLength:n}=t.algorithm;if(typeof n!=\"number\"||n<2048)throw new TypeError(`${e} requires key modulusLength to be 2048 bits or larger`)}function _1(e,t,n){let r=e.algorithm;if(r.name!==t.name)throw xg(t.name);if(t.hash&&r.hash?.name!==t.hash)throw xg(t.hash,\"algorithm.hash\");if(t.namedCurve&&r.namedCurve!==t.namedCurve)throw xg(t.namedCurve,\"algorithm.namedCurve\");if(t.length!==void 0&&r.length!==t.length)throw xg(t.length,\"algorithm.length\");PY(e,n)}function FY(e,t,...n){if(n.length>2){let r=n.pop();e+=`one of type ${n.join(\", \")}, or ${r}.`}else n.length===2?e+=`one of type ${n[0]} or ${n[1]}.`:e+=`of type ${n[0]}.`;return t==null?e+=` Received ${t}`:typeof t==\"function\"&&t.name?e+=` Received function ${t.name}`:typeof t==\"object\"&&t!=null&&t.constructor?.name&&(e+=` Received an instance of ${t.constructor.name}`),e}var xd=(e,t,...n)=>FY(`Key for the ${e} algorithm must be `,t,...n);var an=class extends Error{static code=\"ERR_JOSE_GENERIC\";code=\"ERR_JOSE_GENERIC\";constructor(t,n){super(t,n),this.name=this.constructor.name,Error.captureStackTrace?.(this,this.constructor)}},Is=class extends an{static code=\"ERR_JWT_CLAIM_VALIDATION_FAILED\";code=\"ERR_JWT_CLAIM_VALIDATION_FAILED\";claim;reason;payload;constructor(t,n,r=\"unspecified\",o=\"unspecified\"){super(t,{cause:{claim:r,reason:o,payload:n}}),this.claim=r,this.reason=o,this.payload=n}},yd=class extends an{static code=\"ERR_JWT_EXPIRED\";code=\"ERR_JWT_EXPIRED\";claim;reason;payload;constructor(t,n,r=\"unspecified\",o=\"unspecified\"){super(t,{cause:{claim:r,reason:o,payload:n}}),this.claim=r,this.reason=o,this.payload=n}},yg=class extends an{static code=\"ERR_JOSE_ALG_NOT_ALLOWED\";code=\"ERR_JOSE_ALG_NOT_ALLOWED\"},co=class extends an{static code=\"ERR_JOSE_NOT_SUPPORTED\";code=\"ERR_JOSE_NOT_SUPPORTED\"};var Ln=class extends an{static code=\"ERR_JWS_INVALID\";code=\"ERR_JWS_INVALID\"},Eu=class extends an{static code=\"ERR_JWT_INVALID\";code=\"ERR_JWT_INVALID\"};var bd=class extends an{static code=\"ERR_JWKS_INVALID\";code=\"ERR_JWKS_INVALID\"},Iu=class extends an{static code=\"ERR_JWKS_NO_MATCHING_KEY\";code=\"ERR_JWKS_NO_MATCHING_KEY\";constructor(t=\"no applicable key found in the JSON Web Key Set\",n){super(t,n)}},bg=class extends an{[Symbol.asyncIterator]=async function*(){};static code=\"ERR_JWKS_MULTIPLE_MATCHING_KEYS\";code=\"ERR_JWKS_MULTIPLE_MATCHING_KEYS\";constructor(t=\"multiple matching keys found in the JSON Web Key Set\",n){super(t,n)}},Sg=class extends an{static code=\"ERR_JWKS_TIMEOUT\";code=\"ERR_JWKS_TIMEOUT\";constructor(t=\"request timed out\",n){super(t,n)}},Eg=class extends an{static code=\"ERR_JWS_SIGNATURE_VERIFICATION_FAILED\";code=\"ERR_JWS_SIGNATURE_VERIFICATION_FAILED\";constructor(t=\"signature verification failed\",n){super(t,n)}};var e0=e=>{if(e?.[Symbol.toStringTag]===\"CryptoKey\")return!0;try{return e instanceof CryptoKey}catch{return!1}},UY=e=>e?.[Symbol.toStringTag]===\"KeyObject\",M1=e=>e0(e)||UY(e);function P1(e){if(Uint8Array.fromBase64)return Uint8Array.fromBase64(e);let t=atob(e),n=new Uint8Array(t.length);for(let r=0;r<t.length;r++)n[r]=t.charCodeAt(r);return n}var t0=\"The input to be decoded is not correctly encoded.\";function Sd(e){if(Uint8Array.fromBase64)try{return Uint8Array.fromBase64(typeof e==\"string\"?e:gd.decode(e),{alphabet:\"base64url\"})}catch(n){throw new TypeError(t0,{cause:n})}let t=e;if(t instanceof Uint8Array&&(t=gd.decode(t)),t.includes(\"+\")||t.includes(\"/\"))throw new TypeError(t0);t=t.replace(/-/g,\"+\").replace(/_/g,\"/\");try{return P1(t)}catch{throw new TypeError(t0)}}function fi(e){if(typeof e!=\"object\"||e===null||Object.prototype.toString.call(e)!==\"[object Object]\")return!1;let t=Object.getPrototypeOf(e);return t===null||Object.getPrototypeOf(t)===null}function Ig(e){return fi(e)&&Array.isArray(e.keys)&&Array.from(e.keys).every(fi)}function n0(e,t,n){try{return Sd(e)}catch{throw new n(`Failed to base64url decode the ${t}`)}}function F1(e,t,n){try{return Su(e)}catch{throw new n(`The ${t} is not a valid base64url string`)}}function wg(e,t,n){let r;try{r=JSON.parse(gg.decode(Sd(e)))}catch{throw new t(n)}if(!fi(r))throw new t(n);return r}async function Tg(e,t){if(t.kty===\"RSA\"&&\"oth\"in t&&t.oth!==void 0)throw new co('RSA JWK \"oth\" (Other Primes Info) Parameter value is not supported');if(!e.kty.includes(t.kty))throw new co('Invalid or unsupported JWK \"alg\" (Algorithm) Parameter value');let n=e.resolve?.({kty:t.kty,crv:t.crv})??e.subtle,r=!!(t.d||t.priv),o={...t};return o.kty!==\"AKP\"&&delete o.alg,delete o.use,crypto.subtle.importKey(\"jwk\",o,n,t.ext??!r,t.key_ops??e.usages[r?1:0])}function r0(e){return{__proto__:null,...e}}function U1(e){let t=r0(e);if(t.ext!==void 0&&typeof t.ext!=\"boolean\")throw new TypeError('\"ext\" (Extractable) Parameter must be a boolean');if(t.key_ops!==void 0){let n=t.key_ops,r=Array.isArray(n)?[...n]:void 0;if(!r||r.some(o=>typeof o!=\"string\")||new Set(r).size!==r.length)throw new TypeError('\"key_ops\" (Key Operations) Parameter must be an array of unique strings');t.key_ops=r}return t}var o0=e=>e[Symbol.toStringTag],DY=(e,t,n)=>{let{alg:r}=e;if(t.use!==void 0){let o=n===\"sign\"||n===\"verify\"?\"sig\":\"enc\";if(t.use!==o)throw new TypeError(`Invalid key for this operation, its \"use\" must be \"${o}\" when present`)}if(t.alg!==void 0&&t.alg!==r)throw new TypeError(`Invalid key for this operation, its \"alg\" must be \"${r}\" when present`);if(Array.isArray(t.key_ops)){let o=n===\"encrypt\"||n===\"decrypt\"?e.ops?.[n===\"encrypt\"?0:1]:n;if(o&&!t.key_ops.includes(o))throw new TypeError(`Invalid key for this operation, its \"key_ops\" must include \"${o}\" when present`)}};function NY(e,t,n){let{alg:r,secret:o}=e,s=n===\"decrypt\"||n===\"sign\";if(o&&t instanceof Uint8Array)return[N1,t];if(fi(t)){let i=U1(t);if(typeof i.kty!=\"string\")throw new TypeError(o?xd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\",\"Uint8Array\"):xd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\"));if(!(o?i.kty===\"oct\"&&typeof i.k==\"string\":i.kty!==\"oct\"&&(s?i.kty===\"AKP\"&&typeof i.priv==\"string\"||typeof i.d==\"string\":i.d===void 0&&i.priv===void 0)))throw new TypeError(o?'JSON Web Key for symmetric algorithms must have JWK \"kty\" (Key Type) equal to \"oct\" and the JWK \"k\" (Key Value) present':`JSON Web Key for this operation must be a ${s?\"private\":\"public\"} JWK`);return DY(e,i,n),[j1,t,i]}if(!M1(t))throw new TypeError(o?xd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\",\"Uint8Array\"):xd(r,t,\"CryptoKey\",\"KeyObject\",\"JSON Web Key\"));if(o){if(t.type!==\"secret\")throw new TypeError(`${o0(t)} instances for symmetric algorithms must be of type \"secret\"`)}else{if(t.type===\"secret\")throw new TypeError(`${o0(t)} instances for asymmetric algorithms must not be of type \"secret\"`);let i=s?\"private\":\"public\";if((t.type===\"public\"||t.type===\"private\")&&t.type!==i){let a=n===\"sign\"?\"signing\":n===\"verify\"?\"verifying\":`${n.slice(0,-1)}tion`;throw new TypeError(`${o0(t)} instances for asymmetric algorithm ${a} must be of type \"${i}\"`)}}return e0(t)?[L1,t]:[$1,t]}var N1=0,L1=1,$1=2,j1=3,s0,LY={__proto__:null,prime256v1:\"P-256\",secp384r1:\"P-384\",secp521r1:\"P-521\"};function vg(e,t,n){s0||=new WeakMap;let r=s0.get(e);return n&&(r?r[t]=n:s0.set(e,{[t]:n})),n??r?.[t]}var D1=async(e,t,n)=>vg(e,n.alg)??vg(e,n.alg,await Tg(n,{...t,alg:n.alg})),$Y=(e,t)=>{let n=vg(e,t.alg);if(n)return n;let r=e.type===\"public\",o=t.usages[r?0:1],{asymmetricKeyType:s}=e,i=LY[e.asymmetricKeyDetails?.namedCurve],a=t.resolve?.({crv:i,asymmetricKeyType:s})??t.subtle;return vg(e,t.alg,e.toCryptoKey(a,r,o))};async function B1(e,t,n){let r=NY(e,t,n);switch(r[0]){case N1:case L1:return r[1];case j1:{let o=r[1],s=r[2];if(s.kty===\"oct\")return Sd(s.k);if(!Object.isFrozen(o)){let{key_ops:i}=o;Array.isArray(i)&&Object.freeze(i),Object.freeze(o)}return D1(o,s,e)}case $1:{let o=r[1];return o.type===\"secret\"?o.export():\"toCryptoKey\"in o&&typeof o.toCryptoKey==\"function\"?$Y(o,e):D1(o,o.export({format:\"jwk\"}),e)}}}function q1(e){let t={__proto__:null};for(let n in e)t[n]={...e[n],alg:n};return t}var z1={__proto__:null,b64:!0};function W1(e,t){if(t!==void 0&&(!Array.isArray(t)||t.some(n=>typeof n!=\"string\")))throw new TypeError(`\"${e}\" option must be an array of strings`);if(t)return new Set(t)}function H1(e,t,n,r,o){if(o.crit!==void 0&&r?.crit===void 0)throw new e('\"crit\" (Critical) Header Parameter MUST be integrity protected');if(!r||r.crit===void 0)return[];if(!Array.isArray(r.crit)||r.crit.length===0||r.crit.some(i=>typeof i!=\"string\"||i.length===0))throw new e('\"crit\" (Critical) Header Parameter MUST be an array of non-empty strings when present');let s=n===void 0?t:{__proto__:null,...n,...t};for(let i of r.crit){if(!(i in s))throw new co(`Extension Header Parameter \"${i}\" is not recognized`);if(!Object.hasOwn(o,i)||o[i]===void 0)throw new e(`Extension Header Parameter \"${i}\" is missing`);if(s[i]&&(!Object.hasOwn(r,i)||r[i]===void 0))throw new e(`Extension Header Parameter \"${i}\" MUST be integrity protected`)}return r.crit}function J1(e,t){if(t.includes(\"b64\")){let n=e.b64;if(typeof n!=\"boolean\")throw new Ln('The \"b64\" (base64url-encode payload) Header Parameter must be a boolean');return n}return!0}async function jY(e,t,n){return t instanceof Uint8Array?crypto.subtle.importKey(\"raw\",t,e.subtle,!1,[n]):(_1(t,e.subtle,n),e.minRsaBits&&R1(e.alg,t),t)}async function K1(e,t,n,r){let o=await jY(e,t,\"verify\");try{return await crypto.subtle.verify(e.signing,o,n,r)}catch{return!1}}var Ed=[[\"verify\"],[\"sign\"]];function i0(e){let t={name:\"HMAC\",hash:`SHA-${e}`};return{kty:[\"oct\"],secret:!0,subtle:t,signing:t,usages:Ed}}function wu(e,t){let r={name:t?\"RSA-PSS\":\"RSASSA-PKCS1-v1_5\",hash:`SHA-${e}`};return{kty:[\"RSA\"],subtle:r,signing:t?{...r,saltLength:t}:r,usages:Ed,minRsaBits:2048}}function a0(e,t){return{kty:[\"EC\"],crv:e,subtle:{name:\"ECDSA\",namedCurve:e},signing:{name:\"ECDSA\",hash:`SHA-${t}`},usages:Ed}}function G1(){let e={name:\"Ed25519\"};return{kty:[\"OKP\"],crv:\"Ed25519\",subtle:e,signing:e,usages:Ed}}function c0(e){let n={name:`ML-DSA-${e}`};return{kty:[\"AKP\"],subtle:n,signing:n,usages:Ed}}var u0=q1({HS256:i0(256),HS384:i0(384),HS512:i0(512),RS256:wu(256),RS384:wu(384),RS512:wu(512),PS256:wu(256,32),PS384:wu(384,48),PS512:wu(512,64),ES256:a0(\"P-256\",256),ES384:a0(\"P-384\",384),ES512:a0(\"P-521\",512),EdDSA:G1(),Ed25519:G1(),\"ML-DSA-44\":c0(44),\"ML-DSA-65\":c0(65),\"ML-DSA-87\":c0(87)});function V1(e){let t=typeof e==\"string\"?u0[e]:void 0;if(!t)throw new co(`alg ${e} is not supported either by JOSE or your javascript runtime`);return t}function Z1(e){return[e&&W1(\"algorithms\",e.algorithms),e?.crit]}function BY(e,t=e===void 0?{}:wg(e,Ln,\"JWS Protected Header is invalid\")){return t}function qY(e,t,n){let r=J1(e,H1(Ln,z1,n[1],e,t)),o=t.alg;if(typeof o!=\"string\"||!o)throw new Ln('JWS \"alg\" (Algorithm) Header Parameter missing or invalid');if(n[0]&&!n[0].has(o))throw new yg('\"alg\" (Algorithm) Header Parameter value not allowed');return[r,o]}function zY(e){try{return Su(e)}catch{throw new Ln(\"JWS Compact Serialization payload must use only ASCII characters\")}}async function WY(e,t,n,r,o,s,i){let a=!1;typeof n==\"function\"&&(n=await n(o,e),a=!0);let c=typeof i==\"string\",u=V1(s),f=k1(r!==void 0?Su(r):new Uint8Array,Su(\".\"),c?t[2]??=F1(i,\"payload\",Ln):i),d=n0(e.signature,\"signature\",Ln),p=await B1(u,n,\"verify\");if(!await K1(u,p,d,f))throw new Eg;return[c?n0(i,\"payload\",Ln):i,o,c,p,a]}async function Y1(e,t,n){if(e instanceof Uint8Array&&(e=gd.decode(e)),typeof e!=\"string\")throw new Ln(\"Compact JWS must be a string or Uint8Array\");let{0:r,1:o,2:s,length:i}=e.split(\".\");if(i!==3)throw new Ln(\"Invalid Compact JWS\");let a={payload:o,protected:r,signature:s},c=BY(r),[u,f]=qY(c,c,t),d=u?o:zY(o);return WY(a,t,n,r,c,f,d)}var HY=e=>Math.floor(e.getTime()/1e3),JY={s:1,m:60,h:3600,d:86400,w:604800,y:31557600},KY=/^(\\+|\\-)? ?(\\d+|\\d+\\.\\d+) ?(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)(?: (ago|from now))?$/i,Tu=\"check_failed\";function f0(){throw new TypeError(\"Invalid time period format\")}function Q1(e){typeof e!=\"string\"&&f0();let t=KY.exec(e);(!t||t[4]&&t[1])&&f0();let n=parseFloat(t[2]),r=Math.round(n*JY[t[3][0].toLowerCase()]);return Number.isFinite(r)||f0(),t[1]===\"-\"||t[4]===\"ago\"?-r:r}function l0(e,t){if(!Number.isFinite(t))throw new TypeError(`Invalid ${e} input`);return t}var X1=e=>{let t=e.toLowerCase();return e.includes(\"/\")?t:`application/${t}`},GY=(e,t)=>typeof e==\"string\"?t.includes(e):Array.isArray(e)?t.some(n=>e.includes(n)):!1;function d0(e,t,n=!1){let r=e[t];if(!(r===void 0&&!n)){if(typeof r!=\"number\")throw new Is(`\"${t}\" claim must be a number`,e,t,\"invalid\");return r}}function p0(e,t){throw new Is(`unexpected \"${t}\" claim value`,e,t,Tu)}function e2(e,t,n={}){let r;try{r=JSON.parse(gg.decode(t))}catch{}if(!fi(r))throw new Eu(\"JWT Claims Set must be a top-level JSON object\");let{typ:o}=n;if(o!==void 0&&(typeof e.typ!=\"string\"||X1(e.typ)!==X1(o)))throw new Is('unexpected \"typ\" JWT header value',r,\"typ\",Tu);let{requiredClaims:s=[],issuer:i,subject:a,audience:c,maxTokenAge:u}=n,f=[...s];u!==void 0&&f.push(\"iat\"),c!==void 0&&f.push(\"aud\"),a!==void 0&&f.push(\"sub\"),i!==void 0&&f.push(\"iss\");for(let E of new Set(f.reverse()))if(!Object.hasOwn(r,E))throw new Is(`missing required \"${E}\" claim`,r,E,\"missing\");i!==void 0&&!(Array.isArray(i)?i:[i]).includes(r.iss)&&p0(r,\"iss\"),a!==void 0&&r.sub!==a&&p0(r,\"sub\"),c!==void 0&&!GY(r.aud,typeof c==\"string\"?[c]:c)&&p0(r,\"aud\");let{clockTolerance:d}=n,p=0;if(typeof d==\"string\")p=Q1(d);else if(d!==void 0){if(typeof d!=\"number\")throw new TypeError(\"Invalid clockTolerance option type\");p=d}l0(\"clockTolerance option\",p);let{currentDate:m}=n,g=l0(\"currentDate option\",HY(m===void 0?new Date:m)),b=d0(r,\"iat\",u!==void 0),I=d0(r,\"nbf\");if(I!==void 0&&I>g+p)throw new Is('\"nbf\" claim timestamp check failed',r,\"nbf\",Tu);let w=d0(r,\"exp\");if(w!==void 0&&w<=g-p)throw new yd('\"exp\" claim timestamp check failed',r,\"exp\",Tu);if(u!==void 0){let E=g-b,h=l0(\"maxTokenAge option\",typeof u==\"number\"?u:Q1(u));if(E-p>h)throw new yd('\"iat\" claim timestamp check failed (too far in the past)',r,\"iat\",Tu);if(E<-p)throw new Is('\"iat\" claim timestamp check failed (it should be in the past)',r,\"iat\",Tu)}return r}async function Ag(e,t,n){let r=await Y1(e,Z1(n),t);if(!r[2])throw new Eu(\"JWTs MUST NOT use unencoded payload\");let s={payload:e2(r[1],r[0],n),protectedHeader:r[1]};return typeof t==\"function\"?{...s,key:r[3]}:s}function VY(e,t,n,r){let{kty:o,key_ops:s,ext:i,kid:a,alg:c,use:u,crv:f}=r0(e),d=Array.isArray(s)?[...s]:s;return(i===void 0||typeof i==\"boolean\")&&(d===void 0||Array.isArray(d)&&d.every((p,m)=>typeof p==\"string\"&&d.indexOf(p)===m)&&d.includes(\"verify\"))&&t.kty.includes(o)&&(r===void 0||typeof r==\"string\"&&r===a)&&(c===void 0?o!==\"AKP\":n===c)&&(u===void 0||u===\"sig\")&&(!t.crv||f===t.crv)}async function t2(e,t,n){let r=e.get(t)||e.set(t,{}).get(t),{alg:o}=n;if(r[o]===void 0){let s=await Tg(n,{...t,alg:o,ext:!0});if(s.type!==\"public\")throw new bd(\"JSON Web Key Set members must be public keys\");r[o]=s}return r[o]}function Na(e){let t;try{t=structuredClone(e)}catch{}if(!Ig(t))throw new bd(\"JSON Web Key Set malformed\");let n=new WeakMap;return Object.defineProperty(async(o,s)=>{let{alg:i,kid:a}={...o,...s?.header},c=typeof i==\"string\"?u0[i]:void 0;if(!c||c.secret)throw new co('Unsupported \"alg\" value for a JSON Web Key Set');let u=t.keys.filter(p=>VY(p,c,i,a)),{0:f,length:d}=u;if(!d)throw new Iu;if(d!==1){let p=new bg;throw p[Symbol.asyncIterator]=async function*(){for(let m of u)try{yield await t2(n,m,c)}catch{}},p}return t2(n,f,c)},\"jwks\",{value:()=>structuredClone(t)})}function ZY(){return typeof WebSocketPair<\"u\"||typeof navigator<\"u\"&&navigator.userAgent===\"Cloudflare-Workers\"||typeof EdgeRuntime<\"u\"&&EdgeRuntime===\"vercel\"}var m0;(typeof navigator>\"u\"||!navigator.userAgent?.startsWith?.(\"Mozilla/5.0 \"))&&(m0=\"jose/v6.2.10\");var r2=Symbol();async function YY(e,t,n,r=fetch){let o=await r(e,{method:\"GET\",signal:n,redirect:\"manual\",headers:t}).catch(s=>{throw s.name===\"TimeoutError\"?new Sg:s});if(o.status!==200)throw new an(\"Expected 200 OK from the JSON Web Key Set HTTP response\");try{return await o.json()}catch{throw new an(\"Failed to parse the JSON Web Key Set HTTP response as JSON\")}}var o2=Symbol();function Id(e,t){return Number.isFinite(e)&&Date.now()<e+t}function n2(e,t,n){if(Number.isNaN(e))throw new TypeError(`\"${n}\" option must not be NaN`);return typeof e==\"number\"?e:t}function h0(e,t){if(!(e instanceof URL))throw new TypeError(\"url must be an instance of URL\");let n=new URL(e.href).href,r=t??{},o=r.timeoutDuration;if(typeof o==\"number\"&&(!Number.isInteger(o)||o<0))throw new TypeError('\"timeoutDuration\" option must be a non-negative integer');let s=typeof o==\"number\"?o:5e3,i=n2(r.cooldownDuration,3e4,\"cooldownDuration\"),a=n2(r.cacheMaxAge,6e5,\"cacheMaxAge\"),c=new Headers(r.headers);m0&&!c.has(\"User-Agent\")&&c.set(\"User-Agent\",m0),c.has(\"accept\")||c.set(\"accept\",\"application/json, application/jwk-set+json\");let u=r[r2],f=r[o2],d,p,m=0,g=0,b;if(f&&typeof f==\"object\"){let{uat:E,jwks:h}=f;Id(E,a)&&Ig(h)&&(d=E,b=Na(h))}let I=async()=>{if(p&&ZY()&&(p=void 0),!p){let E=++m,h=p=YY(n,c,AbortSignal.timeout(s),u).then(A=>{let y=Na(A);if(E<=g)return;b=y;let S=Date.now();f&&(f.uat=S,f.jwks=A),d=S,g=E}).finally(()=>{p===h&&(p=void 0)})}await p};return Object.defineProperties(async(E,h)=>{(!b||!Id(d,a))&&await I();try{return await b(E,h)}catch(A){if(A instanceof Iu&&!Id(d,i))return await I(),b(E,h);throw A}},{coolingDown:{get:()=>Id(d,i),enumerable:!0},fresh:{get:()=>Id(d,a),enumerable:!0},reload:{value:I,enumerable:!0},reloading:{get:()=>!!p,enumerable:!0},jwks:{value:()=>b?.jwks(),enumerable:!0}})}function g0(e){let t;if(typeof e==\"string\"){let r=e.split(\".\");(r.length===3||r.length===5)&&([t]=r)}else if(typeof e==\"object\"&&e)if(\"protected\"in e)t=e.protected;else throw new TypeError(\"Token does not contain a Protected Header\");let n=\"Invalid Token or Protected Header formatting\";if(typeof t!=\"string\"||!t)throw new TypeError(n);return wg(t,TypeError,n)}var XY=new Set([\"HOME\",\"HOSTNAME\",\"PATH\",\"PWD\"]),Rr=D.runSync(D.gen(function*(){let e=t=>t.pipe(An.option);return{hostPort:yield*e(An.string(\"SUPABASE_INTERNAL_HOST_PORT\")),functionsRoot:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_ROOT\")),filesRoot:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_FILES_ROOT\")),jwtSecret:yield*e(An.string(\"SUPABASE_INTERNAL_JWT_SECRET\")),supabaseUrl:yield*e(An.string(\"SUPABASE_URL\")),wallclock:yield*e(An.string(\"SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC\")),publishableKey:yield*e(An.string(\"SUPABASE_INTERNAL_PUBLISHABLE_KEY\")),secretKey:yield*e(An.string(\"SUPABASE_INTERNAL_SECRET_KEY\")),functionsConfig:yield*e(An.string(\"SUPABASE_INTERNAL_FUNCTIONS_CONFIG\")),debug:yield*e(An.string(\"SUPABASE_INTERNAL_DEBUG\")),jwks:yield*e(An.string(\"SUPABASE_JWKS\"))}}).pipe(D.provideService(gu.ConfigProvider,gu.fromEnvRecord(Deno.env.toObject(),{preserveEmptyStrings:!0})))),Au=(e,t=\"\")=>ye.getOrElse(e,()=>t),s2=Au(Rr.hostPort,\"8081\"),l2=Au(Rr.functionsRoot),eQ=Au(Rr.jwtSecret),tQ=Au(Rr.supabaseUrl,\"http://127.0.0.1:54321\"),nQ=new URL(\"/auth/v1/.well-known/jwks.json\",tQ),i2=Number.parseInt(Au(Rr.wallclock,\"400\"),10),a2=ye.getOrUndefined(Rr.publishableKey),c2=ye.getOrUndefined(Rr.secretKey),ws={BootError:Nn.ServiceUnavailable,InvalidWorkerResponse:Nn.InternalServerError,WorkerLimit:546},rQ={[ws.BootError]:\"BOOT_ERROR\",[ws.InvalidWorkerResponse]:\"WORKER_ERROR\",[ws.WorkerLimit]:\"WORKER_LIMIT\"},oQ={[ws.BootError]:\"Worker failed to boot (please check logs)\",[ws.InvalidWorkerResponse]:\"Function exited due to an error (please check logs)\",[ws.WorkerLimit]:\"Worker failed to respond due to a resource limit (please check logs)\"},sQ=new Map([[Deno.errors.InvalidWorkerCreation,ws.BootError],[Deno.errors.InvalidWorkerResponse,ws.InvalidWorkerResponse],[Deno.errors.WorkerRequestCancelled,ws.WorkerLimit]]),iQ=e=>{let t=Deno.errors.WorkerAlreadyRetired;return t!==void 0&&e instanceof t},aQ=(s=>(s.MissingAuthHeader=\"UNAUTHORIZED_NO_AUTH_HEADER\",s.InvalidLegacyJWT=\"UNAUTHORIZED_JWT\",s.InvalidAsymmetricJWT=\"UNAUTHORIZED_ASYMMETRIC_JWT\",s.InvalidTokenFormat=\"UNAUTHORIZED_INVALID_JWT_FORMAT\",s.UnsupportedTokenAlgorithm=\"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM\",s))(aQ||{}),cQ=le.Struct({enabled:le.optionalKey(le.Boolean),verifyJWT:le.optionalKey(le.Boolean),verify_jwt:le.optionalKey(le.Boolean),entrypointPath:le.optionalKey(le.String),entrypoint:le.optionalKey(le.String),importMapPath:le.optionalKey(le.String),import_map:le.optionalKey(le.String),importMapRoot:le.optionalKey(le.String),import_map_root:le.optionalKey(le.String),staticFiles:le.optionalKey(le.Array(le.String)),static_files:le.optionalKey(le.Array(le.String)),env:le.optionalKey(le.Record(le.String,le.String))}),uQ=le.Record(le.String,cQ),fQ=le.declare(e=>typeof e==\"object\"&&e!==null&&\"keys\"in e&&Array.isArray(e.keys)&&e.keys.every(t=>typeof t==\"object\"&&t!==null)),lQ=()=>ye.match(Rr.functionsConfig,{onNone:()=>({}),onSome:e=>D.runSync(le.decodeEffect(le.fromJsonString(uQ))(e).pipe(D.orElseSucceed(()=>({}))))}),x0=lQ();if(ye.getOrUndefined(Rr.debug)===\"true\"){let e=Object.fromEntries(Object.entries(x0).map(([t,n])=>[t,Object.fromEntries(Object.entries(n).filter(([r])=>r!==\"env\"))]));D.runSync(Ua.log(\"Functions config:\",JSON.stringify(e,null,2)))}var La=(e,t,n={})=>{let r={...n},o=null;return e!=null&&(typeof e==\"object\"?(r[\"Content-Type\"]=\"application/json\",o=JSON.stringify(e)):(r[\"Content-Type\"]=\"text/plain\",o=typeof e==\"string\"?e:JSON.stringify(e)??null)),new Response(o,{status:t,headers:r})},u2=({code:e,message:t=\"Invalid JWT\"})=>La({code:e,message:t,msg:t},Nn.Unauthorized,{\"sb-error-code\":e,\"Access-Control-Expose-Headers\":\"sb-error-code\"}),dQ=e=>{for(let[t,n]of sQ.entries())if(t!==void 0&&e instanceof t)return La({code:rQ[n],message:oQ[n]},n);return La({code:Yw[Nn.InternalServerError],message:\"Request failed due to an internal server error\"},Nn.InternalServerError)};function pQ(e){let t=e.split(\" \");return t.length===2&&t[0]===\"Bearer\"?t[1]:null}var mQ=e=>{let t=e.headers.get(\"authorization\"),n=e.headers.get(\"sb-api-key\")?.replace(\"Bearer\",\"\").trim();if(!t&&!n)return{code:\"UNAUTHORIZED_NO_AUTH_HEADER\",message:\"Missing authorization header\"};let r=pQ(t??\"\"),o=!r||r.startsWith(\"sb_\")?n:r;return o||{code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"}},vu=class extends bo.TaggedError(\"BootstrapOperationError\"){},hQ=D.runSync(ye.match(Rr.jwks,{onNone:()=>D.succeed(ye.some(Na({keys:[]}))),onSome:e=>D.gen(function*(){let t=yield*le.decodeEffect(le.fromJsonString(fQ))(e);return yield*D.try({try:()=>Na(t),catch:n=>new vu({cause:n})})}).pipe(D.option)})),gQ=ye.getOrElse(hQ,()=>h0(nQ)),li=e=>D.tryPromise({try:e,catch:t=>new vu({cause:t})}),xQ=e=>D.gen(function*(){return yield*li(()=>Ag(e,gQ)),ye.none()}).pipe(D.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_ASYMMETRIC_JWT\"})));function yQ(e,t){return D.gen(function*(){let n=yield*D.try({try:()=>g0(t).alg,catch:r=>new vu({cause:r})});return n?n===\"HS256\"?yield*li(()=>Ag(t,new TextEncoder().encode(e))).pipe(D.as(ye.none()),D.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_JWT\"}))):n===\"ES256\"||n===\"RS256\"?yield*xQ(t):ye.some({code:\"UNAUTHORIZED_UNSUPPORTED_TOKEN_ALGORITHM\",message:`Unsupported JWT algorithm ${n}`}):ye.some({code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"})}).pipe(D.orElseSucceed(()=>ye.some({code:\"UNAUTHORIZED_INVALID_JWT_FORMAT\",message:\"Invalid JWT format\"})))}var d2={lstat:e=>li(()=>Deno.lstat(e)).pipe(D.mapError(t=>new bu({cause:t})),D.map(t=>({isDirectory:t.isDirectory,isFile:t.isFile,isSymbolicLink:t.isSymlink}))),realPath:e=>li(()=>Deno.realPath(e)).pipe(D.mapError(t=>new bu({cause:t}))),readDirectory:e=>Do.suspend(()=>Do.fromAsyncIterable(Deno.readDir(e),t=>new vu({cause:t})).pipe(Do.map(t=>t.name))).pipe(Do.runCollect,D.mapError(t=>new bu({cause:t})))},bQ=e=>A1({root:l2,filesRoot:ye.getOrUndefined(Rr.filesRoot),slug:e,overrides:x0,fs:d2}),f2=new Set,SQ=(e,t)=>t.importMapDiscoveredByRuntime||!v1(t.importMapPath)||f2.has(e)?D.void:D.sync(()=>f2.add(e)).pipe(D.andThen(Ua.warn(`[functions] ${e}: ${t.importMapPath} is not the nearest Deno config of ${t.entrypointPath}, so Edge Runtime loads it as a plain import map without comments or jsr:/npm: subpath imports. Move it next to the entrypoint or into a parent directory without a closer deno.json(c).`))),EQ=C1(()=>Deno.makeTempDirSync({prefix:\"supabase-worker-\"})),IQ=e=>e.importMapPath?D.succeed(!1):O1({root:Au(Rr.filesRoot,l2),config:e,fs:d2}),p2=e=>Do.fromEffectRepeat(li(()=>e.read()).pipe(D.flatMap(t=>t.done?Wt.done():D.succeed(t.value)))),wQ=e=>e===void 0?D.void:Do.runDrain(p2(e)).pipe(D.ignore);function TQ(e,t){let n=new URL(e.url),r=e.headers.get(\"x-forwarded-host\");r&&(n.hostname=r);let o=new Request(n.href,{method:e.method,headers:e.headers,body:t===void 0?null:Do.toReadableStream(p2(t)),signal:e.signal,duplex:\"half\"});return o.headers.delete(\"sb-api-key\"),EdgeRuntime.applySupabaseTag(e,o),o}Deno.serve({handler:e=>D.runPromiseExit(D.gen(function*(){let t=yield*D.acquireRelease(D.sync(()=>e.body?.getReader()),u=>D.interruptible(wQ(u))),{pathname:n}=new URL(e.url);if(n===\"/_internal/health\")return La({message:\"ok\"},Nn.OK);if(n===\"/_internal/metric\")return Response.json(yield*li(()=>EdgeRuntime.getRuntimeMetrics()));let r=n.split(\"/\")[1];if(!r)return La(\"Function not found\",Nn.NotFound);let o=yield*bQ(r);if(!o)return La(\"Function not found\",Nn.NotFound);if(yield*SQ(r,o),e.method!==\"OPTIONS\"&&o.verifyJWT){let u=mQ(e);if(typeof u!=\"string\")return u2(u);let f=yield*yQ(eQ,u);if(ye.isSome(f))return u2(f.value)}let s={...Deno.env.toObject(),...Object.fromEntries(Object.entries(o.env??{}).filter(([u])=>!u.startsWith(\"SUPABASE_\"))),SUPABASE_FUNCTION_SLUG:r};a2&&(s.SUPABASE_PUBLISHABLE_KEYS=yield*le.encodeEffect(le.fromJsonString(le.Unknown))({default:a2})),c2&&(s.SUPABASE_SECRET_KEYS=yield*le.encodeEffect(le.fromJsonString(le.Unknown))({default:c2}));let i=Object.entries(s).filter(([u])=>!XY.has(u)&&!u.startsWith(\"SUPABASE_INTERNAL_\")),a=!(yield*IQ(o));return yield*D.gen(function*(){let u=yield*li(()=>EdgeRuntime.userWorkers.create({servicePath:EQ(r,o),memoryLimitMb:256,workerTimeoutMs:Number.isFinite(i2)?i2*1e3:4e5,noModuleCache:!0,noNpm:a,envVars:i,forceCreate:!0,customModuleRoot:\"\",cpuTimeSoftLimitMs:1e3,cpuTimeHardLimitMs:2e3,decoratorType:\"tc39\",maybeEntrypoint:I1(o.entrypointPath).href,context:{useReadSyncFileAPI:!0,...o.importMapPath===\"\"||o.importMapDiscoveredByRuntime?{}:{importMapPath:o.importMapPath}},staticPatterns:o.staticFiles}));return yield*li(()=>u.fetch(TQ(e,t)))}).pipe(D.retry({times:1,while:({cause:u})=>e.body===null&&iQ(u)}),D.catchTag(\"BootstrapOperationError\",({cause:u})=>Ua.error(\"[functions] worker error\",u).pipe(D.andThen(D.succeed(dQ(u))))))}).pipe(D.scoped),{signal:e.signal}).then(t=>{if(Xt.isSuccess(t))return t.value;if(e.signal.aborted&&Wt.hasInterruptsOnly(t.cause))return new Response(null,{status:499});throw Wt.squash(t.cause)}),onListen:()=>{let t=Object.keys(x0).slice(0,5).map(n=>` - http://127.0.0.1:${s2}/functions/v1/${n}`);D.runSync(Ua.log(`Serving functions on http://127.0.0.1:${s2}/functions/v1/<function-name>${t.length?`\n${t.join(`\n`)}`:\"\"}\nUsing ${Deno.version.deno}`))},onError:()=>La({code:Yw[500],message:\"Request failed due to an internal server error\"},500)});export{aQ as RequestErrors,pQ as extractBearerToken,TQ as prepareUserRequest};\n"; diff --git a/packages/stack/src/functions/serve-main-bundler.integration.test.ts b/packages/stack/src/functions/serve-main-bundler.integration.test.ts index aa14bd4570..8cfcc8e95c 100644 --- a/packages/stack/src/functions/serve-main-bundler.integration.test.ts +++ b/packages/stack/src/functions/serve-main-bundler.integration.test.ts @@ -3,7 +3,7 @@ import { Data, Deferred, Effect, Exit, FileSystem, Path, Schema, Stream } from " import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; import { exportJWK, generateKeyPair, SignJWT } from "jose"; -import { bundleServeMainTemplate } from "../../tests/serve-main-bundler.ts"; +import { defaultFunctionsBootstrap } from "./generated/serve-main-bundle.ts"; type ServeOptions = { readonly handler: (request: Request) => Promise<Response>; @@ -16,6 +16,97 @@ class WorkerAlreadyRetired extends Error {} // oxlint-disable-next-line effecttsgo/extends-native-error -- stands in for Deno.errors.InvalidWorkerResponse, matched by instanceof at the sandbox boundary. class InvalidWorkerResponse extends Error {} +type TestWorker = { fetch(request: Request): Promise<Response> }; + +const serveSandboxed = (functionsConfig: string, createWorker: () => TestWorker) => + Effect.gen(function* () { + const envRecord: Record<string, string> = { + SUPABASE_INTERNAL_FUNCTIONS_ROOT: "/functions", + SUPABASE_INTERNAL_FUNCTIONS_CONFIG: functionsConfig, + }; + const bundled = defaultFunctionsBootstrap; + let serveOptions: ServeOptions | undefined; + const sandbox = { + Deno: { + env: { + get: (name: string) => envRecord[name], + toObject: () => envRecord, + }, + errors: { WorkerAlreadyRetired, InvalidWorkerResponse }, + lstat: (path: string) => { + const isDirectory = path === "/functions" || path === "/functions/hello"; + const isFile = path === "/functions/hello/index.ts"; + return isDirectory || isFile + ? Promise.resolve({ isDirectory, isFile, isSymlink: false }) + : Promise.reject(new MissingFixture()); + }, + realPath: (path: string) => Promise.resolve(path), + readDir: () => Stream.toAsyncIterable(Stream.empty), + makeTempDirSync: () => "/tmp/worker", + version: { deno: "test" }, + serve: (options: ServeOptions) => { + serveOptions = options; + }, + }, + EdgeRuntime: { + applySupabaseTag: () => undefined, + userWorkers: { create: () => Promise.resolve(createWorker()) }, + }, + AbortController, + AbortSignal, + Headers, + ReadableStream, + Request, + Response, + URL, + console, + crypto, + CryptoKey, + Uint8Array, + ArrayBuffer, + atob, + btoa, + setTimeout, + clearTimeout, + TextEncoder, + TextDecoder, + structuredClone, + }; + const module = new SourceTextModule(bundled, { + context: createContext(sandbox), + identifier: "serve.main.sandboxed.bundle.js", + }); + yield* Effect.tryPromise(() => + module.link(() => { + throw new Error("Bundled service unexpectedly imported another module"); + }), + ); + yield* Effect.tryPromise(() => module.evaluate()); + if (serveOptions === undefined) + return yield* Effect.die("Bundled service did not register a server"); + return serveOptions.handler; + }); + +const upload = (chunks = 4) => { + const progress = { readToEnd: false, cancelled: false }; + let sent = 0; + const body = new ReadableStream<Uint8Array>({ + pull: (controller) => { + if (sent === chunks) { + progress.readToEnd = true; + controller.close(); + return; + } + sent += 1; + controller.enqueue(new Uint8Array(1024)); + }, + cancel: () => { + progress.cancelled = true; + }, + }); + return { body, progress }; +}; + describe("stack-owned functions bootstrap", () => { it.live("produces an executable offline service with the expected runtime contract", () => { const controller = new AbortController(); @@ -47,7 +138,7 @@ describe("stack-owned functions bootstrap", () => { let pendingCreation: Promise<TestWorker> | undefined; const workerReady = yield* Deferred.make<TestWorker>(); const createStarted = yield* Deferred.make<void>(); - const bundled = yield* bundleServeMainTemplate; + const bundled = defaultFunctionsBootstrap; const sandbox = { Deno: { env: { @@ -83,6 +174,7 @@ describe("stack-owned functions bootstrap", () => { }, }, AbortController, + ReadableStream, Request, Response, URL, @@ -205,7 +297,7 @@ describe("stack-owned functions bootstrap", () => { it.live("starts with malformed optional functions config", () => Effect.gen(function* () { - const bundled = yield* bundleServeMainTemplate; + const bundled = defaultFunctionsBootstrap; const envRecord: Record<string, string> = { SUPABASE_INTERNAL_FUNCTIONS_CONFIG: "{" }; let serveOptions: ServeOptions | undefined; const sandbox = { @@ -281,7 +373,7 @@ describe("stack-owned functions bootstrap", () => { .setProtectedHeader({ alg: "ES256", kid: "test-key" }) .sign(privateKey), ); - const bundled = yield* bundleServeMainTemplate; + const bundled = defaultFunctionsBootstrap; const envRecord: Record<string, string> = { SUPABASE_INTERNAL_FUNCTIONS_ROOT: "/functions", SUPABASE_INTERNAL_JWT_SECRET: "secret", @@ -374,90 +466,24 @@ describe("stack-owned functions bootstrap", () => { ); describe("retired worker dispatch", () => { - const envRecord: Record<string, string> = { - SUPABASE_INTERNAL_FUNCTIONS_ROOT: "/functions", - SUPABASE_INTERNAL_FUNCTIONS_CONFIG: '{"hello":{"verifyJWT":false}}', - }; // Every create() hands out a distinct worker: worker n always rejects with failures[n - 1] when // one is given, otherwise it answers with its own number so the response names the worker. const serve = (failures: ReadonlyArray<Error>) => Effect.gen(function* () { - const bundled = yield* bundleServeMainTemplate; - let serveOptions: ServeOptions | undefined; let creates = 0; - const sandbox = { - Deno: { - env: { - get: (name: string) => envRecord[name], - toObject: () => envRecord, - }, - errors: { WorkerAlreadyRetired, InvalidWorkerResponse }, - lstat: (path: string) => { - const isDirectory = path === "/functions" || path === "/functions/hello"; - const isFile = path === "/functions/hello/index.ts"; - return isDirectory || isFile - ? Promise.resolve({ isDirectory, isFile, isSymlink: false }) - : Promise.reject(new MissingFixture()); - }, - realPath: (path: string) => Promise.resolve(path), - readDir: () => Stream.toAsyncIterable(Stream.empty), - makeTempDirSync: () => "/tmp/worker", - version: { deno: "test" }, - serve: (options: ServeOptions) => { - serveOptions = options; + const handler = yield* serveSandboxed('{"hello":{"verifyJWT":false}}', () => { + const worker = ++creates; + const failure = failures[worker - 1]; + return { + fetch: (request: Request) => { + if (failure !== undefined) return Promise.reject(failure); + return request + .text() + .then((body) => new Response(`fn-ok worker-${worker} ${request.method} ${body}`)); }, - }, - EdgeRuntime: { - applySupabaseTag: () => undefined, - userWorkers: { - create: () => { - const worker = ++creates; - const failure = failures[worker - 1]; - return Promise.resolve({ - fetch: (request: Request) => { - if (failure !== undefined) return Promise.reject(failure); - return request - .text() - .then( - (body) => new Response(`fn-ok worker-${worker} ${request.method} ${body}`), - ); - }, - }); - }, - }, - }, - AbortController, - AbortSignal, - Headers, - Request, - Response, - URL, - console, - crypto, - CryptoKey, - Uint8Array, - ArrayBuffer, - atob, - btoa, - setTimeout, - clearTimeout, - TextEncoder, - TextDecoder, - structuredClone, - }; - const module = new SourceTextModule(bundled, { - context: createContext(sandbox), - identifier: "serve.main.retired-worker.bundle.js", + }; }); - yield* Effect.tryPromise(() => - module.link(() => { - throw new Error("Bundled service unexpectedly imported another module"); - }), - ); - yield* Effect.tryPromise(() => module.evaluate()); - if (serveOptions === undefined) - return yield* Effect.die("Bundled service did not register a server"); - return { handler: serveOptions.handler, creates: () => creates }; + return { handler, creates: () => creates }; }); it.live("serves a bodyless request with a fresh worker after WorkerAlreadyRetired", () => @@ -514,4 +540,94 @@ describe("stack-owned functions bootstrap", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); }); + + describe("request body ownership", () => { + it.live("reads the rest of a request body the worker abandons", () => + Effect.gen(function* () { + const handler = yield* serveSandboxed('{"hello":{"verifyJWT":false}}', () => ({ + fetch: (request: Request) => { + const reader = request.body?.getReader(); + return Promise.resolve(reader?.read()).then(() => { + // Not awaited: if the body were shared with the incoming request again, Bun + // would never settle this cancel and the test would hang instead of failing. + void reader?.cancel(); + return new Response("rejected", { status: 400 }); + }); + }, + })); + const { body, progress } = upload(); + + const response = yield* Effect.tryPromise(() => + handler(new Request("http://127.0.0.1/hello", { method: "POST", body, duplex: "half" })), + ); + + expect(progress).toEqual({ readToEnd: true, cancelled: false }); + expect(response.status).toBe(400); + expect(yield* Effect.tryPromise(() => response.text())).toBe("rejected"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("settles an aborted request while the abandoned body read is pending", () => { + const { promise: readPending, resolve: markReadPending } = Promise.withResolvers<void>(); + const pendingRead = Promise.withResolvers<void>().promise; + const controller = new AbortController(); + return Effect.gen(function* () { + let pulls = 0; + const body = new ReadableStream<Uint8Array>({ + pull: (streamController) => { + pulls += 1; + if (pulls === 1) { + streamController.enqueue(new Uint8Array([1])); + return; + } + markReadPending(); + return pendingRead; + }, + }); + const handler = yield* serveSandboxed('{"hello":{"verifyJWT":false}}', () => ({ + fetch: () => Promise.resolve(new Response("rejected", { status: 400 })), + })); + const pending = handler( + new Request("http://127.0.0.1/missing", { + method: "POST", + body, + duplex: "half", + signal: controller.signal, + }), + ); + + yield* Effect.tryPromise(() => readPending); + controller.abort(); + + const response = yield* Effect.tryPromise(() => pending); + expect(response.status).toBe(499); + }).pipe(Effect.provide(NodeServices.layer)); + }); + + it.live.each([ + ["an invalid token", "/hello", 401], + ["an unknown function", "/missing", 404], + ] as const)("reads the whole upload before rejecting %s", ([, path, status]) => + Effect.gen(function* () { + const handler = yield* serveSandboxed('{"hello":{"verifyJWT":true}}', () => ({ + fetch: () => Promise.resolve(new Response("should not run")), + })); + const { body, progress } = upload(); + + const response = yield* Effect.tryPromise(() => + handler( + new Request(`http://127.0.0.1${path}`, { + method: "POST", + body, + duplex: "half", + headers: { Authorization: "Bearer invalid" }, + }), + ), + ); + + expect(progress).toEqual({ readToEnd: true, cancelled: false }); + expect(response.status).toBe(status); + }).pipe(Effect.provide(NodeServices.layer)), + ); + }); }); diff --git a/packages/stack/src/functions/serve-main-resolver.integration.test.ts b/packages/stack/src/functions/serve-main-resolver.integration.test.ts index 16f30cbc18..41f4e47798 100644 --- a/packages/stack/src/functions/serve-main-resolver.integration.test.ts +++ b/packages/stack/src/functions/serve-main-resolver.integration.test.ts @@ -36,6 +36,7 @@ describe("Edge Runtime worker service paths", () => { const alpha = { entrypointPath: "/functions/shared/alpha.ts", importMapPath: "", + importMapDiscoveredByRuntime: false, staticFiles: [], verifyJWT: true, }; @@ -265,6 +266,120 @@ describe("Edge Runtime request-time function resolver", () => { }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.live("marks the Deno config Edge Runtime discovers from the entrypoint", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const nodeFileSystem = makeNodeFileSystem(fs); + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-functions-resolver-deno-config-", + }); + const hello = path.join(root, "hello"); + const shared = path.join(root, "shared"); + yield* fs.makeDirectory(hello, { recursive: true }); + yield* fs.makeDirectory(shared, { recursive: true }); + yield* fs.writeFileString(path.join(hello, "index.ts"), "export default 1"); + yield* fs.writeFileString(path.join(hello, "deno.json"), "{}"); + yield* fs.writeFileString(path.join(shared, "index.ts"), "export default 2"); + yield* fs.writeFileString(path.join(root, "deno.json"), "{}"); + const canonicalRoot = yield* fs.realPath(root); + + const template = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { hello: { import_map: path.join(canonicalRoot, "hello", "deno.json") } }, + fs: nodeFileSystem, + }); + const discovered = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: {}, + fs: nodeFileSystem, + }); + const ancestor = yield* resolveFunctionConfig({ + root, + slug: "shared", + overrides: { $default: { import_map_root: "deno.json" } }, + fs: nodeFileSystem, + }); + + expect(template).toMatchObject({ + importMapPath: path.join(canonicalRoot, "hello", "deno.json"), + importMapDiscoveredByRuntime: true, + }); + expect(discovered).toMatchObject({ + importMapPath: path.join(canonicalRoot, "hello", "deno.json"), + importMapDiscoveredByRuntime: true, + }); + expect(ancestor).toMatchObject({ + importMapPath: path.join(canonicalRoot, "deno.json"), + importMapDiscoveredByRuntime: true, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("walks from the normalized entrypoint when it contains parent segments", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const nodeFileSystem = makeNodeFileSystem(fs); + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-functions-resolver-parent-segments-", + }); + const canonicalRoot = yield* fs.realPath(root); + const hello = path.join(canonicalRoot, "hello"); + yield* fs.makeDirectory(path.join(hello, "nested"), { recursive: true }); + yield* fs.writeFileString(path.join(hello, "index.ts"), "export default 1"); + yield* fs.writeFileString(path.join(hello, "nested", "deno.json"), "{}"); + yield* fs.writeFileString(path.join(canonicalRoot, "deno.json"), "{}"); + + const config = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { + $default: { import_map_root: "deno.json" }, + hello: { entrypointPath: `${hello}/nested/../index.ts` }, + }, + fs: nodeFileSystem, + }); + + expect(config).toMatchObject({ + importMapPath: path.join(canonicalRoot, "deno.json"), + importMapDiscoveredByRuntime: true, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.live("keeps import maps Edge Runtime would not discover as explicit import maps", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const nodeFileSystem = makeNodeFileSystem(fs); + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-functions-resolver-plain-import-map-", + }); + const hello = path.join(root, "hello"); + yield* fs.makeDirectory(hello, { recursive: true }); + yield* fs.writeFileString(path.join(hello, "index.ts"), "export default 1"); + yield* fs.writeFileString(path.join(hello, "deno.json"), "{}"); + yield* fs.writeFileString(path.join(hello, "import_map.json"), "{}"); + yield* fs.writeFileString(path.join(root, "deno.json"), "{}"); + + const plain = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { hello: { import_map: "import_map.json" } }, + fs: nodeFileSystem, + }); + const shadowed = yield* resolveFunctionConfig({ + root, + slug: "hello", + overrides: { $default: { import_map_root: "deno.json" } }, + fs: nodeFileSystem, + }); + + expect(plain?.importMapDiscoveredByRuntime).toBe(false); + expect(shadowed?.importMapDiscoveredByRuntime).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); it.live("accepts a symlinked functions root while enforcing canonical descendants", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -374,6 +489,7 @@ describe("Edge Runtime request-time function resolver", () => { const config = { entrypointPath: path.join(functionRoot, "index.ts"), importMapPath: "", + importMapDiscoveredByRuntime: false, staticFiles: [], verifyJWT: true, }; diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index 9c9f53e8bd..6715be1ead 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -23,6 +23,12 @@ export type FunctionOverrides = Readonly<Record<string, FunctionOverride>>; export interface FunctionConfig { readonly entrypointPath: string; readonly importMapPath: string; + /** + * Whether Edge Runtime already loads `importMapPath` as the Deno config it discovers from the + * entrypoint. Passing it as a plain import map would drop config semantics such as `jsr:` subpath + * expansion. + */ + readonly importMapDiscoveredByRuntime: boolean; readonly staticFiles: ReadonlyArray<string>; readonly verifyJWT: boolean; readonly env?: Readonly<Record<string, string>>; @@ -93,6 +99,40 @@ const rejectSymlinkDescendants = ( const relativePath = (base: string, value: string): string => value.length === 0 ? "" : value.startsWith("/") ? value : join(base, value); +const denoConfigNames = ["deno.json", "deno.jsonc"]; + +/** Whether a path names a Deno config file rather than a plain import map. */ +export const isDenoConfigPath = (path: string): boolean => + denoConfigNames.includes(path.slice(path.lastIndexOf("/") + 1)); + +/** Mirrors Deno's nearest-config lookup from the entrypoint directory, bounded by `filesRoot`. */ +const nearestDenoConfig = ( + fs: FunctionFileSystem, + filesRoot: string, + entrypointPath: string, +): Effect.Effect<string | undefined> => + Effect.gen(function* () { + // Edge Runtime receives the entrypoint as a file URL, which resolves `..` segments. + let directory = dirname(join(entrypointPath)); + while (contained(filesRoot, directory)) { + for (const name of denoConfigNames) { + const candidate = join(directory, name); + const info = yield* optionalInfo(fs, candidate); + if (info !== undefined) return info.isFile && !info.isSymbolicLink ? candidate : undefined; + } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return undefined; + }); + +const samePath = (fs: FunctionFileSystem, left: string, right: string): Effect.Effect<boolean> => + Effect.all([fs.realPath(left), fs.realPath(right)], { concurrency: 2 }).pipe( + Effect.map(([canonicalLeft, canonicalRight]) => canonicalLeft === canonicalRight), + Effect.orElseSucceed(() => false), + ); + /** Resolves one request's persisted override/default against the live functions tree. */ export const resolveFunctionConfig = Effect.fn("Functions.resolveFunctionConfig")( function* (options: { @@ -160,7 +200,7 @@ export const resolveFunctionConfig = Effect.fn("Functions.resolveFunctionConfig" const info = yield* optionalInfo(fs, importMapPath); if (info === undefined || !info.isFile || info.isSymbolicLink) return undefined; } else { - for (const candidate of ["deno.json", "deno.jsonc"]) { + for (const candidate of denoConfigNames) { const path = join(functionDirectory, candidate); const info = yield* optionalInfo(fs, path); if (info !== undefined) { @@ -171,6 +211,13 @@ export const resolveFunctionConfig = Effect.fn("Functions.resolveFunctionConfig" } } } + const discoveredDenoConfig = + importMapPath.length === 0 + ? undefined + : yield* nearestDenoConfig(fs, filesRoot, entrypointPath); + const importMapDiscoveredByRuntime = + discoveredDenoConfig !== undefined && + (yield* samePath(fs, discoveredDenoConfig, importMapPath)); const staticFiles = (override.staticFiles ?? override.static_files ?? []).map((pattern) => relativePath(functionDirectory, pattern), @@ -197,6 +244,7 @@ export const resolveFunctionConfig = Effect.fn("Functions.resolveFunctionConfig" return { entrypointPath, importMapPath, + importMapDiscoveredByRuntime, staticFiles, verifyJWT: override.verifyJWT ?? override.verify_jwt ?? true, env: override.env, diff --git a/packages/stack/src/functions/serve.main.ts b/packages/stack/src/functions/serve.main.ts index c1ab80144d..20766f7478 100644 --- a/packages/stack/src/functions/serve.main.ts +++ b/packages/stack/src/functions/serve.main.ts @@ -60,6 +60,7 @@ declare const EdgeRuntime: EdgeRuntimeApi; import { STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; import { createWorkerServicePathResolver, + isDenoConfigPath, packageJsonContainedFor, resolveFunctionConfig, type FunctionConfig, @@ -350,6 +351,20 @@ const functionConfig = (slug: string): Effect.Effect<FunctionConfig | undefined> overrides: configured, fs: denoFileSystem, }); +const warnedPlainDenoConfigs = new Set<string>(); +// Edge Runtime has no user-worker option to load a Deno config from an arbitrary path. +const warnPlainDenoConfig = (slug: string, config: FunctionConfig): Effect.Effect<void> => + config.importMapDiscoveredByRuntime || + !isDenoConfigPath(config.importMapPath) || + warnedPlainDenoConfigs.has(slug) + ? Effect.void + : Effect.sync(() => warnedPlainDenoConfigs.add(slug)).pipe( + Effect.andThen( + Console.warn( + `[functions] ${slug}: ${config.importMapPath} is not the nearest Deno config of ${config.entrypointPath}, so Edge Runtime loads it as a plain import map without comments or jsr:/npm: subpath imports. Move it next to the entrypoint or into a parent directory without a closer deno.json(c).`, + ), + ), + ); const workerServicePath = createWorkerServicePathResolver(() => Deno.makeTempDirSync({ prefix: "supabase-worker-" }), ); @@ -363,12 +378,31 @@ const shouldUsePackageJsonDiscovery = (config: FunctionConfig): Effect.Effect<bo fs: denoFileSystem, }); -export function prepareUserRequest(request: Request): Request { +interface RequestBodyReader { + read(): Promise<{ done: true; value?: undefined } | { done: false; value: Uint8Array }>; +} +const requestBodyChunks = (body: RequestBodyReader) => + Stream.fromEffectRepeat( + foreign(() => body.read()).pipe( + Effect.flatMap((chunk) => (chunk.done ? Cause.done() : Effect.succeed(chunk.value))), + ), + ); +const drainRequestBody = (body: RequestBodyReader | undefined) => + body === undefined ? Effect.void : Stream.runDrain(requestBodyChunks(body)).pipe(Effect.ignore); + +export function prepareUserRequest(request: Request, body: RequestBodyReader | undefined): Request { const url = new URL(request.url); const forwardedHost = request.headers.get("x-forwarded-host"); if (forwardedHost) url.hostname = forwardedHost; - // Cloning tees the body, so an unread branch can stall early worker responses. - const forwarded = new Request(url.href, request); + // The runtime closes a connection whose request body is unread, which gateways report as 502, so + // the worker gets its own stream and cancelling it leaves the body for `drainRequestBody`. + const forwarded = new Request(url.href, { + method: request.method, + headers: request.headers, + body: body === undefined ? null : Stream.toReadableStream(requestBodyChunks(body)), + signal: request.signal, + duplex: "half", + }); forwarded.headers.delete("sb-api-key"); EdgeRuntime.applySupabaseTag(request, forwarded); return forwarded; @@ -378,6 +412,12 @@ Deno.serve({ handler: (request: Request) => Effect.runPromiseExit( Effect.gen(function* () { + // `worker.fetch` settles its body pipe before resolving, so the drain only reads what the + // worker abandoned. + const body = yield* Effect.acquireRelease( + Effect.sync(() => request.body?.getReader()), + (body) => Effect.interruptible(drainRequestBody(body)), + ); const { pathname } = new URL(request.url); if (pathname === "/_internal/health") return getResponse({ message: "ok" }, STATUS_CODE.OK); if (pathname === "/_internal/metric") @@ -386,6 +426,7 @@ Deno.serve({ if (!functionName) return getResponse("Function not found", STATUS_CODE.NotFound); const config = yield* functionConfig(functionName); if (!config) return getResponse("Function not found", STATUS_CODE.NotFound); + yield* warnPlainDenoConfig(functionName, config); if (request.method !== "OPTIONS" && config.verifyJWT) { const token = getAuthToken(request); if (typeof token !== "string") return getAuthErrorResponse(token); @@ -430,12 +471,14 @@ Deno.serve({ maybeEntrypoint: toFileUrl(config.entrypointPath).href, context: { useReadSyncFileAPI: true, - ...(config.importMapPath === "" ? {} : { importMapPath: config.importMapPath }), + ...(config.importMapPath === "" || config.importMapDiscoveredByRuntime + ? {} + : { importMapPath: config.importMapPath }), }, staticPatterns: config.staticFiles, }), ); - return yield* foreign(() => worker.fetch(prepareUserRequest(request))); + return yield* foreign(() => worker.fetch(prepareUserRequest(request, body))); }); return yield* workerRequest.pipe( Effect.retry({ @@ -450,7 +493,7 @@ Deno.serve({ ), ), ); - }), + }).pipe(Effect.scoped), { signal: request.signal }, ).then((exit) => { if (Exit.isSuccess(exit)) return exit.value; diff --git a/packages/stack/src/host/ToolAttachments.integration.test.ts b/packages/stack/src/host/CommandAttachments.integration.test.ts similarity index 79% rename from packages/stack/src/host/ToolAttachments.integration.test.ts rename to packages/stack/src/host/CommandAttachments.integration.test.ts index c0597659ee..7dd4a49536 100644 --- a/packages/stack/src/host/ToolAttachments.integration.test.ts +++ b/packages/stack/src/host/CommandAttachments.integration.test.ts @@ -1,17 +1,17 @@ import { expect, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Ref, Stream } from "effect"; import { StackError } from "../Rpc.ts"; -import { postgres } from "../Tools.ts"; -import { makeToolAttachments } from "./ToolAttachments.ts"; +import { postgres } from "../Commands.ts"; +import { makeCommandAttachments } from "./CommandAttachments.ts"; -it.live("stopAll waits for an admitted tool to register and interrupt its runner", () => +it.live("stopAll waits for an admitted command to register and interrupt its runner", () => Effect.scoped( Effect.gen(function* () { const admissionStarted = yield* Deferred.make<void>(); const releaseAdmission = yield* Deferred.make<void>(); const runnerStarted = yield* Deferred.make<void>(); const runnerFinalized = yield* Deferred.make<void>(); - const attachments = yield* makeToolAttachments({ + const attachments = yield* makeCommandAttachments({ admit: Deferred.succeed(admissionStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseAdmission)), ), @@ -30,10 +30,13 @@ it.live("stopAll waits for an admitted tool to register and interrupt its runner attachments .run({ attachmentId: "admitted", - tool: postgres.psql({ major: 17 }), - args: [], - env: {}, - stdin: false, + command: { + type: "postgres", + command: postgres.psql({ major: 17 }), + args: [], + env: {}, + stdin: false, + }, }) .pipe(Stream.runDrain), ); diff --git a/packages/stack/src/host/ToolAttachments.ts b/packages/stack/src/host/CommandAttachments.ts similarity index 57% rename from packages/stack/src/host/ToolAttachments.ts rename to packages/stack/src/host/CommandAttachments.ts index 1e2b0bc24d..337984e941 100644 --- a/packages/stack/src/host/ToolAttachments.ts +++ b/packages/stack/src/host/CommandAttachments.ts @@ -1,45 +1,57 @@ import { Cause, Effect, Fiber, Queue, Ref, Schema, Semaphore, Stream } from "effect"; -import { StackError, ToolEvent as ToolEventSchema } from "../Rpc.ts"; -import type { PgProveOptions, PostgresTool } from "../Tools.ts"; -import type * as ToolRunner from "./ToolRunner.ts"; -import type { ToolInput } from "./ToolRunner.ts"; +import { StackError, CommandEvent as CommandEventSchema } from "../Rpc.ts"; +import type { CommandInvocation } from "../Commands.ts"; +import type * as CommandRunner from "./CommandRunner.ts"; -type ToolEvent = Schema.Schema.Type<typeof ToolEventSchema>; +type CommandEvent = Schema.Schema.Type<typeof CommandEventSchema>; -export interface ToolAttachmentPayload { +export interface CommandAttachmentPayload { readonly attachmentId: string; - readonly tool: PostgresTool; - readonly args: ReadonlyArray<string>; - readonly env: Readonly<Record<string, string>>; - readonly pgProve?: PgProveOptions; - readonly stdin: boolean; + readonly command: CommandInvocation; +} + +interface AttachedCommandOutput { + readonly stdout: (bytes: Uint8Array) => Effect.Effect<void>; + readonly stderr: (bytes: Uint8Array) => Effect.Effect<void>; } +type AttachedCommandInput = + | (AttachedCommandOutput & { + readonly command: Extract<CommandInvocation, { type: "postgres" }>; + readonly stdin: Stream.Stream<Uint8Array> | undefined; + }) + | (AttachedCommandOutput & { + readonly command: Exclude<CommandInvocation, { type: "postgres" }>; + }); interface Attachment { readonly attachmentId: string; readonly stdin: boolean; readonly input: Queue.Queue<Uint8Array | null>; - readonly output: Queue.Queue<ToolEvent, Cause.Done | StackError>; + readonly output: Queue.Queue<CommandEvent, Cause.Done | StackError>; readonly fiber: Ref.Ref<Fiber.Fiber<void, unknown> | undefined>; } -interface ToolAttachmentOperations { - readonly run: (input: ToolAttachmentPayload) => Stream.Stream<ToolEvent, StackError>; +interface CommandAttachmentOperations { + readonly run: (input: CommandAttachmentPayload) => Stream.Stream<CommandEvent, StackError>; readonly input: ( attachmentId: string, - stdin: boolean, bytes: Uint8Array | null, ) => Effect.Effect<void, StackError>; readonly stopAll: Effect.Effect<void>; } -export interface ToolAttachmentOptions { +export interface CommandAttachmentOptions { readonly admit: Effect.Effect<void, StackError>; - readonly run: ToolRunner.Interface["run"]; + readonly run: ( + input: AttachedCommandInput, + ) => Effect.Effect< + { readonly jobId: string; readonly exitCode: number }, + CommandRunner.CommandError + >; readonly toError: (operation: string, cause: unknown) => StackError; } -export const makeToolAttachments = (options: ToolAttachmentOptions) => +export const makeCommandAttachments = (options: CommandAttachmentOptions) => Effect.gen(function* () { const entries = yield* Ref.make(new Map<string, Attachment>()); const admission = yield* Semaphore.make(1); @@ -50,15 +62,15 @@ export const makeToolAttachments = (options: ToolAttachmentOptions) => (entry) => Effect.gen(function* () { yield* Queue.shutdown(entry.input); - yield* Queue.fail(entry.output, options.toError("tool", "Stack host is draining")); + yield* Queue.fail(entry.output, options.toError("command", "Stack host is draining")); const runner = yield* Ref.get(entry.fiber); if (runner !== undefined) yield* Fiber.interrupt(runner); }), { discard: true }, ); - }).pipe(Effect.withSpan("ToolAttachments.stopAll")); + }).pipe(Effect.withSpan("CommandAttachments.stopAll")); - const run = (input: ToolAttachmentPayload): Stream.Stream<ToolEvent, StackError> => + const run = (input: CommandAttachmentPayload): Stream.Stream<CommandEvent, StackError> => Stream.unwrap( admission.withPermits(1)( Effect.gen(function* () { @@ -67,39 +79,39 @@ export const makeToolAttachments = (options: ToolAttachmentOptions) => Effect.map((values) => values.get(input.attachmentId)), ); if (existing !== undefined) - return yield* options.toError("tool", "Attachment is already in use"); + return yield* options.toError("command", "Attachment is already in use"); const attachment: Attachment = { attachmentId: input.attachmentId, - stdin: input.stdin, + stdin: input.command.type === "postgres" && input.command.stdin, input: yield* Queue.bounded<Uint8Array | null>(1), - output: yield* Queue.make<ToolEvent, Cause.Done | StackError>({ capacity: 16 }), + output: yield* Queue.make<CommandEvent, Cause.Done | StackError>({ capacity: 16 }), fiber: yield* Ref.make<Fiber.Fiber<void, unknown> | undefined>(undefined), }; yield* Ref.update(entries, (values) => new Map(values).set(input.attachmentId, attachment), ); - const stdin = input.stdin + const stdin = attachment.stdin ? Stream.fromQueue(attachment.input).pipe( Stream.takeWhile((chunk): chunk is Uint8Array => chunk !== null), ) : Stream.empty; - const write = (event: ToolEvent) => + const write = (event: CommandEvent) => Queue.offer(attachment.output, event).pipe(Effect.asVoid); + const outputs = { + stdout: (bytes: Uint8Array) => write({ _tag: "Stdout", bytes }), + stderr: (bytes: Uint8Array) => write({ _tag: "Stderr", bytes }), + }; const runner = options - .run({ - tool: input.tool, - args: input.args, - env: input.env, - pgProve: input.pgProve, - stdin, - stdout: (bytes) => write({ _tag: "Stdout", bytes }), - stderr: (bytes) => write({ _tag: "Stderr", bytes }), - } satisfies ToolInput<never, never>) + .run( + input.command.type === "postgres" + ? { command: input.command, stdin, ...outputs } + : { command: input.command, ...outputs }, + ) .pipe( Effect.flatMap((result) => write({ _tag: "Completed", jobId: result.jobId, exitCode: result.exitCode }), ), - Effect.mapError((cause) => options.toError("tool", cause)), + Effect.mapError((cause) => options.toError("command", cause)), Effect.tapError((cause) => Queue.fail(attachment.output, cause)), Effect.ensuring( Effect.gen(function* () { @@ -119,7 +131,7 @@ export const makeToolAttachments = (options: ToolAttachmentOptions) => Stream.succeed({ _tag: "Attached", attachmentId: input.attachmentId, - } satisfies ToolEvent), + } satisfies CommandEvent), Stream.fromQueue(attachment.output), ); return stream.pipe( @@ -131,28 +143,28 @@ export const makeToolAttachments = (options: ToolAttachmentOptions) => }), ), ); - }).pipe(Effect.withSpan("ToolAttachments.run")), + }).pipe(Effect.withSpan("CommandAttachments.run")), ), ); - const input = Effect.fn("ToolAttachments.input")( - (attachmentId: string, stdin: boolean, bytes: Uint8Array | null) => + const input = Effect.fn("CommandAttachments.input")( + (attachmentId: string, bytes: Uint8Array | null) => Ref.get(entries).pipe( Effect.flatMap((values) => { const attachment = values.get(attachmentId); if (attachment === undefined) - return Effect.fail(options.toError("tool-input-closed", "Attachment is closed")); - if (!stdin || !attachment.stdin) - return Effect.fail(options.toError("toolInput", "Tool did not request stdin")); + return Effect.fail(options.toError("command-input-closed", "Attachment is closed")); + if (!attachment.stdin) + return Effect.fail(options.toError("command-input", "Command did not request stdin")); return Queue.offer(attachment.input, bytes).pipe( Effect.flatMap((accepted) => accepted ? Effect.void - : Effect.fail(options.toError("tool-input-closed", "Attachment is closed")), + : Effect.fail(options.toError("command-input-closed", "Attachment is closed")), ), ); }), ), ); - return { run, input, stopAll } satisfies ToolAttachmentOperations; + return { run, input, stopAll } satisfies CommandAttachmentOperations; }); diff --git a/packages/stack/src/host/CommandRunner.initialization.integration.test.ts b/packages/stack/src/host/CommandRunner.initialization.integration.test.ts new file mode 100644 index 0000000000..269d9307c2 --- /dev/null +++ b/packages/stack/src/host/CommandRunner.initialization.integration.test.ts @@ -0,0 +1,244 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Context, Deferred, Effect, Fiber, FileSystem, Layer, Path, Ref } from "effect"; +import { TestClock } from "effect/testing"; +import { catalogPins, resolveArtifact } from "../Artifacts.ts"; +import { initialization } from "../Commands.ts"; +import { + makeArtifactStore, + type ArtifactRequest, + type ArtifactSource, +} from "../preparation/ArtifactStore.ts"; +import { PreparationError } from "../preparation/Errors.ts"; +import type { StackCredentials } from "../State.ts"; +import * as CommandRunner from "./CommandRunner.ts"; + +const target = + process.platform === "darwin" && process.arch === "arm64" + ? "darwin-arm64" + : process.platform === "linux" && process.arch === "x64" + ? "linux-amd64" + : process.platform === "linux" && process.arch === "arm64" + ? "linux-arm64" + : undefined; + +// The real catalog's default auth pin, not hardcoded — `resolveArtifact({ service: "auth", +// version })` below would otherwise fail once a catalog bump moves past a literal. +const authVersion = catalogPins().find((entry) => entry.sourceService === "auth" && entry.isDefault) + ?.pin.upstreamVersion; +if (authVersion === undefined) { + throw new Error("no default auth catalog pin found"); +} + +const prepareAuthArtifact = Effect.fn(function* (cacheRoot: string, script: string) { + if (target === undefined) return yield* Effect.fail("Unsupported test platform"); + const fs = yield* FileSystem.FileSystem; + const { releaseVersion } = yield* resolveArtifact({ service: "auth" }); + const request: ArtifactRequest = { + key: `slim-services/auth/${releaseVersion}/${target}`, + requiredRuntimePaths: ["bin/auth"], + executablePath: "bin/auth", + }; + const source: ArtifactSource = { + checksum: () => Effect.succeed("0".repeat(64)), + materialize: (_request, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/auth`, script); + yield* fs.chmod(`${destination}/bin/auth`, 0o755); + }).pipe( + Effect.mapError( + (cause) => + new PreparationError({ + message: `Unable to write Auth command fixture: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot, source }); + yield* store.prepare(request); +}); + +const makeRunner = Effect.fn(function* (root: string, cacheRoot: string) { + const layer = CommandRunner.layer({ + stackId: "initialization-command-test", + root, + cacheRoot, + runtime: "native", + }).pipe(Layer.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))); + return Context.get(yield* Layer.build(layer), CommandRunner.Service); +}); + +const credentials: StackCredentials = { + jwtSecret: "fixture-stack-jwt-secret-with-more-than-32-characters", + postgresRootKey: "fixture-postgres-root-key", + databasePassword: "fixture-database-password", + publishableKey: "fixture-publishable-key", + secretKey: "fixture-secret-key", + anonKey: "fixture-anon-key", + serviceRoleKey: "fixture-service-role-key", + jwks: "fixture-jwks", + gotrueJwtKeys: "fixture-gotrue-jwt-keys", + remoteJwks: "fixture-remote-jwks", + anonKeyIsOverride: false, + serviceRoleKeyIsOverride: false, +}; + +it.live.skipIf(target === undefined || process.platform === "win32")( + "reports bounded stdout and stderr when an initialization command fails", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "initialization-command-failure-", + }); + const cacheRoot = path.join(root, "cache"); + yield* prepareAuthArtifact( + cacheRoot, + `#!${process.execPath}\nfor (let index = 0; index < 30; index++) { console.log("x".repeat(1500) + " stdout-tail-" + index); console.error("y".repeat(1500) + " stderr-tail-" + index); }\nconsole.error("jwt=" + process.env.GOTRUE_JWT_SECRET);\nconsole.error("keys=" + process.env.GOTRUE_JWT_KEYS);\nconsole.error("fixture migration failure");\nprocess.exit(7);\n`, + ); + const runner = yield* makeRunner(root, cacheRoot); + const error = yield* runner + .run({ + command: initialization.auth({ + version: authVersion, + databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", + }), + credentials, + stdout: () => Effect.void, + stderr: () => Effect.void, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("auth.initialize exited with code 7"); + expect(error.message).toContain("stdout-tail-29"); + expect(error.message).not.toContain("stdout-tail-0"); + expect(error.message).toContain("stderr-tail-29"); + expect(error.message).not.toContain("stderr-tail-0"); + expect(error.message).toContain("fixture migration failure"); + expect(error.message).toContain(`jwt=${credentials.jwtSecret}`); + expect(error.message).toContain(`keys=${credentials.gotrueJwtKeys}`); + expect(error.message.length).toBeLessThan(45_000); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ), +); + +it.live.skipIf(target === undefined || process.platform === "win32")( + "interrupts a running initialization command and completes its cleanup", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "initialization-command-cancel-", + }); + const cacheRoot = path.join(root, "cache"); + yield* prepareAuthArtifact( + cacheRoot, + `#!${process.execPath}\nconsole.log("command started " + process.pid);\nsetInterval(() => {}, 1000);\n`, + ); + const runner = yield* makeRunner(root, cacheRoot); + const started = yield* Deferred.make<void>(); + const pid = yield* Ref.make<string | undefined>(undefined); + const command = yield* runner + .run({ + command: initialization.auth({ + version: authVersion, + databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", + }), + credentials, + stdout: (bytes) => + Effect.gen(function* () { + const match = /command started (\d+)/.exec(new TextDecoder().decode(bytes)); + if (match?.[1] !== undefined) { + yield* Ref.set(pid, match[1]); + yield* Deferred.succeed(started, undefined); + } + }), + stderr: () => Effect.void, + }) + .pipe(Effect.forkScoped); + + yield* Deferred.await(started); + yield* Fiber.interrupt(command); + + const childPid = yield* Ref.get(pid); + if (childPid === undefined) + return yield* Effect.fail("Command process id was not observed"); + const childStillRunning = yield* Effect.sync(() => { + try { + process.kill(Number(childPid), 0); + return true; + } catch { + return false; + } + }); + const jobs = yield* fs.readDirectory(path.join(root, "jobs")); + expect(childStillRunning).toBe(false); + expect(jobs).toHaveLength(0); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ), +); + +it.effect.skipIf(target === undefined || process.platform === "win32")( + "reports bounded output when an initialization command times out", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "initialization-command-timeout-", + }); + const cacheRoot = path.join(root, "cache"); + yield* prepareAuthArtifact( + cacheRoot, + `#!${process.execPath}\nconsole.error("migration still running " + process.pid);\nsetInterval(() => {}, 1000);\n`, + ); + const runner = yield* makeRunner(root, cacheRoot); + const started = yield* Deferred.make<void>(); + const pid = yield* Ref.make<string | undefined>(undefined); + const command = yield* runner + .run({ + command: initialization.auth({ + version: authVersion, + databaseUrl: "postgresql://invalid:invalid@127.0.0.1:1/postgres", + }), + credentials, + stdout: () => Effect.void, + stderr: (bytes) => + Effect.gen(function* () { + const match = /migration still running (\d+)/.exec(new TextDecoder().decode(bytes)); + if (match?.[1] !== undefined) { + yield* Ref.set(pid, match[1]); + yield* Deferred.succeed(started, undefined); + } + }), + }) + .pipe(Effect.forkScoped); + + yield* Deferred.await(started); + yield* TestClock.adjust("60 seconds"); + const error = yield* Fiber.join(command).pipe(Effect.flip); + + expect(error.message).toContain("auth.initialize timed out after 60 seconds"); + expect(error.message).toContain("migration still running"); + const childPid = yield* Ref.get(pid); + if (childPid === undefined) + return yield* Effect.fail("Command process id was not observed"); + const childStillRunning = yield* Effect.sync(() => { + try { + process.kill(Number(childPid), 0); + return true; + } catch { + return false; + } + }); + expect(childStillRunning).toBe(false); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ), +); diff --git a/packages/stack/src/host/ToolRunner.native-cleanup.integration.test.ts b/packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts similarity index 84% rename from packages/stack/src/host/ToolRunner.native-cleanup.integration.test.ts rename to packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts index f5dd5653c4..240d72a977 100644 --- a/packages/stack/src/host/ToolRunner.native-cleanup.integration.test.ts +++ b/packages/stack/src/host/CommandRunner.native-cleanup.integration.test.ts @@ -1,10 +1,10 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; -import { Context, Effect, FileSystem, Layer, Path } from "effect"; +import { Context, Effect, FileSystem, Layer, Path, Stream } from "effect"; import { systemError } from "effect/PlatformError"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { postgres } from "../Tools.ts"; -import * as ToolRunner from "./ToolRunner.ts"; +import { postgres } from "../Commands.ts"; +import * as CommandRunner from "./CommandRunner.ts"; it.live.skipIf(process.platform === "win32")( "retries failed native workload cleanup when the stack runner is cleaned up", @@ -52,18 +52,23 @@ it.live.skipIf(process.platform === "win32")( }); }), ); - const layer = ToolRunner.layer({ + const layer = CommandRunner.layer({ stackId: "native-cleanup-test", root, cacheRoot, runtime: "native", }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))); - const runner = Context.get(yield* Layer.build(layer), ToolRunner.Service); + const runner = Context.get(yield* Layer.build(layer), CommandRunner.Service); const result = yield* runner .run({ - tool: postgres.psql({ major: 17 }), - args: ["--version"], - env: {}, + command: { + type: "postgres", + command: postgres.psql({ major: 17 }), + args: ["--version"], + env: {}, + stdin: false, + }, + stdin: Stream.empty, stdout: () => Effect.void, stderr: () => Effect.void, }) diff --git a/packages/stack/src/host/ToolRunner.ts b/packages/stack/src/host/CommandRunner.ts similarity index 56% rename from packages/stack/src/host/ToolRunner.ts rename to packages/stack/src/host/CommandRunner.ts index a80c4e6d0a..1a318c2892 100644 --- a/packages/stack/src/host/ToolRunner.ts +++ b/packages/stack/src/host/CommandRunner.ts @@ -7,7 +7,6 @@ import { FileSystem, Layer, Path, - Schema, Sink, Stream, Ref, @@ -22,48 +21,62 @@ import { postgresVersion, resolveArtifact, } from "../Artifacts.ts"; -import { makeContainerRuntime } from "../runtime/Container.ts"; +import { makeContainerRuntime, type HostGateway } from "../runtime/Container.ts"; import { spawnNativeProcess } from "../runtime/NativeProcess.ts"; -import { PostgresTool, type PgProveOptions } from "../Tools.ts"; +import { awaitCommandOutput, type CommandOutputResult } from "../runtime/CommandOutput.ts"; +import type { CommandInvocation as CommandInvocationType } from "../Commands.ts"; +import type { StackCredentials } from "../State.ts"; +import { resolveInitializationCommand } from "../services/Initialization.ts"; -class ToolError extends Data.TaggedError("ToolError")<{ +export class CommandError extends Data.TaggedError("CommandError")<{ readonly message: string; readonly cause?: unknown; }> {} -class StdinWriteError extends Data.TaggedError("StdinWriteError")<{ readonly cause: ToolError }> {} +class StdinWriteError extends Data.TaggedError("StdinWriteError")<{ + readonly cause: CommandError; +}> {} -export interface ToolInput<E, R> { - readonly tool: PostgresTool; - readonly args: ReadonlyArray<string>; - readonly env: Readonly<Record<string, string>>; - readonly pgProve?: PgProveOptions; - readonly stdin?: Stream.Stream<Uint8Array, E, R>; +interface CommandInputCommon<E, R> { readonly stdout: (bytes: Uint8Array) => Effect.Effect<void, E, R>; readonly stderr: (bytes: Uint8Array) => Effect.Effect<void, E, R>; } +type CommandInput<E, R> = + | (CommandInputCommon<E, R> & { + readonly command: Extract<CommandInvocationType, { type: "postgres" }>; + readonly stdin: Stream.Stream<Uint8Array, E, R> | undefined; + }) + | (CommandInputCommon<E, R> & { + readonly command: Exclude<CommandInvocationType, { type: "postgres" }>; + readonly credentials: StackCredentials; + }); export interface Interface { readonly run: <E, R>( - input: ToolInput<E, R>, - ) => Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | ToolError, R>; - readonly cleanup: Effect.Effect<void, ToolError>; + input: CommandInput<E, R>, + ) => Effect.Effect<{ readonly jobId: string; readonly exitCode: number }, E | CommandError, R>; + readonly cleanup: Effect.Effect<void, CommandError>; } -export class Service extends Context.Service<Service, Interface>()("@supabase/stack/ToolRunner") {} +export class Service extends Context.Service<Service, Interface>()( + "@supabase/stack/CommandRunner", +) {} const failure = (cause: unknown) => - new ToolError({ + new CommandError({ message: cause instanceof Error ? cause.message : String(cause), cause, }); /** Creates an attached byte-stream runner whose invocation scope owns each finite process. */ -const makeToolRunner = (options: { +const makeCommandRunner = (options: { readonly stackId: string; + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; + /** Shares one host-gateway probe with the host's other container runtimes. */ + readonly hostGateway?: HostGateway; }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -79,13 +92,14 @@ const makeToolRunner = (options: { engine: options.runtime, root: options.root, imageMirrors: slimImageMirrors, + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), }); const jobsRoot = path.join(options.root, "jobs"); yield* fs .makeDirectory(jobsRoot, { recursive: true, mode: 0o700 }) .pipe(Effect.mapError(failure)); - const nativeCleanup = yield* Ref.make(new Map<string, Effect.Effect<void, ToolError>>()); + const nativeCleanup = yield* Ref.make(new Map<string, Effect.Effect<void, CommandError>>()); const cleanupNative = (jobId: string) => Ref.get(nativeCleanup).pipe( Effect.flatMap((entries) => { @@ -125,26 +139,38 @@ const makeToolRunner = (options: { ); }); - const run = Effect.fn("ToolRunner.run")(function* <E, R>(input: ToolInput<E, R>) { + const run = Effect.fn("CommandRunner.run")(function* <E, R>(input: CommandInput<E, R>) { yield* fs .makeDirectory(jobsRoot, { recursive: true, mode: 0o700 }) .pipe(Effect.mapError(failure)); const jobId = yield* crypto.randomUUIDv4.pipe(Effect.mapError(failure)); return yield* Effect.scoped( Effect.gen(function* () { - const tool = yield* Schema.decodeEffect(PostgresTool)(input.tool).pipe( - Effect.mapError(failure), - ); - if (tool.command !== "pg_prove" && input.pgProve !== undefined) - return yield* failure("pgProve options require the pg_prove tool"); - const version = postgresVersion(String(tool.major)); + const command = input.command; const directory = yield* fs .makeTempDirectoryScoped({ directory: jobsRoot, prefix: `${jobId}-` }) .pipe(Effect.mapError(failure)); + const initialization = + "credentials" in input + ? yield* resolveInitializationCommand(input.command, { + runtime: options.runtime, + credentials: input.credentials, + }).pipe(Effect.mapError(failure)) + : undefined; + const postgresCommand = "stdin" in input ? input.command : undefined; + const inputStream = "stdin" in input ? (input.stdin ?? Stream.empty) : Stream.empty; + if ( + postgresCommand !== undefined && + postgresCommand.command.command !== "pg_prove" && + postgresCommand.pgProve !== undefined + ) + return yield* failure("pgProve options require the pg_prove command"); + const version = + initialization?.version ?? postgresVersion(String(postgresCommand?.command.major)); const process = yield* Effect.gen(function* () { if (container === undefined) { const artifact = yield* prepareNativeArtifact( - { service: "database", version }, + { service: initialization?.service ?? "database", version }, options.cacheRoot, ).pipe( Effect.provideService(FileSystem.FileSystem, fs), @@ -155,10 +181,17 @@ const makeToolRunner = (options: { ); const child = yield* spawnNativeProcess( { - executable: path.join(artifact.root, "bin", tool.command), - args: input.args, - env: input.env, - cwd: input.pgProve?.cwd ?? directory, + executable: path.join( + artifact.root, + "bin", + postgresCommand?.command.command ?? initialization?.nativeExecutable ?? "", + ), + args: postgresCommand?.args ?? initialization?.args ?? [], + env: postgresCommand?.env ?? initialization?.env ?? {}, + cwd: + postgresCommand?.pgProve?.cwd ?? + initialization?.cwd ?? + (initialization === undefined ? directory : artifact.root), stdin: "pipe", }, undefined, @@ -180,18 +213,27 @@ const makeToolRunner = (options: { cleanup: Effect.void, }; } - const artifact = yield* resolveArtifact({ service: "database", version }); - yield* container.prepare(artifact.image); + const image = + initialization?.image ?? + (yield* resolveArtifact({ service: "database", version })).image; + yield* container.prepare(image); const child = yield* container - .launchTool({ - image: artifact.image, + .launchCommand({ + image, stackId: options.stackId, instanceId: jobId, - env: input.env, - args: input.args, - entrypoint: tool.command, - workingDir: input.pgProve?.workingDir, - mounts: input.pgProve?.mounts.map((mount) => ({ ...mount, readOnly: true })), + service: initialization?.service ?? "database", + project: options.project, + env: postgresCommand?.env ?? initialization?.env ?? {}, + args: postgresCommand?.args ?? initialization?.args ?? [], + entrypoint: + postgresCommand?.command.command ?? initialization?.containerEntrypoint ?? "", + workingDir: postgresCommand?.pgProve?.workingDir ?? initialization?.workingDir, + mounts: + postgresCommand?.pgProve?.mounts.map((mount) => ({ + ...mount, + readOnly: true, + })) ?? initialization?.mounts, }) .pipe(Effect.catchTag("ContainerLaunchError", (error) => Effect.fail(error.failure))); return { @@ -202,12 +244,12 @@ const makeToolRunner = (options: { cleanup: child.stop.pipe(Effect.andThen(child.remove), Effect.mapError(failure)), }; }).pipe(Effect.mapError(failure)); - const [, , , exitCode] = yield* Effect.acquireUseRelease( + const [, result] = yield* Effect.acquireUseRelease( Effect.succeed(process), (process) => Effect.all( [ - (input.stdin ?? Stream.empty).pipe( + inputStream.pipe( Stream.run( process.stdin.pipe(Sink.mapError((cause) => new StdinWriteError({ cause }))), ), @@ -217,15 +259,40 @@ const makeToolRunner = (options: { ), Effect.raceFirst(process.exitCode.pipe(Effect.asVoid)), ), - process.stdout.pipe(Stream.runForEach(input.stdout)), - process.stderr.pipe(Stream.runForEach(input.stderr)), - process.exitCode, + command.type === "postgres" + ? Effect.all( + [ + process.stdout.pipe(Stream.runForEach(input.stdout)), + process.stderr.pipe(Stream.runForEach(input.stderr)), + process.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([, , exitCode]): CommandOutputResult => ({ + timedOut: false, + exitCode: Number(exitCode), + output: { stdout: [], stderr: [] }, + })), + ) + : awaitCommandOutput(process, { + timeout: "60 seconds", + onOutput: (stream, bytes) => + stream === "stdout" ? input.stdout(bytes) : input.stderr(bytes), + }), ], { concurrency: "unbounded" }, ), (process) => process.cleanup, ); - return { jobId, exitCode }; + if (result.timedOut) + return yield* failure( + `${command.type} timed out after 60 seconds\nstdout:\n${result.output.stdout.join("\n")}\nstderr:\n${result.output.stderr.join("\n")}`, + ); + if (command.type !== "postgres" && result.exitCode !== 0) + return yield* failure( + `${command.type} exited with code ${result.exitCode}\nstdout:\n${result.output.stdout.join("\n")}\nstderr:\n${result.output.stderr.join("\n")}`, + ); + return { jobId, exitCode: result.exitCode }; }), ).pipe( Effect.onExit((exit) => @@ -238,7 +305,10 @@ const makeToolRunner = (options: { export const layer = (options: { readonly stackId: string; + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: "native" | "docker" | "podman"; -}) => Layer.effect(Service, makeToolRunner(options).pipe(Effect.map(Service.of))); + /** Shares one host-gateway probe with the host's other container runtimes. */ + readonly hostGateway?: HostGateway; +}) => Layer.effect(Service, makeCommandRunner(options).pipe(Effect.map(Service.of))); diff --git a/packages/stack/src/host/Credentials.ts b/packages/stack/src/host/Credentials.ts new file mode 100644 index 0000000000..64705e0452 --- /dev/null +++ b/packages/stack/src/host/Credentials.ts @@ -0,0 +1,209 @@ +import { Data, Effect, Redacted, Schema } from "effect"; +import { + DEFAULT_LOCAL_DATABASE_PASSWORD, + DEFAULT_LOCAL_JWT_SECRET, + DEFAULT_POSTGRES_ROOT_KEY, +} from "../Defaults.ts"; +import { ServiceCreation, type ServiceCreationInput } from "../services/Catalog.ts"; +import { resolveStackKeys } from "../services/ServiceConfig.ts"; +import type { SavedStack, StackCredentials, StackKeysInput } from "../State.ts"; + +export class CredentialError extends Data.TaggedError("CredentialError")<{ + readonly message: string; +}> {} + +type Overrides = Partial< + Pick<StackCredentials, "jwtSecret" | "postgresRootKey" | "databasePassword"> +>; + +/** Reports whether a creation receives the stack-wide credential record. */ +export const consumesCredentials = (creation: ServiceCreationInput): boolean => { + switch (creation.service) { + case "database": + case "auth": + case "realtime": + case "storage": + case "functions": + case "studio": + case "pooler": + return true; + case "rest": + return creation.config.jwks === undefined || creation.config.jwtSecret !== undefined; + default: + return false; + } +}; + +const overridesFor = (creation: ServiceCreationInput): Overrides => { + if (creation.service === "database") + return { + ...(creation.config.jwtSecret === undefined + ? {} + : { jwtSecret: Redacted.value(creation.config.jwtSecret) }), + ...(creation.config.rootKey === undefined + ? {} + : { postgresRootKey: Redacted.value(creation.config.rootKey) }), + ...(creation.config.databasePassword === undefined + ? {} + : { databasePassword: Redacted.value(creation.config.databasePassword) }), + }; + return "jwtSecret" in creation.config && creation.config.jwtSecret !== undefined + ? { jwtSecret: creation.config.jwtSecret } + : {}; +}; + +/** Collects explicit credential values, rejecting creations that disagree with each other. */ +export const credentialOverrides = ( + creations: ReadonlyArray<ServiceCreationInput>, +): Effect.Effect<Overrides, CredentialError> => { + const overrides: Record<string, string> = {}; + for (const creation of creations) + for (const [key, value] of Object.entries(overridesFor(creation))) { + if (overrides[key] !== undefined && overrides[key] !== value) + return Effect.fail( + new CredentialError({ + message: `Credential override ${key} conflicts within the stack composition`, + }), + ); + overrides[key] = value; + } + return Effect.succeed(overrides); +}; + +/** Drops the stack credential record once no saved instance consumes it. */ +export const withoutUnusedCredentials = (saved: SavedStack): SavedStack => { + if ( + saved.credentials === undefined || + saved.instances.some(({ creation }) => consumesCredentials(creation)) + ) + return saved; + const withoutCredentials = { ...saved }; + delete withoutCredentials.credentials; + return withoutCredentials; +}; + +const credentialsChanged = (saved: StackCredentials, next: StackCredentials) => + next.jwtSecret !== saved.jwtSecret || + next.publishableKey !== saved.publishableKey || + next.secretKey !== saved.secretKey || + next.anonKey !== saved.anonKey || + next.serviceRoleKey !== saved.serviceRoleKey || + next.jwks !== saved.jwks || + next.gotrueJwtKeys !== saved.gotrueJwtKeys || + next.remoteJwks !== saved.remoteJwks || + next.anonKeyIsOverride !== saved.anonKeyIsOverride || + next.serviceRoleKeyIsOverride !== saved.serviceRoleKeyIsOverride; + +/** + * Resolves the stack credential record, generating one only while no saved instance consumes it; + * it returns the saved record itself when nothing changes. + */ +export const nextCredentials = Effect.fn("Credentials.next")(function* ( + current: Pick<SavedStack, "credentials" | "instances">, + overrides: Overrides, + keys: StackKeysInput | undefined, +) { + const saved = current.credentials; + if (saved === undefined) { + if (current.instances.some(({ creation }) => consumesCredentials(creation))) + return yield* new CredentialError({ + message: "Saved instances have no stack credential record; refusing to infer credentials", + }); + const jwtSecret = overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET; + return { + jwtSecret, + postgresRootKey: overrides.postgresRootKey ?? DEFAULT_POSTGRES_ROOT_KEY, + databasePassword: overrides.databasePassword ?? DEFAULT_LOCAL_DATABASE_PASSWORD, + ...(yield* resolveStackKeys(jwtSecret, keys, undefined)), + } satisfies StackCredentials; + } + const conflict = + (overrides.postgresRootKey !== undefined && + overrides.postgresRootKey !== saved.postgresRootKey && + "rootKey") || + (overrides.databasePassword !== undefined && + overrides.databasePassword !== saved.databasePassword && + "databasePassword") || + (keys === undefined && + overrides.jwtSecret !== undefined && + overrides.jwtSecret !== saved.jwtSecret && + "jwtSecret"); + if (conflict !== false) + return yield* new CredentialError({ + message: `Credential override ${conflict} conflicts with the saved stack value`, + }); + const jwtSecret = + keys === undefined ? saved.jwtSecret : (overrides.jwtSecret ?? DEFAULT_LOCAL_JWT_SECRET); + const next: StackCredentials = { + ...saved, + jwtSecret, + ...(yield* resolveStackKeys(jwtSecret, keys, saved)), + }; + return credentialsChanged(saved, next) ? next : saved; +}); + +type CredentialValue = { + [K in keyof StackCredentials]: StackCredentials[K] extends string ? K : never; +}[keyof StackCredentials]; + +/** Config inputs each service receives from the stack credential record, by input name. */ +const credentialInputs: { + readonly [K in ServiceCreation["service"]]: Readonly<Record<string, CredentialValue>>; +} = { + database: { + databasePassword: "databasePassword", + jwtSecret: "jwtSecret", + rootKey: "postgresRootKey", + }, + rest: { jwtSecret: "jwtSecret", jwks: "jwks" }, + auth: { jwtSecret: "jwtSecret", gotrueJwtKeys: "gotrueJwtKeys" }, + realtime: { jwtSecret: "jwtSecret", jwks: "jwks" }, + storage: { + jwtSecret: "jwtSecret", + jwks: "jwks", + anonKey: "anonKey", + serviceRoleKey: "serviceRoleKey", + }, + functions: { + jwtSecret: "jwtSecret", + jwks: "jwks", + anonKey: "anonKey", + serviceRoleKey: "serviceRoleKey", + publishableKey: "publishableKey", + secretKey: "secretKey", + }, + studio: { + jwtSecret: "jwtSecret", + anonKey: "anonKey", + serviceRoleKey: "serviceRoleKey", + publishableKey: "publishableKey", + secretKey: "secretKey", + }, + pooler: { jwtSecret: "jwtSecret" }, + imgproxy: {}, + pgmeta: {}, + mail: {}, + analytics: {}, + vector: {}, +}; + +/** Names the config inputs the owner fills from the stack credential record. */ +export const credentialInputNames = (service: ServiceCreation["service"]): ReadonlyArray<string> => + Object.keys(credentialInputs[service]); + +/** + * Completes a creation with the stack credentials it consumes. The JWT secret and database + * credentials always come from the record; other inputs keep an explicit value. + */ +export const withCredentials = ( + creation: ServiceCreationInput, + credentials: StackCredentials, +): Effect.Effect<ServiceCreation, Schema.SchemaError> => { + const config: Record<string, unknown> = { ...creation.config }; + for (const [input, source] of Object.entries(credentialInputs[creation.service])) { + const value = credentials[source]; + if (creation.service === "database") config[input] = Redacted.make(value); + else if (input === "jwtSecret" || config[input] === undefined) config[input] = value; + } + return Schema.decodeUnknownEffect(ServiceCreation)({ ...creation, config }); +}; diff --git a/packages/stack/src/host/Endpoints.ts b/packages/stack/src/host/Endpoints.ts index bd8515dde3..036f245874 100644 --- a/packages/stack/src/host/Endpoints.ts +++ b/packages/stack/src/host/Endpoints.ts @@ -1,5 +1,9 @@ import { allowedEndpointNames, type ServiceCreation } from "../services/Catalog.ts"; -import { Data, Effect } from "effect"; +import type { ServiceEndpoint } from "../services/Recipe.ts"; +import { apiPath as storageApiPath } from "../services/Storage.ts"; +import type { NetworkEndpoint } from "../Network.ts"; +import { ProxyError, type BackendAddress } from "../Proxy.ts"; +import { Data, Effect, Redacted } from "effect"; export class EndpointError extends Data.TaggedError("EndpointError")<{ readonly message: string; @@ -9,14 +13,16 @@ export class EndpointError extends Data.TaggedError("EndpointError")<{ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === "object" && value !== null && !Array.isArray(value); -export const endpointNames = (creation: ServiceCreation): ReadonlyArray<string> => { +type EndpointIntents = Pick<ServiceCreation, "service" | "endpoints">; + +export const endpointNames = (creation: EndpointIntents): ReadonlyArray<string> => { const configured: Readonly<Record<string, unknown>> = Object.fromEntries( Object.entries(creation.endpoints ?? {}), ); return allowedEndpointNames(creation.service).filter((name) => isRecord(configured[name])); }; -export const endpointPort = (creation: ServiceCreation, name: string): number | "auto" => { +export const endpointPort = (creation: EndpointIntents, name: string): number | "auto" => { const endpoints: unknown = creation.endpoints; if (!isRecord(endpoints)) return "auto"; const endpoint = endpoints[name]; @@ -24,6 +30,7 @@ export const endpointPort = (creation: ServiceCreation, name: string): number | return endpoint.port === "auto" || typeof endpoint.port === "number" ? endpoint.port : "auto"; }; +/** Path prefix of a service on the shared API listener, or `undefined` when it has a dedicated one. */ export const apiRoute = (service: ServiceCreation["service"]): string | undefined => { switch (service) { case "rest": @@ -31,7 +38,7 @@ export const apiRoute = (service: ServiceCreation["service"]): string | undefine case "auth": return "/auth/v1"; case "storage": - return "/storage/v1"; + return storageApiPath; case "functions": return "/functions/v1"; case "realtime": @@ -41,6 +48,64 @@ export const apiRoute = (service: ServiceCreation["service"]): string | undefine } }; +type RouteKeys = NonNullable<NonNullable<NetworkEndpoint["shared"]>[number]["keyRewrite"]>["keys"]; + +/** Routes a service's HTTP endpoint on the shared API listener, or `undefined` for a dedicated one. */ +export const sharedRoutes = ( + creation: ServiceCreation, + name: string, + keys: RouteKeys, +): NetworkEndpoint["shared"] => { + const route = name === "http" ? apiRoute(creation.service) : undefined; + if (route === undefined) return undefined; + switch (creation.service) { + case "realtime": + return [ + { + prefix: "/realtime/v1/api", + upstreamPrefix: "/api", + upstreamHost: "realtime-dev", + keyRewrite: { policy: "bearer", keys }, + }, + { + prefix: route, + upstreamPrefix: "/socket", + upstreamHost: "realtime-dev", + keyRewrite: { policy: "query", keys }, + }, + ]; + case "storage": + return [ + { prefix: `${route}/s3`, upstreamPrefix: "/s3" }, + { prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }, + ]; + case "rest": + case "auth": + return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "bearer", keys } }]; + case "functions": + return [{ prefix: route, upstreamPrefix: "/", keyRewrite: { policy: "sb-api-key", keys } }]; + default: + return [{ prefix: route, upstreamPrefix: "/" }]; + } +}; + +/** Shared-listener routes a dedicated HTTP endpoint also serves: Studio's MCP server at `/mcp`. */ +export const joinRoutes = (creation: ServiceCreation, name: string): NetworkEndpoint["join"] => + creation.service === "studio" && name === "http" + ? [{ prefix: "/mcp", upstreamPrefix: "/api/mcp" }] + : undefined; + +/** Translates a launched runtime's endpoint into the proxy's backend address. */ +export const backendAddress = ( + endpoint: ServiceEndpoint, +): Effect.Effect<BackendAddress, ProxyError> => { + if (endpoint.kind === "unix") + return endpoint.path === undefined + ? Effect.fail(new ProxyError({ message: "Unix endpoint has no path" })) + : Effect.succeed({ path: `${endpoint.path}/.s.PGSQL.${endpoint.port}` }); + return Effect.succeed({ host: endpoint.host ?? "127.0.0.1", port: endpoint.port }); +}; + export const publicUrl = (host: string, port: number) => `http://${host}:${port}`; const postgresUrl = ( @@ -57,21 +122,25 @@ interface EndpointAddress { readonly port: number; } -export type EndpointAddressFor = ( +export type EndpointAddressFor<E> = ( endpoint: string, from: "host" | "runtime", -) => Effect.Effect<EndpointAddress, EndpointError>; +) => Effect.Effect<EndpointAddress, E>; -export type EndpointPassword = () => Effect.Effect<string, EndpointError>; +const databasePassword = (creation: ServiceCreation): Effect.Effect<string, EndpointError> => + creation.service === "database" + ? Effect.succeed(Redacted.value(creation.config.databasePassword)) + : Effect.fail(new EndpointError({ message: "Database URLs require a database" })); -export const outputsFor = ( +/** Renders dependency outputs; `current` supplies the saved creation at resolution time. */ +export const outputsFor = <E>( creation: ServiceCreation, - address: EndpointAddressFor, - password: EndpointPassword, -): Readonly<Record<string, Effect.Effect<string, EndpointError>>> => { + current: Effect.Effect<ServiceCreation>, + address: EndpointAddressFor<E>, +): Readonly<Record<string, Effect.Effect<string, E | EndpointError>>> => { const output = (name: string) => address(name, "runtime").pipe(Effect.map(({ host, port }) => publicUrl(host, port))); - const outputs: Record<string, Effect.Effect<string, EndpointError>> = {}; + const outputs: Record<string, Effect.Effect<string, E | EndpointError>> = {}; if (endpointNames(creation).includes("http")) { outputs.url = output("http"); outputs.hostUrl = address("http", "host").pipe( @@ -93,54 +162,53 @@ export const outputsFor = ( ] as const) outputs[name] = address("sql", "runtime").pipe( Effect.flatMap(({ host, port }) => - password().pipe(Effect.map((value) => postgresUrl(host, port, role, value, database))), + current.pipe( + Effect.flatMap(databasePassword), + Effect.map((value) => postgresUrl(host, port, role, value, database)), + ), ), ); } return outputs; }; -export const credentialsFor = Effect.fn("Endpoints.credentialsFor")( - ( - creation: ServiceCreation, - address: EndpointAddressFor, - password: EndpointPassword, - from: "host" | "runtime", - ): Effect.Effect<Readonly<Record<string, string>>, EndpointError> => - Effect.gen(function* () { - const credentials: Record<string, string> = {}; - if (endpointNames(creation).includes("http")) { - const { host, port } = yield* address("http", from); - const origin = publicUrl(host, port); - credentials.url = `${origin}${apiRoute(creation.service) ?? ""}`; - if (apiRoute(creation.service) !== undefined) credentials.apiUrl = origin; - } - if (creation.service === "mail") { - if (endpointNames(creation).includes("smtp")) { - const { host, port } = yield* address("smtp", from); - credentials.smtpUrl = `smtp://${host}:${port}`; - } - if (endpointNames(creation).includes("pop3")) { - const { host, port } = yield* address("pop3", from); - credentials.pop3Url = `pop3://${host}:${port}`; - } - } - if (creation.service === "pooler" && endpointNames(creation).includes("sql")) { - const { host, port } = yield* address("sql", from); - credentials.sqlUrl = `postgresql://${host}:${port}`; - } - if (creation.service === "database" && endpointNames(creation).includes("sql")) { - const { host, port } = yield* address("sql", from); - const value = yield* password(); - for (const [name, role, database] of [ - ["databaseUrl", "supabase_admin", "postgres"], - ["authenticatorUrl", "authenticator", "postgres"], - ["authDatabaseUrl", "supabase_auth_admin", "postgres"], - ["storageDatabaseUrl", "supabase_storage_admin", "postgres"], - ["internalDatabaseUrl", "supabase_admin", "_supabase"], - ] as const) - credentials[name] = postgresUrl(host, port, role, value, database); - } - return credentials; - }), -); +export const credentialsFor = Effect.fn("Endpoints.credentialsFor")(function* <E>( + creation: ServiceCreation, + address: EndpointAddressFor<E>, + from: "host" | "runtime", +) { + const credentials: Record<string, string> = {}; + if (endpointNames(creation).includes("http")) { + const { host, port } = yield* address("http", from); + const origin = publicUrl(host, port); + credentials.url = `${origin}${apiRoute(creation.service) ?? ""}`; + if (apiRoute(creation.service) !== undefined) credentials.apiUrl = origin; + } + if (creation.service === "mail") { + if (endpointNames(creation).includes("smtp")) { + const { host, port } = yield* address("smtp", from); + credentials.smtpUrl = `smtp://${host}:${port}`; + } + if (endpointNames(creation).includes("pop3")) { + const { host, port } = yield* address("pop3", from); + credentials.pop3Url = `pop3://${host}:${port}`; + } + } + if (creation.service === "pooler" && endpointNames(creation).includes("sql")) { + const { host, port } = yield* address("sql", from); + credentials.sqlUrl = `postgresql://${host}:${port}`; + } + if (creation.service === "database" && endpointNames(creation).includes("sql")) { + const { host, port } = yield* address("sql", from); + const value = yield* databasePassword(creation); + for (const [name, role, database] of [ + ["databaseUrl", "supabase_admin", "postgres"], + ["authenticatorUrl", "authenticator", "postgres"], + ["authDatabaseUrl", "supabase_auth_admin", "postgres"], + ["storageDatabaseUrl", "supabase_storage_admin", "postgres"], + ["internalDatabaseUrl", "supabase_admin", "_supabase"], + ] as const) + credentials[name] = postgresUrl(host, port, role, value, database); + } + return credentials; +}); diff --git a/packages/stack/src/identity/Identity.ts b/packages/stack/src/identity/Identity.ts index bef867a950..379d04a2a4 100644 --- a/packages/stack/src/identity/Identity.ts +++ b/packages/stack/src/identity/Identity.ts @@ -1,9 +1,11 @@ -import { Crypto, Effect, FileSystem, Schema } from "effect"; +import { Crypto, Effect, FileSystem, Path, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { InvalidProjectRootError, InvalidStackIdentityError } from "./Errors.ts"; import { StackIdSchema } from "./StackId.ts"; import { resolveGitBranchContext } from "./GitBranchContext.ts"; +const DEFAULT_STACK_NAME = "default"; + export interface StackIdentity { readonly projectRoot: string; readonly branchContext: string; @@ -88,7 +90,7 @@ export const resolveStackIdentity = Effect.fn("Identity.resolveStackIdentity")(f }); } - const stackName = options.name ?? "default"; + const stackName = options.name ?? DEFAULT_STACK_NAME; if (stackName.trim().length === 0) { return yield* identityFailure("The stack name must not be blank", { name: options.name }); } @@ -110,3 +112,13 @@ export const resolveStackIdentity = Effect.fn("Identity.resolveStackIdentity")(f ), ); }); + +/** + * Names a stack for container names and grouping labels: the project root's folder name plus any + * non-default stack name, or the stack name alone when the root has no folder name. + */ +export const projectSegmentFor = (identity: StackIdentity, path: Path.Path): string => { + const base = path.basename(identity.projectRoot); + if (base.trim().length === 0) return identity.stackName; + return identity.stackName === DEFAULT_STACK_NAME ? base : `${base}-${identity.stackName}`; +}; diff --git a/packages/stack/src/identity/Identity.unit.test.ts b/packages/stack/src/identity/Identity.unit.test.ts new file mode 100644 index 0000000000..b714d0e820 --- /dev/null +++ b/packages/stack/src/identity/Identity.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Path } from "effect"; +import { projectSegmentFor } from "./Identity.ts"; + +const identity = (projectRoot: string, stackName = "default") => ({ + projectRoot, + branchContext: "ordinary-workspace", + stackName, +}); + +describe("projectSegmentFor", () => { + it.effect("uses the project folder name", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/work/my.app"), path)).toBe("my.app"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("appends a non-default stack name so sibling stacks stay distinguishable", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/work/my.app", "test"), path)).toBe("my.app-test"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("falls back to the stack name for the filesystem root", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(projectSegmentFor(identity("/"), path)).toBe("default"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 7b154fd130..c1df2cbe77 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -1,349 +1,64 @@ -import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { Effect, Layer, ManagedRuntime, Schema, Stream } from "effect"; +import { Effect, Option } from "effect"; import * as StackEffect from "./effect.ts"; -import { StackError } from "./Rpc.ts"; -import { - ServiceCreationInput as CreationSchema, - type ServiceCreation as EffectCreation, -} from "./services/Catalog.ts"; -import type { PostgresTool } from "./Tools.ts"; +import { acquire, runOnce, stackAdapter, type CallOptions, type Client } from "./PromiseClient.ts"; -export { - DEFAULT_LOCAL_DATABASE_PASSWORD, - DEFAULT_LOCAL_JWT_SECRET, - DEFAULT_LOCAL_PUBLISHABLE_KEY, - DEFAULT_LOCAL_S3_ACCESS_KEY_ID, - DEFAULT_LOCAL_S3_REGION, - DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, - DEFAULT_LOCAL_SECRET_KEY, - DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, - DEFAULT_POOLER_VAULT_ENCRYPTION_KEY, - DEFAULT_POSTGRES_ROOT_KEY, - DEFAULT_REALTIME_DB_ENCRYPTION_KEY, - DEFAULT_SIGNING_KEY, -} from "./Defaults.ts"; -export type { StackCredentials, StackIdentityInput } from "./State.ts"; - -export { postgres } from "./Tools.ts"; +export type { + CallOptions, + DatabaseInstance, + ServiceCreationInput, + ServiceInstance, + ServiceInstances, + InitializationCommandOptions, + PostgresCommandOptions, + Stack, +} from "./PromiseClient.ts"; +export type { StackCredentials, StackKeysInput } from "./State.ts"; +export { initialization, postgres } from "./Commands.ts"; export { StackError } from "./Rpc.ts"; -type DatabaseCreation = Extract<EffectCreation, { service: "database" }>; -/** Plain service configuration accepted by non-Effect callers. */ -export type ServiceCreation = - | Exclude<EffectCreation, DatabaseCreation> - | (Omit<DatabaseCreation, "config"> & { - readonly config: Omit< - DatabaseCreation["config"], - "databasePassword" | "jwtSecret" | "rootKey" - > & { - readonly databasePassword?: string; - readonly jwtSecret?: string; - readonly rootKey?: string; - }; - }); -export type ServiceCreationInput = ServiceCreation; -const creationJson = Schema.toCodecJson(CreationSchema); -const decodeCreation = (creation: unknown) => - Schema.decodeUnknownEffect(creationJson)(creation).pipe( - Effect.mapError((cause) => new StackError({ operation: "config", message: cause.message })), - ); export type { CompositionConfig } from "./Orchestrator.ts"; export type { Observation } from "./Rpc.ts"; -export type { PgProveOptions } from "./effect.ts"; -export type { SupabaseCompositionOptions } from "./effect.ts"; -export type { CreateOptions, OpenOptions, StackLocations } from "./effect.ts"; - -const clientLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); -type Runtime = ReturnType<typeof makeRuntime>; -const makeRuntime = () => ManagedRuntime.make(clientLayer); -type Kind = ServiceCreation["service"]; -/** Optional cancellation ends the caller's wait, or its attached tool job. */ -export interface CallOptions { - readonly signal?: AbortSignal; -} -export type CompositionSupabaseOptions = StackEffect.SupabaseCompositionOptions & CallOptions; -/** An individual service; closing the client does not stop this process. */ -export interface ServiceInstance<K extends Kind = Kind> { - readonly id: string; - readonly service: K; - readonly start: (options?: CallOptions) => Promise<void>; - readonly ready: (options?: CallOptions) => Promise<void>; - readonly stop: (options?: CallOptions) => Promise<void>; - readonly restart: ( - input?: Pick<Extract<ServiceCreation, { service: K }>, "config">, - options?: CallOptions, - ) => Promise<void>; - readonly destroy: (options?: CallOptions) => Promise<void>; - readonly prepare: (options?: CallOptions) => Promise<void>; - readonly status: (options?: CallOptions) => Promise<StackEffect.Observation>; - readonly followStatus: () => AsyncIterable<StackEffect.Observation>; - readonly logs: () => AsyncIterable<{ - readonly stream: "stdout" | "stderr"; - readonly bytes: Uint8Array; - }>; - readonly credentials: ( - options?: CallOptions & { readonly from?: "host" | "runtime" }, - ) => Promise<Readonly<Record<string, string>>>; -} -/** Database storage operations require a stopped instance with wake disabled. */ -export interface DatabaseInstance extends ServiceInstance<"database"> { - readonly saveSnapshot: (key: string, options?: CallOptions) => Promise<void>; - readonly restoreSnapshot: (key: string, options?: CallOptions) => Promise<boolean>; - /** Removes database-owned data while preserving the instance registration. */ - readonly resetData: (options?: CallOptions) => Promise<void>; -} -/** Creation preserves the selected service's available operations. */ -export type ServiceInstances = { - [K in Kind]: K extends "database" ? DatabaseInstance : ServiceInstance<K>; -}; -type AnyInstance = ServiceInstances[Kind]; -/** Streaming inputs and awaited output sinks for an attached finite tool. */ -export interface ToolOptions extends CallOptions { - readonly args?: ReadonlyArray<string>; - readonly env?: Readonly<Record<string, string>>; - readonly pgProve?: StackEffect.PgProveOptions; - readonly stdin?: AsyncIterable<Uint8Array>; - readonly stdout: (bytes: Uint8Array) => void | Promise<void>; - readonly stderr: (bytes: Uint8Array) => void | Promise<void>; -} +export type { + CreateOptions, + CreationChange, + DatabaseSnapshotOptions, + DestroyResult, + FindOptions, + FoundStack, + OpenOptions, + PgProveOptions, + PlannedInstance, + SavedStack, + StackLocations, + SupabaseCompositionOptions, +} from "./effect.ts"; -const adapt = (handle: StackEffect.Stack, runtime: Runtime) => { - const run = <A, E>(effect: Effect.Effect<A, E>, options?: CallOptions) => - runtime.runPromise(effect, options); - const activeIterators = new Set<() => Promise<void>>(); - let clientClosePromise: Promise<void> | undefined; - const iterable = <A, E>(stream: Stream.Stream<A, E>): AsyncIterable<A> => ({ - [Symbol.asyncIterator]() { - if (clientClosePromise !== undefined) throw new Error("Stack client is closed"); - const iterator = runtime - .runSync(Stream.toAsyncIterableEffect(stream)) - [Symbol.asyncIterator](); - const iteratorReturn = iterator.return; - const iteratorThrow = iterator.throw; - let iteratorClosePromise: Promise<IteratorResult<A>> | undefined; - let dispose: () => Promise<void>; - const close = (): Promise<IteratorResult<A>> => { - if (iteratorClosePromise !== undefined) return iteratorClosePromise; - const promise: Promise<IteratorResult<A>> = ( - iteratorReturn === undefined - ? Promise.resolve<IteratorResult<A>>({ done: true, value: undefined }) - : iteratorReturn(undefined) - ).finally(() => activeIterators.delete(dispose)); - iteratorClosePromise = promise; - return promise; - }; - dispose = () => close().then(() => undefined); - activeIterators.add(dispose); - return { - next: (value?: unknown) => - iterator.next(value).then( - (result) => { - if (result.done) { - activeIterators.delete(dispose); - } - return result; - }, - (error) => { - activeIterators.delete(dispose); - return Promise.reject(error); - }, - ), - return: close, - ...(iteratorThrow === undefined - ? {} - : { - throw: (error?: unknown) => - iteratorThrow(error).then( - (result) => { - if (result.done) { - activeIterators.delete(dispose); - } - return result; - }, - (failure) => { - activeIterators.delete(dispose); - return Promise.reject(failure); - }, - ), - }), - }; - }, - }); - const common = <K extends Kind>(service: StackEffect.ServiceInstance<K>): ServiceInstance<K> => ({ - id: service.id, - service: service.service, - start: (options) => run(service.start, options), - ready: (options) => run(service.ready, options), - stop: (options) => run(service.stop, options), - restart: (input, options) => - run( - input === undefined - ? service.restart() - : decodeCreation({ - service: service.service, - config: input.config, - }).pipe( - Effect.mapError( - (cause) => new StackError({ operation: "restart", message: cause.message }), - ), - Effect.flatMap((decoded) => { - const matchesKind = ( - candidate: StackEffect.ServiceCreationInput, - ): candidate is Extract<StackEffect.ServiceCreationInput, { service: K }> => - candidate.service === service.service; - return matchesKind(decoded) - ? service.restart(decoded) - : Effect.fail( - new StackError({ - operation: "restart", - message: "Service kind cannot change", - }), - ); - }), - ), - options, - ), - destroy: (options) => run(service.destroy, options), - prepare: (options) => run(service.prepare, options), - status: (options) => run(service.status, options), - followStatus: () => iterable(service.followStatus), - logs: () => iterable(service.logs), - credentials: (options) => run(service.credentials(options), options), - }); - function instance<K extends Kind>(service: StackEffect.ServiceInstances[K]): ServiceInstances[K]; - function instance(service: StackEffect.ServiceInstances[Kind]): AnyInstance { - switch (service.service) { - case "database": - return { - ...common(service), - saveSnapshot: (key, options) => run(service.saveSnapshot(key), options), - restoreSnapshot: (key, options) => run(service.restoreSnapshot(key), options), - resetData: (options) => run(service.resetData, options), - }; - case "rest": - return common(service); - case "auth": - return common(service); - case "realtime": - return common(service); - case "storage": - return common(service); - case "imgproxy": - return common(service); - case "functions": - return common(service); - case "studio": - return common(service); - case "pgmeta": - return common(service); - case "mail": - return common(service); - case "analytics": - return common(service); - case "vector": - return common(service); - case "pooler": - return common(service); - } - } - function create<Input extends ServiceCreation>( - creation: Input, - options?: CallOptions, - ): Promise<ServiceInstances[Input["service"]]>; - function create(creation: ServiceCreation, options?: CallOptions): Promise<AnyInstance> { - return run( - decodeCreation(creation).pipe(Effect.flatMap(handle.services.create), Effect.map(instance)), - options, - ); - } - const sinkError = (cause: unknown) => - new StackError({ - operation: "tool-stream", - message: cause instanceof Error ? cause.message : String(cause), - }); - return { - id: handle.id, - services: { - create, - get: (id: string, options?: CallOptions) => - run(handle.services.get(id).pipe(Effect.map(instance)), options), - list: (options?: CallOptions) => - run(handle.services.list.pipe(Effect.map((services) => services.map(instance))), options), - }, - credentials: { - get: (options?: CallOptions) => run(handle.credentials.get, options), - }, - composition: { - supabase: (services: ReadonlyArray<ServiceCreation>, options?: CompositionSupabaseOptions) => - run( - Effect.forEach(services, decodeCreation).pipe( - Effect.flatMap((decoded) => handle.composition.supabase(decoded, options)), - Effect.map((instances) => instances.map(instance)), - ), - options, - ), - configure: (config: StackEffect.CompositionConfig, options?: CallOptions) => - run(handle.composition.configure(config), options), - describe: (options?: CallOptions) => run(handle.composition.describe, options), - start: (options?: CallOptions) => run(handle.composition.start, options), - stop: (options?: CallOptions) => run(handle.composition.stop, options), - restart: (options?: CallOptions) => run(handle.composition.restart, options), - }, - stop: (options?: CallOptions) => run(handle.stop, options), - destroy: (options?: CallOptions) => run(handle.destroy, options), - close: () => { - if (clientClosePromise !== undefined) return clientClosePromise; - clientClosePromise = Promise.allSettled( - [...activeIterators].map((dispose) => dispose()), - ).then(() => runtime.dispose()); - return clientClosePromise; - }, - tools: { - run: (tool: PostgresTool, options: ToolOptions) => - run( - handle.tools.run(tool, { - args: options.args, - env: options.env, - pgProve: options.pgProve, - ...(options.stdin === undefined - ? {} - : { stdin: Stream.fromAsyncIterable(options.stdin, sinkError) }), - stdout: (bytes) => - Effect.tryPromise({ - try: () => Promise.resolve(options.stdout(bytes)), - catch: sinkError, - }), - stderr: (bytes) => - Effect.tryPromise({ - try: () => Promise.resolve(options.stderr(bytes)), - catch: sinkError, - }), - }), - options, - ), - }, - }; -}; -/** A Promise client whose close operation leaves the detached owner running. */ -export type Stack = ReturnType<typeof adapt>; +const adaptStack = (acquired: { readonly value: StackEffect.Stack; readonly client: Client }) => + stackAdapter(acquired.client).stack(acquired.value); -const acquire = ( - effect: ReturnType<typeof StackEffect.create>, - options?: CallOptions, -): Promise<Stack> => { - const runtime = makeRuntime(); - return runtime.runPromise(effect, options).then( - (handle) => adapt(handle, runtime), - (error: unknown) => runtime.dispose().then(() => Promise.reject(error)), - ); -}; /** Registers a new stack identity. */ -export const create = ( - options: StackEffect.CreateOptions, - callOptions?: CallOptions, -): Promise<Stack> => acquire(StackEffect.create(options), callOptions); +export const create = (options: StackEffect.CreateOptions, callOptions?: CallOptions) => + acquire(StackEffect.create(options), callOptions).then(adaptStack); /** Opens an existing stack without launching its services. */ -export const open = (options: StackEffect.OpenOptions, callOptions?: CallOptions): Promise<Stack> => - acquire(StackEffect.open(options), callOptions); -/** Discovers saved stacks and separately reports their live-owner availability. */ -export const discover = (options: Pick<StackEffect.StackLocations, "stateRoot">) => - Effect.runPromise(StackEffect.discover(options).pipe(Effect.provide(clientLayer))); +export const open = (options: StackEffect.OpenOptions, callOptions?: CallOptions) => + acquire(StackEffect.open(options), callOptions).then(adaptStack); +/** Discovers readable saved stacks with their live owners; `onInvalidState` observes skipped entries. */ +export const discover = ( + options: Pick<StackEffect.StackLocations, "stateRoot"> & { + readonly onInvalidState?: (id: string, error: Error) => void; + }, + callOptions?: CallOptions, +) => { + const onInvalidState = options.onInvalidState; + return runOnce( + StackEffect.discover({ + stateRoot: options.stateRoot, + ...(onInvalidState === undefined + ? {} + : { onInvalidState: (id, error) => Effect.sync(() => onInvalidState(id, error)) }), + }), + callOptions, + ); +}; +/** Reads one saved stack by id or by project identity; resolves `undefined` when none is saved. */ +export const find = (options: StackEffect.FindOptions, callOptions?: CallOptions) => + runOnce(StackEffect.find(options).pipe(Effect.map(Option.getOrUndefined)), callOptions); diff --git a/packages/stack/src/internal/artifacts.ts b/packages/stack/src/internal/artifacts.ts new file mode 100644 index 0000000000..0ee05f1fac --- /dev/null +++ b/packages/stack/src/internal/artifacts.ts @@ -0,0 +1,13 @@ +/** Artifact catalog and preparation shared with the CLI's stack-independent clients. */ +export { + ArtifactError, + artifactServiceKinds, + catalogPins, + defaultRuntime, + postgresVersion, + prepareNativeArtifact, + resolveArtifact, + type ArtifactPin, + type NativePin, + type ServiceKind, +} from "../Artifacts.ts"; diff --git a/packages/stack/src/internal/dispatch.integration.test.ts b/packages/stack/src/internal/dispatch.integration.test.ts index 2d0008a0f5..7a547d4ef4 100644 --- a/packages/stack/src/internal/dispatch.integration.test.ts +++ b/packages/stack/src/internal/dispatch.integration.test.ts @@ -64,6 +64,7 @@ it.live("dispatches compiled owner and native launchers through the production b runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "compiled" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); diff --git a/packages/stack/src/internal/dispatch.ts b/packages/stack/src/internal/dispatch.ts index 8af8959b38..acd2ad7784 100644 --- a/packages/stack/src/internal/dispatch.ts +++ b/packages/stack/src/internal/dispatch.ts @@ -6,15 +6,7 @@ import { /** Runs the embedded owner when a compiled CLI receives its private dispatch marker. */ export const runHostProcessIfDispatched = (argv: ReadonlyArray<string>): Promise<boolean> => { if (argv[0] !== HOST_PROCESS_DISPATCH_SENTINEL) return Promise.resolve(false); - return import("./host-process.ts") - .then(({ runHostProcess }) => runHostProcess(argv.slice(1))) - .then( - () => true, - () => { - process.exitCode = 1; - return true; - }, - ); + return import("./host-process.ts").then(({ runHostProcess }) => runHostProcess(argv.slice(1))); }; /** Runs the embedded native launcher when a compiled CLI receives its private dispatch marker. */ diff --git a/packages/stack/src/internal/error-message.ts b/packages/stack/src/internal/error-message.ts new file mode 100644 index 0000000000..f770b806d5 --- /dev/null +++ b/packages/stack/src/internal/error-message.ts @@ -0,0 +1,14 @@ +/** Walks an error's `.cause` chain, joining each distinct message so nested detail is not lost. */ +export const errorChainMessage = (error: unknown): string => { + const parts: Array<string> = []; + const visited = new Set<Error>(); + let current: unknown = error; + while (current instanceof Error && !visited.has(current)) { + visited.add(current); + const text = current.message || current.name; + // Wrappers often copy their cause's message; keep the chain but not the repeat. + if (parts.at(-1) !== text) parts.push(text); + current = current.cause; + } + return parts.join(": "); +}; diff --git a/packages/stack/src/internal/failure-message.ts b/packages/stack/src/internal/failure-message.ts new file mode 100644 index 0000000000..fb483cce5e --- /dev/null +++ b/packages/stack/src/internal/failure-message.ts @@ -0,0 +1,13 @@ +/** Describes a failure as a string; Effect errors such as `Cause.UnknownError` can lack a message. */ +export const failureMessage = (cause: unknown): string => { + if (!(cause instanceof Error)) return String(cause); + const visited = new Set<Error>(); + let current: unknown = cause; + while (current instanceof Error && !visited.has(current)) { + visited.add(current); + const message: unknown = current.message; + if (typeof message === "string" && message.length > 0) return message; + current = current.cause; + } + return current === undefined || current instanceof Error ? cause.name : failureMessage(current); +}; diff --git a/packages/stack/src/internal/host-process.ts b/packages/stack/src/internal/host-process.ts index c2b439775e..eb459d0c37 100644 --- a/packages/stack/src/internal/host-process.ts +++ b/packages/stack/src/internal/host-process.ts @@ -1,15 +1,9 @@ -import { Cause, Effect, Option, Schema } from "effect"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- readiness is an inherited launcher descriptor. import { closeSync, writeSync } from "node:fs"; +import { SavedStack } from "../State.ts"; import { runStackHost, StackHostError, type StackHostOptions } from "../StackHost.ts"; -const causeCode = (cause: unknown): string | undefined => { - if (typeof cause !== "object" || cause === null) return undefined; - if ("code" in cause && typeof cause.code === "string") return cause.code; - if ("cause" in cause) return causeCode(cause.cause); - return undefined; -}; - const writeLine = (value: unknown) => Effect.gen(function* () { const serialized = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))( @@ -38,30 +32,37 @@ const writeLine = (value: unknown) => const options = ( args: ReadonlyArray<string>, report: (value: unknown) => Effect.Effect<void, StackHostError>, -): Effect.Effect<StackHostOptions, StackHostError> => { - if (args.length !== 3) - return Effect.fail( - new StackHostError({ +): Effect.Effect<StackHostOptions, StackHostError> => + Effect.gen(function* () { + const [stateRoot, cacheRoot, stackId, register, ...rest] = args; + if ( + stateRoot === undefined || + cacheRoot === undefined || + stackId === undefined || + rest.length > 0 + ) + return yield* new StackHostError({ operation: "startup", - message: "Expected stateRoot, cacheRoot and stackId", - }), - ); - const stateRoot = args[0]; - const cacheRoot = args[1]; - const stackId = args[2]; - if (stateRoot === undefined || cacheRoot === undefined || stackId === undefined) - return Effect.fail( - new StackHostError({ operation: "startup", message: "Missing host argument" }), - ); - return Effect.succeed({ - stateRoot, - cacheRoot, - stackId, - onReady: (endpoint) => report({ type: "ready", endpoint }), + message: "Expected stateRoot, cacheRoot, stackId and an optional stack to register", + }); + const registered = + register === undefined + ? undefined + : yield* Schema.decodeEffect(Schema.fromJsonString(SavedStack))(register).pipe( + Effect.mapError( + (cause) => new StackHostError({ operation: "startup", message: cause.message }), + ), + ); + return { + stateRoot, + cacheRoot, + stackId, + ...(registered === undefined ? {} : { register: registered }), + onReady: ({ endpoint, secret }) => report({ type: "ready", endpoint, secret }), + }; }); -}; -const program = (args: ReadonlyArray<string>) => +const program = (args: ReadonlyArray<string>, overrides: Pick<StackHostOptions, "release">) => Effect.gen(function* () { let reported = false; const report = (value: unknown) => @@ -71,25 +72,43 @@ const program = (args: ReadonlyArray<string>) => return writeLine(value); }); const host = yield* options(args, report); - yield* runStackHost(host).pipe( + yield* runStackHost({ ...host, ...overrides }).pipe( Effect.catchCause((cause) => { const failure = Option.getOrUndefined(Cause.findErrorOption(cause)); - const reason = causeCode(failure?.cause) === "EADDRINUSE" ? "bind-conflict" : undefined; return report({ type: "error", - message: String(cause), - ...(reason === undefined ? {} : { reason }), + message: failure?.message ?? Cause.pretty(cause), + ...(failure?.reason === undefined ? {} : { reason: failure.reason }), }).pipe(Effect.exit, Effect.andThen(Effect.failCause(cause))); }), ); }); -/** Runs the owner process with its state root, artifact cache and stack identity. */ -export const runHostProcess = (args: ReadonlyArray<string>): Promise<void> => - Effect.runPromise(program(args)); +const flushed = (stream: NodeJS.WriteStream) => + Effect.callback<void>((resume) => { + stream.write("", () => resume(Effect.void)); + }).pipe(Effect.timeoutOption("1 second")); -if (import.meta.main) { - void runHostProcess(process.argv.slice(2)).catch(() => { - process.exitCode = 1; - }); -} +/** + * Runs the owner process with its state root, artifact cache and stack identity, then exits the + * process once shutdown cleanup completes and the owner log is flushed, so no leftover handle + * delays the exit clients wait for. + */ +export const runHostProcess = ( + args: ReadonlyArray<string>, + overrides: Pick<StackHostOptions, "release"> = {}, +): Promise<never> => + Effect.runPromise( + program(args, overrides).pipe( + Effect.exit, + Effect.tap((exit) => + Exit.isFailure(exit) + ? Effect.sync(() => process.stderr.write(`${Cause.pretty(exit.cause)}\n`)) + : Effect.void, + ), + Effect.tap(() => Effect.all([flushed(process.stdout), flushed(process.stderr)])), + Effect.flatMap((exit) => Effect.sync(() => process.exit(Exit.isSuccess(exit) ? 0 : 1))), + ), + ); + +if (import.meta.main) void runHostProcess(process.argv.slice(2)); diff --git a/packages/stack/src/internal/postgres-artifact.ts b/packages/stack/src/internal/postgres-artifact.ts deleted file mode 100644 index 3a3121a83e..0000000000 --- a/packages/stack/src/internal/postgres-artifact.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Artifact preparation shared with the CLI's stack-independent PostgreSQL clients. */ -export { - ArtifactError, - postgresVersion, - prepareNativeArtifact, - resolveArtifact, -} from "../Artifacts.ts"; diff --git a/packages/stack/src/internal/release.integration.test.ts b/packages/stack/src/internal/release.integration.test.ts new file mode 100644 index 0000000000..4ab004acbb --- /dev/null +++ b/packages/stack/src/internal/release.integration.test.ts @@ -0,0 +1,22 @@ +import { NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; +import { sourceDigest } from "./release.ts"; + +it.live("digests only regular module files, ignoring dangling editor lock links", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-release-digest-" }); + yield* fs.makeDirectory(path.join(root, "nested")); + yield* fs.writeFileString(path.join(root, "nested", "Module.ts"), "export const a = 1;\n"); + const clean = yield* sourceDigest(root); + + yield* fs.symlink("user@host.1234:1700000000", path.join(root, "nested", ".#Module.ts")); + yield* fs.makeDirectory(path.join(root, "folder.ts")); + + expect(yield* sourceDigest(root)).toBe(clean); + yield* fs.writeFileString(path.join(root, "nested", "Module.ts"), "export const a = 2;\n"); + expect(yield* sourceDigest(root)).not.toBe(clean); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/packages/stack/src/internal/release.ts b/packages/stack/src/internal/release.ts new file mode 100644 index 0000000000..9539d4d243 --- /dev/null +++ b/packages/stack/src/internal/release.ts @@ -0,0 +1,50 @@ +import { Crypto, Effect, FileSystem, Path } from "effect"; +import effectPackage from "effect/package.json" with { type: "json" }; +import { fileURLToPath } from "node:url"; + +const hex = (bytes: Uint8Array) => + Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + +/** Digests the regular `.ts` modules under `sourceRoot`, skipping symlinks and other entries. */ +export const sourceDigest = Effect.fn("Release.sourceDigest")(function* (sourceRoot: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const regularFile = (file: string) => + fs.readLink(path.join(sourceRoot, file)).pipe( + Effect.as(false), + Effect.catch(() => + fs.stat(path.join(sourceRoot, file)).pipe(Effect.map((info) => info.type === "File")), + ), + ); + const modules = yield* Effect.filter( + (yield* fs.readDirectory(sourceRoot, { recursive: true })) + .filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts")) + .toSorted(), + regularFile, + ); + const encoder = new TextEncoder(); + const parts: Array<Uint8Array> = [encoder.encode(`effect@${effectPackage.version}\0`)]; + for (const file of modules) + parts.push( + encoder.encode(`${file}\0`), + yield* fs.readFile(path.join(sourceRoot, file)), + encoder.encode("\0"), + ); + const content = new Uint8Array(parts.reduce((size, part) => size + part.length, 0)); + let offset = 0; + for (const part of parts) { + content.set(part, offset); + offset += part.length; + } + return hex(yield* crypto.digest("SHA-256", content)).slice(0, 16); +}); + +/** + * Digests this package's module sources and its Effect version, which together determine the + * owner's behavior and wire protocol. Source runs and CLI builds of the same sources agree on it. + */ +export const stackSourceDigest = Effect.gen(function* () { + const path = yield* Path.Path; + return yield* sourceDigest(path.dirname(path.dirname(fileURLToPath(import.meta.url)))); +}); diff --git a/packages/stack/src/internal/service-catalog.ts b/packages/stack/src/internal/service-catalog.ts deleted file mode 100644 index 3392a3c9f4..0000000000 --- a/packages/stack/src/internal/service-catalog.ts +++ /dev/null @@ -1 +0,0 @@ -export { artifactServiceKinds, postgresVersion, resolveArtifact } from "../Artifacts.ts"; diff --git a/packages/stack/src/lifetime.integration.test.ts b/packages/stack/src/lifetime.integration.test.ts new file mode 100644 index 0000000000..da1a17a058 --- /dev/null +++ b/packages/stack/src/lifetime.integration.test.ts @@ -0,0 +1,224 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { + Context, + Data, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Path, + Schema, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { create, open } from "./effect.ts"; +import * as State from "./State.ts"; +import { assertOwnerExited, captureOwnerPid, watchLeaseRelease } from "../tests/owner.ts"; + +class LifetimeTestError extends Data.TaggedError("LifetimeTestError")<{ + readonly message: string; +}> {} + +const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); +const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; +const sessionFixture = fileURLToPath( + new URL("../tests/session-client-fixture.ts", import.meta.url), +); + +const stateFor = (root: string) => + Layer.build(State.layer({ root })).pipe( + Effect.map((context) => Context.get(context, State.Service)), + ); + +const collect = (child: ChildProcessSpawner.ChildProcessHandle) => + Effect.all( + [ + child.stdout.pipe(Stream.decodeText, Stream.mkString), + child.stderr.pipe(Stream.decodeText, Stream.mkString), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + +/** Lists the live descendants of `pid` from the process table. */ +const descendantsOf = Effect.fn("LifetimeTest.descendantsOf")(function* (pid: number) { + const [table] = yield* Effect.scoped( + ChildProcess.make("ps", ["-axo", "pid=,ppid="], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }).pipe(Effect.flatMap(collect)), + ); + const children = new Map<number, Array<number>>(); + for (const line of table.split("\n")) { + const [child, parent] = line.trim().split(/\s+/u).map(Number); + if (child === undefined || parent === undefined || Number.isNaN(child)) continue; + children.set(parent, [...(children.get(parent) ?? []), child]); + } + const found: Array<number> = []; + const pending = [pid]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const direct = children.get(next) ?? []; + found.push(...direct); + pending.push(...direct); + } + return found; +}); + +const alive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +/** Completes once `entry` disappears from `directory`; subscribe before triggering the removal. */ +const awaitRemoval = (directory: string, entry: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(directory, entry); + yield* fs.watch(directory).pipe( + Stream.filter((event) => event.path === entry || event.path === target), + Stream.mapEffect(() => fs.exists(target)), + Stream.takeUntil((exists) => !exists), + Stream.runDrain, + ); + }); + +it.live.skipIf(process.platform === "win32")( + "destroys a session stack and its native processes when the creating process is killed", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-kill-" }); + const stateRoot = `${root}/state`; + const creator = yield* ChildProcess.make( + process.execPath, + [sessionFixture, root, cacheRoot], + { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); + const stderr = yield* creator.stderr.pipe( + Stream.decodeText, + Stream.mkString, + Effect.forkScoped, + ); + const ready = yield* creator.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runHead, + Effect.timeout("2 minutes"), + Effect.flatMap( + Option.match({ + onNone: () => + Fiber.join(stderr).pipe( + Effect.flatMap((diagnostics) => + Effect.fail( + new LifetimeTestError({ + message: `Session creator exited before readiness: ${diagnostics}`, + }), + ), + ), + ), + onSome: Effect.succeed, + }), + ), + ); + const { stackId } = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ stackId: Schema.String })), + )(ready); + const state = yield* stateFor(stateRoot); + expect(yield* state.leased(stackId)).toBe(true); + expect((yield* state.claims).find(({ id }) => id === stackId)?.ports.length).toBeGreaterThan( + 0, + ); + const ownerPid = yield* captureOwnerPid({ stateRoot, cacheRoot }, stackId); + const owned = yield* descendantsOf(ownerPid); + expect(owned.length, "the owner runs the native mail service").toBeGreaterThan(0); + + const removed = yield* awaitRemoval(stateRoot, stackId).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const released = yield* watchLeaseRelease(stateRoot, stackId); + yield* creator.kill({ killSignal: "SIGKILL" }); + yield* Fiber.join(removed).pipe( + Effect.timeoutOrElse({ + duration: "1 minute", + orElse: () => + Effect.fail(new LifetimeTestError({ message: "Session stack was not destroyed" })), + }), + ); + + // Releasing the lease is the owner's last act, after its native processes have exited. + yield* released; + expect(owned.filter(alive), "native processes die with their owner").toEqual([]); + expect(yield* fs.exists(`${stateRoot}/${stackId}`)).toBe(false); + expect((yield* state.claims).some(({ id }) => id === stackId)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layer)), + { timeout: 180_000 }, +); + +it.live("destroys a session stack when its creating handle closes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-close-" }); + const locations = { stateRoot: `${root}/state`, cacheRoot }; + const { id, ownerPid } = yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* create({ + ...locations, + projectRoot: root, + runtime: "native", + lifetime: "session", + }); + expect(yield* stack.composition.start).toEqual([]); + return { id: stack.id, ownerPid: yield* captureOwnerPid(locations, stack.id) }; + }), + ); + yield* assertOwnerExited(ownerPid); + const state = yield* stateFor(locations.stateRoot); + expect(yield* state.read(id)).toBeUndefined(); + expect(yield* fs.exists(`${locations.stateRoot}/${id}`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layer)), +); + +it.live("lets only the creating handle start a session stack's owner", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-session-attach-" }); + const locations = { stateRoot: `${root}/state`, cacheRoot }; + const id = yield* Effect.scoped( + Effect.gen(function* () { + const stack = yield* create({ + ...locations, + projectRoot: root, + runtime: "native", + lifetime: "session", + }); + const other = yield* open({ ...locations, id: stack.id }); + expect(yield* other.composition.start).toEqual([]); + yield* other.stop; + + const refused = yield* Effect.flip(other.composition.start); + expect(refused.message).toContain("has no live owner"); + + expect(yield* stack.composition.start).toEqual([]); + expect(yield* other.composition.stop).toEqual([]); + return stack.id; + }), + ); + const state = yield* stateFor(locations.stateRoot); + expect(yield* state.read(id)).toBeUndefined(); + expect(yield* state.leased(id)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(layer)), +); diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 2421c4bda3..1bbaf34755 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -1,4 +1,14 @@ -import { Crypto, Effect, FileSystem, Option, Path, PlatformError, Predicate, Schema } from "effect"; +import { + Clock, + Crypto, + Effect, + FileSystem, + Option, + Path, + PlatformError, + Predicate, + Schema, +} from "effect"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ArtifactIntegrityError, PreparationError } from "./Errors.ts"; @@ -61,6 +71,11 @@ type ArtifactStoreError = PreparationError | ArtifactIntegrityError; const ARTIFACT_FORMAT = "supabase-stack-artifact-v3"; const METADATA_NAME = ".artifact.json"; const EXECUTABLE_MODE = 0o755; +/** + * Child of the staging directory that sources materialize into: GNU tar resets its extraction + * target's mtime from the archive, which must not make a staging directory look like a leftover. + */ +const STAGING_CONTENT_NAME = "content"; type ArtifactPathKind = "file" | "directory" | "symlink"; @@ -496,31 +511,22 @@ const ensureExecutableFile = ( ); }; -const metadataMatchesRequest = (request: ArtifactRequest, metadata: ArtifactMetadata): boolean => { - const samePaths = - metadata.requiredRuntimePaths.length === request.requiredRuntimePaths.length && - metadata.requiredRuntimePaths.every( - (entry, index) => entry === request.requiredRuntimePaths[index], - ); - const kindEntries = Object.keys(metadata.requiredRuntimeKinds); - const sameKinds = - kindEntries.length === request.requiredRuntimePaths.length && - request.requiredRuntimePaths.every( - (entry) => metadata.requiredRuntimeKinds[entry] !== undefined, - ); - return ( - metadata.key === request.key && - samePaths && - sameKinds && - metadata.executablePath === request.executablePath +const identityMismatch = (request: ArtifactRequest, metadata: ArtifactMetadata): boolean => + metadata.key !== request.key || metadata.executablePath !== request.executablePath; + +const unrecordedRequiredPaths = ( + request: ArtifactRequest, + metadata: ArtifactMetadata, +): ReadonlyArray<string> => + request.requiredRuntimePaths.filter( + (relative) => metadata.requiredRuntimeKinds[relative] === undefined, ); -}; -const verifyMetadata = ( +const sha256Of = ( request: ArtifactRequest, metadata: ArtifactMetadata, -): Effect.Effect<string, ArtifactIntegrityError> => { - const sha256 = validateSha256(metadata.sha256).pipe( +): Effect.Effect<string, ArtifactIntegrityError> => + validateSha256(metadata.sha256).pipe( Effect.mapError((cause) => metadataError("Cached artifact metadata contains an invalid SHA-256", { key: request.key, @@ -528,12 +534,6 @@ const verifyMetadata = ( }), ), ); - if (!metadataMatchesRequest(request, metadata)) - return Effect.fail( - metadataError("Cached artifact metadata does not match the request", { key: request.key }), - ); - return sha256; -}; const writeBytesSync = ( fs: FileSystem.FileSystem, @@ -585,6 +585,84 @@ const cleanup = (fs: FileSystem.FileSystem, path: string): Effect.Effect<void, P ), ); +const updateMetadataAtomic = ( + fs: FileSystem.FileSystem, + path: Path.Path, + crypto: Crypto.Crypto, + metadataPath: string, + metadata: ArtifactMetadata, +): Effect.Effect<void, PreparationError> => + Effect.gen(function* () { + const token = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => + artifactError(`Unable to allocate artifact metadata temporary name: ${cause.message}`, { + path: metadataPath, + cause, + }), + ), + ); + const temporary = path.join(path.dirname(metadataPath), `${METADATA_NAME}.${token}.tmp`); + yield* Effect.gen(function* () { + yield* writeMetadataSync(fs, temporary, metadata); + yield* mapFs( + metadataPath, + "update cached artifact metadata", + fs.rename(temporary, metadataPath), + ); + }).pipe(Effect.onExit(() => cleanup(fs, temporary))); + }); + +const orphanMaxAgeMillis = 24 * 60 * 60 * 1000; + +const leftoverToken = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; + +/** Matches the temporary and quarantine names the store creates beside `targetName`. */ +const isLeftoverOf = (targetName: string, name: string): boolean => { + const prefix = `.${targetName}.`; + const suffix = [".tmp", ".invalid"].find((candidate) => name.endsWith(candidate)); + return ( + suffix !== undefined && + name.startsWith(prefix) && + leftoverToken.test(name.slice(prefix.length, name.length - suffix.length)) + ); +}; + +/** + * Best-effort removal of one target's temp/quarantine siblings that a hard kill left behind. + * Only entries older than `orphanMaxAgeMillis` are removed, so a concurrent in-progress download + * by another process is never touched. + */ +const reapLeftoversOf = ( + fs: FileSystem.FileSystem, + path: Path.Path, + target: string, +): Effect.Effect<void> => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const parent = path.dirname(target); + const targetName = path.basename(target); + const names = yield* fs.readDirectory(parent); + yield* Effect.forEach( + names.filter((name) => isLeftoverOf(targetName, name)), + (name) => { + const candidate = path.join(parent, name); + return fs.stat(candidate).pipe( + Effect.flatMap((info) => + Option.match(info.mtime, { + onNone: () => Effect.void, + onSome: (mtime) => + now - mtime.getTime() > orphanMaxAgeMillis + ? fs.remove(candidate, { recursive: true, force: true }) + : Effect.void, + }), + ), + Effect.ignore, + ); + }, + { discard: true }, + ); + }).pipe(Effect.ignore); + const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( fs: FileSystem.FileSystem, path: Path.Path, @@ -599,10 +677,11 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const target = path.resolve(cacheRoot, request.key); const targetParent = path.dirname(target); yield* ensureDirectory(fs, path, targetParent, cacheRoot); + yield* reapLeftoversOf(fs, path, target); const metadataPath = path.join(target, METADATA_NAME); const checkCached: Effect.Effect< Option.Option<PreparedArtifact>, - ArtifactIntegrityError + ArtifactStoreError > = Effect.gen(function* () { const realRoot = yield* ensureSafeRoot(fs, path, target, cacheRoot).pipe( Effect.map(Option.some), @@ -614,20 +693,59 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const cachedMetadata = yield* readMetadata(fs, metadataPath); if (Option.isNone(cachedMetadata)) return Option.none(); const metadata = cachedMetadata.value; - // Same version/target key with an expanded required-path list is a miss, not corruption. - if (!metadataMatchesRequest(request, metadata)) return Option.none(); - const sha256 = yield* verifyMetadata(request, metadata); + // A key names immutable content and sources unpack whole archives, so only a different key + // or executable is a miss; paths beyond the recorded ones are checked in the published tree. + if (identityMismatch(request, metadata)) return Option.none(); + const sha256 = yield* sha256Of(request, metadata); + const newPaths = unrecordedRequiredPaths(request, metadata); + const newPathSet = new Set(newPaths); + const recordedPaths = request.requiredRuntimePaths.filter( + (relative) => !newPathSet.has(relative), + ); // Published content is not rehashed on cache hits: metadata and cheap structural checks // protect the cache boundary; content tampering may execute or fail later when the // workload starts. - const safePaths = yield* ensureSafePaths( + const recordedSafePaths = yield* ensureSafePaths( fs, path, target, realRoot.value, metadata, - request.requiredRuntimePaths, + recordedPaths, ); + // A path the recorded metadata has not seen yet gets the same containment and safety + // validation a fresh publish applies, before its kind is trusted and recorded. + const newSafePaths = + newPaths.length > 0 + ? yield* validateFreshRuntimePaths(fs, path, target, realRoot.value, newPaths).pipe( + Effect.mapError((cause) => + metadataError( + `Cached artifact ${request.key} cannot serve newly required runtime paths (${cause.message}); remove ${target} to download it again`, + { key: request.key, path: target, cause }, + ), + ), + ) + : {}; + const safePaths = { ...recordedSafePaths, ...newSafePaths }; + if (newPaths.length > 0) { + const newKinds = Object.fromEntries( + Object.entries(newSafePaths).map(([relative, inspected]) => [relative, inspected.kind]), + ); + const appendedPaths = newPaths.filter( + (relative) => !metadata.requiredRuntimePaths.includes(relative), + ); + // The paths above are already validated; a failed write only loses the recording, so + // it must not fail preparation. A later request re-validates and retries the write. + yield* updateMetadataAtomic(fs, path, crypto, metadataPath, { + ...metadata, + requiredRuntimePaths: [...metadata.requiredRuntimePaths, ...appendedPaths], + requiredRuntimeKinds: { ...metadata.requiredRuntimeKinds, ...newKinds }, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Unable to record newly validated artifact runtime paths", cause), + ), + ); + } if (request.executablePath !== undefined) { const executable = safePaths[request.executablePath]; if (executable === undefined) @@ -716,11 +834,12 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( }), ), ); - const temporary = path.join(targetParent, `.${path.basename(target)}.${token}.tmp`); + const staging = path.join(targetParent, `.${path.basename(target)}.${token}.tmp`); + const content = path.join(staging, STAGING_CONTENT_NAME); const published = yield* Effect.gen(function* () { - yield* ensureDirectory(fs, path, temporary, cacheRoot); - const temporaryRoot = yield* ensureSafeRoot(fs, path, temporary, cacheRoot); - yield* source.materialize(request, temporary, expectedSha256, onProgress).pipe( + yield* ensureDirectory(fs, path, content, cacheRoot); + const contentRoot = yield* ensureSafeRoot(fs, path, content, cacheRoot); + yield* source.materialize(request, content, expectedSha256, onProgress).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.provideService(Crypto.Crypto, crypto), @@ -731,8 +850,8 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const runtimePaths = yield* validateFreshRuntimePaths( fs, path, - temporary, - temporaryRoot, + content, + contentRoot, request.requiredRuntimePaths, ); const runtimeKinds = Object.fromEntries( @@ -740,7 +859,7 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( ); yield* writeMetadataSync( fs, - path.join(temporary, METADATA_NAME), + path.join(content, METADATA_NAME), metadataFor(request, expectedSha256, runtimeKinds), ); if (request.executablePath !== undefined) { @@ -758,7 +877,7 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( } const beforePublish = yield* inspectCache(); if (Option.isSome(beforePublish)) return beforePublish.value; - const rename = mapFs(temporary, "publish artifact", fs.rename(temporary, target)).pipe( + const rename = mapFs(content, "publish artifact", fs.rename(content, target)).pipe( Effect.as(undefined), ); const recoverPublish = (): Effect.Effect<PreparedArtifact | undefined, ArtifactStoreError> => @@ -770,7 +889,7 @@ const makeArtifactOperation = Effect.fn("ArtifactStore.operation")(function* ( const recovered: Effect.Effect<PreparedArtifact | undefined, ArtifactStoreError> = rename.pipe(Effect.catch(recoverPublish)); return yield* recovered; - }).pipe(Effect.onExit(() => cleanup(fs, temporary))); + }).pipe(Effect.onExit(() => cleanup(fs, staging))); if (published !== undefined) return published; return { key: request.key, diff --git a/packages/stack/src/preparation/Integrity.ts b/packages/stack/src/preparation/Integrity.ts index 381aef46ca..a376dc6618 100644 --- a/packages/stack/src/preparation/Integrity.ts +++ b/packages/stack/src/preparation/Integrity.ts @@ -35,7 +35,7 @@ export const verifySha256 = Effect.fn("Integrity.verifySha256")(function* ( ); const actual = digestHex(digest); if (actual !== canonical) - return yield* integrityError("Artifact SHA-256 does not match the catalog", { + return yield* integrityError(`expected ${canonical}, got ${actual}`, { expected: canonical, actual, }); diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index 90b57330a5..97ed1487e5 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -4,8 +4,10 @@ import { HttpClient, HttpClientError } from "effect/unstable/http"; import { createZstdDecompress } from "node:zlib"; import { createHash } from "node:crypto"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { errorChainMessage } from "../internal/error-message.ts"; import type { ArtifactRequest, ArtifactSource } from "./ArtifactStore.ts"; import { PreparationError } from "./Errors.ts"; +import { verifySha256 } from "./Integrity.ts"; /** One host serving the archive and manifest of a slim-services release asset. */ interface SlimServicesMirror { @@ -13,33 +15,20 @@ interface SlimServicesMirror { readonly manifestUrl: string; } -/** - * Where the expected archive digest comes from: a release `SHA256SUMS` file, or the archive layer - * of the native OCI artifact in a registry that allows anonymous pulls. - */ -type SlimServicesChecksumSource = - | { readonly kind: "sha256sums"; readonly url: string } - | { - readonly kind: "oci"; - readonly registry: string; - readonly repository: string; - readonly tag: string; - }; - export interface SlimServicesArtifact { readonly provider: "supabase/slim-services"; readonly service: string; + /** Release version, `<upstream>-r<revision>`. */ readonly version: string; readonly releaseTag: string; readonly target: "darwin-arm64" | "linux-amd64" | "linux-arm64"; readonly archive: "tar.zst"; readonly assetName: string; - /** Checksum authorities, tried in order. Download mirrors never supply their own checksum. */ - readonly checksums: readonly [ - SlimServicesChecksumSource, - ...ReadonlyArray<SlimServicesChecksumSource>, - ]; - /** Hosts carrying the same release assets, tried in order until one serves them. */ + /** Pinned archive digest. Mirrors only serve bytes; they never supply a digest. */ + readonly sha256: string; + /** Pinned manifest digest, verified before the manifest is parsed. */ + readonly manifestSha256: string; + /** Hosts carrying the same release assets, tried in order until one serves the pinned bytes. */ readonly mirrors: readonly [SlimServicesMirror, ...ReadonlyArray<SlimServicesMirror>]; readonly requiredRuntimePaths: ReadonlyArray<string>; readonly executablePath: string; @@ -96,9 +85,9 @@ const systemTarBoundary: TarBoundary = { ); }), }; -const responseFor = (url: string, headers?: Readonly<Record<string, string>>) => +const responseFor = (url: string) => Effect.flatMap(HttpClient.HttpClient, (client) => - HttpClient.followRedirects(client).get(url, { headers }), + HttpClient.followRedirects(client).get(url), ).pipe( Effect.flatMap((response) => Effect.gen(function* () { @@ -144,9 +133,8 @@ const withTransferRetry = const fetchBytes = Effect.fn("SlimServicesSource.fetchBytes")(function* ( url: string, backoff: Schedule.Schedule<unknown>, - headers?: Readonly<Record<string, string>>, ) { - return yield* responseFor(url, headers).pipe( + return yield* responseFor(url).pipe( Effect.flatMap((response) => response.arrayBuffer), Effect.map((bytes) => new Uint8Array(bytes)), withTransferRetry(url, backoff), @@ -224,124 +212,54 @@ const downloadToFile = Effect.fn("SlimServicesSource.downloadToFile")(function* }); }); -const fallbackDetail = (error: PreparationError): string => { - const nested = error.cause instanceof PreparationError ? error.cause.message : undefined; - return nested === undefined || nested.length === 0 - ? error.message - : `${error.message} (${nested})`; -}; - /** * Runs `attempt` against each candidate until one succeeds. When every candidate fails, the - * returned error is still the primary's. A fallback failure, including a checksum mismatch, is - * logged as a warning, and a fallback that succeeds names the host it used. + * returned error names each attempted source with its failure detail (the sole source's own + * error when there was nothing to aggregate). A fallback failure, including a checksum mismatch, + * is logged as a warning, and a fallback that succeeds names the host it used. */ const firstSuccess = <T, A, R>( + label: string, + artifact: Pick<SlimServicesArtifact, "service" | "version">, candidates: readonly [T, ...ReadonlyArray<T>], describe: (candidate: T) => string, attempt: (candidate: T) => Effect.Effect<A, PreparationError, R>, ): Effect.Effect<A, PreparationError, R> => { const [primary, ...fallbacks] = candidates; const fallback = ( - primaryError: PreparationError, + failures: ReadonlyArray<readonly [T, PreparationError]>, remaining: ReadonlyArray<T>, ): Effect.Effect<A, PreparationError, R> => { const [candidate, ...rest] = remaining; - if (candidate === undefined) return Effect.fail(primaryError); + if (candidate === undefined) { + const [first] = failures; + if (failures.length === 1 && first !== undefined) return Effect.fail(first[1]); + const detail = failures + .map(([failed, error]) => `${describe(failed)} (${errorChainMessage(error)})`) + .join("; "); + // The message already carries every failure, so a cause would repeat it in chain renderers. + return Effect.fail( + new PreparationError({ + message: `${label}: ${detail}`, + service: artifact.service, + version: artifact.version, + }), + ); + } return attempt(candidate).pipe( Effect.tap(() => Effect.logInfo(`Slim-services used ${describe(candidate)}`)), Effect.tapError((cause) => Effect.logWarning( - `Slim-services fallback ${describe(candidate)} failed: ${fallbackDetail(cause)}`, + `Slim-services fallback ${describe(candidate)} failed: ${errorChainMessage(cause)}`, ), ), - Effect.catch(() => fallback(primaryError, rest)), + Effect.catch((cause) => fallback([...failures, [candidate, cause] as const], rest)), ); }; - return attempt(primary).pipe(Effect.catch((primaryError) => fallback(primaryError, fallbacks))); -}; - -const checksumFor = (contents: string, archiveName: string): string | undefined => - contents - .split(/\r?\n/u) - .map((line) => line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/iu)) - .find((match) => match?.[2] === archiveName || match?.[2]?.endsWith(`/${archiveName}`))?.[1]; - -const ARCHIVE_MEDIA_TYPE = "application/vnd.supabase.slim.archive.v1.tar+zstd"; - -const RegistryToken = Schema.Struct({ token: Schema.String }); - -const NativeOciManifest = Schema.Struct({ - layers: Schema.Array(Schema.Struct({ mediaType: Schema.String, digest: Schema.String })), -}); - -const decodeJson = <S extends Schema.Top>(schema: S, bytes: Uint8Array, message: string) => - Schema.decodeEffect(Schema.fromJsonString(schema))(new TextDecoder().decode(bytes)).pipe( - Effect.mapError((cause) => new PreparationError({ message, cause })), + return attempt(primary).pipe( + Effect.catch((primaryError) => fallback([[primary, primaryError]], fallbacks)), ); - -/** Reads the archive layer digest of a native OCI artifact through an anonymous pull token. */ -const ociArchiveDigest = Effect.fn("SlimServicesSource.ociArchiveDigest")(function* ( - source: Extract<SlimServicesChecksumSource, { readonly kind: "oci" }>, - backoff: Schedule.Schedule<unknown>, -) { - const tokenUrl = `https://${source.registry}/token?scope=repository:${source.repository}:pull&service=${source.registry}`; - const { token } = yield* decodeJson( - RegistryToken, - yield* fetchBytes(tokenUrl, backoff), - "Slim-services registry token is invalid", - ); - const manifest = yield* decodeJson( - NativeOciManifest, - yield* fetchBytes( - `https://${source.registry}/v2/${source.repository}/manifests/${source.tag}`, - backoff, - { - accept: "application/vnd.oci.image.manifest.v1+json", - authorization: `Bearer ${token}`, - }, - ), - "Slim-services native OCI manifest is invalid", - ); - const digest = manifest.layers - .find((layer) => layer.mediaType === ARCHIVE_MEDIA_TYPE) - ?.digest.match(/^sha256:([a-f0-9]{64})$/u)?.[1]; - if (digest === undefined) - return yield* new PreparationError({ - message: "Slim-services native OCI manifest has no archive layer", - }); - return digest; -}); - -const describeChecksumSource = (source: SlimServicesChecksumSource): string => - source.kind === "sha256sums" - ? source.url - : `${source.registry}/${source.repository}:${source.tag}`; - -export const slimServicesChecksum = Effect.fn("SlimServicesSource.checksum")(function* ( - artifact: SlimServicesArtifact, - backoff: Schedule.Schedule<unknown> = transferBackoff, -) { - return yield* firstSuccess(artifact.checksums, describeChecksumSource, (source) => - source.kind === "oci" - ? ociArchiveDigest(source, backoff) - : fetchBytes(source.url, backoff).pipe( - Effect.map((bytes) => new TextDecoder().decode(bytes)), - Effect.flatMap((contents) => { - const checksum = checksumFor(contents, `${artifact.assetName}.tar.zst`); - return checksum === undefined || !/^[a-f0-9]{64}$/iu.test(checksum) - ? Effect.fail( - new PreparationError({ - message: "Slim-services checksum is missing", - service: artifact.service, - version: artifact.version, - }), - ) - : Effect.succeed(checksum.toLowerCase()); - }), - ), - ); -}); +}; const manifestSchema = Schema.Struct({ service: Schema.String, @@ -357,6 +275,18 @@ const verifiedManifest = Effect.fn("SlimServicesSource.verifiedManifest")(functi backoff: Schedule.Schedule<unknown>, ) { const manifestBytes = yield* fetchBytes(mirror.manifestUrl, backoff); + yield* verifySha256(manifestBytes, artifact.manifestSha256).pipe( + Effect.mapError( + (cause) => + new PreparationError({ + message: "Slim-services manifest does not match its pin", + service: artifact.service, + version: artifact.version, + target: artifact.target, + cause, + }), + ), + ); const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(manifestSchema))( new TextDecoder().decode(manifestBytes), ).pipe( @@ -479,10 +409,7 @@ export const makeSlimServicesSource = ( return artifact; }); return { - checksum: (request) => - resolveArtifact(request).pipe( - Effect.flatMap((artifact) => slimServicesChecksum(artifact, backoff)), - ), + checksum: (request) => resolveArtifact(request).pipe(Effect.map(({ sha256 }) => sha256)), materialize: Effect.fn("SlimServicesSource.materialize")( function* (request, destination, expectedSha256, onProgress) { const fs = yield* FileSystem.FileSystem; @@ -496,6 +423,8 @@ export const makeSlimServicesSource = ( return yield* Effect.gen(function* () { const artifact = yield* resolveArtifact(request); yield* firstSuccess( + "Unable to download the slim-services archive", + artifact, artifact.mirrors, (mirror) => mirror.downloadUrl, (mirror) => diff --git a/packages/stack/src/preparation/artifacts.integration.test.ts b/packages/stack/src/preparation/artifacts.integration.test.ts index 92b1c19194..8f637d0a42 100644 --- a/packages/stack/src/preparation/artifacts.integration.test.ts +++ b/packages/stack/src/preparation/artifacts.integration.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Crypto, + DateTime, Deferred, Effect, Exit, @@ -11,6 +12,7 @@ import { Layer, Option, PlatformError, + Schema, } from "effect"; import { ArtifactIntegrityError, PreparationError } from "./Errors.ts"; import { makeArtifactStore, type ArtifactRequest, type ArtifactSource } from "./ArtifactStore.ts"; @@ -146,38 +148,57 @@ describe("verified native artifact preparation", () => { ), ); - it.live("re-downloads when required runtime paths expand on the same key", () => - withPlatform( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ - prefix: "supabase-stack-artifact-paths-expand-", - }); - let checksumCalls = 0; - let materializeCalls = 0; - const source: ArtifactSource = { - checksum: () => - Effect.sync(() => { - checksumCalls += 1; - return archiveSha256; - }), - materialize: (entry, destination) => - Effect.sync(() => { - materializeCalls += 1; - return entry; - }).pipe(Effect.andThen(sourceWriting().materialize(entry, destination, archiveSha256))), - }; - const store = yield* makeArtifactStore({ cacheRoot: root, source }); - const narrow: ArtifactRequest = { ...request, requiredRuntimePaths: ["bin/postgres"] }; - const first = yield* store.prepare(narrow); - const second = yield* store.prepare(request); - expect(first.outcome).toBe("downloaded"); - expect(second.outcome).toBe("downloaded"); - expect(second.requiredRuntimePaths).toEqual([...request.requiredRuntimePaths]); - expect(checksumCalls).toBe(2); - expect(materializeCalls).toBe(2); - }), - ), + it.live( + "keeps the published tree and reuses it when required runtime paths expand on the same key", + () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-paths-expand-", + }); + let checksumCalls = 0; + let materializeCalls = 0; + const source: ArtifactSource = { + checksum: () => + Effect.sync(() => { + checksumCalls += 1; + return archiveSha256; + }), + materialize: (entry, destination) => + Effect.sync(() => { + materializeCalls += 1; + return entry; + }).pipe( + Effect.andThen(sourceWriting().materialize(entry, destination, archiveSha256)), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const narrow: ArtifactRequest = { ...request, requiredRuntimePaths: ["bin/postgres"] }; + const first = yield* store.prepare(narrow); + const publishedIno = (yield* fs.stat(first.path)).ino; + const second = yield* store.prepare(request); + const stillPublishedIno = (yield* fs.stat(second.path)).ino; + expect(first.outcome).toBe("downloaded"); + expect(second.outcome).toBe("cached"); + expect(second.path).toBe(first.path); + expect(stillPublishedIno).toEqual(publishedIno); + expect(second.requiredRuntimePaths).toEqual([...request.requiredRuntimePaths]); + expect(checksumCalls).toBe(1); + expect(materializeCalls).toBe(1); + expect(yield* fs.readFileString(`${second.path}/bin/postgres`)).toBe("native postgres"); + const metadata = yield* Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + requiredRuntimePaths: Schema.Array(Schema.String), + requiredRuntimeKinds: Schema.Record(Schema.String, Schema.String), + }), + ), + )(yield* fs.readFileString(`${second.path}/.artifact.json`)); + expect(metadata.requiredRuntimePaths).toContain("etc/postgres.conf"); + expect(metadata.requiredRuntimeKinds["etc/postgres.conf"]).toBe("file"); + }), + ), ); it.live("replaces a cached tree with unknown artifact metadata", () => @@ -362,6 +383,33 @@ describe("verified native artifact preparation", () => { ), ); + it.live("rejects a cache hit whose newly required path is missing from the published tree", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-new-path-missing-", + }); + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + const narrow: ArtifactRequest = { ...request, requiredRuntimePaths: ["bin/postgres"] }; + const published = yield* store.prepare(narrow); + const publishedIno = (yield* fs.stat(published.path)).ino; + + const wider: ArtifactRequest = { + ...request, + requiredRuntimePaths: ["bin/postgres", "share/missing"], + }; + const exit = yield* store.prepare(wider).pipe(Effect.exit); + + const error = errorOf(exit); + expect(error).toBeInstanceOf(ArtifactIntegrityError); + expect(error?.message).toContain(`remove ${published.path} to download it again`); + expect(yield* fs.exists(published.path)).toBe(true); + expect((yield* fs.stat(published.path)).ino).toEqual(publishedIno); + }), + ), + ); + it.live("rejects a required path whose basic kind changes on a cache hit", () => withPlatform( Effect.gen(function* () { @@ -608,6 +656,57 @@ describe("verified native artifact preparation", () => { ), ); + it.live("rejects an escaping symlink nested in a newly required directory on a cache hit", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-new-nested-link-escape-", + }); + const outside = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-cache-new-nested-link-outside-", + }); + const outsideConfig = `${outside}/config`; + yield* fs.writeFileString(outsideConfig, "outside"); + const directoryRequest: ArtifactRequest = { + ...request, + key: "database/postgres-cache-new-nested-link", + requiredRuntimePaths: ["bin/postgres", "share/runtime"], + }; + const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/share/runtime`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.symlink(outsideConfig, `${destination}/share/runtime/config`); + return; + }).pipe( + Effect.mapError( + (cause) => + new PreparationError({ + message: `materialization failed: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const narrow: ArtifactRequest = { + ...directoryRequest, + requiredRuntimePaths: ["bin/postgres"], + }; + const published = yield* store.prepare(narrow); + + const exit = yield* store.prepare(directoryRequest).pipe(Effect.exit); + + expect(errorOf(exit)).toBeInstanceOf(ArtifactIntegrityError); + expect(yield* fs.exists(published.path)).toBe(true); + }), + ), + ); + it.live("cancels caller-owned preparation when the caller is interrupted", () => withPlatform( Effect.gen(function* () { @@ -748,3 +847,111 @@ describe("verified native artifact preparation", () => { ), ); }); + +describe("orphaned temp/quarantine sweep", () => { + it.live("removes a prepared key's old leftovers and keeps fresh and unrelated ones", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-sweep-", + }); + const now = yield* DateTime.now; + const old = DateTime.toDate(DateTime.subtract(now, { hours: 25 })); + const recent = DateTime.toDate(DateTime.subtract(now, { hours: 1 })); + const parent = `${root}/database`; + const oldTemp = `${parent}/.postgres.00000000-0000-4000-8000-000000000001.tmp`; + const oldQuarantine = `${parent}/.postgres.00000000-0000-4000-8000-000000000002.invalid`; + const freshTemp = `${parent}/.postgres.00000000-0000-4000-8000-000000000003.tmp`; + const freshQuarantine = `${parent}/.postgres.00000000-0000-4000-8000-000000000004.invalid`; + const unrelated = `${parent}/nested/.x.tmp`; + const leftovers = [oldTemp, oldQuarantine, freshTemp, freshQuarantine, unrelated]; + for (const leftover of leftovers) { + yield* fs.makeDirectory(leftover, { recursive: true }); + yield* fs.writeFileString(`${leftover}/marker`, "leftover"); + } + for (const leftover of [oldTemp, oldQuarantine, unrelated]) + yield* fs.utimes(leftover, old, old); + for (const leftover of [freshTemp, freshQuarantine]) + yield* fs.utimes(leftover, recent, recent); + + const store = yield* makeArtifactStore({ cacheRoot: root, source: sourceWriting() }); + expect(yield* fs.exists(oldTemp), "construction leaves the cache alone").toBe(true); + expect((yield* store.prepare(request)).outcome).toBe("downloaded"); + + expect(yield* fs.exists(oldTemp)).toBe(false); + expect(yield* fs.exists(oldQuarantine)).toBe(false); + expect(yield* fs.exists(freshTemp)).toBe(true); + expect(yield* fs.exists(freshQuarantine)).toBe(true); + expect(yield* fs.exists(unrelated)).toBe(true); + }), + ), + ); + + it.live("never reaps an in-progress extraction whose source backdated its own destination", () => + withPlatform( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-stack-artifact-reaper-race-", + }); + const backdated = yield* Deferred.make<void>(); + const secondReachedMaterialize = yield* Deferred.make<void>(); + let materializeCalls = 0; + const source: ArtifactSource = { + checksum: () => Effect.succeed(archiveSha256), + materialize: (_entry, destination, expectedSha256) => + Effect.gen(function* () { + materializeCalls += 1; + if (materializeCalls > 1) { + // The second preparation arrives here after running the reaper; it fails instead + // of racing the first to publish, which keeps the interleaving deterministic. + yield* Deferred.succeed(secondReachedMaterialize, undefined); + return yield* new PreparationError({ + message: "the second preparation stops before publishing", + }); + } + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + yield* fs.makeDirectory(`${destination}/etc`, { recursive: true }); + yield* fs.writeFileString(`${destination}/bin/postgres`, "native postgres"); + yield* fs.writeFileString(`${destination}/etc/postgres.conf`, "config"); + yield* verifySha256(archive, expectedSha256).pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); + // GNU tar sets an extraction destination's own mtime from the archive's `./` + // entry; the slim-services archives carry epoch mtimes there. + yield* fs.utimes(destination, 0, 0); + yield* Deferred.succeed(backdated, undefined); + yield* Deferred.await(secondReachedMaterialize); + }).pipe( + Effect.mapError((cause) => + cause instanceof PreparationError || cause instanceof ArtifactIntegrityError + ? cause + : new PreparationError({ + message: `materialization failed: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot: root, source }); + + const first = yield* Effect.forkChild(store.prepare(request)); + yield* Deferred.await(backdated); + + // Runs the same leftover reaper the first operation's staging directory is exposed to; + // it refuses its own materialization once it gets there, so it can never win a race to + // publish and mask a reap that already happened. + const second = yield* store.prepare(request).pipe(Effect.exit); + expect(Exit.isFailure(second)).toBe(true); + + const published = yield* Fiber.join(first); + expect(published.outcome).toBe("downloaded"); + expect(yield* fs.exists(`${published.path}/bin/postgres`)).toBe(true); + expect(yield* fs.readFileString(`${published.path}/bin/postgres`)).toBe("native postgres"); + expect(yield* fs.readFileString(`${published.path}/etc/postgres.conf`)).toBe("config"); + }), + ), + ); +}); diff --git a/packages/stack/src/preparation/slim-services.integration.test.ts b/packages/stack/src/preparation/slim-services.integration.test.ts index 467c31b253..c321aae3c1 100644 --- a/packages/stack/src/preparation/slim-services.integration.test.ts +++ b/packages/stack/src/preparation/slim-services.integration.test.ts @@ -17,12 +17,11 @@ import { import { createServer, type Server } from "node:http"; import { zstdCompress } from "node:zlib"; import { FetchHttpClient } from "effect/unstable/http"; -import { prepareNativeArtifact, resolveArtifact } from "../Artifacts.ts"; -import { makeArtifactStore, type ArtifactRequest } from "./ArtifactStore.ts"; +import { catalogPins, prepareNativeArtifact, resolveArtifact } from "../Artifacts.ts"; +import { makeArtifactStore, type ArtifactRequest, type ArtifactSource } from "./ArtifactStore.ts"; import { digestHex } from "./Integrity.ts"; import { makeSlimServicesSource, - slimServicesChecksum, type SlimServicesArtifact, type ZstdDecompressor, } from "./SlimServicesSource.ts"; @@ -34,15 +33,31 @@ const waitForAbort = (signal?: AbortSignal | null): Promise<never> => const waitForRelease = (released: Deferred.Deferred<void>): Promise<void> => Effect.runPromise(Deferred.await(released)); +const manifestBytes = (version: string, service = "demo"): Uint8Array => + new TextEncoder().encode(JSON.stringify({ service, version, target: "linux-amd64" })); + +const demoManifest = manifestBytes("v1.0.0-r0"); + +/** Passes every manifest check except its pin. */ +const commandManifest = new TextEncoder().encode( + JSON.stringify({ + service: "demo", + version: "v1.0.0-r0", + target: "linux-amd64", + cmd: ["bin/evil"], + }), +); + const artifact: SlimServicesArtifact = { provider: "supabase/slim-services", service: "demo", - version: "v1.0.0", - releaseTag: "demo-v1.0.0", + version: "v1.0.0-r0", + releaseTag: "demo-v1.0.0-r0", target: "linux-amd64", archive: "tar.zst", - assetName: "demo-v1.0.0-linux-amd64", - checksums: [{ kind: "sha256sums", url: "https://example.test/SHA256SUMS" }], + assetName: "demo-v1.0.0-r0-linux-amd64", + sha256: "0".repeat(64), + manifestSha256: "0".repeat(64), mirrors: [ { downloadUrl: "https://example.test/demo.tar.zst", @@ -52,6 +67,34 @@ const artifact: SlimServicesArtifact = { requiredRuntimePaths: ["bin/demo"], executablePath: "bin/demo", }; + +const releaseMirror = { + downloadUrl: "https://release.test/demo-v1.0.0-r0-linux-amd64.tar.zst", + manifestUrl: "https://release.test/demo-v1.0.0-r0-linux-amd64.manifest.json", +}; +const bucketMirror = { + downloadUrl: "https://bucket.test/demo-v1.0.0-r0-linux-amd64.tar.zst", + manifestUrl: "https://bucket.test/demo-v1.0.0-r0-linux-amd64.manifest.json", +}; + +const sha256Of = Effect.fn(function* (bytes: Uint8Array) { + const crypto = yield* Crypto.Crypto; + return digestHex(yield* crypto.digest("SHA-256", bytes)); +}); + +/** Pins `base` to the digests of the given archive and manifest bytes. */ +const pinned = Effect.fn(function* ( + archive: Uint8Array, + manifest: Uint8Array = demoManifest, + base: SlimServicesArtifact = artifact, +) { + return { + ...base, + sha256: yield* sha256Of(archive), + manifestSha256: yield* sha256Of(manifest), + } satisfies SlimServicesArtifact; +}); + const request: ArtifactRequest = { key: "demo/v1", requiredRuntimePaths: ["bin/demo"], @@ -138,59 +181,62 @@ const withFetch = <A, E, R>(fetcher: FetchLike, effect: Effect.Effect<A, E, R>) Effect.provideService(FetchHttpClient.Fetch, fetcher), ); -const registryChecksum = { - kind: "oci", - registry: "registry.test", - repository: "supabase/cli/demo", - tag: "v1.0.0-native-linux-amd64", -} as const; +/** Serves each host's manifest and archive, answers 403 elsewhere, and records every URL. */ +const serving = + ( + hosts: Readonly< + Record<string, { readonly archive: Uint8Array; readonly manifest: Uint8Array }> + >, + requested: Array<string> = [], + ): FetchLike => + (input) => { + const url = requestUrl(input); + requested.push(url); + const host = hosts[new URL(url).host]; + if (host === undefined) return Promise.resolve(new Response("", { status: 403 })); + return Promise.resolve( + new Response(url.endsWith(".manifest.json") ? host.manifest : host.archive), + ); + }; -/** Anonymous registry token and native manifest; the manifest requires the issued token. */ -const registryResponse = ( - input: Parameters<typeof fetch>[0], - init: Parameters<typeof fetch>[1], - archiveSha256: string, - registry = "registry.test", - repository = "supabase/cli/demo", - tag = "v1.0.0-native-linux-amd64", -): Response | undefined => { - const url = requestUrl(input); - if (url === `https://${registry}/token?scope=repository:${repository}:pull&service=${registry}`) - return new Response(JSON.stringify({ token: "anonymous" })); - if (url !== `https://${registry}/v2/${repository}/manifests/${tag}`) return undefined; - if (new Headers(init?.headers).get("authorization") !== "Bearer anonymous") - return new Response("", { status: 401 }); - return new Response( - JSON.stringify({ - layers: [ - { - mediaType: "application/vnd.supabase.slim.archive.v1.tar+zstd", - digest: `sha256:${archiveSha256}`, - }, - { - mediaType: "application/vnd.supabase.slim.checksum.v1", - digest: `sha256:${"f".repeat(64)}`, - }, - ], - }), - ); -}; +const fixtureSource = (content: string): ArtifactSource => ({ + checksum: () => Effect.succeed("0".repeat(64)), + materialize: (entry, destination) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const file of entry.requiredRuntimePaths) { + yield* fs.makeDirectory(`${destination}/${file.slice(0, file.lastIndexOf("/"))}`, { + recursive: true, + }); + yield* fs.writeFileString(`${destination}/${file}`, content); + yield* fs.chmod(`${destination}/${file}`, 0o755); + } + }).pipe( + Effect.mapError( + (cause) => new PreparationError({ message: "Unable to write cache fixture", cause }), + ), + ), +}); describe("slim-services artifact source", () => { it.live("follows release redirects through the supplied Node HTTP client", () => Effect.scoped( Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); const server = yield* Effect.acquireRelease( Effect.tryPromise({ try: () => // oxlint-disable-next-line effecttsgo/new-promise -- node server listen exposes a callback lifecycle. new Promise<Server>((resolve, reject) => { const value = createServer((request, response) => { - if (request.url === "/redirect") { + const url = request.url ?? ""; + if (url.startsWith("/redirect/")) { response.statusCode = 302; - response.setHeader("location", "/checksums"); + response.setHeader("location", url.slice("/redirect".length)); + response.end(); + return; } - response.end("a".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"); + response.end(url.endsWith(".manifest.json") ? demoManifest : archive); }); value.once("error", reject); value.listen(0, "127.0.0.1", () => resolve(value)); @@ -207,50 +253,62 @@ describe("slim-services artifact source", () => { const address = server.address(); if (address === null || typeof address === "string") return yield* Effect.die("redirect server did not expose a port"); - const redirected: SlimServicesArtifact = { + const origin = `http://127.0.0.1:${address.port}/redirect`; + const redirected = yield* pinned(archive, demoManifest, { ...artifact, - checksums: [{ kind: "sha256sums", url: `http://127.0.0.1:${address.port}/redirect` }], - }; - const checksum = yield* slimServicesChecksum(redirected); - expect(checksum).toBe("a".repeat(64)); + mirrors: [ + { + downloadUrl: `${origin}/demo.tar.zst`, + manifestUrl: `${origin}/demo.manifest.json`, + }, + ], + }); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-redirect-", + }); + yield* makeSlimServicesSource(() => redirected).materialize( + request, + destination, + redirected.sha256, + ); + expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); }).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ), ); - it.live("verifies checksums and extracts a manifest-matched archive using injected fetch", () => + it.live("prepares a pinned archive and manifest without fetching any checksum", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const checksums = expected + " demo-v1.0.0-linux-amd64.tar.zst\n"; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) return Promise.resolve(new Response(checksums)); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; - expect(yield* withFetch(fetcher, slimServicesChecksum(artifact))).toBe(expected); - const source = makeSlimServicesSource(() => artifact); + const demo = yield* pinned(archive); + const requested: string[] = []; + const fetcher = serving({ "example.test": { archive, manifest: demoManifest } }, requested); const fs = yield* FileSystem.FileSystem; - const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-source-" }); - yield* withFetch(fetcher, source.materialize(request, destination, expected)); - expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-store-" }); + const store = yield* makeArtifactStore({ + cacheRoot: root, + source: makeSlimServicesSource(() => demo), + }); + + const prepared = yield* withFetch(fetcher, store.prepare(request)); + expect(prepared.outcome).toBe("downloaded"); + expect(prepared.sha256).toBe(demo.sha256); + expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); + expect(requested).toEqual([demo.mirrors[0].manifestUrl, demo.mirrors[0].downloadUrl]); + + const cached = yield* withFetch(fetcher, store.prepare(request)); + expect(cached.outcome).toBe("cached"); + expect(requested).toHaveLength(2); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("retries a gateway error on the checksum and a truncated archive transfer", () => + it.live("retries a gateway error on the manifest and a truncated archive transfer", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const demo = yield* pinned(archive); const attempts = new Map<string, number>(); const count = (url: string): number => { const next = (attempts.get(url) ?? 0) + 1; @@ -266,17 +324,9 @@ describe("slim-services artifact source", () => { }); const flaky: FetchLike = (input) => { const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - count(url) === 1 - ? new Response("", { status: 504 }) - : new Response(`${expected} demo-v1.0.0-linux-amd64.tar.zst\n`), - ); if (url.endsWith("manifest.json")) return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), + count(url) === 1 ? new Response("", { status: 504 }) : new Response(demoManifest), ); return Promise.resolve( count(url) === 1 ? new Response(truncated()) : new Response(archive), @@ -284,78 +334,68 @@ describe("slim-services artifact source", () => { }; const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-retry-" }); - const source = makeSlimServicesSource(() => artifact, { backoff: immediate }); - expect(yield* withFetch(flaky, source.checksum(request))).toBe(expected); - expect(attempts.get("https://example.test/SHA256SUMS")).toBe(2); - yield* withFetch(flaky, source.materialize(request, destination, expected)); + const source = makeSlimServicesSource(() => demo, { backoff: immediate }); + yield* withFetch(flaky, source.materialize(request, destination, demo.sha256)); expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); - expect(attempts.get(artifact.mirrors[0].downloadUrl)).toBe(2); + expect(attempts.get(demo.mirrors[0].manifestUrl)).toBe(2); + expect(attempts.get(demo.mirrors[0].downloadUrl)).toBe(2); }).pipe(Effect.provide(NodeServices.layer)), ), ); it.live("spends five attempts on a persistent gateway error and one on a missing asset", () => - Effect.gen(function* () { - let gateway = 0; - const exhausted = yield* withFetch(() => { - gateway += 1; - return Promise.resolve(new Response("", { status: 504 })); - }, slimServicesChecksum(artifact, immediate).pipe(Effect.exit)); - expect(errorOf(exhausted)).toBeInstanceOf(PreparationError); - expect(gateway).toBe(5); - - let missing = 0; - const failed = yield* withFetch(() => { - missing += 1; - return Promise.resolve(new Response("", { status: 404 })); - }, slimServicesChecksum(artifact, immediate).pipe(Effect.exit)); - expect(errorOf(failed)).toBeInstanceOf(PreparationError); - expect(missing).toBe(1); - }), + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const source = makeSlimServicesSource(() => artifact, { backoff: immediate }); + + let gateway = 0; + const exhausted = yield* withFetch( + () => { + gateway += 1; + return Promise.resolve(new Response("", { status: 504 })); + }, + source + .materialize( + request, + yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-gateway-" }), + artifact.sha256, + ) + .pipe(Effect.exit), + ); + expect(errorOf(exhausted)).toBeInstanceOf(PreparationError); + expect(gateway).toBe(5); + + let missing = 0; + const failed = yield* withFetch( + () => { + missing += 1; + return Promise.resolve(new Response("", { status: 404 })); + }, + source + .materialize( + request, + yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-missing-" }), + artifact.sha256, + ) + .pipe(Effect.exit), + ); + expect(errorOf(failed)).toBeInstanceOf(PreparationError); + expect(missing).toBe(1); + }).pipe(Effect.provide(NodeServices.layer)), + ), ); it.live("prepares the artifact from the next mirror when the release host is blocked", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const mirrored: SlimServicesArtifact = { + const mirrored = yield* pinned(archive, demoManifest, { ...artifact, - checksums: [ - { kind: "sha256sums", url: "https://release.test/SHA256SUMS" }, - registryChecksum, - ], - mirrors: [ - { - downloadUrl: "https://release.test/demo-v1.0.0-linux-amd64.tar.zst", - manifestUrl: "https://release.test/demo-v1.0.0-linux-amd64.manifest.json", - }, - { - downloadUrl: "https://bucket.test/demo-v1.0.0-linux-amd64.tar.zst", - manifestUrl: "https://bucket.test/demo-v1.0.0-linux-amd64.manifest.json", - }, - ], - }; - const blocked = new Set<string>(); - const bucket: string[] = []; - const fetcher: FetchLike = (input, init) => { - const url = requestUrl(input); - if (url.startsWith("https://release.test/")) { - blocked.add(url); - return Promise.resolve(new Response("", { status: 403 })); - } - const registry = registryResponse(input, init, expected); - if (registry !== undefined) return Promise.resolve(registry); - bucket.push(url); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + mirrors: [releaseMirror, bucketMirror], + }); + const requested: string[] = []; + const fetcher = serving({ "bucket.test": { archive, manifest: demoManifest } }, requested); const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-fallback-" }); const store = yield* makeArtifactStore({ @@ -365,88 +405,140 @@ describe("slim-services artifact source", () => { const prepared = yield* withFetch(fetcher, store.prepare(request)); expect(prepared.outcome).toBe("downloaded"); expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); - expect([...blocked]).toEqual([ - "https://release.test/SHA256SUMS", - "https://release.test/demo-v1.0.0-linux-amd64.manifest.json", + expect(requested).toEqual([ + releaseMirror.manifestUrl, + bucketMirror.manifestUrl, + bucketMirror.downloadUrl, ]); - expect(bucket).toEqual([ - "https://bucket.test/demo-v1.0.0-linux-amd64.manifest.json", - "https://bucket.test/demo-v1.0.0-linux-amd64.tar.zst", + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("falls through to the next mirror when the primary serves a tampered archive", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const tampered = yield* compress(tar("bin/demo", "evil")); + const mirrored = yield* pinned(archive, demoManifest, { + ...artifact, + mirrors: [releaseMirror, bucketMirror], + }); + const requested: string[] = []; + const fetcher = serving( + { + "release.test": { archive: tampered, manifest: demoManifest }, + "bucket.test": { archive, manifest: demoManifest }, + }, + requested, + ); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-archive-" }); + yield* withFetch( + fetcher, + makeSlimServicesSource(() => mirrored).materialize(request, destination, mirrored.sha256), + ); + expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); + expect(requested).toEqual([ + releaseMirror.manifestUrl, + releaseMirror.downloadUrl, + bucketMirror.manifestUrl, + bucketMirror.downloadUrl, ]); }).pipe(Effect.provide(NodeServices.layer)), ), ); - it.live("reports the primary checksum failure when every checksum source fails", () => - Effect.gen(function* () { - const mirrored: SlimServicesArtifact = { - ...artifact, - checksums: [ - { kind: "sha256sums", url: "https://release.test/SHA256SUMS" }, - registryChecksum, - ], - }; - const requested: string[] = []; - const failed = yield* withFetch((input) => { - requested.push(requestUrl(input)); - return Promise.resolve(new Response("", { status: 403 })); - }, slimServicesChecksum(mirrored, immediate).pipe(Effect.exit)); - expect(errorOf(failed)?.message).toBe("Unable to download https://release.test/SHA256SUMS"); - expect(requested).toEqual([ - "https://release.test/SHA256SUMS", - "https://registry.test/token?scope=repository:supabase/cli/demo:pull&service=registry.test", - ]); - }), + it.live("falls through to the next mirror when the primary serves a tampered manifest", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const mirrored = yield* pinned(archive, demoManifest, { + ...artifact, + mirrors: [releaseMirror, bucketMirror], + }); + const requested: string[] = []; + const fetcher = serving( + { + "release.test": { archive, manifest: commandManifest }, + "bucket.test": { archive, manifest: demoManifest }, + }, + requested, + ); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ + prefix: "slim-services-manifest-", + }); + yield* withFetch( + fetcher, + makeSlimServicesSource(() => mirrored).materialize(request, destination, mirrored.sha256), + ); + expect(yield* fs.readFileString(`${destination}/bin/demo`)).toBe("demo"); + expect(requested).toEqual([ + releaseMirror.manifestUrl, + bucketMirror.manifestUrl, + bucketMirror.downloadUrl, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ), ); - it.live("accepts a fallback archive only when it matches the checksum", () => + it.live("names every mirror and its mismatch when none serves the pinned bytes", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); const tampered = yield* compress(tar("bin/demo", "evil")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const mirrored = (fallback: Uint8Array): SlimServicesArtifact => ({ + const tamperedManifest = manifestBytes("v1.0.0-r0", "evil"); + const mirrored = yield* pinned(archive, demoManifest, { ...artifact, - mirrors: [ - { - downloadUrl: "https://release.test/demo.tar.zst", - manifestUrl: "https://release.test/demo.manifest.json", - }, - { - downloadUrl: `https://bucket.test/${fallback === archive ? "good" : "bad"}.tar.zst`, - manifestUrl: "https://bucket.test/demo.manifest.json", - }, - ], + mirrors: [releaseMirror, bucketMirror], + }); + const fetcher = serving({ + "release.test": { archive: tampered, manifest: demoManifest }, + "bucket.test": { archive, manifest: tamperedManifest }, }); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(url.endsWith("good.tar.zst") ? archive : tampered)); - }; const fs = yield* FileSystem.FileSystem; - - const recovered = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-recover-" }); - yield* withFetch( + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-reject-" }); + const failed = yield* withFetch( fetcher, - makeSlimServicesSource(() => mirrored(archive)).materialize(request, recovered, expected), + makeSlimServicesSource(() => mirrored) + .materialize(request, destination, mirrored.sha256) + .pipe(Effect.exit), ); - expect(yield* fs.readFileString(`${recovered}/bin/demo`)).toBe("demo"); + expect(errorOf(failed)?.message).toBe( + "Unable to download the slim-services archive: " + + `${releaseMirror.downloadUrl} (Unable to download slim-services archive: ` + + `expected ${mirrored.sha256}, got ${yield* sha256Of(tampered)}); ` + + `${bucketMirror.downloadUrl} (Slim-services manifest does not match its pin: ` + + `expected ${mirrored.manifestSha256}, got ${yield* sha256Of(tamperedManifest)})`, + ); + expect(yield* fs.exists(`${destination}/bin/demo`)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); - const rejected = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-reject-" }); + it.live("rejects a manifest that names the upstream version instead of the release", () => + Effect.scoped( + Effect.gen(function* () { + const archive = yield* compress(tar("bin/demo", "demo")); + const upstreamManifest = manifestBytes("v1.0.0"); + const demo = yield* pinned(archive, upstreamManifest); + const requested: string[] = []; + const fetcher = serving( + { "example.test": { archive, manifest: upstreamManifest } }, + requested, + ); + const fs = yield* FileSystem.FileSystem; + const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-version-" }); const failed = yield* withFetch( fetcher, - makeSlimServicesSource(() => mirrored(tampered)) - .materialize(request, rejected, expected) + makeSlimServicesSource(() => demo) + .materialize(request, destination, demo.sha256) .pipe(Effect.exit), ); - expect(errorOf(failed)?.message).toBe("Unable to download slim-services archive"); - expect(yield* fs.exists(`${rejected}/bin/demo`)).toBe(false); + expect(errorOf(failed)?.message).toBe( + "Slim-services manifest does not match the catalog artifact", + ); + expect(requested).toEqual([demo.mirrors[0].manifestUrl]); }).pipe(Effect.provide(NodeServices.layer)), ), ); @@ -455,26 +547,14 @@ describe("slim-services artifact source", () => { Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("../outside", "unsafe")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; - const source = makeSlimServicesSource(() => artifact); + const demo = yield* pinned(archive); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-unsafe-" }); const failed = yield* withFetch( - fetcher, - source.materialize(request, destination, expected).pipe(Effect.exit), + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo) + .materialize(request, destination, demo.sha256) + .pipe(Effect.exit), ); expect(errorOf(failed)).toBeInstanceOf(PreparationError); expect(yield* fs.exists(`${destination}/outside`)).toBe(false); @@ -491,48 +571,21 @@ describe("slim-services artifact source", () => { { name: "bin/current", link: "demo" }, ]), ); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + const demo = yield* pinned(archive); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-links-" }); yield* withFetch( - fetcher, - makeSlimServicesSource(() => artifact).materialize(request, destination, expected), + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo).materialize(request, destination, demo.sha256), ); expect(yield* fs.readFileString(`${destination}/bin/current`)).toBe("demo"); const malformed = yield* compress(new Uint8Array([1, 2, 3])); - const malformedDigest = digestHex(yield* crypto.digest("SHA-256", malformed)); - const malformedFetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - new Response(malformedDigest + " demo-v1.0.0-linux-amd64.tar.zst\n"), - ); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(malformed)); - }; + const malformedDemo = yield* pinned(malformed); const failed = yield* withFetch( - malformedFetcher, - makeSlimServicesSource(() => artifact) - .materialize(request, destination, malformedDigest) + serving({ "example.test": { archive: malformed, manifest: demoManifest } }), + makeSlimServicesSource(() => malformedDemo) + .materialize(request, destination, malformedDemo.sha256) .pipe(Effect.exit), ); expect(errorOf(failed)).toBeInstanceOf(PreparationError); @@ -549,28 +602,15 @@ describe("slim-services artifact source", () => { { name: "bin/escape", link: "../../outside" }, ]), ); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(`${expected} demo-v1.0.0-linux-amd64.tar.zst\n`)); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + const demo = yield* pinned(archive); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-link-escape-", }); const failed = yield* withFetch( - fetcher, - makeSlimServicesSource(() => artifact) - .materialize(request, destination, expected) + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo) + .materialize(request, destination, demo.sha256) .pipe(Effect.exit), ); expect(errorOf(failed)).toBeInstanceOf(PreparationError); @@ -582,20 +622,12 @@ describe("slim-services artifact source", () => { it.live("interrupts an in-flight download without publishing a staging artifact", () => Effect.scoped( Effect.gen(function* () { + const demo = yield* pinned(new Uint8Array()); const started = yield* Deferred.make<void>(); let signal: AbortSignal | undefined; const fetcher: FetchLike = (input, init) => { - const url = requestUrl(input); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - new Response("0".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"), - ); + if (requestUrl(input).endsWith("manifest.json")) + return Promise.resolve(new Response(demoManifest)); signal = init?.signal ?? undefined; Deferred.doneUnsafe(started, Effect.void); return waitForAbort(signal); @@ -607,11 +639,7 @@ describe("slim-services artifact source", () => { const fiber = yield* Effect.forkChild( withFetch( fetcher, - makeSlimServicesSource(() => artifact).materialize( - request, - destination, - "0".repeat(64), - ), + makeSlimServicesSource(() => demo).materialize(request, destination, demo.sha256), ), { startImmediately: true }, ); @@ -623,39 +651,25 @@ describe("slim-services artifact source", () => { ), ); - it.live("rejects a streamed checksum mismatch before publishing and retries cleanly", () => + it.live("rejects an archive that misses its pin before publishing and retries cleanly", () => Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - let validChecksum = false; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - new Response( - `${validChecksum ? expected : "0".repeat(64)} demo-v1.0.0-linux-amd64.tar.zst\n`, - ), - ); - return Promise.resolve(new Response(archive)); - }; + const demo = yield* pinned(archive); + let current: SlimServicesArtifact = { ...demo, sha256: "0".repeat(64) }; const fs = yield* FileSystem.FileSystem; const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-store-integrity-", }); - const source = makeSlimServicesSource(() => artifact); - const store = yield* makeArtifactStore({ cacheRoot: root, source }); + const store = yield* makeArtifactStore({ + cacheRoot: root, + source: makeSlimServicesSource(() => current), + }); + const fetcher = serving({ "example.test": { archive, manifest: demoManifest } }); const failed = yield* withFetch(fetcher, store.prepare(request).pipe(Effect.exit)); expect(Exit.isFailure(failed)).toBe(true); expect(yield* fs.exists(`${root}/demo/v1`)).toBe(false); - validChecksum = true; + current = demo; const prepared = yield* withFetch(fetcher, store.prepare(request)); expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); }).pipe(Effect.provide(NodeServices.layer)), @@ -665,21 +679,13 @@ describe("slim-services artifact source", () => { it.live("cancels a streamed response after transfer starts and removes its staging file", () => Effect.scoped( Effect.gen(function* () { + const demo = yield* pinned(new Uint8Array()); const started = yield* Deferred.make<void>(); let signal: AbortSignal | undefined; let canceled = false; const fetcher: FetchLike = (input, init) => { - const url = requestUrl(input); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve( - new Response("0".repeat(64) + " demo-v1.0.0-linux-amd64.tar.zst\n"), - ); + if (requestUrl(input).endsWith("manifest.json")) + return Promise.resolve(new Response(demoManifest)); signal = init?.signal ?? undefined; let pulls = 0; const released = Deferred.makeUnsafe<void>(); @@ -706,11 +712,7 @@ describe("slim-services artifact source", () => { const fiber = yield* Effect.forkChild( withFetch( fetcher, - makeSlimServicesSource(() => artifact).materialize( - request, - destination, - "0".repeat(64), - ), + makeSlimServicesSource(() => demo).materialize(request, destination, demo.sha256), ), { startImmediately: true }, ); @@ -727,8 +729,7 @@ describe("slim-services artifact source", () => { Effect.scoped( Effect.gen(function* () { const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); + const demo = yield* pinned(archive); const started = yield* Deferred.make<void>(); let destroyed = false; const decompressor: ZstdDecompressor = { @@ -740,27 +741,15 @@ describe("slim-services artifact source", () => { }); }), }; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(`${expected} demo-v1.0.0-linux-amd64.tar.zst\n`)); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-zstd-" }); const fiber = yield* Effect.forkChild( withFetch( - fetcher, - makeSlimServicesSource(() => artifact, { decompressor }).materialize( + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo, { decompressor }).materialize( request, destination, - expected, + demo.sha256, ), ), { startImmediately: true }, @@ -778,114 +767,106 @@ describe("slim-services artifact source", () => { Effect.gen(function* () { const longName = `bin/${"long-function-name-".repeat(8)}.js`; const archive = yield* compress(paxTar(longName)); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) - return Promise.resolve(new Response(expected + " demo-v1.0.0-linux-amd64.tar.zst\n")); - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + const demo = yield* pinned(archive); const fs = yield* FileSystem.FileSystem; const destination = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-pax-" }); yield* withFetch( - fetcher, - makeSlimServicesSource(() => artifact).materialize(request, destination, expected), + serving({ "example.test": { archive, manifest: demoManifest } }), + makeSlimServicesSource(() => demo).materialize(request, destination, demo.sha256), ); expect(yield* fs.exists(`${destination}/${longName}`)).toBe(true); }).pipe(Effect.provide(NodeServices.layer)), ), ); - - it.live("publishes the extracted slim artifact through the verified store", () => - Effect.scoped( - Effect.gen(function* () { - const archive = yield* compress(tar("bin/demo", "demo")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const checksums = `${expected} demo-v1.0.0-linux-amd64.tar.zst\n`; - let checksumRequests = 0; - const fetcher: FetchLike = (input) => { - const url = requestUrl(input); - if (url.endsWith("SHA256SUMS")) { - checksumRequests += 1; - return Promise.resolve(new Response(checksums)); - } - if (url.endsWith("manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "demo", version: "v1.0.0", target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; - const source = makeSlimServicesSource(() => artifact); - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-store-" }); - const store = yield* makeArtifactStore({ cacheRoot: root, source }); - const prepared = yield* withFetch(fetcher, store.prepare(request)); - expect(prepared.outcome).toBe("downloaded"); - expect(yield* fs.readFileString(`${prepared.path}/bin/demo`)).toBe("demo"); - expect(yield* fs.exists(`${prepared.path}/.artifact.json`)).toBe(true); - expect(checksumRequests).toBe(1); - }).pipe(Effect.provide(NodeServices.layer)), - ), - ); }); describe("native artifact catalog", () => { - it.live("verifies an S3 download against GHCR when GitHub release assets are blocked", () => + const linux = { os: "linux", arch: "x64" }; + const s3 = "supabase-cli-artifacts.s3.us-east-1.amazonaws.com"; + + it.live( + "requests release-versioned assets from GitHub, then S3, and rejects unpinned bytes", + () => + Effect.scoped( + Effect.gen(function* () { + const { version, releaseVersion } = yield* resolveArtifact({ service: "rest" }); + expect(releaseVersion).toMatch(/-r(0|[1-9][0-9]*)$/u); + expect(releaseVersion.startsWith(`${version}-r`)).toBe(true); + const asset = `postgrest-${releaseVersion}-linux-amd64`; + const archive = yield* compress(tar("bin/postgrest", "postgrest")); + const manifest = manifestBytes(releaseVersion, "postgrest"); + const requested: string[] = []; + const fetcher = serving( + { "github.com": { archive, manifest }, [s3]: { archive, manifest } }, + requested, + ); + const fs = yield* FileSystem.FileSystem; + const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-catalog-" }); + const failed = yield* withFetch( + fetcher, + prepareNativeArtifact({ service: "rest" }, cacheRoot, linux).pipe(Effect.exit), + ); + const github = `https://github.com/supabase/slim-services/releases/download/postgrest-${releaseVersion}`; + const bucket = `https://${s3}/postgrest/${releaseVersion}`; + expect(requested).toEqual([ + `${github}/${asset}.manifest.json`, + `${bucket}/${asset}.manifest.json`, + ]); + const message = errorOf(failed)?.message ?? ""; + expect(message).toContain( + `${github}/${asset}.tar.zst (Slim-services manifest does not match its pin`, + ); + expect(message).toContain( + `${bucket}/${asset}.tar.zst (Slim-services manifest does not match its pin`, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ), + ); + + it.live("keys the native cache by release version and ignores a legacy upstream entry", () => Effect.scoped( Effect.gen(function* () { - const { version } = yield* resolveArtifact({ service: "rest" }); - const asset = `postgrest-${version}-linux-amd64`; - const archive = yield* compress(tar("bin/postgrest", "postgrest")); - const crypto = yield* Crypto.Crypto; - const expected = digestHex(yield* crypto.digest("SHA-256", archive)); - const served: string[] = []; - const fetcher: FetchLike = (input, init) => { - const url = requestUrl(input); - if (url.startsWith("https://github.com/")) - return Promise.resolve(new Response("", { status: 403 })); - served.push(url); - const registry = registryResponse( - input, - init, - expected, - "ghcr.io", - "supabase/cli/postgrest", - `${version}-native-linux-amd64`, - ); - if (registry !== undefined) return Promise.resolve(registry); - if (url.endsWith(".manifest.json")) - return Promise.resolve( - new Response( - JSON.stringify({ service: "postgrest", version, target: "linux-amd64" }), - ), - ); - return Promise.resolve(new Response(archive)); - }; + const resolved = yield* resolveArtifact({ service: "rest" }); const fs = yield* FileSystem.FileSystem; - const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-catalog-" }); - const prepared = yield* withFetch( - fetcher, - prepareNativeArtifact({ service: "rest" }, cacheRoot, { os: "linux", arch: "x64" }), + const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "slim-services-cache-" }); + const requested: string[] = []; + const seed = (version: string, content: string) => + makeArtifactStore({ cacheRoot, source: fixtureSource(content) }).pipe( + Effect.flatMap((store) => + withFetch( + serving({}, requested), + store.prepare({ + key: `slim-services/postgrest/${version}/linux-amd64`, + requiredRuntimePaths: resolved.requiredRuntimePaths, + executablePath: resolved.executablePath, + }), + ), + ), + ); + const prepare = withFetch( + serving({}, requested), + prepareNativeArtifact({ service: "rest" }, cacheRoot, linux), ); - expect(yield* fs.readFileString(prepared.executable)).toBe("postgrest"); - const bucket = `https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com/postgrest/${version}`; - expect(served).toEqual([ - "https://ghcr.io/token?scope=repository:supabase/cli/postgrest:pull&service=ghcr.io", - `https://ghcr.io/v2/supabase/cli/postgrest/manifests/${version}-native-linux-amd64`, - `${bucket}/${asset}.manifest.json`, - `${bucket}/${asset}.tar.zst`, - ]); + + yield* seed(resolved.version, "legacy"); + expect(Exit.isFailure(yield* prepare.pipe(Effect.exit))).toBe(true); + expect(requested).not.toEqual([]); + + const seeded = yield* seed(resolved.releaseVersion, "pinned"); + requested.length = 0; + const prepared = yield* prepare; + expect(prepared.root).toBe(seeded.path); + expect(yield* fs.readFileString(prepared.executable)).toBe("pinned"); + expect(requested).toEqual([]); }).pipe(Effect.provide(NodeServices.layer)), ), ); + + it("catalog has no placeholder pins", () => { + const digests = catalogPins().flatMap(({ pin }) => [ + pin.image.slice(pin.image.lastIndexOf(":") + 1), + ...Object.values(pin.natives).flatMap((native) => [native.archive, native.manifest]), + ]); + expect(digests).not.toContain("0".repeat(64)); + }); }); diff --git a/packages/stack/src/promise-api.integration.test.ts b/packages/stack/src/promise-api.integration.test.ts new file mode 100644 index 0000000000..d17f2f7792 --- /dev/null +++ b/packages/stack/src/promise-api.integration.test.ts @@ -0,0 +1,72 @@ +import { NodeServices, NodeSocketServer } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Redacted, Schema } from "effect"; +import { discover, StackError, type Observation } from "./index.ts"; +import { createTestStack } from "./testing.ts"; + +const databaseSecret = (observation: Observation) => + observation.config.service === "database" + ? observation.config.config.databasePassword + : undefined; + +it.live( + "returns observations as data, with exits and Redacted secrets intact", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-promise-state-" }); + const test = yield* Effect.acquireRelease( + Effect.promise(() => createTestStack({ runtime: "native", stateRoot })), + (created) => Effect.promise(() => created[Symbol.asyncDispose]()), + ); + const database = test.services.database; + + yield* Effect.promise(() => database.stop()); + const stopped = yield* Effect.promise(() => database.status()); + expect(Exit.isExit(stopped.exit)).toBe(true); + expect(Redacted.isRedacted(databaseSecret(stopped))).toBe(true); + + yield* Effect.promise(() => test.stack.composition.start()); + const members = yield* Effect.promise(() => test.stack.composition.stop()); + expect(members.length).toBeGreaterThan(0); + for (const member of members) expect(Exit.isExit(member.exit)).toBe(true); + expect(members.map(databaseSecret).filter(Redacted.isRedacted)).toHaveLength(1); + + const cancelled = yield* Effect.promise(() => + test.stack.composition.start({ signal: AbortSignal.abort() }).then( + () => "resolved", + () => "rejected", + ), + ); + expect(cancelled).toBe("rejected"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + { timeout: 120_000 }, +); + +it.live( + "rejects with the startup failure and removes the stack when Promise test startup fails", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-promise-failure-" }); + const occupied = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }); + if (occupied.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP"); + const port = occupied.address.port; + + const failure = yield* Effect.promise(() => + createTestStack({ + services: [{ service: "mail", endpoints: { http: { port } } }], + runtime: "native", + stateRoot, + }).then( + () => undefined, + (error: unknown) => error, + ), + ); + + expect(Schema.is(StackError)(failure)).toBe(true); + expect(Schema.is(StackError)(failure) ? failure.operation : undefined).toBe("test-startup"); + expect(yield* Effect.promise(() => discover({ stateRoot }))).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + { timeout: 120_000 }, +); diff --git a/packages/stack/src/promise-api.unit.test.ts b/packages/stack/src/promise-api.unit.test.ts new file mode 100644 index 0000000000..499801b95d --- /dev/null +++ b/packages/stack/src/promise-api.unit.test.ts @@ -0,0 +1,156 @@ +import type { Effect } from "effect"; +import { describe, expectTypeOf, it } from "vitest"; +import type { InitializationCommand } from "./Commands.ts"; +import type * as StackEffect from "./effect.ts"; +import type * as PromiseApi from "./index.ts"; +import { createTestStack, makeTestStack } from "./testing.ts"; + +type Kind = StackEffect.ServiceCreationInput["service"]; +type CallOptions = PromiseApi.CallOptions; + +describe("Promise API derived from the Effect API", () => { + it("exposes every Effect stack operation, plus close", () => { + expectTypeOf<keyof PromiseApi.Stack>().toEqualTypeOf<keyof StackEffect.Stack | "close">(); + expectTypeOf<keyof PromiseApi.Stack["services"]>().toEqualTypeOf< + keyof StackEffect.Stack["services"] + >(); + expectTypeOf<keyof PromiseApi.Stack["composition"]>().toEqualTypeOf< + keyof StackEffect.Stack["composition"] + >(); + expectTypeOf<keyof PromiseApi.Stack["commands"]>().toEqualTypeOf< + keyof StackEffect.Stack["commands"] + >(); + }); + + it("exposes every Effect service operation for each service kind", () => { + expectTypeOf<{ [K in Kind]: keyof PromiseApi.ServiceInstances[K] }>().toEqualTypeOf<{ + [K in Kind]: keyof StackEffect.ServiceInstances[K]; + }>(); + }); + + it("turns Effects into cancellable Promise calls", () => { + expectTypeOf<PromiseApi.Stack["composition"]["describe"]>().toEqualTypeOf< + (options?: CallOptions) => Promise<PromiseApi.CompositionConfig> + >(); + expectTypeOf<PromiseApi.DatabaseInstance["resetData"]>().toEqualTypeOf< + (options?: CallOptions) => Promise<void> + >(); + }); + + it("gives Effect-returning functions trailing call options", () => { + expectTypeOf<PromiseApi.DatabaseInstance["restoreSnapshot"]>().toEqualTypeOf< + ( + key: string, + options?: PromiseApi.DatabaseSnapshotOptions, + callOptions?: CallOptions, + ) => Promise<boolean> + >(); + expectTypeOf<PromiseApi.Stack["composition"]["plan"]>().returns.resolves.toEqualTypeOf< + ReadonlyArray<PromiseApi.PlannedInstance> + >(); + expectTypeOf<PromiseApi.Stack["commands"]["run"]>() + .parameter(2) + .toEqualTypeOf<CallOptions | undefined>(); + }); + + it("runs initialization commands with optional output sinks", () => { + const initialize = (stack: PromiseApi.Stack, command: InitializationCommand) => + stack.commands.run(command); + expectTypeOf<ReturnType<typeof initialize>>().resolves.toEqualTypeOf<{ + readonly jobId: string; + readonly exitCode: number; + }>(); + }); + + it("accepts plain secrets wherever the Effect API takes Redacted configuration", () => { + expectTypeOf<{ + service: "database"; + config: { version: "17"; jwtExpiry: 3600; databasePassword: string; jwtSecret: string }; + endpoints: {}; + }>().toExtend<PromiseApi.ServiceCreationInput>(); + expectTypeOf<{ + config: { version: string; jwtExpiry: number; databasePassword: string }; + }>().toExtend<Parameters<PromiseApi.DatabaseInstance["restart"]>[0]>(); + }); + + it("turns Streams into async iterables", () => { + expectTypeOf<PromiseApi.DatabaseInstance["followStatus"]>().toEqualTypeOf< + () => AsyncIterable<PromiseApi.Observation> + >(); + }); + + it("types created and returned handles by service kind", () => { + const createDatabase = (stack: PromiseApi.Stack) => + stack.services.create({ + service: "database", + config: { version: "17", jwtExpiry: 3600 }, + endpoints: {}, + }); + const createRest = (stack: PromiseApi.Stack) => + stack.services.create({ service: "rest", config: {}, endpoints: {} }); + type Rest = Awaited<ReturnType<typeof createRest>>; + expectTypeOf< + Awaited<ReturnType<typeof createDatabase>> + >().toEqualTypeOf<PromiseApi.DatabaseInstance>(); + expectTypeOf<"saveSnapshot" extends keyof Rest ? true : false>().toEqualTypeOf<false>(); + expectTypeOf<Rest["restart"]>().returns.resolves.toEqualTypeOf<void>(); + expectTypeOf< + Awaited<ReturnType<PromiseApi.Stack["services"]["list"]>>[number]["start"] + >().toEqualTypeOf<(options?: CallOptions) => Promise<void>>(); + }); + + it("types test stack services by the selected kinds", () => { + const selectDefault = () => createTestStack().then((test) => test.services); + const selectMany = () => + createTestStack({ services: ["database", { service: "rest", config: {} }] }).then( + (test) => test.services, + ); + expectTypeOf<keyof Awaited<ReturnType<typeof selectDefault>>>().toEqualTypeOf<"database">(); + expectTypeOf<Awaited<ReturnType<typeof selectMany>>>().toEqualTypeOf<{ + readonly database: PromiseApi.DatabaseInstance; + readonly rest: PromiseApi.ServiceInstances["rest"]; + }>(); + }); + + it("types a test stack kind as present only when every list variant selects it", () => { + const branches = (flag: boolean) => + createTestStack({ services: flag ? ["database", "auth"] : ["database"] }).then( + (test) => test.services, + ); + expectTypeOf<Awaited<ReturnType<typeof branches>>>().toEqualTypeOf<{ + readonly database: PromiseApi.DatabaseInstance; + readonly auth?: PromiseApi.ServiceInstances["auth"]; + }>(); + const element = (flag: boolean) => + createTestStack({ services: ["database", flag ? "auth" : "rest"] }).then( + (test) => test.services, + ); + expectTypeOf<Awaited<ReturnType<typeof element>>>().toEqualTypeOf<{ + readonly database: PromiseApi.DatabaseInstance; + readonly auth?: PromiseApi.ServiceInstances["auth"]; + readonly rest?: PromiseApi.ServiceInstances["rest"]; + }>(); + const effect = (flag: boolean) => + makeTestStack({ services: ["database", flag ? "auth" : "rest"] }); + expectTypeOf<Effect.Success<ReturnType<typeof effect>>["services"]>().toEqualTypeOf<{ + readonly database: StackEffect.DatabaseInstance; + readonly auth?: StackEffect.ServiceInstances["auth"]; + readonly rest?: StackEffect.ServiceInstances["rest"]; + }>(); + }); + + it("types test stack services from a list without a static length as optional", () => { + const services: ReadonlyArray<"database" | "rest"> = ["database"]; + const selectPromise = () => createTestStack({ services }).then((test) => test.services); + expectTypeOf<Awaited<ReturnType<typeof selectPromise>>>().toEqualTypeOf<{ + readonly database?: PromiseApi.DatabaseInstance; + readonly rest?: PromiseApi.ServiceInstances["rest"]; + }>(); + expectTypeOf< + Effect.Success<ReturnType<typeof makeTestStack<typeof services>>>["services"] + >().toEqualTypeOf<{ + readonly database?: StackEffect.DatabaseInstance; + readonly rest?: StackEffect.ServiceInstances["rest"]; + }>(); + }); +}); diff --git a/packages/stack/src/public.e2e.test.ts b/packages/stack/src/public.e2e.test.ts index 1c2c951f94..f8f33a415c 100644 --- a/packages/stack/src/public.e2e.test.ts +++ b/packages/stack/src/public.e2e.test.ts @@ -4,7 +4,8 @@ import { Effect, FileSystem, Layer, Path, Redacted, Schema, Stream } from "effec import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HttpClient } from "effect/unstable/http"; import { tmpdir } from "node:os"; -import { open, postgres } from "./effect.ts"; +import { discover, open } from "./effect.ts"; +import { postgres } from "./Commands.ts"; import * as PromiseStack from "./index.ts"; import { assertOwnerExited, captureOwnerPid } from "../tests/owner.ts"; import { destroyTestStack } from "../tests/stack-cleanup.ts"; @@ -110,7 +111,7 @@ it.live( expect((yield* mail.credentials()).url).toBe(credentials.url); const stdoutChunks: Array<Uint8Array> = []; - const tool = yield* stack.tools.run(postgres.psql({ major: 17 }), { + const tool = yield* stack.commands.run(postgres.psql({ major: 17 }), { args: ["--version"], stdout: (bytes) => Effect.sync(() => { @@ -145,14 +146,14 @@ it.live( path.join(pgProveRoot, "nested.sql"), "SELECT plan(1);\nSELECT pass('public pgProve');\nSELECT * FROM finish();\n", ); - const extension = yield* stack.tools.run(postgres.psql({ major: 17 }), { + const extension = yield* stack.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", databaseUrl, "-c", "CREATE EXTENSION IF NOT EXISTS pgtap"], stdout: () => Effect.void, stderr: () => Effect.void, }); expect(extension.exitCode).toBe(0); const pgProveOutput: Array<Uint8Array> = []; - const pgProve = yield* stack.tools.run(postgres.pgProve({ major: 17 }), { + const pgProve = yield* stack.commands.run(postgres.pgProve({ major: 17 }), { args: ["--dbname", databaseUrl, "--ext", ".sql", "main.sql", "--verbose"], pgProve: { mounts: [{ source: pgProveRoot, target: "/tests" }], @@ -206,7 +207,7 @@ it.live( const databaseUrl = urls.databaseUrl; if (databaseUrl === undefined) return yield* Effect.die("Database URL missing"); const promiseExtension = yield* Effect.tryPromise(() => - client.tools.run(postgres.psql({ major: 17 }), { + client.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", databaseUrl, "-c", "CREATE EXTENSION IF NOT EXISTS pgtap"], stdout: () => {}, stderr: () => {}, @@ -216,7 +217,7 @@ it.live( const promiseProveOutput: Array<Uint8Array> = []; const promiseProveError: Array<Uint8Array> = []; const promiseProve = yield* Effect.tryPromise(() => - client.tools.run(postgres.pgProve({ major: 17 }), { + client.commands.run(postgres.pgProve({ major: 17 }), { args: ["--dbname", databaseUrl, "--ext", ".sql", "main.sql", "--verbose"], pgProve: { mounts: [{ source: pgProveRoot, target: "/tests" }], @@ -240,7 +241,7 @@ it.live( ).toContain("public pgProve"); const sqlOutput: Array<Uint8Array> = []; const sql = yield* Effect.tryPromise(() => - client.tools.run(postgres.psql({ major: 17 }), { + client.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", databaseUrl, "-At"], stdin: Stream.toAsyncIterable( Stream.make(new TextEncoder().encode("SELECT 42;\n")), @@ -265,7 +266,7 @@ it.live( return yield* Effect.die("Snapshot source URL missing"); const sourceUrl = sourceCredentials.databaseUrl; const seed = yield* Effect.tryPromise(() => - client.tools.run(postgres.psql({ major: 17 }), { + client.commands.run(postgres.psql({ major: 17 }), { args: [ "--dbname", sourceUrl, @@ -306,7 +307,7 @@ it.live( const restoredUrl = restoredCredentials.databaseUrl; const restoredOutput: Array<Uint8Array> = []; const restoredQuery = yield* Effect.tryPromise(() => - client.tools.run(postgres.psql({ major: 17 }), { + client.commands.run(postgres.psql({ major: 17 }), { args: ["--dbname", restoredUrl, "-Atc", "SELECT value FROM snapshot_rows"], stdout: (bytes) => { restoredOutput.push(bytes); @@ -425,3 +426,41 @@ it.live( ), { timeout: 60_000 }, ); + +for (const runtime of ["node", "bun"] as const) { + it.live( + `${runtime}: a Promise test stack resets to its checkpoint and is destroyed on disposal`, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ + prefix: `stack-test-client-${runtime}-`, + }); + const child = yield* ChildProcess.make( + runtime === "bun" ? process.execPath : "node", + [new URL("../tests/test-stack-client.ts", import.meta.url).pathname, stateRoot], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + child.stdout.pipe(Stream.decodeText, Stream.mkString), + child.stderr.pipe(Stream.decodeText, Stream.mkString), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + expect(Number(code), stderr).toBe(0); + const disposed = yield* Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ stackId: Schema.String, projectRoot: Schema.String }), + ), + )(stdout.trim()); + expect(yield* discover({ stateRoot })).toEqual([]); + expect(yield* fs.exists(disposed.projectRoot)).toBe(false); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 300_000 }, + ); +} diff --git a/packages/stack/src/runtime/CommandOutput.ts b/packages/stack/src/runtime/CommandOutput.ts new file mode 100644 index 0000000000..348a6d79a6 --- /dev/null +++ b/packages/stack/src/runtime/CommandOutput.ts @@ -0,0 +1,79 @@ +import { Duration, Effect, Option, Ref, Stream } from "effect"; + +export interface CommandOutputProcess<E> { + readonly stdout: Stream.Stream<Uint8Array, E>; + readonly stderr: Stream.Stream<Uint8Array, E>; + readonly exitCode: Effect.Effect<number, E>; +} + +export type CommandOutputResult = + | { + readonly timedOut: true; + readonly output: Readonly<{ stdout: ReadonlyArray<string>; stderr: ReadonlyArray<string> }>; + } + | { + readonly timedOut: false; + readonly exitCode: number; + readonly output: Readonly<{ stdout: ReadonlyArray<string>; stderr: ReadonlyArray<string> }>; + }; + +const maxTailLines = 20; +const maxLineChars = 1_000; + +const clipLine = (line: string) => + line.length > maxLineChars + ? `…${line.slice(-maxLineChars).replace(/^[\uDC00-\uDFFF]/, "")}` + : line; + +/** Captures bounded process output while streaming bytes to the owning observer. */ +export const awaitCommandOutput = <E, E2 = never, R = never>( + process: CommandOutputProcess<E>, + options: { + readonly timeout: Duration.Input; + readonly onOutput?: ( + stream: "stdout" | "stderr", + bytes: Uint8Array, + ) => Effect.Effect<void, E2, R>; + }, +): Effect.Effect<CommandOutputResult, E | E2, R> => + Effect.gen(function* () { + const collect = ( + source: Stream.Stream<Uint8Array, E>, + name: "stdout" | "stderr", + tail: Ref.Ref<ReadonlyArray<string>>, + ) => + Effect.gen(function* () { + const appendLines = (lines: ReadonlyArray<string>) => + Ref.update(tail, (current) => + [...current, ...lines.filter((line) => line.trim().length > 0).map(clipLine)].slice( + -maxTailLines, + ), + ); + const partial = yield* Ref.make(""); + yield* source.pipe( + Stream.tap((bytes) => options.onOutput?.(name, bytes) ?? Effect.void), + Stream.decodeText, + Stream.runForEach((text) => + Ref.modify(partial, (rest): [ReadonlyArray<string>, string] => { + const lines = `${rest}${text}`.split(/\r?\n/); + const next = lines.pop() ?? ""; + return [lines, next.slice(-(maxLineChars + 1))]; + }).pipe(Effect.flatMap(appendLines)), + ), + Effect.ensuring(Ref.get(partial).pipe(Effect.flatMap((rest) => appendLines([rest])))), + ); + }); + const stdout = yield* Ref.make<ReadonlyArray<string>>([]); + const stderr = yield* Ref.make<ReadonlyArray<string>>([]); + const completed = yield* Effect.all( + [ + collect(process.stdout, "stdout", stdout), + collect(process.stderr, "stderr", stderr), + process.exitCode, + ], + { concurrency: "unbounded" }, + ).pipe(Effect.timeoutOption(options.timeout)); + const output = { stdout: yield* Ref.get(stdout), stderr: yield* Ref.get(stderr) }; + if (Option.isNone(completed)) return { timedOut: true, output }; + return { timedOut: false, exitCode: Number(completed.value[2]), output }; + }); diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index 634e17d57c..b724055684 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -8,17 +8,37 @@ import { Effect, Exit, Fiber, + FileSystem, + Layer, Option, + Path, Ref, + Schema, Sink, Stream, } from "effect"; +import { TestClock } from "effect/testing"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import { HttpClient } from "effect/unstable/http"; -import { ContainerLaunchError, makeContainerRuntime, type ContainerProcess } from "./Container.ts"; +import { + ContainerLaunchError, + DOCKER_HOST_ALIAS, + makeContainerRuntime, + makeHostGateway, + type ContainerProcess, +} from "./Container.ts"; -const image = "oven/bun:1.4.1-slim"; +const image = await Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const file = yield* path.fromFileUrl(new URL("../../../../.bun-version", import.meta.url)); + const version = yield* fs.readFileString(file); + return `oven/bun:${version.trim()}-slim`; +}).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); +const ipv4 = /^(?:\d{1,3}\.){3}\d{1,3}$/u; +const stoppableIdleScript = + "process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1000)"; class ContainerTestError extends Data.TaggedError("ContainerTestError")<{ readonly message: string; @@ -104,7 +124,7 @@ describe("container process adapter", () => { Effect.gen(function* () { const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); yield* runtime.prepare(image); - const process = yield* runtime.launchTool({ + const process = yield* runtime.launchCommand({ image, stackId: "e".repeat(64), instanceId: "tool-input", @@ -135,6 +155,423 @@ describe("container process adapter", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.live("maps the stack host alias to an explicit IPv4 host gateway only", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias", + env: {}, + args: ["-e", "console.log(await Bun.file('/etc/hosts').text())"], + }); + const [hosts, exitCode] = yield* Effect.all( + [process.stdout.pipe(Stream.decodeText, Stream.mkString), process.exitCode], + { concurrency: "unbounded" }, + ); + expect(exitCode).toBe(0); + const addresses = hosts + .split("\n") + .map((line) => line.trim().split(/\s+/u)) + .filter(([, ...names]) => names.includes(DOCKER_HOST_ALIAS)) + .map(([address]) => address ?? ""); + expect(addresses.length).toBeGreaterThan(0); + expect(addresses.every((address) => ipv4.test(address))).toBe(true); + expect(yield* inspectExtraHosts(process.id)).toContain( + `${DOCKER_HOST_ALIAS}:${addresses[0]}`, + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("probes the host gateway once for concurrent launches across runtimes", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const hostGateway = yield* makeHostGateway; + const makeRuntime = makeContainerRuntime({ engine: "docker", root: ".", hostGateway }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, () => undefined), + ), + ); + const first = yield* makeRuntime; + const second = yield* makeRuntime; + yield* first.prepare(image); + const processes = yield* Effect.all( + [first, second].map((runtime, index) => + runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: `host-alias-concurrent-${index}`, + env: {}, + args: ["-e", "process.exit(0)"], + }), + ), + { concurrency: "unbounded" }, + ); + expect(yield* Ref.get(probes)).toBe(1); + for (const process of processes) { + const aliases = (yield* inspectExtraHosts(process.id)).filter((entry) => + entry.startsWith(`${DOCKER_HOST_ALIAS}:`), + ); + expect(aliases).toHaveLength(1); + expect(aliases[0]?.slice(DOCKER_HOST_ALIAS.length + 1)).toMatch(ipv4); + } + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("launches without awaiting a background probe that later launches share", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const release = yield* Deferred.make<void>(); + const hostGateway = yield* makeHostGateway; + const spawner = makeHostGatewayProbeSpawner(delegate, probes, () => undefined, release); + const database = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway, + awaitHostGateway: false, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + const service = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + yield* database.prepare(image); + const launchExit = (runtime: typeof service, instanceId: string) => + runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId, + env: {}, + args: ["-e", "process.exit(0)"], + }); + + const early = yield* launchExit(database, "host-alias-background"); + expect(yield* inspectExtraHosts(early.id)).toContain(`${DOCKER_HOST_ALIAS}:host-gateway`); + const waiting = yield* launchExit(service, "host-alias-awaiting").pipe(Effect.forkChild); + yield* Deferred.succeed(release, undefined); + const later = yield* Fiber.join(waiting); + + const aliases = (yield* inspectExtraHosts(later.id)).filter((entry) => + entry.startsWith(`${DOCKER_HOST_ALIAS}:`), + ); + expect(aliases).toHaveLength(1); + expect(aliases[0]?.slice(DOCKER_HOST_ALIAS.length + 1)).toMatch(ipv4); + expect(yield* Ref.get(probes)).toBe(1); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("maps the host alias to the IPv4 gateway listed after an IPv6 one", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner( + delegate, + yield* Ref.make(0), + () => + `console.log("fdc4:f303:9324::254\\t${DOCKER_HOST_ALIAS}\\n192.168.65.254\\t${DOCKER_HOST_ALIAS}")`, + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias-dual-stack", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain( + `${DOCKER_HOST_ALIAS}:192.168.65.254`, + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("falls back to host-gateway without reprobing when the gateway has no IPv4 address", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner( + delegate, + probes, + () => `console.log("fdc4:f303:9324::254\\t${DOCKER_HOST_ALIAS}")`, + ), + ), + ); + yield* runtime.prepare(image); + for (const instanceId of ["host-alias-fallback-a", "host-alias-fallback-b"]) { + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId, + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain( + `${DOCKER_HOST_ALIAS}:host-gateway`, + ); + } + expect(yield* Ref.get(probes)).toBe(1); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("retries a probe whose image lacks cat before launching", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, (attempt) => + attempt === 1 + ? `console.error('exec: "cat": executable file not found in $PATH'); process.exit(127)` + : undefined, + ), + ), + ); + yield* runtime.prepare(image); + const launchExit = (instanceId: string) => + runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId, + env: {}, + args: ["-e", "process.exit(0)"], + }); + const launched = yield* launchExit("host-alias-retried-probe"); + const aliases = (yield* inspectExtraHosts(launched.id)).filter((entry) => + entry.startsWith(`${DOCKER_HOST_ALIAS}:`), + ); + expect(aliases).toHaveLength(1); + expect(aliases[0]?.slice(DOCKER_HOST_ALIAS.length + 1)).toMatch(ipv4); + expect(yield* Ref.get(probes)).toBe(2); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("maps the host alias to the engine's own host address when it rejects host-gateway", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, (attempt) => + attempt === 1 ? hostGatewayRejectionScript : podmanHostsScript, + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias-engine-host", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain(`${DOCKER_HOST_ALIAS}:10.88.0.1`); + expect(yield* Ref.get(probes)).toBe(2); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("retries the engine host probe after it fails transiently", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, (attempt) => + attempt === 2 + ? `console.error("daemon busy"); process.exit(1)` + : attempt === 4 + ? podmanHostsScript + : hostGatewayRejectionScript, + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias-engine-host-retry", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain(`${DOCKER_HOST_ALIAS}:10.88.0.1`); + expect(yield* Ref.get(probes)).toBe(4); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("fails launches actionably when an engine rejecting host-gateway maps no IPv4 host", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const runtime = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner(delegate, probes, (attempt) => + attempt === 1 + ? hostGatewayRejectionScript + : `console.log("127.0.0.1\\tlocalhost\\n::1\\thost.containers.internal")`, + ), + ), + ); + yield* runtime.prepare(image); + for (const instanceId of ["host-alias-unsupported-a", "host-alias-unsupported-b"]) { + const failure = yield* Effect.scoped( + runtime.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId, + env: {}, + args: ["-e", "process.exit(0)"], + }), + ).pipe(Effect.flip); + expect(failure._tag).toBe("ContainerError"); + expect(failure.message).toContain("upgrade Podman or use --runtime podman"); + } + expect(yield* Ref.get(probes)).toBe(2); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("recreates an unawaited launch the engine rejects for host-gateway", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const probes = yield* Ref.make(0); + const database = yield* makeContainerRuntime({ + engine: "docker", + root: ".", + hostGateway: yield* makeHostGateway, + awaitHostGateway: false, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeHostGatewayProbeSpawner( + makeHostGatewayRejectingCreateSpawner(delegate), + probes, + (attempt) => (attempt === 1 ? hostGatewayRejectionScript : podmanHostsScript), + ), + ), + ); + yield* database.prepare(image); + const process = yield* database.launchCommand({ + image, + stackId: "e".repeat(64), + instanceId: "host-alias-recreated", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(yield* process.exitCode).toBe(0); + expect(yield* inspectExtraHosts(process.id)).toContain(`${DOCKER_HOST_ALIAS}:10.88.0.1`); + expect(yield* Ref.get(probes)).toBe(2); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("names and labels a service container for compose-style grouping", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "9".repeat(64), + instanceId: "naming-service", + project: "My Cool App", + service: "auth", + env: {}, + args: ["-e", stoppableIdleScript], + }); + expect(process.id).toMatch(/^supabase-My-Cool-App-auth-[0-9a-f]{12}$/u); + const labels = yield* inspectLabels(process.id); + expect(labels["com.supabase.service"]).toBe("auth"); + expect(labels["com.docker.compose.project"]).toBe(`supabase-my-cool-app-${"9".repeat(12)}`); + expect(labels["com.docker.compose.service"]).toBe("auth"); + expect(labels["com.docker.compose.oneoff"]).toBeUndefined(); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("marks a one-shot container's name and compose labels as a task", () => + Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const process = yield* runtime.launchCommand({ + image, + stackId: "9".repeat(64), + instanceId: "naming-task", + project: "My Cool App", + service: "auth", + env: {}, + args: ["-e", "process.exit(0)"], + }); + expect(process.id).toMatch(/^supabase-My-Cool-App-auth-task-[0-9a-f]{12}$/u); + const labels = yield* inspectLabels(process.id); + expect(labels["com.supabase.service"]).toBe("auth"); + expect(labels["com.docker.compose.service"]).toBe("auth"); + expect(labels["com.docker.compose.oneoff"]).toBe("True"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + it.live( "publishes two private ports and keeps the second service alive after the first stops", () => @@ -174,6 +611,32 @@ describe("container process adapter", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("stops a container with its configured stop signal", () => + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "r".repeat(64), + instanceId: "stop-signal", + env: {}, + args: [ + "-e", + "process.on('SIGINT', () => process.exit(3)); setInterval(() => {}, 1000); console.log('ready')", + ], + stopSignal: "SIGINT", + stopGraceSeconds: 20, + }); + const logs = yield* ready(process); + + yield* process.stop; + expect(yield* process.exitCode).toBe(3); + + yield* process.remove; + yield* Fiber.interrupt(logs); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("waits until a discarded container stops before returning", () => Effect.gen(function* () { const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); @@ -254,6 +717,71 @@ describe("container process adapter", () => { ).pipe(Effect.provide(NodeServices.layer)), ); + it.live("releases a log follower that exits while its container keeps running", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const released = yield* Deferred.make<void>(); + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeLogFollowerSpawner( + delegate, + released, + "console.error('injected log stream failure'); process.exit(1)", + ), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "s".repeat(64), + instanceId: "dropped-log-follower", + env: {}, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + yield* Deferred.await(released).pipe(Effect.timeout("10 seconds")); + expect( + yield* process.stderr.pipe( + Stream.decodeText, + Stream.mkString, + Effect.timeout("10 seconds"), + ), + ).toContain("injected log stream failure"); + expect(yield* running(process.id)).toBe(true); + yield* process.discard; + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("releases a still running log follower when its launch scope closes", () => + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const released = yield* Deferred.make<void>(); + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeLogFollowerSpawner(delegate, released, "setInterval(() => {}, 1000)"), + ), + ); + yield* runtime.prepare(image); + const process = yield* runtime.launch({ + image, + stackId: "t".repeat(64), + instanceId: "live-log-follower", + env: {}, + args: ["-e", "setInterval(() => {}, 1000)"], + }); + expect(yield* Deferred.isDone(released)).toBe(false); + yield* process.discard; + }), + ); + expect(yield* Deferred.isDone(released)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.live("treats an externally removed container as already stopped", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -269,7 +797,7 @@ describe("container process adapter", () => { stackId: "x".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* removeExternally(process.id); yield* process.stop; @@ -301,7 +829,7 @@ describe("container process adapter", () => { stackId: "k".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; yield* process.remove; @@ -338,7 +866,7 @@ describe("container process adapter", () => { stackId: "l".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; const removeResult = yield* process.remove.pipe(Effect.exit); @@ -380,7 +908,7 @@ describe("container process adapter", () => { stackId: "m".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; const removeResult = yield* process.remove.pipe(Effect.exit); @@ -424,7 +952,7 @@ describe("container process adapter", () => { stackId: "n".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; yield* process.remove; @@ -463,7 +991,7 @@ describe("container process adapter", () => { stackId: "p".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; const removeResult = yield* process.remove.pipe(Effect.exit); @@ -510,7 +1038,7 @@ describe("container process adapter", () => { stackId: "o".repeat(64), instanceId, env: {}, - args: ["-e", "setInterval(() => {}, 1000)"], + args: ["-e", stoppableIdleScript], }); yield* process.stop; const remover = yield* process.remove.pipe( @@ -706,6 +1234,47 @@ describe("container process adapter", () => { expect(yield* Ref.get(present)).toBe(false); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("bounds a hung docker create and removes the container it may still create", () => + Effect.gen(function* () { + const engine = yield* makeHangingCreateSpawner(); + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provide(engine.layer), + ); + const launch = yield* Effect.scoped( + runtime.launch({ image, stackId: "i".repeat(64), instanceId: "hung-create", env: {} }), + ).pipe(Effect.provide(engine.layer), Effect.exit, Effect.forkChild); + yield* Deferred.await(engine.createStarted); + yield* TestClock.adjust("2 minutes"); + const result = yield* Fiber.join(launch); + expect(Exit.isFailure(result)).toBe(true); + const failure = Exit.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) + : undefined; + expect(failure instanceof ContainerLaunchError).toBe(true); + expect( + failure instanceof ContainerLaunchError ? failure.failure.message : undefined, + ).toContain("did not respond"); + expect(engine.commands).toContainEqual(["rm", "--force", engine.createdName()]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("removes the container a hung docker create may still create when interrupted", () => + Effect.gen(function* () { + const engine = yield* makeHangingCreateSpawner(); + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }).pipe( + Effect.provide(engine.layer), + ); + const launch = yield* Effect.scoped( + runtime.launch({ image, stackId: "i".repeat(64), instanceId: "interrupted", env: {} }), + ).pipe(Effect.provide(engine.layer), Effect.forkChild); + yield* Deferred.await(engine.createStarted); + yield* Fiber.interrupt(launch); + expect(Exit.hasInterrupts(yield* Fiber.await(launch))).toBe(true); + expect(engine.createdName()).toMatch(/^supabase-/u); + expect(engine.commands).toContainEqual(["rm", "--force", engine.createdName()]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); const repoDigest = (spawner: ChildProcessSpawnerService["Service"], image: string) => @@ -761,6 +1330,56 @@ const makePullFailureSpawner = ( return delegate.spawn(command); }); +const hostGatewayRejectionScript = `console.error(${JSON.stringify( + 'Error response from daemon: invalid IP address in add-host: "host-gateway"', +)}); process.exit(125)`; + +const podmanHostsScript = `console.log("10.88.0.1\\thost.containers.internal host.docker.internal")`; + +/** Rejects a `docker create` that maps the host alias to `host-gateway`, as older Podman does. */ +const makeHostGatewayRejectingCreateSpawner = (delegate: ChildProcessSpawnerService["Service"]) => + ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && + command.command === "docker" && + command.args[0] === "create" && + command.args.includes(`${DOCKER_HOST_ALIAS}:host-gateway`) + ? delegate.spawn( + ChildProcess.make(process.execPath, ["-e", hostGatewayRejectionScript], { + stdin: "ignore", + }), + ) + : delegate.spawn(command), + ); + +/** + * Replaces a host-gateway probe with the script `fake` returns for its attempt, if any; a probe + * spawns only once `release`, when given, completes. + */ +const makeHostGatewayProbeSpawner = ( + delegate: ChildProcessSpawnerService["Service"], + probes: Ref.Ref<number>, + fake: (attempt: number) => string | undefined, + release?: Deferred.Deferred<void>, +) => + ChildProcessSpawner.make((command) => { + if ( + !ChildProcess.isStandardCommand(command) || + command.command !== "docker" || + command.args[0] !== "run" || + !command.args.includes("/etc/hosts") + ) + return delegate.spawn(command); + return Effect.gen(function* () { + const script = fake(yield* Ref.updateAndGet(probes, (count) => count + 1)); + if (release !== undefined) yield* Deferred.await(release); + return yield* delegate.spawn( + script === undefined + ? command + : ChildProcess.make(process.execPath, ["-e", script], { stdin: "ignore" }), + ); + }); + }); + const makeStopFailureSpawner = ( delegate: ChildProcessSpawnerService["Service"], failStop: Ref.Ref<boolean>, @@ -785,6 +1404,26 @@ const makeStopFailureSpawner = ( return delegate.spawn(command); }); +const makeLogFollowerSpawner = ( + delegate: ChildProcessSpawnerService["Service"], + released: Deferred.Deferred<void>, + script: string, +) => + ChildProcessSpawner.make((command) => { + if ( + ChildProcess.isStandardCommand(command) && + command.command === "docker" && + command.args[0] === "logs" + ) + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => Deferred.succeed(released, undefined)); + return yield* delegate.spawn( + ChildProcess.make(process.execPath, ["-e", script], { stdin: "ignore" }), + ); + }); + return delegate.spawn(command); + }); + const makeLostRemoveResultSpawner = ( delegate: ChildProcessSpawnerService["Service"], lostResult: Ref.Ref<boolean>, @@ -1034,6 +1673,44 @@ const successfulHandle = () => unref: Effect.succeed(Effect.void), }); +const makeHangingCreateSpawner = Effect.fn("ContainerTest.hangingCreateSpawner")(function* () { + const commands: string[][] = []; + const createStarted = yield* Deferred.make<void>(); + const spawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) return Effect.die("unexpected piped command"); + commands.push([...command.args]); + if (command.args[0] !== "create") return Effect.succeed(successfulHandle()); + // The engine never answers create; the real daemon may or may not have committed it. + return Deferred.succeed(createStarted, undefined).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), + ); + }); + const createdName = () => { + const args = commands.find((command) => command[0] === "create") ?? []; + return args[args.indexOf("--name") + 1]; + }; + return { + commands, + createStarted, + createdName, + layer: Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + }; +}); + const ready = (process: ContainerProcess) => Effect.gen(function* () { const signal = yield* Deferred.make<void>(); @@ -1062,6 +1739,34 @@ const exists = (id: string) => return Number(yield* child.exitCode) === 0; }); +const inspectLabels = (id: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["inspect", "--format={{json .Config.Labels}}", id], { + stdin: "ignore", + }), + ); + const output = yield* child.stdout.pipe(Stream.decodeText, Stream.mkString); + return yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), + )(output.trim()); + }); + +const inspectExtraHosts = (id: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make("docker", ["inspect", "--format={{json .HostConfig.ExtraHosts}}", id], { + stdin: "ignore", + }), + ); + const output = yield* child.stdout.pipe(Stream.decodeText, Stream.mkString); + return yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.String)))( + output.trim(), + ); + }); + const removeExternally = (id: string) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index f421a2b2c5..43459fbdf9 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -2,6 +2,7 @@ import { Cause, Crypto, Data, + Deferred, type Duration, Effect, Exit, @@ -9,6 +10,7 @@ import { Fiber, Option, Path, + PlatformError, Ref, Schedule, Schema, @@ -17,17 +19,23 @@ import { Stream, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { identifyContainer } from "./ContainerName.ts"; export class ContainerError extends Data.TaggedError("ContainerError")<{ readonly operation: string; readonly message: string; readonly cause?: unknown; + readonly reason?: "engine-unavailable"; }> {} interface ContainerSpec { readonly image: string; readonly stackId: string; readonly instanceId: string; + /** Labels the container with its service kind so stack log collectors can route it. */ + readonly service?: string; + /** Groups this stack's containers under one name in Docker Desktop/OrbStack. */ + readonly project?: string; readonly env: Readonly<Record<string, string>>; readonly args?: ReadonlyArray<string>; readonly entrypoint?: string; @@ -42,6 +50,8 @@ interface ContainerSpec { readonly ports?: ReadonlyArray<number>; /** Seconds `docker stop` waits before SIGKILL. Omitted means 10. */ readonly stopGraceSeconds?: number; + /** Signal `docker stop` sends first; omitted uses the image's stop signal. */ + readonly stopSignal?: "SIGINT" | "SIGTERM"; } export interface ContainerProcess { @@ -71,7 +81,7 @@ export interface ContainerRuntime { readonly launch: ( spec: ContainerSpec, ) => Effect.Effect<ContainerProcess, ContainerError | ContainerLaunchError, Scope.Scope>; - readonly launchTool: ( + readonly launchCommand: ( spec: Omit<ContainerSpec, "ports">, ) => Effect.Effect<ContainerProcess, ContainerError | ContainerLaunchError, Scope.Scope>; } @@ -86,10 +96,136 @@ const errorFor = (operation: string, cause: unknown) => const rateLimited = (error: ContainerError) => /toomanyrequests|too many requests|rate limit|rate exceeded/iu.test(error.message); +/** + * Matches a dropped registry connection, not a permanent rejection or a failing engine socket + * (`error during connect`, `%2F…` hosts). `EOF` only counts after a registry request URL. + */ +const transientPullFailure = (error: ContainerError) => + !/error during connect/iu.test(error.message) && + /(?:Get|Head|Post|Put) "https?:\/\/(?!%2F)[^"]+": (?:unexpected )?EOF|connection reset by peer|i\/o timeout|TLS handshake timeout|net\/http: request canceled|502 Bad Gateway|503 Service Unavailable|504 Gateway Timeout|received unexpected HTTP status: 5\d\d/iu.test( + error.message, + ); + +/** + * Matches an engine CLI that is missing or reports a daemon that is not listening, not one that + * rejects the caller. Podman's connection wrappers and Windows' `error during connect` also wrap + * authentication and TLS failures, so only their refused or missing-endpoint causes match. + */ +const engineUnreachable = (error: ContainerError) => + (error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" && + error.cause.reason.method === "spawn") || + /cannot connect to the docker daemon|connection refused|connect: no such file or directory|error during connect:[^\n]*(?:docker daemon is not running|the system cannot find the file specified)/iu.test( + error.message, + ); + +/** A pull worth retrying: rate-limited or a dropped connection, never an unreachable engine. */ +const retryablePull = (error: ContainerError) => + (rateLimited(error) || transientPullFailure(error)) && !engineUnreachable(error); + +const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + const PULL_MAX_RETRIES = 4; +/** + * Host alias mapped in Docker containers' `/etc/hosts` to the engine's IPv4 host gateway, or to + * `host-gateway` for runtimes that do not await the probe; Docker Desktop's `host.docker.internal` + * and `host-gateway` also resolve to an IPv6 address. Engines that reject `host-gateway` get the + * IPv4 address they map to `host.docker.internal` or `host.containers.internal` instead. + */ +export const DOCKER_HOST_ALIAS = "host.supabase.internal"; + +/** Bounds the throwaway container that resolves the IPv4 host gateway. */ +const HOST_GATEWAY_PROBE_TIMEOUT: Duration.Input = "15 seconds"; + +const IPV4_ADDRESS = /^(?:\d{1,3}\.){3}\d{1,3}$/u; + +/** Names an engine may write into `/etc/hosts` for its host, such as Podman's compat socket. */ +const ENGINE_HOST_NAMES = ["host.docker.internal", "host.containers.internal"]; + +const firstIpv4For = (hosts: string, names: ReadonlyArray<string>) => + hosts + .split("\n") + .map((line) => line.trim().split(/\s+/u)) + .find( + ([address, ...mapped]) => + IPV4_ADDRESS.test(address ?? "") && mapped.some((name) => names.includes(name)), + )?.[0]; + +/** Matches an engine that rejects the `host-gateway` keyword in `--add-host`, such as older Podman. */ +const rejectsHostGateway = (error: ContainerError) => + /(?:invalid|unknown|unsupported|bad)[^\n]*add-host[^\n]*host-gateway/iu.test(error.message); + +/** + * One stack host's `--add-host` target for `DOCKER_HOST_ALIAS`, shared by its Docker runtimes. + * At most one probe runs at a time, in the gateway's scope; a probe yielding `undefined` leaves + * the target unresolved so a later caller probes again, and a probe failure is cached. + */ +export interface HostGateway { + /** Awaits the cached or in-flight target, starting `probe` when there is neither. */ + readonly resolve: ( + probe: Effect.Effect<string | undefined, ContainerError>, + ) => Effect.Effect<string, ContainerError>; + /** Starts `probe` in the background unless a target is cached or in flight. */ + readonly prefetch: ( + probe: Effect.Effect<string | undefined, ContainerError>, + ) => Effect.Effect<void>; +} + +/** Probes on every platform: engines with IPv6 on the bridge map `host-gateway` to both families. */ +export const makeHostGateway: Effect.Effect<HostGateway, never, Scope.Scope> = Effect.gen( + function* () { + const scope = yield* Scope.Scope; + const target = yield* Ref.make< + Deferred.Deferred<string | undefined, ContainerError> | undefined + >(undefined); + const start = (probe: Effect.Effect<string | undefined, ContainerError>) => + Effect.uninterruptible( + Effect.gen(function* () { + const fresh = yield* Deferred.make<string | undefined, ContainerError>(); + const current = yield* Ref.modify(target, (state) => + state === undefined ? [undefined, fresh] : [state, state], + ); + if (current !== undefined) return current; + // Starting immediately installs `onExit` before a closed scope can interrupt the fiber. + yield* probe.pipe( + Effect.onExit((exit) => { + const failure = Exit.isFailure(exit) + ? Cause.findErrorOption(exit.cause) + : Option.none(); + if (Option.isSome(failure)) return Deferred.fail(fresh, failure.value); + const probed = Exit.isSuccess(exit) ? exit.value : undefined; + return (probed === undefined ? Ref.set(target, undefined) : Effect.void).pipe( + Effect.andThen(Deferred.succeed(fresh, probed)), + ); + }), + Effect.forkIn(scope, { startImmediately: true }), + ); + return fresh; + }), + ); + return { + // A waiter whose shared probe failed retries once before falling back. + resolve: (probe) => + start(probe).pipe( + Effect.flatMap(Deferred.await), + Effect.flatMap((probed) => + probed === undefined + ? start(probe).pipe(Effect.flatMap(Deferred.await)) + : Effect.succeed(probed), + ), + Effect.map((probed) => probed ?? "host-gateway"), + ), + prefetch: (probe) => Effect.asVoid(start(probe)), + }; + }, +); + const pullBackoff = Schedule.exponential("2 seconds").pipe(Schedule.jittered); +/** `docker create` only writes metadata; a healthy daemon answers well within this bound. */ +const CREATE_TIMEOUT: Duration.Input = "2 minutes"; + const PublishedPorts = Schema.Record( Schema.String, Schema.NullOr( @@ -110,17 +246,29 @@ const mountField = (key: string, value: string) => { /** * Captures the selected local engine; each launch owns one exact container. An image whose pull * fails is pulled from the first of its `imageMirrors` that succeeds, and launches of it then use - * that mirror reference. When every mirror fails, the primary pull error is reported; a - * rate-limited primary retries the whole chain with backoff. + * that mirror reference. When every mirror fails, the primary pull error is reported; a primary + * that is rate-limited or hits a transient registry transport failure retries the whole chain + * with backoff. */ export const makeContainerRuntime = (options: { readonly engine: "docker" | "podman"; readonly root: string; readonly imageMirrors?: (image: string) => ReadonlyArray<string>; + /** Omitted gives this runtime its own host-gateway probe. */ + readonly hostGateway?: HostGateway; + /** + * `false` launches with `host-gateway` at once and resolves the IPv4 target in the background, + * for containers that do not rely on reaching the host over IPv4. + */ + readonly awaitHostGateway?: boolean; }): Effect.Effect< ContainerRuntime, never, - ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | Crypto.Crypto + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | Scope.Scope > => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -225,20 +373,88 @@ export const makeContainerRuntime = (options: { }); return yield* attempt.pipe( Effect.tapError((error) => - rateLimited(error) - ? Effect.logWarning(`Registry rate-limited the pull of ${image}`) - : Effect.void, + !retryablePull(error) + ? Effect.void + : rateLimited(error) + ? Effect.logWarning(`Registry rate-limited the pull of ${image}`) + : Effect.logWarning(`Registry pull of ${image} failed transiently`), ), - Effect.retry({ schedule: pullBackoff, times: PULL_MAX_RETRIES, while: rateLimited }), + Effect.retry({ schedule: pullBackoff, times: PULL_MAX_RETRIES, while: retryablePull }), ); }); const prepare = Effect.fn("Container.prepare")((image: string) => prepareImage(image).pipe(Effect.asVoid), ); + const hostGateway = options.hostGateway ?? (yield* makeHostGateway); + /** Reads `/etc/hosts` from a throwaway container of an already present image. */ + const readProbeHosts = (image: string, spec: ContainerSpec, addHost: ReadonlyArray<string>) => + run( + [ + "run", + "--rm", + "--pull", + "never", + ...addHost, + // No instance label: `--rm` removal is asynchronous and must not count as an + // instance container; the stack labels keep it sweepable. + "--label", + `com.supabase.stack=${spec.stackId}`, + "--label", + `com.supabase.stack-root=${stackRoot}`, + "--entrypoint", + "cat", + image, + "/etc/hosts", + ], + { timeout: undefined }, + ).pipe(Effect.timeout(HOST_GATEWAY_PROBE_TIMEOUT)); + /** Resolves the IPv4 host address the engine writes itself, since it rejects `host-gateway`. */ + const engineHostProbe = (image: string, spec: ContainerSpec, rejection: ContainerError) => + readProbeHosts(image, spec, []).pipe( + Effect.matchEffect({ + // A failed or slow read says nothing about the engine, so the next launch retries. + onFailure: (error) => + Effect.logDebug(`Engine host probe failed: ${error.message}`).pipe( + Effect.as(undefined), + ), + onSuccess: (hosts) => { + const address = firstIpv4For(hosts, ENGINE_HOST_NAMES); + return address === undefined + ? Effect.fail( + new ContainerError({ + operation: "host-gateway", + message: `The container engine rejects host-gateway in --add-host and maps no IPv4 address to ${ENGINE_HOST_NAMES.join(" or ")}; upgrade Podman or use --runtime podman`, + cause: rejection, + }), + ) + : Effect.succeed(address); + }, + }), + ); + /** Probes the engine's first IPv4 `host-gateway` address with an already present image. */ + const hostGatewayProbe = (image: string, spec: ContainerSpec) => + readProbeHosts(image, spec, ["--add-host", `${DOCKER_HOST_ALIAS}:host-gateway`]).pipe( + // No IPv4 entry is a stable engine answer, so `host-gateway` is cached rather than re-probed. + Effect.map((hosts) => firstIpv4For(hosts, [DOCKER_HOST_ALIAS]) ?? "host-gateway"), + // A failed or slow probe (e.g. an image without `cat`) is retried by the next launch. + Effect.catch((error) => + error instanceof ContainerError && rejectsHostGateway(error) + ? engineHostProbe(image, spec, error) + : Effect.logDebug(`Host gateway probe failed: ${error.message}`).pipe( + Effect.as(undefined), + ), + ), + ); + const hostAliasTarget = (image: string, spec: ContainerSpec) => + options.awaitHostGateway === false + ? hostGateway.prefetch(hostGatewayProbe(image, spec)).pipe(Effect.as("host-gateway")) + : hostGateway.resolve(hostGatewayProbe(image, spec)); + const launch = Effect.fn("Container.launch")(function* ( spec: ContainerSpec, interactive = false, + oneOff = false, ) { const owner = yield* Scope.Scope; const image = (yield* Ref.get(mirrored)).get(spec.image) ?? spec.image; @@ -270,14 +486,22 @@ export const makeContainerRuntime = (options: { const token = yield* crypto.randomUUIDv4.pipe( Effect.mapError((cause) => errorFor("identity", cause)), ); - const name = `supabase-${token}`; - const args = [ + const { name, composeProject, composeService } = identifyContainer(spec, token, oneOff); + const hostAlias = + options.engine === "docker" ? yield* hostAliasTarget(image, spec) : undefined; + const createArgs = (target: string | undefined) => [ "create", "--pull", "never", ...(interactive ? ["--interactive", "--init"] : []), - ...(options.engine === "docker" && process.platform === "linux" - ? ["--add-host", "host.docker.internal:host-gateway"] + ...(target !== undefined + ? [ + "--add-host", + `${DOCKER_HOST_ALIAS}:${target}`, + ...(process.platform === "linux" + ? ["--add-host", `host.docker.internal:${target}`] + : []), + ] : []), "--name", name, @@ -287,6 +511,12 @@ export const makeContainerRuntime = (options: { `com.supabase.instance=${spec.instanceId}`, "--label", `com.supabase.stack-root=${stackRoot}`, + ...(spec.service === undefined ? [] : ["--label", `com.supabase.service=${spec.service}`]), + "--label", + `com.docker.compose.project=${composeProject}`, + "--label", + `com.docker.compose.service=${composeService}`, + ...(oneOff ? ["--label", "com.docker.compose.oneoff=True"] : []), "--env-file", envPath, ...(spec.mounts ?? []).flatMap((mount) => [ @@ -302,26 +532,59 @@ export const makeContainerRuntime = (options: { ].join(","), ]), ...(spec.workingDir === undefined ? [] : ["--workdir", spec.workingDir]), + ...(spec.stopSignal === undefined ? [] : ["--stop-signal", spec.stopSignal]), ...(spec.ports ?? []).flatMap((port) => ["--publish", `127.0.0.1::${port}`]), ...(spec.entrypoint === undefined ? [] : ["--entrypoint", spec.entrypoint]), image, ...(spec.args ?? []), ]; + const create = run(createArgs(hostAlias), { timeout: undefined }).pipe( + // An unawaited `host-gateway` can reach an engine that rejects it before the probe answers. + Effect.catchTag("ContainerError", (error) => + hostAlias === "host-gateway" && rejectsHostGateway(error) + ? hostGateway + .resolve(hostGatewayProbe(image, spec)) + .pipe(Effect.flatMap((target) => run(createArgs(target), { timeout: undefined }))) + : Effect.fail(error), + ), + ); return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - // Creation must settle before cleanup can safely run. - const creation = yield* run(args, { timeout: undefined }).pipe( - Effect.mapError( - (error) => - new ContainerError({ - operation: error.operation, - message: `${error.message} (container name ${name})`, - cause: error, - }), + // Creation must settle before cleanup can safely run. The timeout below needs genuine + // interruptibility to bound a hung daemon, so this restores it just for the create + // call; either that timeout or an external interrupt reaching this window may still + // leave a container needing best-effort removal, handled in both branches below. + const creation = yield* restore( + create.pipe( + Effect.timeout(CREATE_TIMEOUT), + Effect.mapError((error) => + error._tag === "TimeoutError" + ? error + : new ContainerError({ + operation: error.operation, + message: `${error.message} (container name ${name})`, + cause: error, + }), + ), + Effect.catchTag("TimeoutError", () => + Effect.uninterruptible( + Effect.gen(function* () { + yield* run(["rm", "--force", name], { timeout: "10 seconds" }).pipe( + Effect.ignore, + ); + return yield* errorFor( + "create", + `Engine did not respond to container creation within ${CREATE_TIMEOUT} (container name ${name})`, + ); + }), + ), + ), + Effect.onInterrupt(() => + run(["rm", "--force", name], { timeout: "10 seconds" }).pipe(Effect.ignore), + ), ), - Effect.exit, - ); + ).pipe(Effect.exit); const stopped = yield* Ref.make(false); const removed = yield* Ref.make(false); const reconcileAbsent = Effect.fn("Container.reconcileAbsent")(function* ( @@ -333,7 +596,8 @@ export const makeContainerRuntime = (options: { "--all", "--no-trunc", "--filter", - `name=^/?${name}$`, + // Docker matches this as a regex; `.` is the only metacharacter a name can hold. + `name=^/?${name.replaceAll(".", "\\.")}$`, "--format", "{{.State}}", ], @@ -484,13 +748,27 @@ export const makeContainerRuntime = (options: { } const logProcess = attached ?? - (yield* spawner - .spawn( - ChildProcess.make(options.engine, ["logs", "--follow", name], { - stdin: "ignore", - }), - ) - .pipe(Effect.mapError((cause) => errorFor("logs", cause)))); + (yield* Effect.gen(function* () { + // Released on exit, since a release left for service stop can hit a reused pid. + const followerScope = yield* Scope.fork(owner); + const follower = yield* spawner + .spawn( + ChildProcess.make(options.engine, ["logs", "--follow", name], { + stdin: "ignore", + }), + ) + .pipe( + Scope.provide(followerScope), + Effect.mapError((cause) => errorFor("logs", cause)), + ); + yield* Effect.forkIn( + Effect.exit(follower.exitCode).pipe( + Effect.andThen(Scope.close(followerScope, Exit.void)), + ), + owner, + ); + return follower; + })); return { ...partial, ports, @@ -504,7 +782,12 @@ export const makeContainerRuntime = (options: { }), ); }); - return { prepare, prepareImage, launch, launchTool: (spec) => launch(spec, true) }; + return { + prepare, + prepareImage, + launch, + launchCommand: (spec) => launch(spec, true, true), + }; }); export const removeStackContainers = Effect.fn("Container.removeStackContainers")( @@ -555,7 +838,22 @@ export const removeStackContainers = Effect.fn("Container.removeStackContainers" `label=com.supabase.stack-root=${stackRoot}`, ]; const list = () => run(["ps", "--all", "--quiet", "--no-trunc", ...filters]); - const ids = (yield* list()).split("\n").filter((id) => id.length > 0); + // Only the initial listing can show the engine itself is unreachable; a later `rm` or + // leftover check failing is a per-container cleanup problem instead. + const ids = (yield* list().pipe( + Effect.mapError((cause) => + engineUnreachable(cause) + ? new ContainerError({ + operation: cause.operation, + message: cause.message, + cause: cause.cause, + reason: "engine-unavailable", + }) + : cause, + ), + )) + .split("\n") + .filter((id) => id.length > 0); yield* Effect.forEach( ids, (id) => @@ -575,3 +873,19 @@ export const removeStackContainers = Effect.fn("Container.removeStackContainers" return yield* errorFor("cleanup", `Stack containers remain: ${remaining}`); }), ); + +/** Shell command that removes the same containers as `removeStackContainers`, succeeding when none remain. */ +export const removeStackContainersCommand = (options: { + readonly engine: "docker" | "podman"; + readonly stackId: string; + readonly root: string; +}): string => { + const filters = [ + `label=com.supabase.stack=${options.stackId}`, + `label=com.supabase.stack-root=${options.root}`, + ] + .map((filter) => `--filter ${shellQuote(filter)}`) + .join(" "); + // `sh -c` keeps POSIX word splitting of `$ids` when pasted into shells like zsh that skip it. + return `sh -c ${shellQuote(`ids=$(${options.engine} ps --all --quiet --no-trunc ${filters}) && { [ -z "$ids" ] || ${options.engine} rm --force $ids; }`)}`; +}; diff --git a/packages/stack/src/runtime/Container.unit.test.ts b/packages/stack/src/runtime/Container.unit.test.ts new file mode 100644 index 0000000000..fbd132bb2e --- /dev/null +++ b/packages/stack/src/runtime/Container.unit.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect"; +import { ContainerError, makeHostGateway } from "./Container.ts"; + +const gatewayAddress = "192.168.65.254"; + +const gatedProbe = Effect.fnUntraced(function* (result: string | undefined) { + const release = yield* Deferred.make<void>(); + const runs = yield* Ref.make(0); + const probe = Ref.update(runs, (count) => count + 1).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(result), + ); + return { probe, runs, release: Deferred.succeed(release, undefined) }; +}); + +describe("host gateway", () => { + it.effect("prefetches without waiting and lets a later resolve await the same probe", () => + Effect.gen(function* () { + const gateway = yield* makeHostGateway; + const { probe, runs, release } = yield* gatedProbe(gatewayAddress); + + yield* gateway.prefetch(probe); + const waiting = yield* gateway.resolve(probe).pipe(Effect.forkChild); + yield* release; + + expect(yield* Fiber.join(waiting)).toBe(gatewayAddress); + expect(yield* gateway.resolve(probe)).toBe(gatewayAddress); + expect(yield* Ref.get(runs)).toBe(1); + }), + ); + + it.effect("retries once for a waiter whose background probe finds no target", () => + Effect.gen(function* () { + const gateway = yield* makeHostGateway; + const background = yield* gatedProbe(undefined); + const later = yield* gatedProbe(gatewayAddress); + + yield* gateway.prefetch(background.probe); + const waiting = yield* gateway + .resolve(later.probe) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* background.release; + yield* later.release; + + expect(yield* Fiber.join(waiting)).toBe(gatewayAddress); + expect(yield* Ref.get(later.runs)).toBe(1); + expect(yield* gateway.resolve(later.probe)).toBe(gatewayAddress); + expect(yield* Ref.get(later.runs)).toBe(1); + }), + ); + + it.effect("caches a probe failure for later resolves without probing again", () => + Effect.gen(function* () { + const gateway = yield* makeHostGateway; + const runs = yield* Ref.make(0); + const unsupported = new ContainerError({ operation: "host-gateway", message: "unsupported" }); + const probe = Ref.update(runs, (count) => count + 1).pipe( + Effect.andThen(Effect.fail(unsupported)), + ); + + expect(yield* Effect.flip(gateway.resolve(probe))).toBe(unsupported); + expect(yield* Effect.flip(gateway.resolve(probe))).toBe(unsupported); + expect(yield* Ref.get(runs)).toBe(1); + }), + ); + + it.effect("keeps the probe running when a waiting resolve is interrupted", () => + Effect.gen(function* () { + const gateway = yield* makeHostGateway; + const { probe, runs, release } = yield* gatedProbe(gatewayAddress); + + const waiting = yield* gateway + .resolve(probe) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.interrupt(waiting); + yield* release; + + expect(yield* gateway.resolve(probe)).toBe(gatewayAddress); + expect(yield* Ref.get(runs)).toBe(1); + }), + ); + + it.effect("interrupts its probe when the owning scope closes", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const gateway = yield* makeHostGateway.pipe(Scope.provide(scope)); + const interrupted = yield* Deferred.make<void>(); + + yield* gateway.prefetch( + Effect.never.pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))), + ); + yield* Scope.close(scope, Exit.void); + + expect(yield* Deferred.isDone(interrupted)).toBe(true); + expect(yield* gateway.resolve(Effect.never)).toBe("host-gateway"); + }), + ); +}); diff --git a/packages/stack/src/runtime/ContainerName.ts b/packages/stack/src/runtime/ContainerName.ts new file mode 100644 index 0000000000..a506ac8a27 --- /dev/null +++ b/packages/stack/src/runtime/ContainerName.ts @@ -0,0 +1,44 @@ +/** The stack fields that name a container and group it for Docker Desktop/OrbStack. */ +interface ContainerIdentityInput { + readonly stackId: string; + readonly service?: string; + readonly project?: string; +} + +const nameSegment = (value: string): string => + value.replaceAll(/[^a-zA-Z0-9_.-]+/gu, "-").slice(0, 40); + +// Compose project names allow only lowercase letters, digits, dashes, and underscores. +const composeSegment = (value: string): string => + value + .toLowerCase() + .replaceAll(/[^a-z0-9_-]+/gu, "-") + .slice(0, 40); + +/** Groups a stack's containers, helpers included, as one compose project. */ +export const composeProjectFor = (stackId: string, project: string | undefined): string => + [ + "supabase", + project === undefined ? "stack" : composeSegment(project) || "stack", + stackId.slice(0, 12).toLowerCase(), + ].join("-"); + +/** Names a container and sets compose grouping labels; one-shots get a `-task` segment. */ +export const identifyContainer = (spec: ContainerIdentityInput, token: string, oneOff: boolean) => { + const project = spec.project === undefined ? undefined : nameSegment(spec.project); + const service = spec.service === undefined ? undefined : nameSegment(spec.service); + const name = [ + "supabase", + project, + service, + oneOff ? "task" : undefined, + token.replaceAll("-", "").slice(0, 12), + ] + .filter((segment): segment is string => segment !== undefined && segment.length > 0) + .join("-"); + return { + name, + composeProject: composeProjectFor(spec.stackId, spec.project), + composeService: service ?? "task", + }; +}; diff --git a/packages/stack/src/runtime/ContainerName.unit.test.ts b/packages/stack/src/runtime/ContainerName.unit.test.ts new file mode 100644 index 0000000000..a663dc7a6b --- /dev/null +++ b/packages/stack/src/runtime/ContainerName.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { identifyContainer } from "./ContainerName.ts"; + +const stackId = "0123456789abcdef0123"; +const token = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"; + +describe("identifyContainer", () => { + it("names a service container after its project and service", () => { + expect( + identifyContainer({ stackId, project: "my-app", service: "studio" }, token, false), + ).toEqual({ + name: "supabase-my-app-studio-a1b2c3d4e5f6", + composeProject: "supabase-my-app-0123456789ab", + composeService: "studio", + }); + }); + + it("marks one-shot containers with a task segment but keeps the service for grouping", () => { + const identity = identifyContainer( + { stackId, project: "my-app", service: "auth" }, + token, + true, + ); + expect(identity.name).toBe("supabase-my-app-auth-task-a1b2c3d4e5f6"); + expect(identity.composeService).toBe("auth"); + }); + + it("keeps dotted folder names in the container name but not in the compose project", () => { + const identity = identifyContainer( + { stackId, project: "My.App", service: "rest" }, + token, + false, + ); + expect(identity.name).toBe("supabase-My.App-rest-a1b2c3d4e5f6"); + expect(identity.composeProject).toBe("supabase-my-app-0123456789ab"); + }); + + it("replaces characters outside the docker name alphabet and bounds the length", () => { + const identity = identifyContainer( + { stackId, project: `café ${"x".repeat(60)}`, service: "database" }, + token, + false, + ); + expect(identity.name).toMatch(/^supabase-caf-x+-database-a1b2c3d4e5f6$/u); + expect(identity.name.length).toBeLessThanOrEqual( + "supabase-".length + 40 + "-database-".length + 12, + ); + expect(identity.composeProject).toMatch(/^supabase-[a-z0-9_-]{1,40}-0123456789ab$/u); + }); + + it("falls back to generic segments when the project and service are unknown", () => { + expect(identifyContainer({ stackId }, token, true)).toEqual({ + name: "supabase-task-a1b2c3d4e5f6", + composeProject: "supabase-stack-0123456789ab", + composeService: "task", + }); + }); +}); diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 62222d3398..2b3ffce13e 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -138,11 +138,20 @@ export const ensureInternalDatabase = Effect.fn("PostgresDatabaseSession.ensureI openInternal: Effect.Effect<PostgresDatabaseSession, DatabaseBootstrapError, Scope.Scope>, ) { return yield* Effect.gen(function* () { - const databases = yield* postgres.query("SELECT 1 FROM pg_database WHERE datname = $1", [ - INTERNAL_DATABASE, - ]); - if (databases.length === 0) - yield* postgres.execute(`CREATE DATABASE ${INTERNAL_DATABASE} WITH OWNER postgres`); + const exists = postgres + .query("SELECT 1 FROM pg_database WHERE datname = $1", [INTERNAL_DATABASE]) + .pipe(Effect.map((databases) => databases.length > 0)); + // A concurrent creator, such as an abandoned earlier attempt, can win between check and create. + if (!(yield* exists)) + yield* postgres + .execute(`CREATE DATABASE ${INTERNAL_DATABASE} WITH OWNER postgres`) + .pipe( + Effect.catch((error) => + exists.pipe( + Effect.flatMap((created) => (created ? Effect.void : Effect.fail(error))), + ), + ), + ); const internal = yield* openInternal; for (const schema of INTERNAL_SCHEMAS) { diff --git a/packages/stack/src/runtime/Session.ts b/packages/stack/src/runtime/Session.ts new file mode 100644 index 0000000000..bc27d3bd44 --- /dev/null +++ b/packages/stack/src/runtime/Session.ts @@ -0,0 +1,120 @@ +import { Cause, Effect, Exit, Fiber, PubSub, Ref, Scope, Stream } from "effect"; +import { failureMessage } from "../internal/failure-message.ts"; +import { ServiceError, type RuntimeSession } from "../Service.ts"; +import type { ContainerProcess } from "./Container.ts"; + +/** Wraps an arbitrary failure as a `ServiceError`, special-casing Effect timeouts for a clearer message. */ +export const mapToServiceError = (operation: string, cause: unknown): ServiceError => + cause instanceof ServiceError + ? cause + : new ServiceError({ + operation, + message: Cause.isTimeoutError(cause) + ? `Service ${operation} timed out` + : failureMessage(cause), + cause, + }); + +/** A descendant outside the process group can keep stderr open after the launcher exits. */ +const stderrTailReady = (drained: Fiber.Fiber<void>) => + Fiber.await(drained).pipe( + Effect.asVoid, + Effect.raceFirst(Effect.sleep("1 second")), + Effect.ignore, + ); + +/** + * Settles a runtime's exit as a `ServiceError` exit, optionally waiting briefly for a captured + * stderr tail to drain and folding it into the failure message. + */ +export const processExit = <E extends { readonly message: string }>( + exitCode: Effect.Effect<number, E>, + describe: (code: number) => string, + stderr?: { + readonly tail: Ref.Ref<string>; + readonly drained: Fiber.Fiber<void>; + }, +): Effect.Effect<Exit.Exit<void, ServiceError>> => + (stderr === undefined + ? exitCode + : exitCode.pipe(Effect.tap(() => stderrTailReady(stderr.drained))) + ).pipe( + Effect.flatMap((code) => + Number(code) === 0 + ? Effect.void + : (stderr === undefined ? Effect.succeed("") : Ref.get(stderr.tail)).pipe( + Effect.flatMap((text) => { + const detail = text.trim(); + return Effect.fail( + new ServiceError({ + operation: "exit", + message: + detail.length === 0 + ? describe(Number(code)) + : `${describe(Number(code))}: ${detail}`, + }), + ); + }), + ), + ), + Effect.mapError((cause) => mapToServiceError("exit", cause)), + Effect.exit, + ); + +/** + * Forks stdout/stderr drains that publish to `logs`; returns the stderr fiber so callers can await + * drain completion (e.g. before finalizing exit). Optionally captures a rolling stderr tail for + * folding into exit failure messages. + */ +export const publishProcessLogs = ( + process: { + readonly stdout: Stream.Stream<Uint8Array, unknown>; + readonly stderr: Stream.Stream<Uint8Array, unknown>; + }, + logs: PubSub.PubSub<{ readonly stream: "stdout" | "stderr"; readonly bytes: Uint8Array }>, + scope: Scope.Closeable, + stderrTail?: Ref.Ref<string>, +): Effect.Effect<Fiber.Fiber<void>> => { + const drain = (stream: Stream.Stream<Uint8Array, unknown>, name: "stdout" | "stderr") => { + const decoder = name === "stderr" && stderrTail !== undefined ? new TextDecoder() : undefined; + const appendTail = (text: string) => + text.length === 0 || stderrTail === undefined + ? Effect.void + : Ref.update(stderrTail, (current) => (current + text).slice(-4096)); + return stream.pipe( + Stream.runForEach((bytes) => + Effect.gen(function* () { + if (decoder !== undefined) yield* appendTail(decoder.decode(bytes, { stream: true })); + yield* PubSub.publish(logs, { stream: name, bytes }); + }), + ), + Effect.andThen( + decoder === undefined + ? Effect.void + : Effect.sync(() => decoder.decode()).pipe(Effect.flatMap(appendTail)), + ), + Effect.catch((cause) => Effect.logError(cause)), + ); + }; + return Effect.gen(function* () { + yield* Effect.forkIn(drain(process.stdout, "stdout"), scope); + return yield* Effect.forkIn(drain(process.stderr, "stderr"), scope); + }); +}; + +/** Wraps a container process into a `RuntimeSession`; `discard` is included only when requested. */ +export const runtimeSessionFromContainer = ( + process: ContainerProcess, + describeExit: (code: number) => string, + options?: { readonly discard?: boolean }, +): RuntimeSession => ({ + health: Effect.void, + exit: processExit(process.exitCode, describeExit), + stop: process.stop.pipe(Effect.mapError((cause) => mapToServiceError("stop", cause))), + ...(options?.discard === true + ? { + discard: process.discard.pipe(Effect.mapError((cause) => mapToServiceError("stop", cause))), + } + : {}), + remove: process.remove.pipe(Effect.mapError((cause) => mapToServiceError("remove", cause))), +}); diff --git a/packages/stack/src/runtime/Session.unit.test.ts b/packages/stack/src/runtime/Session.unit.test.ts new file mode 100644 index 0000000000..fcf290afef --- /dev/null +++ b/packages/stack/src/runtime/Session.unit.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause } from "effect"; +import { mapToServiceError } from "./Session.ts"; + +describe("mapToServiceError", () => { + it("names an error whose cause carries no message", () => { + const failure = mapToServiceError("stop", new Cause.UnknownError(undefined)); + + expect(failure.message).toBe("UnknownError"); + }); + + it("describes a wrapped error without a message by its cause", () => { + const cause = new Cause.UnknownError(new Error("container is gone")); + const failure = mapToServiceError("stop", cause); + + expect(failure.message).toBe("container is gone"); + }); +}); diff --git a/packages/stack/src/runtime/container-image-mirror.integration.test.ts b/packages/stack/src/runtime/container-image-mirror.integration.test.ts index 7cb91acfd9..e528d9d33e 100644 --- a/packages/stack/src/runtime/container-image-mirror.integration.test.ts +++ b/packages/stack/src/runtime/container-image-mirror.integration.test.ts @@ -11,14 +11,19 @@ const secondMirror = "registry.test/supabase/cli/postgrest:v16.2"; /** * Docker stand-in: `image inspect` reports `local`, `pull` is rate-limited for the counted - * `throttled` attempts and then succeeds for `pullable`, `create` refuses. + * `throttled` attempts, answers each queued `failures` message once, and then succeeds for + * `pullable`; `create` refuses. */ const fakeEngine = (options: { readonly pullable: ReadonlyArray<string>; readonly throttled?: Readonly<Record<string, number>>; + readonly failures?: Readonly<Record<string, ReadonlyArray<string>>>; }) => { const pullable = new Set(options.pullable); const throttled = new Map(Object.entries(options.throttled ?? {})); + const failures = new Map( + Object.entries(options.failures ?? {}).map(([ref, messages]) => [ref, [...messages]]), + ); const local = new Set<string>(); const commands: string[][] = []; const handle = (exitCode: number, stdout = "", stderr = "") => @@ -42,6 +47,9 @@ const fakeEngine = (options: { const ref = args.at(-1) ?? ""; if (args[0] === "image") return Effect.succeed(handle(0, local.has(ref) ? "sha256:1" : "")); if (args[0] === "pull") { + const queued = failures.get(ref); + const message = queued?.shift(); + if (message !== undefined) return Effect.succeed(handle(1, "", message)); const remaining = throttled.get(ref) ?? 0; if (remaining > 0) { throttled.set(ref, remaining - 1); @@ -182,6 +190,47 @@ describe("container image mirror", () => { }).pipe(Effect.provide(Layer.merge(NodeServices.layer, engine.layer))); }); + it.effect("retries a pull after a transient registry transport failure", () => { + const engine = fakeEngine({ + pullable: [primary], + failures: { + [primary]: [`Get "https://ghcr.io/v2/supabase/cli/pgmeta/manifests/sha256:1": EOF`], + }, + }); + return Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + const prepared = yield* runtime.prepare(primary).pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + yield* Fiber.join(prepared); + expect(engine.commands.filter((args) => args[0] === "pull")).toHaveLength(2); + expect(engine.local.has(primary)).toBe(true); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, engine.layer))); + }); + + const permanentPullFailures = [ + "manifest unknown", + `manifest for ${primary}/eof:latest not found: manifest unknown`, + `error during connect: Get "http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.47/images/create?fromImage=eof": EOF`, + ]; + + for (const message of permanentPullFailures) { + it.effect(`does not retry a permanent pull failure: ${message}`, () => { + const engine = fakeEngine({ + pullable: [], + failures: { [primary]: [message] }, + }); + return Effect.gen(function* () { + const runtime = yield* makeContainerRuntime({ engine: "docker", root: "." }); + const failed = yield* runtime.prepare(primary).pipe(Effect.exit); + const error = Exit.isFailure(failed) + ? Option.getOrUndefined(Cause.findErrorOption(failed.cause)) + : undefined; + expect(error?.message).toContain(message); + expect(engine.commands.filter((args) => args[0] === "pull")).toHaveLength(1); + }).pipe(Effect.provide(Layer.merge(NodeServices.layer, engine.layer))); + }); + } + it.effect("reports the rate limit after five throttled attempts", () => { const engine = fakeEngine({ pullable: [primary], throttled: { [primary]: 10 } }); return Effect.gen(function* () { diff --git a/packages/stack/src/runtime/postgres-database-session.integration.test.ts b/packages/stack/src/runtime/postgres-database-session.integration.test.ts index 2b41980f38..1a4de7c5b4 100644 --- a/packages/stack/src/runtime/postgres-database-session.integration.test.ts +++ b/packages/stack/src/runtime/postgres-database-session.integration.test.ts @@ -147,6 +147,72 @@ describe("Postgres database session", () => { }), ); + it.live("accepts an internal database that a concurrent creator publishes first", () => + Effect.gen(function* () { + const calls: Array<string> = []; + let published = false; + const postgres = makeDatabaseSessionFromSqlClient({ + unsafe: (sql) => + Effect.suspend(() => { + calls.push(sql); + if (sql.startsWith("CREATE DATABASE")) { + published = true; + return Effect.fail( + new SqlError({ + reason: new UnknownError({ + message: 'database "_supabase" already exists', + cause: new Error("duplicate database"), + }), + }), + ); + } + return Effect.succeed( + sql.startsWith("SELECT 1 FROM pg_database") && published ? [{ exists: true }] : [], + ); + }), + withTransaction: <A, E, R>(effect: Effect.Effect<A, E, R>) => effect, + }); + const internal = makeDatabaseSessionFromSqlClient({ + unsafe: (sql) => + Effect.sync(() => { + calls.push(sql); + return []; + }), + withTransaction: <A, E, R>(effect: Effect.Effect<A, E, R>) => effect, + }); + + yield* Effect.scoped(ensureInternalDatabase(postgres, Effect.succeed(internal))); + + expect(calls).toContain("CREATE DATABASE _supabase WITH OWNER postgres"); + expect(calls).toContain("CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION postgres"); + }), + ); + + it.live("fails when the internal database is still missing after a failed create", () => + Effect.gen(function* () { + const postgres = makeDatabaseSessionFromSqlClient({ + unsafe: (sql) => + sql.startsWith("CREATE DATABASE") + ? Effect.fail( + new SqlError({ + reason: new UnknownError({ + message: "permission denied", + cause: new Error("permission denied"), + }), + }), + ) + : Effect.succeed([]), + withTransaction: <A, E, R>(effect: Effect.Effect<A, E, R>) => effect, + }); + + const error = yield* Effect.scoped( + ensureInternalDatabase(postgres, Effect.die("internal database must not open")), + ).pipe(Effect.flip); + + expect(error).toBeInstanceOf(DatabaseBootstrapError); + }), + ); + it.live("reuses an existing internal database without recreating it", () => Effect.gen(function* () { const calls: Array<string> = []; diff --git a/packages/stack/src/runtime/postgres-user.ts b/packages/stack/src/runtime/postgres-user.ts index 3f41b876bf..975a8e1c87 100644 --- a/packages/stack/src/runtime/postgres-user.ts +++ b/packages/stack/src/runtime/postgres-user.ts @@ -21,15 +21,23 @@ const sandboxes = [ detect: (env: Environment) => (env["CLAUDECODE"] ?? "") !== "" && env["IS_SANDBOX"] === "yes", preferredUsers: ["ubuntu"], }, + { + // Set only in Modal Sandboxes, not Functions; images have no fixed account, so rely on the scan. + name: "Modal Sandbox", + detect: (env: Environment) => (env["MODAL_SANDBOX_ID"] ?? "") !== "", + preferredUsers: [], + }, ]; -const environmentNames = [NATIVE_POSTGRES_USER_ENV, "CLAUDECODE", "IS_SANDBOX"]; +const environmentNames = [NATIVE_POSTGRES_USER_ENV, "CLAUDECODE", "IS_SANDBOX", "MODAL_SANDBOX_ID"]; export type NativePostgresUser = | { readonly _tag: "NotNeeded" } | { readonly _tag: "StepDown"; readonly user: PasswdEntry; readonly message: string } | { readonly _tag: "Unavailable"; readonly message: string; readonly suggestion: string }; -const suggestion = `Set ${NATIVE_POSTGRES_USER_ENV}=<user> to run PostgreSQL as a non-root user.`; +const suggestion = + `Set ${NATIVE_POSTGRES_USER_ENV}=<user> to run PostgreSQL as a non-root user, creating one if ` + + "needed (on Linux, for example `useradd --system --user-group supabase-postgres`)."; const unavailable = (message: string): NativePostgresUser => ({ _tag: "Unavailable", message, @@ -183,13 +191,14 @@ export const openNativePostgresInstance = Effect.fn("NativePostgresUser.openInst /** The bundle's first-boot init runs `chmod +x` on this script, which only its owner may do. */ const GETKEY_SCRIPT = "share/supabase-cli/config/pgsodium_getkey.sh"; -/** Hands the instance data, key, socket, and the bundle's getkey script to the PostgreSQL user. */ +/** Hands the instance data, key, socket, HBA file, and the bundle's getkey script to the PostgreSQL user. */ export const handOverNativePostgresFiles = Effect.fn("NativePostgresUser.handOverFiles")(function* ( user: PasswdEntry, paths: { readonly dataPath: string; readonly rootKeyPath: string; readonly socketPath: string; + readonly hbaPath: string; readonly bundleRoot: string; readonly executable: string; }, @@ -199,7 +208,7 @@ export const handOverNativePostgresFiles = Effect.fn("NativePostgresUser.handOve const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const getkey = path.join(paths.bundleRoot, GETKEY_SCRIPT); const hasGetkey = yield* fs.exists(getkey); - const targets = [paths.dataPath, paths.rootKeyPath, paths.socketPath]; + const targets = [paths.dataPath, paths.rootKeyPath, paths.socketPath, paths.hbaPath]; if (hasGetkey) { yield* requireOwnedByRootOr(user, getkey); targets.push(getkey); diff --git a/packages/stack/src/runtime/postgres-user.unit.test.ts b/packages/stack/src/runtime/postgres-user.unit.test.ts index bef7faf9dd..99f0b03e15 100644 --- a/packages/stack/src/runtime/postgres-user.unit.test.ts +++ b/packages/stack/src/runtime/postgres-user.unit.test.ts @@ -18,6 +18,7 @@ const nobody = entry("nobody", 65_534); const ubuntu = entry("ubuntu", 1000); const dev = entry("dev", 1001); const sandbox = { CLAUDECODE: "1", IS_SANDBOX: "yes" }; +const modal = { MODAL_SANDBOX_ID: "sb-01" }; const override = (name: string) => ({ [NATIVE_POSTGRES_USER_ENV]: name }); const asRoot = (env: Record<string, string>, passwd: ReadonlyArray<PasswdEntry>) => @@ -82,7 +83,7 @@ describe("resolvePostgresUser", () => { expect(asRoot({ CLAUDECODE: "1" }, [root, ubuntu])).toEqual({ _tag: "Unavailable", message: "PostgreSQL cannot be run as root", - suggestion: `Set ${NATIVE_POSTGRES_USER_ENV}=<user> to run PostgreSQL as a non-root user.`, + suggestion: expect.stringContaining(`Set ${NATIVE_POSTGRES_USER_ENV}=<user>`), }); }); @@ -92,4 +93,20 @@ describe("resolvePostgresUser", () => { "Running as root in Claude Code sandbox; PostgreSQL will run as preferred user 'ubuntu' (uid 1000)", }); }); + + it("detects a Modal Sandbox and scans for its node account, since it has no preferred user", () => { + expect(userOf(modal, [root, entry("node", 1000)])).toBe("node"); + }); + + it("suggests creating an account when a Modal Sandbox has none usable", () => { + expect(asRoot(modal, [root])).toEqual({ + _tag: "Unavailable", + message: "PostgreSQL cannot be run as root and Modal Sandbox has no non-root user", + suggestion: expect.stringContaining("useradd --system --user-group supabase-postgres"), + }); + }); + + it("does not detect a sandbox from MODAL_TASK_ID alone", () => { + expect(userOf({ MODAL_TASK_ID: "ta-01" }, [root, entry("node", 1000)])).toBe("Unavailable"); + }); }); diff --git a/packages/stack/src/services/Analytics.ts b/packages/stack/src/services/Analytics.ts index 93273d6c30..a8d25fd4b2 100644 --- a/packages/stack/src/services/Analytics.ts +++ b/packages/stack/src/services/Analytics.ts @@ -1,10 +1,10 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), backend: Schema.optionalKey(Schema.Literal("postgres")), apiKey: Schema.optionalKey(Schema.String), }); @@ -25,9 +25,14 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ env: (creation, endpoints, container) => Effect.gen(function* () { const http = endpoints.get("http"); - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput( + "analytics", + "databaseUrl", + creation.config.databaseUrl, + ); + const db = yield* databaseConnection(databaseUrl); return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, ...(http === undefined ? {} : { PORT: String(http.port), PHX_HTTP_PORT: String(http.port) }), @@ -43,11 +48,11 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ ...(creation.config.apiKey === undefined ? {} : { LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey }), - POSTGRES_BACKEND_URL: creation.config.databaseUrl, + POSTGRES_BACKEND_URL: databaseUrl, POSTGRES_BACKEND_SCHEMA: "_analytics", }; }), args: () => Effect.succeed(["start"]), mounts: () => Effect.succeed([]), - startup: [{ args: [], nativeExecutable: "prepare", skipInContainer: true }], + startupCommands: [{ args: [], nativeExecutable: "prepare", skipInContainer: true }], }); diff --git a/packages/stack/src/services/Auth.ts b/packages/stack/src/services/Auth.ts index 850f672649..c63474cd2c 100644 --- a/packages/stack/src/services/Auth.ts +++ b/packages/stack/src/services/Auth.ts @@ -2,8 +2,8 @@ import { Effect, Schema } from "effect"; import { ServiceError } from "../Service.ts"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; import { DEFAULT_SIGNING_KEY } from "../Defaults.ts"; -import { localJwtSecret } from "./ServiceConfig.ts"; -import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +import { localJwtSecret, requiredInput } from "./ServiceConfig.ts"; +import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; import { Settings, settingsEnvironment } from "./AuthSettings.ts"; @@ -17,7 +17,7 @@ const Smtp = Schema.Struct({ }); export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), siteUrl: Schema.optionalKey(Schema.String), apiExternalUrl: Schema.optionalKey(Schema.String), externalApiUrl: Schema.optionalKey(Schema.String), @@ -41,6 +41,12 @@ export const Creation = serviceCreation("auth", Config, Endpoints); export interface Creation extends Schema.Schema.Type<typeof Creation> {} +export const initializationCommand = { + args: ["migrate"], + nativeExecutable: "auth", + containerEntrypoint: "/usr/local/bin/auth", +} satisfies StartupCommand & { readonly containerEntrypoint: string }; + const smtpEnvironment = Effect.fn("Auth.smtpEnvironment")((value: string) => Effect.try({ try: () => { @@ -57,84 +63,85 @@ const smtpEnvironment = Effect.fn("Auth.smtpEnvironment")((value: string) => }), ); +const defaultJwtKeys = JSON.stringify([DEFAULT_SIGNING_KEY]); + export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ service: "auth", executable: "bin/auth", ports: { http: 9999 }, healthPath: "/health", - env: (creation, endpoints, container) => { - const http = endpoints.get("http"); - const apiExternalUrl = creation.config.apiExternalUrl; - const authExternalUrl = - creation.config.authExternalUrl !== undefined && creation.config.authExternalUrl.length > 0 - ? creation.config.authExternalUrl - : apiExternalUrl !== undefined && apiExternalUrl.length > 0 - ? `${apiExternalUrl.replace(/\/+$/u, "")}/auth/v1` - : (creation.config.externalApiUrl ?? creation.config.siteUrl ?? "http://localhost:3000"); - const jwtIssuer = creation.config.settings?.jwtIssuer || authExternalUrl; - const base = { - GOTRUE_API_HOST: container ? "0.0.0.0" : "127.0.0.1", - GOTRUE_DB_DATABASE_URL: creation.config.databaseUrl, - DATABASE_URL: creation.config.databaseUrl, - GOTRUE_DB_DRIVER: "postgres", - GOTRUE_SITE_URL: creation.config.siteUrl ?? "http://localhost:3000", - GOTRUE_JWT_SECRET: creation.config.jwtSecret ?? localJwtSecret, - GOTRUE_JWT_KEYS: creation.config.gotrueJwtKeys ?? JSON.stringify([DEFAULT_SIGNING_KEY]), - GOTRUE_JWT_VALIDMETHODS: "HS256,RS256,ES256", - GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", - GOTRUE_JWT_AUD: "authenticated", - GOTRUE_JWT_ADMIN_ROLES: "service_role", - GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", - GOTRUE_MAILER_AUTOCONFIRM: "true", - GOTRUE_DISABLE_SIGNUP: String(creation.config.disableSignup ?? false), - GOTRUE_RATE_LIMIT_EMAIL_SENT: "360000", - ...(http === undefined ? {} : { GOTRUE_API_PORT: String(http.port) }), - API_EXTERNAL_URL: authExternalUrl, - GOTRUE_JWT_ISSUER: jwtIssuer, - GOTRUE_MAILER_URLPATHS_INVITE: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, - GOTRUE_MAILER_URLPATHS_CONFIRMATION: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, - GOTRUE_MAILER_URLPATHS_RECOVERY: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, - ...(creation.config.jwtExpiry === undefined - ? {} - : { GOTRUE_JWT_EXP: String(creation.config.jwtExpiry) }), - ...settingsEnvironment(creation.config.settings, jwtIssuer), - }; - const smtp = creation.config.smtp; - if (smtp !== undefined) { - return Effect.succeed({ + env: (creation, endpoints, container) => + Effect.gen(function* () { + const databaseUrl = yield* requiredInput("auth", "databaseUrl", creation.config.databaseUrl); + const http = endpoints.get("http"); + const apiExternalUrl = creation.config.apiExternalUrl; + const authExternalUrl = + creation.config.authExternalUrl !== undefined && creation.config.authExternalUrl.length > 0 + ? creation.config.authExternalUrl + : apiExternalUrl !== undefined && apiExternalUrl.length > 0 + ? `${apiExternalUrl.replace(/\/+$/u, "")}/auth/v1` + : (creation.config.externalApiUrl ?? + creation.config.siteUrl ?? + "http://localhost:3000"); + const jwtIssuer = creation.config.settings?.jwtIssuer || authExternalUrl; + const base = { + GOTRUE_API_HOST: container ? "0.0.0.0" : "127.0.0.1", + GOTRUE_DB_DATABASE_URL: databaseUrl, + DATABASE_URL: databaseUrl, + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_SITE_URL: creation.config.siteUrl ?? "http://localhost:3000", + GOTRUE_JWT_SECRET: creation.config.jwtSecret ?? localJwtSecret, + GOTRUE_JWT_KEYS: creation.config.gotrueJwtKeys ?? defaultJwtKeys, + GOTRUE_JWT_VALIDMETHODS: "HS256,RS256,ES256", + GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", + GOTRUE_JWT_AUD: "authenticated", + GOTRUE_JWT_ADMIN_ROLES: "service_role", + GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", + GOTRUE_MAILER_AUTOCONFIRM: "true", + GOTRUE_DISABLE_SIGNUP: String(creation.config.disableSignup ?? false), + GOTRUE_RATE_LIMIT_EMAIL_SENT: "360000", + ...(http === undefined ? {} : { GOTRUE_API_PORT: String(http.port) }), + API_EXTERNAL_URL: authExternalUrl, + GOTRUE_JWT_ISSUER: jwtIssuer, + GOTRUE_MAILER_URLPATHS_INVITE: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, + GOTRUE_MAILER_URLPATHS_CONFIRMATION: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, + GOTRUE_MAILER_URLPATHS_RECOVERY: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: `${authExternalUrl.replace(/\/+$/u, "")}/verify`, + ...(creation.config.jwtExpiry === undefined + ? {} + : { GOTRUE_JWT_EXP: String(creation.config.jwtExpiry) }), + ...settingsEnvironment(creation.config.settings, jwtIssuer), + }; + const smtp = creation.config.smtp; + if (smtp !== undefined) { + return { + ...base, + GOTRUE_SMTP_HOST: smtp.host, + GOTRUE_SMTP_PORT: String(smtp.port), + GOTRUE_SMTP_USER: smtp.user, + GOTRUE_SMTP_PASS: smtp.pass, + GOTRUE_SMTP_ADMIN_EMAIL: smtp.adminEmail, + ...(creation.config.settings?.rateLimit?.email_sent === undefined + ? {} + : { + GOTRUE_RATE_LIMIT_EMAIL_SENT: String(creation.config.settings.rateLimit.email_sent), + }), + ...(smtp.senderName === undefined ? {} : { GOTRUE_SMTP_SENDER_NAME: smtp.senderName }), + }; + } + if (creation.config.smtpUrl === undefined) return base; + return { ...base, - GOTRUE_SMTP_HOST: smtp.host, - GOTRUE_SMTP_PORT: String(smtp.port), - GOTRUE_SMTP_USER: smtp.user, - GOTRUE_SMTP_PASS: smtp.pass, - GOTRUE_SMTP_ADMIN_EMAIL: smtp.adminEmail, - ...(creation.config.settings?.rateLimit?.email_sent === undefined + ...(yield* smtpEnvironment(creation.config.smtpUrl)), + ...(creation.config.smtpAdminEmail === undefined + ? {} + : { GOTRUE_SMTP_ADMIN_EMAIL: creation.config.smtpAdminEmail }), + ...(creation.config.smtpSenderName === undefined ? {} - : { - GOTRUE_RATE_LIMIT_EMAIL_SENT: String(creation.config.settings.rateLimit.email_sent), - }), - ...(smtp.senderName === undefined ? {} : { GOTRUE_SMTP_SENDER_NAME: smtp.senderName }), - }); - } - return creation.config.smtpUrl === undefined - ? Effect.succeed(base) - : smtpEnvironment(creation.config.smtpUrl).pipe( - Effect.map((value) => ({ - ...base, - ...value, - ...(creation.config.smtpAdminEmail === undefined - ? {} - : { GOTRUE_SMTP_ADMIN_EMAIL: creation.config.smtpAdminEmail }), - ...(creation.config.smtpSenderName === undefined - ? {} - : { GOTRUE_SMTP_SENDER_NAME: creation.config.smtpSenderName }), - })), - ); - }, + : { GOTRUE_SMTP_SENDER_NAME: creation.config.smtpSenderName }), + }; + }), args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), - startup: [ - { args: ["migrate"], nativeExecutable: "auth", containerEntrypoint: "/usr/local/bin/auth" }, - ], + startupCommands: [initializationCommand], }); diff --git a/packages/stack/src/services/AuthSettings.ts b/packages/stack/src/services/AuthSettings.ts index dfce740c25..9bf72c2e0d 100644 --- a/packages/stack/src/services/AuthSettings.ts +++ b/packages/stack/src/services/AuthSettings.ts @@ -159,6 +159,11 @@ export const Settings = Schema.Struct({ }); export interface Settings extends Schema.Schema.Type<typeof Settings> {} +const zeroDuration = /^[+-]?(?:0|(?:(?:0+(?:\.0*)?|\.0+)(?:ns|us|µs|μs|ms|s|m|h))+)$/u; + +const sessionLimit = (duration: string | undefined) => + zeroDuration.test(duration ?? "") ? undefined : duration; + /** Converts Auth policy into the upstream process configuration. */ export const settingsEnvironment = ( settings: Settings | undefined, @@ -265,8 +270,8 @@ export const settingsEnvironment = ( put("GOTRUE_MFA_PHONE_OTP_LENGTH", mfa.phone.otp_length); put("GOTRUE_MFA_PHONE_MAX_FREQUENCY", mfa.phone.max_frequency); } - put("GOTRUE_SESSIONS_TIMEBOX", settings.sessions?.timebox); - put("GOTRUE_SESSIONS_INACTIVITY_TIMEOUT", settings.sessions?.inactivity_timeout); + put("GOTRUE_SESSIONS_TIMEBOX", sessionLimit(settings.sessions?.timebox)); + put("GOTRUE_SESSIONS_INACTIVITY_TIMEOUT", sessionLimit(settings.sessions?.inactivity_timeout)); for (const [name, provider] of Object.entries(settings.external ?? {})) { const prefix = `GOTRUE_EXTERNAL_${name.toUpperCase()}`; put(`${prefix}_ENABLED`, provider.enabled); diff --git a/packages/stack/src/services/AuthSettings.unit.test.ts b/packages/stack/src/services/AuthSettings.unit.test.ts index c440e175e6..82ab1f8cc8 100644 --- a/packages/stack/src/services/AuthSettings.unit.test.ts +++ b/packages/stack/src/services/AuthSettings.unit.test.ts @@ -98,4 +98,20 @@ describe("Auth settings environment", () => { expect(env["GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED"]).toBeUndefined(); expect(env["GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION"]).toBeUndefined(); }); + + it("omits zero session limits and forwards every other value", () => { + const env = (sessions: { timebox: string; inactivity_timeout: string }) => + settingsEnvironment(Schema.decodeSync(Settings)({ sessions }), "https://issuer.example"); + + const unlimited = env({ timebox: "0s", inactivity_timeout: "0h0m0s" }); + const limited = env({ timebox: "24h", inactivity_timeout: "8h" }); + const invalid = env({ timebox: "never", inactivity_timeout: "-1h" }); + + expect(unlimited["GOTRUE_SESSIONS_TIMEBOX"]).toBeUndefined(); + expect(unlimited["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBeUndefined(); + expect(limited["GOTRUE_SESSIONS_TIMEBOX"]).toBe("24h"); + expect(limited["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBe("8h"); + expect(invalid["GOTRUE_SESSIONS_TIMEBOX"]).toBe("never"); + expect(invalid["GOTRUE_SESSIONS_INACTIVITY_TIMEOUT"]).toBe("-1h"); + }); }); diff --git a/packages/stack/src/services/Catalog.ts b/packages/stack/src/services/Catalog.ts index 37484f2ea5..64e7828df5 100644 --- a/packages/stack/src/services/Catalog.ts +++ b/packages/stack/src/services/Catalog.ts @@ -30,11 +30,11 @@ import { type ProcessRecipeResult, } from "./Recipe.ts"; import { makeProcessRecipe, type ProcessDependencies } from "./ProcessRecipe.ts"; +import { missingInput } from "./ServiceConfig.ts"; +import type { SnapshotScope } from "./DatabaseSnapshot.ts"; import { slimImageMirrors, type ServiceKind } from "../Artifacts.ts"; import type { ServiceInstanceContext } from "../Service.ts"; -export type { CatalogLog } from "./Recipe.ts"; - const endpointSchemas = [ ["database", DatabaseEndpoints], ["rest", Rest.Endpoints], @@ -91,6 +91,31 @@ export const ServiceCreation = Schema.Union([ Pooler.Creation, ]); export type ServiceCreation = Schema.Schema.Type<typeof ServiceCreation>; + +/** Inputs a service cannot launch without; a composition binding or the caller supplies them. */ +const requiredInputs: { readonly [K in ServiceKind]?: ReadonlyArray<string> } = { + rest: ["databaseUrl"], + auth: ["databaseUrl"], + realtime: ["databaseUrl"], + storage: ["databaseUrl"], + pgmeta: ["databaseUrl"], + analytics: ["databaseUrl"], + pooler: ["databaseUrl"], + vector: ["analyticsUrl"], +}; + +/** Rejects a creation that lacks a required input before any lifecycle change. */ +export const requireInputs = ( + creation: ServiceCreation, +): Effect.Effect<ServiceCreation, ServiceError> => { + const config = new Map(Object.entries(creation.config)); + const missing = (requiredInputs[creation.service] ?? []).find( + (input) => config.get(input) === undefined, + ); + return missing === undefined + ? Effect.succeed(creation) + : Effect.fail(missingInput(creation.service, missing)); +}; const DatabaseCreationInput = serviceCreation( "database", Schema.Struct({ @@ -142,20 +167,22 @@ const serviceError = (operation: string, cause: unknown) => const widen = <C extends { readonly service: ServiceKind }>( isCreation: (value: unknown) => value is C, definition: ServiceDefinition<C>, -): ServiceDefinition<ServiceCreation> => ({ - prepare: (candidate) => - isCreation(candidate) - ? (definition.prepare?.(candidate) ?? Effect.void) - : Effect.fail(serviceError("prepare", "Service kind cannot change during restart")), - launch: (context) => - isCreation(context.config) - ? definition.launch({ ...context, config: context.config }) - : Effect.fail(serviceError("launch", "Service kind cannot change during restart")), - removeData: (context) => - isCreation(context.config) - ? definition.removeData({ ...context, config: context.config }) - : Effect.fail(serviceError("destroy", "Service kind cannot change during restart")), -}); +): ServiceDefinition<ServiceCreation> => { + return { + prepare: (candidate) => + isCreation(candidate) + ? (definition.prepare?.(candidate) ?? Effect.void) + : Effect.fail(serviceError("prepare", "Service kind cannot change during restart")), + launch: (context) => + isCreation(context.config) + ? definition.launch({ ...context, config: context.config }) + : Effect.fail(serviceError("launch", "Service kind cannot change during restart")), + removeData: (context) => + isCreation(context.config) + ? definition.removeData({ ...context, config: context.config }) + : Effect.fail(serviceError("destroy", "Service kind cannot change during restart")), + }; +}; const catalogRecipe = <C extends ServiceCreation>( creation: C, @@ -213,10 +240,10 @@ const databaseRecipe = ( : Effect.fail( new CatalogError({ operation: "reset", message: "Service kind cannot change" }), ), - saveDatabaseSnapshot: (context, key) => + saveDatabaseSnapshot: (context, key, scope) => context.config.service === "database" ? component - .saveSnapshot({ ...context, config: context.config.config }, key) + .saveSnapshot({ ...context, config: context.config.config }, key, scope) .pipe( Effect.mapError( (cause) => @@ -226,10 +253,10 @@ const databaseRecipe = ( : Effect.fail( new CatalogError({ operation: "snapshot-save", message: "Service kind cannot change" }), ), - restoreDatabaseSnapshot: (context, key) => + restoreDatabaseSnapshot: (context, key, scope) => context.config.service === "database" ? component - .restoreSnapshot({ ...context, config: context.config.config }, key) + .restoreSnapshot({ ...context, config: context.config.config }, key, scope) .pipe( Effect.mapError( (cause) => @@ -296,10 +323,12 @@ export const makeServiceRecipe = Effect.fn("Catalog.makeServiceRecipe")( const component = yield* makeDatabase({ stackId: options.stackId, instanceId: options.instanceId, + project: options.project, root: options.root, cacheRoot: options.cacheRoot, runtime: options.runtime, ...(options.helpers === undefined ? {} : { helpers: options.helpers }), + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), }).pipe( Effect.mapError( (cause) => @@ -320,6 +349,7 @@ export const makeServiceRecipe = Effect.fn("Catalog.makeServiceRecipe")( engine: options.runtime, root: options.root, imageMirrors: slimImageMirrors, + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), }); const deps: ProcessDependencies = { fs, path, crypto, client, spawner, container }; switch (creation.service) { @@ -406,9 +436,11 @@ export type CatalogRecipe = RecipeCatalogRecipe<ServiceCreation> & { readonly saveDatabaseSnapshot?: ( context: ServiceInstanceContext<ServiceCreation>, key: string, + scope: SnapshotScope, ) => Effect.Effect<void, CatalogError>; readonly restoreDatabaseSnapshot?: ( context: ServiceInstanceContext<ServiceCreation>, key: string, + scope: SnapshotScope, ) => Effect.Effect<boolean, CatalogError>; }; diff --git a/packages/stack/src/services/Database.integration.test.ts b/packages/stack/src/services/Database.integration.test.ts index a788390923..08afa7397d 100644 --- a/packages/stack/src/services/Database.integration.test.ts +++ b/packages/stack/src/services/Database.integration.test.ts @@ -1,12 +1,23 @@ import { PgClient } from "@effect/sql-pg"; import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Context, Deferred, Effect, FileSystem, Layer, Path, Redacted, Ref, Stream } from "effect"; +import { + Context, + Deferred, + Effect, + FileSystem, + Layer, + Path, + Predicate, + Redacted, + Ref, + Stream, +} from "effect"; import { tmpdir } from "node:os"; import { DEFAULT_POSTGRES_ROOT_KEY } from "../Defaults.ts"; import { makeService } from "../Service.ts"; import { makeDatabase, type BackendEndpoint, type DatabaseConfig } from "./Database.ts"; -import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; +import { makeDockerDatabaseRoot, runDocker } from "../../tests/docker-fixture.ts"; const config: DatabaseConfig = { version: "17", @@ -23,6 +34,7 @@ const query = ( password: Redacted.Redacted<string>, statement: string, database = "postgres", + username = "supabase_admin", ) => Effect.scoped( Effect.gen(function* () { @@ -32,7 +44,7 @@ const query = ( host, port: endpoint.port, database, - username: "supabase_admin", + username, password, }), ); @@ -144,6 +156,61 @@ describe("database component", { timeout: 180_000 }, () => { ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + it.live("requires passwords from non-superusers on the native socket", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "stack-database-hba-" }); + const database = yield* makeDatabase({ + stackId: "stack-integration", + instanceId: "hba", + root, + cacheRoot: artifactCacheRoot, + runtime: "native", + }); + const service = yield* makeService(database.definition, { id: "database:hba", config }); + yield* service.start; + yield* service.ready; + const endpoint = yield* database.endpoint; + expect( + yield* query( + endpoint, + config.databasePassword, + "SELECT rolsuper FROM pg_roles WHERE rolname = 'postgres'", + ), + ).toEqual([{ rolsuper: false }]); + const rejected = yield* query( + endpoint, + Redacted.make("wrong-password"), + "SELECT 1", + "postgres", + "postgres", + ).pipe(Effect.flip); + expect(Predicate.isTagged(rejected.reason, "AuthenticationError")).toBe(true); + yield* query( + endpoint, + config.databasePassword, + "CREATE EXTENSION dblink; CREATE ROLE dblink_probe LOGIN PASSWORD 'probe-password'", + ); + const connected = yield* query( + endpoint, + config.databasePassword, + "SELECT dblink_connect(format('host=%s port=%s dbname=postgres user=dblink_probe password=probe-password', current_setting('unix_socket_directories'), current_setting('port'))) AS status", + "postgres", + "postgres", + ); + expect(connected).toEqual([{ status: "OK" }]); + const rotated = Redacted.make("rotated-password"); + yield* service.restart({ ...config, databasePassword: rotated }); + yield* service.ready; + expect( + yield* query(yield* database.endpoint, rotated, "SELECT 1 AS ok", "postgres", "postgres"), + ).toEqual([{ ok: 1 }]); + yield* service.destroy; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + it.live( "persists SQL data across exact-session stop and reopen, isolates instances, and validates restart before stopping", () => @@ -392,4 +459,135 @@ describe("database component", { timeout: 180_000 }, () => { }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + + it.live("groups the database and its storage helper under one compose project", () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const stackId = "stack-compose-group"; + const root = yield* makeDockerDatabaseRoot("stack-database-group-", stackId); + const database = yield* makeDatabase({ + stackId, + instanceId: "database", + project: "my.app", + root, + cacheRoot: artifactCacheRoot, + runtime: "docker", + }); + const service = yield* makeService(database.definition, { + id: "database:group", + config, + }); + yield* service.start; + yield* service.ready; + const listed = yield* runDocker([ + "ps", + "--filter", + `label=com.supabase.stack-root=${path.resolve(root)}`, + "--format", + '{{.Names}}|{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.service"}}', + ]); + const rows = listed.output + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => line.trim().split("|")); + expect(rows.some(([name]) => name?.startsWith("supabase-db-helper-"))).toBe(true); + expect(new Set(rows.map(([, project]) => project))).toEqual( + new Set(["supabase-my-app-stack-compos"]), + ); + expect(new Set(rows.map(([, , group]) => group))).toEqual( + new Set(["database", "database-helper"]), + ); + yield* service.destroy; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("shuts PostgreSQL down fast while a client stays connected across stop", () => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const stackId = "stack-fast-shutdown"; + const root = yield* makeDockerDatabaseRoot("stack-database-shutdown-", stackId); + const database = yield* makeDatabase({ + stackId, + instanceId: "database", + root, + cacheRoot: artifactCacheRoot, + runtime: "docker", + }); + const service = yield* makeService(database.definition, { + id: "database:shutdown", + config: { ...config, stopGraceSeconds: 30 }, + }); + yield* service.start; + yield* service.ready; + const endpoint = yield* database.endpoint; + if (endpoint.kind !== "tcp") return yield* Effect.die("Expected a TCP endpoint"); + const listed = yield* runDocker([ + "ps", + "--filter", + `label=com.supabase.stack-root=${path.resolve(root)}`, + "--filter", + "label=com.supabase.instance=database", + "--format", + "{{.Names}}", + ]); + // The shared volume helper carries the same stack-root/instance labels; exclude it by name. + const containers = listed.output + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0 && !name.startsWith("supabase-db-helper-")); + expect(containers).toHaveLength(1); + const container = containers.join(""); + const startedAt = yield* runDocker([ + "inspect", + "--format", + "{{.State.StartedAt}}", + container, + ]); + + yield* Effect.scoped( + Effect.gen(function* () { + const services = yield* Layer.build( + PgClient.layer({ + host: endpoint.host, + port: endpoint.port, + database: "postgres", + username: "supabase_admin", + password: config.databasePassword, + }), + ); + const connection = yield* Context.get(services, PgClient.PgClient).reserve; + expect(yield* connection.executeUnprepared("SELECT 1 AS ok", [], undefined)).toEqual([ + { ok: 1 }, + ]); + yield* service.stop; + }), + ); + + const stoppedAt = yield* runDocker(["info", "--format", "{{.SystemTime}}"]); + const events = yield* runDocker([ + "events", + "--since", + startedAt.output.trim(), + "--until", + stoppedAt.output.trim(), + "--filter", + `container=${container}`, + "--filter", + "event=kill", + "--filter", + "event=die", + "--format", + '{{.Action}} {{index .Actor.Attributes "signal"}}{{index .Actor.Attributes "exitCode"}}', + ]); + expect( + events.output.trim().split("\n"), + "stop sends SIGINT and PostgreSQL exits cleanly", + ).toEqual(["kill 2", "die 0"]); + yield* service.destroy; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); }); diff --git a/packages/stack/src/services/Database.ts b/packages/stack/src/services/Database.ts index d8eb375d99..dab2a348f2 100644 --- a/packages/stack/src/services/Database.ts +++ b/packages/stack/src/services/Database.ts @@ -31,12 +31,19 @@ import { makeContainerRuntime, type ContainerProcess, type ContainerRuntime, + type HostGateway, } from "../runtime/Container.ts"; import { DatabaseBootstrapError, runDatabaseBootstrap } from "../runtime/DatabaseBootstrap.ts"; import { ensureInternalDatabase, makeDatabaseSessionFromSqlClient, } from "../runtime/PostgresDatabaseSession.ts"; +import { + mapToServiceError, + processExit as sharedProcessExit, + publishProcessLogs, + runtimeSessionFromContainer, +} from "../runtime/Session.ts"; import { ServiceError, ServiceLaunchError, @@ -57,12 +64,22 @@ import { type PasswdEntry, } from "../runtime/postgres-user.ts"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; +import { + containerInstancePath, + ensureOwnedInstanceRoot, + removeOwnedInstanceRoot, +} from "./InstanceRoot.ts"; import { DEFAULT_POSTGRES_ROOT_KEY } from "../Defaults.ts"; -import { makeDatabaseSnapshots } from "./DatabaseSnapshot.ts"; +import { + instanceSnapshotsDirectory, + makeDatabaseSnapshots, + type SnapshotScope, +} from "./DatabaseSnapshot.ts"; import type { DockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; import { makeDockerDatabaseStorage, type DockerDatabaseStorage, + type DockerDatabaseStorageError, } from "../storage/DockerDatabaseStorage.ts"; export const DatabaseConfig = Schema.Struct({ @@ -85,6 +102,9 @@ const DatabaseReadyMarker = Schema.Struct({ profile: Schema.Literal("supabase"), }); +// Health reconciles role passwords as supabase_admin, including after a configured password change. +const NATIVE_HBA_RULES = "local all supabase_admin trust\nlocal all all scram-sha-256\n"; + export interface DatabaseConfig extends Schema.Schema.Type<typeof DatabaseConfig> {} export const DatabaseEndpoints = Schema.Struct({ sql: Schema.optionalKey(EndpointIntent) }); @@ -112,11 +132,15 @@ export class DatabaseError extends Data.TaggedError("DatabaseError")<{ export interface DatabaseOptions { readonly stackId: StackId | string; readonly instanceId: string; + /** Project folder name; the container runtime sanitizes it into names and grouping labels. */ + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: DatabaseRuntime; /** Reuses one volume helper across databases in this host. */ readonly helpers?: DockerHelperRegistry; + /** Shares one host-gateway probe across this host's container runtimes. */ + readonly hostGateway?: HostGateway; } export interface DatabaseComponent { @@ -127,23 +151,18 @@ export interface DatabaseComponent { readonly saveSnapshot: ( context: ServiceInstanceContext<DatabaseConfig>, key: string, + scope: SnapshotScope, ) => Effect.Effect<void, ServiceError>; readonly restoreSnapshot: ( context: ServiceInstanceContext<DatabaseConfig>, key: string, + scope: SnapshotScope, ) => Effect.Effect<boolean, ServiceError>; readonly endpoint: Effect.Effect<BackendEndpoint, DatabaseError>; readonly logs: Stream.Stream<DatabaseLog, DatabaseError>; } -const errorFor = (operation: string, cause: unknown): ServiceError => - cause instanceof ServiceError - ? cause - : new ServiceError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); +const errorFor = mapToServiceError; const postgresArguments = (config: DatabaseConfig): Array<string> => { const configured = new Set(Object.keys(config.settings ?? {}).map((key) => key.toLowerCase())); @@ -165,13 +184,7 @@ const databaseError = (operation: string, cause: unknown): DatabaseError => cause, }); -/** A descendant outside the process group can keep stderr open after the launcher exits. */ -const stderrTailReady = (drained: Fiber.Fiber<void>) => - Fiber.await(drained).pipe( - Effect.asVoid, - Effect.raceFirst(Effect.sleep("1 second")), - Effect.ignore, - ); +const describePostgresExit = (code: number) => `PostgreSQL exited with code ${code}`; /** Settles a native PostgreSQL exit, waiting briefly after it for the stderr tail to drain. */ export const processExit = <E extends { readonly message: string }>( @@ -181,31 +194,7 @@ export const processExit = <E extends { readonly message: string }>( readonly drained: Fiber.Fiber<void>; }, ): Effect.Effect<Exit.Exit<void, ServiceError>> => - (stderr === undefined - ? exitCode - : exitCode.pipe(Effect.tap(() => stderrTailReady(stderr.drained))) - ).pipe( - Effect.flatMap((code) => - Number(code) === 0 - ? Effect.void - : (stderr === undefined ? Effect.succeed("") : Ref.get(stderr.tail)).pipe( - Effect.flatMap((text) => { - const detail = text.trim(); - return Effect.fail( - new ServiceError({ - operation: "exit", - message: - detail.length === 0 - ? `PostgreSQL exited with code ${String(code)}` - : `PostgreSQL exited with code ${String(code)}: ${detail}`, - }), - ); - }), - ), - ), - Effect.mapError((cause) => errorFor("exit", cause)), - Effect.exit, - ); + sharedProcessExit(exitCode, describePostgresExit, stderr); const reconcileContainerPassword = Effect.fn("Database.reconcileContainerPassword")( ( @@ -258,6 +247,7 @@ const reconcileContainerPassword = Effect.fn("Database.reconcileContainerPasswor ), ); +/** Idempotent readiness reconciliation, so a session also re-runs it as its probe. */ const health = Effect.fn("Database.health")(( endpoint: BackendEndpoint, config: DatabaseConfig, @@ -362,126 +352,67 @@ const health = Effect.fn("Database.health")(( ); }); -const publishLogs = Effect.fn("Database.publishLogs")(( - process: { - readonly stdout: Stream.Stream<Uint8Array, unknown>; - readonly stderr: Stream.Stream<Uint8Array, unknown>; - }, - logs: PubSub.PubSub<DatabaseLog>, - scope: Scope.Closeable, - stderrTail?: Ref.Ref<string>, -) => { - const drain = (stream: Stream.Stream<Uint8Array, unknown>, name: DatabaseLog["stream"]) => { - const decoder = name === "stderr" && stderrTail !== undefined ? new TextDecoder() : undefined; - const appendTail = (text: string) => - text.length === 0 || stderrTail === undefined - ? Effect.void - : Ref.update(stderrTail, (current) => (current + text).slice(-4096)); - return stream.pipe( - Stream.runForEach((bytes) => - Effect.gen(function* () { - if (decoder !== undefined) yield* appendTail(decoder.decode(bytes, { stream: true })); - yield* PubSub.publish(logs, { stream: name, bytes }); - }), - ), - Effect.andThen( - decoder === undefined - ? Effect.void - : Effect.sync(() => decoder.decode()).pipe(Effect.flatMap(appendTail)), - ), - Effect.catch((cause) => Effect.logError(cause)), - ); - }; - return Effect.gen(function* () { - yield* Effect.forkIn(drain(process.stdout, "stdout"), scope); - return yield* Effect.forkIn(drain(process.stderr, "stderr"), scope); - }); -}); +const publishLogs = publishProcessLogs; -const runtimeFromContainer = (process: ContainerProcess, discard: boolean): RuntimeSession => ({ - health: Effect.void, - exit: processExit(process.exitCode), - stop: process.stop.pipe(Effect.mapError((cause) => errorFor("stop", cause))), - ...(discard - ? { - discard: process.discard.pipe(Effect.mapError((cause) => errorFor("stop", cause))), - } - : {}), - remove: process.remove.pipe(Effect.mapError((cause) => errorFor("remove", cause))), -}); +const runtimeFromContainer = (process: ContainerProcess, discard: boolean): RuntimeSession => + runtimeSessionFromContainer(process, describePostgresExit, { discard }); -const ensureOwnedRoot = Effect.fn("Database.ensureOwnedRoot")(( +const databaseOwnerFileName = ".supabase-database-owner.json"; + +const ensureOwnedRoot = ( fs: FileSystem.FileSystem, path: Path.Path, - root: string, + parentRoot: string, stackId: string, instanceId: string, -): Effect.Effect<void, DatabaseError> => { - const ownerFile = path.join(root, ".supabase-database-owner.json"); - const marker = JSON.stringify({ stackId, instanceId }); - return Effect.gen(function* () { - yield* fs - .makeDirectory(root, { recursive: true, mode: 0o700 }) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - const present = yield* fs - .exists(ownerFile) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - if (present) { - const existing = yield* fs - .readFileString(ownerFile) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - if (existing !== marker) - return yield* databaseError("data", "Database root belongs to another instance"); - } else { - const entries = yield* fs - .readDirectory(root) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - if (entries.length > 0) - return yield* databaseError("data", "Database root is non-empty and unmarked"); - yield* fs - .writeFileString(ownerFile, marker, { mode: 0o600, flag: "wx" }) - .pipe(Effect.mapError((cause) => databaseError("data", cause))); - } - }); -}); +): Effect.Effect<void, DatabaseError> => + ensureOwnedInstanceRoot( + { + fs, + path, + parentRoot, + stackId, + instanceId, + ownerFileName: databaseOwnerFileName, + label: "Database root", + }, + databaseError, + ); -const removeOwnedRoot = Effect.fn("Database.removeOwnedRoot")(( +const removeOwnedRoot = ( fs: FileSystem.FileSystem, path: Path.Path, - root: string, + parentRoot: string, stackId: string, instanceId: string, removeData: Effect.Effect<void, ServiceError>, -): Effect.Effect<void, ServiceError> => { - const ownerFile = path.join(root, ".supabase-database-owner.json"); - const marker = JSON.stringify({ stackId, instanceId }); - return Effect.gen(function* () { - const present = yield* fs - .exists(ownerFile) - .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); - if (!present) { - if (yield* fs.exists(root).pipe(Effect.mapError((cause) => errorFor("destroy", cause)))) - return yield* errorFor("destroy", "Database root is unmarked"); - return; - } - const existing = yield* fs - .readFileString(ownerFile) - .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); - if (existing !== marker) - return yield* errorFor("destroy", "Database root belongs to another instance"); - yield* removeData; - yield* fs - .remove(root, { recursive: true, force: true }) - .pipe(Effect.mapError((cause) => errorFor("destroy", cause))); - }); -}); + /** Root entries kept with the owner marker; when empty the root itself is removed. */ + keep: ReadonlyArray<string> = [], +): Effect.Effect<void, ServiceError> => + removeOwnedInstanceRoot( + { + fs, + path, + parentRoot, + stackId, + instanceId, + ownerFileName: databaseOwnerFileName, + label: "Database root", + }, + removeData, + errorFor, + keep, + ); const nativeProcess = ( artifact: PreparedNativeArtifact, config: DatabaseConfig, - dataPath: string, - socketPath: string, - rootKeyPath: string, + paths: { + readonly dataPath: string; + readonly socketPath: string; + readonly hbaPath: string; + readonly rootKeyPath: string; + }, settings: ReadonlyArray<string>, context: ServiceInstanceContext<DatabaseConfig>, stackId: string, @@ -495,19 +426,21 @@ const nativeProcess = ( ...(user === undefined ? {} : { uid: user.uid, gid: user.gid, cwd: "/" }), args: [ "-D", - dataPath, + paths.dataPath, "-p", "5432", "-c", "listen_addresses=", "-c", - `unix_socket_directories=${socketPath}`, + `unix_socket_directories=${paths.socketPath}`, + "-c", + `hba_file=${paths.hbaPath}`, ...settings, ], env: { ...(user === undefined ? {} : { HOME: user.home }), - PGDATA: dataPath, - PGSODIUM_KEY_FILE: rootKeyPath, + PGDATA: paths.dataPath, + PGSODIUM_KEY_FILE: paths.rootKeyPath, POSTGRES_USER: "supabase_admin", POSTGRES_DB: "postgres", POSTGRES_PASSWORD: Redacted.value(config.databasePassword), @@ -547,10 +480,8 @@ export const makeDatabase = ( const prepared = yield* Ref.make<ReadonlyMap<string, PreparedNativeArtifact>>(new Map()); if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(String(options.stackId))) return yield* databaseError("identity", "Invalid stack id"); - if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u.test(options.instanceId)) - return yield* databaseError("identity", "Invalid instance id"); + yield* ensureOwnedRoot(fs, path, options.root, String(options.stackId), options.instanceId); const instanceRoot = path.join(options.root, options.instanceId); - yield* ensureOwnedRoot(fs, path, instanceRoot, String(options.stackId), options.instanceId); const container: ContainerRuntime | undefined = options.runtime === "native" ? undefined @@ -558,6 +489,9 @@ export const makeDatabase = ( engine: options.runtime, root: options.root, imageMirrors: slimImageMirrors, + ...(options.hostGateway === undefined ? {} : { hostGateway: options.hostGateway }), + // PostgreSQL's outbound HTTP falls back across address families. + awaitHostGateway: false, }); const storage: DockerDatabaseStorage | undefined = options.runtime === "native" @@ -566,6 +500,7 @@ export const makeDatabase = ( runtime: options.runtime, stackId: String(options.stackId), instanceId: options.instanceId, + ...(options.project === undefined ? {} : { project: options.project }), instanceRoot, root: options.root, cacheRoot: options.cacheRoot, @@ -583,8 +518,6 @@ export const makeDatabase = ( cacheRoot: options.cacheRoot, runtime: options.runtime, version, - stackId: String(options.stackId), - instanceId: options.instanceId, }).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -602,16 +535,18 @@ export const makeDatabase = ( }); return yield* Effect.scoped( Effect.gen(function* () { - const child = yield* container.launchTool({ + const child = yield* container.launchCommand({ image: artifact.image, stackId: String(options.stackId), instanceId: options.instanceId, + service: "database", + project: options.project, entrypoint: "/usr/bin/busybox", args, env: {}, mounts: [ storage === undefined - ? { source: instanceRoot, target: "/instance", readOnly: false } + ? { source: instanceRoot, target: containerInstancePath, readOnly: false } : yield* storage.mount(version).pipe( Effect.map((mount) => ({ ...mount, target: "/var/lib/postgresql/data" })), Effect.mapError((cause) => errorFor("data", cause)), @@ -787,6 +722,8 @@ export const makeDatabase = ( image: image.image, stackId: String(options.stackId), instanceId: options.instanceId, + service: "database", + project: options.project, env: { PGDATA: "/var/lib/postgresql/data", PGSODIUM_KEY_FILE: "/etc/postgresql-custom/pgsodium_root.key", @@ -804,6 +741,8 @@ export const makeDatabase = ( }, ], ports: [5432], + // SIGTERM is PostgreSQL's smart shutdown, which waits for every client to disconnect. + stopSignal: "SIGINT", ...(config.stopGraceSeconds === undefined ? {} : { stopGraceSeconds: config.stopGraceSeconds }), @@ -883,6 +822,10 @@ export const makeDatabase = ( Scope.provide(context.scope), Effect.mapError((cause) => errorFor("launch", cause)), ); + const hbaPath = path.join(socketPath, "pg_hba.conf"); + yield* fs + .writeFileString(hbaPath, NATIVE_HBA_RULES, { mode: 0o600 }) + .pipe(Effect.mapError((cause) => errorFor("launch", cause))); const artifact = (yield* Ref.get(prepared)).get(config.version); if (artifact === undefined) return yield* errorFor("launch", `Artifact ${config.version} was not prepared`); @@ -892,6 +835,7 @@ export const makeDatabase = ( dataPath, rootKeyPath, socketPath, + hbaPath, bundleRoot: artifact.root, executable: artifact.executable, }), @@ -899,9 +843,7 @@ export const makeDatabase = ( const process = yield* nativeProcess( artifact, config, - dataPath, - socketPath, - rootKeyPath, + { dataPath, socketPath, hbaPath, rootKeyPath }, settings, context, String(options.stackId), @@ -917,19 +859,21 @@ export const makeDatabase = ( yield* Ref.set(endpoint, selectedEndpoint); const stderrTail = yield* Ref.make(""); const stderrDrained = yield* publishLogs(process, logs, context.scope, stderrTail); + const setup = health(selectedEndpoint, config, Effect.void, { + fs, + instanceRoot, + version: config.version, + runtime: options.runtime, + markInitialized: + storage === undefined + ? undefined + : storage + .markInitialized(config.version) + .pipe(Effect.mapError((cause) => errorFor("health", cause))), + }); return { - health: health(selectedEndpoint, config, Effect.void, { - fs, - instanceRoot, - version: config.version, - runtime: options.runtime, - markInitialized: - storage === undefined - ? undefined - : storage - .markInitialized(config.version) - .pipe(Effect.mapError((cause) => errorFor("health", cause))), - }), + health: setup, + probe: setup, exit: processExit(process.exitCode, { tail: stderrTail, drained: stderrDrained }), stop: process.kill.pipe(Effect.mapError((cause) => errorFor("stop", cause))), remove: fs.remove(socketPath, { recursive: true, force: true }).pipe( @@ -946,30 +890,32 @@ export const makeDatabase = ( yield* Ref.set(endpoint, selectedEndpoint); yield* publishLogs(launched, logs, context.scope); const session = runtimeFromContainer(launched, config.stopGraceSeconds === 0); + const setup = health( + selectedEndpoint, + config, + reconcileContainerPassword( + options.runtime, + launched.id, + config.databasePassword, + spawner, + ), + { + fs, + instanceRoot, + version: config.version, + runtime: options.runtime, + markInitialized: + storage === undefined + ? undefined + : storage + .markInitialized(config.version) + .pipe(Effect.mapError((cause) => errorFor("health", cause))), + }, + ); return { ...session, - health: health( - selectedEndpoint, - config, - reconcileContainerPassword( - options.runtime, - launched.id, - config.databasePassword, - spawner, - ), - { - fs, - instanceRoot, - version: config.version, - runtime: options.runtime, - markInitialized: - storage === undefined - ? undefined - : storage - .markInitialized(config.version) - .pipe(Effect.mapError((cause) => errorFor("health", cause))), - }, - ), + health: setup, + probe: setup, remove: session.remove.pipe(Effect.tap(() => Ref.set(endpoint, undefined))), } satisfies RuntimeSession; }), @@ -982,7 +928,7 @@ export const makeDatabase = ( removeOwnedRoot( fs, path, - instanceRoot, + options.root, String(options.stackId), options.instanceId, Effect.gen(function* () { @@ -995,53 +941,46 @@ export const makeDatabase = ( }).pipe(Effect.mapError((cause) => errorFor("destroy", cause))), ), }; - const resetData = Effect.fn("Database.resetData")( - (context: ServiceInstanceContext<DatabaseConfig>) => + const resetData = Effect.fn("Database.resetData")(( + context: ServiceInstanceContext<DatabaseConfig>, + ) => { + const clear: Effect.Effect<void, ServiceError | DockerDatabaseStorageError> = storage === undefined - ? definition - .removeData(context) - .pipe( - Effect.andThen( - ensureOwnedRoot( - fs, - path, - instanceRoot, - String(options.stackId), - options.instanceId, - ).pipe(Effect.mapError((cause) => errorFor("reset", cause))), - ), - ) - : storage.removeData(postgresVersion(context.config.version)).pipe( - Effect.andThen( - ensureOwnedRoot( - fs, - path, - instanceRoot, - String(options.stackId), - options.instanceId, - ).pipe(Effect.mapError((cause) => errorFor("reset", cause))), - ), - Effect.mapError((cause) => errorFor("reset", cause)), - ), - ); + ? removeOwnedRoot( + fs, + path, + options.root, + String(options.stackId), + options.instanceId, + Effect.void, + [instanceSnapshotsDirectory], + ) + : storage.removeData(postgresVersion(context.config.version)); + return clear.pipe( + Effect.andThen( + ensureOwnedRoot(fs, path, options.root, String(options.stackId), options.instanceId), + ), + Effect.mapError((cause) => errorFor("reset", cause)), + ); + }); return { definition, resetData, - saveSnapshot: (context, key) => + saveSnapshot: (context, key, scope) => storage === undefined ? Effect.flatMap(snapshots(context.config.version), (store) => - store.saveSnapshot(key), + store.saveSnapshot(key, scope), ).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))) : storage - .saveSnapshot(postgresVersion(context.config.version), key) + .saveSnapshot(postgresVersion(context.config.version), key, scope) .pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), - restoreSnapshot: (context, key) => + restoreSnapshot: (context, key, scope) => storage === undefined ? Effect.flatMap(snapshots(context.config.version), (store) => - store.restoreSnapshot(key), + store.restoreSnapshot(key, scope), ).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))) : storage - .restoreSnapshot(postgresVersion(context.config.version), key) + .restoreSnapshot(postgresVersion(context.config.version), key, scope) .pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), endpoint: Ref.get(endpoint).pipe( Effect.flatMap((value) => diff --git a/packages/stack/src/services/DatabaseSnapshot.integration.test.ts b/packages/stack/src/services/DatabaseSnapshot.integration.test.ts index d03797aed1..a89e190335 100644 --- a/packages/stack/src/services/DatabaseSnapshot.integration.test.ts +++ b/packages/stack/src/services/DatabaseSnapshot.integration.test.ts @@ -1,326 +1,682 @@ import { NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Clock, Crypto, Effect, Exit, FileSystem, Option, Path, Schema, Scope } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeDatabaseSnapshots } from "./DatabaseSnapshot.ts"; +import { Crypto, Data, Effect, Exit, FileSystem, Path, Ref, Schema, Scope, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { postgresVersion, resolveArtifact } from "../Artifacts.ts"; +import { makeContainerRuntime } from "../runtime/Container.ts"; +import { makeDockerDatabaseStorage } from "../storage/DockerDatabaseStorage.ts"; +import { makeDockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; +import { shellQuote } from "../storage/DockerSnapshotBackend.ts"; +import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; +import { makeDatabaseSnapshots, type SnapshotScope } from "./DatabaseSnapshot.ts"; -const version = "17.6.1.173"; +// Derived from the real catalog (not hardcoded), so a Postgres pin bump never makes this go stale. +const version = postgresVersion("17"); -const setup = Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "database-snapshot-" }); - const path = yield* Path.Path; - const cache = path.join(root, "cache"); +class ShellError extends Data.TaggedError("ShellError")<{ readonly message: string }> {} + +type Services = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | Scope.Scope; +type Spawner = ChildProcessSpawner.ChildProcessSpawner["Service"]; - const makeInstance = (name: string) => +const run = (spawner: Spawner, command: string, args: ReadonlyArray<string>) => + Effect.scoped( Effect.gen(function* () { - const instance = path.join(root, name); - yield* fs.makeDirectory(path.join(instance, "data"), { recursive: true }); - yield* fs.writeFileString(path.join(instance, "data", "PG_VERSION"), "17\n"); - yield* fs.writeFileString( - path.join(instance, ".supabase-database-ready.json"), - '{"version":"17.6.1.173","runtime":"native","profile":"supabase"}', + const child = yield* spawner.spawn(ChildProcess.make(command, args, { stdin: "ignore" })); + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(child.stdout)), + Stream.mkString(Stream.decodeText(child.stderr)), + child.exitCode, + ], + { concurrency: "unbounded" }, ); - return instance; - }); + if (Number(code) !== 0) + return yield* new ShellError({ message: `${command} failed: ${stderr.trim()}` }); + return stdout.trim(); + }), + ).pipe( + Effect.mapError((cause) => new ShellError({ message: String(cause) })), + Effect.orDie, + ); + +/** Rewrites one spawned command; other commands pass through. */ +const rewriting = ( + spawner: Spawner, + rewrite: (command: string, args: ReadonlyArray<string>) => ReadonlyArray<string> | undefined, +): Spawner => + ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) return spawner.spawn(command); + const replaced = rewrite(command.command, command.args); + if (replaced === undefined) return spawner.spawn(command); + const [executable = "", ...args] = replaced; + return spawner.spawn(ChildProcess.make(executable, args, command.options)); + }); + +interface Overrides { + readonly fs?: FileSystem.FileSystem; + /** Makes the copy tool leave a partial tree and fail. */ + readonly partialCopy?: boolean; + /** Leaves the instance without a data directory or its parent. */ + readonly fresh?: boolean; +} + +interface SnapshotError { + readonly operation: string; +} + +interface Instance { + readonly root: string; + readonly data: string; + readonly entries: string; + readonly stages: string; + readonly restoreStages: string; + readonly saveSnapshot: (key: string, scope?: SnapshotScope) => Effect.Effect<void, SnapshotError>; + readonly restoreSnapshot: ( + key: string, + scope?: SnapshotScope, + ) => Effect.Effect<boolean, SnapshotError>; + /** Writes a stopped, ready database whose fixture files hold `value`. */ + readonly seed: (value: string) => Effect.Effect<void>; + /** Removes the database data the way a database reset does. */ + readonly clear: Effect.Effect<void>; + readonly destroy: Effect.Effect<void>; +} - return { fs, path, root, cache, makeInstance }; +interface Engine { + readonly runtime: "native" | "docker"; + /** Runs a script where the instance paths above resolve; `tool` prefixes file tools. */ + readonly shell: (script: string) => Effect.Effect<string>; + readonly tool: string; + readonly instance: (name: string, overrides?: Overrides) => Effect.Effect<Instance>; +} + +const readyMarker = (runtime: string) => + `{"version":"${version}","runtime":"${runtime}","profile":"supabase"}`; + +const native = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const services = yield* Effect.context<Services>(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "database-snapshot-contract-" }); + const cacheRoot = `${root}/cache`; + const shell = (script: string) => run(spawner, "/bin/sh", ["-c", script]); + const partialCopy = rewriting(spawner, (command, args) => + command === "cp" + ? [ + "/bin/sh", + "-c", + 'mkdir -p "$1/nested" && printf partial > "$1/nested/fixture"; exit 1', + "sh", + args.at(-1) ?? "", + ] + : undefined, + ); + const instance = (name: string, overrides: Overrides = {}) => + Effect.gen(function* () { + const instanceRoot = `${root}/${name}`; + const data = `${instanceRoot}/data`; + if (overrides.fresh !== true) yield* fs.makeDirectory(data, { recursive: true }); + const store = yield* makeDatabaseSnapshots({ + instanceRoot, + cacheRoot, + runtime: "native", + version, + }).pipe( + Effect.provideService(FileSystem.FileSystem, overrides.fs ?? fs), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + overrides.partialCopy === true ? partialCopy : spawner, + ), + ); + const instance: Instance = { + root: instanceRoot, + data, + entries: `${cacheRoot}/stack-database-snapshots/entries`, + stages: `${cacheRoot}/stack-database-snapshots/stages`, + restoreStages: `${instanceRoot}/.supabase-restore`, + saveSnapshot: store.saveSnapshot, + restoreSnapshot: store.restoreSnapshot, + seed: (value) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${data}/nested`, { recursive: true }); + yield* fs.writeFileString(`${data}/PG_VERSION`, "17\n"); + yield* fs.writeFileString(`${data}/fixture`, value); + yield* fs.writeFileString(`${data}/nested/fixture`, value); + yield* fs.writeFileString( + `${instanceRoot}/.supabase-database-ready.json`, + readyMarker("native"), + ); + }).pipe(Effect.orDie), + clear: Effect.all([ + fs.remove(data, { recursive: true, force: true }), + fs.remove(`${instanceRoot}/.supabase-database-ready.json`, { force: true }), + ]).pipe(Effect.asVoid, Effect.orDie), + destroy: fs.remove(instanceRoot, { recursive: true }).pipe(Effect.orDie), + }; + return instance; + }).pipe(Effect.orDie, Effect.provideContext(services)); + const engine: Engine = { runtime: "native", shell, tool: "", instance }; + return engine; }); -const store = (instance: string, cache: string) => - makeDatabaseSnapshots({ - instanceRoot: instance, - cacheRoot: cache, - runtime: "native", - version, - stackId: "test", - instanceId: "database", +const StorageMarker = Schema.fromJsonString( + Schema.Struct({ + volume: Schema.String, + namespace: Schema.String, + cacheNamespace: Schema.String, + }), +); + +const docker = Effect.fnUntraced(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const services = yield* Effect.context<Services>(); + const scope = yield* Scope.Scope; + const stackId = "snapshot-contract"; + const root = yield* makeDockerDatabaseRoot("database-snapshot-contract-", stackId); + const cacheRoot = path.resolve(root, "../../../cache"); + const image = (yield* resolveArtifact({ service: "database", version }).pipe(Effect.orDie)).image; + const container = yield* makeContainerRuntime({ engine: "docker", root }); + const helpers = yield* makeDockerHelperRegistry( + `snapshot-contract-${yield* crypto.randomUUIDv4}`, + ); + const inspector = yield* Ref.make<string | undefined>(undefined); + // The inspector mounts the store volume only after storage has created and labelled it. + const inspect = (volume: string) => + Effect.gen(function* () { + const current = yield* Ref.get(inspector); + if (current !== undefined) return current; + const id = yield* Effect.acquireRelease( + run(spawner, "docker", [ + "run", + "-d", + "--label", + "com.supabase.stack-managed=true", + "--mount", + `type=volume,src=${volume},dst=/store`, + "--entrypoint", + "/bin/sh", + image, + "-c", + "trap : TERM INT; while :; do sleep 3600; done", + ]), + (id) => run(spawner, "docker", ["rm", "-f", id]).pipe(Effect.asVoid), + ).pipe(Scope.provide(scope)); + yield* Ref.set(inspector, id); + return id; + }); + const shell = (script: string) => + Ref.get(inspector).pipe( + Effect.flatMap((id) => + id === undefined + ? Effect.die("Docker store is not created yet") + : run(spawner, "docker", ["exec", id, "/bin/sh", "-c", script]), + ), + ); + const partialCopy = rewriting(spawner, (command, args) => { + const script = args.at(-1) ?? ""; + if (command !== "docker" || args[0] !== "exec" || !script.includes("/usr/local/bin/cp")) + return undefined; + return [ + command, + ...args.slice(0, -1), + script.replace( + /\/usr\/local\/bin\/cp -a --reflink=auto (\S+) (\S+)/u, + (_match, _from: string, to: string) => + `/usr/bin/busybox mkdir -p ${to}/nested; printf partial > ${to}/nested/fixture; exit 1`, + ), + ]; }); + const instance = (name: string, overrides: Overrides = {}) => + Effect.gen(function* () { + const instanceRoot = `${root}/${name}`; + yield* fs.makeDirectory(instanceRoot, { recursive: true }); + const storage = yield* makeDockerDatabaseStorage({ + runtime: "docker", + stackId, + instanceId: name, + instanceRoot, + root, + cacheRoot, + fs: overrides.fs ?? fs, + path, + crypto, + container, + spawner: overrides.partialCopy === true ? partialCopy : spawner, + helpers, + }).pipe(Scope.provide(scope)); + yield* storage.mount(version); + if (overrides.fresh !== true) yield* storage.prepare(version); + const marker = yield* fs + .readFileString(`${instanceRoot}/.supabase-database-storage.json`) + .pipe(Effect.flatMap(Schema.decodeEffect(StorageMarker))); + yield* inspect(marker.volume); + const data = `/store/${marker.namespace}/data`; + const cache = `/store/${marker.cacheNamespace}`; + const instance: Instance = { + root: instanceRoot, + data, + entries: `${cache}/entries`, + stages: `${cache}/stages`, + restoreStages: `${cache}/stages`, + saveSnapshot: (key, scope) => storage.saveSnapshot(version, key, scope), + restoreSnapshot: (key, scope) => storage.restoreSnapshot(version, key, scope), + seed: (value) => + Effect.gen(function* () { + yield* storage.prepare(version); + yield* shell( + `set -eu; /usr/bin/busybox mkdir -p ${shellQuote(`${data}/nested`)}; printf 17 > ${shellQuote(`${data}/PG_VERSION`)}; printf '%s' ${shellQuote(value)} > ${shellQuote(`${data}/fixture`)}; printf '%s' ${shellQuote(value)} > ${shellQuote(`${data}/nested/fixture`)}`, + ); + yield* storage.markInitialized(version); + yield* fs.writeFileString( + `${instanceRoot}/.supabase-database-ready.json`, + readyMarker("docker"), + ); + }).pipe(Effect.orDie), + clear: storage.removeData(version).pipe(Effect.orDie), + destroy: storage + .destroyData(version) + .pipe(Effect.andThen(fs.remove(instanceRoot, { recursive: true })), Effect.orDie), + }; + return instance; + }).pipe(Effect.orDie, Effect.provideContext(services)); + const engine: Engine = { runtime: "docker", shell, tool: "/usr/bin/busybox ", instance }; + return engine; +}); + +const engines = [ + { name: "native", make: native }, + { name: "docker", make: docker }, +]; -const entries = (fs: FileSystem.FileSystem, path: Path.Path, cache: string) => +const Descriptor = Schema.fromJsonString(Schema.Struct({ logicalKey: Schema.String })); +const ReadyVersion = Schema.fromJsonString(Schema.Struct({ version: Schema.String })); + +const setup = <E>(make: () => Effect.Effect<Engine, E, Services>) => Effect.gen(function* () { - const root = path.join(cache, "stack-database-snapshots", "entries"); - const names = yield* fs.readDirectory(root); - const result: Array<{ readonly name: string; readonly key: string }> = []; - for (const name of names) { - const descriptor = yield* Schema.decodeEffect( - Schema.fromJsonString(Schema.Struct({ logicalKey: Schema.String })), - )(yield* fs.readFileString(path.join(root, name, "descriptor.json"))).pipe( - Effect.orElseSucceed(() => undefined), + const fs = yield* FileSystem.FileSystem; + const engine = yield* make(); + const { shell, tool } = engine; + const fixture = (instance: Instance) => + shell( + `${tool}cat ${shellQuote(`${instance.data}/fixture`)}; echo; ${tool}cat ${shellQuote(`${instance.data}/nested/fixture`)}`, + ); + const contents = (directory: string) => + shell(`if [ -d ${shellQuote(directory)} ]; then ${tool}ls -A ${shellQuote(directory)}; fi`); + const entries = (instance: Instance) => + Effect.gen(function* () { + const listing = yield* shell( + `for entry in ${shellQuote(instance.entries)}/*; do [ -d "$entry" ] || continue; printf '%s ' "$entry"; ${tool}cat "$entry/descriptor.json"; echo; done`, + ); + const result = new Map<string, string>(); + for (const line of listing.split("\n")) { + const separator = line.indexOf(" "); + const decoded = yield* Schema.decodeEffect(Descriptor)(line.slice(separator + 1)).pipe( + Effect.option, + ); + if (decoded._tag === "Some") + result.set(decoded.value.logicalKey, line.slice(0, separator)); + } + return result; + }); + const entry = (instance: Instance, key: string) => + entries(instance).pipe( + Effect.flatMap((all) => Effect.fromNullishOr(all.get(key))), + Effect.orDie, ); - if (descriptor !== undefined) result.push({ name, key: descriptor.logicalKey }); - } - return result; + return { fs, engine, shell, tool, fixture, contents, entries, entry }; }); -const entryPath = (path: Path.Path, cache: string, name: string) => - path.join(cache, "stack-database-snapshots", "entries", name); - -const live = <A, E>( - effect: Effect.Effect< - A, - E, - | ChildProcessSpawner.ChildProcessSpawner - | Crypto.Crypto - | FileSystem.FileSystem - | Path.Path - | Scope.Scope - >, -) => Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - -describe("managed native database snapshots", () => { - it.live("saves, restores, replaces, and isolates source and destination mutations", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const target = yield* makeInstance("target"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "first"); - const sourceStore = yield* store(source, cache); - yield* sourceStore.saveSnapshot("same-key"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "second"); - yield* sourceStore.saveSnapshot("same-key"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "mutated-source"); - yield* fs.remove(source, { recursive: true }); - yield* fs.remove(path.join(target, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(target, "data")); - expect(yield* (yield* store(target, cache)).restoreSnapshot("same-key")).toBe(true); - expect(yield* fs.readFileString(path.join(target, "data", "fixture"))).toBe("second"); - - yield* fs.writeFileString(path.join(target, "data", "fixture"), "destination"); - expect(yield* fs.readFileString(path.join(target, "data", "fixture"))).toBe("destination"); - const secondTarget = yield* makeInstance("second-target"); - yield* fs.remove(path.join(secondTarget, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(secondTarget, "data")); - expect(yield* (yield* store(secondTarget, cache)).restoreSnapshot("same-key")).toBe(true); - expect(yield* fs.readFileString(path.join(secondTarget, "data", "fixture"))).toBe("second"); - }), - ), - ); +const live = <A, E>(effect: Effect.Effect<A, E, Services>) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); - it.live("returns an absent-key miss without changing an empty target", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const target = yield* makeInstance("target"); - yield* fs.remove(path.join(target, "data", "PG_VERSION")); - const snapshots = yield* store(target, cache); - expect(yield* snapshots.restoreSnapshot("missing")).toBe(false); - expect(yield* fs.readDirectory(path.join(target, "data"))).toEqual([]); - }), - ), - ); +for (const { name, make } of engines) + describe(`${name} database snapshots`, { timeout: 120_000 }, () => { + it.live("restores a saved snapshot that is isolated from later source changes", () => + live( + Effect.gen(function* () { + const { fs, engine, fixture } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + yield* source.seed("mutated"); - it.live("fails a miss on a nonempty target and retains its contents", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const target = yield* makeInstance("target"); - yield* fs.writeFileString(path.join(target, "data", "keep"), "safe"); - const failure = yield* (yield* store(target, cache)) - .restoreSnapshot("missing") - .pipe(Effect.flip); - expect(failure.operation).toBe("restore"); - expect(yield* fs.readFileString(path.join(target, "data", "keep"))).toBe("safe"); - }), - ), - ); + const target = yield* engine.instance("target"); + yield* fs.writeFileString(`${target.root}/.supabase-database-ready.json`, "stale"); + expect(yield* target.restoreSnapshot("key")).toBe(true); - it.live("evicts the oldest entry by touch time and restore updates the LRU time", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const snapshots = yield* store(source, cache); - for (const key of ["one", "two", "three"]) { - yield* fs.writeFileString(path.join(source, "data", "fixture"), key); - yield* snapshots.saveSnapshot(key); - } - const saved = yield* entries(fs, path, cache); - for (const [key, seconds] of [ - ["one", 1_000], - ["two", 2_000], - ["three", 3_000], - ] as const) { - const entry = saved.find((candidate) => candidate.key === key); - if (entry === undefined) throw new Error(`Missing ${key}`); - yield* fs.utimes(entryPath(path, cache, entry.name), seconds, seconds); - } - yield* fs.remove(path.join(source, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "data")); - const one = saved.find((entry) => entry.key === "one"); - if (one === undefined) throw new Error("Missing one"); - const beforeRestore = yield* Clock.currentTimeMillis; - expect(yield* snapshots.restoreSnapshot("one")).toBe(true); - const afterRestore = yield* Clock.currentTimeMillis; - const touched = Option.match((yield* fs.stat(entryPath(path, cache, one.name))).mtime, { - onNone: () => undefined, - onSome: (mtime) => mtime.getTime(), - }); - if (touched === undefined) throw new Error("Snapshot touch time is unavailable"); - expect(touched).toBeGreaterThanOrEqual(beforeRestore - 1_000); - expect(touched).toBeLessThanOrEqual(afterRestore + 1_000); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "four"); - yield* snapshots.saveSnapshot("four"); - expect((yield* entries(fs, path, cache)).map((entry) => entry.key).sort()).toEqual([ - "four", - "one", - "three", - ]); - }), - ), - ); + expect(yield* fixture(target)).toBe("saved\nsaved"); + expect( + yield* Schema.decodeEffect(ReadyVersion)( + yield* fs.readFileString(`${target.root}/.supabase-database-ready.json`), + ), + ).toEqual({ version }); + }), + ), + ); - it.live("treats an incompatible descriptor as a full-key miss", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const snapshots = yield* store(source, cache); - yield* snapshots.saveSnapshot("old-version"); - const saved = yield* entries(fs, path, cache); - const entry = saved.find((candidate) => candidate.key === "old-version"); - if (entry === undefined) throw new Error("Missing old-version"); - const descriptorPath = path.join(entryPath(path, cache, entry.name), "descriptor.json"); - const descriptor = yield* fs.readFileString(descriptorPath); - yield* fs.writeFileString(descriptorPath, descriptor.replace(version, "15.14.1.173")); - yield* fs.remove(path.join(source, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "data")); - expect(yield* snapshots.restoreSnapshot("old-version")).toBe(false); - expect(yield* fs.readDirectory(path.join(source, "data"))).toEqual([]); - }), - ), - ); + it.live("restores into an instance without a data directory or its parent", () => + live( + Effect.gen(function* () { + const { engine, fixture } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); - it.live("rejects corrupt manifests and data before changing the target", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const snapshots = yield* store(source, cache); - yield* snapshots.saveSnapshot("corrupt-manifest"); - const saved = yield* entries(fs, path, cache); - const manifest = saved.find((candidate) => candidate.key === "corrupt-manifest"); - if (manifest === undefined) throw new Error("Missing corrupt-manifest"); - yield* fs.writeFileString( - path.join(entryPath(path, cache, manifest.name), "descriptor.json"), - "{}", - ); - yield* fs.remove(path.join(source, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "data")); - const manifestFailure = yield* snapshots - .restoreSnapshot("corrupt-manifest") - .pipe(Effect.flip); - expect(manifestFailure.operation).toBe("descriptor"); - expect(yield* fs.readDirectory(path.join(source, "data"))).toEqual([]); - - yield* fs.writeFileString(path.join(source, "data", "PG_VERSION"), "17\n"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "invalid"); - yield* snapshots.saveSnapshot("corrupt-data"); - const dataEntry = (yield* entries(fs, path, cache)).find( - (candidate) => candidate.key === "corrupt-data", - ); - if (dataEntry === undefined) throw new Error("Missing corrupt-data"); - yield* fs.writeFileString( - path.join(entryPath(path, cache, dataEntry.name), "data", "PG_VERSION"), - "16\n", - ); - yield* fs.remove(path.join(source, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(source, "data")); - const dataFailure = yield* snapshots.restoreSnapshot("corrupt-data").pipe(Effect.flip); - expect(dataFailure.operation).toBe("validate"); - expect(yield* fs.readDirectory(path.join(source, "data"))).toEqual([]); - }), - ), - ); + const target = yield* engine.instance("target", { fresh: true }); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("saved\nsaved"); + }), + ), + ); - it.live("completes concurrent same-key saves with complete generations", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const first = yield* makeInstance("first"); - const second = yield* makeInstance("second"); - for (const [root, value] of [ - [first, "first"], - [second, "second"], - ] as const) { - yield* fs.writeFileString(path.join(root, "data", "generation"), value); - yield* fs.makeDirectory(path.join(root, "data", "nested")); - yield* fs.writeFileString(path.join(root, "data", "nested", "generation"), value); - } - const results = yield* Effect.all( - [ - (yield* store(first, cache)).saveSnapshot("same-key"), - (yield* store(second, cache)).saveSnapshot("same-key"), - ], - { concurrency: "unbounded" }, - ); - expect(results).toHaveLength(2); - const target = yield* makeInstance("target"); - yield* fs.remove(path.join(target, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(target, "data")); - expect(yield* (yield* store(target, cache)).restoreSnapshot("same-key")).toBe(true); - const generation = yield* fs.readFileString(path.join(target, "data", "generation")); - expect(["first", "second"]).toContain(generation); - expect(yield* fs.readFileString(path.join(target, "data", "nested", "generation"))).toBe( - generation, - ); - }), - ), - ); + it.live("replaces an existing key with the newer generation", () => + live( + Effect.gen(function* () { + const { engine, fixture, entries } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("first"); + yield* source.saveSnapshot("key"); + yield* source.seed("second"); + yield* source.saveSnapshot("key"); - it.live("reclaims stale stages before the next operation", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const stageRoot = path.join(cache, "stack-database-snapshots", "stages"); - yield* fs.makeDirectory(path.join(stageRoot, "abandoned"), { recursive: true }); - const staleRestore = path.join(source, ".supabase-restore-abandoned", "data"); - yield* fs.makeDirectory(staleRestore, { recursive: true }); - yield* (yield* store(source, cache)).saveSnapshot("cleanup"); - expect(yield* fs.exists(path.join(stageRoot, "abandoned"))).toBe(false); - expect(yield* fs.exists(path.join(source, ".supabase-restore-abandoned"))).toBe(false); - }), - ), - ); + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("second\nsecond"); + expect([...(yield* entries(source)).keys()]).toEqual(["key"]); + }), + ), + ); - it.live("rolls back a failed marker publication", () => - live( - Effect.gen(function* () { - const { fs, path, cache, makeInstance } = yield* setup; - const source = yield* makeInstance("source"); - const target = yield* makeInstance("target"); - yield* fs.writeFileString(path.join(source, "data", "fixture"), "snapshot"); - yield* (yield* store(source, cache)).saveSnapshot("rollback"); - yield* fs.remove(path.join(target, "data"), { recursive: true }); - yield* fs.makeDirectory(path.join(target, "data")); - let fail = true; - const failingFs: FileSystem.FileSystem = { - ...fs, - rename: (from, to) => { - if ( - fail && - from.endsWith("/ready.json") && + it.live("misses an absent key and leaves the target empty", () => + live( + Effect.gen(function* () { + const { engine, contents } = yield* setup(make); + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("absent")).toBe(false); + expect(yield* contents(target.data)).toBe(""); + }), + ), + ); + + it.live("refuses to restore over existing data", () => + live( + Effect.gen(function* () { + const { engine, fixture } = yield* setup(make); + const target = yield* engine.instance("target"); + yield* target.seed("existing"); + const failure = yield* target.restoreSnapshot("absent").pipe(Effect.flip); + expect(failure.operation).toBe("restore"); + expect(yield* fixture(target)).toBe("existing\nexisting"); + }), + ), + ); + + it.live("refuses to save a running database", () => + live( + Effect.gen(function* () { + const { engine, entries, shell, tool } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("running"); + yield* shell(`${tool}touch ${shellQuote(`${source.data}/postmaster.pid`)}`); + const failure = yield* source.saveSnapshot("key").pipe(Effect.flip); + expect(failure.operation).toBe("data"); + expect((yield* entries(source)).size).toBe(0); + }), + ), + ); + + it.live("keeps the previous generation and no stages when a copy fails midway", () => + live( + Effect.gen(function* () { + const { engine, fixture, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("first"); + yield* source.saveSnapshot("key"); + const failingSource = yield* engine.instance("source", { partialCopy: true }); + yield* failingSource.seed("second"); + expect((yield* failingSource.saveSnapshot("key").pipe(Effect.flip)).operation).toBe( + "copy", + ); + expect(yield* contents(source.stages)).toBe(""); + + const failingTarget = yield* engine.instance("target", { partialCopy: true }); + expect((yield* failingTarget.restoreSnapshot("key").pipe(Effect.flip)).operation).toBe( + "copy", + ); + expect(yield* contents(failingTarget.data)).toBe(""); + expect(yield* contents(failingTarget.restoreStages)).toBe(""); + + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("first\nfirst"); + }), + ), + ); + + it.live("recovers a generation left retired by an interrupted save", () => + live( + Effect.gen(function* () { + const { engine, entry, fixture, shell, tool } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + const saved = yield* entry(source, "key"); + const digest = saved.slice(saved.lastIndexOf("/") + 1); + yield* shell( + `${tool}mv ${shellQuote(saved)} ${shellQuote(`${source.stages}/retired-${digest}-abandoned`)}`, + ); + + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("saved\nsaved"); + }), + ), + ); + + it.live("keeps the newest entry and the two most recently used others", () => + live( + Effect.gen(function* () { + const { engine, entries } = yield* setup(make); + const source = yield* engine.instance("source"); + for (const key of ["one", "two", "three"]) { + yield* source.seed(key); + yield* source.saveSnapshot(key); + } + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("one")).toBe(true); + yield* source.seed("four"); + yield* source.saveSnapshot("four"); + + expect([...(yield* entries(source)).keys()].sort()).toEqual(["four", "one", "three"]); + }), + ), + ); + + it.live("misses a descriptor for another identity and rejects a corrupt one", () => + live( + Effect.gen(function* () { + const { engine, entry, shell, tool, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + const descriptor = shellQuote(`${yield* entry(source, "key")}/descriptor.json`); + const saved = yield* shell(`${tool}cat ${descriptor}`); + const target = yield* engine.instance("target"); + + // Any version different from `version` proves the mismatch check; this one is a + // synthetic sentinel, not a real catalog pin, so it stays correct across catalog bumps. + yield* shell( + `printf '%s' ${shellQuote(saved.replace(version, "0.0.0-version-mismatch"))} > ${descriptor}`, + ); + expect(yield* target.restoreSnapshot("key")).toBe(false); + expect(yield* contents(target.data)).toBe(""); + + yield* shell(`printf '{}' > ${descriptor}`); + expect((yield* target.restoreSnapshot("key").pipe(Effect.flip)).operation).toBe( + "descriptor", + ); + expect(yield* contents(target.data)).toBe(""); + }), + ), + ); + + it.live("rejects snapshot data for another PostgreSQL major", () => + live( + Effect.gen(function* () { + const { engine, entry, shell, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + yield* shell( + `printf 16 > ${shellQuote(`${yield* entry(source, "key")}/data/PG_VERSION`)}`, + ); + const target = yield* engine.instance("target"); + expect((yield* target.restoreSnapshot("key").pipe(Effect.flip)).operation).toBe( + "validate", + ); + expect(yield* contents(target.data)).toBe(""); + }), + ), + ); + + it.live("keeps instance snapshots beyond cache retention and outside the cache", () => + live( + Effect.gen(function* () { + const { engine, entries, fixture } = yield* setup(make); + const owner = yield* engine.instance("owner"); + for (const key of ["one", "two", "three", "four"]) { + yield* owner.seed(`checkpoint-${key}`); + yield* owner.saveSnapshot(key, "instance"); + } + const other = yield* engine.instance("other"); + for (const key of ["a", "b", "c", "d"]) { + yield* other.seed(key); + yield* other.saveSnapshot(key); + } + + yield* owner.clear; + expect(yield* owner.restoreSnapshot("one", "instance")).toBe(true); + expect(yield* fixture(owner)).toBe("checkpoint-one\ncheckpoint-one"); + expect([...(yield* entries(other)).keys()].sort()).toEqual(["b", "c", "d"]); + }), + ), + ); + + it.live("restores instance snapshots only into their instance and removes them with it", () => + live( + Effect.gen(function* () { + const { engine } = yield* setup(make); + const owner = yield* engine.instance("owner"); + yield* owner.seed("checkpoint"); + yield* owner.saveSnapshot("key", "instance"); + + const other = yield* engine.instance("other"); + expect(yield* other.restoreSnapshot("key", "instance")).toBe(false); + yield* owner.destroy; + const recreated = yield* engine.instance("owner"); + expect(yield* recreated.restoreSnapshot("key", "instance")).toBe(false); + }), + ), + ); + + it.live("restores snapshots after the source instance is destroyed", () => + live( + Effect.gen(function* () { + const { engine, fixture } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("survivor"); + yield* source.saveSnapshot("key"); + yield* source.destroy; + + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(yield* fixture(target)).toBe("survivor\nsurvivor"); + }), + ), + ); + + it.live("keeps the previous marker and empties the target when marker publication fails", () => + live( + Effect.gen(function* () { + const { fs, engine, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* source.saveSnapshot("key"); + const failingFs = FileSystem.FileSystem.of({ + ...fs, + rename: (from, to) => to.endsWith(".supabase-database-ready.json") - ) { - fail = false; - return Effect.die(new Error("injected marker publication failure")); - } - return fs.rename(from, to); - }, - }; - const result = yield* (yield* store(target, cache).pipe( - Effect.provideService(FileSystem.FileSystem, failingFs), - )) - .restoreSnapshot("rollback") - .pipe(Effect.exit); - expect(Exit.isFailure(result)).toBe(true); - expect( - yield* fs.readFileString(path.join(target, ".supabase-database-ready.json")), - ).toContain(version); - expect(yield* fs.readDirectory(path.join(target, "data"))).toEqual([]); - }), - ), - ); -}); + ? Effect.die("Injected marker publication failure") + : fs.rename(from, to), + }); + const target = yield* engine.instance("target", { fs: failingFs }); + yield* fs.writeFileString(`${target.root}/.supabase-database-ready.json`, "previous"); + + expect(Exit.isFailure(yield* target.restoreSnapshot("key").pipe(Effect.exit))).toBe(true); + expect(yield* fs.readFileString(`${target.root}/.supabase-database-ready.json`)).toBe( + "previous", + ); + expect(yield* contents(target.data)).toBe(""); + }), + ), + ); + + it.live("publishes one complete generation from concurrent saves of a key", () => + live( + Effect.gen(function* () { + const { engine, fixture } = yield* setup(make); + const first = yield* engine.instance("first"); + const second = yield* engine.instance("second"); + yield* first.seed("first"); + yield* second.seed("second"); + yield* Effect.all([first.saveSnapshot("key"), second.saveSnapshot("key")], { + concurrency: "unbounded", + }); + + const target = yield* engine.instance("target"); + expect(yield* target.restoreSnapshot("key")).toBe(true); + expect(["first\nfirst", "second\nsecond"]).toContain(yield* fixture(target)); + }), + ), + ); + + it.live("reclaims stale stages before the next operation", () => + live( + Effect.gen(function* () { + const { engine, shell, tool, contents } = yield* setup(make); + const source = yield* engine.instance("source"); + yield* source.seed("saved"); + yield* shell(`${tool}mkdir -p ${shellQuote(`${source.stages}/abandoned`)}`); + yield* shell(`${tool}mkdir -p ${shellQuote(`${source.restoreStages}/abandoned`)}`); + yield* source.saveSnapshot("key"); + expect(yield* contents(source.stages)).toBe(""); + expect(yield* contents(source.restoreStages)).toBe(""); + }), + ), + ); + + it.live("refuses to clear a restore-stage root that is a symbolic link", () => + live( + Effect.gen(function* () { + const { engine, shell, tool } = yield* setup(make); + const target = yield* engine.instance("target"); + const victim = `${target.restoreStages}-victim`; + yield* shell( + `set -eu; ${tool}rm -rf ${shellQuote(target.restoreStages)}; ${tool}mkdir -p ${shellQuote(victim)}; printf kept > ${shellQuote(`${victim}/sentinel`)}; ${tool}ln -s ${shellQuote(victim)} ${shellQuote(target.restoreStages)}`, + ); + + const failure = yield* target.restoreSnapshot("absent").pipe(Effect.flip); + expect(failure.operation).toBe("clear"); + expect(yield* shell(`${tool}cat ${shellQuote(`${victim}/sentinel`)}`)).toBe("kept"); + }), + ), + ); + }); diff --git a/packages/stack/src/services/DatabaseSnapshot.ts b/packages/stack/src/services/DatabaseSnapshot.ts index d4a951b8a2..eb335fcaa1 100644 --- a/packages/stack/src/services/DatabaseSnapshot.ts +++ b/packages/stack/src/services/DatabaseSnapshot.ts @@ -13,376 +13,531 @@ import { Schedule, Schema, } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { postgresVersion } from "../Artifacts.ts"; -import { copyDirectory } from "../storage/DirectoryCopy.ts"; +import { failureMessage } from "../internal/failure-message.ts"; +import { copyDirectory, type DirectoryCopyError } from "../storage/DirectoryCopy.ts"; import type { DatabaseRuntime } from "./Database.ts"; -const SnapshotDescriptor = Schema.Struct({ - format: Schema.Literal("supabase-database-snapshot-v1"), - version: Schema.String, - runtime: Schema.Literals(["native", "docker", "podman"]), - platform: Schema.String, - arch: Schema.String, - profile: Schema.Literal("supabase"), - logicalKey: Schema.String, - keyDigest: Schema.String, -}); - -export class DatabaseSnapshotError extends Data.TaggedError("DatabaseSnapshotError")<{ - readonly operation: string; - readonly message: string; - readonly cause?: unknown; -}> {} +export class DatabaseSnapshotError extends Schema.TaggedError<DatabaseSnapshotError>()( + "DatabaseSnapshotError", + { operation: Schema.String, message: Schema.String, cause: Schema.optionalKey(Schema.Defect()) }, +) {} const errorFor = (operation: string, cause: unknown) => - cause instanceof DatabaseSnapshotError + Schema.is(DatabaseSnapshotError)(cause) ? cause : new DatabaseSnapshotError({ operation, - message: cause instanceof Error ? cause.message : String(cause), + message: failureMessage(cause), cause, }); +const SnapshotStop = Schema.Literals(["nonempty", "miss", "descriptor", "major", "running"]); +/** Names the guard that stopped a snapshot program. */ +type SnapshotStop = typeof SnapshotStop.Type; +/** Decodes a stop name reported by a backend outside this process. */ +export const decodeSnapshotStop = Schema.decodeUnknownEffect(SnapshotStop); + +/** + * One filesystem operation of a snapshot program. Guards stop the program with an outcome + * instead of failing. `Rename` creates the destination's parent and refuses to replace + * anything but an empty directory; an optional rename runs only when its source exists and + * its destination does not. `Recover` moves each `retired-<digest>-<token>` stage back to + * its entry when that entry is missing. + */ +export type SnapshotStep = Data.TaggedEnum<{ + Ensure: { readonly directory: string }; + Clear: { readonly directory: string }; + Recover: { readonly stages: string; readonly entries: string }; + Expect: { readonly path: string; readonly present: boolean; readonly otherwise: SnapshotStop }; + ExpectEmpty: { readonly directory: string; readonly otherwise: SnapshotStop }; + ExpectText: { readonly file: string; readonly text: string; readonly otherwise: SnapshotStop }; + Copy: { readonly from: string; readonly to: string }; + Adopt: { readonly directory: string }; + Write: { readonly file: string; readonly text: string }; + Rename: { readonly from: string; readonly to: string; readonly optional: boolean }; + Remove: { readonly path: string }; + Touch: { readonly path: string }; + Prune: { readonly directory: string; readonly keep: number; readonly except: string }; +}>; +export const SnapshotStep = Data.taggedEnum<SnapshotStep>(); + +/** Result of a snapshot program; a stopped `ExpectText` carries the file's actual text. */ +export type SnapshotRun = Data.TaggedEnum<{ + Completed: {}; + Stopped: { readonly outcome: SnapshotStop; readonly text: string }; +}>; +export const SnapshotRun = Data.taggedEnum<SnapshotRun>(); + +/** + * Where a snapshot lives: the shared cache keeps a bounded number of entries across stacks, and an + * instance keeps its own entries, outside that retention, until the instance is destroyed. + */ +export const snapshotScopes = ["cache", "instance"] as const; +export type SnapshotScope = (typeof snapshotScopes)[number]; + +/** Directory in a database instance root that holds its instance-scoped snapshots. */ +export const instanceSnapshotsDirectory = ".supabase-snapshots"; + +/** Filesystem namespace in which one engine stores snapshots and database data. */ +export interface SnapshotBackend { + /** Host file whose SQLite write lock serializes operations on this snapshot store. */ + readonly lockFile: string; + readonly entries: string; + readonly stages: string; + /** Holds restore stages on the filesystem that holds `data`. */ + readonly restoreStages: string; + readonly data: string; + readonly join: (...parts: ReadonlyArray<string>) => string; + /** Runs every step in order, in one round trip where the backend is remote. */ + readonly run: ( + steps: ReadonlyArray<SnapshotStep>, + ) => Effect.Effect<SnapshotRun, DatabaseSnapshotError>; +} + +/** Cache-scoped entries kept besides the one just saved; instance-scoped checkpoints are never pruned. */ +const retainedPrevious = 2; +const format = "supabase-database-snapshot-v1" as const; +const runtimes = Schema.Literals(["native", "docker", "podman"]); +const SnapshotIdentity = Schema.Struct({ + format: Schema.Literal(format), + version: Schema.String, + runtime: runtimes, + platform: Schema.String, + arch: Schema.String, + profile: Schema.Literal("supabase"), + logicalKey: Schema.String, +}); +const SnapshotDescriptor = Schema.Struct({ ...SnapshotIdentity.fields, keyDigest: Schema.String }); const ReadyMarker = Schema.Struct({ version: Schema.String, - runtime: Schema.Literals(["native", "docker", "podman"]), + runtime: runtimes, profile: Schema.Literal("supabase"), }); -const markerText = (version: string, runtime: DatabaseRuntime) => - Schema.encodeEffect(Schema.fromJsonString(ReadyMarker))({ - version, - runtime, - profile: "supabase", - }); +const withStoreLock = <A, E>(lockFile: string, effect: Effect.Effect<A, E>) => + Effect.acquireUseRelease( + Effect.try({ + try: () => new DatabaseSync(lockFile), + catch: (cause) => errorFor("lock", cause), + }), + (connection) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => connection.exec("PRAGMA busy_timeout = 0"), + catch: (cause) => errorFor("lock", cause), + }); + yield* Effect.try({ + try: () => connection.exec("BEGIN IMMEDIATE"), + catch: (cause) => errorFor("lock", cause), + }).pipe( + Effect.retry({ + schedule: Schedule.spaced("50 millis").pipe(Schedule.upTo({ duration: "120 seconds" })), + while: (cause) => + Predicate.hasProperty(cause.cause, "errcode") && cause.cause.errcode === 5, + }), + ); + return yield* effect; + }), + (connection) => + Effect.try({ + try: () => connection.close(), + catch: (cause) => errorFor("unlock", cause), + }), + ); -export const makeDatabaseSnapshots = Effect.fn("DatabaseSnapshot.make")(function* (options: { +/** Saves and restores database data through the snapshot protocol, with one backend per scope. */ +export const makeSnapshotStore = Effect.fn("DatabaseSnapshot.makeStore")(function* (options: { + readonly backends: { readonly [Scope in SnapshotScope]: SnapshotBackend }; readonly instanceRoot: string; - readonly cacheRoot: string; readonly runtime: DatabaseRuntime; readonly version: string; - readonly stackId: string; - readonly instanceId: string; }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const version = postgresVersion(options.version); - const root = path.join(options.cacheRoot, "stack-database-snapshots"); - const entries = path.join(root, "entries"); - const stages = path.join(root, "stages"); - const lockFile = path.join(root, ".lock.sqlite"); - const base = { - format: "supabase-database-snapshot-v1" as const, - version, - runtime: options.runtime, - platform: process.platform, - arch: process.arch, - profile: "supabase" as const, - }; + const { backends, runtime, version } = options; + const { Adopt, Clear, Copy, Ensure, Expect, ExpectEmpty, ExpectText } = SnapshotStep; + const { Prune, Recover, Remove, Rename, Touch, Write } = SnapshotStep; + const major = version.split(".")[0] ?? version; + const markerPath = path.join(options.instanceRoot, ".supabase-database-ready.json"); const mapError = <A, E>(operation: string, effect: Effect.Effect<A, E>) => effect.pipe(Effect.mapError((cause) => errorFor(operation, cause))); - const ensureStore = mapError( - "storage", - fs - .makeDirectory(root, { recursive: true, mode: 0o700 }) - .pipe(Effect.andThen(fs.makeDirectory(entries, { recursive: true, mode: 0o700 }))), + + const describe = Effect.fnUntraced( + function* (logicalKey: string) { + const identity = { + format, + version, + runtime, + platform: process.platform, + arch: process.arch, + profile: "supabase" as const, + logicalKey, + }; + const bytes = yield* Schema.encodeEffect(Schema.fromJsonString(SnapshotIdentity))( + identity, + ).pipe( + Effect.flatMap((encoded) => crypto.digest("SHA-256", new TextEncoder().encode(encoded))), + ); + const keyDigest = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + const descriptor = yield* Schema.encodeEffect(Schema.fromJsonString(SnapshotDescriptor))({ + ...identity, + keyDigest, + }); + return { keyDigest, descriptor }; + }, + Effect.mapError((cause) => errorFor("descriptor", cause)), ); - const withLock = <A>(effect: Effect.Effect<A, DatabaseSnapshotError>) => - Effect.acquireUseRelease( - Effect.try({ - try: () => new DatabaseSync(lockFile), - catch: (cause) => errorFor("lock", cause), - }), - (connection) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => connection.exec("PRAGMA busy_timeout = 0"), - catch: (cause) => errorFor("lock", cause), - }); - yield* Effect.try({ - try: () => connection.exec("BEGIN IMMEDIATE"), - catch: (cause) => errorFor("lock", cause), - }).pipe( - Effect.retry({ - schedule: Schedule.spaced("50 millis").pipe( - Schedule.upTo({ duration: "120 seconds" }), + const locked = <A>(backend: SnapshotBackend, effect: Effect.Effect<A, DatabaseSnapshotError>) => + mapError( + "lock", + fs.makeDirectory(path.dirname(backend.lockFile), { recursive: true, mode: 0o700 }), + ).pipe(Effect.andThen(withStoreLock(backend.lockFile, effect))); + const token = mapError("stage", crypto.randomUUIDv4); + // Compensation also runs after an interrupt; a failed compensation must not hide the + // failure it follows. + const compensate = + (backend: SnapshotBackend, steps: ReadonlyArray<SnapshotStep>) => + <A, E, R>(effect: Effect.Effect<A, E, R>) => + effect.pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.void + : Effect.uninterruptible( + backend.run(steps).pipe(Effect.catch(Effect.logWarning), Effect.asVoid), ), - while: (cause) => - Predicate.hasProperty(cause.cause, "errcode") && cause.cause.errcode === 5, - }), - Effect.mapError((cause) => errorFor("lock", cause)), - ); - return yield* effect; - }), - (connection) => - Effect.try({ - try: () => connection.close(), - catch: (cause) => errorFor("unlock", cause), - }), - ); + ), + ); + const reclaimStages = (backend: SnapshotBackend) => [ + Recover({ stages: backend.stages, entries: backend.entries }), + Clear({ directory: backend.stages }), + ...(backend.restoreStages === backend.stages + ? [] + : [Clear({ directory: backend.restoreStages })]), + ]; - const keyDescriptor = (key: string) => ({ ...base, logicalKey: key }); - const digest = (key: string) => - Schema.encodeEffect( - Schema.fromJsonString( - Schema.Struct({ - format: Schema.Literal("supabase-database-snapshot-v1"), - version: Schema.String, - runtime: Schema.Literals(["native", "docker", "podman"]), - platform: Schema.String, - arch: Schema.String, - profile: Schema.Literal("supabase"), - logicalKey: Schema.String, - }), - ), - )(keyDescriptor(key)).pipe( - Effect.flatMap((encoded) => crypto.digest("SHA-256", new TextEncoder().encode(encoded))), - Effect.map((bytes) => - Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), - ), - Effect.mapError((cause) => errorFor("key", cause)), - ); - - const copy = (source: string, destination: string) => - copyDirectory(source, destination).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.mapError((cause) => errorFor("copy", cause)), + const saveSnapshot = Effect.fn("DatabaseSnapshot.save")(function* ( + logicalKey: string, + scope: SnapshotScope = "cache", + ) { + const backend = backends[scope]; + const ready = yield* mapError( + "ready", + fs + .readFileString(markerPath) + .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ReadyMarker)))), ); - - const readReady = mapError( - "ready", - fs - .readFileString(path.join(options.instanceRoot, ".supabase-database-ready.json")) - .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ReadyMarker)))), - ); - - const sourceData = Effect.gen(function* () { - const ready = yield* readReady; - if (ready.version !== version || ready.runtime !== options.runtime) + if (ready.version !== version || ready.runtime !== runtime) return yield* errorFor("ready", "Database readiness marker does not match the instance"); - const data = path.join(options.instanceRoot, "data"); - if (!(yield* mapError("data", fs.exists(data)))) - return yield* errorFor("data", "Data is absent"); - if (yield* mapError("data", fs.exists(path.join(data, "postmaster.pid")))) - return yield* errorFor("data", "Database must be stopped before snapshotting"); - const pgVersion = yield* mapError("data", fs.readFileString(path.join(data, "PG_VERSION"))); - if (pgVersion.trim() !== version.split(".")[0]) - return yield* errorFor("data", "Database PostgreSQL major version is incompatible"); - return data; - }); - - const cleanupChildren = (directory: string, predicate: (name: string) => boolean) => - Effect.gen(function* () { - const names = yield* mapError("cleanup", fs.readDirectory(directory)); - for (const name of names) { - if (predicate(name)) - yield* mapError("cleanup", fs.remove(path.join(directory, name), { recursive: true })); - } - }); - - const cleanupStaleStages = Effect.gen(function* () { - yield* cleanupChildren(stages, (name) => name !== "." && name !== ".."); - yield* cleanupChildren(options.instanceRoot, (name) => name.startsWith(".supabase-restore-")); - }); - - const touch = (target: string) => - Effect.gen(function* () { - const now = yield* Clock.currentTimeMillis; - yield* mapError("touch", fs.utimes(target, now / 1_000, now / 1_000)); - }); - - const retention = (current: string) => - Effect.gen(function* () { - const names = yield* mapError("retention", fs.readDirectory(entries)); - const values: Array<{ readonly name: string; readonly mtime: number }> = []; - for (const name of names) { - if (name === current) continue; - const info = yield* mapError("retention", fs.stat(path.join(entries, name))); - values.push({ - name, - mtime: Option.match(info.mtime, { - onNone: () => 0, - onSome: (mtime) => mtime.getTime(), - }), - }); - } - values.sort((left, right) => left.mtime - right.mtime || left.name.localeCompare(right.name)); - for (const value of values.slice(0, Math.max(0, values.length - 2))) - yield* mapError( - "retention", - fs.remove(path.join(entries, value.name), { recursive: true }), - ); - }); - - const saveSnapshot = Effect.fn("DatabaseSnapshot.save")(function* (logicalKey: string) { - const source = yield* sourceData; - const keyDigest = yield* digest(logicalKey); - const descriptor = { ...keyDescriptor(logicalKey), keyDigest }; - const encoded = yield* mapError( - "descriptor", - Schema.encodeEffect(Schema.fromJsonString(SnapshotDescriptor))(descriptor), - ); - const target = path.join(entries, keyDigest); - yield* ensureStore; - yield* withLock( + const { keyDigest, descriptor } = yield* describe(logicalKey); + yield* locked( + backend, Effect.gen(function* () { - yield* mapError("storage", fs.makeDirectory(stages, { recursive: true, mode: 0o700 })); - yield* cleanupStaleStages; - const token = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("stage", cause)), - ); - const stage = path.join(stages, token); - const retired = path.join(stages, "retired-" + token); - yield* mapError("stage", fs.makeDirectory(stage, { recursive: true, mode: 0o700 })); - yield* Effect.acquireUseRelease( - Effect.succeed(stage), - () => - Effect.gen(function* () { - yield* copy(source, path.join(stage, "data")); - yield* mapError( - "descriptor", - fs.writeFileString(path.join(stage, "descriptor.json"), encoded, { mode: 0o600 }), - ); - const hadTarget = yield* mapError("publish", fs.exists(target)); - if (hadTarget) yield* mapError("publish", fs.rename(target, retired)); - const publication = yield* Effect.exit(mapError("publish", fs.rename(stage, target))); - if (Exit.isFailure(publication)) { - if (hadTarget) yield* mapError("rollback", fs.rename(retired, target)); - return yield* Effect.failCause(publication.cause); - } - yield* mapError("publish", fs.remove(retired, { recursive: true, force: true })); - yield* touch(target); - yield* retention(keyDigest); + const id = yield* token; + const stage = backend.join(backend.stages, id); + const retired = backend.join(backend.stages, `retired-${keyDigest}-${id}`); + const target = backend.join(backend.entries, keyDigest); + const result = yield* backend + .run([ + Ensure({ directory: backend.entries }), + ...reclaimStages(backend), + Expect({ + path: backend.join(backend.data, "postmaster.pid"), + present: false, + otherwise: "running", }), - () => mapError("cleanup", fs.remove(stage, { recursive: true, force: true })), - ); + ExpectText({ + file: backend.join(backend.data, "PG_VERSION"), + text: major, + otherwise: "major", + }), + Ensure({ directory: stage }), + Copy({ from: backend.data, to: backend.join(stage, "data") }), + Write({ file: backend.join(stage, "descriptor.json"), text: descriptor }), + Rename({ from: target, to: retired, optional: true }), + Rename({ from: stage, to: target, optional: false }), + Remove({ path: retired }), + Touch({ path: target }), + ...(scope === "cache" + ? [Prune({ directory: backend.entries, keep: retainedPrevious, except: keyDigest })] + : []), + ]) + .pipe( + compensate(backend, [ + Rename({ from: retired, to: target, optional: true }), + Remove({ path: stage }), + ]), + ); + if (result._tag === "Stopped") + return yield* result.outcome === "running" + ? errorFor("data", "Database must be stopped before snapshotting") + : errorFor("data", "Database data is missing or has another PostgreSQL major version"); }), ); }); - const restoreSnapshot = Effect.fn("DatabaseSnapshot.restore")(function* (logicalKey: string) { - const data = path.join(options.instanceRoot, "data"); - const keyDigest = yield* digest(logicalKey); - const target = path.join(entries, keyDigest); - yield* ensureStore; - return yield* withLock( - Effect.gen(function* () { - yield* mapError("storage", fs.makeDirectory(stages, { recursive: true, mode: 0o700 })); - yield* cleanupStaleStages; - if (yield* mapError("restore", fs.exists(data))) { - if ((yield* mapError("restore", fs.readDirectory(data))).length > 0) - return yield* errorFor("restore", "Restore target data directory must be empty"); - } - if (!(yield* mapError("restore", fs.exists(target)))) return false; - - const descriptor = yield* mapError( - "descriptor", - fs - .readFileString(path.join(target, "descriptor.json")) - .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(SnapshotDescriptor)))), - ); - if (descriptor.keyDigest !== keyDigest || descriptor.logicalKey !== logicalKey) - return yield* errorFor("descriptor", "Snapshot manifest does not match its key"); - if ( - descriptor.version !== version || - descriptor.runtime !== options.runtime || - descriptor.platform !== process.platform || - descriptor.arch !== process.arch - ) - return false; - - const token = yield* crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("stage", cause)), - ); - const stage = path.join(options.instanceRoot, ".supabase-restore-" + token); - const retired = path.join(options.instanceRoot, ".supabase-restore-retired-" + token); - const marker = path.join(options.instanceRoot, ".supabase-database-ready.json"); - const retiredMarker = path.join( - options.instanceRoot, - ".supabase-restore-retired-marker-" + token, - ); - const stagedMarker = path.join(stage, "ready.json"); - yield* mapError("stage", fs.makeDirectory(stage, { recursive: true, mode: 0o700 })); - yield* Effect.acquireUseRelease( - Effect.succeed(stage), - () => - Effect.gen(function* () { - yield* copy(path.join(target, "data"), path.join(stage, "data")); - const stagedData = path.join(stage, "data"); - const stagedVersion = yield* mapError( - "validate", - fs.readFileString(path.join(stagedData, "PG_VERSION")), - ); - if (stagedVersion.trim() !== version.split(".")[0]) - return yield* errorFor( - "validate", - "Snapshot PostgreSQL major version is incompatible", - ); - if (yield* mapError("validate", fs.exists(path.join(stagedData, "postmaster.pid")))) - return yield* errorFor("validate", "Snapshot contains a running database"); - const markerContents = yield* markerText(version, options.runtime).pipe( - Effect.mapError((cause) => errorFor("ready", cause)), - ); - yield* mapError( - "ready", - fs.writeFileString(stagedMarker, markerContents, { mode: 0o600 }), - ); + const publishReadyMarker = Effect.fnUntraced( + function* (id: string) { + const staged = `${markerPath}.${id}`; + const marker = yield* Schema.encodeEffect(Schema.fromJsonString(ReadyMarker))({ + version, + runtime, + profile: "supabase", + }); + yield* fs.writeFileString(staged, marker, { mode: 0o600 }); + yield* fs + .rename(staged, markerPath) + .pipe(Effect.onError(() => fs.remove(staged, { force: true }).pipe(Effect.ignore))); + }, + Effect.mapError((cause) => errorFor("ready", cause)), + ); - // The handoff is short and uninterruptible. If marker publication fails, the old - // complete data tree and marker are restored before the error escapes. - yield* Effect.uninterruptible( - Effect.gen(function* () { - const hadData = yield* mapError("publish", fs.exists(data)); - const hadMarker = yield* mapError("publish", fs.exists(marker)); - if (hadData) yield* mapError("publish", fs.rename(data, retired)); - const dataPublication = yield* Effect.exit( - mapError("publish", fs.rename(path.join(stage, "data"), data)), + const restoreSnapshot = Effect.fn("DatabaseSnapshot.restore")(function* ( + logicalKey: string, + scope: SnapshotScope = "cache", + ) { + const backend = backends[scope]; + const { keyDigest, descriptor } = yield* describe(logicalKey); + return yield* locked( + backend, + Effect.gen(function* () { + const id = yield* token; + const stage = backend.join(backend.restoreStages, id); + const published = backend.join(backend.restoreStages, `${id}.published`); + const entry = backend.join(backend.entries, keyDigest); + // Once the published record exists the target was empty, so rollback may clear it. + const rollback = [ + Remove({ path: stage }), + Expect({ path: published, present: true, otherwise: "miss" }), + Clear({ directory: backend.data }), + ]; + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const result = yield* restore( + backend.run([ + Ensure({ directory: backend.entries }), + ...reclaimStages(backend), + ExpectEmpty({ directory: backend.data, otherwise: "nonempty" }), + Expect({ path: entry, present: true, otherwise: "miss" }), + ExpectText({ + file: backend.join(entry, "descriptor.json"), + text: descriptor, + otherwise: "descriptor", + }), + ExpectText({ + file: backend.join(entry, "data", "PG_VERSION"), + text: major, + otherwise: "major", + }), + Expect({ + path: backend.join(entry, "data", "postmaster.pid"), + present: false, + otherwise: "running", + }), + Copy({ from: backend.join(entry, "data"), to: stage }), + Adopt({ directory: stage }), + Touch({ path: entry }), + Write({ file: published, text: id }), + Rename({ from: stage, to: backend.data, optional: false }), + ]), + ); + if (result._tag === "Stopped") { + switch (result.outcome) { + case "miss": + return false; + case "descriptor": + // A well-formed descriptor for another identity is a miss; anything else is corrupt. + return yield* Schema.decodeEffect(Schema.fromJsonString(SnapshotDescriptor))( + result.text, + ).pipe( + Effect.as(false), + Effect.mapError((cause) => errorFor("descriptor", cause)), ); - if (Exit.isFailure(dataPublication)) { - if (hadData) yield* mapError("rollback", fs.rename(retired, data)); - return yield* Effect.failCause(dataPublication.cause); - } - if (hadMarker) { - const markerRetirement = yield* Effect.exit( - mapError("publish", fs.rename(marker, retiredMarker)), - ); - if (Exit.isFailure(markerRetirement)) { - yield* mapError( - "rollback", - fs.remove(data, { recursive: true, force: true }), - ); - if (hadData) yield* mapError("rollback", fs.rename(retired, data)); - return yield* Effect.failCause(markerRetirement.cause); - } - } - const markerPublication = yield* Effect.exit( - mapError("ready", fs.rename(stagedMarker, marker)), + case "nonempty": + return yield* errorFor("restore", "Restore target data directory must be empty"); + case "major": + return yield* errorFor( + "validate", + "Snapshot PostgreSQL major version is incompatible", ); - if (Exit.isFailure(markerPublication)) { - if (hadMarker) yield* mapError("rollback", fs.rename(retiredMarker, marker)); - yield* mapError("rollback", fs.remove(data, { recursive: true, force: true })); - if (hadData) yield* mapError("rollback", fs.rename(retired, data)); - return yield* Effect.failCause(markerPublication.cause); - } - yield* mapError("publish", fs.remove(retired, { recursive: true, force: true })); - if (hadMarker) - yield* mapError("publish", fs.remove(retiredMarker, { force: true })); - }), - ); - yield* touch(target); - }), - () => mapError("cleanup", fs.remove(stage, { recursive: true, force: true })), - ); - return true; + case "running": + return yield* errorFor("validate", "Snapshot contains a running database"); + } + } + yield* publishReadyMarker(id); + return true; + }), + ).pipe(compensate(backend, rollback)); }), ); }); return { saveSnapshot, restoreSnapshot }; }); + +/** Interprets snapshot programs directly on the host filesystem, keeping entries under `root`. */ +const makeNativeSnapshotBackend = Effect.fnUntraced(function* (options: { + readonly instanceRoot: string; + readonly root: string; + readonly lockFile: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const { instanceRoot, root } = options; + const exists = (target: string) => fs.exists(target); + const isEmptyDirectory = (target: string) => + fs.readDirectory(target).pipe(Effect.map((names) => names.length === 0)); + const readText = (file: string) => + fs.readFileString(file).pipe( + Effect.map((text) => text.replace(/\n+$/u, "")), + Effect.orElseSucceed(() => undefined), + ); + const proceed = Option.none<SnapshotRun>(); + const stop = (outcome: SnapshotStop, text = "") => + Option.some(SnapshotRun.Stopped({ outcome, text })); + const step = ( + current: SnapshotStep, + ): Effect.Effect< + Option.Option<SnapshotRun>, + PlatformError | DirectoryCopyError | DatabaseSnapshotError + > => + SnapshotStep.$match(current, { + Ensure: ({ directory }) => + fs.makeDirectory(directory, { recursive: true, mode: 0o700 }).pipe(Effect.as(proceed)), + Clear: ({ directory }) => + Effect.gen(function* () { + if (yield* fs.readLink(directory).pipe(Effect.isSuccess)) + return yield* errorFor("clear", `${directory} is a symbolic link`); + yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 }); + for (const name of yield* fs.readDirectory(directory)) + yield* fs.remove(path.join(directory, name), { recursive: true, force: true }); + return proceed; + }), + Recover: ({ stages, entries }) => + Effect.gen(function* () { + if (!(yield* exists(stages))) return proceed; + for (const name of yield* fs.readDirectory(stages)) { + const digest = /^retired-([0-9a-f]+)-/u.exec(name)?.[1]; + if (digest === undefined || (yield* exists(path.join(entries, digest)))) continue; + yield* fs.makeDirectory(entries, { recursive: true, mode: 0o700 }); + yield* fs.rename(path.join(stages, name), path.join(entries, digest)); + } + return proceed; + }), + Expect: ({ path: target, present, otherwise }) => + exists(target).pipe(Effect.map((found) => (found === present ? proceed : stop(otherwise)))), + ExpectEmpty: ({ directory, otherwise }) => + Effect.gen(function* () { + if (!(yield* exists(directory)) || (yield* isEmptyDirectory(directory))) return proceed; + return stop(otherwise); + }), + ExpectText: ({ file, text, otherwise }) => + readText(file).pipe( + Effect.map((actual) => (actual === text ? proceed : stop(otherwise, actual ?? ""))), + ), + Copy: ({ from, to }) => + copyDirectory(from, to).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.as(proceed), + ), + // Native data already belongs to the user that runs the database. + Adopt: () => Effect.succeedNone, + Write: ({ file, text }) => + fs.writeFileString(file, text, { mode: 0o600 }).pipe(Effect.as(proceed)), + Rename: ({ from, to, optional }) => + Effect.gen(function* () { + const destination = yield* exists(to); + if (optional && (destination || !(yield* exists(from)))) return proceed; + if (destination) { + if (!(yield* isEmptyDirectory(to))) + return yield* errorFor("rename", `${to} already exists`); + yield* fs.remove(to, { recursive: true }); + } + yield* fs.makeDirectory(path.dirname(to), { recursive: true, mode: 0o700 }); + yield* fs.rename(from, to); + return proceed; + }), + Remove: ({ path: target }) => + fs.remove(target, { recursive: true, force: true }).pipe(Effect.as(proceed)), + Touch: ({ path: target }) => + Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => fs.utimes(target, now / 1_000, now / 1_000)), + Effect.as(proceed), + ), + Prune: ({ directory, keep, except }) => + Effect.gen(function* () { + const entries: Array<{ readonly name: string; readonly mtime: number }> = []; + for (const name of yield* fs.readDirectory(directory)) { + if (name === except) continue; + const info = yield* fs.stat(path.join(directory, name)); + const mtime = Option.match(info.mtime, { + onNone: () => 0, + onSome: (date) => date.getTime(), + }); + entries.push({ name, mtime }); + } + entries.sort( + (left, right) => right.mtime - left.mtime || left.name.localeCompare(right.name), + ); + for (const entry of entries.slice(keep)) + yield* fs.remove(path.join(directory, entry.name), { recursive: true, force: true }); + return proceed; + }), + }); + const backend: SnapshotBackend = { + lockFile: options.lockFile, + entries: path.join(root, "entries"), + stages: path.join(root, "stages"), + restoreStages: path.join(instanceRoot, ".supabase-restore"), + data: path.join(instanceRoot, "data"), + join: (...parts) => path.join(...parts), + run: Effect.fnUntraced(function* (steps) { + for (const current of steps) { + const stopped = yield* step(current).pipe( + Effect.mapError((cause) => errorFor(current._tag.toLowerCase(), cause)), + ); + if (Option.isSome(stopped)) return stopped.value; + } + return SnapshotRun.Completed(); + }), + }; + return backend; +}); + +/** Wires a native database instance's cache-scoped and instance-scoped snapshot backends. */ +export const makeDatabaseSnapshots = Effect.fn("DatabaseSnapshot.make")(function* (options: { + readonly instanceRoot: string; + readonly cacheRoot: string; + readonly runtime: DatabaseRuntime; + readonly version: string; +}) { + const path = yield* Path.Path; + const cache = path.join(options.cacheRoot, "stack-database-snapshots"); + const instance = path.join(options.instanceRoot, instanceSnapshotsDirectory); + // Both backends share the instance's data and restore stages. Service.storage runs one operation + // per instance at a time, so each lock only guards its own store across processes. + return yield* makeSnapshotStore({ + backends: { + cache: yield* makeNativeSnapshotBackend({ + instanceRoot: options.instanceRoot, + root: cache, + lockFile: path.join(cache, "locks", "native.sqlite"), + }), + instance: yield* makeNativeSnapshotBackend({ + instanceRoot: options.instanceRoot, + root: instance, + lockFile: path.join(instance, "lock.sqlite"), + }), + }, + instanceRoot: options.instanceRoot, + runtime: options.runtime, + version: postgresVersion(options.version), + }); +}); diff --git a/packages/stack/src/services/Functions.integration.test.ts b/packages/stack/src/services/Functions.integration.test.ts index 466d93c2a0..12a6ac4d60 100644 --- a/packages/stack/src/services/Functions.integration.test.ts +++ b/packages/stack/src/services/Functions.integration.test.ts @@ -1,11 +1,25 @@ -import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { NodeHttpClient, NodePath, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, FileSystem, Layer, Ref, Schedule, Schema, Stream } from "effect"; -import { HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"; +import { + Cause, + Crypto, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + Path, + Ref, + Schema, + Scope, + Stream, +} from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { ContainerError, type ContainerRuntime } from "../runtime/Container.ts"; import { makeService } from "../Service.ts"; -import { bundleServeMainTemplate } from "../../tests/serve-main-bundler.ts"; import { makeServiceRecipe } from "./Catalog.ts"; +import * as Functions from "./Functions.ts"; const options = (root: string) => ({ stackId: "catalog-functions", @@ -20,21 +34,6 @@ const dockerOptions = (root: string) => ({ runtime: "docker" as const, }); -// CI occasionally drops the first connection to a fresh container with no response. The notice -// goes to stderr because vitest hides console output from passing tests. -const getFunction = (client: HttpClient.HttpClient, url: string) => - client.execute(HttpClientRequest.get(url)).pipe( - Effect.retry( - Schedule.recurs(1).pipe( - Schedule.setInputType<HttpClientError.HttpClientError>(), - Schedule.while(({ input }) => input.reason._tag === "TransportError"), - Schedule.tap(({ input }) => - Effect.sync(() => process.stderr.write(`Retrying ${url} after ${input.message}\n`)), - ), - ), - ), - ); - const dockerInfo = Effect.scoped( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -60,13 +59,11 @@ describe("service catalog", () => { functionsRoot + "/hello/index.ts", "Deno.serve(() => Response.json({ custom: Deno.env.get('CUSTOM_ENV'), root: Deno.env.get('SUPABASE_INTERNAL_FUNCTIONS_ROOT'), port: Deno.env.get('EDGE_RUNTIME_PORT'), url: Deno.env.get('SUPABASE_URL'), db: Deno.env.get('SUPABASE_DB_URL'), jwt: Deno.env.get('SUPABASE_INTERNAL_JWT_SECRET'), jwks: Deno.env.get('SUPABASE_JWKS'), anon: Deno.env.get('SUPABASE_ANON_KEY'), service: Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'), publishable: Deno.env.get('SUPABASE_PUBLISHABLE_KEYS'), secret: Deno.env.get('SUPABASE_SECRET_KEYS') }));", ); - const bootstrap = yield* bundleServeMainTemplate; const recipe = yield* makeServiceRecipe( { service: "functions", config: { functionsRoot, - bootstrap, databaseUrl: "postgres://functions-db", apiUrl: "http://functions-api", jwtSecret: "functions-jwt-secret", @@ -101,9 +98,8 @@ describe("service catalog", () => { yield* instance.start; yield* instance.ready; const endpoint = yield* recipe.endpoint("http"); - const response = yield* getFunction( - client, - "http://" + endpoint.host + ":" + endpoint.port + "/hello", + const response = yield* client.execute( + HttpClientRequest.get("http://" + endpoint.host + ":" + endpoint.port + "/hello"), ); expect(response.status).toBe(200); expect(yield* response.json).toEqual( @@ -185,7 +181,6 @@ describe("service catalog", () => { service: "functions", config: { functionsRoot, - bootstrap: yield* bundleServeMainTemplate, verifyJwt: false, }, }, @@ -220,6 +215,160 @@ describe("service catalog", () => { ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), { timeout: 120_000 }, ); + + it.live( + "loads a function's configured deno.jsonc as its Deno config", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const temporaryRoot = `${process.cwd()}/tmp`; + yield* fs.makeDirectory(temporaryRoot, { recursive: true }); + const root = yield* fs.makeTempDirectoryScoped({ + directory: temporaryRoot, + prefix: "functions-deno-config-", + }); + const functionsRoot = `${root}/supabase/functions`; + yield* fs.makeDirectory(`${functionsRoot}/hello`, { recursive: true }); + yield* fs.writeFileString( + `${functionsRoot}/hello/deno.jsonc`, + '// Deno config files allow comments; plain import maps do not.\n{"imports":{"message":"./message.ts"}}', + ); + yield* fs.writeFileString( + `${functionsRoot}/hello/message.ts`, + 'export const message = "config";', + ); + yield* fs.writeFileString( + `${functionsRoot}/hello/index.ts`, + 'import { message } from "message"; Deno.serve(() => new Response(message));', + ); + const recipe = yield* makeServiceRecipe( + { + service: "functions", + config: { + functionsRoot, + verifyJwt: false, + functions: { hello: { import_map: `${functionsRoot}/hello/deno.jsonc` } }, + }, + }, + { + ...options(root), + stackId: "e".repeat(64), + instanceId: "deno-config", + cacheRoot: "/tmp/supabase-stack-artifacts", + }, + ); + const logs = yield* Ref.make(""); + yield* recipe.logs.pipe( + Stream.runForEach(({ bytes }) => + Ref.update(logs, (text) => text + new TextDecoder().decode(bytes)), + ), + Effect.forkScoped({ startImmediately: true }), + ); + const instance = yield* makeService(recipe.definition, { + id: "deno-config", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready.pipe( + Effect.tapError(() => Ref.get(logs).pipe(Effect.flatMap(Effect.logError))), + ); + const endpoint = yield* recipe.endpoint("http"); + + const response = yield* client.get(`http://${endpoint.host}:${endpoint.port}/hello`); + + expect(response.status, yield* Ref.get(logs)).toBe(200); + expect(yield* response.text).toBe("config"); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); + + it.live( + "warns when a configured Deno config is not the one Edge Runtime discovers", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const temporaryRoot = `${process.cwd()}/tmp`; + yield* fs.makeDirectory(temporaryRoot, { recursive: true }); + const root = yield* fs.makeTempDirectoryScoped({ + directory: temporaryRoot, + prefix: "functions-plain-deno-config-", + }); + const functionsRoot = `${root}/supabase/functions`; + yield* fs.makeDirectory(`${functionsRoot}/hello`, { recursive: true }); + yield* fs.makeDirectory(`${functionsRoot}/_shared`, { recursive: true }); + yield* fs.writeFileString( + `${functionsRoot}/_shared/deno.jsonc`, + '{"imports":{"message":"./message.ts"}}', + ); + yield* fs.writeFileString( + `${functionsRoot}/_shared/message.ts`, + 'export const message = "shared";', + ); + yield* fs.writeFileString( + `${functionsRoot}/hello/index.ts`, + 'import { message } from "message"; Deno.serve(() => new Response(message));', + ); + const recipe = yield* makeServiceRecipe( + { + service: "functions", + config: { + functionsRoot, + verifyJwt: false, + functions: { hello: { import_map: `${functionsRoot}/_shared/deno.jsonc` } }, + }, + }, + { + ...options(root), + stackId: "f".repeat(64), + instanceId: "plain-deno-config", + cacheRoot: "/tmp/supabase-stack-artifacts", + }, + ); + const logs = yield* Ref.make(""); + const warned = yield* Deferred.make<void>(); + yield* recipe.logs.pipe( + Stream.runForEach(({ bytes }) => + Ref.updateAndGet(logs, (text) => text + new TextDecoder().decode(bytes)).pipe( + Effect.flatMap((text) => + text.includes("is not the nearest Deno config") + ? Deferred.succeed(warned, undefined) + : Effect.void, + ), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ); + const instance = yield* makeService(recipe.definition, { + id: "plain-deno-config", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready.pipe( + Effect.tapError(() => Ref.get(logs).pipe(Effect.flatMap(Effect.logError))), + ); + const endpoint = yield* recipe.endpoint("http"); + + const response = yield* client.get(`http://${endpoint.host}:${endpoint.port}/hello`); + yield* Deferred.await(warned).pipe( + Effect.timeout("30 seconds"), + Effect.tapError(() => Ref.get(logs).pipe(Effect.flatMap(Effect.logError))), + ); + + expect(response.status).toBe(200); + expect(yield* Ref.get(logs)).toContain( + `hello: ${yield* fs.realPath(functionsRoot)}/_shared/deno.jsonc is not the nearest Deno config`, + ); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 120_000 }, + ); }); for (const runtime of ["native", "docker"] as const) { @@ -264,7 +413,6 @@ for (const runtime of ["native", "docker"] as const) { config: { functionsRoot, filesRoot, - bootstrap: yield* bundleServeMainTemplate, jwtSecret: "test-function-jwt-with-at-least-32-characters", verifyJwt: true, env: { @@ -316,7 +464,7 @@ for (const runtime of ["native", "docker"] as const) { ); const endpoint = yield* recipe.endpoint("http"); const base = `http://${endpoint.host}:${endpoint.port}`; - const response = yield* getFunction(client, `${base}/hello`); + const response = yield* client.execute(HttpClientRequest.get(`${base}/hello`)); const responseText = yield* response.text; expect(response.status, responseText).toBe(200); const body = yield* Schema.decodeEffect( @@ -352,3 +500,85 @@ for (const runtime of ["native", "docker"] as const) { { timeout: 120_000 }, ); } + +it.effect("passes POSIX project paths to a docker Functions container from a Windows host", () => + Effect.scoped( + Effect.gen(function* () { + const launched = yield* Ref.make<Parameters<ContainerRuntime["launch"]>[0] | undefined>( + undefined, + ); + const container: ContainerRuntime = { + prepare: () => Effect.void, + prepareImage: (image) => Effect.succeed(image), + launchCommand: () => Effect.die("Functions has no startup commands"), + launch: (spec) => + Ref.set(launched, spec).pipe( + Effect.andThen( + Effect.fail(new ContainerError({ operation: "start", message: "captured" })), + ), + ), + }; + const filesRoot = "C:\\Users\\dev\\project"; + const creation: Functions.Creation = { + service: "functions", + config: { + functionsRoot: `${filesRoot}\\supabase\\functions`, + filesRoot, + functions: { + hello: { + entrypoint: `${filesRoot}\\source\\main.ts`, + import_map: `${filesRoot}\\supabase\\import_map.json`, + static_files: [`${filesRoot}\\source\\*.txt`], + }, + }, + }, + }; + const recipe = yield* Functions.makeRecipe( + creation, + { + stackId: "e".repeat(64), + instanceId: "windows", + root: "C:\\Users\\dev\\stack", + cacheRoot: "C:\\Users\\dev\\cache", + runtime: "docker", + }, + { + fs: yield* FileSystem.FileSystem, + path: yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32)), + crypto: yield* Crypto.Crypto, + client: yield* HttpClient.HttpClient, + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + container, + }, + ); + const scope = yield* Scope.make(); + yield* recipe.definition + .launch({ id: "windows", config: creation, scope }) + .pipe(Effect.flip, Effect.ensuring(Scope.close(scope, Exit.void))); + + const spec = yield* Ref.get(launched); + expect(spec?.env.SUPABASE_INTERNAL_FUNCTIONS_ROOT).toBe( + "/__supabase_project/supabase/functions", + ); + expect(spec?.env.SUPABASE_INTERNAL_FUNCTIONS_FILES_ROOT).toBe("/__supabase_project"); + expect(spec?.args).toContain("--main-service=/__supabase_functions"); + const config = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ + entrypoint: Schema.optionalKey(Schema.String), + import_map: Schema.optionalKey(Schema.String), + static_files: Schema.optionalKey(Schema.Array(Schema.String)), + }), + ), + ), + )(spec?.env.SUPABASE_INTERNAL_FUNCTIONS_CONFIG); + expect(config.hello).toEqual({ + entrypoint: "/__supabase_project/source/main.ts", + import_map: "/__supabase_project/supabase/import_map.json", + static_files: ["/__supabase_project/source/*.txt"], + }); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/packages/stack/src/services/Functions.ts b/packages/stack/src/services/Functions.ts index b8c190ac60..d3c81eb740 100644 --- a/packages/stack/src/services/Functions.ts +++ b/packages/stack/src/services/Functions.ts @@ -3,6 +3,7 @@ import { makeFunctionsBootstrapOwner, type FunctionsBootstrapOwner, } from "../functions/FunctionsBootstrap.ts"; +import { defaultFunctionsBootstrap } from "../functions/generated/serve-main-bundle.ts"; import { StackIdSchema } from "../identity/StackId.ts"; import { ServiceError } from "../Service.ts"; import { serviceJwt } from "./ServiceConfig.ts"; @@ -32,7 +33,7 @@ export const Config = Schema.Struct({ functionsRoot: Schema.String, filesRoot: Schema.optionalKey(Schema.String), functions: Schema.optionalKey(Schema.Record(Schema.String, FunctionSettings)), - bootstrap: Schema.String, + bootstrap: Schema.optionalKey(Schema.String), databaseUrl: Schema.optionalKey(Schema.String), env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), apiUrl: Schema.optionalKey(Schema.String), @@ -90,13 +91,17 @@ const makeSpec = ( }), ), ); - const runtimePath = (value: string) => - !path.isAbsolute(value) || filesRoot === undefined || canonicalFilesRoot === undefined - ? value - : path.join( - container ? "/__supabase_project" : canonicalFilesRoot, - path.relative(filesRoot, value), - ); + const runtimePath = (value: string) => { + if (!path.isAbsolute(value) || filesRoot === undefined || canonicalFilesRoot === undefined) + return value; + const relative = path.relative(filesRoot, value); + // Container paths are POSIX regardless of the host path flavor. + return container + ? ["/__supabase_project", ...relative.split(path.sep).filter((part) => part !== "")].join( + "/", + ) + : path.join(canonicalFilesRoot, relative); + }; const root = container && filesRoot === undefined ? "/__supabase_functions" @@ -200,9 +205,9 @@ const makeSpec = ( : [{ source: override, target: "/__supabase_bootstrap", readOnly: true }]), ]; }), - startup: [], + startupCommands: [], prepare: (creation) => - bootstrap.write({ content: creation.config.bootstrap }).pipe( + bootstrap.write({ content: creation.config.bootstrap ?? defaultFunctionsBootstrap }).pipe( Effect.flatMap((target) => Ref.set(functionsRoot, path.dirname(target))), Effect.mapError( (cause) => diff --git a/packages/stack/src/services/Imgproxy.ts b/packages/stack/src/services/Imgproxy.ts index de0bd405e1..7c497b7760 100644 --- a/packages/stack/src/services/Imgproxy.ts +++ b/packages/stack/src/services/Imgproxy.ts @@ -34,5 +34,5 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ ? [] : [{ source: creation.config.filePath, target: "/mnt", readOnly: true }], ), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/Initialization.ts b/packages/stack/src/services/Initialization.ts new file mode 100644 index 0000000000..7e67c439f5 --- /dev/null +++ b/packages/stack/src/services/Initialization.ts @@ -0,0 +1,116 @@ +import { Effect } from "effect"; +import { resolveArtifact } from "../Artifacts.ts"; +import type { InitializationCommand, ResolvedCommand } from "../Commands.ts"; +import { ServiceError } from "../Service.ts"; +import type { StackCredentials } from "../State.ts"; +import * as Auth from "./Auth.ts"; +import * as Realtime from "./Realtime.ts"; +import * as Storage from "./Storage.ts"; +import { + resolveStartupCommand, + startupEndpointsFor, + type ProcessRecipeSpec, + type StartupCommand, +} from "./ProcessRecipe.ts"; +import type { RecipeCreation, CatalogOptions } from "./Recipe.ts"; + +const resolve = <C extends RecipeCreation<"auth" | "storage" | "realtime", unknown>>( + service: C["service"], + creation: C, + spec: ProcessRecipeSpec<C>, + command: StartupCommand & { readonly containerEntrypoint: string }, + runtime: CatalogOptions["runtime"], +): Effect.Effect<ResolvedCommand, ServiceError> => + Effect.gen(function* () { + const artifact = yield* resolveArtifact({ service, version: creation.version }); + const startup = yield* resolveStartupCommand( + creation, + spec, + command, + startupEndpointsFor(creation, spec, { container: runtime !== "native" }), + { container: runtime !== "native" }, + ); + return { + service, + version: artifact.version, + image: artifact.image, + nativeExecutable: startup.executable, + containerEntrypoint: command.containerEntrypoint, + args: startup.args, + env: startup.env, + mounts: startup.mounts, + } satisfies ResolvedCommand; + }).pipe( + Effect.mapError((cause) => + cause instanceof ServiceError + ? cause + : new ServiceError({ + operation: "command", + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ); + +/** Resolves a typed one-shot service command from the service's ordinary recipe. */ +export const resolveInitializationCommand = ( + input: InitializationCommand, + options: { + readonly runtime: CatalogOptions["runtime"]; + readonly credentials: StackCredentials; + }, +): Effect.Effect<ResolvedCommand, ServiceError> => { + switch (input.type) { + case "auth.initialize": + return resolve( + "auth", + { + service: "auth", + ...(input.version === undefined ? {} : { version: input.version }), + config: { + databaseUrl: input.databaseUrl, + jwtSecret: options.credentials.jwtSecret, + gotrueJwtKeys: options.credentials.gotrueJwtKeys, + }, + }, + Auth.makeSpec(), + Auth.initializationCommand, + options.runtime, + ); + case "storage.initialize": + return resolve( + "storage", + { + service: "storage", + ...(input.version === undefined ? {} : { version: input.version }), + config: { + databaseUrl: input.databaseUrl, + filePath: input.filePath, + jwtSecret: options.credentials.jwtSecret, + jwks: options.credentials.jwks, + anonKey: options.credentials.anonKey, + serviceRoleKey: options.credentials.serviceRoleKey, + }, + }, + Storage.makeSpec(), + Storage.initializationCommand, + options.runtime, + ); + case "realtime.initialize": + return resolve( + "realtime", + { + service: "realtime", + ...(input.version === undefined ? {} : { version: input.version }), + config: { + databaseUrl: input.databaseUrl, + jwtSecret: options.credentials.jwtSecret, + jwks: options.credentials.jwks, + }, + }, + Realtime.makeSpec(), + Realtime.initializationCommand, + options.runtime, + ); + } +}; diff --git a/packages/stack/src/services/InstanceRoot.integration.test.ts b/packages/stack/src/services/InstanceRoot.integration.test.ts new file mode 100644 index 0000000000..62b9c2fe65 --- /dev/null +++ b/packages/stack/src/services/InstanceRoot.integration.test.ts @@ -0,0 +1,45 @@ +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { ensureOwnedInstanceRoot, type OwnedInstanceRootParams } from "./InstanceRoot.ts"; + +class TestInstanceRootError extends Data.TaggedError("TestInstanceRootError")<{ + readonly operation: string; + readonly cause: unknown; +}> {} + +const onError = (operation: string, cause: unknown) => + new TestInstanceRootError({ operation, cause }); + +const run = <A, E, R>(effect: Effect.Effect<A, E, R>) => + Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)); + +describe("InstanceRoot", () => { + it.live("lets concurrent first claims of a fresh root all succeed", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parentRoot = yield* fs.makeTempDirectoryScoped({ prefix: "instance-root-race-" }); + const params: OwnedInstanceRootParams = { + fs, + path, + parentRoot, + stackId: "stack", + instanceId: "instance", + ownerFileName: ".supabase-instance-owner.json", + label: "Instance root", + }; + const results = yield* Effect.all( + Array.from({ length: 10 }, () => Effect.exit(ensureOwnedInstanceRoot(params, onError))), + { concurrency: "unbounded" }, + ); + expect(results.every(Exit.isSuccess)).toBe(true); + const marker = yield* fs.readFileString( + path.join(parentRoot, "instance", ".supabase-instance-owner.json"), + ); + expect(marker).toBe('{"stackId":"stack","instanceId":"instance"}'); + }), + ), + ); +}); diff --git a/packages/stack/src/services/InstanceRoot.ts b/packages/stack/src/services/InstanceRoot.ts new file mode 100644 index 0000000000..49d7a998d1 --- /dev/null +++ b/packages/stack/src/services/InstanceRoot.ts @@ -0,0 +1,114 @@ +import { Effect, type FileSystem, type Path } from "effect"; + +/** The container mount target for an instance-scoped directory owned on the host. */ +export const containerInstancePath = "/instance"; + +const safeInstanceIdPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u; + +/** True when `instanceId` is safe to join to a root as one path segment, without traversal. */ +const isSafeInstanceId = (instanceId: string): boolean => safeInstanceIdPattern.test(instanceId); + +export interface OwnedInstanceRootParams { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + /** Parent directory the instance-scoped root is joined under, as `<parentRoot>/<instanceId>`. */ + readonly parentRoot: string; + readonly stackId: string; + readonly instanceId: string; + readonly ownerFileName: string; + /** Names the owned instance root in error messages, e.g. "Database root". */ + readonly label: string; +} + +/** Claims `<parentRoot>/<instanceId>` for one stack+instance pair, failing if another instance already owns it. */ +export const ensureOwnedInstanceRoot = Effect.fn("InstanceRoot.ensureOwnedInstanceRoot")(<E>( + params: OwnedInstanceRootParams, + onError: (operation: string, cause: unknown) => E, +): Effect.Effect<void, E> => { + const { fs, path, parentRoot, stackId, instanceId, ownerFileName, label } = params; + const root = path.join(parentRoot, instanceId); + const ownerFile = path.join(root, ownerFileName); + const marker = JSON.stringify({ stackId, instanceId }); + const claimExistingMarker = Effect.gen(function* () { + const existing = yield* fs + .readFileString(ownerFile) + .pipe(Effect.mapError((cause) => onError("data", cause))); + if (existing !== marker) + return yield* Effect.fail(onError("data", `${label} belongs to another instance`)); + }); + return Effect.gen(function* () { + if (!isSafeInstanceId(instanceId)) + return yield* Effect.fail(onError("data", `${label} instance id is not a safe path segment`)); + yield* fs + .makeDirectory(root, { recursive: true, mode: 0o700 }) + .pipe(Effect.mapError((cause) => onError("data", cause))); + const present = yield* fs + .exists(ownerFile) + .pipe(Effect.mapError((cause) => onError("data", cause))); + if (present) return yield* claimExistingMarker; + const entries = yield* fs + .readDirectory(root) + .pipe(Effect.mapError((cause) => onError("data", cause))); + if (entries.length > 0) { + // A concurrent first claim may have written the marker between the `exists` and + // `readDirectory` calls above; an otherwise-empty root is still safe to compare. + if (entries.length === 1 && entries[0] === ownerFileName) return yield* claimExistingMarker; + return yield* Effect.fail(onError("data", `${label} is non-empty and unmarked`)); + } + yield* fs.writeFileString(ownerFile, marker, { mode: 0o600, flag: "wx" }).pipe( + // A concurrent first claim may win the exclusive create; the loser re-reads the marker + // instead of failing, since both wrote the same stack+instance marker. + Effect.catchIf( + (error) => error.reason._tag === "AlreadyExists", + () => claimExistingMarker, + ), + Effect.catchTag("PlatformError", (cause) => Effect.fail(onError("data", cause))), + ); + }); +}); + +/** Removes `<parentRoot>/<instanceId>` for one stack+instance pair after running `removeData`, keeping any `keep` entries. */ +export const removeOwnedInstanceRoot = Effect.fn("InstanceRoot.removeOwnedInstanceRoot")(<E>( + params: OwnedInstanceRootParams, + removeData: Effect.Effect<void, E>, + onError: (operation: string, cause: unknown) => E, + /** Root entries kept with the owner marker; when empty the root itself is removed. */ + keep: ReadonlyArray<string> = [], +): Effect.Effect<void, E> => { + const { fs, path, parentRoot, stackId, instanceId, ownerFileName, label } = params; + const root = path.join(parentRoot, instanceId); + const ownerFile = path.join(root, ownerFileName); + const marker = JSON.stringify({ stackId, instanceId }); + return Effect.gen(function* () { + if (!isSafeInstanceId(instanceId)) + return yield* Effect.fail( + onError("destroy", `${label} instance id is not a safe path segment`), + ); + const present = yield* fs + .exists(ownerFile) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + if (!present) { + if (yield* fs.exists(root).pipe(Effect.mapError((cause) => onError("destroy", cause)))) + return yield* Effect.fail(onError("destroy", `${label} is unmarked`)); + return; + } + const existing = yield* fs + .readFileString(ownerFile) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + if (existing !== marker) + return yield* Effect.fail(onError("destroy", `${label} belongs to another instance`)); + yield* removeData; + if (keep.length === 0) + return yield* fs + .remove(root, { recursive: true, force: true }) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + const names = yield* fs + .readDirectory(root) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + for (const name of names) + if (name !== path.basename(ownerFile) && !keep.includes(name)) + yield* fs + .remove(path.join(root, name), { recursive: true, force: true }) + .pipe(Effect.mapError((cause) => onError("destroy", cause))); + }); +}); diff --git a/packages/stack/src/services/Mail.integration.test.ts b/packages/stack/src/services/Mail.integration.test.ts index eea7be5eff..f80d9a3073 100644 --- a/packages/stack/src/services/Mail.integration.test.ts +++ b/packages/stack/src/services/Mail.integration.test.ts @@ -1,7 +1,7 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer } from "effect"; -import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { makeService } from "../Service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; @@ -18,6 +18,42 @@ const dockerOptions = (root: string) => ({ runtime: "docker" as const, }); +interface MailpitMessages { + readonly total: number; + readonly messages: ReadonlyArray<{ readonly Subject: string }>; +} + +const fetchMessages = (endpoint: { readonly host?: string; readonly port: number }) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const host = endpoint.host ?? "127.0.0.1"; + const response = yield* client.execute( + HttpClientRequest.get(`http://${host}:${endpoint.port}/api/v1/messages`), + ); + return (yield* response.json) as unknown as MailpitMessages; + }); + +/** Sends a test email through Mailpit's HTTP send API rather than a raw SMTP session. */ +const sendTestEmail = ( + endpoint: { readonly host?: string; readonly port: number }, + subject: string, +) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const host = endpoint.host ?? "127.0.0.1"; + const response = yield* client.execute( + HttpClientRequest.post(`http://${host}:${endpoint.port}/api/v1/send`).pipe( + HttpClientRequest.bodyJsonUnsafe({ + From: { Email: "sender@example.com" }, + To: [{ Email: "recipient@example.com" }], + Subject: subject, + Text: "body", + }), + ), + ); + yield* HttpClientResponse.filterStatusOk(response); + }); + describe("service catalog", () => { it.live("launches the real Mailpit recipe and serves its HTTP endpoint", () => Effect.scoped( @@ -44,4 +80,144 @@ describe("service catalog", () => { }), ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), ); + + it.live( + "keeps two native mail instances on one isolated database each, both becoming ready", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-mail-isolation-" }); + const recipeFor = (instanceId: string) => + makeServiceRecipe({ service: "mail", config: {} }, { ...options(root), instanceId }); + const first = yield* recipeFor("mail-a"); + const second = yield* recipeFor("mail-b"); + const firstInstance = yield* makeService(first.definition, { + id: "mail-a", + config: first.creation, + }); + const secondInstance = yield* makeService(second.definition, { + id: "mail-b", + config: second.creation, + }); + yield* Effect.all([firstInstance.start, secondInstance.start], { + concurrency: "unbounded", + }); + yield* Effect.all([firstInstance.ready, secondInstance.ready], { + concurrency: "unbounded", + }); + expect(yield* fs.exists(path.join(root, "mail-a", "mailpit.db"))).toBe(true); + expect(yield* fs.exists(path.join(root, "mail-b", "mailpit.db"))).toBe(true); + yield* Effect.all([firstInstance.stop, secondInstance.stop], { + concurrency: "unbounded", + }); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("keeps a captured email after a native mail stop and start", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-mail-persistence-" }); + const recipe = yield* makeServiceRecipe({ service: "mail", config: {} }, options(root)); + const instance = yield* makeService(recipe.definition, { + id: "mail", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready; + yield* sendTestEmail(yield* recipe.endpoint("http"), "persistence-test"); + const beforeRestart = yield* fetchMessages(yield* recipe.endpoint("http")); + expect(beforeRestart.messages.map((message) => message.Subject)).toContain( + "persistence-test", + ); + yield* instance.stop; + yield* instance.start; + yield* instance.ready; + const afterRestart = yield* fetchMessages(yield* recipe.endpoint("http")); + expect(afterRestart.messages.map((message) => message.Subject)).toContain( + "persistence-test", + ); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("keeps a captured email after a Docker mail stop and start", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "catalog-mail-persistence-docker-", + }); + const recipe = yield* makeServiceRecipe( + { service: "mail", config: {} }, + dockerOptions(root), + ); + const instance = yield* makeService(recipe.definition, { + id: "mail", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready; + yield* sendTestEmail(yield* recipe.endpoint("http"), "persistence-test-docker"); + const beforeRestart = yield* fetchMessages(yield* recipe.endpoint("http")); + expect(beforeRestart.messages.map((message) => message.Subject)).toContain( + "persistence-test-docker", + ); + yield* instance.stop; + yield* instance.start; + yield* instance.ready; + const afterRestart = yield* fetchMessages(yield* recipe.endpoint("http")); + expect(afterRestart.messages.map((message) => message.Subject)).toContain( + "persistence-test-docker", + ); + yield* instance.stop; + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("removes the native mail instance directory when destroyed", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-mail-destroy-" }); + const recipe = yield* makeServiceRecipe({ service: "mail", config: {} }, options(root)); + const instance = yield* makeService(recipe.definition, { + id: "mail", + config: recipe.creation, + }); + yield* instance.start; + yield* instance.ready; + const instanceRoot = path.join(root, "instance"); + expect(yield* fs.exists(instanceRoot)).toBe(true); + yield* instance.destroy; + expect(yield* fs.exists(instanceRoot)).toBe(false); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); + + it.live("rejects a traversal instance id and creates nothing outside the root", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "catalog-mail-traversal-" }); + const recipe = yield* makeServiceRecipe( + { service: "mail", config: {} }, + { ...options(root), instanceId: "../escaped" }, + ); + const instance = yield* makeService(recipe.definition, { + id: "mail", + config: recipe.creation, + }); + const exit = yield* Effect.exit(instance.start); + expect(Exit.isFailure(exit)).toBe(true); + expect(yield* fs.exists(path.join(root, "..", "escaped"))).toBe(false); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + ); }); diff --git a/packages/stack/src/services/Mail.ts b/packages/stack/src/services/Mail.ts index cfa43810b7..3f7ef84ac0 100644 --- a/packages/stack/src/services/Mail.ts +++ b/packages/stack/src/services/Mail.ts @@ -21,7 +21,10 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ executable: "bin/mailpit", ports: { http: 8025, smtp: 1025, pop3: 1110 }, healthPath: "/readyz", - env: (_creation, endpoints, container) => { + // Mailpit's default /tmp temp-file name collides across parallel native instances, and it + // deletes that file on every stop regardless of runtime, losing captured mail. + instanceDirectory: true, + env: (_creation, endpoints, container, instanceDir) => { const http = endpoints.get("http"); const smtp = endpoints.get("smtp"); const pop3 = endpoints.get("pop3"); @@ -46,9 +49,10 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ }), // Docker gateway addresses can make SMTP reverse DNS delay Auth recovery. MP_SMTP_DISABLE_RDNS: "true", + ...(instanceDir === undefined ? {} : { MP_DATABASE: `${instanceDir}/mailpit.db` }), }); }, args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/Pgmeta.ts b/packages/stack/src/services/Pgmeta.ts index 87c95452a3..ecaefffb37 100644 --- a/packages/stack/src/services/Pgmeta.ts +++ b/packages/stack/src/services/Pgmeta.ts @@ -1,9 +1,9 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; -export const Config = Schema.Struct({ databaseUrl: Schema.String }); +export const Config = Schema.Struct({ databaseUrl: Schema.optionalKey(Schema.String) }); export interface Config extends Schema.Schema.Type<typeof Config> {} export const Endpoints = Schema.Struct({ http: Schema.optionalKey(EndpointIntent) }); @@ -21,13 +21,18 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ env: (creation, endpoints, container) => Effect.gen(function* () { const http = endpoints.get("http"); - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput( + "pgmeta", + "databaseUrl", + creation.config.databaseUrl, + ); + const db = yield* databaseConnection(databaseUrl); return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, PG_META_HOST: container ? "0.0.0.0" : "127.0.0.1", ...(container ? {} : { PG_META_ADMIN_PORT: "0" }), ...(http === undefined ? {} : { PG_META_PORT: String(http.port) }), - PG_META_DB_URL: creation.config.databaseUrl, + PG_META_DB_URL: databaseUrl, PG_META_DB_HOST: db.host, PG_META_DB_PORT: db.port, PG_META_DB_NAME: db.database, @@ -37,5 +42,5 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ }), args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/Pooler.ts b/packages/stack/src/services/Pooler.ts index 53b224b279..7e58fb7b11 100644 --- a/packages/stack/src/services/Pooler.ts +++ b/packages/stack/src/services/Pooler.ts @@ -1,6 +1,6 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection, localJwtSecret } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput, localJwtSecret } from "./ServiceConfig.ts"; import { DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, DEFAULT_POOLER_VAULT_ENCRYPTION_KEY, @@ -8,7 +8,7 @@ import { import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), jwtSecret: Schema.optionalKey(Schema.String), tenant: Schema.optionalKey(Schema.String), defaultPoolSize: Schema.optionalKey(Schema.Finite), @@ -28,10 +28,11 @@ const environment: ProcessRecipeSpec<Creation>["env"] = (creation, endpoints, co Effect.gen(function* () { const http = endpoints.get("http"); const sql = endpoints.get("sql"); - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput("pooler", "databaseUrl", creation.config.databaseUrl); + const db = yield* databaseConnection(databaseUrl); const mode = creation.config.poolMode ?? "transaction"; return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, ...(http === undefined ? {} : { PORT: String(http.port) }), ...(creation.config.tenant === undefined ? {} : { TENANT_ID: creation.config.tenant }), POSTGRES_HOST: db.host, @@ -105,7 +106,7 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ containerPort: (creation, name, port) => name === "sql" && creation.config.poolMode === "session" ? 5432 : port, containerEntrypoint: () => "/usr/bin/tini", - startup: [ + startupCommands: [ { args: [], nativeExecutable: "prepare", diff --git a/packages/stack/src/services/ProcessRecipe.integration.test.ts b/packages/stack/src/services/ProcessRecipe.integration.test.ts index ff36a2fcef..9f14cc70ce 100644 --- a/packages/stack/src/services/ProcessRecipe.integration.test.ts +++ b/packages/stack/src/services/ProcessRecipe.integration.test.ts @@ -24,7 +24,7 @@ import { systemError } from "effect/PlatformError"; import * as Net from "node:net"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- the collision fixture owns a local HTTP listener. import * as NodeHttp from "node:http"; -import { prepareNativeArtifact } from "../Artifacts.ts"; +import { catalogPins, resolveArtifact, type ServiceKind } from "../Artifacts.ts"; import { makeArtifactStore, type ArtifactRequest, @@ -49,12 +49,21 @@ import * as Pooler from "./Pooler.ts"; type TestCreation = RecipeCreation<"rest", Record<string, never>> & { readonly service: "rest"; - readonly version: "v16.2"; + readonly version: string; }; +// The real catalog's default postgrest pin, not hardcoded — `makeProcessRecipe` resolves this +// through the real catalog, which would otherwise fail once a bump moves past a literal. +const postgrestVersion = catalogPins().find( + (entry) => entry.sourceService === "postgrest" && entry.isDefault, +)?.pin.upstreamVersion; +if (postgrestVersion === undefined) { + throw new Error("no default postgrest catalog pin found"); +} + const creation: TestCreation = { service: "rest", - version: "v16.2", + version: postgrestVersion, config: {}, }; @@ -74,7 +83,7 @@ const spec: ProcessRecipeSpec<TestCreation> = { args: () => Effect.succeed([]), env: () => Effect.succeed({}), mounts: () => Effect.succeed([]), - startup: [{ args: [] }], + startupCommands: [{ args: [] }], }; const isPortOccupied = (port: number): Effect.Effect<boolean> => @@ -135,7 +144,7 @@ describe("ProcessRecipe launch cleanup", () => { const container: ContainerRuntime = { prepare: () => Effect.void, prepareImage: (image) => Effect.succeed(image), - launchTool: () => + launchCommand: () => Effect.gen(function* () { const launch = yield* Ref.updateAndGet(startupLaunches, (value) => value + 1); if (launch === 1) { @@ -246,15 +255,18 @@ describe("ProcessRecipe launch cleanup", () => { }), ), ); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-native-" }); + const cacheRoot = path.join(root, "cache"); + yield* nativeRestArtifact(cacheRoot); const nativeOptions: CatalogOptions = { ...options, - root: yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-native-" }), - cacheRoot: "/tmp/supabase-stack-artifacts", + root, + cacheRoot, runtime: "native", }; const nativeSpec: ProcessRecipeSpec<TestCreation> = { ...spec, - startup: [{ nativeExecutable: "postgrest", args: ["--version"] }], + startupCommands: [{ nativeExecutable: "postgrest", args: ["--version"] }], }; const dependencies = { fs, @@ -294,17 +306,8 @@ describe("ProcessRecipe launch cleanup", () => { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const testScope = yield* Scope.Scope; const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-port-order-" }); - const cacheRoot = "/tmp/supabase-stack-artifacts"; - const artifact = yield* prepareNativeArtifact( - { service: "rest", version: "v16.2" }, - cacheRoot, - ).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(HttpClient.HttpClient, client), - ); + const cacheRoot = path.join(root, "cache"); + const artifact = yield* nativeRestArtifact(cacheRoot); const nativeOptions: CatalogOptions = { ...options, root, @@ -365,9 +368,9 @@ describe("ProcessRecipe launch cleanup", () => { } return { PORT: String(port) }; }), - startup: [ + startupCommands: [ { - nativeExecutable: path.relative(path.join(artifact.root, "bin"), process.execPath), + nativeExecutable: path.relative(path.join(artifact.path, "bin"), process.execPath), args: [ "-e", `import net from "node:net"; const server = net.createServer(); server.once("error", () => process.exit(17)); server.listen(Number(process.env.PORT), "127.0.0.1", () => server.close((error) => process.exit(error ? 18 : 0)));`, @@ -419,7 +422,7 @@ const startupContainer = (tool: { prepare: () => Effect.void, prepareImage: (image) => Effect.succeed(image), launch: () => Effect.die("the main process must not launch after a failed startup"), - launchTool: () => + launchCommand: () => Effect.succeed({ id: "startup-tool", ports: {}, @@ -463,7 +466,15 @@ const realtimeService = Effect.fn(function* (container: ContainerRuntime) { const platform = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); -const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { +const nativeFixtureArtifact = Effect.fn(function* ( + cacheRoot: string, + artifact: { + readonly service: ServiceKind; + readonly name: string; + readonly executablePath: string; + readonly files: Readonly<Record<string, string>>; + }, +) { const platformName = `${process.platform}-${process.arch}`; const target = platformName === "darwin-arm64" @@ -475,12 +486,45 @@ const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { : undefined; if (target === undefined) return yield* Effect.fail(`Unsupported test platform: ${platformName}`); + const { releaseVersion } = yield* resolveArtifact({ service: artifact.service }); const request: ArtifactRequest = { - key: `slim-services/pooler/v2.9.12/${target}`, - requiredRuntimePaths: ["bin/server", "bin/prepare", "bin/provision-tenant"], - executablePath: "bin/server", + key: `slim-services/${artifact.name}/${releaseVersion}/${target}`, + requiredRuntimePaths: Object.keys(artifact.files), + executablePath: artifact.executablePath, }; const fs = yield* FileSystem.FileSystem; + const source: ArtifactSource = { + checksum: () => Effect.succeed("0".repeat(64)), + materialize: (_entry, destination) => + Effect.gen(function* () { + yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); + for (const [file, content] of Object.entries(artifact.files)) { + yield* fs.writeFileString(`${destination}/${file}`, content); + yield* fs.chmod(`${destination}/${file}`, 0o755); + } + }).pipe( + Effect.mapError( + (cause) => + new PreparationError({ + message: `Unable to write native ${artifact.name} fixture: ${cause.message}`, + cause, + }), + ), + ), + }; + const store = yield* makeArtifactStore({ cacheRoot, source }); + return yield* store.prepare(request); +}); + +const nativeRestArtifact = (cacheRoot: string, program = "") => + nativeFixtureArtifact(cacheRoot, { + service: "rest", + name: "postgrest", + executablePath: "bin/postgrest", + files: { "bin/postgrest": `#!${process.execPath}\n${program}` }, + }); + +const nativePoolerArtifact = (cacheRoot: string) => { const server = `#!${process.execPath}\nconst http = require("node:http");\n` + `const port = Number(process.env.PORT);\n` + @@ -498,29 +542,13 @@ const nativePoolerArtifact = Effect.fn(function* (cacheRoot: string) { `console.log("Running SupavisorWeb.Endpoint at 127.0.0.1:" + port + " (http)");\n` + `});\n`; const oneShot = `#!${process.execPath}\nprocess.exit(0);\n`; - const source: ArtifactSource = { - checksum: () => Effect.succeed("0".repeat(64)), - materialize: (_entry, destination) => - Effect.gen(function* () { - yield* fs.makeDirectory(`${destination}/bin`, { recursive: true }); - yield* fs.writeFileString(`${destination}/bin/server`, server); - yield* fs.writeFileString(`${destination}/bin/prepare`, oneShot); - yield* fs.writeFileString(`${destination}/bin/provision-tenant`, oneShot); - yield* fs.chmod(`${destination}/bin/prepare`, 0o755); - yield* fs.chmod(`${destination}/bin/provision-tenant`, 0o755); - }).pipe( - Effect.mapError( - (cause) => - new PreparationError({ - message: `Unable to write native Pooler fixture: ${cause.message}`, - cause, - }), - ), - ), - }; - const store = yield* makeArtifactStore({ cacheRoot, source }); - yield* store.prepare(request); -}); + return nativeFixtureArtifact(cacheRoot, { + service: "pooler", + name: "pooler", + executablePath: "bin/server", + files: { "bin/server": server, "bin/prepare": oneShot, "bin/provision-tenant": oneShot }, + }); +}; const NativeLaunchPayload = Schema.Struct({ executable: Schema.String, @@ -579,6 +607,65 @@ const countingSpawner = ( ), }); +const interceptRestLaunch = ( + spawner: ChildProcessSpawnerService["Service"], + onLaunch: (payload: typeof NativeLaunchPayload.Type) => Effect.Effect<void>, +): ChildProcessSpawnerService["Service"] => ({ + ...spawner, + spawn: (command) => + spawner.spawn(command).pipe( + Effect.map((handle) => ({ + ...handle, + getInputFd: (fd: number) => { + const sink = handle.getInputFd(fd); + if (fd !== 4) return sink; + return Sink.mapInputEffect(sink, (bytes) => + Effect.gen(function* () { + const payload = yield* Schema.decodeEffect( + Schema.fromJsonString(NativeLaunchPayload), + )(new TextDecoder().decode(bytes)).pipe(Effect.orDie); + if (payload.executable.endsWith("/bin/postgrest")) yield* onLaunch(payload); + return bytes; + }), + ); + }, + })), + ), +}); + +const nativeRestRecipe = Effect.fn(function* ( + root: string, + program: string, + spawner: ChildProcessSpawnerService["Service"], + env: Readonly<Record<string, string>> = {}, + nativeReadinessOutput?: ProcessRecipeSpec<TestCreation>["nativeReadinessOutput"], +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const client = yield* HttpClient.HttpClient; + const cacheRoot = path.join(root, "cache"); + yield* nativeRestArtifact(cacheRoot, program); + return yield* makeProcessRecipe( + creation, + { + ...options, + root, + cacheRoot, + runtime: "native", + platform: { os: process.platform, arch: process.arch }, + }, + { fs, path, crypto, client, spawner, container: undefined }, + { + ...spec, + env: (_creation, endpoints) => + Effect.succeed({ ...env, PORT: String(endpoints.get("http")?.port) }), + startupCommands: [], + ...(nativeReadinessOutput === undefined ? {} : { nativeReadinessOutput }), + }, + ); +}); + describe("process recipe startup", () => { it.effect("reports the startup process's recent stdout and stderr when it exits non-zero", () => Effect.scoped( @@ -597,6 +684,7 @@ describe("process recipe startup", () => { const error = yield* Effect.flip(realtime.start); + expect(error).toMatchObject({ operation: "launch" }); expect(error.message).toContain("realtime startup exited with 1"); expect(error.message).toContain(postgrexFailure); expect(error.message).toContain(poolTimeout); @@ -604,6 +692,154 @@ describe("process recipe startup", () => { ).pipe(Effect.provide(platform)), ); + it.live("relaunches on fresh ports while a silently failing process finds its ports taken", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const testScope = yield* Effect.scope; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-collision-" }); + const launches = yield* Ref.make(0); + const taken = yield* Ref.make<ReadonlyArray<number>>([]); + const collidingSpawner = interceptRestLaunch(spawner, (payload) => + Effect.gen(function* () { + if ((yield* Ref.updateAndGet(launches, (count) => count + 1)) > 2) return; + const port = Number(payload.env?.PORT); + yield* Effect.acquireRelease( + Effect.callback<Net.Server>((resume) => { + const server = Net.createServer((socket) => socket.destroy()); + server.once("error", (cause) => resume(Effect.die(cause))); + server.listen(port, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => + Effect.callback<void>((resume) => { + server.close(() => resume(Effect.void)); + }), + ).pipe(Scope.provide(testScope)); + yield* Ref.update(taken, (ports) => [...ports, port]); + }), + ); + const recipe = yield* nativeRestRecipe( + root, + `const http = require("node:http");\n` + + `const server = http.createServer((_request, response) => response.end("owned-fixture"));\n` + + `server.on("error", () => process.exit(1));\n` + + `server.listen(Number(process.env.PORT), "127.0.0.1");\n`, + collidingSpawner, + ); + const service = yield* makeService(recipe.definition, { id: "rest", config: creation }); + + yield* service.start; + yield* service.ready; + + const endpoint = (yield* Ref.get(recipe.endpoints)).get("http"); + expect(yield* Ref.get(launches)).toBe(3); + expect(yield* Ref.get(taken)).toHaveLength(2); + expect(yield* Ref.get(taken)).not.toContain(endpoint?.port); + const response = yield* client.execute( + HttpClientRequest.get(`http://${endpoint?.host}:${endpoint?.port}/`), + ); + expect(yield* response.text).toBe("owned-fixture"); + yield* service.stop; + }), + ).pipe(Effect.provide(platform)), + ); + + it.live( + "does not relaunch a crash after the bind is confirmed while its port still answers", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const testScope = yield* Effect.scope; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-crash-" }); + const launches = yield* Ref.make(0); + const inheritedListener = interceptRestLaunch(spawner, (payload) => + Effect.gen(function* () { + yield* Ref.update(launches, (count) => count + 1); + yield* Effect.acquireRelease( + Effect.callback<Net.Server>((resume) => { + const server = Net.createServer((socket) => socket.destroy()); + server.once("error", (cause) => resume(Effect.die(cause))); + server.listen(Number(payload.env?.PORT), "127.0.0.1", () => + resume(Effect.succeed(server)), + ); + }), + (server) => + Effect.callback<void>((resume) => { + server.close(() => resume(Effect.void)); + }), + ).pipe(Scope.provide(testScope)); + }), + ); + const recipe = yield* nativeRestRecipe( + root, + `process.stdout.write("listener bound\\n");\n` + + `setTimeout(() => process.exit(4), 50);\n`, + inheritedListener, + {}, + (line) => line.includes("listener bound"), + ); + const service = yield* makeService(recipe.definition, { id: "rest", config: creation }); + + yield* service.start; + const failure = yield* Effect.flip(service.ready); + + expect(failure.message).toContain("rest exited with 4 before it was ready"); + expect(failure.message).not.toContain("native port collision"); + expect(yield* Ref.get(launches)).toBe(1); + }), + ).pipe(Effect.provide(platform)), + ); + + it.live("fails an early exit promptly when a detached descendant keeps its output open", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "process-recipe-drain-" }); + const pidFile = path.join(root, "descendant.pid"); + yield* Effect.addFinalizer(() => + fs.readFileString(pidFile).pipe( + Effect.flatMap((pid) => + Effect.sync(() => { + try { + process.kill(Number(pid), "SIGKILL"); + } catch { + // The descendant already exited. + } + }), + ), + Effect.ignore, + ), + ); + const recipe = yield* nativeRestRecipe( + root, + `const { spawn } = require("node:child_process");\n` + + `const { writeFileSync, writeSync } = require("node:fs");\n` + + `const descendant = spawn(process.execPath, ["-e", "setTimeout(() => {}, 300000)"], { detached: true, stdio: ["ignore", "inherit", "inherit"] });\n` + + `writeFileSync(process.env.PID_FILE, String(descendant.pid));\n` + + `writeSync(2, "startup failed before listening\\n");\n` + + `process.exit(3);\n`, + spawner, + { PID_FILE: pidFile }, + ); + const service = yield* makeService(recipe.definition, { id: "rest", config: creation }); + + yield* service.start; + const failure = yield* Effect.flip(service.ready); + + expect(failure.message).toContain("rest exited with 3 before it was ready"); + expect(failure.message).toContain("startup failed before listening"); + expect(failure.message).not.toContain("native port collision"); + expect(yield* fs.exists(pidFile)).toBe(true); + }), + ).pipe(Effect.provide(platform)), + ); + it.live("recovers when a healthy competing listener claims Pooler's selected HTTP port", () => Effect.scoped( Effect.gen(function* () { @@ -698,6 +934,7 @@ describe("process recipe startup", () => { config: creation, scope, }); + yield* runtime.health; const endpoints = yield* Ref.get(recipe.endpoints); const endpoint = endpoints.get("http"); const collided = yield* Ref.get(collisionPort); @@ -834,8 +1071,14 @@ describe("process recipe startup", () => { Stream.concat( Stream.fromEffectDrain( Deferred.await(exitReached).pipe( - Effect.andThen(TestClock.adjust("61 seconds")), - Effect.tap(() => Deferred.succeed(clockAdvanced, undefined)), + // The bounded output drain can close this attempt while the clock moves. + Effect.andThen( + Effect.uninterruptible( + TestClock.adjust("61 seconds").pipe( + Effect.andThen(Deferred.succeed(clockAdvanced, undefined)), + ), + ), + ), ), ), ), @@ -887,13 +1130,10 @@ describe("process recipe startup", () => { ); if (recipe.definition.prepare !== undefined) yield* recipe.definition.prepare(creation); const scope = yield* Scope.fork(yield* Effect.scope, "sequential"); - const launched = recipe.definition - .launch({ id: "pooler", config: creation, scope }) - .pipe(Effect.forkChild); - const fiber = yield* launched; + const runtime = yield* recipe.definition.launch({ id: "pooler", config: creation, scope }); + const health = yield* Effect.flip(runtime.health).pipe(Effect.forkChild); yield* Deferred.await(clockAdvanced); - const runtime = yield* Fiber.join(fiber); - const failure = yield* Effect.flip(runtime.health); + const failure = yield* Fiber.join(health); expect(failure.operation).toBe("launch"); expect(failure.message).toContain("native port collision"); expect(failure.message).not.toContain("readiness timed out"); @@ -906,7 +1146,7 @@ describe("process recipe startup", () => { ).pipe(Effect.provide(platform)), ); - it.live("does not retry an unrelated native Pooler startup failure", () => + it.live("does not relaunch a native process that exits early while its ports stay free", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -953,10 +1193,10 @@ describe("process recipe startup", () => { if (recipe.definition.prepare !== undefined) yield* recipe.definition.prepare(creation); const scope = yield* Scope.fork(yield* Effect.scope, "sequential"); const runtime = yield* recipe.definition.launch({ id: "pooler", config: creation, scope }); + const health = yield* Effect.exit(runtime.health); expect(yield* Ref.get(mainLaunches)).toBe(1); expect(yield* Ref.get(startupLaunches)).toEqual(["prepare", "provision-tenant"]); expect(yield* Ref.get(recipe.endpoints)).toEqual(new Map()); - const health = yield* Effect.exit(runtime.health); expect(Exit.isFailure(health)).toBe(true); if (Exit.isFailure(health)) expect(health.cause.toString()).toContain("unrelated startup failure"); diff --git a/packages/stack/src/services/ProcessRecipe.ts b/packages/stack/src/services/ProcessRecipe.ts index 2602ca60c3..73a81799b9 100644 --- a/packages/stack/src/services/ProcessRecipe.ts +++ b/packages/stack/src/services/ProcessRecipe.ts @@ -2,6 +2,7 @@ import { Cause, Clock, Crypto, + Data, Deferred, Duration, Effect, @@ -20,18 +21,31 @@ import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/u import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import * as Net from "node:net"; import { prepareNativeArtifact, resolveArtifact, type ServiceKind } from "../Artifacts.ts"; +import { accepts } from "../Ports.ts"; import { type ContainerError, type ContainerProcess, type ContainerRuntime, } from "../runtime/Container.ts"; +import { awaitCommandOutput } from "../runtime/CommandOutput.ts"; import { defaultNativeProcessLauncher, type NativeProcess, type NativeProcessError, spawnNativeProcess, } from "../runtime/NativeProcess.ts"; +import { + mapToServiceError, + processExit as sharedProcessExit, + publishProcessLogs, + runtimeSessionFromContainer, +} from "../runtime/Session.ts"; import { ServiceError, ServiceLaunchError, type RuntimeSession } from "../Service.ts"; +import { + containerInstancePath, + ensureOwnedInstanceRoot, + removeOwnedInstanceRoot, +} from "./InstanceRoot.ts"; import { type CatalogLog, CatalogError, @@ -47,7 +61,7 @@ interface RecipeMount { readonly readOnly: boolean; } -interface StartupProcess { +export interface StartupCommand { readonly args: ReadonlyArray<string>; readonly nativeExecutable?: string; readonly containerEntrypoint?: string; @@ -68,6 +82,8 @@ export interface ProcessRecipeSpec<C extends RecipeCreation<ServiceKind, unknown creation: C, endpoints: ReadonlyMap<string, ServiceEndpoint>, container: boolean, + /** The claimed instance directory: the host path natively, `/instance` in a container. */ + instanceDir?: string, ) => Effect.Effect<Readonly<Record<string, string>>, ServiceError>; readonly nativeStartupEnv?: ( creation: C, @@ -81,14 +97,63 @@ export interface ProcessRecipeSpec<C extends RecipeCreation<ServiceKind, unknown creation: C, context: { readonly container: boolean }, ) => Effect.Effect<ReadonlyArray<RecipeMount>, ServiceError>; - readonly startup: ReadonlyArray<StartupProcess>; + readonly startupCommands: ReadonlyArray<StartupCommand>; readonly enabledPort?: (creation: C, name: string) => boolean; readonly containerPort?: (creation: C, name: string, port: number) => number; readonly containerEntrypoint?: (creation: C) => string | undefined; readonly prepare?: (creation: C) => Effect.Effect<void, ServiceError>; readonly removeData?: (creation: C) => Effect.Effect<void, ServiceError>; + /** Claims an owned instance directory, mounted at `/instance` in containers. */ + readonly instanceDirectory?: boolean; +} + +export interface ResolvedStartupCommand { + readonly args: ReadonlyArray<string>; + readonly executable: string; + readonly entrypoint?: string; + readonly env: Readonly<Record<string, string>>; + readonly mounts: ReadonlyArray<RecipeMount>; } +export const startupEndpointsFor = <C extends RecipeCreation<ServiceKind, unknown>>( + creation: C, + spec: ProcessRecipeSpec<C>, + context: { readonly container: boolean }, +): ReadonlyMap<string, ServiceEndpoint> => + new Map( + Object.entries(spec.ports) + .filter(([name]) => spec.enabledPort === undefined || spec.enabledPort(creation, name)) + .map(([name, port]) => [ + name, + { + kind: "tcp" as const, + host: "127.0.0.1" as const, + port: context.container ? (spec.containerPort?.(creation, name, port) ?? port) : 0, + }, + ]), + ); + +export const resolveStartupCommand = <C extends RecipeCreation<ServiceKind, unknown>>( + creation: C, + spec: ProcessRecipeSpec<C>, + command: StartupCommand, + endpoints: ReadonlyMap<string, ServiceEndpoint>, + context: { readonly container: boolean }, +): Effect.Effect<ResolvedStartupCommand, ServiceError> => + Effect.gen(function* () { + return { + args: command.args, + executable: command.nativeExecutable ?? "prepare", + ...(command.containerEntrypoint === undefined + ? {} + : { entrypoint: command.containerEntrypoint }), + env: yield* context.container + ? spec.env(creation, endpoints, true) + : (spec.nativeStartupEnv ?? spec.env)(creation, endpoints, false), + mounts: yield* spec.mounts(creation, { container: context.container }), + }; + }); + export interface ProcessDependencies { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; @@ -98,18 +163,9 @@ export interface ProcessDependencies { readonly container: ContainerRuntime | undefined; } -const serviceError = (operation: string, cause: unknown): ServiceError => - cause instanceof ServiceError - ? cause - : new ServiceError({ - operation, - message: Cause.isTimeoutError(cause) - ? `Service ${operation} timed out` - : cause instanceof Error - ? cause.message - : String(cause), - cause, - }); +const serviceError = mapToServiceError; + +const describeProcessExit = (code: number) => `Process exited with ${code}`; const catalogError = (operation: string, message: string, service?: ServiceKind, cause?: unknown) => new CatalogError({ @@ -121,25 +177,10 @@ const catalogError = (operation: string, message: string, service?: ServiceKind, const processExit = ( exitCode: Effect.Effect<number, { readonly message: string }>, -): Effect.Effect<Exit.Exit<void, ServiceError>> => - exitCode.pipe( - Effect.flatMap((code) => - Number(code) === 0 - ? Effect.void - : Effect.fail( - new ServiceError({ operation: "exit", message: `Process exited with ${code}` }), - ), - ), - Effect.mapError((cause) => serviceError("exit", cause)), - Effect.exit, - ); +): Effect.Effect<Exit.Exit<void, ServiceError>> => sharedProcessExit(exitCode, describeProcessExit); -const runtimeFromContainer = (process: ContainerProcess): RuntimeSession => ({ - health: Effect.void, - exit: processExit(process.exitCode), - stop: process.stop.pipe(Effect.mapError((cause) => serviceError("stop", cause))), - remove: process.remove.pipe(Effect.mapError((cause) => serviceError("remove", cause))), -}); +const runtimeFromContainer = (process: ContainerProcess): RuntimeSession => + runtimeSessionFromContainer(process, describeProcessExit); const runtimeFromNative = (process: NativeProcess): RuntimeSession => ({ health: Effect.void, @@ -192,30 +233,10 @@ const reserveNativePort = Effect.fn("ProcessRecipe.reserveNativePort")( ), ); -const publishLogs = Effect.fn("ProcessRecipe.publishLogs")(( - process: { - readonly stdout: Stream.Stream<Uint8Array, unknown>; - readonly stderr: Stream.Stream<Uint8Array, unknown>; - }, - logs: PubSub.PubSub<CatalogLog>, - scope: Scope.Closeable, -): Effect.Effect<void> => { - const drain = (stream: Stream.Stream<Uint8Array, unknown>, name: CatalogLog["stream"]) => - stream.pipe( - Stream.runForEach((bytes) => PubSub.publish(logs, { stream: name, bytes })), - Effect.catch((cause) => Effect.logError(cause)), - Effect.asVoid, - ); - return Effect.all( - [ - Effect.forkIn(drain(process.stdout, "stdout"), scope), - Effect.forkIn(drain(process.stderr, "stderr"), scope), - ], - { concurrency: "unbounded", discard: true }, - ); -}); - const startupTimeoutSeconds = 60; +const nativeLaunchAttempts = 3; +const probeTimeout = Duration.seconds(10); +const outputDrainGrace = Duration.seconds(2); const startupOutputTailLines = 20; const startupOutputLineChars = 1_000; @@ -247,57 +268,23 @@ const awaitStartup = Effect.fn("ProcessRecipe.awaitStartup")( Readonly<{ readonly code: number; readonly output: StartupOutput }>, ServiceError > => - Effect.gen(function* () { - const collect = Effect.fnUntraced(function* ( - stream: Stream.Stream<Uint8Array, NativeProcessError | ContainerError>, - name: CatalogLog["stream"], - tail: Ref.Ref<ReadonlyArray<string>>, - ) { - const appendLines = (lines: ReadonlyArray<string>) => - Ref.update(tail, (current) => - [...current, ...lines.filter((line) => line.trim().length > 0).map(clipLine)].slice( - -startupOutputTailLines, - ), - ); - const partial = yield* Ref.make(""); - // The unterminated last line is flushed on interruption so a timeout still reports it. - yield* stream.pipe( - Stream.tap((bytes) => PubSub.publish(logs, { stream: name, bytes })), - Stream.decodeText, - Stream.runForEach((text) => - Ref.modify(partial, (rest): [ReadonlyArray<string>, string] => { - const lines = `${rest}${text}`.split(/\r?\n/); - const next = lines.pop() ?? ""; - return [lines, next.slice(-(startupOutputLineChars + 1))]; - }).pipe(Effect.flatMap(appendLines)), - ), - Effect.ensuring(Ref.get(partial).pipe(Effect.flatMap((rest) => appendLines([rest])))), - ); - }); - const stdout = yield* Ref.make<ReadonlyArray<string>>([]); - const stderr = yield* Ref.make<ReadonlyArray<string>>([]); - const completed = yield* Effect.all( - [ - collect(process.stdout, "stdout", stdout), - collect(process.stderr, "stderr", stderr), - process.exitCode, - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => serviceError("launch", cause)), - Effect.timeoutOption(Duration.seconds(startupTimeoutSeconds)), - ); - const output = { stdout: yield* Ref.get(stdout), stderr: yield* Ref.get(stderr) }; - if (Option.isNone(completed)) - return yield* serviceError( - "launch", - withRecentOutput( - `${service} startup timed out after ${startupTimeoutSeconds} seconds`, - output, - ), - ); - return { code: Number(completed.value[2]), output }; - }), + awaitCommandOutput(process, { + timeout: Duration.seconds(startupTimeoutSeconds), + onOutput: (stream, bytes) => PubSub.publish(logs, { stream, bytes }), + }).pipe( + Effect.mapError((cause) => serviceError("launch", cause)), + Effect.flatMap((result) => + result.timedOut + ? serviceError( + "launch", + withRecentOutput( + `${service} startup timed out after ${startupTimeoutSeconds} seconds`, + result.output, + ), + ) + : Effect.succeed({ code: result.exitCode, output: result.output }), + ), + ), ); const startupFailure = ( @@ -312,16 +299,17 @@ const startupFailure = ( const isAddressInUse = (line: string) => /eaddrinuse|address already in use|port already in use/i.test(line); -const terminalSession = ( - error: ServiceError, - endpoints: Ref.Ref<ReadonlyMap<string, ServiceEndpoint>>, -) => - ({ - health: Effect.fail(error), - exit: Effect.succeed(Exit.fail(error)), - stop: Effect.void, - remove: Ref.set(endpoints, new Map()), - }) satisfies RuntimeSession; +class NativePortCollision extends Data.TaggedError("NativePortCollision")<{ + readonly failure: ServiceError; +}> {} + +/** Another process accepting on a port the exited attempt was given means that attempt lost the bind. */ +const anotherListenerHolds = (endpoints: ReadonlyMap<string, ServiceEndpoint>) => + Effect.forEach( + [...endpoints.values()].filter((endpoint) => endpoint.kind === "tcp"), + (endpoint) => accepts(endpoint.host ?? "127.0.0.1", endpoint.port), + { concurrency: "unbounded" }, + ).pipe(Effect.map((accepted) => accepted.some(Boolean))); const collectNativeOutput = Effect.fn("ProcessRecipe.collectNativeOutput")(function* ( process: NativeProcess, @@ -430,8 +418,23 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> const preparedRoot = yield* Ref.make<string | undefined>(undefined); const endpoints = yield* Ref.make<ReadonlyMap<string, ServiceEndpoint>>(new Map()); const logs = yield* PubSub.sliding<CatalogLog>(256); + const instanceRoot = deps.path.join(options.root, options.instanceId); + const ownedInstanceRoot = { + fs: deps.fs, + path: deps.path, + parentRoot: options.root, + stackId: options.stackId, + instanceId: options.instanceId, + ownerFileName: ".supabase-instance-owner.json", + label: "Instance root", + }; + const nativeInstanceDir = spec.instanceDirectory === true ? instanceRoot : undefined; + const containerInstanceDir = + spec.instanceDirectory === true ? containerInstancePath : undefined; const prepare = Effect.fn("ProcessRecipe.prepare")(function* (candidate: C) { + if (spec.instanceDirectory === true) + yield* ensureOwnedInstanceRoot(ownedInstanceRoot, serviceError); if (spec.prepare !== undefined) yield* spec.prepare(candidate); const resolved = yield* resolveArtifact({ service: candidate.service, @@ -506,7 +509,7 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> readonly endpoints: ReadonlyMap<string, ServiceEndpoint>; } | undefined; - if (spec.startup.length > 0) { + if (spec.startupCommands.length > 0) { const startupScope = yield* Scope.fork(context.scope, "sequential"); const reservation = spec.nativeStartupEnv === undefined @@ -514,22 +517,20 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> : yield* reserveEndpoints(context.scope); const startupEndpoints = reservation?.endpoints ?? - new Map( - portNames.map(([name]) => [ - name, - { kind: "tcp" as const, host: "127.0.0.1", port: 0 }, - ]), + startupEndpointsFor(context.config, spec, { container: false }); + for (const [index, process] of spec.startupCommands.entries()) { + const command = yield* resolveStartupCommand( + context.config, + spec, + process, + startupEndpoints, + { container: false }, ); - for (const [index, process] of spec.startup.entries()) { const startupProcess = yield* spawnNativeProcess( { - executable: `${artifactRoot}/bin/${process.nativeExecutable ?? "prepare"}`, - args: process.args, - env: yield* (spec.nativeStartupEnv ?? spec.env)( - context.config, - startupEndpoints, - false, - ), + executable: `${artifactRoot}/bin/${command.executable}`, + args: command.args, + env: command.env, cwd: artifactRoot, }, defaultNativeProcessLauncher(), @@ -559,37 +560,19 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> } const deadline = (yield* Clock.currentTimeMillis) + startupTimeoutSeconds * 1_000; - const lastCollision = yield* Ref.make<ServiceError | undefined>(undefined); - let firstAttempt = true; - const launchAttempt = Effect.fn("ProcessRecipe.launchNativeAttempt")(function* () { - if ( - spec.nativeReadinessOutput !== undefined && - (yield* Clock.currentTimeMillis) >= deadline - ) { - const previousCollision = yield* Ref.get(lastCollision); - return terminalSession( - previousCollision === undefined - ? serviceError("health", `${context.config.service} native readiness timed out`) - : serviceError("launch", previousCollision.message), - endpoints, - ); - } - const attemptScope = yield* Scope.fork(context.scope, "sequential"); - const heldReservation = firstAttempt ? reusableReservation : undefined; - const reuse = firstAttempt ? heldReservation?.endpoints : undefined; - firstAttempt = false; - const reservation = - reuse === undefined ? yield* reserveEndpoints(attemptScope) : undefined; - const selected = reuse ?? reservation?.endpoints ?? new Map<string, ServiceEndpoint>(); + const spawnAttempt = Effect.fn("ProcessRecipe.spawnNativeAttempt")(function* (held?: { + readonly portScope: Scope.Closeable; + readonly endpoints: ReadonlyMap<string, ServiceEndpoint>; + }) { + const scope = yield* Scope.fork(context.scope, "sequential"); + const reservation = held ?? (yield* reserveEndpoints(scope)); + const selected = reservation.endpoints; const args = yield* spec.args(context.config, selected, { container: false, artifactRoot, }); - const env = yield* spec.env(context.config, selected, false); - if (reservation !== undefined) yield* Scope.close(reservation.portScope, Exit.void); - if (heldReservation !== undefined) - yield* Scope.close(heldReservation.portScope, Exit.void); - + const env = yield* spec.env(context.config, selected, false, nativeInstanceDir); + yield* Scope.close(reservation.portScope, Exit.void); const native: NativeProcess = yield* spawnNativeProcess( { executable, @@ -601,169 +584,184 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> defaultNativeProcessLauncher(), { stackId: String(options.stackId), workloadId: context.id }, ).pipe( - Scope.provide(attemptScope), + Scope.provide(scope), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, deps.spawner), Effect.mapError((cause) => serviceError("launch", cause)), ); - if (spec.nativeReadinessOutput === undefined) { - yield* Ref.set(endpoints, selected); - yield* publishLogs(native, logs, attemptScope); - const ready = selected.get("http"); - return { - health: - ready === undefined - ? Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")) - : readiness(deps.client, ready, spec.healthPath), - exit: processExit(native.exitCode), - stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), - remove: Ref.set(endpoints, new Map()), - } satisfies RuntimeSession; - } - const output = yield* collectNativeOutput( native, logs, selected, spec.nativeReadinessOutput, - attemptScope, + scope, ); - const ready = selected.get("http"); - const timeLeft = Math.max(0, deadline - (yield* Clock.currentTimeMillis)); - const readyCondition = - ready === undefined - ? Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")) - : Effect.all( - [ - readiness(deps.client, ready, spec.healthPath, Duration.millis(timeLeft)), - Deferred.await(output.bindReady), - ], - { concurrency: "unbounded", discard: true }, - ).pipe(Effect.asVoid); - const boundedReady = readyCondition.pipe( - Effect.timeout(Duration.millis(timeLeft)), + yield* Ref.set(endpoints, selected); + return { native, output, selected, scope }; + }); + type NativeAttempt = Effect.Success<ReturnType<typeof spawnAttempt>>; + + const readyCondition = (attempt: NativeAttempt, timeout: Duration.Input) => { + const http = attempt.selected.get("http"); + if (http === undefined) + return Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")); + return Effect.all( + [ + readiness(deps.client, http, spec.healthPath, timeout), + spec.nativeReadinessOutput === undefined + ? Effect.void + : Deferred.await(attempt.output.bindReady), + ], + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.timeout(timeout), Effect.mapError((cause) => serviceError("health", cause)), ); + }; + const recentOutput = (attempt: NativeAttempt) => + Effect.all({ + stdout: Ref.get(attempt.output.stdout), + stderr: Ref.get(attempt.output.stderr), + }); + + const firstAttempt = yield* spawnAttempt(reusableReservation); + const live = yield* Ref.make(firstAttempt); + const unobserved = yield* Ref.make<NativeAttempt | undefined>(firstAttempt); + const settled = yield* Deferred.make<Exit.Exit<void, ServiceError>>(); + const settleOnExit = (attempt: NativeAttempt) => + Effect.forkIn( + processExit(attempt.native.exitCode).pipe( + Effect.flatMap((exit) => Deferred.succeed(settled, exit)), + ), + context.scope, + ); + const settleFailure = (failure: ServiceError) => + Deferred.succeed(settled, Exit.fail(failure)).pipe(Effect.andThen(Effect.fail(failure))); + + const nextAttempt = Ref.getAndSet(unobserved, undefined).pipe( + Effect.flatMap((attempt) => + attempt !== undefined + ? Effect.succeed(attempt) + : spawnAttempt().pipe( + Effect.tap((relaunched) => Ref.set(live, relaunched)), + Effect.catch(settleFailure), + ), + ), + ); + + const observeAttempt = Effect.fn("ProcessRecipe.observeNativeAttempt")(function* ( + attempt: NativeAttempt, + ) { + const timeLeft = Math.max(0, deadline - (yield* Clock.currentTimeMillis)); const observed = yield* Effect.race( - Effect.exit(boundedReady).pipe( + Effect.exit(readyCondition(attempt, Duration.millis(timeLeft))).pipe( Effect.map((result) => ({ _tag: "ready" as const, result })), ), - Effect.exit(native.exitCode).pipe( + Effect.exit(attempt.native.exitCode).pipe( Effect.map((result) => ({ _tag: "exit" as const, result })), ), ); - if (observed._tag === "ready" && Exit.isSuccess(observed.result)) { - yield* Ref.set(endpoints, selected); - return { - health: Effect.void, - exit: processExit(native.exitCode), - stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), - remove: Ref.set(endpoints, new Map()), - } satisfies RuntimeSession; - } - if (observed._tag === "ready") { - const bindReady = yield* Deferred.isDone(output.bindReady); - const [stdoutLines, stderrLines] = yield* Effect.all([ - Ref.get(output.stdout), - Ref.get(output.stderr), - ]); - const failure = serviceError( + yield* settleOnExit(attempt); + if (Exit.isSuccess(observed.result)) return; + const error = Option.getOrUndefined(Cause.findErrorOption(observed.result.cause)); + if (error !== undefined && !Cause.isTimeoutError(error.cause)) return yield* error; + const bindConfirmed = + spec.nativeReadinessOutput === undefined || + (yield* Deferred.isDone(attempt.output.bindReady)); + return yield* serviceError( "health", withRecentOutput( - bindReady + bindConfirmed ? `${context.config.service} HTTP readiness timed out` : `${context.config.service} native listener bind was not confirmed`, - { stdout: stdoutLines, stderr: stderrLines }, + yield* recentOutput(attempt), ), ); - yield* Ref.set(endpoints, selected); - return { - health: Effect.fail(failure), - exit: processExit(native.exitCode), - stop: native.kill.pipe(Effect.mapError((cause) => serviceError("stop", cause))), - remove: Ref.set(endpoints, new Map()), - } satisfies RuntimeSession; } - if (Exit.isFailure(observed.result)) yield* Scope.close(attemptScope, Exit.void); - else yield* Deferred.await(output.drained); - const [stdoutLines, stderrLines] = yield* Effect.all([ - Ref.get(output.stdout), - Ref.get(output.stderr), - ]); + // A detached descendant can hold the output pipes open after the process exits. + if (Exit.isSuccess(observed.result)) + yield* Deferred.await(attempt.output.drained).pipe( + Effect.timeoutOption(outputDrainGrace), + ); + yield* Scope.close(attempt.scope, Exit.void); + yield* Ref.set(endpoints, new Map()); const exitMessage = Exit.isSuccess(observed.result) - ? `${context.config.service} startup exited with ${observed.result.value}` - : `${context.config.service} startup process failed: ${Cause.pretty(observed.result.cause)}`; + ? `${context.config.service} exited with ${observed.result.value} before it was ready` + : `${context.config.service} process failed: ${Cause.pretty(observed.result.cause)}`; const failure = serviceError( "launch", - withRecentOutput(exitMessage, { stdout: stdoutLines, stderr: stderrLines }), + withRecentOutput(exitMessage, yield* recentOutput(attempt)), ); - const collision = yield* Ref.get(output.bindError); - const nonzeroExit = Exit.isSuccess(observed.result) && observed.result.value !== 0; - if (Exit.isSuccess(observed.result)) yield* Scope.close(attemptScope, Exit.void); - yield* Ref.set(endpoints, new Map()); - if (collision && nonzeroExit) { - const collisionFailure = serviceError( - "native-port-collision", + const collided = + Exit.isSuccess(observed.result) && + observed.result.value !== 0 && + ((yield* Ref.get(attempt.output.bindError)) || + (!(yield* Deferred.isDone(attempt.output.bindReady)) && + (yield* anotherListenerHolds(attempt.selected)))); + if (!collided) return yield* settleFailure(failure); + return yield* new NativePortCollision({ + failure: serviceError( + "launch", `${context.config.service} native port collision\n${failure.message}`, - ); - yield* Ref.set(lastCollision, collisionFailure); - return yield* collisionFailure; - } - return terminalSession(failure, endpoints); + ), + }); }); - if (spec.nativeReadinessOutput !== undefined) { - const collisionRetries = Schedule.recurs(2).pipe( - Schedule.setInputType<ServiceError | ServiceLaunchError>(), - Schedule.while( - ({ input }) => - input instanceof ServiceError && input.operation === "native-port-collision", - ), - ); - return yield* launchAttempt().pipe( - Effect.retry(collisionRetries), - Effect.catchIf( - (error) => - error instanceof ServiceError && error.operation === "native-port-collision", - (collision) => - Effect.succeed( - terminalSession(serviceError("launch", collision.message), endpoints), - ), - ), - ); - } - return yield* launchAttempt(); + // An attempt that loses a port bind before it is ready relaunches on fresh ports. + const supervise = nextAttempt.pipe( + Effect.flatMap(observeAttempt), + Effect.retry({ + times: nativeLaunchAttempts - 1, + while: (error) => + error._tag === "NativePortCollision" + ? Clock.currentTimeMillis.pipe(Effect.map((now) => now < deadline)) + : Effect.succeed(false), + }), + Effect.catchTag("NativePortCollision", ({ failure }) => settleFailure(failure)), + ); + + return { + health: supervise, + probe: Ref.get(live).pipe( + Effect.flatMap((attempt) => readyCondition(attempt, probeTimeout)), + ), + exit: Deferred.await(settled), + stop: Ref.get(live).pipe( + Effect.flatMap((attempt) => attempt.native.kill), + Effect.mapError((cause) => serviceError("stop", cause)), + ), + remove: Ref.set(endpoints, new Map()), + } satisfies RuntimeSession; } - const desired = new Map<string, ServiceEndpoint>(); - for (const [name, port] of portNames) - desired.set(name, { kind: "tcp", host: "127.0.0.1", port }); if (deps.container === undefined) return yield* serviceError("launch", "Container runtime unavailable"); const resolved = yield* resolveArtifact({ service: context.config.service, version: context.config.version, }).pipe(Effect.mapError((cause) => serviceError("launch", cause))); - const containerDesired = new Map<string, ServiceEndpoint>(); - for (const [name, endpoint] of desired) { - const port = - spec.containerPort === undefined - ? endpoint.port - : spec.containerPort(context.config, name, endpoint.port); - containerDesired.set(name, { ...endpoint, port }); - } - for (const process of spec.startup) { + const containerDesired = startupEndpointsFor(context.config, spec, { container: true }); + for (const process of spec.startupCommands) { if (process.skipInContainer === true) continue; + const command = yield* resolveStartupCommand( + context.config, + spec, + process, + containerDesired, + { container: true }, + ); const startupProcess = yield* deps.container - .launchTool({ + .launchCommand({ image: resolved.image, stackId: options.stackId, instanceId: options.instanceId, - env: yield* spec.env(context.config, containerDesired, true), - entrypoint: process.containerEntrypoint, - args: process.args, - mounts: yield* spec.mounts(context.config, { container: true }), + service: spec.service, + project: options.project, + env: command.env, + entrypoint: command.entrypoint, + args: command.args, + mounts: command.mounts, }) .pipe( Effect.catchTag("ContainerLaunchError", ({ failure, process }) => @@ -808,10 +806,17 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> image: resolved.image, stackId: options.stackId, instanceId: options.instanceId, - env: yield* spec.env(context.config, containerDesired, true), + service: spec.service, + project: options.project, + env: yield* spec.env(context.config, containerDesired, true, containerInstanceDir), entrypoint: spec.containerEntrypoint?.(context.config), args: yield* spec.args(context.config, containerDesired, { container: true }), - mounts: yield* spec.mounts(context.config, { container: true }), + mounts: [ + ...(yield* spec.mounts(context.config, { container: true })), + ...(spec.instanceDirectory === true + ? [{ source: instanceRoot, target: containerInstancePath, readOnly: false }] + : []), + ], ports: [...containerDesired.values()].map((endpoint) => endpoint.port), }) .pipe( @@ -839,15 +844,22 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> selected.set(name, { kind: "tcp", host: "127.0.0.1", port: published }); } yield* Ref.set(endpoints, selected); - yield* publishLogs(launched, logs, context.scope); + yield* publishProcessLogs(launched, logs, context.scope); const runtime = runtimeFromContainer(launched); const ready = selected.get("http"); + const noReadinessEndpoint = Effect.fail( + serviceError("health", "Recipe has no HTTP readiness endpoint"), + ); return { ...runtime, health: ready === undefined - ? Effect.fail(serviceError("health", "Recipe has no HTTP readiness endpoint")) + ? noReadinessEndpoint : readiness(deps.client, ready, spec.healthPath), + probe: + ready === undefined + ? noReadinessEndpoint + : readiness(deps.client, ready, spec.healthPath, probeTimeout), remove: runtime.remove.pipe(Effect.tap(() => Ref.set(endpoints, new Map()))), } satisfies RuntimeSession; }); @@ -857,9 +869,14 @@ export const makeProcessRecipe = <C extends RecipeCreation<ServiceKind, unknown> prepare, launch, removeData: (context) => - (spec.removeData?.(context.config) ?? Effect.void).pipe( - Effect.andThen(Ref.set(endpoints, new Map())), - ), + (spec.instanceDirectory === true + ? removeOwnedInstanceRoot( + ownedInstanceRoot, + spec.removeData?.(context.config) ?? Effect.void, + serviceError, + ) + : (spec.removeData?.(context.config) ?? Effect.void) + ).pipe(Effect.andThen(Ref.set(endpoints, new Map()))), }, endpoints, logs: Stream.fromPubSub(logs).pipe( diff --git a/packages/stack/src/services/Realtime.integration.test.ts b/packages/stack/src/services/Realtime.integration.test.ts index d6991a73a0..3d495a5ef9 100644 --- a/packages/stack/src/services/Realtime.integration.test.ts +++ b/packages/stack/src/services/Realtime.integration.test.ts @@ -90,6 +90,7 @@ describe("service catalog", () => { { service: "studio", config: { + databaseUrl, pgmetaUrl: `http://${pgmetaRelay.host}:${pgmetaRelay.port}`, analyticsApiKey: "catalog-analytics-key", apiUrl: "http://localhost:8000", @@ -112,6 +113,13 @@ describe("service catalog", () => { ), ); expect(profileResponse.status).toBe(200); + const queryResponse = yield* client.execute( + HttpClientRequest.post( + `http://${studioEndpoint.host}:${studioEndpoint.port}/api/platform/pg-meta/default/query`, + ).pipe(HttpClientRequest.bodyJsonUnsafe({ query: "select current_user" })), + ); + expect(queryResponse.status).toBe(200); + expect(yield* queryResponse.text).toContain('"current_user":"postgres"'); yield* studio.stop; yield* pgmeta.stop; diff --git a/packages/stack/src/services/Realtime.ts b/packages/stack/src/services/Realtime.ts index f5744e39ed..87a7a46f35 100644 --- a/packages/stack/src/services/Realtime.ts +++ b/packages/stack/src/services/Realtime.ts @@ -1,14 +1,14 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection, localJwtSecret } from "./ServiceConfig.ts"; +import { databaseConnection, requiredInput, localJwtSecret } from "./ServiceConfig.ts"; import { DEFAULT_LOCAL_SERVICE_SECRET_KEY_BASE, DEFAULT_REALTIME_DB_ENCRYPTION_KEY, } from "../Defaults.ts"; -import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), jwtSecret: Schema.optionalKey(Schema.String), jwks: Schema.optionalKey(Schema.String), dbEncryptionKey: Schema.optionalKey(Schema.String), @@ -28,6 +28,11 @@ export const Creation = serviceCreation("realtime", Config, Endpoints); export interface Creation extends Schema.Schema.Type<typeof Creation> {} +export const initializationCommand = { + args: [], + containerEntrypoint: "/app/bin/prepare", +} satisfies StartupCommand & { readonly containerEntrypoint: string }; + export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ service: "realtime", executable: "bin/server", @@ -35,14 +40,19 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ healthPath: "/healthcheck", env: (creation, endpoints, container) => Effect.gen(function* () { - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput( + "realtime", + "databaseUrl", + creation.config.databaseUrl, + ); + const db = yield* databaseConnection(databaseUrl); const http = endpoints.get("http"); const rpc = endpoints.get("rpc"); const jwt = creation.config.jwtSecret ?? localJwtSecret; return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, ...(http === undefined ? {} : { PORT: String(http.port) }), - DB_URL: creation.config.databaseUrl, + DB_URL: databaseUrl, DB_HOST: db.host, DB_PORT: db.port, DB_USER: db.username ?? "supabase_admin", @@ -60,8 +70,8 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ MAX_HEADER_LENGTH: String(creation.config.maxHeaderLength ?? 4096), ERL_AFLAGS: creation.config.ipVersion === "IPv6" ? "-proto_dist inet6_tcp" : "-proto_dist inet_tcp", - // The stack database is reachable only over IPv4; unset, Realtime prefers IPv6 for - // dual-stack hosts such as Docker Desktop's host.docker.internal. + // The stack database listens on IPv4 only. This covers Realtime's own repo; tenant + // connections probe IPv6 first and rely on the runtime host alias having no AAAA record. DB_IP_VERSION: "ipv4", RUN_JANITOR: "true", ...(rpc === undefined @@ -76,6 +86,6 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ args: (_creation, _endpoints, context) => Effect.succeed(context.container ? ["-s", "-g", "--", "/app/bin/server"] : []), mounts: () => Effect.succeed([]), - startup: [{ args: [], containerEntrypoint: "/app/bin/prepare" }], + startupCommands: [initializationCommand], containerEntrypoint: () => "/usr/bin/tini", }); diff --git a/packages/stack/src/services/Recipe.ts b/packages/stack/src/services/Recipe.ts index 93cc5df0e6..940e1f5f38 100644 --- a/packages/stack/src/services/Recipe.ts +++ b/packages/stack/src/services/Recipe.ts @@ -3,6 +3,7 @@ import type { Stream } from "effect"; import type { Effect, Ref } from "effect"; import type { ServiceKind } from "../Artifacts.ts"; import type { ServiceDefinition } from "../Service.ts"; +import type { HostGateway } from "../runtime/Container.ts"; import type { DockerHelperRegistry } from "../storage/DockerHelperRegistry.ts"; type CatalogRuntime = "native" | "docker" | "podman"; @@ -45,12 +46,16 @@ export const serviceCreation = < export interface CatalogOptions { readonly stackId: string; readonly instanceId: string; + /** Project folder name; the container runtime sanitizes it into names and grouping labels. */ + readonly project?: string; readonly root: string; readonly cacheRoot: string; readonly runtime: CatalogRuntime; readonly platform?: { readonly os: string; readonly arch: string }; /** Reuses one volume helper across databases in this host. */ readonly helpers?: DockerHelperRegistry; + /** Shares one host-gateway probe across this host's container runtimes. */ + readonly hostGateway?: HostGateway; } export interface CatalogLog { diff --git a/packages/stack/src/services/Rest.integration.test.ts b/packages/stack/src/services/Rest.integration.test.ts index d8b156eddf..a8d87db95f 100644 --- a/packages/stack/src/services/Rest.integration.test.ts +++ b/packages/stack/src/services/Rest.integration.test.ts @@ -57,7 +57,8 @@ describe("service catalog", () => { identity: { projectRoot: root, branchContext: "test", stackName: "catalog" }, runtime: "docker", instances: [], - composition: {}, + lifetime: "detached", + composition: { members: [], dependencies: [] }, ports: [], }); const network = yield* makeTestNetwork({ stackId, runtime: "docker", state }); diff --git a/packages/stack/src/services/Rest.ts b/packages/stack/src/services/Rest.ts index e238e6a039..bbd80ef641 100644 --- a/packages/stack/src/services/Rest.ts +++ b/packages/stack/src/services/Rest.ts @@ -1,9 +1,10 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +import { requiredInput } from "./ServiceConfig.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), externalApiUrl: Schema.optionalKey(Schema.String), schemas: Schema.optionalKey(Schema.String), extraSearchPath: Schema.optionalKey(Schema.String), @@ -26,26 +27,28 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ executable: "bin/postgrest", ports: { http: 3000 }, healthPath: "/", - env: (creation, endpoints) => { - const http = endpoints.get("http"); - const jwtSecret = creation.config.jwks ?? creation.config.jwtSecret; - return Effect.succeed({ - DATABASE_URL: creation.config.databaseUrl, - PGRST_DB_URI: creation.config.databaseUrl, - ...(http === undefined ? {} : { PGRST_SERVER_PORT: String(http.port) }), - PGRST_DB_SCHEMAS: creation.config.schemas ?? "public,graphql_public", - ...(creation.config.extraSearchPath === undefined - ? {} - : { PGRST_DB_EXTRA_SEARCH_PATH: creation.config.extraSearchPath }), - PGRST_DB_ANON_ROLE: creation.config.anonRole ?? "anon", - PGRST_DB_MAX_ROWS: String(creation.config.maxRows ?? 1000), - ...(jwtSecret === undefined ? {} : { PGRST_JWT_SECRET: jwtSecret }), - ...(creation.config.externalApiUrl === undefined - ? {} - : { PGRST_OPENAPI_SERVER_PROXY_URI: creation.config.externalApiUrl }), - }); - }, + env: (creation, endpoints) => + Effect.gen(function* () { + const databaseUrl = yield* requiredInput("rest", "databaseUrl", creation.config.databaseUrl); + const http = endpoints.get("http"); + const jwtSecret = creation.config.jwks ?? creation.config.jwtSecret; + return { + DATABASE_URL: databaseUrl, + PGRST_DB_URI: databaseUrl, + ...(http === undefined ? {} : { PGRST_SERVER_PORT: String(http.port) }), + PGRST_DB_SCHEMAS: creation.config.schemas ?? "public,graphql_public", + ...(creation.config.extraSearchPath === undefined + ? {} + : { PGRST_DB_EXTRA_SEARCH_PATH: creation.config.extraSearchPath }), + PGRST_DB_ANON_ROLE: creation.config.anonRole ?? "anon", + PGRST_DB_MAX_ROWS: String(creation.config.maxRows ?? 1000), + ...(jwtSecret === undefined ? {} : { PGRST_JWT_SECRET: jwtSecret }), + ...(creation.config.externalApiUrl === undefined + ? {} + : { PGRST_OPENAPI_SERVER_PROXY_URI: creation.config.externalApiUrl }), + }; + }), args: () => Effect.succeed([]), mounts: () => Effect.succeed([]), - startup: [], + startupCommands: [], }); diff --git a/packages/stack/src/services/ServiceConfig.ts b/packages/stack/src/services/ServiceConfig.ts index d9005ca0d4..72156d14a0 100644 --- a/packages/stack/src/services/ServiceConfig.ts +++ b/packages/stack/src/services/ServiceConfig.ts @@ -7,7 +7,7 @@ import { DEFAULT_LOCAL_SECRET_KEY, DEFAULT_SIGNING_KEY, } from "../Defaults.ts"; -import type { StackCredentials, StackIdentityInput } from "../State.ts"; +import type { StackCredentials, StackKeysInput } from "../State.ts"; const serviceError = (operation: string, cause: unknown): ServiceError => cause instanceof ServiceError @@ -20,7 +20,7 @@ const serviceError = (operation: string, cause: unknown): ServiceError => const stringify = (value: unknown) => Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe( - Effect.mapError((cause) => serviceError("identity", cause)), + Effect.mapError((cause) => serviceError("keys", cause)), ); export const localJwtSecret = DEFAULT_LOCAL_JWT_SECRET; @@ -43,10 +43,10 @@ const fixedJwt = (secret: string, role: "anon" | "service_role") => new SignJWT({ iss: "supabase-demo", role, exp: 1983812996 }) .setProtectedHeader({ alg: "HS256", typ: "JWT" }) .sign(new TextEncoder().encode(secret)), - catch: (cause) => serviceError("identity", cause), + catch: (cause) => serviceError("keys", cause), }); -const defaultStackIdentity = (jwtSecret: string) => +const defaultStackKeys = (jwtSecret: string) => Effect.gen(function* () { const anonKey = yield* fixedJwt(jwtSecret, "anon"); const serviceRoleKey = yield* fixedJwt(jwtSecret, "service_role"); @@ -65,14 +65,14 @@ const defaultStackIdentity = (jwtSecret: string) => const JsonArray = Schema.Array(Schema.Unknown); const jsonArray = (value: string, field: string) => Schema.decodeEffect(Schema.fromJsonString(JsonArray))(value).pipe( - Effect.mapError((cause) => serviceError("identity", new Error(`${field}: ${cause.message}`))), + Effect.mapError((cause) => serviceError("keys", new Error(`${field}: ${cause.message}`))), ); -export const resolveStackIdentity = Effect.fn("ServiceConfig.resolveStackIdentity")( - (jwtSecret: string, input: StackIdentityInput | undefined, saved: StackCredentials | undefined) => +export const resolveStackKeys = Effect.fn("ServiceConfig.resolveStackKeys")( + (jwtSecret: string, input: StackKeysInput | undefined, saved: StackCredentials | undefined) => Effect.gen(function* () { if (input === undefined && saved !== undefined) return saved; - const defaults = yield* defaultStackIdentity(jwtSecret); + const defaults = yield* defaultStackKeys(jwtSecret); const gotrueJwtKeys = input?.gotrueJwtKeys ?? defaults.gotrueJwtKeys; const publicSigningKeys = input?.publicSigningKeys ?? defaults.publicSigningKeys; const remoteJwks = input?.remoteJwks ?? "[]"; @@ -85,7 +85,7 @@ export const resolveStackIdentity = Effect.fn("ServiceConfig.resolveStackIdentit if (saved !== undefined) { const savedJwks = yield* Schema.decodeEffect( Schema.fromJsonString(Schema.Struct({ keys: Schema.Array(Schema.Unknown) })), - )(saved.jwks).pipe(Effect.mapError((cause) => serviceError("identity", cause))); + )(saved.jwks).pipe(Effect.mapError((cause) => serviceError("keys", cause))); const savedRemoteKeys = yield* jsonArray(saved.remoteJwks, "remoteJwks"); savedLocalKeys = savedJwks.keys.slice(savedRemoteKeys.length); } @@ -141,6 +141,21 @@ export const serviceJwt = Effect.fn("ServiceConfig.serviceJwt")( ).pipe(Effect.mapError((cause) => serviceError("launch", cause))), ); +/** A required input that is neither bound by a composition nor provided in config. */ +export const missingInput = (service: string, input: string): ServiceError => + new ServiceError({ + operation: "input", + message: `${service} requires input ${input}; bind it through a composition or provide it in config`, + }); + +/** Fails a launch whose required input is neither bound by a composition nor provided in config. */ +export const requiredInput = ( + service: string, + input: string, + value: string | undefined, +): Effect.Effect<string, ServiceError> => + value === undefined ? Effect.fail(missingInput(service, input)) : Effect.succeed(value); + export const databaseConnection = Effect.fn("ServiceConfig.databaseConnection")( ( value: string, diff --git a/packages/stack/src/services/ServiceConfig.unit.test.ts b/packages/stack/src/services/ServiceConfig.unit.test.ts index 0093f94c70..b2be74d51d 100644 --- a/packages/stack/src/services/ServiceConfig.unit.test.ts +++ b/packages/stack/src/services/ServiceConfig.unit.test.ts @@ -8,7 +8,7 @@ import { DEFAULT_POSTGRES_ROOT_KEY, DEFAULT_SIGNING_KEY, } from "../Defaults.ts"; -import { resolveStackIdentity } from "./ServiceConfig.ts"; +import { resolveStackKeys } from "./ServiceConfig.ts"; const PublishedJwks = Schema.fromJsonString( Schema.Struct({ @@ -28,7 +28,7 @@ const PublishedJwks = Schema.fromJsonString( ); const savedDefaults = Effect.map( - resolveStackIdentity(DEFAULT_LOCAL_JWT_SECRET, undefined, undefined), + resolveStackKeys(DEFAULT_LOCAL_JWT_SECRET, undefined, undefined), (identity) => ({ ...identity, jwtSecret: DEFAULT_LOCAL_JWT_SECRET, @@ -49,7 +49,7 @@ it.effect("preserves the full saved identity when no identity input is supplied" jwks: '[{"kid":"saved-jwks"}]', }; - expect(yield* resolveStackIdentity(DEFAULT_LOCAL_JWT_SECRET, undefined, saved)).toEqual(saved); + expect(yield* resolveStackKeys(DEFAULT_LOCAL_JWT_SECRET, undefined, saved)).toEqual(saved); }), ); @@ -70,7 +70,7 @@ it.effect("removing key overrides uses new signing-key tokens and drops remote J remoteJwks: '[{"kid":"remote"}]', jwks: '{"keys":[{"kid":"remote"},{"kid":"public-signing-key"}]}', }; - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: privateKeys, @@ -97,7 +97,7 @@ it.effect("removing key overrides uses new signing-key tokens and drops remote J it.effect("publishes a usable HMAC key for an empty signing-key file", () => Effect.gen(function* () { - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: "[]", publicSigningKeys: "[]" }, undefined, @@ -149,7 +149,7 @@ it.effect( .setProtectedHeader({ alg: "ES256", typ: "JWT", kid: DEFAULT_SIGNING_KEY.kid }) .sign(privateKey), ); - const configured = yield* resolveStackIdentity( + const configured = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))([ @@ -176,7 +176,7 @@ it.effect( yield* Effect.tryPromise(() => jwtVerify(configured.anonKey, publishedKey)); yield* Effect.tryPromise(() => jwtVerify(configured.serviceRoleKey, publishedKey)); - const reverted = yield* resolveStackIdentity( + const reverted = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, {}, { @@ -212,7 +212,7 @@ it.effect("retains generated asymmetric tokens when the signing source is unchan gotrueJwtKeys: privateKeys, jwks: '{"keys":[{"kid":"public-signing-key"}]}', }; - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: privateKeys, @@ -240,7 +240,7 @@ it.effect("does not rotate local tokens when only remote JWKS changes", () => gotrueJwtKeys: '[{"kid":"local-private"}]', jwks: '{"keys":[{"kid":"local-public"}]}', }; - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: saved.gotrueJwtKeys, @@ -268,7 +268,7 @@ it.effect("keeps generated tokens when signing key JSON formatting changes", () gotrueJwtKeys: '[{"kid":"local-private"}]', jwks: '{"keys":[{"kid":"local-public"}]}', }; - const resolved = yield* resolveStackIdentity( + const resolved = yield* resolveStackKeys( DEFAULT_LOCAL_JWT_SECRET, { gotrueJwtKeys: '[ { "kid" : "local-private" } ]', diff --git a/packages/stack/src/services/Storage.ts b/packages/stack/src/services/Storage.ts index de97446864..2018dfad64 100644 --- a/packages/stack/src/services/Storage.ts +++ b/packages/stack/src/services/Storage.ts @@ -1,10 +1,15 @@ import { Effect, Schema } from "effect"; +import { + DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + DEFAULT_LOCAL_S3_REGION, + DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, +} from "../Defaults.ts"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { databaseConnection, localJwtSecret, serviceJwt } from "./ServiceConfig.ts"; -import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +import { databaseConnection, requiredInput, localJwtSecret, serviceJwt } from "./ServiceConfig.ts"; +import { type ProcessRecipeSpec, type StartupCommand } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - databaseUrl: Schema.String, + databaseUrl: Schema.optionalKey(Schema.String), filePath: Schema.String, jwtSecret: Schema.optionalKey(Schema.String), jwks: Schema.optionalKey(Schema.String), @@ -13,6 +18,9 @@ export const Config = Schema.Struct({ imgproxyUrl: Schema.optionalKey(Schema.String), fileSizeLimit: Schema.optionalKey(Schema.String), s3ProtocolEnabled: Schema.optionalKey(Schema.Boolean), + s3AccessKeyId: Schema.optionalKey(Schema.String), + s3SecretAccessKey: Schema.optionalKey(Schema.String), + s3Region: Schema.optionalKey(Schema.String), vectorEnabled: Schema.optionalKey(Schema.Boolean), vectorDatabaseUrl: Schema.optionalKey(Schema.String), vectorMaxBuckets: Schema.optionalKey(Schema.Finite), @@ -27,6 +35,14 @@ export const Creation = serviceCreation("storage", Config, Endpoints); export interface Creation extends Schema.Schema.Type<typeof Creation> {} +/** Path prefix the stack gateway strips before forwarding a request to Storage. */ +export const apiPath = "/storage/v1"; + +export const initializationCommand = { + args: [], + containerEntrypoint: "/slim-runtime/bin/prepare", +} satisfies StartupCommand & { readonly containerEntrypoint: string }; + export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ service: "storage", executable: "bin/storage", @@ -35,21 +51,29 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ env: (creation, endpoints, container) => Effect.gen(function* () { const http = endpoints.get("http"); - const db = yield* databaseConnection(creation.config.databaseUrl); + const databaseUrl = yield* requiredInput( + "storage", + "databaseUrl", + creation.config.databaseUrl, + ); + const db = yield* databaseConnection(databaseUrl); const jwt = creation.config.jwtSecret ?? localJwtSecret; const anon = yield* serviceJwt("anon", jwt); const service = yield* serviceJwt("service_role", jwt); const filePath = container ? "/mnt" : creation.config.filePath; return { - DATABASE_URL: creation.config.databaseUrl, + DATABASE_URL: databaseUrl, ...(http === undefined ? {} : { STORAGE_PORT: String(http.port), PORT: String(http.port) }), ANON_KEY: creation.config.anonKey ?? anon, SERVICE_KEY: creation.config.serviceRoleKey ?? service, AUTH_JWT_SECRET: jwt, PGRST_JWT_SECRET: jwt, ...(creation.config.jwks === undefined ? {} : { JWT_JWKS: creation.config.jwks }), + // The Storage image sets NODE_ENV=production, which forces https into TUS upload URLs. + NODE_ENV: "development", TENANT_ID: "stub", REGION: "local", + STORAGE_S3_REGION: creation.config.s3Region ?? DEFAULT_LOCAL_S3_REGION, GLOBAL_S3_BUCKET: "stub", STORAGE_BACKEND: "file", DB_HOST: db.host, @@ -59,6 +83,13 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ DB_NAME: db.database, FILE_STORAGE_BACKEND_PATH: filePath, STORAGE_FILE_BACKEND_PATH: filePath, + TUS_URL_PATH: `${apiPath}/upload/resumable`, + S3_PROTOCOL_PREFIX: apiPath, + S3_PROTOCOL_ACCESS_KEY_ID: creation.config.s3AccessKeyId ?? DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + S3_PROTOCOL_ACCESS_KEY_SECRET: + creation.config.s3SecretAccessKey ?? DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", ...(creation.config.s3ProtocolEnabled === undefined ? {} : { S3_PROTOCOL_ENABLED: String(creation.config.s3ProtocolEnabled) }), @@ -70,7 +101,7 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ : { VECTOR_BUCKET_PROVIDER: "pgvector", VECTOR_STORE_MIGRATIONS_ENABLED: "true", - VECTOR_DATABASE_URL: creation.config.vectorDatabaseUrl ?? creation.config.databaseUrl, + VECTOR_DATABASE_URL: creation.config.vectorDatabaseUrl ?? databaseUrl, }), ...(creation.config.vectorMaxBuckets === undefined ? {} @@ -85,12 +116,12 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ ? {} : { IMGPROXY_URL: creation.config.imgproxyUrl, - ENABLE_IMAGE_TRANSFORMATION: "true", + IMAGE_TRANSFORMATION_ENABLED: "true", }), }; }), args: () => Effect.succeed([]), mounts: (creation, _context) => Effect.succeed([{ source: creation.config.filePath, target: "/mnt", readOnly: false }]), - startup: [{ args: [], containerEntrypoint: "/slim-runtime/bin/prepare" }], + startupCommands: [initializationCommand], }); diff --git a/packages/stack/src/services/StorageGateway.integration.test.ts b/packages/stack/src/services/StorageGateway.integration.test.ts new file mode 100644 index 0000000000..857e6b9460 --- /dev/null +++ b/packages/stack/src/services/StorageGateway.integration.test.ts @@ -0,0 +1,153 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { Context, Crypto, Effect, FileSystem, Layer, Path, Redacted } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { tmpdir } from "node:os"; +import { + DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + DEFAULT_LOCAL_S3_REGION, + DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, +} from "../Defaults.ts"; +import * as State from "../State.ts"; +import type { SavedStack } from "../State.ts"; +import { makeDockerDatabaseRoot } from "../../tests/docker-fixture.ts"; +import { ownerFor } from "../../tests/owner-rpc.ts"; + +const cacheRoot = `${tmpdir()}/supabase-stack-artifacts`; +const jwtSecret = "storage-gateway-secret-with-at-least-32-chars"; +const s3Credentials = { + accessKeyId: DEFAULT_LOCAL_S3_ACCESS_KEY_ID, + secretAccessKey: DEFAULT_LOCAL_S3_SECRET_ACCESS_KEY, + region: DEFAULT_LOCAL_S3_REGION, +}; + +const layout = Effect.fnUntraced(function* (runtime: SavedStack["runtime"], stackId: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (runtime === "native") { + const root = yield* fs.makeTempDirectoryScoped({ prefix: "storage-gateway-native-" }); + return { stateRoot: `${root}/state`, dataRoot: `${root}/data`, storageRoot: `${root}/storage` }; + } + const dataRoot = yield* makeDockerDatabaseRoot("storage-gateway-docker-", stackId).pipe( + Effect.flatMap(fs.realPath), + ); + return { + stateRoot: path.dirname(path.dirname(dataRoot)), + dataRoot, + storageRoot: `${dataRoot}/storage`, + }; +}); + +/** Starts Database and Storage in an owned stack and returns Storage's gateway URL. */ +const serveStorage = Effect.fnUntraced(function* (runtime: SavedStack["runtime"]) { + const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; + const stackId = `storage-gateway-${runtime}-${(yield* crypto.randomUUIDv4).slice(0, 8)}`; + const { stateRoot, dataRoot, storageRoot } = yield* layout(runtime, stackId); + yield* fs.makeDirectory(storageRoot, { recursive: true }); + const saved: SavedStack = { + id: stackId, + identity: { projectRoot: "/tmp/project", branchContext: "storage-gateway", stackName: stackId }, + runtime, + instances: [], + lifetime: "detached", + composition: { members: [], dependencies: [] }, + ports: [], + }; + const state = Context.get(yield* Layer.build(State.layer({ root: stateRoot })), State.Service); + yield* state.save(saved); + const owner = yield* ownerFor({ saved, state, root: dataRoot, cacheRoot }); + yield* Effect.addFinalizer(() => owner.namespace.destroy.pipe(Effect.ignore)); + const created = yield* owner.rpc.supabaseComposition({ + services: [ + { + service: "database", + config: { + version: "17", + databasePassword: Redacted.make("postgres"), + jwtSecret: Redacted.make(jwtSecret), + jwtExpiry: 3600, + }, + endpoints: { sql: { port: "auto" } }, + }, + { + service: "storage", + config: { filePath: storageRoot, jwtSecret, s3ProtocolEnabled: true }, + endpoints: { http: { port: "auto" } }, + }, + ], + }); + const storage = created.find((entry) => entry.creation.service === "storage"); + if (storage === undefined) return yield* Effect.die("Storage member missing"); + yield* owner.rpc.startComposition(); + const { url } = yield* owner.rpc.credentials({ id: storage.id, from: "host" }); + if (url === undefined) return yield* Effect.die("Storage gateway URL missing"); + const { serviceRoleKey } = yield* owner.getStackCredentials; + return { url, serviceRoleKey }; +}); + +for (const runtime of ["native", "docker"] as const) + it.live( + `accepts S3 requests signed over the gateway path and resumes TUS uploads there (${runtime})`, + () => + Effect.scoped( + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const { url, serviceRoleKey } = yield* serveStorage(runtime); + const bucket = "gateway"; + + const createBucket = yield* client.execute( + HttpClientRequest.post(`${url}/bucket`).pipe( + HttpClientRequest.setHeaders({ authorization: `Bearer ${serviceRoleKey}` }), + HttpClientRequest.bodyJsonUnsafe({ name: bucket }), + ), + ); + expect(createBucket.status, yield* createBucket.text).toBe(200); + const s3 = new Bun.S3Client({ ...s3Credentials, endpoint: `${url}/s3`, bucket }); + yield* Effect.promise(() => s3.write("signed.txt", "signed over the gateway")); + const listing = yield* Effect.promise(() => s3.list()); + expect(listing.contents?.map(({ key }) => key)).toEqual(["signed.txt"]); + expect(yield* Effect.promise(() => s3.file("signed.txt").text())).toBe( + "signed over the gateway", + ); + + const metadata = [ + ["bucketName", bucket], + ["objectName", "resumable.txt"], + ["contentType", "text/plain"], + ] + .map(([name, value]) => `${name} ${btoa(value ?? "")}`) + .join(","); + const create = yield* client.execute( + HttpClientRequest.post(`${url}/upload/resumable`).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${serviceRoleKey}`, + "tus-resumable": "1.0.0", + "upload-length": "11", + "upload-metadata": metadata, + }), + ), + ); + expect(create.status, yield* create.text).toBe(201); + const location = create.headers.location ?? ""; + expect(location.startsWith(`${url}/upload/resumable/`), location).toBe(true); + + const patch = yield* client.execute( + HttpClientRequest.patch(location).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${serviceRoleKey}`, + "tus-resumable": "1.0.0", + "upload-offset": "0", + }), + HttpClientRequest.bodyUint8Array( + new TextEncoder().encode("hello"), + "application/offset+octet-stream", + ), + ), + ); + expect(patch.status, yield* patch.text).toBe(204); + expect(patch.headers["upload-offset"]).toBe("5"); + }), + ).pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), + { timeout: 180_000 }, + ); diff --git a/packages/stack/src/services/Studio.ts b/packages/stack/src/services/Studio.ts index 2bc03a70e8..133e1f24ea 100644 --- a/packages/stack/src/services/Studio.ts +++ b/packages/stack/src/services/Studio.ts @@ -1,22 +1,31 @@ import { Effect, Schema } from "effect"; import { EndpointIntent, serviceCreation } from "./Recipe.ts"; -import { serviceJwt } from "./ServiceConfig.ts"; +import { databaseConnection, serviceJwt } from "./ServiceConfig.ts"; import { type ProcessRecipeSpec } from "./ProcessRecipe.ts"; +const CONTAINER_SNIPPETS_ROOT = "/__supabase_snippets"; + export const Config = Schema.Struct({ + databaseUrl: Schema.optionalKey(Schema.String), functionsRoot: Schema.optionalKey(Schema.String), + snippetsRoot: Schema.optionalKey(Schema.String), pgmetaUrl: Schema.optionalKey(Schema.String), analyticsUrl: Schema.optionalKey(Schema.String), analyticsApiKey: Schema.optionalKey(Schema.String), + analyticsBackend: Schema.optionalKey(Schema.Literal("postgres")), functionsUrl: Schema.optionalKey(Schema.String), apiUrl: Schema.optionalKey(Schema.String), publicApiUrl: Schema.optionalKey(Schema.String), + apiSchemas: Schema.optionalKey(Schema.String), + apiExtraSearchPath: Schema.optionalKey(Schema.String), + apiMaxRows: Schema.optionalKey(Schema.Finite), jwtSecret: Schema.optionalKey(Schema.String), anonKey: Schema.optionalKey(Schema.String), serviceRoleKey: Schema.optionalKey(Schema.String), publishableKey: Schema.optionalKey(Schema.String), secretKey: Schema.optionalKey(Schema.String), openaiApiKey: Schema.optionalKey(Schema.String), + cliVersion: Schema.optionalKey(Schema.String), }); export interface Config extends Schema.Schema.Type<typeof Config> {} @@ -45,6 +54,16 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ ...(http === undefined ? {} : { PORT: String(http.port) }), HOSTNAME: container ? "0.0.0.0" : "127.0.0.1", }; + if (creation.config.databaseUrl !== undefined) { + // Studio encrypts a connection string from these for pg-meta; the bootstrap gives the + // `postgres` role the same managed password as the URL's admin role. + const db = yield* databaseConnection(creation.config.databaseUrl); + values.POSTGRES_HOST = db.host; + values.POSTGRES_PORT = db.port; + values.POSTGRES_DB = db.database; + values.POSTGRES_PASSWORD = db.password ?? "postgres"; + values.POSTGRES_USER_READ_WRITE = "postgres"; + } if (creation.config.pgmetaUrl !== undefined) values.STUDIO_PG_META_URL = creation.config.pgmetaUrl; if (creation.config.analyticsUrl !== undefined) @@ -53,6 +72,9 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ values.LOGFLARE_PRIVATE_ACCESS_TOKEN = creation.config.analyticsApiKey; values.NEXT_PUBLIC_ENABLE_LOGS = "true"; } + if (creation.config.analyticsBackend !== undefined) + values.NEXT_ANALYTICS_BACKEND_PROVIDER = creation.config.analyticsBackend; + if (jwt !== undefined) values.AUTH_JWT_SECRET = jwt; if (anonKey !== undefined) values.SUPABASE_ANON_KEY = anonKey; if (serviceRoleKey !== undefined) values.SUPABASE_SERVICE_KEY = serviceRoleKey; if (creation.config.publishableKey !== undefined) @@ -62,20 +84,32 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ if (creation.config.apiUrl !== undefined) values.SUPABASE_URL = creation.config.apiUrl; if (creation.config.publicApiUrl !== undefined) values.SUPABASE_PUBLIC_URL = creation.config.publicApiUrl; + if (creation.config.apiSchemas !== undefined) + values.PGRST_DB_SCHEMAS = creation.config.apiSchemas; + if (creation.config.apiExtraSearchPath !== undefined) + values.PGRST_DB_EXTRA_SEARCH_PATH = creation.config.apiExtraSearchPath; + if (creation.config.apiMaxRows !== undefined) + values.PGRST_DB_MAX_ROWS = String(creation.config.apiMaxRows); if (creation.config.openaiApiKey !== undefined) values.OPENAI_API_KEY = creation.config.openaiApiKey; if (creation.config.functionsRoot !== undefined) values.EDGE_FUNCTIONS_MANAGEMENT_FOLDER = container ? "/__supabase_functions" : creation.config.functionsRoot; + if (creation.config.snippetsRoot !== undefined) + values.SNIPPETS_MANAGEMENT_FOLDER = container + ? CONTAINER_SNIPPETS_ROOT + : creation.config.snippetsRoot; if (creation.config.functionsUrl !== undefined) values.EDGE_FUNCTIONS_URL = creation.config.functionsUrl; + if (creation.config.cliVersion !== undefined) + values.CURRENT_CLI_VERSION = creation.config.cliVersion; return values; }), args: () => Effect.succeed([]), mounts: (creation) => - Effect.succeed( - creation.config.functionsRoot === undefined + Effect.succeed([ + ...(creation.config.functionsRoot === undefined ? [] : [ { @@ -83,7 +117,16 @@ export const makeSpec = (): ProcessRecipeSpec<Creation> => ({ target: "/__supabase_functions", readOnly: true, }, - ], - ), - startup: [], + ]), + ...(creation.config.snippetsRoot === undefined + ? [] + : [ + { + source: creation.config.snippetsRoot, + target: CONTAINER_SNIPPETS_ROOT, + readOnly: false, + }, + ]), + ]), + startupCommands: [], }); diff --git a/packages/stack/src/services/Studio.unit.test.ts b/packages/stack/src/services/Studio.unit.test.ts new file mode 100644 index 0000000000..e471f95578 --- /dev/null +++ b/packages/stack/src/services/Studio.unit.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { makeSpec, type Creation } from "./Studio.ts"; + +const creation = (config: Creation["config"]): Creation => ({ service: "studio", config }); + +it.effect("points Studio's pg-meta connection at the bound database as the postgres role", () => + Effect.gen(function* () { + const env = yield* makeSpec().env( + creation({ + databaseUrl: + "postgresql://supabase_admin:managed-secret@host.docker.internal:54329/postgres", + }), + new Map(), + true, + ); + expect(env).toMatchObject({ + POSTGRES_HOST: "host.docker.internal", + POSTGRES_PORT: "54329", + POSTGRES_DB: "postgres", + POSTGRES_PASSWORD: "managed-secret", + POSTGRES_USER_READ_WRITE: "postgres", + }); + }), +); + +it.effect("mounts the snippets folder read-write and names it per runtime", () => + Effect.gen(function* () { + const spec = makeSpec(); + const config = creation({ snippetsRoot: "/project/supabase/snippets" }); + const containerEnv = yield* spec.env(config, new Map(), true); + const nativeEnv = yield* spec.env(config, new Map(), false); + expect(containerEnv.SNIPPETS_MANAGEMENT_FOLDER).toBe("/__supabase_snippets"); + expect(nativeEnv.SNIPPETS_MANAGEMENT_FOLDER).toBe("/project/supabase/snippets"); + expect(yield* spec.mounts(config, { container: true })).toEqual([ + { source: "/project/supabase/snippets", target: "/__supabase_snippets", readOnly: false }, + ]); + }), +); diff --git a/packages/stack/src/services/Vector.ts b/packages/stack/src/services/Vector.ts index 3718e91d3b..aeab546a93 100644 --- a/packages/stack/src/services/Vector.ts +++ b/packages/stack/src/services/Vector.ts @@ -1,6 +1,7 @@ import { Effect, type FileSystem, type Path, Schema } from "effect"; import { ServiceError } from "../Service.ts"; import { type CatalogOptions, EndpointIntent, serviceCreation } from "./Recipe.ts"; +import { requiredInput } from "./ServiceConfig.ts"; import { makeProcessRecipe, type ProcessDependencies, @@ -8,7 +9,7 @@ import { } from "./ProcessRecipe.ts"; export const Config = Schema.Struct({ - analyticsUrl: Schema.String, + analyticsUrl: Schema.optionalKey(Schema.String), apiKey: Schema.optionalKey(Schema.String), /** Pipeline config without an `api` block; the recipe adds its own. */ configPath: Schema.optionalKey(Schema.String), @@ -19,8 +20,35 @@ export interface Endpoints extends Schema.Schema.Type<typeof Endpoints> {} export const Creation = serviceCreation("vector", Config, Endpoints); export interface Creation extends Schema.Schema.Type<typeof Creation> {} -// Vector has no API flag or env var, so the recipe loads this alongside the pipeline config. -const apiConfig = 'api:\n enabled: true\n address: "${VECTOR_API_ADDRESS}"\n'; +/** + * Vector has no API flag or env var, so the recipe loads this alongside the pipeline config. + * `address` is templated directly into the file content at write time (not through Vector's own + * `${VAR}` config interpolation, which 0.58 disabled by default) — this is the only stack-owned + * config Vector loads that ever varied per launch, so no interpolation flag is needed at all. + */ +const apiConfigFor = (address: string | undefined): string => + address === undefined + ? "api:\n enabled: false\n" + : `api:\n enabled: true\n address: "${address}"\n`; + +/** + * Every `${VAR}` name the recipe's own `env` sets and documents for a pipeline config + * (`Config.analyticsUrl`/`apiKey`, mirrored into `LOGFLARE_URL`/`LOGFLARE_PRIVATE_ACCESS_TOKEN`). + * A caller-supplied pipeline (`Config.configPath`) may reference these the same way the recipe's + * own API config used to. `renderKnownPlaceholders` substitutes only this closed, recipe-owned + * set directly into the file at write time — never a caller's or the process's arbitrary + * environment — so a caller config keeps working on Vector 0.58 without the recipe ever passing + * `--dangerously-allow-env-var-interpolation` (which would expose every env var, not just these). + */ +const renderKnownPlaceholders = ( + content: string, + values: Readonly<Record<string, string | undefined>>, +): string => + Object.entries(values).reduce( + (rendered, [name, value]) => + value === undefined ? rendered : rendered.replaceAll(`\${${name}}`, value), + content, + ); // Vector requires at least one source and sink; the default forwards nothing. const defaultPipelineConfig = [ @@ -72,7 +100,10 @@ const makeSpec = ( const configRoot = path.join(instanceRoot, "runtime", "vector"); const apiConfigPath = path.join(configRoot, "vector-api.yaml"); const defaultPipelinePath = path.join(configRoot, "vector.yaml"); - const pipelinePath = (creation: Creation) => creation.config.configPath ?? defaultPipelinePath; + const renderedPipelinePath = path.join(configRoot, "vector.rendered.yaml"); + /** What Vector actually loads: a caller's file is rendered to a recipe-owned copy first. */ + const resolvedPipelinePath = (creation: Creation) => + creation.config.configPath === undefined ? defaultPipelinePath : renderedPipelinePath; const ownedInstance = (operation: string) => /^[a-zA-Z0-9_-]+$/u.test(instanceId) ? Effect.void @@ -82,30 +113,50 @@ const makeSpec = ( executable: "bin/vector", ports: { http: 9001 }, healthPath: "/health", - env: (creation, endpoints, container) => { - const http = endpoints.get("http"); - return Effect.succeed({ - ...(http === undefined - ? {} - : { VECTOR_API_ADDRESS: `${container ? "0.0.0.0" : "127.0.0.1"}:${http.port}` }), - LOGFLARE_URL: creation.config.analyticsUrl, - ...(creation.config.apiKey === undefined - ? {} - : { LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey }), - }); - }, - args: (creation, _endpoints, context) => - Effect.succeed( - context.container - ? ["--config", containerPipelinePath, "--config", containerApiPath] - : ["--config", pipelinePath(creation), "--config", apiConfigPath], + env: (creation, _endpoints, _container) => + requiredInput("vector", "analyticsUrl", creation.config.analyticsUrl).pipe( + Effect.map((analyticsUrl) => ({ + LOGFLARE_URL: analyticsUrl, + ...(creation.config.apiKey === undefined + ? {} + : { LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey }), + })), ), + args: (creation, endpoints, context) => + Effect.gen(function* () { + const http = endpoints.get("http"); + const address = + http === undefined + ? undefined + : `${context.container ? "0.0.0.0" : "127.0.0.1"}:${http.port}`; + yield* writeAtomically(fs, path, apiConfigPath, apiConfigFor(address)); + if (creation.config.configPath !== undefined) { + const source = yield* fs.readFileString(creation.config.configPath).pipe( + Effect.mapError( + (cause) => + new ServiceError({ + operation: "prepare", + message: "Unable to read Vector configPath", + cause, + }), + ), + ); + const rendered = renderKnownPlaceholders(source, { + LOGFLARE_URL: creation.config.analyticsUrl, + LOGFLARE_PRIVATE_ACCESS_TOKEN: creation.config.apiKey, + }); + yield* writeAtomically(fs, path, renderedPipelinePath, rendered); + } + return context.container + ? ["--config", containerPipelinePath, "--config", containerApiPath] + : ["--config", resolvedPipelinePath(creation), "--config", apiConfigPath]; + }), mounts: (creation) => Effect.succeed([ - { source: pipelinePath(creation), target: containerPipelinePath, readOnly: true }, + { source: resolvedPipelinePath(creation), target: containerPipelinePath, readOnly: true }, { source: apiConfigPath, target: containerApiPath, readOnly: true }, ]), - startup: [], + startupCommands: [], prepare: (creation) => Effect.gen(function* () { yield* ownedInstance("prepare"); @@ -132,6 +183,7 @@ const makeSpec = ( const owned = yield* Effect.all([ canonical(apiConfigPath), canonical(defaultPipelinePath), + canonical(renderedPipelinePath), ]).pipe( Effect.mapError( (cause) => @@ -148,7 +200,9 @@ const makeSpec = ( message: "Vector configPath must not point at a stack-owned Vector config file", }); } - yield* writeAtomically(fs, path, apiConfigPath, apiConfig); + // A safe placeholder until `args` writes the real address — the listening port isn't + // known until endpoints are reserved for an actual launch, which happens after `prepare`. + yield* writeAtomically(fs, path, apiConfigPath, apiConfigFor(undefined)); if (callerPath === undefined) yield* writeAtomically(fs, path, defaultPipelinePath, defaultPipelineConfig); }), @@ -158,6 +212,7 @@ const makeSpec = ( yield* ownedInstance("destroy"); yield* fs.remove(apiConfigPath, { force: true }); yield* fs.remove(defaultPipelinePath, { force: true }); + yield* fs.remove(renderedPipelinePath, { force: true }); if (yield* fs.exists(configRoot)) for (const entry of yield* fs.readDirectory(configRoot)) if (entry.startsWith(temporaryPrefix)) diff --git a/packages/stack/src/shutdown.integration.test.ts b/packages/stack/src/shutdown.integration.test.ts index b94788cae3..905d95750c 100644 --- a/packages/stack/src/shutdown.integration.test.ts +++ b/packages/stack/src/shutdown.integration.test.ts @@ -5,7 +5,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { fileURLToPath } from "node:url"; import * as PromiseStack from "./index.ts"; import { HostEndpoint } from "./HostProcess.ts"; -import { StackErrorSchema } from "./Rpc.ts"; +import { StackError } from "./Rpc.ts"; import * as State from "./State.ts"; import { open as openEffect } from "./effect.ts"; @@ -58,13 +58,14 @@ const withHeldOwner = <A, E, R>( runtime: "native", identity: { projectRoot: root, branchContext: "main", stackName: "shutdown-held" }, instances: [], + lifetime: "detached", composition: { members: [], dependencies: [] }, ports: [], }); const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; return yield* Effect.acquireUseRelease( spawner.spawn( - ChildProcess.make(process.execPath, [fixturePath, stateRoot, cacheRoot, id, "rpc-held"], { + ChildProcess.make(process.execPath, [fixturePath, stateRoot, cacheRoot, id, "held"], { cwd: process.cwd(), detached: true, stdin: "ignore", @@ -120,9 +121,8 @@ it.live("Effect stop reports shutdown-exit when the acknowledged owner remains a return yield* Effect.flip(stack.stop); }), ); - expect(Schema.is(StackErrorSchema)(error)).toBe(true); - if (!Schema.is(StackErrorSchema)(error)) - return yield* Effect.die("unexpected shutdown error"); + expect(Schema.is(StackError)(error)).toBe(true); + if (!Schema.is(StackError)(error)) return yield* Effect.die("unexpected shutdown error"); expect(error.operation).toBe("shutdown-exit"); expect(error.message).toContain("shutdown acknowledgement"); }), @@ -145,9 +145,8 @@ it.live("Promise destroy waits for owner disappearance after the RPC acknowledge (stack) => Effect.tryPromise(() => stack.close()), ), ); - expect(Schema.is(StackErrorSchema)(error)).toBe(true); - if (!Schema.is(StackErrorSchema)(error)) - return yield* Effect.die("unexpected shutdown error"); + expect(Schema.is(StackError)(error)).toBe(true); + if (!Schema.is(StackError)(error)) return yield* Effect.die("unexpected shutdown error"); expect(error).toMatchObject({ operation: "shutdown-exit" }); expect(error.message).toContain("shutdown acknowledgement"); }), diff --git a/packages/stack/src/storage/DirectoryCopy.integration.test.ts b/packages/stack/src/storage/DirectoryCopy.integration.test.ts index 31e9dbef3c..39321e98c2 100644 --- a/packages/stack/src/storage/DirectoryCopy.integration.test.ts +++ b/packages/stack/src/storage/DirectoryCopy.integration.test.ts @@ -192,26 +192,27 @@ describe("copyDirectory", () => { ), ); - it.live("removes a failed host copy and copies the tree itself", () => + it.live("removes a failed host copy and reports the failure", () => run( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "directory-copy-fallback-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "directory-copy-failed-" }); const source = path.join(root, "source"); const destination = path.join(root, "destination"); yield* fs.makeDirectory(source); yield* fs.writeFileString(path.join(source, "file.txt"), "file\n"); - yield* copyDirectory(source, destination).pipe( + const failure = yield* copyDirectory(source, destination).pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, failedHostCopy(fs, path, destination), ), + Effect.flip, ); - expect(yield* fs.readFileString(path.join(destination, "file.txt"))).toBe("file\n"); - expect(yield* fs.exists(path.join(destination, "nested"))).toBe(false); + expect(failure.operation).toBe("copy"); + expect(yield* fs.exists(destination)).toBe(false); }), ), ); diff --git a/packages/stack/src/storage/DirectoryCopy.ts b/packages/stack/src/storage/DirectoryCopy.ts index 8b84843c4b..2c976c1619 100644 --- a/packages/stack/src/storage/DirectoryCopy.ts +++ b/packages/stack/src/storage/DirectoryCopy.ts @@ -1,7 +1,5 @@ -// oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no lstat or clone-copy operation. -import { copyFile as nativeCopyFile, lstat, readdir } from "node:fs/promises"; -// oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no clone-copy flags. -import { constants as fsConstants } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem has no lstat or typed directory listing. +import { lstat, readdir } from "node:fs/promises"; import { Cause, Effect, FileSystem, Option, Path, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -24,26 +22,12 @@ type NativeStats = Awaited<ReturnType<typeof lstat>>; const errorFor = (operation: string, source: string, destination: string, cause: unknown) => new DirectoryCopyError({ operation, source, destination, cause }); -const isCloneUnsupported = (cause: unknown): boolean => { - if (Schema.is(NativeFileError)(cause)) return isCloneUnsupported(cause.cause); - if (typeof cause !== "object" || cause === null || !("code" in cause)) return false; - const code = cause.code; - return ( - code === "ENOTSUP" || - code === "EOPNOTSUPP" || - code === "ENOSYS" || - code === "EXDEV" || - code === "EINVAL" - ); -}; - const isNotFound = (cause: unknown): boolean => Schema.is(NativeFileError)(cause) ? isNotFound(cause.cause) : typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT"; -// FileSystem does not expose lstat or clone flags, so these two operations stay at the -// native boundary while the traversal and directory operations use the Effect service. +// FileSystem does not expose lstat, which link detection needs. const nativeLstat = (target: string): Effect.Effect<NativeStats, NativeFileError> => Effect.uninterruptible( Effect.tryPromise({ @@ -52,19 +36,6 @@ const nativeLstat = (target: string): Effect.Effect<NativeStats, NativeFileError }), ); -const nativeCopy = ( - source: string, - destination: string, - clone: boolean, -): Effect.Effect<void, NativeFileError> => - Effect.uninterruptible( - Effect.tryPromise({ - try: () => - nativeCopyFile(source, destination, clone ? fsConstants.COPYFILE_FICLONE_FORCE : 0), - catch: (cause) => new NativeFileError({ cause }), - }), - ); - const inspect = ( source: string, destination: string, @@ -73,93 +44,7 @@ const inspect = ( Effect.mapError((cause) => errorFor("lstat", source, destination, cause)), ); -const copyFile = (source: string, destination: string): Effect.Effect<void, DirectoryCopyError> => { - const regular = nativeCopy(source, destination, false).pipe( - Effect.mapError((cause) => errorFor("copyFile", source, destination, cause)), - ); - if (process.platform === "win32") return regular; - return nativeCopy(source, destination, true).pipe( - Effect.catch((cause: NativeFileError) => - isCloneUnsupported(cause) - ? regular - : Effect.fail(errorFor("copyFile", source, destination, cause)), - ), - ); -}; - -const validateTree = ( - source: string, - destination: string, - fs: FileSystem.FileSystem, - path: Path.Path, -): Effect.Effect<void, DirectoryCopyError, FileSystem.FileSystem | Path.Path> => - Effect.gen(function* () { - const stats = yield* inspect(source, destination); - if (stats.isSymbolicLink()) - return yield* errorFor("validate", source, destination, "symbolic link"); - if (!stats.isDirectory()) - return yield* errorFor("validate", source, destination, "not a directory"); - const entries = yield* fs - .readDirectory(source) - .pipe(Effect.mapError((cause) => errorFor("readDirectory", source, destination, cause))); - for (const entry of entries) { - const childSource = path.join(source, entry); - const childDestination = path.join(destination, entry); - const childStats = yield* inspect(childSource, childDestination); - if (childStats.isSymbolicLink()) - return yield* errorFor("validate", childSource, childDestination, "symbolic link"); - if (childStats.isDirectory()) yield* validateTree(childSource, childDestination, fs, path); - else if (!childStats.isFile()) - return yield* errorFor("validate", childSource, childDestination, "special file"); - } - }); - -const copyTree = ( - source: string, - destination: string, - fs: FileSystem.FileSystem, - path: Path.Path, -): Effect.Effect<void, DirectoryCopyError, FileSystem.FileSystem | Path.Path> => - Effect.gen(function* () { - const sourceStats = yield* inspect(source, destination); - if (sourceStats.isSymbolicLink()) - return yield* errorFor("validate", source, destination, "symbolic link"); - if (sourceStats.isDirectory() === false) - return yield* errorFor("validate", source, destination, "not a directory"); - - yield* fs - .makeDirectory(destination, { - mode: (Number(sourceStats.mode) | 0o700) & 0o7777, - }) - .pipe(Effect.mapError((cause) => errorFor("makeDirectory", source, destination, cause))); - - const entries = yield* fs - .readDirectory(source) - .pipe(Effect.mapError((cause) => errorFor("readDirectory", source, destination, cause))); - for (const entry of entries) { - const childSource = path.join(source, entry); - const childDestination = path.join(destination, entry); - const childStats = yield* inspect(childSource, childDestination); - if (childStats.isSymbolicLink()) - return yield* errorFor("validate", childSource, childDestination, "symbolic link"); - if (childStats.isDirectory()) { - yield* copyTree(childSource, childDestination, fs, path); - } else if (childStats.isFile()) { - yield* copyFile(childSource, childDestination); - yield* fs - .chmod(childDestination, Number(childStats.mode) & 0o7777) - .pipe( - Effect.mapError((cause) => errorFor("chmod", childSource, childDestination, cause)), - ); - } else { - return yield* errorFor("validate", childSource, childDestination, "special file"); - } - } - yield* fs - .chmod(destination, Number(sourceStats.mode) & 0o7777) - .pipe(Effect.mapError((cause) => errorFor("chmod", source, destination, cause))); - }); - +/** Copies a plain directory tree with one host copy process; links and special files fail. */ export const copyDirectory = Effect.fn("DirectoryCopy.copyDirectory")(function* ( source: string, destination: string, @@ -175,39 +60,45 @@ export const copyDirectory = Effect.fn("DirectoryCopy.copyDirectory")(function* onSuccess: () => Effect.fail(errorFor("validate", source, destination, "destination exists")), }), ); - // cp and robocopy keep or follow links. Only a plain directory tree can use them. - const copies = hostCopies(source, destination); - if (copies.length > 0 && (yield* directoryTreeIsCopyable(source, destination, path))) { - for (const copy of copies) { - const copied = yield* runExec( - copy.command, - copy.args, - source, - destination, - "copy", - copy.acceptStatus, - ).pipe( - Effect.asVoid, - // matchCauseEffect does not observe an external interrupt. - Effect.onInterrupt(() => removePartial(destination, fs, path)), - Effect.matchCauseEffect({ - onSuccess: () => Effect.succeed("copied" as const), - onFailure: (cause) => - removePartial(destination, fs, path).pipe( - Effect.flatMap(() => { - if (Cause.hasInterrupts(cause)) return Effect.failCause(cause); - const outcome = isUsageFailure(cause) ? "usage" : "failed"; - return Effect.succeed(outcome); + const stats = yield* inspect(source, destination); + if (stats.isSymbolicLink() || !stats.isDirectory()) + return yield* errorFor("validate", source, destination, "not a plain directory"); + // cp and robocopy keep or follow links, so only a plain tree reaches them. + const unsupported = yield* process.platform === "win32" + ? scanUnsupported(source, destination, path) + : findUnsupportedEntry(source, destination); + if (unsupported) return yield* errorFor("validate", source, destination, "link or special file"); + let failure: DirectoryCopyError | undefined; + for (const copy of hostCopies(source, destination)) { + const copied = yield* runExec( + copy.command, + copy.args, + source, + destination, + "copy", + copy.acceptStatus, + ).pipe( + Effect.asVoid, + // matchCauseEffect does not observe an external interrupt. + Effect.onInterrupt(() => removePartial(destination, fs, path)), + Effect.matchCauseEffect({ + onSuccess: () => Effect.succeedNone, + onFailure: (cause) => + removePartial(destination, fs, path).pipe( + Effect.flatMap(() => + Option.match(Cause.findErrorOption(cause), { + onNone: () => Effect.failCause(cause), + onSome: (error) => Effect.succeedSome(error), }), ), - }), - ); - if (copied === "copied") return; - if (copied === "failed") break; - } + ), + }), + ); + if (Option.isNone(copied)) return; + failure = copied.value; + if (!isUsageError(failure.cause)) break; } - yield* validateTree(source, destination, fs, path); - return yield* copyTree(source, destination, fs, path); + return yield* failure ?? errorFor("copy", source, destination, "no host copy tool"); }); const runExec = ( @@ -241,27 +132,6 @@ const runExec = ( }), ); -const directoryTreeIsCopyable = ( - source: string, - destination: string, - path: Path.Path, -): Effect.Effect<boolean, DirectoryCopyError, ChildProcessSpawner.ChildProcessSpawner> => - Effect.gen(function* () { - const stats = yield* inspect(source, destination); - if (!stats.isDirectory() || stats.isSymbolicLink()) return false; - return yield* ( - process.platform === "win32" - ? scanUnsupported(source, destination, path) - : findUnsupportedEntry(source, destination) - ).pipe( - Effect.matchCauseEffect({ - onSuccess: (found) => Effect.succeed(!found), - onFailure: (cause) => - Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(false), - }), - ); - }); - const findUnsupportedEntry = ( source: string, destination: string, @@ -390,12 +260,6 @@ const isUsageError = (cause: unknown): boolean => { ); }; -const isUsageFailure = (cause: Cause.Cause<DirectoryCopyError>): boolean => - Option.match(Cause.findErrorOption(cause), { - onNone: () => false, - onSome: (error) => isUsageError(error.cause), - }); - // One process copies the tree. macOS clones, and Linux reflinks when the filesystem can. const hostCopies = (source: string, destination: string): ReadonlyArray<HostCopy> => { switch (process.platform) { diff --git a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts index 798cb5e3cc..14021aa972 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.integration.test.ts @@ -179,7 +179,7 @@ describe("Docker database storage", { timeout: 120_000 }, () => { prepare: () => Effect.void, prepareImage: (image) => Effect.succeed(image), launch: () => Effect.die("unused"), - launchTool: () => Effect.die("unused"), + launchCommand: () => Effect.die("unused"), }, }); yield* storage.prepare("17"); @@ -638,27 +638,28 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.live("destroys legacy host data without a storage marker", () => + it.live("refuses to start unmarked data and removes it through the helper on destroy", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const helperImage = yield* postgresImage("17"); - const root = yield* fs.makeTempDirectoryScoped({ prefix: "docker-storage-legacy-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "docker-storage-unmarked-" }); const storageRoot = path.join(root, "state", "stack", "data"); const cacheRoot = path.join(root, "cache"); - const instanceRoot = path.join(storageRoot, "legacy"); + const instanceRoot = path.join(storageRoot, "unmarked"); const dataRoot = path.join(instanceRoot, "data"); - yield* fs.makeDirectory(dataRoot, { recursive: true }); - yield* fs.writeFileString(path.join(dataRoot, "PG_VERSION"), "17\n"); - yield* fs.writeFileString(path.join(dataRoot, "fixture"), "legacy"); + const checkpointsRoot = path.join(instanceRoot, ".supabase-snapshots"); + yield* fs.makeDirectory(path.join(dataRoot, "base"), { recursive: true }); + yield* fs.writeFileString(path.join(dataRoot, "base", "fixture"), "unmarked"); + yield* fs.makeDirectory(path.join(checkpointsRoot, "entries", "checkpoint", "data"), { + recursive: true, + }); yield* fs.writeFileString( - path.join(instanceRoot, ".supabase-database-ready.json"), - '{"version":"17","runtime":"docker","profile":"supabase"}', + path.join(checkpointsRoot, "entries", "checkpoint", "data", "PG_VERSION"), + "17", ); - const container = yield* makeContainerRuntime({ engine: "docker", root }); - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; yield* docker([ "run", "--rm", @@ -667,12 +668,14 @@ describe("Docker database storage", { timeout: 120_000 }, () => { helperImage, "/bin/sh", "-c", - "chown -R 100:101 /instance/data; chmod 700 /instance/data", + "chown -R 100:101 /instance/data /instance/.supabase-snapshots; chmod -R 700 /instance/data /instance/.supabase-snapshots", ]); + const container = yield* makeContainerRuntime({ engine: "docker", root }); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const storage = yield* makeDockerDatabaseStorage({ runtime: "docker", - stackId: `storage-legacy-${yield* crypto.randomUUIDv4}`, - instanceId: "legacy", + stackId: `storage-unmarked-${yield* crypto.randomUUIDv4}`, + instanceId: "unmarked", instanceRoot, root: storageRoot, cacheRoot, @@ -682,15 +685,53 @@ describe("Docker database storage", { timeout: 120_000 }, () => { container, spawner, }); - expect(yield* fs.exists(path.join(instanceRoot, ".supabase-database-storage.json"))).toBe( - false, - ); + const markerPath = path.join(instanceRoot, ".supabase-database-storage.json"); + + const failure = yield* storage.prepare("17").pipe(Effect.flip); + expect(failure.message).toContain("without a storage marker"); + expect(yield* fs.exists(markerPath)).toBe(false); + yield* storage.destroyData("17"); expect(yield* fs.exists(dataRoot)).toBe(false); - expect(yield* fs.exists(path.join(instanceRoot, ".supabase-database-ready.json"))).toBe( + expect(yield* fs.exists(checkpointsRoot)).toBe(false); + expect(yield* fs.exists(markerPath)).toBe(false); + yield* fs.remove(cacheRoot, { recursive: true, force: true }); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.live("refuses to adopt unmarked Podman data at startup", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "podman-storage-unmarked-" }); + const storageRoot = path.join(root, "state", "stack", "data"); + const instanceRoot = path.join(storageRoot, "unmarked"); + const dataRoot = path.join(instanceRoot, "data"); + yield* fs.makeDirectory(dataRoot, { recursive: true }); + yield* fs.writeFileString(path.join(dataRoot, "PG_VERSION"), "17\n"); + const storage = yield* makeDockerDatabaseStorage({ + runtime: "podman", + stackId: "storage-podman-unmarked", + instanceId: "unmarked", + instanceRoot, + root: storageRoot, + cacheRoot: path.join(root, "cache"), + fs, + path, + crypto, + container: yield* makeContainerRuntime({ engine: "podman", root }), + spawner: yield* ChildProcessSpawner.ChildProcessSpawner, + }); + + const failure = yield* storage.prepare("17").pipe(Effect.flip); + expect(failure.message).toContain("without a storage marker"); + expect(yield* fs.exists(path.join(instanceRoot, ".supabase-database-storage.json"))).toBe( false, ); - yield* fs.remove(cacheRoot, { recursive: true, force: true }); + expect(yield* fs.readFileString(path.join(dataRoot, "PG_VERSION"))).toBe("17\n"); }), ).pipe(Effect.provide(NodeServices.layer)), ); @@ -926,7 +967,7 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ).pipe(Effect.provide(NodeServices.layer)), ); - it.live("adopts root-owned host data through helper operations", () => + it.live("handles root-owned host data through helper operations", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -941,6 +982,19 @@ describe("Docker database storage", { timeout: 120_000 }, () => { const dataRoot = path.join(instanceRoot, "data"); yield* fs.makeDirectory(dataRoot, { recursive: true }); yield* fs.makeDirectory(cacheRoot, { recursive: true }); + // A storage marker always precedes data on disk; write it directly here to set up + // a host-backed, initialized instance without going through that normal sequence. + const initialMarker = yield* Schema.encodeEffect(Schema.fromJsonString(Marker))({ + backend: "host", + namespace: "instance-storage-host-test-adopted", + cacheNamespace: `cache-${"0".repeat(32)}`, + initialized: true, + }); + yield* fs.writeFileString( + path.join(instanceRoot, ".supabase-database-storage.json"), + initialMarker, + { mode: 0o600 }, + ); yield* fs.writeFileString(path.join(dataRoot, "PG_VERSION"), "17\n"); yield* fs.writeFileString(path.join(dataRoot, "fixture"), "adopted"); yield* fs.writeFileString( @@ -998,20 +1052,21 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ]); if (process.platform === "linux") expect(expectedOwnership).toBe("100:101"); yield* storage.saveSnapshot("17", "adopted"); + yield* storage.saveSnapshot("17", "checkpoint", "instance"); const nativeRoot = path.join(root, "native"); yield* fs.makeDirectory(path.join(nativeRoot, "data"), { recursive: true }); yield* fs.writeFileString(path.join(nativeRoot, "data", "PG_VERSION"), "17\n"); yield* fs.writeFileString( path.join(nativeRoot, ".supabase-database-ready.json"), - '{"version":"17.6.1.173","runtime":"native","profile":"supabase"}', + // `makeDatabaseSnapshots` below resolves `version: "17"` through the real catalog + // (`postgresVersion`), so the ready marker must carry that same resolved version. + `{"version":"${postgresVersion("17")}","runtime":"native","profile":"supabase"}`, ); const nativeSnapshots = yield* makeDatabaseSnapshots({ instanceRoot: nativeRoot, cacheRoot, runtime: "native", version: "17", - stackId: "native-interoperability", - instanceId: "database", }); yield* nativeSnapshots.saveSnapshot("native"); yield* fs.remove(path.join(nativeRoot, "data"), { recursive: true }); @@ -1031,11 +1086,14 @@ describe("Docker database storage", { timeout: 120_000 }, () => { ]), ).toBe(expectedOwnership); yield* storage.removeData("17"); + expect(yield* storage.restoreSnapshot("17", "checkpoint", "instance")).toBe(true); + yield* storage.removeData("17"); yield* storage.destroyData("unsupported"); expect(yield* fs.exists(path.join(instanceRoot, ".supabase-database-storage.json"))).toBe( true, ); expect(yield* fs.exists(dataRoot)).toBe(false); + expect(yield* fs.exists(path.join(instanceRoot, ".supabase-snapshots"))).toBe(false); yield* fs.remove(cacheRoot, { recursive: true }); }), ).pipe(Effect.provide(NodeServices.layer)), diff --git a/packages/stack/src/storage/DockerDatabaseStorage.ts b/packages/stack/src/storage/DockerDatabaseStorage.ts index 045426f882..5bf4fcec34 100644 --- a/packages/stack/src/storage/DockerDatabaseStorage.ts +++ b/packages/stack/src/storage/DockerDatabaseStorage.ts @@ -14,9 +14,18 @@ import { import { ChildProcess } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerService } from "effect/unstable/process/ChildProcessSpawner"; import { postgresVersion, resolveArtifact } from "../Artifacts.ts"; +import { failureMessage } from "../internal/failure-message.ts"; import type { ContainerRuntime } from "../runtime/Container.ts"; +import { composeProjectFor } from "../runtime/ContainerName.ts"; import type { DatabaseRuntime } from "../services/Database.ts"; +import { + DatabaseSnapshotError, + instanceSnapshotsDirectory, + makeSnapshotStore, + type SnapshotScope, +} from "../services/DatabaseSnapshot.ts"; import type { DockerHelperRegistry } from "./DockerHelperRegistry.ts"; +import { makeDockerSnapshotBackend, shellQuote } from "./DockerSnapshotBackend.ts"; const Marker = Schema.Struct({ backend: Schema.Literals(["docker", "host"]), @@ -27,29 +36,6 @@ const Marker = Schema.Struct({ initialized: Schema.Boolean, }); type Marker = Schema.Schema.Type<typeof Marker>; -const SnapshotIdentity = Schema.Struct({ - format: Schema.String, - version: Schema.String, - runtime: Schema.String, - platform: Schema.String, - arch: Schema.String, - profile: Schema.String, - key: Schema.String, -}); -const SnapshotDescriptor = Schema.Struct({ - format: Schema.String, - version: Schema.String, - runtime: Schema.String, - platform: Schema.String, - arch: Schema.String, - profile: Schema.String, - keyDigest: Schema.String, -}); -const ReadyMarker = Schema.Struct({ - version: Schema.String, - runtime: Schema.Literals(["native", "docker", "podman"]), - profile: Schema.Literal("supabase"), -}); const DaemonIdentity = Schema.Struct({ daemonId: Schema.String, clientMajor: Schema.Finite, @@ -91,38 +77,47 @@ export interface DockerDatabaseStorage { readonly saveSnapshot: ( version: string, key: string, + scope?: SnapshotScope, ) => Effect.Effect<void, DockerDatabaseStorageError>; readonly restoreSnapshot: ( version: string, key: string, + scope?: SnapshotScope, ) => Effect.Effect<boolean, DockerDatabaseStorageError>; } +// Snapshot failures keep the protocol's operation so both engines report the same step. const errorFor = (operation: string, cause: unknown) => Schema.is(DockerDatabaseStorageError)(cause) ? cause - : new DockerDatabaseStorageError({ - operation, - message: cause instanceof Error ? cause.message : String(cause), - cause, - }); - -const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; - -const withSnapshotLock = (root: string, command: string): string => - `set -eu; /usr/bin/busybox mkdir -p ${shellQuote(root)}; exec 9>${shellQuote(`${root}/.lock`)}; attempt=0; until lock_error=$(/usr/bin/busybox flock -n 9 2>&1); do if [ -n "$lock_error" ]; then echo "$lock_error" >&2; exit 1; fi; if [ "$attempt" -ge 120 ]; then echo 'Timed out waiting for database snapshot lock' >&2; exit 1; fi; attempt=$((attempt + 1)); /usr/bin/busybox sleep 1; done; /usr/bin/sh -eu -c ${shellQuote(command)}`; + : Schema.is(DatabaseSnapshotError)(cause) + ? new DockerDatabaseStorageError({ + operation: cause.operation, + message: cause.message, + cause, + }) + : new DockerDatabaseStorageError({ + operation, + message: failureMessage(cause), + cause, + }); const parseMajor = (version: string): number | undefined => { const major = Number(version.trim().split(".")[0]); return Number.isInteger(major) ? major : undefined; }; +/** Derives the shared Docker volume name from a state-root/daemon identity digest. */ +export const volumeNameFor = (stateDigest: string): string => + `supabase-db-${stateDigest.slice(0, 32)}`; + /** Owns the placement and lifecycle of one database's Docker data and snapshot namespaces. */ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make")( (options: { readonly runtime: DatabaseRuntime; readonly stackId: string; readonly instanceId: string; + readonly project?: string; readonly instanceRoot: string; readonly root: string; readonly cacheRoot: string; @@ -135,6 +130,12 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") }): Effect.Effect<DockerDatabaseStorage, DockerDatabaseStorageError, Scope.Scope> => Effect.gen(function* () { const markerPath = options.path.join(options.instanceRoot, ".supabase-database-storage.json"); + const composeHelperLabels = [ + "--label", + `com.docker.compose.project=${composeProjectFor(options.stackId, options.project)}`, + "--label", + "com.docker.compose.service=database-helper", + ]; const stateRoot = options.path.dirname(options.path.dirname(options.root)); const dataNamespace = `instance-${options.stackId}-${options.instanceId}`; const hash = (value: string) => @@ -145,8 +146,6 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.mapError((cause) => errorFor("identity", cause)), ); const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker)); - const encodeIdentity = Schema.encodeEffect(Schema.fromJsonString(SnapshotIdentity)); - const encodeDescriptor = Schema.encodeEffect(Schema.fromJsonString(SnapshotDescriptor)); const validateMarker = (marker: Marker) => Effect.gen(function* () { if ( @@ -164,6 +163,26 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") forceLive: boolean, ) => Effect.Effect<ResolvedDaemon, DockerDatabaseStorageError> = () => Effect.die("Docker daemon identity was resolved before the engine command existed"); + const hostData = options.path.join(options.instanceRoot, "data"); + const hasUnmarkedData = Effect.gen(function* () { + if ( + !(yield* options.fs + .exists(hostData) + .pipe(Effect.mapError((cause) => errorFor("data", cause)))) + ) + return false; + return yield* options.fs.readDirectory(hostData).pipe( + Effect.map((entries) => entries.length > 0), + // An inaccessible directory cannot be proven empty; treat it as non-empty. + Effect.orElseSucceed(() => true), + ); + }); + // This package always writes the storage marker before populating data, so non-empty + // unmarked data indicates a corrupted state rather than legacy data. + const rejectUnmarkedData = Effect.gen(function* () { + if (yield* hasUnmarkedData) + return yield* errorFor("data", "Database data exists without a storage marker"); + }); const selectedCache = yield* Ref.make<Marker | undefined>(undefined); const selectionLock = yield* Semaphore.make(1); const selected = selectionLock.withPermit( @@ -204,20 +223,12 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") } return marker; } - const hostData = options.path.join(options.instanceRoot, "data"); - const initialized = - (yield* options.fs - .exists(hostData) - .pipe(Effect.mapError((cause) => errorFor("data", cause)))) && - (yield* options.fs.readDirectory(hostData).pipe( - Effect.map((entries) => entries.length > 0), - Effect.orElseSucceed(() => true), - )); + yield* rejectUnmarkedData; const value: Marker = { backend: "host", namespace: dataNamespace, cacheNamespace, - initialized, + initialized: false, }; const encoded = yield* encodeMarker(value); yield* options.fs @@ -289,31 +300,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") .pipe(Effect.mapError((cause) => errorFor("marker", cause))); return updated; } - const hostData = options.path.join(options.instanceRoot, "data"); - if ( - yield* options.fs - .exists(hostData) - .pipe(Effect.mapError((cause) => errorFor("data", cause))) - ) { - const entries = yield* options.fs.readDirectory(hostData).pipe( - // A legacy data directory may be owned by PostgreSQL's container UID. - // Let the root helper validate and adopt it instead of treating EACCES as empty. - Effect.orElseSucceed(() => ["inaccessible-data"]), - ); - if (entries.length > 0) { - const value: Marker = { - backend: "host", - namespace: dataNamespace, - cacheNamespace: `cache-${resolved.cacheDigest.slice(0, 32)}`, - initialized: true, - }; - const encoded = yield* encodeMarker(value); - yield* options.fs - .writeFileString(markerPath, encoded, { mode: 0o600 }) - .pipe(Effect.mapError((cause) => errorFor("marker", cause))); - return value; - } - } + yield* rejectUnmarkedData; // Docker 26 introduced volume-subpath. Older engines retain the host-backed path. const backend = resolved.clientMajor >= 26 && resolved.serverMajor >= 26 ? "docker" : "host"; @@ -456,7 +443,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") Effect.map(hash(`${canonicalStateRoot}\0${identity.daemonId}`), (stateDigest) => ({ ...identity, stateDigest, - volume: `supabase-db-${stateDigest.slice(0, 32)}`, + volume: volumeNameFor(stateDigest), cacheDigest, observed, volumeConfirmed, @@ -496,7 +483,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") .realPath(stateRoot) .pipe(Effect.mapError((cause) => errorFor("identity", cause))); const stateDigest = yield* hash(`${canonicalStateRoot}\0${resolved.daemonId}`); - const expectedVolume = `supabase-db-${stateDigest.slice(0, 32)}`; + const expectedVolume = volumeNameFor(stateDigest); if (marker.volume !== expectedVolume) return yield* errorFor( "destroy", @@ -609,6 +596,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") `com.supabase.instance=${options.instanceId}`, "--label", `com.supabase.stack-root=${options.path.resolve(options.root)}`, + ...composeHelperLabels, ...mountArgs(mounts), preparedImage, "/bin/sh", @@ -698,6 +686,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") `com.supabase.stack=${options.stackId}`, "--label", `com.supabase.stack-root=${options.path.resolve(options.root)}`, + ...composeHelperLabels, ...mountArgs(mounts), preparedImage, "/bin/sh", @@ -778,50 +767,47 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") }); const getMarkerForRemoval = Effect.gen(function* () { const existing = yield* getMarkerIfPresent; - if (Option.isSome(existing)) { - if (options.runtime === "docker") { - yield* selected; - return Option.some(yield* getMarker); - } - return existing; + if (Option.isSome(existing) && options.runtime === "docker") { + yield* selected; + return Option.some(yield* getMarker); } - const data = options.path.join(options.instanceRoot, "data"); - if (!(yield* options.fs.exists(data))) return Option.none<Marker>(); - const nonEmpty = yield* options.fs.readDirectory(data).pipe( - Effect.map((entries) => entries.length > 0), - Effect.orElseSucceed(() => true), - ); - if (!nonEmpty) return Option.none<Marker>(); - yield* selected; - return Option.some(yield* getMarker); + return existing; }); + const readyMarkerPath = options.path.join( + options.instanceRoot, + ".supabase-database-ready.json", + ); + // The host owns this file; a helper's bind-mount view can still list it after the host + // removes it, which makes the helper's unlink fail. + const removeReadyMarker = options.fs + .remove(readyMarkerPath, { force: true }) + .pipe(Effect.mapError((cause) => errorFor("reset", cause))); + /** Unmarked data cannot start, so removal is its only in-product recovery. */ + const removeUnmarkedData = ( + version: string, + { checkpoints }: { readonly checkpoints: boolean }, + ) => + Effect.gen(function* () { + const removeCheckpoints = + checkpoints && + (yield* options.fs + .exists(options.path.join(options.instanceRoot, instanceSnapshotsDirectory)) + .pipe(Effect.mapError((cause) => errorFor("data", cause)))); + if (!removeCheckpoints && !(yield* hasUnmarkedData)) return; + yield* runHelper( + `set -eu; rm -rf /instance/data /instance/.supabase-restore${removeCheckpoints ? ` ${shellQuote(`/instance/${instanceSnapshotsDirectory}`)}` : ""}`, + [{ source: options.instanceRoot, target: "/instance", readOnly: false }], + version, + true, + ); + yield* removeReadyMarker; + }); const writeMarker = (marker: Marker) => encodeMarker(marker).pipe( Effect.flatMap((encoded) => options.fs.writeFileString(markerPath, encoded, { mode: 0o600 }), ), ); - const publishReadyMarker = (version: string) => - Effect.scoped( - Effect.gen(function* () { - const stage = yield* options.fs.makeTempDirectoryScoped({ - directory: options.instanceRoot, - prefix: ".ready-", - }); - const ready = yield* Schema.encodeEffect(Schema.fromJsonString(ReadyMarker))({ - version, - runtime: options.runtime, - profile: "supabase", - }); - const staged = options.path.join(stage, "marker"); - const destination = options.path.join( - options.instanceRoot, - ".supabase-database-ready.json", - ); - yield* options.fs.writeFileString(staged, ready, { mode: 0o600 }); - yield* options.fs.rename(staged, destination); - }), - ); const setup = Effect.fn("DockerDatabaseStorage.prepare")((version: string) => Effect.gen(function* () { yield* selected; @@ -864,7 +850,7 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") ); } else { yield* runHelper( - `set -eu; mkdir -p ${shellQuote(`${store}/data`)} ${shellQuote(`${cache}/entries`)} ${shellQuote(`${cache}/stages`)}; chown -R 100:101 ${shellQuote(store)}`, + `set -eu; mkdir -p ${shellQuote(`${store}/data`)} ${shellQuote(`${cache}/entries`)} ${shellQuote(`${cache}/stages`)}; chown -R 100:101 ${shellQuote(`${store}/data`)}`, [{ source: marker.volume ?? "", target: "/store", readOnly: false, type: "volume" }], version, ); @@ -915,11 +901,12 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") const removeData = Effect.fn("DockerDatabaseStorage.removeData")((version: string) => Effect.gen(function* () { const markerOption = yield* getMarkerForRemoval; - if (Option.isNone(markerOption)) return; + if (Option.isNone(markerOption)) + return yield* removeUnmarkedData(version, { checkpoints: false }); const marker = markerOption.value; if (marker.backend === "host") { yield* runHelper( - `set -eu; rm -rf /instance/data /instance/.supabase-restore-*; mkdir -p /instance/data; chown 100:101 /instance/data`, + `set -eu; rm -rf /instance/data /instance/.supabase-restore; mkdir -p /instance/data; chown 100:101 /instance/data`, snapshotPaths(marker).mounts, version, true, @@ -932,27 +919,26 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") true, ); } - yield* options.fs - .remove(options.path.join(options.instanceRoot, ".supabase-database-ready.json"), { - force: true, - }) - .pipe(Effect.mapError((cause) => errorFor("reset", cause))); + yield* removeReadyMarker; yield* writeMarker({ ...marker, initialized: false }); }).pipe(Effect.mapError((cause) => errorFor("reset", cause))), ); const destroyData = Effect.fn("DockerDatabaseStorage.destroyData")((version: string) => Effect.gen(function* () { - const present = yield* getMarkerIfPresent; - const markerOption = Option.isSome(present) ? present : yield* getMarkerForRemoval; - if (Option.isNone(markerOption)) return; + const markerOption = yield* getMarkerIfPresent; + if (Option.isNone(markerOption)) { + yield* removeUnmarkedData(version, { checkpoints: true }); + return yield* removeHelper(); + } const marker = markerOption.value; if (marker.backend === "host") { yield* runHelper( - `set -eu; rm -rf /instance/data /instance/.supabase-restore-* /instance/.supabase-database-ready.json`, + `set -eu; rm -rf /instance/data /instance/.supabase-restore ${shellQuote(`/instance/${instanceSnapshotsDirectory}`)}`, snapshotPaths(marker).mounts, version, true, ); + yield* removeReadyMarker; yield* removeHelper(); } else { yield* validateDockerMarkerIdentity(marker); @@ -997,117 +983,77 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") { source: options.cacheRoot, target: "/cache", readOnly: false as const }, ], }; - const descriptorDigest = (version: string, key: string) => - encodeIdentity({ - format: "supabase-database-snapshot-v1", - version, + const snapshots = (store: Marker, version: string) => { + const paths = snapshotPaths(store); + const host = store.backend === "host"; + const instanceSnapshotRoot = host + ? `/instance/${instanceSnapshotsDirectory}` + : `/store/${store.namespace}/snapshots`; + const exec = (script: string) => runHelper(script, paths.mounts, version); + return makeSnapshotStore({ + backends: { + cache: makeDockerSnapshotBackend({ + lockFile: options.path.join( + options.cacheRoot, + "stack-database-snapshots", + "locks", + `${host ? "host" : (store.volume ?? "volume")}-${store.cacheNamespace}.sqlite`, + ), + root: paths.root, + data: paths.source, + restoreStages: host ? "/instance/.supabase-restore" : `${paths.root}/stages`, + // Host-backed snapshots stay removable by the host user; restored data belongs + // to the database user. + ...(host + ? { + adoptOwner: "100:101", + epilogue: `owner=$(/usr/bin/busybox stat -c "%u:%g" /cache); /usr/bin/busybox mkdir -p /cache/stack-database-snapshots-helper; /usr/bin/busybox chown "$owner" /cache/stack-database-snapshots-helper; /usr/bin/busybox chown -R "$owner" ${shellQuote(paths.root)}`, + } + : {}), + exec, + }), + // Instance snapshots live beside the instance data, so destroying it removes them. + instance: makeDockerSnapshotBackend({ + lockFile: options.path.join( + options.instanceRoot, + instanceSnapshotsDirectory, + "lock.sqlite", + ), + root: instanceSnapshotRoot, + data: paths.source, + restoreStages: host + ? "/instance/.supabase-restore" + : `${instanceSnapshotRoot}/stages`, + ...(host ? { adoptOwner: "100:101" } : {}), + exec, + }), + }, + instanceRoot: options.instanceRoot, runtime: options.runtime, - platform: process.platform, - arch: process.arch, - profile: "supabase", - key, + version, }).pipe( - Effect.mapError((cause) => errorFor("snapshot", cause)), - Effect.flatMap(hash), + Effect.provideService(FileSystem.FileSystem, options.fs), + Effect.provideService(Path.Path, options.path), + Effect.provideService(Crypto.Crypto, options.crypto), ); + }; const saveSnapshot = Effect.fn("DockerDatabaseStorage.saveSnapshot")( - (version: string, key: string) => + (version: string, key: string, scope: SnapshotScope = "cache") => Effect.gen(function* () { yield* selected; const store = yield* getMarker; if (!store.initialized) return yield* errorFor("snapshot", "Database is not initialized"); - const readyPath = options.path.join( - options.instanceRoot, - ".supabase-database-ready.json", - ); - if ( - !(yield* options.fs - .exists(readyPath) - .pipe(Effect.mapError((cause) => errorFor("snapshot", cause)))) - ) - return yield* errorFor("snapshot", "Database is not ready"); - const ready = yield* options.fs - .readFileString(readyPath) - .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ReadyMarker)))); - if (ready.version !== version || ready.runtime !== options.runtime) - return yield* errorFor( - "snapshot", - "Database readiness marker does not match the requested configuration", - ); - const digest = yield* descriptorDigest(version, key); - const paths = snapshotPaths(store); - const root = paths.root; - const source = paths.source; - const target = `${root}/entries/${digest}`; - const token = yield* options.crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("snapshot", cause)), - ); - const stage = `${root}/stages/${digest}-${token}`; - const descriptor = yield* encodeDescriptor({ - format: "supabase-database-snapshot-v1", - version, - runtime: options.runtime, - platform: process.platform, - arch: process.arch, - profile: "supabase", - keyDigest: digest, - }); - const cacheOwnership = - store.backend === "host" - ? `; owner=$(/usr/bin/busybox stat -c "%u:%g" /cache); /usr/bin/busybox mkdir -p /cache/stack-database-snapshots-helper; /usr/bin/busybox chown "$owner" /cache/stack-database-snapshots-helper; /usr/bin/busybox chown -R "$owner" ${root}` - : ""; - const command = withSnapshotLock( - root, - `set -eu; /usr/bin/busybox mkdir -p ${shellQuote(`${root}/entries`)} ${shellQuote(`${root}/stages`)}; /usr/bin/busybox find ${shellQuote(`${root}/stages`)} -mindepth 1 -maxdepth 1 -exec /usr/bin/busybox rm -rf -- {} +; /usr/bin/busybox find ${shellQuote(`${root}/entries`)} -mindepth 1 -maxdepth 1 -name '*.retired' -exec /usr/bin/busybox rm -rf -- {} +; trap '/usr/bin/busybox rm -rf ${stage}${cacheOwnership}' EXIT; if [ -e ${shellQuote(`${source}/postmaster.pid`)} ]; then echo 'Cannot save a running database snapshot' >&2; exit 1; fi; if [ ! -f ${shellQuote(`${source}/PG_VERSION`)} ]; then echo 'Cannot save snapshot: PG_VERSION is missing' >&2; exit 1; fi; actual=$(/usr/bin/busybox cat ${shellQuote(`${source}/PG_VERSION`)}); if [ "$actual" != ${shellQuote(majorVersion(version))} ]; then echo 'Cannot save snapshot: PostgreSQL major does not match requested version' >&2; exit 1; fi; bad=$(/usr/bin/busybox find ${shellQuote(source)} \\( ! -type f ! -type d \\) -print -quit); if [ -n "$bad" ]; then echo "Cannot save snapshot: unsupported filesystem entry $bad" >&2; exit 1; fi; /usr/bin/busybox rm -rf ${shellQuote(stage)}; /usr/bin/busybox mkdir -p ${shellQuote(stage)}; /usr/local/bin/cp -a --reflink=auto ${shellQuote(source)} ${shellQuote(`${stage}/data`)}; printf '%s' ${shellQuote(descriptor)} > ${shellQuote(`${stage}/descriptor.json`)}; if [ -e ${shellQuote(target)} ]; then /usr/bin/busybox rm -rf ${shellQuote(`${target}.retired`)}; /usr/bin/busybox mv ${shellQuote(target)} ${shellQuote(`${target}.retired`)}; if ! /usr/bin/busybox mv ${shellQuote(stage)} ${shellQuote(target)}; then /usr/bin/busybox mv ${shellQuote(`${target}.retired`)} ${shellQuote(target)}; exit 1; fi; else /usr/bin/busybox mv ${shellQuote(stage)} ${shellQuote(target)}; fi; /usr/bin/busybox rm -rf ${shellQuote(`${target}.retired`)}; /usr/bin/busybox touch ${shellQuote(target)}; /usr/bin/busybox find ${shellQuote(`${root}/entries`)} -mindepth 1 -maxdepth 1 -type d ! -name ${shellQuote(digest)} ! -name '*.retired' -exec /usr/bin/busybox stat -c '%y %n' {} + | /usr/bin/busybox sort -r | /usr/bin/busybox tail -n +3 | /usr/bin/busybox cut -d ' ' -f 4- | /usr/bin/busybox xargs -r /usr/bin/busybox rm -rf`, - ); - yield* runHelper(command, paths.mounts, version); + yield* (yield* snapshots(store, version)).saveSnapshot(key, scope); }).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), ); const restoreSnapshot = Effect.fn("DockerDatabaseStorage.restoreSnapshot")( - (version: string, key: string) => + (version: string, key: string, scope: SnapshotScope = "cache") => Effect.gen(function* () { yield* selected; const store = yield* getMarker; - const digest = yield* descriptorDigest(version, key); - const paths = snapshotPaths(store); - const root = paths.root; - const source = `${root}/entries/${digest}`; - const data = paths.source; - const token = yield* options.crypto.randomUUIDv4.pipe( - Effect.mapError((cause) => errorFor("snapshot", cause)), - ); - const stage = - store.backend === "host" - ? `/instance/.supabase-restore-${digest}` - : `${root}/stages/restore-${digest}-${token}`; - const descriptor = yield* encodeDescriptor({ - format: "supabase-database-snapshot-v1", - version, - runtime: options.runtime, - platform: process.platform, - arch: process.arch, - profile: "supabase", - keyDigest: digest, - }); - const targetSetup = store.initialized - ? `test -d ${shellQuote(data)}` - : `/usr/bin/busybox mkdir -p ${shellQuote(data)}`; - const cacheOwnership = - store.backend === "host" - ? `; owner=$(/usr/bin/busybox stat -c "%u:%g" /cache); /usr/bin/busybox mkdir -p /cache/stack-database-snapshots-helper; /usr/bin/busybox chown "$owner" /cache/stack-database-snapshots-helper; /usr/bin/busybox chown -R "$owner" ${root}` - : ""; - const command = withSnapshotLock( - root, - `set -eu; /usr/bin/busybox mkdir -p ${shellQuote(`${root}/entries`)} ${shellQuote(`${root}/stages`)}; /usr/bin/busybox find ${shellQuote(`${root}/stages`)} -mindepth 1 -maxdepth 1 -exec /usr/bin/busybox rm -rf -- {} +; /usr/bin/busybox find ${shellQuote(`${root}/entries`)} -mindepth 1 -maxdepth 1 -name '*.retired' -exec /usr/bin/busybox rm -rf -- {} +; trap '/usr/bin/busybox rm -rf ${stage}${cacheOwnership}' EXIT; if ! ${targetSetup}; then echo 'Initialized restore target data directory is missing' >&2; exit 1; fi; bad=$(/usr/bin/busybox find ${shellQuote(data)} -mindepth 1 -print -quit); if [ -n "$bad" ]; then echo NONEMPTY; exit 0; fi; if [ ! -d ${shellQuote(source)} ]; then echo MISS; exit 0; fi; if [ ! -f ${shellQuote(`${source}/descriptor.json`)} ]; then echo 'Snapshot descriptor is missing' >&2; exit 1; fi; actual=$(/usr/bin/busybox cat ${shellQuote(`${source}/descriptor.json`)}); expected=${shellQuote(descriptor)}; if [ "$actual" != "$expected" ]; then echo 'Snapshot descriptor does not match requested identity' >&2; exit 1; fi; bad=$(/usr/bin/busybox find ${shellQuote(`${source}/data`)} \\( ! -type f ! -type d \\) -print -quit); if [ -n "$bad" ]; then echo "Snapshot contains unsupported filesystem entry $bad" >&2; exit 1; fi; if [ -e ${shellQuote(`${source}/data/postmaster.pid`)} ]; then echo 'Snapshot contains postmaster.pid' >&2; exit 1; fi; /usr/bin/busybox rm -rf ${shellQuote(stage)}; /usr/local/bin/cp -a --reflink=auto ${shellQuote(`${source}/data`)} ${shellQuote(stage)}; actual=$(/usr/bin/busybox cat ${shellQuote(`${stage}/PG_VERSION`)}); if [ "$actual" != ${shellQuote(majorVersion(version))} ]; then echo 'Snapshot PostgreSQL major does not match requested version' >&2; exit 1; fi; ${store.backend === "host" ? `/usr/bin/busybox chown -R 100:101 ${shellQuote(stage)};` : ""} /usr/bin/busybox rmdir ${shellQuote(data)}; /usr/bin/busybox mv ${shellQuote(stage)} ${shellQuote(data)}; /usr/bin/busybox touch ${shellQuote(source)}; echo HIT`, - ); - const result = yield* runHelper(command, paths.mounts, version); - if (result === "MISS") return false; - if (result === "NONEMPTY") - return yield* errorFor("restore", "Restore target data directory must be empty"); - if (result !== "HIT") - return yield* errorFor("restore", "Docker snapshot restore did not publish"); - yield* publishReadyMarker(version); + if (!(yield* (yield* snapshots(store, version)).restoreSnapshot(key, scope))) + return false; yield* writeMarker({ ...store, initialized: true }); return true; }).pipe(Effect.mapError((cause) => errorFor("snapshot", cause))), @@ -1124,3 +1070,43 @@ export const makeDockerDatabaseStorage = Effect.fn("DockerDatabaseStorage.make") }; }), ); + +/** Shell commands that delete the Docker-volume data recorded by the database instances under a stack's data root. */ +export const volumeDataCleanupCommands = Effect.fn( + "DockerDatabaseStorage.volumeDataCleanupCommands", +)( + function* (options: { + readonly engine: "docker" | "podman"; + readonly root: string; + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + }) { + if (!(yield* options.fs.exists(options.root))) return []; + const markers: Array<Marker> = []; + for (const entry of yield* options.fs.readDirectory(options.root)) { + const markerPath = options.path.join(options.root, entry, ".supabase-database-storage.json"); + if (!(yield* options.fs.exists(markerPath))) continue; + const marker = yield* options.fs + .readFileString(markerPath) + .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(Marker)))); + if (marker.backend !== "docker") continue; + if ( + marker.volume === undefined || + !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,254}$/u.test(marker.volume) || + !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u.test(marker.namespace) + ) + return yield* errorFor( + "destroy", + `Recorded database storage identity is invalid: ${markerPath}`, + ); + markers.push(marker); + } + if (markers.length === 0) return []; + const { image } = yield* resolveArtifact({ service: "database" }); + return markers.map( + (marker) => + `${options.engine} run --rm --mount ${shellQuote(`type=volume,src=${marker.volume},dst=/store`)} ${shellQuote(image)} /bin/sh -c ${shellQuote(`rm -rf /store/${marker.namespace}`)}`, + ); + }, + Effect.mapError((cause) => errorFor("destroy", cause)), +); diff --git a/packages/stack/src/storage/DockerHelperRegistry.ts b/packages/stack/src/storage/DockerHelperRegistry.ts index 79ede8af52..90a8c224b4 100644 --- a/packages/stack/src/storage/DockerHelperRegistry.ts +++ b/packages/stack/src/storage/DockerHelperRegistry.ts @@ -1,4 +1,4 @@ -import { Effect, Ref, Scope, Semaphore } from "effect"; +import { Effect, Exit, Ref, Scope, Semaphore } from "effect"; export interface DockerHelperRegistry { /** Identifies helpers owned by one host process. */ @@ -14,73 +14,105 @@ export interface DockerHelperRegistry { readonly drop: (key: string) => Effect.Effect<void>; } +interface Helper { + readonly id: string; + /** Distinguishes reopened helpers, whose ids can repeat. */ + readonly generation: number; + readonly close: (id: string) => Effect.Effect<void>; +} + +/** Uses of one helper that may run at once; opening or closing it takes all of them. */ +const concurrentUses = 1024; + /** * One sleeping container per volume mount. Mounts are fixed when the container is created, - * and every database in a state directory uses that same volume. + * and every database in a state directory uses that same volume. Uses of one helper run + * concurrently; opening and closing it wait for them. */ export const makeDockerHelperRegistry = ( ownerId: string, ): Effect.Effect<DockerHelperRegistry, never, Scope.Scope> => Effect.gen(function* () { - const helpers = yield* Ref.make( - new Map< - string, - { readonly id: string; readonly close: (id: string) => Effect.Effect<void> } - >(), - ); - const gate = yield* Semaphore.make(1); + const helpers = yield* Ref.make(new Map<string, Helper>()); + const generations = yield* Ref.make(0); + const gates = yield* Ref.make(new Map<string, Semaphore.Semaphore>()); const scope = yield* Scope.Scope; - const release = (id: string, close: (id: string) => Effect.Effect<void>) => close(id); - yield* Scope.addFinalizer( - scope, - gate.withPermits(1)( - Effect.gen(function* () { - const current = yield* Ref.getAndSet(helpers, new Map()); - for (const entry of current.values()) yield* release(entry.id, entry.close); - }), - ), - ); - const forgetAndClose = (key: string): Effect.Effect<void> => + const gateFor = (key: string) => + Ref.modify(gates, (map) => { + const existing = map.get(key); + if (existing !== undefined) return [existing, map]; + const gate = Semaphore.makeUnsafe(concurrentUses); + return [gate, new Map(map).set(key, gate)]; + }); + const exclusive = <A, E>(key: string, effect: Effect.Effect<A, E>) => + gateFor(key).pipe(Effect.flatMap((gate) => gate.withPermits(concurrentUses)(effect))); + // Only the helper a use saw is closed; a replacement opened since then stays. + const forget = (key: string, generation?: number): Effect.Effect<void> => Effect.gen(function* () { const entry = (yield* Ref.get(helpers)).get(key); - if (entry === undefined) return; + if (entry === undefined || (generation !== undefined && entry.generation !== generation)) + return; yield* Ref.update(helpers, (map) => { const next = new Map(map); next.delete(key); return next; }); - yield* release(entry.id, entry.close); + yield* entry.close(entry.id); }); + yield* Scope.addFinalizer( + scope, + Effect.gen(function* () { + for (const key of (yield* Ref.get(gates)).keys()) yield* exclusive(key, forget(key)); + }), + ); + const openOnce = <E>( + key: string, + open: Effect.Effect<string, E>, + close: (id: string) => Effect.Effect<void, E>, + ) => + exclusive( + key, + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + if ((yield* Ref.get(helpers)).has(key)) return; + const id = yield* restore(open); + const generation = yield* Ref.updateAndGet(generations, (value) => value + 1); + yield* Ref.update(helpers, (map) => + new Map(map).set(key, { + id, + generation, + close: (helperId) => + close(helperId).pipe(Effect.catch((cause) => Effect.logError(cause))), + }), + ); + }), + ), + ); const use = <A, E>( key: string, open: Effect.Effect<string, E>, close: (id: string) => Effect.Effect<void, E>, body: (id: string) => Effect.Effect<A, E>, ): Effect.Effect<A, E> => - gate.withPermit( + Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - const current = yield* Ref.get(helpers); - const existing = current.get(key); - const id = - existing?.id ?? - (yield* Effect.uninterruptibleMask((restore) => - restore(open).pipe( - Effect.flatMap((id) => - Ref.update(helpers, (map) => { - const next = new Map(map); - next.set(key, { - id, - close: (helperId) => - close(helperId).pipe(Effect.catch((cause) => Effect.logError(cause))), - }); - return next; - }).pipe(Effect.as(id)), - ), - ), - )); - return yield* body(id).pipe(Effect.onInterrupt(() => forgetAndClose(key))); + const gate = yield* gateFor(key); + let helper: Helper | undefined; + while (helper === undefined) { + yield* restore(gate.take(1)); + helper = (yield* Ref.get(helpers)).get(key); + if (helper !== undefined) break; + yield* gate.release(1); + yield* restore(openOnce(key, open, close)); + } + const exit = yield* Effect.exit(restore(body(helper.id))); + yield* gate.release(1); + // An interrupted exec can leave its command running inside the helper, so the + // helper is closed once its other uses finish. + if (Exit.hasInterrupts(exit)) yield* exclusive(key, forget(key, helper.generation)); + return yield* exit; }), ); - const drop = (key: string): Effect.Effect<void> => gate.withPermit(forgetAndClose(key)); + const drop = (key: string): Effect.Effect<void> => exclusive(key, forget(key)); return { ownerId, use, drop }; }); diff --git a/packages/stack/src/storage/DockerHelperRegistry.unit.test.ts b/packages/stack/src/storage/DockerHelperRegistry.unit.test.ts index adf48cda18..6b709d1433 100644 --- a/packages/stack/src/storage/DockerHelperRegistry.unit.test.ts +++ b/packages/stack/src/storage/DockerHelperRegistry.unit.test.ts @@ -2,18 +2,23 @@ import { expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Fiber, Ref } from "effect"; import { makeDockerHelperRegistry } from "./DockerHelperRegistry.ts"; +const counters = Effect.gen(function* () { + const opened = yield* Ref.make(0); + const closed = yield* Ref.make<Array<string>>([]); + const open = Ref.updateAndGet(opened, (count) => count + 1).pipe( + Effect.map((count) => `helper-${String(count)}`), + ); + const close = (id: string) => Ref.update(closed, (ids) => [...ids, id]); + return { opened, closed, open, close }; +}); + it.effect("opens a helper once and closes it with the host scope", () => Effect.gen(function* () { - const opened = yield* Ref.make(0); - const closed = yield* Ref.make<Array<string>>([]); + const { opened, closed, open, close } = yield* counters; const seen = yield* Ref.make<Array<string>>([]); yield* Effect.scoped( Effect.gen(function* () { const registry = yield* makeDockerHelperRegistry("owner-one"); - const open = Ref.updateAndGet(opened, (count) => count + 1).pipe( - Effect.map((count) => `helper-${String(count)}`), - ); - const close = (id: string) => Ref.update(closed, (ids) => [...ids, id]); yield* registry.use("volume", open, close, (id) => Ref.update(seen, (ids) => [...ids, id])); yield* registry.use("volume", open, close, (id) => Ref.update(seen, (ids) => [...ids, id])); expect(yield* Ref.get(opened)).toBe(1); @@ -27,12 +32,8 @@ it.effect("opens a helper once and closes it with the host scope", () => it.effect("keeps the helper when a command fails", () => Effect.gen(function* () { - const opened = yield* Ref.make(0); + const { opened, open, close } = yield* counters; const registry = yield* makeDockerHelperRegistry("owner-one"); - const open = Ref.updateAndGet(opened, (count) => count + 1).pipe( - Effect.map((count) => `helper-${String(count)}`), - ); - const close = (_id: string) => Effect.void; const failed = yield* registry .use("volume", open, close, () => Effect.fail("script")) .pipe(Effect.exit); @@ -43,53 +44,98 @@ it.effect("keeps the helper when a command fails", () => }), ); -it.effect("closes only when an interrupted use entered the helper body", () => +it.effect("runs concurrent uses of one helper and of different helpers together", () => Effect.gen(function* () { - const opened = yield* Ref.make(0); - const closed = yield* Ref.make<Array<string>>([]); - const enteredFirst = yield* Deferred.make<void>(); - const releaseFirst = yield* Deferred.make<void>(); - const waiterStarted = yield* Deferred.make<void>(); + const { opened, open, close } = yield* counters; const registry = yield* makeDockerHelperRegistry("owner-one"); - const open = Ref.updateAndGet(opened, (count) => count + 1).pipe( - Effect.map((count) => `helper-${String(count)}`), - ); - const close = (id: string) => Ref.update(closed, (ids) => [...ids, id]); - const first = yield* registry + const release = yield* Deferred.make<void>(); + const entered = yield* Ref.make<Array<string>>([]); + const allEntered = yield* Deferred.make<void>(); + const hold = (label: string) => (id: string) => + Effect.gen(function* () { + const labels = yield* Ref.updateAndGet(entered, (values) => [...values, label]); + if (labels.length === 3) yield* Deferred.succeed(allEntered, undefined); + yield* Deferred.await(release); + return id; + }); + const uses = yield* Effect.all( + [ + registry.use("volume-a", open, close, hold("first")), + registry.use("volume-a", open, close, hold("second")), + registry.use("volume-b", open, close, hold("other")), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* Deferred.await(allEntered); + yield* Deferred.succeed(release, undefined); + const ids = yield* Fiber.join(uses); + expect(ids[0]).toBe(ids[1]); + expect(ids[2]).not.toBe(ids[0]); + expect(yield* Ref.get(opened)).toBe(2); + }), +); + +it.effect("closes an interrupted use's helper after its other uses finish", () => + Effect.gen(function* () { + const { closed, open, close } = yield* counters; + const registry = yield* makeDockerHelperRegistry("owner-one"); + const enteredInterrupted = yield* Deferred.make<void>(); + const enteredActive = yield* Deferred.make<void>(); + const releaseActive = yield* Deferred.make<void>(); + const interrupted = yield* registry .use("volume", open, close, () => - Deferred.succeed(enteredFirst, undefined).pipe( - Effect.andThen(Deferred.await(releaseFirst)), - ), + Deferred.succeed(enteredInterrupted, undefined).pipe(Effect.andThen(Effect.never)), ) .pipe(Effect.forkChild); - yield* Deferred.await(enteredFirst); - const waiter = yield* Effect.gen(function* () { - yield* Deferred.succeed(waiterStarted, undefined); - return yield* registry.use("volume", open, close, () => Effect.succeed("unexpected")); - }).pipe(Effect.forkChild); - yield* Deferred.await(waiterStarted); - const waiterExit = yield* Fiber.interrupt(waiter).pipe(Effect.andThen(Fiber.await(waiter))); - expect(Exit.isFailure(waiterExit)).toBe(true); - expect(yield* Ref.get(closed)).toEqual([]); - yield* Deferred.succeed(releaseFirst, undefined); - yield* Fiber.await(first); - - const reused = yield* registry.use("volume", open, close, (id) => Effect.succeed(id)); - expect(reused).toBe("helper-1"); - expect(yield* Ref.get(opened)).toBe(1); - - const enteredActive = yield* Deferred.make<void>(); + yield* Deferred.await(enteredInterrupted); const active = yield* registry - .use("volume", open, close, () => - Deferred.succeed(enteredActive, undefined).pipe(Effect.andThen(Effect.never)), + .use("volume", open, close, (id) => + Deferred.succeed(enteredActive, undefined).pipe( + Effect.andThen(Deferred.await(releaseActive)), + Effect.as(id), + ), ) .pipe(Effect.forkChild); yield* Deferred.await(enteredActive); - const exit = yield* Fiber.interrupt(active).pipe(Effect.andThen(Fiber.await(active))); - expect(Exit.isFailure(exit)).toBe(true); - expect(yield* Ref.get(closed)).toEqual(["helper-1"]); + yield* Effect.sync(() => interrupted.interruptUnsafe()); + expect(yield* Ref.get(closed)).toEqual([]); + yield* Deferred.succeed(releaseActive, undefined); + expect(yield* Fiber.join(active)).toBe("helper-1"); + const exit = yield* Fiber.await(interrupted); + + expect(Exit.hasInterrupts(exit)).toBe(true); + expect(yield* Ref.get(closed)).toEqual(["helper-1"]); const replacement = yield* registry.use("volume", open, close, (id) => Effect.succeed(id)); expect(replacement).toBe("helper-2"); }), ); + +it.effect("keeps the helper when a use is interrupted before it starts", () => + Effect.gen(function* () { + const { closed, open, close } = yield* counters; + const registry = yield* makeDockerHelperRegistry("owner-one"); + const opening = yield* Deferred.make<void>(); + const releaseOpen = yield* Deferred.make<void>(); + const slowOpen = Deferred.succeed(opening, undefined).pipe( + Effect.andThen(Deferred.await(releaseOpen)), + Effect.andThen(open), + ); + const first = yield* registry + .use("volume", slowOpen, close, (id) => Effect.succeed(id)) + .pipe(Effect.forkChild); + yield* Deferred.await(opening); + const waiterStarted = yield* Deferred.make<void>(); + const waiter = yield* Deferred.succeed(waiterStarted, undefined).pipe( + Effect.andThen(registry.use("volume", open, close, (id) => Effect.succeed(id))), + Effect.forkChild, + ); + yield* Deferred.await(waiterStarted); + const waiterExit = yield* Fiber.interrupt(waiter).pipe(Effect.andThen(Fiber.await(waiter))); + expect(Exit.hasInterrupts(waiterExit)).toBe(true); + yield* Deferred.succeed(releaseOpen, undefined); + + expect(yield* Fiber.join(first)).toBe("helper-1"); + expect(yield* Ref.get(closed)).toEqual([]); + }), +); diff --git a/packages/stack/src/storage/DockerSnapshotBackend.ts b/packages/stack/src/storage/DockerSnapshotBackend.ts new file mode 100644 index 0000000000..07addab4f3 --- /dev/null +++ b/packages/stack/src/storage/DockerSnapshotBackend.ts @@ -0,0 +1,127 @@ +import { Effect } from "effect"; +import { failureMessage } from "../internal/failure-message.ts"; +import { + DatabaseSnapshotError, + decodeSnapshotStop, + type SnapshotBackend, + SnapshotRun, + SnapshotStep, +} from "../services/DatabaseSnapshot.ts"; + +/** Quotes one POSIX shell word. */ +export const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +const busybox = "/usr/bin/busybox"; +// The database image ships GNU cp at /usr/local/bin for reflink copies. +const cp = "/usr/local/bin/cp"; +const stopMarker = "snapshot-stop:"; +const stepMarker = "snapshot-step:"; +const doneMarker = "snapshot-done"; + +const stepScript = (step: SnapshotStep, adoptOwner: string | undefined): string => { + const stop = (outcome: string) => `{ echo ${stopMarker}${outcome}; exit 0; }`; + const body = SnapshotStep.$match(step, { + Ensure: ({ directory }) => `${busybox} mkdir -p ${shellQuote(directory)}`, + Clear: ({ directory }) => + `[ ! -L ${shellQuote(directory)} ] || { echo ${shellQuote(`${directory} is a symbolic link`)} >&2; exit 1; }; ${busybox} mkdir -p ${shellQuote(directory)}; ${busybox} find ${shellQuote(directory)} -mindepth 1 -maxdepth 1 -exec ${busybox} rm -rf -- {} +`, + Recover: ({ stages, entries }) => + `for retired in ${shellQuote(stages)}/retired-*; do [ -d "$retired" ] || continue; digest=\${retired##*/retired-}; digest=\${digest%%-*}; if [ ! -e ${shellQuote(entries)}/"$digest" ]; then ${busybox} mkdir -p ${shellQuote(entries)}; ${busybox} mv "$retired" ${shellQuote(entries)}/"$digest"; fi; done`, + Expect: ({ path, present, otherwise }) => + `[ ${present ? "" : "! "}-e ${shellQuote(path)} ] || ${stop(otherwise)}`, + ExpectEmpty: ({ directory, otherwise }) => + `if [ -d ${shellQuote(directory)} ] && [ -n "$(${busybox} find ${shellQuote(directory)} -mindepth 1 -print -quit)" ]; then ${stop(otherwise)}; fi`, + ExpectText: ({ file, text, otherwise }) => + `if [ ! -f ${shellQuote(file)} ] || [ "$(${busybox} cat ${shellQuote(file)})" != ${shellQuote(text)} ]; then echo ${stopMarker}${otherwise}; ${busybox} cat ${shellQuote(file)} 2>/dev/null || true; exit 0; fi`, + Copy: ({ from, to }) => + `[ ! -e ${shellQuote(to)} ] || { echo "Snapshot copy destination exists" >&2; exit 1; }; unsupported=$(${busybox} find ${shellQuote(from)} \\( ! -type f ! -type d \\) -print -quit); [ -z "$unsupported" ] || { echo "Unsupported filesystem entry $unsupported" >&2; exit 1; }; ${cp} -a --reflink=auto ${shellQuote(from)} ${shellQuote(to)}`, + Adopt: ({ directory }) => + adoptOwner === undefined ? ":" : `${busybox} chown -R ${adoptOwner} ${shellQuote(directory)}`, + Write: ({ file, text }) => `printf '%s' ${shellQuote(text)} > ${shellQuote(file)}`, + Rename: ({ from, to, optional }) => + optional + ? `if [ -e ${shellQuote(from)} ] && [ ! -e ${shellQuote(to)} ]; then ${busybox} mv ${shellQuote(from)} ${shellQuote(to)}; fi` + : `${busybox} mkdir -p "$(${busybox} dirname ${shellQuote(to)})"; if [ -d ${shellQuote(to)} ]; then ${busybox} rmdir ${shellQuote(to)}; elif [ -e ${shellQuote(to)} ]; then echo ${shellQuote(`${to} already exists`)} >&2; exit 1; fi; ${busybox} mv ${shellQuote(from)} ${shellQuote(to)}`, + Remove: ({ path }) => `${busybox} rm -rf ${shellQuote(path)}`, + Touch: ({ path }) => `${busybox} touch ${shellQuote(path)}`, + // `%y` sorts by nanosecond modification time; entry names never contain spaces. + Prune: ({ directory, keep, except }) => + `${busybox} find ${shellQuote(directory)} -mindepth 1 -maxdepth 1 ! -name ${shellQuote(except)} -exec ${busybox} stat -c '%y %n' {} + | ${busybox} sort -r | ${busybox} tail -n +${keep + 1} | ${busybox} cut -d ' ' -f 4- | ${busybox} xargs -r ${busybox} rm -rf`, + }); + return `step=${step._tag.toLowerCase()}\n${body}`; +}; + +// A helper exec outlives an interrupted or killed client, so each script holds the store lock +// itself; this also serializes hosts that share one daemon. +const prologue = (root: string) => + [ + "step=preflight", + `for helper_binary in ${busybox} ${cp}; do command -v "$helper_binary" >/dev/null 2>&1 || { echo "Database snapshot helper image is missing required binary: $helper_binary" >&2; exit 97; }; done`, + "step=lock", + `${busybox} mkdir -p ${shellQuote(root)}`, + `exec 9>${shellQuote(`${root}/.lock`)}`, + `attempt=0; until lock_error=$(${busybox} flock -n 9 2>&1); do if [ -n "$lock_error" ]; then echo "$lock_error" >&2; exit 1; fi; if [ "$attempt" -ge 120 ]; then echo 'Timed out waiting for database snapshot lock' >&2; exit 1; fi; attempt=$((attempt + 1)); ${busybox} sleep 1; done`, + ].join("\n"); + +const helperError = (message: string, cause: unknown) => { + const step = new RegExp(`^${stepMarker}(\\w+)$`, "mu").exec(message); + return new DatabaseSnapshotError({ + operation: step?.[1] ?? "helper", + message: message.replace(new RegExp(`\\n?^${stepMarker}\\w+$`, "mu"), "").trim(), + cause, + }); +}; + +const parseRun = (output: string) => + Effect.gen(function* () { + const [first = "", ...rest] = output.split("\n"); + if (first === doneMarker) return SnapshotRun.Completed(); + if (!first.startsWith(stopMarker)) + return yield* new DatabaseSnapshotError({ + operation: "helper", + message: `Snapshot helper returned unexpected output: ${first}`, + }); + const outcome = yield* decodeSnapshotStop(first.slice(stopMarker.length)).pipe( + Effect.mapError( + (cause) => new DatabaseSnapshotError({ operation: "helper", message: cause.message }), + ), + ); + return SnapshotRun.Stopped({ outcome, text: rest.join("\n") }); + }); + +/** Runs each snapshot program as one POSIX shell script inside a storage helper container. */ +export const makeDockerSnapshotBackend = <E>(options: { + readonly lockFile: string; + readonly root: string; + readonly data: string; + readonly restoreStages: string; + /** Owner applied to restored data before publication; unset keeps copied ownership. */ + readonly adoptOwner?: string; + /** Runs on every exit of the script, after the program. */ + readonly epilogue?: string; + readonly exec: (script: string) => Effect.Effect<string, E>; +}): SnapshotBackend => { + const onExit = `status=$?; if [ "$status" -ne 0 ]; then echo "${stepMarker}$step" >&2; fi; ${options.epilogue ?? ":"}; exit "$status"`; + return { + lockFile: options.lockFile, + entries: `${options.root}/entries`, + stages: `${options.root}/stages`, + restoreStages: options.restoreStages, + data: options.data, + join: (...parts) => parts.join("/"), + run: (steps) => + options + .exec( + [ + "set -eu", + `trap ${shellQuote(onExit)} EXIT`, + prologue(options.root), + ...steps.map((step) => stepScript(step, options.adoptOwner)), + `echo ${doneMarker}`, + ].join("\n"), + ) + .pipe( + Effect.mapError((cause) => helperError(failureMessage(cause), cause)), + Effect.flatMap(parseRun), + ), + }; +}; diff --git a/packages/stack/src/testing.integration.test.ts b/packages/stack/src/testing.integration.test.ts new file mode 100644 index 0000000000..8f92f1b45e --- /dev/null +++ b/packages/stack/src/testing.integration.test.ts @@ -0,0 +1,246 @@ +import { NodeHttpClient, NodeServices, NodeSocketServer } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import { + Cause, + ConfigProvider, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Path, + Stream, +} from "effect"; +import { discover, type Stack } from "./effect.ts"; +import { makeTestStack } from "./testing.ts"; +import { postgres } from "./Commands.ts"; + +const sql = (stack: Stack, databaseUrl: string, command: string) => + Effect.gen(function* () { + const decoder = new TextDecoder(); + let stdout = ""; + let stderr = ""; + const result = yield* stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--dbname", databaseUrl, "--set", "ON_ERROR_STOP=1", "-tA", "--command", command], + stdout: (bytes) => Effect.sync(() => (stdout += decoder.decode(bytes))), + stderr: (bytes) => Effect.sync(() => (stderr += decoder.decode(bytes))), + }); + if (result.exitCode !== 0) return yield* Effect.die(`psql failed: ${stderr}`); + return stdout.trim(); + }); + +const snapshotDescriptors = (root: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(root))) return []; + const files = yield* fs.readDirectory(root, { recursive: true }); + return files.filter((file) => file.endsWith("descriptor.json")); + }); + +it.live( + "keeps every checkpoint of parallel stacks beyond the snapshot cache bound and removes them on destroy", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-state-" }); + const cacheRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-cache-" }); + const exercise = (label: string) => + Effect.gen(function* () { + const test = yield* makeTestStack({ runtime: "native", stateRoot, cacheRoot }); + const { databaseUrl } = yield* test.services.database.credentials(); + if (databaseUrl === undefined) return yield* Effect.die("Database URL missing"); + const rows = sql( + test.stack, + databaseUrl, + "SELECT string_agg(value, ',' ORDER BY value) FROM checkpoint_rows", + ); + yield* sql(test.stack, databaseUrl, "CREATE TABLE checkpoint_rows (value text NOT NULL)"); + for (const step of ["0", "1", "2", "3"]) { + yield* sql( + test.stack, + databaseUrl, + `INSERT INTO checkpoint_rows VALUES ('${label}${step}')`, + ); + yield* test.checkpoint(step); + } + + yield* test.reset("0"); + expect(yield* rows).toBe(`${label}0`); + yield* test.reset("2"); + expect(yield* rows).toBe(`${label}0,${label}1,${label}2`); + const unknown = yield* test.reset("unknown").pipe(Effect.flip); + expect(unknown.message).toContain("the database data was not reset"); + expect(yield* rows).toBe(`${label}0,${label}1,${label}2`); + expect(yield* snapshotDescriptors(path.join(stateRoot, test.stack.id))).toHaveLength(4); + }).pipe(Effect.scoped); + + yield* Effect.all([exercise("a"), exercise("b")], { concurrency: "unbounded" }); + + expect(yield* discover({ stateRoot })).toEqual([]); + expect(yield* snapshotDescriptors(stateRoot)).toEqual([]); + expect(yield* snapshotDescriptors(cacheRoot)).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 240_000 }, +); + +it.live( + "warns that data may be partially reset when resetData fails before restoring a checkpoint", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "stack-testing-partial-reset-", + }); + const test = yield* makeTestStack({ runtime: "native", stateRoot }); + yield* test.checkpoint("0"); + + const root = path.join(stateRoot, test.stack.id); + const owners = (yield* fs.readDirectory(root, { recursive: true })).filter((file) => + file.endsWith(".supabase-database-owner.json"), + ); + expect(owners).toHaveLength(1); + const ownerFile = path.join(root, owners[0]!); + const originalMarker = yield* fs.readFileString(ownerFile); + // Released before the stack's teardown, which destroys only data carrying its own marker. + yield* Effect.acquireRelease( + fs.writeFileString( + ownerFile, + `{"stackId":"not-this-stack","instanceId":"not-this-instance"}`, + ), + () => fs.writeFileString(ownerFile, originalMarker).pipe(Effect.orDie), + ); + + const failure = yield* test.reset("0").pipe(Effect.flip); + expect(failure.message).toContain("the database data may have been partially reset"); + expect(failure.message).not.toContain("was reset without restoring checkpoint"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 120_000 }, +); + +it.live( + "restarts the composition when a checkpoint is interrupted after the stack stops", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-interrupt-" }); + const test = yield* makeTestStack({ runtime: "native", stateRoot }); + const database = test.services.database; + const subscribed = yield* Deferred.make<void>(); + const stopped = yield* Deferred.make<void>(); + yield* database.followStatus.pipe( + Stream.runForEach((status) => + Deferred.succeed(subscribed, undefined).pipe( + Effect.andThen( + status.lifecycle === "stopped" ? Deferred.succeed(stopped, undefined) : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + + const checkpoint = yield* test.checkpoint("interrupted").pipe(Effect.forkScoped); + yield* Deferred.await(stopped); + yield* Fiber.interrupt(checkpoint); + + expect(Exit.hasInterrupts(yield* Fiber.await(checkpoint))).toBe(true); + const status = yield* database.status; + expect(status.lifecycle).toBe("running"); + expect(status.health).toBe("healthy"); + const { databaseUrl } = yield* database.credentials(); + if (databaseUrl === undefined) return yield* Effect.die("Database URL missing"); + expect(yield* sql(test.stack, databaseUrl, "SELECT 1")).toBe("1"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 120_000 }, +); + +it.live( + "names the failure, service states and owner log, then removes the stack when test startup fails", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-failure-" }); + const occupied = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }); + if (occupied.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP"); + const port = occupied.address.port; + + const startup = yield* makeTestStack({ + services: [{ service: "mail", endpoints: { http: { port } } }], + runtime: "native", + stateRoot, + }).pipe(Effect.scoped, Effect.exit); + + expect(Exit.isFailure(startup)).toBe(true); + const failure = Exit.isFailure(startup) + ? Cause.findErrorOption(startup.cause) + : Option.none(); + if (Option.isNone(failure)) return yield* Effect.die("Expected a typed startup failure"); + expect(failure.value.operation).toBe("test-startup"); + // macOS and Windows refuse the port while claiming it; Linux reports the failed bind. + expect(failure.value.message).toMatch(new RegExp(`\\b${port}\\b.*\\bin use\\b`)); + expect(failure.value.message).toContain("Services: none"); + expect(failure.value.message).toMatch(/Owner log: .+\/owner\.log$/); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 120_000 }, +); + +it.live( + "closes a test stack that its test already destroyed", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-destroyed-" }); + + const afterDestroy = yield* Effect.gen(function* () { + const test = yield* makeTestStack({ services: ["mail"], runtime: "native", stateRoot }); + yield* test.stack.destroy; + return yield* test.stack.composition.start.pipe(Effect.flip); + }).pipe(Effect.scoped); + + expect(afterDestroy.reason).toBe("owner-unavailable"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), + { timeout: 120_000 }, +); + +it.live("rejects an unknown SUPABASE_STACK_TEST_RUNTIME before creating a stack", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "stack-testing-runtime-" }); + + const failure = yield* makeTestStack({ stateRoot }).pipe( + Effect.scoped, + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromUnknown({ SUPABASE_STACK_TEST_RUNTIME: "vm" })), + ), + Effect.flip, + ); + + expect(failure.operation).toBe("test-stack"); + expect(failure.message).toContain("SUPABASE_STACK_TEST_RUNTIME"); + expect(yield* discover({ stateRoot })).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), + ), +); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts new file mode 100644 index 0000000000..b92c16d860 --- /dev/null +++ b/packages/stack/src/testing.ts @@ -0,0 +1,367 @@ +import { Config, Crypto, Effect, FileSystem, Option, Path, Ref, Schema, Scope } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- FileSystem exposes no OS temp or home directory path for the shared roots. +import { homedir, tmpdir, userInfo } from "node:os"; +import type { PlatformError } from "effect/PlatformError"; +import { defaultRuntime } from "./Artifacts.ts"; +import * as StackEffect from "./effect.ts"; +import { + acquire, + stackAdapter, + type CallOptions, + type Plain, + type Promised, + type ServiceHandles, + type ServiceInstances, + type Stack, +} from "./PromiseClient.ts"; +import { StackError, stackError } from "./Rpc.ts"; +import { + allowedEndpointNames, + ServiceCreationInput as CreationSchema, +} from "./services/Catalog.ts"; + +type Kind = StackEffect.ServiceCreationInput["service"]; +type Creation<K extends Kind> = Extract<StackEffect.ServiceCreationInput, { readonly service: K }>; + +/** A service kind to compose, optionally with config and endpoint overrides. */ +export type TestService<K extends Kind = Kind> = K extends Kind + ? + | K + | { + readonly service: K; + readonly config?: Partial<Creation<K>["config"]>; + readonly endpoints?: Creation<K>["endpoints"]; + } + : never; +/** A test service with plain configuration values. */ +export type PlainTestService = Plain<TestService>; +/** The service kinds a test stack selects. */ +export type TestServiceKind<S> = S extends Kind + ? S + : S extends { readonly service: infer K extends Kind } + ? K + : never; + +type UnionToIntersection<U> = (U extends unknown ? (value: U) => void : never) extends ( + value: infer I, +) => void + ? I + : never; +/** A kind when it is exactly one kind, not a union a conditional element may pick from. */ +type SingleKind<K> = [K] extends [never] ? never : [K] extends [UnionToIntersection<K>] ? K : never; +/** Kinds one service list always selects; none when its length is not static. */ +type SelectedKinds<T> = + T extends ReadonlyArray<unknown> + ? number extends T["length"] + ? never + : { [I in keyof T]: SingleKind<TestServiceKind<T[I]>> }[number] + : never; +/** Kinds every variant of a service list selects, so their handles are always present. */ +type RequiredKinds<S> = + UnionToIntersection<S extends unknown ? { readonly kinds: SelectedKinds<S> } : never> extends { + readonly kinds: infer K extends Kind; + } + ? K + : never; + +/** Test stack options; omitted roots use shared per-user or temporary locations. */ +export interface TestStackOptions<S> { + /** Defaults to `["database"]`. */ + readonly services?: S; + /** Defaults to `SUPABASE_STACK_TEST_RUNTIME`, then the platform's default runtime. */ + readonly runtime?: StackEffect.CreateOptions["runtime"]; + readonly stateRoot?: string; + readonly cacheRoot?: string; + /** + * A caller-owned project root; by default a temporary one is removed on disposal. Default + * Storage and Functions directories live in the temporary root, never in a caller's root. + */ + readonly projectRoot?: string; +} + +/** A composed, ready session stack that is destroyed when its scope closes. */ +export interface EffectTestStack< + Required extends Kind = "database", + Optional extends Kind = never, +> { + readonly stack: StackEffect.Stack; + /** Handles by kind; a kind that some variant of the service list omits is optional. */ + readonly services: ServiceHandles<Required, Optional, StackEffect.ServiceInstances>; + readonly projectRoot: string; + /** + * Saves the database data under `name` as an instance snapshot, which other stacks cannot evict + * and destroying this stack removes; the composition stops and restarts around it. + */ + readonly checkpoint: (name: string) => Effect.Effect<void, StackError>; + /** + * Restores the data saved by `checkpoint(name)` and waits until every service is ready. An + * unknown name fails before any data changes; the composition restarts even when reset fails. + */ + readonly reset: (name: string) => Effect.Effect<void, StackError>; +} + +/** A composed, ready session stack; disposal destroys it, then closes its client. */ +export interface TestStack< + Required extends Kind = "database", + Optional extends Kind = never, +> extends AsyncDisposable { + readonly stack: Stack; + /** Handles by kind; a kind that some variant of the service list omits is optional. */ + readonly services: ServiceHandles<Required, Optional, ServiceInstances>; + readonly projectRoot: string; + readonly checkpoint: Promised<EffectTestStack["checkpoint"]>; + readonly reset: Promised<EffectTestStack["reset"]>; +} + +const runtimeOverride = Config.option( + Config.literals(["native", "docker", "podman"], "SUPABASE_STACK_TEST_RUNTIME"), +); + +const testFailure = (operation: string) => (cause: unknown) => stackError(operation, cause); + +/** Local-development configuration for each kind, with every endpoint on an automatic port. */ +const localCreation = Effect.fnUntraced(function* ( + kind: Kind, + dataRoot: Effect.Effect<string, PlatformError, Scope.Scope>, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = Effect.fnUntraced(function* (name: string) { + const target = path.join(yield* dataRoot, name); + yield* fs.makeDirectory(target, { recursive: true }); + return target; + }); + const config = + kind === "database" + ? { version: "17", jwtExpiry: 3600 } + : kind === "storage" || kind === "imgproxy" + ? { filePath: yield* directory("storage") } + : kind === "functions" + ? { functionsRoot: yield* directory("functions") } + : {}; + const endpoints = Object.fromEntries( + allowedEndpointNames(kind).map((name) => [name, { port: "auto" }]), + ); + return { config, endpoints }; +}); + +function byKind<Required extends Kind, Optional extends Kind>( + members: ReadonlyArray<StackEffect.ServiceInstances[Kind]>, +): ServiceHandles<Required, Optional, StackEffect.ServiceInstances>; +function byKind( + members: ReadonlyArray<StackEffect.ServiceInstances[Kind]>, +): Readonly<Record<string, StackEffect.ServiceInstances[Kind]>> { + return Object.fromEntries(members.map((member) => [member.service, member])); +} + +const make = Effect.fn("TestStack.make")( + function* <Required extends Kind, Optional extends Kind = never>( + options: TestStackOptions<ReadonlyArray<TestService | PlainTestService>>, + decode: (creation: unknown) => Effect.Effect<StackEffect.ServiceCreationInput, StackError>, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const runtime = + options.runtime ?? Option.getOrUndefined(yield* runtimeOverride) ?? defaultRuntime(); + // Shared roots are created owner-only, so each OS user needs its own. + const user = process.getuid?.() ?? userInfo().username; + const stateRoot = options.stateRoot ?? path.join(tmpdir(), `supabase-stack-tests-${user}`); + const cacheRoot = options.cacheRoot ?? path.join(tmpdir(), `supabase-stack-artifacts-${user}`); + // Native Edge Runtime on Linux cannot read project files below /tmp. + const temporaryRoot = fs.makeTempDirectoryScoped({ + prefix: "supabase-test-stack-", + ...(runtime === "native" && process.platform === "linux" ? { directory: homedir() } : {}), + }); + const projectRoot = options.projectRoot ?? (yield* temporaryRoot); + const dataRoot = + options.projectRoot === undefined + ? Effect.succeed(projectRoot) + : yield* Effect.cached(temporaryRoot); + const services: ReadonlyArray<TestService | PlainTestService> = options.services ?? [ + "database", + ]; + const creations = yield* Effect.forEach(services, (service) => + Effect.gen(function* () { + const kind = typeof service === "string" ? service : service.service; + const local = yield* localCreation(kind, dataRoot); + return yield* decode({ + service: kind, + config: { ...local.config, ...(typeof service === "string" ? {} : service.config) }, + endpoints: { + ...local.endpoints, + ...(typeof service === "string" ? {} : service.endpoints), + }, + }); + }), + ); + const stack = yield* Effect.acquireRelease( + StackEffect.create({ + projectRoot, + stateRoot, + cacheRoot, + runtime, + name: `test-${yield* crypto.randomUUIDv4}`, + lifetime: "session", + }), + (created) => created.destroy.pipe(Effect.orDie), + ); + const diagnose = (operation: string) => (cause: StackError) => + stack.services.list.pipe( + Effect.flatMap((registered) => + Effect.forEach(registered, (member) => + member.status.pipe( + Effect.map( + (status) => + `${member.service}=${status.lifecycle}` + + `${status.health === undefined ? "" : `/${status.health}`}` + + `${status.error === undefined ? "" : ` (${status.error.message})`}`, + ), + Effect.orElseSucceed(() => `${member.service}=unobservable`), + ), + ), + ), + Effect.orElseSucceed((): ReadonlyArray<string> => []), + Effect.flatMap((statuses) => + Effect.fail( + new StackError({ + ...cause, + operation, + message: `${cause.message}\nServices: ${statuses.length === 0 ? "none" : statuses.join(", ")}\nOwner log: ${path.join(stateRoot, stack.id, "owner.log")}`, + }), + ), + ), + ); + const members = yield* stack.composition + .supabase(creations, { eager: true }) + .pipe(Effect.catch(diagnose("test-startup"))); + const start = (operation: string) => + stack.composition.start.pipe( + Effect.andThen( + Effect.forEach(members, (member) => member.ready, { + concurrency: "unbounded", + discard: true, + }), + ), + Effect.catch(diagnose(operation)), + ); + yield* start("test-startup"); + const database = members.find( + (member): member is StackEffect.DatabaseInstance => member.service === "database", + ); + const checkpoints = yield* Ref.make<ReadonlySet<string>>(new Set()); + const withContext = (suffix: string) => + Effect.mapError( + (failure: StackError) => + new StackError({ ...failure, message: `${failure.message}; ${suffix}` }), + ); + /** Stops the composition around `work` and brings it back whether or not `work` succeeds. */ + const whileStopped = ( + operation: string, + work: (database: StackEffect.DatabaseInstance) => Effect.Effect<void, StackError>, + ) => + database === undefined + ? Effect.fail( + new StackError({ operation, message: "Test stack checkpoints require a database" }), + ) + : // The owner completes a stop its caller abandons, so the restart must follow its reply. + Effect.uninterruptible(stack.composition.stop).pipe( + Effect.andThen(work(database)), + Effect.matchEffect({ + onSuccess: () => start(operation), + onFailure: (failure) => + start(operation).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.fail(failure), + onFailure: (restart) => + Effect.fail(failure).pipe( + withContext(`restarting the composition also failed: ${restart.message}`), + ), + }), + ), + }), + // Signal aborts and test timeouts interrupt; the shared stack must not stay stopped. + Effect.onInterrupt(() => start(operation)), + ); + const testStack: EffectTestStack<Required, Optional> = { + stack, + services: byKind<Required, Optional>(members), + projectRoot, + checkpoint: (name) => + whileStopped("checkpoint", (current) => + current + .saveSnapshot(name, { scope: "instance" }) + .pipe(Effect.andThen(Ref.update(checkpoints, (names) => new Set([...names, name])))), + ), + reset: (name) => + Ref.get(checkpoints).pipe( + Effect.flatMap((names) => + names.has(name) + ? whileStopped("reset", (current) => + current.resetData.pipe( + withContext("the database data may have been partially reset"), + Effect.andThen( + current.restoreSnapshot(name, { scope: "instance" }).pipe( + Effect.flatMap((restored) => + restored + ? Effect.void + : Effect.fail( + new StackError({ + operation: "reset", + message: `Checkpoint ${name} is missing`, + }), + ), + ), + withContext( + `the database data was reset without restoring checkpoint ${name}`, + ), + ), + ), + ), + ) + : Effect.fail( + new StackError({ + operation: "reset", + message: `No checkpoint named ${name}; the database data was not reset`, + }), + ), + ), + ), + }; + return testStack; + }, + Effect.mapError(testFailure("test-stack")), +); + +const decodeCreation = (creation: unknown) => + Schema.decodeUnknownEffect(CreationSchema)(creation).pipe(Effect.mapError(testFailure("config"))); +const creationJson = Schema.toCodecJson(CreationSchema); +const decodePlainCreation = (creation: unknown) => + Schema.decodeUnknownEffect(creationJson)(creation).pipe(Effect.mapError(testFailure("config"))); + +/** Creates, composes and readies a session stack that the enclosing scope destroys. */ +export const makeTestStack = <const S extends ReadonlyArray<TestService> = readonly ["database"]>( + options: TestStackOptions<S> = {}, +) => make<RequiredKinds<S>, TestServiceKind<S[number]>>(options, decodeCreation); + +/** Creates, composes and readies a session stack for `await using`. */ +export const createTestStack = < + const S extends ReadonlyArray<PlainTestService> = readonly ["database"], +>( + options: TestStackOptions<S> = {}, + callOptions?: CallOptions, +): Promise<TestStack<RequiredKinds<S>, TestServiceKind<S[number]>>> => + acquire( + make<RequiredKinds<S>, TestServiceKind<S[number]>>(options, decodePlainCreation), + callOptions, + ).then(({ value, client }) => { + const adapter = stackAdapter(client); + return { + stack: adapter.stack(value.stack), + services: adapter.services<RequiredKinds<S>, TestServiceKind<S[number]>>(value.services), + projectRoot: value.projectRoot, + checkpoint: (name, runOptions) => client.run(value.checkpoint(name), runOptions), + reset: (name, runOptions) => client.run(value.reset(name), runOptions), + [Symbol.asyncDispose]: client.close, + }; + }); diff --git a/packages/stack/src/whole-stack.docker.e2e.test.ts b/packages/stack/src/whole-stack.docker.e2e.test.ts deleted file mode 100644 index 2cbaf21608..0000000000 --- a/packages/stack/src/whole-stack.docker.e2e.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { it } from "@effect/vitest"; -import { Effect } from "effect"; -import { allEager, defaultLifecycle, parallel } from "../tests/whole-stack/scenarios.ts"; -import { idleWake } from "../tests/whole-stack/idle.ts"; -import { servicesLayer } from "../tests/whole-stack/fixture.ts"; - -const run = <A, E, R>(effect: Effect.Effect<A, E, R>) => - Effect.scoped(effect).pipe(Effect.provide(servicesLayer)); - -it.live( - "Docker: default database eager lifecycle and reopen", - () => run(defaultLifecycle("docker")), - { - timeout: 15 * 60_000, - }, -); -it.live("Docker: all services eager", () => run(allEager("docker")), { timeout: 15 * 60_000 }); -it.live("Docker: idle and wake", () => run(idleWake("docker")), { timeout: 10 * 60_000 }); -it.live("Docker: parallel stack isolation", () => run(parallel("docker")), { - timeout: 30 * 60_000, -}); diff --git a/packages/stack/src/whole-stack.docker.idle-parallel.e2e.test.ts b/packages/stack/src/whole-stack.docker.idle-parallel.e2e.test.ts new file mode 100644 index 0000000000..a18f68ff23 --- /dev/null +++ b/packages/stack/src/whole-stack.docker.idle-parallel.e2e.test.ts @@ -0,0 +1,9 @@ +import { it } from "@effect/vitest"; +import { parallel } from "../tests/whole-stack/scenarios.ts"; +import { idleWake } from "../tests/whole-stack/idle.ts"; +import { run } from "../tests/whole-stack/fixture.ts"; + +it.live("Docker: idle and wake", () => run(idleWake("docker")), { timeout: 10 * 60_000 }); +it.live("Docker: parallel stack isolation", () => run(parallel("docker")), { + timeout: 30 * 60_000, +}); diff --git a/packages/stack/src/whole-stack.docker.lifecycle.e2e.test.ts b/packages/stack/src/whole-stack.docker.lifecycle.e2e.test.ts new file mode 100644 index 0000000000..53aa5d5dd0 --- /dev/null +++ b/packages/stack/src/whole-stack.docker.lifecycle.e2e.test.ts @@ -0,0 +1,12 @@ +import { it } from "@effect/vitest"; +import { allEager, defaultLifecycle } from "../tests/whole-stack/scenarios.ts"; +import { run } from "../tests/whole-stack/fixture.ts"; + +it.live( + "Docker: default database eager lifecycle and reopen", + () => run(defaultLifecycle("docker")), + { + timeout: 15 * 60_000, + }, +); +it.live("Docker: all services eager", () => run(allEager("docker")), { timeout: 15 * 60_000 }); diff --git a/packages/stack/src/whole-stack.native.e2e.test.ts b/packages/stack/src/whole-stack.native.e2e.test.ts deleted file mode 100644 index 70ca2a6835..0000000000 --- a/packages/stack/src/whole-stack.native.e2e.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { it } from "@effect/vitest"; -import { Effect } from "effect"; -import { allEager, defaultLifecycle, parallel } from "../tests/whole-stack/scenarios.ts"; -import { idleWake } from "../tests/whole-stack/idle.ts"; -import { servicesLayer } from "../tests/whole-stack/fixture.ts"; - -const run = <A, E, R>(effect: Effect.Effect<A, E, R>) => - Effect.scoped(effect).pipe(Effect.provide(servicesLayer)); - -it.live( - "native: default database eager lifecycle and reopen", - () => run(defaultLifecycle("native")), - { - timeout: 15 * 60_000, - }, -); -it.live("native: all services eager", () => run(allEager("native")), { timeout: 15 * 60_000 }); -it.live("native: idle and wake", () => run(idleWake("native")), { timeout: 10 * 60_000 }); -it.live("native: parallel stack isolation", () => run(parallel("native")), { - timeout: 30 * 60_000, -}); diff --git a/packages/stack/src/whole-stack.native.idle-parallel.e2e.test.ts b/packages/stack/src/whole-stack.native.idle-parallel.e2e.test.ts new file mode 100644 index 0000000000..16fdcce8be --- /dev/null +++ b/packages/stack/src/whole-stack.native.idle-parallel.e2e.test.ts @@ -0,0 +1,9 @@ +import { it } from "@effect/vitest"; +import { parallel } from "../tests/whole-stack/scenarios.ts"; +import { idleWake } from "../tests/whole-stack/idle.ts"; +import { run } from "../tests/whole-stack/fixture.ts"; + +it.live("native: idle and wake", () => run(idleWake("native")), { timeout: 10 * 60_000 }); +it.live("native: parallel stack isolation", () => run(parallel("native")), { + timeout: 30 * 60_000, +}); diff --git a/packages/stack/src/whole-stack.native.lifecycle.e2e.test.ts b/packages/stack/src/whole-stack.native.lifecycle.e2e.test.ts new file mode 100644 index 0000000000..d78a02f169 --- /dev/null +++ b/packages/stack/src/whole-stack.native.lifecycle.e2e.test.ts @@ -0,0 +1,12 @@ +import { it } from "@effect/vitest"; +import { allEager, defaultLifecycle } from "../tests/whole-stack/scenarios.ts"; +import { run } from "../tests/whole-stack/fixture.ts"; + +it.live( + "native: default database eager lifecycle and reopen", + () => run(defaultLifecycle("native")), + { + timeout: 15 * 60_000, + }, +); +it.live("native: all services eager", () => run(allEager("native")), { timeout: 15 * 60_000 }); diff --git a/packages/stack/tests/compiled-dispatch-fixture.ts b/packages/stack/tests/compiled-dispatch-fixture.ts index 7f3b1d60d6..a4ac872efd 100644 --- a/packages/stack/tests/compiled-dispatch-fixture.ts +++ b/packages/stack/tests/compiled-dispatch-fixture.ts @@ -1,25 +1,22 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; -import { Data, Effect, Layer, Option, Stream } from "effect"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { Data, Effect, FileSystem, Layer, Option, Stream } from "effect"; import { runHostProcessIfDispatched, runNativeProcessIfDispatched, } from "../src/internal/dispatch.ts"; -import { connectHost, launchHost, type HostEndpoint } from "../src/HostProcess.ts"; +import { + connectHost, + launchHost, + shutdownHost, + ownerExitProbe, + waitForOwnerExit, + type HostEndpoint, +} from "../src/HostProcess.ts"; import { spawnNativeProcess } from "../src/runtime/NativeProcess.ts"; -import { StackRpc } from "../src/Rpc.ts"; import * as State from "../src/State.ts"; const argv = process.argv.slice(2); class FixtureError extends Data.TaggedError("FixtureError")<{ readonly message: string }> {} -const clientFor = (port: number) => - RpcClient.make(StackRpc).pipe( - Effect.provide( - RpcClient.layerProtocolHttp({ url: `http://127.0.0.1:${port}/rpc` }).pipe( - Layer.provide(RpcSerialization.layerNdjson), - ), - ), - ); if (!(await runHostProcessIfDispatched(argv)) && !(await runNativeProcessIfDispatched(argv))) { const [mode, ...modeArgs] = argv; const output = (endpoint: HostEndpoint) => process.stdout.write(`${JSON.stringify(endpoint)}\n`); @@ -31,14 +28,19 @@ if (!(await runHostProcessIfDispatched(argv)) && !(await runNativeProcessIfDispa const program = Effect.scoped( Effect.gen(function* () { const state = yield* State.Service; - const endpoint = yield* mode === "owner" + const access = yield* mode === "owner" ? launchHost(state, { stateRoot, cacheRoot: cacheRoot ?? stateRoot, stackId }) : connectHost(state, stackId); if (mode === "stop") { - const client = yield* clientFor(endpoint.port); - yield* client.shutdown({ destroy: false }); + const refusal = yield* shutdownHost(access, false); + if (Option.isSome(refusal)) + return yield* new FixtureError({ message: refusal.value.message }); + yield* waitForOwnerExit( + access.endpoint.pid, + ownerExitProbe(yield* FileSystem.FileSystem), + ); } - output(endpoint); + output(access.endpoint); }), ).pipe( Effect.provide( diff --git a/packages/stack/tests/docker-fixture.ts b/packages/stack/tests/docker-fixture.ts index 94a6040bd5..f986eb7931 100644 --- a/packages/stack/tests/docker-fixture.ts +++ b/packages/stack/tests/docker-fixture.ts @@ -1,8 +1,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { Crypto, Effect, FileSystem, Path, Stream } from "effect"; +import { volumeNameFor } from "../src/storage/DockerDatabaseStorage.ts"; import { cleanupDockerRoot } from "./docker-cleanup.ts"; -const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray<string>) => +/** Runs a Docker CLI command and returns its combined output and exit code. */ +export const runDocker = Effect.fn("DockerTest.runDocker")((args: ReadonlyArray<string>) => Effect.scoped( Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -47,7 +49,7 @@ export const makeDockerDatabaseRoot = Effect.fn("DockerTest.makeDatabaseRoot")( Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""), ), ); - const volume = `supabase-db-${stateDigest.slice(0, 32)}`; + const volume = volumeNameFor(stateDigest); yield* Effect.addFinalizer(() => Effect.gen(function* () { const inspected = yield* runDocker([ diff --git a/packages/stack/tests/docker-relay.ts b/packages/stack/tests/docker-relay.ts index 975de1ae79..003922d0a5 100644 --- a/packages/stack/tests/docker-relay.ts +++ b/packages/stack/tests/docker-relay.ts @@ -21,7 +21,7 @@ export const makeDockerTcpRelay = Effect.fn("DockerRelay.makeTcp")( const listener = yield* bindTcp("0.0.0.0", 0); if (!Predicate.isTagged(listener.address, "TcpAddress")) return yield* Effect.die("Expected TCP relay listener"); - yield* serveTcp(listener, address(endpoint)).pipe(Effect.forkScoped); + yield* serveTcp(listener, address(endpoint), "docker-relay").pipe(Effect.forkScoped); return { host: "host.docker.internal", port: listener.address.port }; }), ); diff --git a/packages/stack/tests/failing-owner-fixture.ts b/packages/stack/tests/failing-owner-fixture.ts new file mode 100644 index 0000000000..86e800a580 --- /dev/null +++ b/packages/stack/tests/failing-owner-fixture.ts @@ -0,0 +1,24 @@ +import { Effect, Schema } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the fixture reports on the inherited readiness descriptor. +import { closeSync, rmSync, writeSync } from "node:fs"; + +const [stateRoot, , stackId] = process.argv.slice(2); +if (stateRoot === undefined || stackId === undefined) throw new Error("Fixture arguments missing"); + +/** Logs a diagnostic, deletes its own log as a failed session start does, then reports failure. */ +const program = Effect.gen(function* () { + process.stderr.write("failing-owner-diagnostic\n"); + rmSync(`${stateRoot}/${stackId}/owner.log`, { force: true }); + const line = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ + type: "error", + message: "owner startup failed", + }); + try { + writeSync(3, `${line}\n`); + } finally { + closeSync(3); + } + process.exitCode = 1; +}); + +await Effect.runPromise(program); diff --git a/packages/stack/tests/host-process-fixture.ts b/packages/stack/tests/host-process-fixture.ts index c35995a6e2..f0210334c5 100644 --- a/packages/stack/tests/host-process-fixture.ts +++ b/packages/stack/tests/host-process-fixture.ts @@ -1,28 +1,25 @@ import { NodeHttpClient, NodeServices, NodeStream } from "@effect/platform-node"; -import { Context, Data, Deferred, Effect, FileSystem, Layer, Path, Schema, Stream } from "effect"; +import { + Context, + Data, + DateTime, + Deferred, + Effect, + FileSystem, + Layer, + Path, + Schema, + Stream, +} from "effect"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { Rpc, RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- test fixture owns inherited readiness and release descriptors. import { closeSync, createReadStream, writeSync } from "node:fs"; -import { acquireHost, HostEndpoint, launchHost } from "../src/HostProcess.ts"; +import { currentRelease, launchHost, authorizes, type HostEndpoint } from "../src/HostProcess.ts"; +import { bindControl } from "../src/StackHost.ts"; import * as State from "../src/State.ts"; class FixtureError extends Data.TaggedError("FixtureError")<{ readonly message: string }> {} -const FixtureRpc = RpcGroup.make( - Rpc.make("shutdown", { - payload: { destroy: Schema.Boolean }, - error: Schema.Never, - }), -); - -const causeCode = (cause: unknown): string | undefined => { - if (typeof cause !== "object" || cause === null) return undefined; - if ("code" in cause && typeof cause.code === "string") return cause.code; - if ("cause" in cause) return causeCode(cause.cause); - return undefined; -}; - const makeState = (root: string) => Layer.build(State.layer({ root })).pipe( Effect.map((context) => Context.get(context, State.Service)), @@ -33,10 +30,25 @@ const awaitBarrier = NodeStream.fromReadable({ onError: (cause) => new FixtureError({ message: String(cause) }), }).pipe(Stream.take(1), Stream.runDrain); +let reported = false; +const report = (value: unknown) => { + if (reported) return; + reported = true; + try { + writeSync( + 3, + Buffer.from(`${Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(value)}\n`), + ); + } finally { + closeSync(3); + } +}; + const [stateRoot, cacheRoot, stackId, mode = "owner", ownerEntrypoint] = process.argv.slice(2); if (stateRoot === undefined || stackId === undefined) throw new FixtureError({ message: "fixture arguments missing" }); +/** Follows the owner protocol without services; `held` acknowledges shutdown but stays alive. */ const owner = Effect.scoped( Effect.gen(function* () { const state = yield* makeState(stateRoot); @@ -44,96 +56,84 @@ const owner = Effect.scoped( const path = yield* Path.Path; const stack = yield* state.read(stackId); if (stack === undefined) return yield* new FixtureError({ message: "stack is not registered" }); - const host = yield* acquireHost(state, stackId); + if (!(yield* state.lease(stackId))) { + report({ type: "error", message: "lease held", reason: "lease-held" }); + return; + } + yield* state.retractHolder(stackId); if (stack.identity.stackName === "slow-handshake") { const marker = path.join(stateRoot, "slow-handshake.pid"); yield* fs.writeFileString(`${marker}.tmp`, String(process.pid)); yield* fs.rename(`${marker}.tmp`, marker); return yield* Effect.never; } + const control = yield* bindControl(); const shutdown = yield* Deferred.make<void>(); const endpoint: HostEndpoint = { stackId, identity: stack.identity, pid: process.pid, - port: host.port, + port: control.port, + release: yield* currentRelease, }; - const rpc = yield* RpcServer.toHttpEffect(FixtureRpc, { streamBufferSize: 4 }).pipe( - Effect.provide( - Layer.merge( - FixtureRpc.toLayer({ - shutdown: () => Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid), - }), - RpcSerialization.layerNdjson, - ), - ), - ); - const serving = host.server.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.method === "GET" && request.url === "/identity") - return yield* HttpServerResponse.json(endpoint).pipe( - Effect.map(HttpServerResponse.setHeader("connection", "close")), - ); - if (request.method === "POST" && request.url === "/shutdown") { - yield* Deferred.succeed(shutdown, undefined); - return HttpServerResponse.empty({ status: 202 }).pipe( - HttpServerResponse.setHeader("connection", "close"), - ); - } - if (mode === "rpc-held" && request.method === "POST" && request.url.startsWith("/rpc")) - return yield* rpc; - return HttpServerResponse.empty({ status: 404 }); - }), - ); - yield* serving.pipe(Effect.forkScoped); - try { - const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))({ - type: "ready", - endpoint, - }); - writeSync(3, Buffer.from(`${encoded}\n`)); - } finally { - closeSync(3); - } - if (mode === "rpc-held") yield* awaitBarrier; + const secret = `fixture-${process.pid}`; + yield* control.server + .serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (!authorizes(request.headers.authorization, secret)) + return HttpServerResponse.empty({ status: 401 }); + if (request.method === "GET" && request.url === "/identity") + return yield* HttpServerResponse.json(endpoint).pipe( + Effect.map(HttpServerResponse.setHeader("connection", "close")), + ); + if (request.method === "POST" && request.url === "/shutdown") { + yield* Deferred.succeed(shutdown, undefined); + return HttpServerResponse.empty({ status: 204 }).pipe( + HttpServerResponse.setHeader("connection", "close"), + ); + } + return HttpServerResponse.empty({ status: 404 }); + }), + ) + .pipe(Effect.forkScoped); + yield* state.publishHolder(stackId, { + role: "owner", + secret, + port: endpoint.port, + pid: endpoint.pid, + release: endpoint.release, + lifetime: stack.lifetime, + startedAt: DateTime.formatIso(yield* DateTime.now), + }); + report({ type: "ready", endpoint, secret }); + if (mode === "held") yield* awaitBarrier; else yield* Deferred.await(shutdown); + yield* state.retractHolder(stackId); }), ); -const launcher = Effect.gen(function* () { - const state = yield* makeState(stateRoot); - const endpoint = yield* launchHost(state, { - stateRoot, - cacheRoot: cacheRoot ?? stateRoot, - stackId, - entrypoint: ownerEntrypoint ?? new URL(import.meta.url).pathname, - }); - const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(endpoint); - process.stdout.write(`${encoded}\n`); -}); +const launcher = Effect.scoped( + Effect.gen(function* () { + const state = yield* makeState(stateRoot); + const { endpoint } = yield* launchHost(state, { + stateRoot, + cacheRoot: cacheRoot ?? stateRoot, + stackId, + entrypoint: ownerEntrypoint ?? new URL(import.meta.url).pathname, + }); + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(endpoint); + process.stdout.write(`${encoded}\n`); + }), +); -const program = mode === "launcher" ? launcher : owner; try { - await Effect.runPromise( - Effect.scoped(program).pipe( - Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp)), - ), - ); + const layer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); + if (mode === "launcher") await Effect.runPromise(launcher.pipe(Effect.provide(layer))); + else await Effect.runPromise(owner.pipe(Effect.provide(layer))); } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); - const reason = causeCode(cause) === "EADDRINUSE" ? "bind-conflict" : undefined; - const error = `${Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))({ - type: "error", - message, - ...(reason === undefined ? {} : { reason }), - })}\n`; - if (mode === "owner") { - try { - writeSync(3, Buffer.from(error)); - } finally { - closeSync(3); - } - } else process.stderr.write(error); + if (mode === "launcher") process.stderr.write(`${message}\n`); + else report({ type: "error", message }); process.exitCode = 1; } diff --git a/packages/stack/tests/lease-wait-fixture.ts b/packages/stack/tests/lease-wait-fixture.ts new file mode 100644 index 0000000000..f12bf25774 --- /dev/null +++ b/packages/stack/tests/lease-wait-fixture.ts @@ -0,0 +1,34 @@ +import { Data, Effect, Predicate } from "effect"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the waiter reports on stdout synchronously before it blocks on the lock. +import { writeSync } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- the waiter takes the same SQLite lock as the lease. +import { DatabaseSync } from "node:sqlite"; +import { pathToFileURL } from "node:url"; + +class LeaseWaitError extends Data.TaggedError("LeaseWaitError")<{ readonly message: string }> {} + +const [file] = process.argv.slice(2); +if (file === undefined) throw new Error("Lease file missing"); + +/** Opens an existing lease file, reports `waiting`, then blocks until its holder releases it. */ +const program = Effect.try({ + try: () => { + const connection = new DatabaseSync(new URL(`${pathToFileURL(file).href}?mode=rw`)); + writeSync(1, "waiting\n"); + connection.exec("PRAGMA busy_timeout = 120000"); + try { + connection.exec("BEGIN IMMEDIATE"); + } catch (cause) { + // A lock file its releasing holder unlinked: IOERR_VNODE on macOS, IOERR_FSTAT on Linux. + if ( + !(Predicate.hasProperty(cause, "errcode") && [6922, 1802].includes(Number(cause.errcode))) + ) + throw cause; + } + connection.close(); + writeSync(1, "free\n"); + }, + catch: (cause) => new LeaseWaitError({ message: String(cause) }), +}); + +await Effect.runPromise(program); diff --git a/packages/stack/tests/lingering-owner-fixture.ts b/packages/stack/tests/lingering-owner-fixture.ts new file mode 100644 index 0000000000..d2175732fb --- /dev/null +++ b/packages/stack/tests/lingering-owner-fixture.ts @@ -0,0 +1,6 @@ +import { runHostProcess } from "../src/internal/host-process.ts"; + +// oxlint-disable-next-line effecttsgo/global-timers -- a raw open handle stands in for a leaked timer or socket. +setInterval(() => undefined, 60_000); + +if (import.meta.main) await runHostProcess(process.argv.slice(2)); diff --git a/packages/stack/tests/logs.ts b/packages/stack/tests/logs.ts new file mode 100644 index 0000000000..a6bc76ea59 --- /dev/null +++ b/packages/stack/tests/logs.ts @@ -0,0 +1,10 @@ +import { Logger, type LogLevel } from "effect"; + +/** Captures rendered log lines at the given levels into `lines` for assertions in tests. */ +export const captureLogs = (levels: ReadonlyArray<LogLevel.LogLevel>) => (lines: Array<string>) => + Logger.layer([ + Logger.make(({ logLevel, message }) => { + if (levels.some((level) => level === logLevel)) + lines.push((Array.isArray(message) ? message : [message]).map(String).join(" ")); + }), + ]); diff --git a/packages/stack/tests/owner-rpc.ts b/packages/stack/tests/owner-rpc.ts new file mode 100644 index 0000000000..fd8d254423 --- /dev/null +++ b/packages/stack/tests/owner-rpc.ts @@ -0,0 +1,25 @@ +import { Context, Effect, Layer } from "effect"; +import { RpcTest } from "effect/unstable/rpc"; +import * as Owner from "../src/Owner.ts"; +import { OwnerRpc } from "../src/Rpc.ts"; +import { Service as StateService } from "../src/State.ts"; +import type { Interface as StateInterface, SavedStack } from "../src/State.ts"; + +/** Builds an in-process owner and an RPC client bound to its handlers. */ +export const ownerFor = (options: { + readonly saved: SavedStack; + readonly state: StateInterface; + readonly root: string; + readonly cacheRoot: string; +}) => + Effect.gen(function* () { + const { state, ...layerOptions } = options; + const context = yield* Layer.build( + Owner.layer(layerOptions).pipe(Layer.provide(Layer.succeed(StateService, state))), + ); + const owner = Context.get(context, Owner.Service); + const rpc = yield* RpcTest.makeClient(OwnerRpc).pipe( + Effect.provide(OwnerRpc.toLayer(owner.handlers)), + ); + return { rpc, namespace: owner.namespace, getStackCredentials: owner.getStackCredentials }; + }); diff --git a/packages/stack/tests/owner.ts b/packages/stack/tests/owner.ts index a295b375ab..1678d75ab4 100644 --- a/packages/stack/tests/owner.ts +++ b/packages/stack/tests/owner.ts @@ -1,7 +1,66 @@ import { expect } from "@effect/vitest"; -import { Effect, Predicate } from "effect"; +import { Deferred, Effect, Fiber, Option, Predicate, Stream } from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import { fileURLToPath } from "node:url"; import { discover, type StackLocations } from "../src/effect.ts"; -import { HostProcessError } from "../src/HostProcess.ts"; +import { HostProcessError, shutdownHost, type HostAccess } from "../src/HostProcess.ts"; + +/** Requests shutdown through the owner's stable endpoint, failing with the owner's refusal. */ +export const shutdownOwner = Effect.fn("Test.shutdownOwner")(function* ( + access: HostAccess, + destroy: boolean, +) { + const refusal = yield* shutdownHost(access, destroy); + if (Option.isSome(refusal)) return yield* Effect.fail(refusal.value); +}); + +const leaseWaiter = fileURLToPath(new URL("./lease-wait-fixture.ts", import.meta.url)); + +/** + * Starts waiting on a stack's lease file and returns an effect that completes once its current + * holder releases it; call it before triggering the release. + */ +export const watchLeaseRelease = Effect.fn("Test.watchLeaseRelease")(function* ( + stateRoot: string, + id: string, +) { + const waiter = yield* ChildProcess.make( + process.execPath, + [leaseWaiter, `${stateRoot}/${id}/owner.lock`], + { stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + const waiting = yield* Deferred.make<void>(); + const stderr = yield* Stream.decodeText(waiter.stderr).pipe(Stream.mkString, Effect.forkScoped); + const lines = yield* Stream.decodeText(waiter.stdout).pipe( + Stream.splitLines, + Stream.tap((line) => (line === "waiting" ? Deferred.succeed(waiting, undefined) : Effect.void)), + Stream.runCollect, + Effect.forkScoped, + ); + const fail = (message: string) => + Fiber.join(stderr).pipe( + Effect.flatMap((diagnostics) => + Effect.fail( + new HostProcessError({ operation: "test-lease", message: `${message}: ${diagnostics}` }), + ), + ), + ); + const output = Fiber.join(lines).pipe(Effect.map((collected) => Array.from(collected))); + yield* Deferred.await(waiting).pipe( + Effect.raceFirst(output.pipe(Effect.andThen(fail("Lease waiter did not open the lease")))), + ); + return yield* Effect.succeed( + output.pipe( + Effect.flatMap((collected) => + collected.includes("free") ? Effect.void : fail("Lease waiter exited before the release"), + ), + Effect.timeoutOrElse({ + duration: "2 minutes", + orElse: () => fail("The lease was not released"), + }), + ), + ); +}); export const captureOwnerPid = Effect.fn("Test.captureOwnerPid")(function* ( locations: StackLocations, diff --git a/packages/stack/tests/release-owner-fixture.ts b/packages/stack/tests/release-owner-fixture.ts new file mode 100644 index 0000000000..065d953cbd --- /dev/null +++ b/packages/stack/tests/release-owner-fixture.ts @@ -0,0 +1,6 @@ +import { runHostProcess } from "../src/internal/host-process.ts"; + +/** The release this owner reports, which no client build matches. */ +export const foreignRelease = "0.0.0-foreign"; + +if (import.meta.main) await runHostProcess(process.argv.slice(2), { release: foreignRelease }); diff --git a/packages/stack/tests/serve-main-bundler.ts b/packages/stack/tests/serve-main-bundler.ts deleted file mode 100644 index 01f6bc3394..0000000000 --- a/packages/stack/tests/serve-main-bundler.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { build, stop } from "esbuild"; -import { Data, Effect } from "effect"; - -class ServeMainBundleError extends Data.TaggedError("ServeMainBundleError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -const serveMainEntrypoint = fileURLToPath( - new URL("../src/functions/serve.main.ts", import.meta.url), -); - -export const bundleServeMainTemplate = Effect.gen(function* () { - const result = yield* Effect.tryPromise({ - try: () => - build({ - entryPoints: [serveMainEntrypoint], - bundle: true, - format: "esm", - platform: "browser", - minify: true, - write: false, - legalComments: "none", - logLevel: "silent", - }), - catch: (cause) => - new ServeMainBundleError({ message: "Unable to bundle functions bootstrap", cause }), - }); - const output = result.outputFiles[0]?.text; - if (output === undefined) - return yield* new ServeMainBundleError({ - message: "esbuild produced no functions bootstrap output", - }); - return output; -}).pipe((buildProgram) => - Effect.uninterruptibleMask((restore) => - Effect.flatMap(Effect.exit(restore(buildProgram)), (result) => - Effect.tryPromise({ - try: () => stop(), - catch: (cause) => new ServeMainBundleError({ message: "Unable to stop esbuild", cause }), - }).pipe(Effect.andThen(result)), - ), - ), -); diff --git a/packages/stack/tests/session-client-fixture.ts b/packages/stack/tests/session-client-fixture.ts new file mode 100644 index 0000000000..aa7b2020c8 --- /dev/null +++ b/packages/stack/tests/session-client-fixture.ts @@ -0,0 +1,35 @@ +import { NodeHttpClient, NodeServices } from "@effect/platform-node"; +import { Effect, Layer, Schema } from "effect"; +import { create } from "../src/effect.ts"; + +const [root, cacheRoot] = process.argv.slice(2); +if (root === undefined || cacheRoot === undefined) throw new Error("Session fixture roots missing"); + +/** Creates a session stack with a running native service, reports it, and waits to be killed. */ +const program = Effect.scoped( + Effect.gen(function* () { + const stack = yield* create({ + projectRoot: root, + stateRoot: `${root}/state`, + cacheRoot, + runtime: "native", + lifetime: "session", + }); + const mail = yield* stack.services.create({ + service: "mail", + config: {}, + endpoints: { http: { port: "auto" } }, + }); + yield* mail.start; + yield* mail.ready; + const ready = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Struct({ stackId: Schema.String })), + )({ stackId: stack.id }); + process.stdout.write(`${ready}\n`); + return yield* Effect.never; + }), +); + +await Effect.runPromise( + program.pipe(Effect.provide(Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp))), +); diff --git a/packages/stack/tests/state-lock-fixture.ts b/packages/stack/tests/state-lock-fixture.ts index deed86a674..d78b5d0e24 100644 --- a/packages/stack/tests/state-lock-fixture.ts +++ b/packages/stack/tests/state-lock-fixture.ts @@ -28,7 +28,7 @@ const program = Effect.scoped( return yield* Effect.die("Missing writer state"); yield* state.save({ ...saved, - instances: [...saved.instances, { id, creation: {} }], + instances: [...saved.instances, { id, creation: { service: "mail", config: {} } }], }); return; } diff --git a/packages/stack/tests/test-stack-client.ts b/packages/stack/tests/test-stack-client.ts new file mode 100644 index 0000000000..01e76a5b46 --- /dev/null +++ b/packages/stack/tests/test-stack-client.ts @@ -0,0 +1,58 @@ +/* oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch -- This fixture exercises the Promise testing API as a non-Effect consumer does. */ +import { discover, postgres, type Stack } from "../src/index.ts"; +import { createTestStack } from "../src/testing.ts"; + +const check = (condition: boolean, message: string) => { + if (!condition) throw new Error(message); +}; + +const sql = async (stack: Stack, databaseUrl: string, command: string) => { + const errors: Array<string> = []; + const result = await stack.commands.run(postgres.psql({ major: 17 }), { + args: ["--dbname", databaseUrl, "--set", "ON_ERROR_STOP=1", "--command", command], + stdout: () => {}, + stderr: (bytes) => { + errors.push(new TextDecoder().decode(bytes)); + }, + }); + check(result.exitCode === 0, `psql failed: ${errors.join("")}`); +}; + +const readRows = async (restUrl: string) => { + const response = await fetch(`${restUrl}/checkpoint_rows?select=value&order=value`); + const body = await response.text(); + check(response.status === 200, `REST read failed with ${response.status}: ${body}`); + return JSON.stringify(JSON.parse(body)); +}; + +const stateRoot = process.argv[2]; +if (stateRoot === undefined) throw new Error("Missing fixture state root"); + +let disposed: { readonly stackId: string; readonly projectRoot: string } | undefined; +{ + await using test = await createTestStack({ services: ["database", "rest"], stateRoot }); + const registered = await discover({ stateRoot }); + check( + registered.some((entry) => entry.definition.id === test.stack.id), + "Test stack is not registered under the fixture state root", + ); + disposed = { stackId: test.stack.id, projectRoot: test.projectRoot }; + const { databaseUrl } = await test.services.database.credentials({ from: "runtime" }); + const { url: restUrl } = await test.services.rest.credentials(); + if (databaseUrl === undefined || restUrl === undefined) throw new Error("Endpoints missing"); + await sql( + test.stack, + databaseUrl, + "CREATE TABLE public.checkpoint_rows (value text NOT NULL); GRANT SELECT ON public.checkpoint_rows TO anon; INSERT INTO public.checkpoint_rows VALUES ('seeded');", + ); + await test.checkpoint("seeded"); + await sql(test.stack, databaseUrl, "INSERT INTO public.checkpoint_rows VALUES ('scratch');"); + const beforeReset = await readRows(restUrl); + check(beforeReset === '[{"value":"scratch"},{"value":"seeded"}]', `Before reset: ${beforeReset}`); + + await test.reset("seeded"); + + const afterReset = await readRows(restUrl); + check(afterReset === '[{"value":"seeded"}]', `After reset: ${afterReset}`); +} +process.stdout.write(`${JSON.stringify(disposed)}\n`); diff --git a/packages/stack/tests/whole-stack/fixture.ts b/packages/stack/tests/whole-stack/fixture.ts index ce891afb98..98e048cf87 100644 --- a/packages/stack/tests/whole-stack/fixture.ts +++ b/packages/stack/tests/whole-stack/fixture.ts @@ -7,14 +7,14 @@ import { FileSystem, Layer, Option, + Path, Redacted, Ref, Stream, } from "effect"; -import { postgres } from "../../src/Tools.ts"; +import { postgres } from "../../src/Commands.ts"; import { homedir, tmpdir } from "node:os"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { bundleServeMainTemplate } from "../serve-main-bundler.ts"; import { create, type Stack } from "../../src/effect.ts"; import type { Observation } from "../../src/Rpc.ts"; import { vectorAnalyticsConfig } from "./analytics.ts"; @@ -87,6 +87,29 @@ const watchServiceLogs = Effect.fn("WholeStack.watchServiceLogs")( const endpoint = (port: "auto") => ({ port }); +/** Bounds diagnostic log text to its last lines, so a runaway owner log stays readable. */ +const tailLines = (content: string, limit: number): string => + content.trimEnd().split("\n").slice(-limit).join("\n"); + +/** Reads only the end of the owner log, so a runaway log doesn't slow down diagnostics. */ +const ownerLogTail = Effect.fn("WholeStack.ownerLogTail")( + (fs: FileSystem.FileSystem, path: Path.Path, stateRoot: string, stackId: string) => + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(path.join(stateRoot, stackId, "owner.log")); + const size = (yield* file.stat).size; + const length = size < 4096n ? size : 4096n; + yield* file.seek(size - length, "start"); + const bytes = yield* file.readAlloc(length); + const content = Option.match(bytes, { + onNone: () => "", + onSome: (buffer) => new TextDecoder().decode(buffer), + }); + return tailLines(content, 40); + }), + ).pipe(Effect.orElseSucceed(() => "")), +); + export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -103,7 +126,6 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => `${functionsRoot}/hello/index.ts`, "Deno.serve(async (request) => { const authorization = request.headers.get('authorization') ?? ''; const input = await request.json(); const response = await fetch(`${Deno.env.get('SUPABASE_URL')}/rest/v1/whole_stack_items?id=eq.${input.id}`, { headers: { authorization, apikey: authorization.replace('Bearer ', '') } }); return new Response(await response.text(), { status: response.status, headers: { 'content-type': 'application/json' } }); });", ); - const bootstrap = yield* bundleServeMainTemplate; const crypto = yield* Crypto.Crypto; const secret = `whole-stack-${yield* crypto.randomUUIDv4}-secret`; const locations = { @@ -141,23 +163,22 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => }, { service: "rest", - config: { databaseUrl: "postgresql://placeholder", jwtSecret: secret }, + config: { jwtSecret: secret }, endpoints: { http: endpoint("auto") }, }, { service: "auth", - config: { databaseUrl: "postgresql://placeholder", jwtSecret: secret }, + config: { jwtSecret: secret }, endpoints: { http: endpoint("auto") }, }, { service: "realtime", - config: { databaseUrl: "postgresql://placeholder", jwtSecret: secret }, + config: { jwtSecret: secret }, endpoints: { http: endpoint("auto"), rpc: endpoint("auto") }, }, { service: "storage", config: { - databaseUrl: "postgresql://placeholder", filePath: storageRoot, jwtSecret: secret, }, @@ -170,13 +191,13 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => }, { service: "functions", - config: { functionsRoot, bootstrap, verifyJwt: true, jwtSecret: secret }, + config: { functionsRoot, verifyJwt: true, jwtSecret: secret }, endpoints: { http: endpoint("auto") }, }, { service: "studio", config: { jwtSecret: secret }, endpoints: { http: endpoint("auto") } }, { service: "pgmeta", - config: { databaseUrl: "postgresql://placeholder" }, + config: {}, endpoints: { http: endpoint("auto") }, }, { @@ -186,13 +207,12 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => }, { service: "analytics", - config: { databaseUrl: "postgresql://placeholder", backend: "postgres", apiKey: secret }, + config: { backend: "postgres", apiKey: secret }, endpoints: { http: endpoint("auto") }, }, { service: "vector", config: { - analyticsUrl: "http://placeholder", apiKey: secret, configPath: vectorConfigPath, }, @@ -201,7 +221,6 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => { service: "pooler", config: { - databaseUrl: "postgresql://placeholder", jwtSecret: secret, tenant: "whole", poolMode: "transaction", @@ -209,7 +228,7 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => endpoints: { http: endpoint("auto"), sql: endpoint("auto") }, }, ], - { identity: { gotrueJwtKeys: "[]", publicSigningKeys: "[]" } }, + { keys: { gotrueJwtKeys: "[]", publicSigningKeys: "[]" } }, ); const logTails = yield* Ref.make<ReadonlyArray<readonly [string, string]>>([]); yield* watchServiceLogs(created, logTails); @@ -232,8 +251,10 @@ export const wholeStack = Effect.fn("WholeStack.fixture")((runtime: Runtime) => ), ); const tails = yield* Ref.get(logTails); + const path = yield* Path.Path; + const ownerLog = yield* ownerLogTail(fs, path, locations.stateRoot, stack.id); yield* Effect.logError( - `Whole-stack failure diagnostics: cause=${Cause.pretty(exit.cause)} statuses=${statuses.join(",")} logs=${tails.map(([name, value]) => `${name}: ${value}`).join("\n")}`, + `Whole-stack failure diagnostics: cause=${Cause.pretty(exit.cause)} statuses=${statuses.join(",")} logs=${tails.map(([name, value]) => `${name}: ${value}`).join("\n")} owner log=${ownerLog}`, ); }).pipe(Effect.ignoreCause) : Effect.void, @@ -338,7 +359,7 @@ export const sql = Effect.fn("WholeStack.sql")((fixture: WholeStack, statement: if (databaseUrl === undefined) return yield* Effect.die("Database URL missing"); const output: Array<Uint8Array> = []; const errors: Array<Uint8Array> = []; - const result = yield* fixture.stack.tools.run(postgres.psql({ major: 17 }), { + const result = yield* fixture.stack.commands.run(postgres.psql({ major: 17 }), { args: ["--set", "ON_ERROR_STOP=1", "--dbname", databaseUrl, "-At"], stdin: Stream.make(new TextEncoder().encode(`${statement}\n`)), stdout: (bytes) => Effect.sync(() => output.push(bytes)), @@ -366,4 +387,7 @@ export const waitForLifecycle = Effect.fn("WholeStack.waitForLifecycle")( ), ); -export const servicesLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); +const servicesLayer = Layer.merge(NodeServices.layer, NodeHttpClient.layerNodeHttp); + +export const run = <A, E, R>(effect: Effect.Effect<A, E, R>) => + Effect.scoped(effect).pipe(Effect.provide(servicesLayer)); diff --git a/packages/stack/tests/whole-stack/idle.ts b/packages/stack/tests/whole-stack/idle.ts index b781328220..67e3f46289 100644 --- a/packages/stack/tests/whole-stack/idle.ts +++ b/packages/stack/tests/whole-stack/idle.ts @@ -19,6 +19,9 @@ class IdleProbeError extends Schema.TaggedError<IdleProbeError>()("IdleProbeErro cause: Schema.optionalKey(Schema.Unknown), }) {} +// At least 5s, so a service can't idle out between back-to-back requests on a slow runner. +const idleMillis = 5_000; + const signServiceToken = Effect.fn("WholeStack.signServiceToken")((secret: string) => Effect.tryPromise({ try: () => @@ -48,7 +51,7 @@ export const idleWake = (runtime: Runtime) => member.id === database.id ? { id: member.id, activation: "eager" as const } : timedServices.has(member.id) - ? { id: member.id, activation: "lazy" as const, idleMillis: 15_000 } + ? { id: member.id, activation: "lazy" as const, idleMillis } : { id: member.id, activation: "lazy" as const }, ), }); diff --git a/packages/stack/tests/whole-stack/scenarios.ts b/packages/stack/tests/whole-stack/scenarios.ts index 07e9bfd92b..5edcc136fa 100644 --- a/packages/stack/tests/whole-stack/scenarios.ts +++ b/packages/stack/tests/whole-stack/scenarios.ts @@ -56,7 +56,7 @@ const assertDefaultPolicy = (fixture: WholeStack) => expect(member.idleMillis).toBeUndefined(); } else { expect(member.activation).toBe("lazy"); - expect(member.idleMillis).toBe(60_000); + expect(member.idleMillis).toBe(name === "studio" ? 300_000 : 60_000); } } }); diff --git a/packages/stack/vitest.config.ts b/packages/stack/vitest.config.ts index 638d2ef600..88e8c7d186 100644 --- a/packages/stack/vitest.config.ts +++ b/packages/stack/vitest.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ hookTimeout: 120_000, testTimeout: 30_000, // Integration workers start real service processes and containers. - maxWorkers: 2, + maxWorkers: 4, sequence: { groupOrder: 1 }, }, }, diff --git a/patches/@effect__platform-node-shared@4.0.0-rc.112.patch b/patches/@effect__platform-node-shared@4.0.0-rc.112.patch new file mode 100644 index 0000000000..9ad2c171a9 --- /dev/null +++ b/patches/@effect__platform-node-shared@4.0.0-rc.112.patch @@ -0,0 +1,15 @@ +diff --git a/dist/NodeChildProcessSpawner.js b/dist/NodeChildProcessSpawner.js +index aa8abbe375f40350f9a5a12f36143d5d85802485..5a86ce881e6a5aa1358059dc0f10ec0934ef164d 100644 +--- a/dist/NodeChildProcessSpawner.js ++++ b/dist/NodeChildProcessSpawner.js +@@ -196,6 +196,10 @@ const make = /*#__PURE__*/Effect.gen(function* () { + // sink that will attached to the process handle + let sink = Sink.drain; + if (Predicate.isNotNull(childProcess.stdin)) { ++ // A child that exits before reading its input fails the final flush of `end()` with ++ // EPIPE after the sink's own listener is gone; without a listener Node raises it as an ++ // uncaught exception. The write loop still reports write failures through `onError`. ++ childProcess.stdin.on("error", () => {}); + sink = NodeSink.fromWritable({ + evaluate: () => childProcess.stdin, + onError: error => toPlatformError("fromWritable(stdin)", toError(error), command), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f1bcbba39..8d31bdf13a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,153 +7,153 @@ importers: configDependencies: {} packageManagerDependencies: pnpm: - specifier: 12.4.0 - version: 12.4.0 + specifier: 12.8.1 + version: 12.8.1 packages: - '@pnpm/exe.android-arm64@12.4.0': - resolution: {integrity: sha512-sAwslzCw74OpqK2P9l39cgdrRWHSqf5wEjB58JEzeVX6wdLvaHyg9i/GeRK5Ou6PZNA3AkD4yLO3c/y7UqOf8w==} + '@pnpm/exe.android-arm64@12.8.1': + resolution: {integrity: sha512-siDxcidfz5eM6L2tJfik+i2bbUEw8UC7MZ3Yl2HYdRmqbdhHGAOQACtOOu6QVW4Jul4/w5wVKm+wM8ACtgh1Pg==} cpu: [arm64] os: [android] - '@pnpm/exe.android-x64@12.4.0': - resolution: {integrity: sha512-Ps3Gz0OrqYjuRfhzeNqcvIow6QmNrU3BSzMxJu5rUCSl7inVUUFX2gZbNL9naQsNLoorKCdxUf4N+WI9Mr1WBg==} + '@pnpm/exe.android-x64@12.8.1': + resolution: {integrity: sha512-0F/uqPRc3aN4vLHsa0f+6fTtmsigFQ9/yxEU43fionF//pNHEd5e97b3w/nKIgqYMCUQCJphmBRMA9Q8/wlo9Q==} cpu: [x64] os: [android] - '@pnpm/exe.darwin-arm64@12.4.0': - resolution: {integrity: sha512-75EYiF8GuiTsnvP4cbZsviMBjohXUYtg2zjD4gdfhqxlU1y9Nj+KdEsbH4jfgB/FgyJSYPB2rqfmvvgLnRlVug==} + '@pnpm/exe.darwin-arm64@12.8.1': + resolution: {integrity: sha512-/rwavvMQJsl2RIxOHBnDF+4Gk+fhQFAY4M3PQZoKskJOEnaJcygG9p1xby6wcQcbS4d9dIubv09CQhufTgfmpw==} cpu: [arm64] os: [darwin] - '@pnpm/exe.darwin-x64@12.4.0': - resolution: {integrity: sha512-b74TzBg8lxl0lqZImGOXpRbAGcqVKX+UMckl3wG+KH52yYHI86VBJP86HbJX30HJ/EFeC6Tgbz2E4b5hhKRvdA==} + '@pnpm/exe.darwin-x64@12.8.1': + resolution: {integrity: sha512-htYt2gN7zqJKLhC99Ft6EUiO7myjZAZXfmGCTJUL1v+o7vE9hZdE1+fAkk4Oj1ZHNRti6b2woOxEkRkAusi8OQ==} cpu: [x64] os: [darwin] - '@pnpm/exe.freebsd-x64@12.4.0': - resolution: {integrity: sha512-A45d6axdQIFNyNTYZAC64wh5+6LJ6dNKhNAoNMNi/EC5y9CRfv0GL3ZYDBqpkmsN9KMAkVsFWizq/Ng6xtjnhw==} + '@pnpm/exe.freebsd-x64@12.8.1': + resolution: {integrity: sha512-PB5BULNgytxvExoOXUUeseS/DcpPENs+cg3iA6xHHeaLT4ctEGKSGXEbuj7USeqvo8JBpAx039hivnbzGqDwnA==} cpu: [x64] os: [freebsd] - '@pnpm/exe.linux-arm64-musl@12.4.0': - resolution: {integrity: sha512-yS6DNel7twfEUoW1yka1hpQrnhRe/s1JZ1MThVfgrmNQrNaPyHxQvAihm/GtL2CM6OeMV6z5532H/W/yDjQp5g==} + '@pnpm/exe.linux-arm64-musl@12.8.1': + resolution: {integrity: sha512-kKeyC/ArjrgdciQRBrOL0j9HtOI4gDRgoyP+eJ1CQKyadpKlf/DhDDyspXe07R3B9KagTVLUF2nlt6Dr5quFIg==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/exe.linux-arm64@12.4.0': - resolution: {integrity: sha512-6npQUwq3D/WXbTgR4evApEKQ9ITznZ6Iz/dptcjBzA7+wSLTaYkK98Od2wsHCoPmEFb9tGtM+cvG82+wy0o9uA==} + '@pnpm/exe.linux-arm64@12.8.1': + resolution: {integrity: sha512-q4s9a9X2O3N9aeUFooW24orNrxvu20+jyVUFR5RanPCqOKKPBrgs2iywMkBBx1aXkkzdWT54/mfz8Be8sJlWEw==} cpu: [arm64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-ppc64@12.4.0': - resolution: {integrity: sha512-7shJ4WytyvBEdjrbIDqx2gDAH7sr0HBDooTmnNQ7DlzcLeeGi7Yqir7gJjOTm6s9S1cVnT56IwmqnnwQMP1HTQ==} + '@pnpm/exe.linux-ppc64@12.8.1': + resolution: {integrity: sha512-F7XSjKJthwCh/L6abZiMpAfo2Cm61SZIT3e4dfgiIjESndlWaXCnfLKngSgZ/SEMsdmHT6dud3+kWIB3tw0/xQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-riscv64@12.4.0': - resolution: {integrity: sha512-q03EGUFoe/oOU/67E3sEAePr3qjFu8xrsX+WOXTodcFXfO5FondC6TPs7BSwhTiV27j3jeDP56qhJP05pXn+vw==} + '@pnpm/exe.linux-riscv64@12.8.1': + resolution: {integrity: sha512-z1ecpIK/ZY08Unk69KWWE9867oaKUytGUjrHnBoGFAAhsK+q+0cfvBH+f5fWobJ7FX97leMlLO+gkJolddM45Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@pnpm/exe.linux-s390x@12.4.0': - resolution: {integrity: sha512-GZ5YellCtNnaLe9zVj9l3avlw9CbSzExUFDh7v+tVbLfOOfkkRLpx2lsw1ytJ/nFWvxF2TO6M6Q9JDT7q8svRg==} + '@pnpm/exe.linux-s390x@12.8.1': + resolution: {integrity: sha512-KU6tnrBfu4uKXTq6N5qJwSm8FSrs9Wc8b5AUEsHK9bRXwNdsZ1/lRxG1++X29SKRW9OSNuDSjxASv5mAvGo1gA==} cpu: [s390x] os: [linux] libc: [glibc] - '@pnpm/exe.linux-x64-musl@12.4.0': - resolution: {integrity: sha512-c8YyjVL39L48tRg9i3iw3d90fN6q84UjxlgWBhplcBOpzCgmglDVkPMtEAVDC+t9iobvYzs2hJnmTqLaA87MVA==} + '@pnpm/exe.linux-x64-musl@12.8.1': + resolution: {integrity: sha512-eUE8BWJkDr2tJb/yrk0yVnszlmNqFarVxUlqiZ9BAkct28Nq/5g2H0tvMDmPA7fAQW3STNj+omJPPRVT+4s5sQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/exe.linux-x64@12.4.0': - resolution: {integrity: sha512-SQVgRkcR4Xyqf8+VNbtY0rtcEnfDq48RhH30HWo2/UfqKEfle2rOMyGZOmN1DbMw4ZzG5mWYoC81O7ZqHFZcPw==} + '@pnpm/exe.linux-x64@12.8.1': + resolution: {integrity: sha512-8bDZ0lZlCdi2rvnFul7EYgcC7zKeWSe76y8JV2oXobaS8p9i9d6PSf6LgLtTM0fI7R9Oh4eLCbveXyNDMmvZ+g==} cpu: [x64] os: [linux] libc: [glibc] - '@pnpm/exe.win32-arm64@12.4.0': - resolution: {integrity: sha512-ZamXPhx0X6APZJAIkcH+K9KAsKcTYwxEgOyTQ/NXE+2UHBT9bRnSQ91sBeY6ELNd58V5cmMAkXSW5xmU+2ry+w==} + '@pnpm/exe.win32-arm64@12.8.1': + resolution: {integrity: sha512-MsT0dlrRxnCRCHCaosNbhWpEiAFnFuMEu3KuDJDU41BXgTwiINxU8goloUSUDejWZUqKaUqNC/qUTihl9nFtqg==} cpu: [arm64] os: [win32] - '@pnpm/exe.win32-x64@12.4.0': - resolution: {integrity: sha512-b8bLaprnpYi/2zN0M3H9RDAHuxCP3fmV5agPPwdp9fIdjNSUkV7V/RC3L4oWwbZvVgYEjjFLx1RuJOdltoKP6g==} + '@pnpm/exe.win32-x64@12.8.1': + resolution: {integrity: sha512-SWtPe0PsbTTk//gJND10tMdOfCYjrkq5+qV49hzVhY7xz2iJ339Rc+xwASlcvbwTfrvH39YNqpKYibD+CRGLwA==} cpu: [x64] os: [win32] - pnpm@12.4.0: - resolution: {integrity: sha512-N1NsJu1Aq0E0tlEeCfayfz67RWh0aPJAbKOAUnmk5coVjBkxNQrZd01qshCNcbPbrrOZQxWSlDdeTQU+jgVoXA==} + pnpm@12.8.1: + resolution: {integrity: sha512-9kupB1B/XOr+BsjTjmBS0BeURFgOwSed3Vv8EctIqoomRLZlmOB+dh2oSHKp/FfV+QK4f6SdAkGY1VhhKqu+RQ==} engines: {node: '>=18.*'} hasBin: true snapshots: - '@pnpm/exe.android-arm64@12.4.0': + '@pnpm/exe.android-arm64@12.8.1': optional: true - '@pnpm/exe.android-x64@12.4.0': + '@pnpm/exe.android-x64@12.8.1': optional: true - '@pnpm/exe.darwin-arm64@12.4.0': + '@pnpm/exe.darwin-arm64@12.8.1': optional: true - '@pnpm/exe.darwin-x64@12.4.0': + '@pnpm/exe.darwin-x64@12.8.1': optional: true - '@pnpm/exe.freebsd-x64@12.4.0': + '@pnpm/exe.freebsd-x64@12.8.1': optional: true - '@pnpm/exe.linux-arm64-musl@12.4.0': + '@pnpm/exe.linux-arm64-musl@12.8.1': optional: true - '@pnpm/exe.linux-arm64@12.4.0': + '@pnpm/exe.linux-arm64@12.8.1': optional: true - '@pnpm/exe.linux-ppc64@12.4.0': + '@pnpm/exe.linux-ppc64@12.8.1': optional: true - '@pnpm/exe.linux-riscv64@12.4.0': + '@pnpm/exe.linux-riscv64@12.8.1': optional: true - '@pnpm/exe.linux-s390x@12.4.0': + '@pnpm/exe.linux-s390x@12.8.1': optional: true - '@pnpm/exe.linux-x64-musl@12.4.0': + '@pnpm/exe.linux-x64-musl@12.8.1': optional: true - '@pnpm/exe.linux-x64@12.4.0': + '@pnpm/exe.linux-x64@12.8.1': optional: true - '@pnpm/exe.win32-arm64@12.4.0': + '@pnpm/exe.win32-arm64@12.8.1': optional: true - '@pnpm/exe.win32-x64@12.4.0': + '@pnpm/exe.win32-x64@12.8.1': optional: true - pnpm@12.4.0: + pnpm@12.8.1: optionalDependencies: - '@pnpm/exe.android-arm64': 12.4.0 - '@pnpm/exe.android-x64': 12.4.0 - '@pnpm/exe.darwin-arm64': 12.4.0 - '@pnpm/exe.darwin-x64': 12.4.0 - '@pnpm/exe.freebsd-x64': 12.4.0 - '@pnpm/exe.linux-arm64': 12.4.0 - '@pnpm/exe.linux-arm64-musl': 12.4.0 - '@pnpm/exe.linux-ppc64': 12.4.0 - '@pnpm/exe.linux-riscv64': 12.4.0 - '@pnpm/exe.linux-s390x': 12.4.0 - '@pnpm/exe.linux-x64': 12.4.0 - '@pnpm/exe.linux-x64-musl': 12.4.0 - '@pnpm/exe.win32-arm64': 12.4.0 - '@pnpm/exe.win32-x64': 12.4.0 + '@pnpm/exe.android-arm64': 12.8.1 + '@pnpm/exe.android-x64': 12.8.1 + '@pnpm/exe.darwin-arm64': 12.8.1 + '@pnpm/exe.darwin-x64': 12.8.1 + '@pnpm/exe.freebsd-x64': 12.8.1 + '@pnpm/exe.linux-arm64': 12.8.1 + '@pnpm/exe.linux-arm64-musl': 12.8.1 + '@pnpm/exe.linux-ppc64': 12.8.1 + '@pnpm/exe.linux-riscv64': 12.8.1 + '@pnpm/exe.linux-s390x': 12.8.1 + '@pnpm/exe.linux-x64': 12.8.1 + '@pnpm/exe.linux-x64-musl': 12.8.1 + '@pnpm/exe.win32-arm64': 12.8.1 + '@pnpm/exe.win32-x64': 12.8.1 --- lockfileVersion: '9.0' @@ -186,8 +186,8 @@ catalogs: specifier: ^1.0.11 version: 1.0.11 '@types/bun': - specifier: ^1.4.0 - version: 1.4.0 + specifier: ^1.4.2 + version: 1.4.2 '@types/ws': specifier: 8.18.1 version: 8.18.1 @@ -231,9 +231,10 @@ catalogs: overrides: '@launchql/protobufjs>@types/node': 24.10.4 -packageExtensionsChecksum: sha256-+q1Reu9SIvF0cNyYHg1TxU5zvG0d67iLruu2/1HlPHo= +packageExtensionsChecksum: sha256-gAA6Mc6Jn3jUpogzVWO9hE5trVRioaB8peQBgDbyG6I= patchedDependencies: + '@effect/platform-node-shared@4.0.0-rc.112': ae37153251ecf7edd0be9de349cc18b4b13c8cf3bd7e9ead20e8e891716af128 '@effect/vitest@4.0.0-rc.112': fbd126f7e68e041231312d90b8c02f705f12b66c2cb2d6fc246f5c12a4ed9787 '@libpg-query/parser@17.6.10': ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3 @@ -255,7 +256,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 husky: specifier: ^9.1.7 version: 9.1.7 @@ -330,26 +331,26 @@ importers: specifier: workspace:* version: link:../../packages/config '@supabase/pg-delta': - specifier: 1.0.0-alpha.52 - version: 1.0.0-alpha.52(@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0))(supports-color@7.2.0) + specifier: 1.0.0-alpha.56 + version: 1.0.0-alpha.56(@supabase/pg-topo@1.0.0-alpha.7(supports-color@7.2.0))(supports-color@7.2.0) '@supabase/pg-topo': - specifier: 1.0.0-alpha.6 - version: 1.0.0-alpha.6(supports-color@7.2.0) - '@supabase/postgrest-typegen': - specifier: 0.2.2 - version: 0.2.2 + specifier: 1.0.0-alpha.7 + version: 1.0.0-alpha.7(supports-color@7.2.0) '@supabase/stack': specifier: workspace:* version: link:../../packages/stack '@supabase/supabase-js': specifier: 'catalog:' version: 2.112.4 + '@supabase/typegen': + specifier: 0.2.1 + version: 0.2.1(oxfmt@0.66.0) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@types/pg': specifier: ^8.23.1 version: 8.23.1 @@ -441,7 +442,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 typescript: specifier: 'catalog:' version: 7.0.2 @@ -505,7 +506,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) @@ -535,7 +536,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) @@ -579,7 +580,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@vitest/coverage-v8': specifier: 'catalog:' version: 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0))(vitest@5.0.0) @@ -622,7 +623,7 @@ importers: version: 1.0.11 '@types/bun': specifier: 'catalog:' - version: 1.4.0 + version: 1.4.2 '@types/ws': specifier: 'catalog:' version: 8.18.1 @@ -2888,18 +2889,18 @@ packages: resolution: {integrity: sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g==} engines: {node: '>=22.0.0'} - '@supabase/pg-delta@1.0.0-alpha.52': - resolution: {integrity: sha512-mp1knQPI3x06O2YaqZqaGQzoI7ZYs7rlSYgdYCkX0ktkEj5VvMb+kT64Oatg3hElr2D4Jslqg8XcgOVlT6gqCQ==} + '@supabase/pg-delta@1.0.0-alpha.56': + resolution: {integrity: sha512-Cerar16vqsLV4dO/IxjDbKBSItB5936JOEE46R/gUjey1hHztkfdcgP0GCoM8i510nxyAT/lUDCdXFraRheizQ==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: - '@supabase/pg-topo': ^1.0.0-alpha.6 + '@supabase/pg-topo': ^1.0.0-alpha.7 peerDependenciesMeta: '@supabase/pg-topo': optional: true - '@supabase/pg-topo@1.0.0-alpha.6': - resolution: {integrity: sha512-bejMubS6l1E3/8AUpJ3a6kG9DTKbCq5oMoDYgfzlh4VEPdX34wd0U32OglzfuSBojlqU1YpDIpJp2m9Qq3IrnQ==} + '@supabase/pg-topo@1.0.0-alpha.7': + resolution: {integrity: sha512-agvnNRKOSs0A02mW95KW+AKaF0YR/uk0GFBoCfQbGjjdgawk/I2O2wlMWQvbryzsuDr0unWbdPdj3Aah5FexLA==} '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} @@ -2908,9 +2909,14 @@ packages: resolution: {integrity: sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug==} engines: {node: '>=22.0.0'} - '@supabase/postgrest-typegen@0.2.2': - resolution: {integrity: sha512-TKh+GgakrkMlLahWtvwa6WWzbRzlK4tBExNfgZbnB0V5yMl51RIOvrgITQOacoJR3S/ZZCNbpvkRgHrhxSWsBg==} - engines: {node: '>=20.0.0'} + '@supabase/postgrest-typegen@0.3.1': + resolution: {integrity: sha512-FxgP+13VCH0ho3LKAhFHA6+vPk3Rbw54eZmGcDbFUcFzhjigSp8HBrgMJHVcNwjRrHpmItL0SvXAeAkOYxO7hQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + oxfmt: 0.66.0 + peerDependenciesMeta: + oxfmt: + optional: true '@supabase/realtime-js@2.112.4': resolution: {integrity: sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==} @@ -2929,6 +2935,15 @@ packages: '@opentelemetry/api': optional: true + '@supabase/typegen@0.2.1': + resolution: {integrity: sha512-7M86EUD/zp9e3arbcBwXAPlUYowKEfhXXx5lgizjAqXzJeN4LZX8jeN8v+EYBWn3c8O1xXpl+HVG08V1MFLflQ==} + engines: {node: '>=22.12.0'} + peerDependencies: + oxfmt: 0.66.0 + peerDependenciesMeta: + oxfmt: + optional: true + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -2968,8 +2983,8 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/bun@1.4.0': - resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==} + '@types/bun@1.4.2': + resolution: {integrity: sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3576,8 +3591,8 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bun-types@1.4.0: - resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==} + bun-types@1.4.2: + resolution: {integrity: sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==} byte-counter@0.1.0: resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} @@ -7110,13 +7125,13 @@ snapshots: '@effect/platform-bun@4.0.0-rc.112(effect@4.0.0-rc.112)': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) + '@effect/platform-node-shared': 4.0.0-rc.112(patch_hash=ae37153251ecf7edd0be9de349cc18b4b13c8cf3bd7e9ead20e8e891716af128)(effect@4.0.0-rc.112) effect: 4.0.0-rc.112 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-rc.112)': + '@effect/platform-node-shared@4.0.0-rc.112(patch_hash=ae37153251ecf7edd0be9de349cc18b4b13c8cf3bd7e9ead20e8e891716af128)(effect@4.0.0-rc.112)': dependencies: '@types/ws': 8.18.1 effect: 4.0.0-rc.112 @@ -7127,7 +7142,7 @@ snapshots: '@effect/platform-node@4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1)': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-rc.112) + '@effect/platform-node-shared': 4.0.0-rc.112(patch_hash=ae37153251ecf7edd0be9de349cc18b4b13c8cf3bd7e9ead20e8e891716af128)(effect@4.0.0-rc.112) effect: 4.0.0-rc.112 mime: 4.1.0 redis: 6.2.1 @@ -8653,7 +8668,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/pg-delta@1.0.0-alpha.52(@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0))(supports-color@7.2.0)': + '@supabase/pg-delta@1.0.0-alpha.56(@supabase/pg-topo@1.0.0-alpha.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@types/debug': 4.1.13 '@types/pg': 8.23.1 @@ -8661,12 +8676,12 @@ snapshots: pg: 8.23.0 pg-connection-string: 2.14.0 optionalDependencies: - '@supabase/pg-topo': 1.0.0-alpha.6(supports-color@7.2.0) + '@supabase/pg-topo': 1.0.0-alpha.7(supports-color@7.2.0) transitivePeerDependencies: - pg-native - supports-color - '@supabase/pg-topo@1.0.0-alpha.6(supports-color@7.2.0)': + '@supabase/pg-topo@1.0.0-alpha.7(supports-color@7.2.0)': dependencies: '@pgsql/traverse': 17.2.6(supports-color@7.2.0) plpgsql-parser: 0.5.16(supports-color@7.2.0) @@ -8679,13 +8694,11 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/postgrest-typegen@0.2.2': + '@supabase/postgrest-typegen@0.3.1(oxfmt@0.66.0)': dependencies: arktype: 2.2.3 + optionalDependencies: oxfmt: 0.66.0 - transitivePeerDependencies: - - svelte - - vite-plus '@supabase/realtime-js@2.112.4': dependencies: @@ -8705,6 +8718,12 @@ snapshots: '@supabase/realtime-js': 2.112.4 '@supabase/storage-js': 2.112.4 + '@supabase/typegen@0.2.1(oxfmt@0.66.0)': + dependencies: + '@supabase/postgrest-typegen': 0.3.1(oxfmt@0.66.0) + optionalDependencies: + oxfmt: 0.66.0 + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -8734,9 +8753,9 @@ snapshots: tslib: 2.8.1 optional: true - '@types/bun@1.4.0': + '@types/bun@1.4.2': dependencies: - bun-types: 1.4.0 + bun-types: 1.4.2 '@types/chai@5.2.3': dependencies: @@ -9320,7 +9339,7 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bun-types@1.4.0: + bun-types@1.4.2: dependencies: '@types/node': 26.4.1 undici-types: 8.3.0 @@ -11567,6 +11586,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.66.0 '@oxfmt/binding-win32-ia32-msvc': 0.66.0 '@oxfmt/binding-win32-x64-msvc': 0.66.0 + optional: true oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -11816,6 +11836,7 @@ snapshots: plpgsql-deparser@0.7.13: dependencies: + '@libpg-query/parser': 17.6.10(patch_hash=ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3) '@pgsql/types': 17.6.2 pgsql-deparser: 17.18.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d0f80497c5..3f28d1bc19 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,7 @@ catalog: "@effect/vitest": "4.0.0-rc.112" "@supabase/supabase-js": ^2.110.7 "@tsconfig/bun": "^1.0.11" - "@types/bun": "^1.4.0" + "@types/bun": "^1.4.2" "@types/ws": "8.18.1" "typescript": "^7.0.2" "@vitest/coverage-v8": "^5.0.0" @@ -55,6 +55,9 @@ packageExtensions: # imports pg and debug without shipping their @types packages. "@types/debug": "^4.1.12" "@types/pg": "^8.23.1" + plpgsql-deparser: + dependencies: + "@libpg-query/parser": "^17.6.3" # vitest lists vite as both a dependency and a peer, so pnpm resolves it as a # peer only. These consumers reach vitest through their own `vitest` peer inside a # peer cycle, which leaves them a vitest variant with no vite at all — fatal @@ -105,11 +108,10 @@ minimumReleaseAgeExclude: - "@effect/platform-node-shared@4.0.0-rc.112" - "@effect/sql-pg@4.0.0-rc.112" - "@effect/vitest@4.0.0-rc.112" - - "@supabase/pg-delta@1.0.0-alpha.52" - - "@supabase/pg-topo@1.0.0-alpha.6" - - "@supabase/postgrest-typegen@0.2.2" - - "@types/bun@1.4.0" - - "bun-types@1.4.0" + - "@supabase/pg-delta@1.0.0-alpha.56" + - "@supabase/pg-topo@1.0.0-alpha.7" + - "@supabase/postgrest-typegen@0.3.1" + - "@supabase/typegen@0.2.1" - "effect@4.0.0-rc.112" - "turbo@2.10.11" @@ -129,6 +131,7 @@ supportedArchitectures: - win32 patchedDependencies: + "@effect/platform-node-shared@4.0.0-rc.112": patches/@effect__platform-node-shared@4.0.0-rc.112.patch "@effect/vitest@4.0.0-rc.112": patches/@effect__vitest@4.0.0-rc.112.patch "@libpg-query/parser@17.6.10": patches/@libpg-query__parser@17.6.10.patch diff --git a/tools/release/local-release.ts b/tools/release/local-release.ts index 6f1db04923..370ba72188 100644 --- a/tools/release/local-release.ts +++ b/tools/release/local-release.ts @@ -16,8 +16,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import { oxfmtStubPlugin } from "../../apps/cli/scripts/bundle-externals.ts"; -import { compileOptions } from "../../apps/cli/scripts/compile-options.ts"; +import { compileOptions, stackReleaseDefine } from "../../apps/cli/scripts/compile-options.ts"; const PORT = 4873; const REGISTRY = `http://localhost:${PORT}`; @@ -188,7 +187,7 @@ async function main() { entrypoints: [entrypoint], compile: { target: platform.bunTarget, outfile: bunBinary }, ...compileOptions, - plugins: [oxfmtStubPlugin], + define: await stackReleaseDefine(), }); for (const log of buildResult.logs) { console.warn(log); diff --git a/turbo.json b/turbo.json index 3487f7723d..70cb4b17c8 100644 --- a/turbo.json +++ b/turbo.json @@ -49,7 +49,7 @@ }, "@supabase/cli-go#build": { "cache": true, - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/mise.toml", "$TURBO_ROOT$/mise.lock"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/mise.lock"], "outputs": ["supabase-go"], "env": ["GO*", "CGO_*", "CC", "CXX"] }, @@ -58,15 +58,16 @@ "dependsOn": ["@supabase/config#build", "@supabase/cli-go#build"], "inputs": [ "$TURBO_DEFAULT$", - "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock", - "$TURBO_ROOT$/packages/api/**" + "$TURBO_ROOT$/packages/api/**", + "$TURBO_ROOT$/packages/stack/package.json", + "$TURBO_ROOT$/packages/stack/src/**" ], "outputs": ["dist/**"] }, "@supabase/config#build": { "cache": true, - "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock"], + "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/mise.lock"], "outputs": ["dist/**"] }, "@supabase/api#generate": { @@ -74,6 +75,10 @@ "outputs": ["src/generated/**", "scripts/openapi-source.json"], "env": ["SUPABASE_API_URL"] }, + "@supabase/stack#generate": { + "cache": false, + "outputs": ["src/functions/generated/**"] + }, "@supabase/docs#generate": { "cache": true, "dependsOn": ["supabase#build", "@supabase/config#build"], @@ -82,7 +87,6 @@ "!content/docs/commands/**", "!public/cli/config.schema.json", "!public/cli/project-config.schema.json", - "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock" ], "outputs": [ @@ -99,7 +103,6 @@ "!content/docs/commands/**", "!public/cli/config.schema.json", "!public/cli/project-config.schema.json", - "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock" ], "outputs": [".next/**"]