From 3db700db24141d960dcd11620e51c79e9c2df941 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 11:37:01 +0000 Subject: [PATCH 1/5] feat(cli): enable the shadow baseline cache by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip SUPABASE_SHADOW_CACHE from an opt-in gate (unset = off) to an opt-out gate (unset = on). An explicitly set value keeps the exact viper ParseBool semantics, so `0`/`false`/empty/garbage — from the shell env or the project dotenv — still disable the cache, and `sync --no-cache` still bypasses it per invocation. - legacyViperEnvBoolWithProjectFallback grows a `whenUnset` option so a key absent from both envs can resolve to a CLI-chosen default while present values keep their established coercion. - The default-gate integration test now asserts the default-ON cold export + warm restore, with a new project-dotenv opt-out test. - SIDE_EFFECTS.md for db diff, db pull, and declarative sync/generate document the new default; test helpers keep pinning `0` so mocked-spawner and e2e suites stay off the cache path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uj7BjiXEnbcWy41ToxCnPs --- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 9 ++--- .../legacy/commands/db/diff/diff.handler.ts | 3 +- .../legacy/commands/db/pull/SIDE_EFFECTS.md | 9 ++--- .../declarative/generate/SIDE_EFFECTS.md | 2 +- .../schema/declarative/sync/SIDE_EFFECTS.md | 8 ++--- .../shadow-cache.integration.test.ts | 35 +++++++++++++++++-- .../shared/db-bootstrap/shadow-cache.ts | 9 +++-- .../src/legacy/shared/legacy-pgdelta.cache.ts | 11 +++--- .../cli/src/shared/legacy/legacy-viper-env.ts | 11 +++++- .../legacy/legacy-viper-env.unit.test.ts | 22 ++++++++++++ apps/cli/tests/helpers/cli.ts | 2 +- apps/cli/tests/helpers/legacy-mocks.ts | 4 +-- 12 files changed, 97 insertions(+), 28 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md index 8b57af8184..8d2aca5ded 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -97,7 +97,7 @@ of this command's own target resolve, ahead of the differ container. | `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_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | | `PGDELTA_DEBUG` | pg-delta debug capture | no | | `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | @@ -229,10 +229,11 @@ transaction metadata. no longer the `db __shadow` seam) plus a native pg-delta catalog export. No hidden Go `db schema declarative __catalog` subprocess runs for this path any more. -### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default OFF) +### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default ON) -Off unless `SUPABASE_SHADOW_CACHE` is set; `false`/`0` keep it off (honored from the ambient env AND the -project's dotenv, e.g. `supabase/.env`), restoring the documented uncached lifecycle. A warm hit +On by default; setting `SUPABASE_SHADOW_CACHE` to anything not viper-true (`false`/`0`/empty/garbage, +honored from the ambient env AND the project's dotenv, e.g. `supabase/.env`) turns it off, +restoring the documented uncached lifecycle. A warm hit skips the platform baseline, so the `Initialising schema...` progress line does not print — progress text reflects the work actually performed. Artifact: `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` (~90MB; `SUPABASE_HOME` diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index f1c7a3bd51..a707377cb1 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -692,7 +692,8 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ctx, }; // `legacyWithShadowDatabase` (`shadow-cache.ts`) owns the interrupt-safe lifecycle and the - // cache seam — a plain create/remove pair when `SUPABASE_SHADOW_CACHE` is unset. The key's + // cache seam — a plain create/remove pair when `SUPABASE_SHADOW_CACHE` is explicitly + // disabled (the cache is on by default). The key's // webhooks policy must mirror what `legacyPrepareShadowSource` selects for this mode // (legacy migrate forces `pg_net` on, next follows config), or the two engines could // restore each other's tars. diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index 3c13990982..8fd38dfa92 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -77,10 +77,11 @@ disables formatting without disabling safe compaction. - `pg_dump` container — the initial-migra pull's native remote-schema dump (`legacyStreamPgDump`, shared with `db dump`). -### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default OFF) +### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default ON) -Off unless `SUPABASE_SHADOW_CACHE` is set; `false`/`0` keep it off (honored from the ambient env AND the -project's dotenv, e.g. `supabase/.env`), restoring the documented uncached lifecycle. A warm hit +On by default; setting `SUPABASE_SHADOW_CACHE` to anything not viper-true (`false`/`0`/empty/garbage, +honored from the ambient env AND the project's dotenv, e.g. `supabase/.env`) turns it off, +restoring the documented uncached lifecycle. A warm hit skips the platform baseline, so the `Initialising schema...` progress line does not print — progress text reflects the work actually performed. Artifact: `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` (~90MB; `SUPABASE_HOME` @@ -121,7 +122,7 @@ baseline, so it is never cached. | `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_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | | `SUPABASE_EXPERIMENTAL` | selects the deprecated structured-dump branch (still delegates to Go, see below) | no | | `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 6d827f6882..81b3e06cb4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -53,7 +53,7 @@ formatting without disabling safe compaction. | `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | | `DB_PASSWORD` | password for `--linked` / `--db-url` | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the legacy opt-out's shadow baseline cache | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache for the legacy opt-out's catalog-miss shadows; opt-in (`1`/`true`) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache for the legacy opt-out's catalog-miss shadows; on by default, opt-out (`0`/`false`) | no | | `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | | `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | | `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 6159e934bf..996164ce14 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -57,7 +57,7 @@ disabling safe compaction. | `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | | `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | +| `SUPABASE_SHADOW_CACHE` | shadow baseline cache; on by default, opt-out (`0`/`false`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | | `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | | `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate | no | | `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | @@ -138,11 +138,11 @@ existing SQL or creates an export manifest. in-process using the same primitives as `db diff`; catalog export, declarative apply, and diff run through the edge-runtime pg-delta scripts. -### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default OFF) +### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default ON) The bundled (pg-delta next) engine provisions both plan shadows through -`legacyAcquireShadowDatabase` (`legacy-pgdelta-next-shadow.layer.ts`): off unless -`SUPABASE_SHADOW_CACHE` is set (ambient env or project dotenv); `--no-cache` +`legacyAcquireShadowDatabase` (`legacy-pgdelta-next-shadow.layer.ts`): on by default, off when +`SUPABASE_SHADOW_CACHE` is set to anything not viper-true (ambient env or project dotenv); `--no-cache` bypasses restore and publish for that invocation. Next allocates an ephemeral host port per shadow; the cache key hashes the cluster recipe (including the effective Webhooks/`pg_net` policy), not the published port, so worktrees and repeated syncs with the same settings share diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts index 250127ecbc..c12fb8c69d 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts @@ -315,7 +315,7 @@ describe("legacyAcquireShadowDatabase", () => { ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); }); - it.live("stays uncached when the env var is unset (default OFF)", () => { + it.live("caches by default when the env var is unset (default ON)", () => { const docker = mockLegacyDockerDaemonCliSpawner(); const cluster = fakeCluster(); const out = mockOutput(); @@ -324,7 +324,38 @@ describe("legacyAcquireShadowDatabase", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const handle = yield* legacyAcquireShadowDatabase(docker.spawner, shadowInput(fs, path)); + const input = shadowInput(fs, path); + // First acquire is a cold cache-enabled provision (no `--rm`: the export must be able + // to stop and restart the container), and its snapshot step publishes the tar. + const cold = yield* coldRun(docker, input); + expect(cold.baselinePresent).toBe(false); + expect(cold.snapshotRequired).toBe(true); + expect(docker.calls("create")[0] ?? []).not.toContain("--rm"); + expect(yield* soleTarName(fs, path)).toHaveLength(1); + + // The next acquire — still with the env var unset — is a warm restore. + const warm = yield* legacyAcquireShadowDatabase(docker.spawner, input); + expect(warm.baselinePresent).toBe(true); + yield* legacyRemoveShadowDatabase(docker.spawner, warm.containerId); + }), + ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); + }); + + it.live("a project dotenv opt-out (SUPABASE_SHADOW_CACHE=0) disables the default", () => { + const docker = mockLegacyDockerDaemonCliSpawner(); + const cluster = fakeCluster(); + const out = mockOutput(); + return withShadowCacheHome( + undefined, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = shadowInput(fs, path); + const input = { + ...base, + setup: { ...base.setup, projectEnvValues: { SUPABASE_SHADOW_CACHE: "0" } }, + }; + const handle = yield* legacyAcquireShadowDatabase(docker.spawner, input); expect(handle.baselinePresent).toBe(false); expect(docker.calls("create")[0] ?? []).toContain("--rm"); yield* handle.snapshotBaseline; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts index eeea2b044c..df5452a80a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts @@ -1,7 +1,8 @@ /** * Shadow baseline cache for `db diff`/`db pull`/catalog resolution. Snapshots the platform * baseline as a PGDATA tar under `${SUPABASE_HOME}/cache/shadow-baseline/` (keep 3, 2-day TTL). - * Off unless `SUPABASE_SHADOW_CACHE` is set (viper bool + project dotenv). A cache miss or + * On by default; an explicitly set `SUPABASE_SHADOW_CACHE` that is not viper-true (`0`/`false`/ + * empty/garbage, from the shell env or the project dotenv) turns it off. A cache miss or * anomaly never fails the run except when the shadow does not come back after a cold export. */ @@ -68,7 +69,7 @@ import { type Spawner = ChildProcessSpawner["Service"]; -/** `SUPABASE_SHADOW_CACHE` — opt-in gate (viper bool; unset is off). */ +/** `SUPABASE_SHADOW_CACHE` — opt-out gate (viper bool when set; unset is ON). */ export const LEGACY_SHADOW_CACHE_ENV = "SUPABASE_SHADOW_CACHE"; /** @@ -885,7 +886,8 @@ const legacyWarmShadow = ( * `legacy-pgdelta-next-shadow.layer.ts` for the scoped `acquireRelease` form next uses so the * container outlives provision (the engine keeps using the URL after this returns). * - * Unset or falsey {@link LEGACY_SHADOW_CACHE_ENV} is the uncached create. Otherwise it + * An explicitly falsey {@link LEGACY_SHADOW_CACHE_ENV} (set, but not viper-true) is the + * uncached create. Otherwise — including when the variable is unset, the default — it * restores this key's snapshot (warm) or creates one and exports the baseline (cold). * * Runs inside `acquireUseRelease`'s uninterruptible `acquire`, same as @@ -915,6 +917,7 @@ export const legacyAcquireShadowDatabase = ( !legacyViperEnvBoolWithProjectFallback( LEGACY_SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}, + { whenUnset: true }, ) ) { return yield* legacyUncachedShadow(spawner, input); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index c9160d15ab..545a243fe3 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -788,11 +788,12 @@ const exportViaShadowCatalog = ( ); // `legacyWithShadowDatabase` (`db-bootstrap/shadow-cache.ts`) rather than a bare // `legacyCreateShadowDatabase`/`legacyRemoveShadowDatabase` pair — see its doc comment: with - // `SUPABASE_SHADOW_CACHE` unset it IS that pair (identical Docker argv, identical labels), and - // with it set a catalog cache miss restores a key-matching PGDATA snapshot into the fresh - // shadow instead of paying the full cold provision — the same swap `db diff`/`db pull`'s own - // call sites make. `shadowCacheOpts` carries `sync --no-cache`'s bypass and the - // caller's effective Webhooks policy — see `LegacyShadowCacheOpts`. + // `SUPABASE_SHADOW_CACHE` explicitly disabled it IS that pair (identical Docker argv, + // identical labels), and by default (cache on) a catalog cache miss restores a key-matching + // PGDATA snapshot into the fresh shadow instead of paying the full cold provision — the same + // swap `db diff`/`db pull`'s own call sites make. `shadowCacheOpts` carries `sync + // --no-cache`'s bypass and the caller's effective Webhooks policy — see + // `LegacyShadowCacheOpts`. const written = yield* legacyWithShadowDatabase( spawner, shadowInput, diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.ts b/apps/cli/src/shared/legacy/legacy-viper-env.ts index 03fa22be15..b52e2f6e45 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.ts @@ -48,12 +48,21 @@ export function legacyViperEnvBool(name: string): boolean { * which cast to `false`) — suppresses the project value entirely; the file value is * consulted only when the variable is absent from the shell env. `??` (not `||`) encodes * exactly that presence check. + * + * `opts.whenUnset` is a CLI-chosen extension over viper (whose zero value is always `false`): + * it resolves a key that is absent from BOTH the shell and the project env, letting an + * opt-out gate (e.g. `SUPABASE_SHADOW_CACHE`) default ON while a *present* value keeps the + * exact `ParseBool` semantics above — so `=0`, `=false`, empty, and garbage all still + * disable. */ export function legacyViperEnvBoolWithProjectFallback( name: string, projectEnv: Record, + opts: { readonly whenUnset?: boolean } = {}, ): boolean { - return legacyViperBool(process.env[name] ?? projectEnv[name]); + const raw = process.env[name] ?? projectEnv[name]; + if (raw === undefined) return opts.whenUnset ?? false; + return legacyViperBool(raw); } /** diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts index c67befd1d8..6fa371d2bf 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts @@ -74,6 +74,28 @@ describe("legacyViperEnvBoolWithProjectFallback", () => { process.env[KEY] = "true"; expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(true); }); + + it("whenUnset: true resolves a key absent from both envs to true (opt-out gate default)", () => { + delete process.env[KEY]; + expect(legacyViperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(true); + }); + + it("whenUnset: true still yields false for any present non-true value", () => { + // A present value keeps the exact ParseBool semantics — `0`, `false`, empty, and garbage + // all disable, whether from the shell or the project dotenv. + process.env[KEY] = "0"; + expect(legacyViperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(false); + process.env[KEY] = ""; + expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }, { whenUnset: true })).toBe( + false, + ); + process.env[KEY] = "banana"; + expect(legacyViperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(false); + delete process.env[KEY]; + expect( + legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" }, { whenUnset: true }), + ).toBe(false); + }); }); describe("legacyViperEnvStringWithProjectFallback", () => { diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index 0b8fbef7de..143bf0372b 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -327,7 +327,7 @@ export function spawnSupabase( SUPABASE_HOME: homeDir, SUPABASE_NO_KEYRING: "1", SUPABASE_TELEMETRY_DISABLED: "1", - // Isolate e2e from a developer/CI soak (`SUPABASE_SHADOW_CACHE=1`). Cache-subject tests opt in via `options.env`. + // Isolate e2e from the default-ON shadow cache. Cache-subject tests opt in via `options.env`. SUPABASE_SHADOW_CACHE: "0", ...options?.env, }; diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 36b84cee04..3f31dd911c 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -764,8 +764,8 @@ export const legacyWithEnv = ( ); /** - * Pins `SUPABASE_SHADOW_CACHE=0` for the calling file so a developer/CI soak (`=1`) - * cannot flip mocked-spawner suites onto the cache path. Call at module scope (or + * 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 * inside the surrounding `describe`). Cache-subject tests opt back in with * {@link legacyWithEnv}. */ From 70596dfd2b30832c9b0edad194ca03423513a250 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 11:40:28 +0000 Subject: [PATCH 2/5] docs(cli): realign SIDE_EFFECTS env tables after cache-default wording Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uj7BjiXEnbcWy41ToxCnPs --- .../declarative/generate/SIDE_EFFECTS.md | 20 +++++++++---------- .../schema/declarative/sync/SIDE_EFFECTS.md | 16 +++++++-------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 81b3e06cb4..7ecde9ae53 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -48,17 +48,17 @@ formatting without disabling safe compaction. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ---------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | -| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the legacy opt-out's shadow baseline cache | no | +| Variable | Purpose | Required? | +| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | +| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | +| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the legacy opt-out's shadow baseline cache | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache for the legacy opt-out's catalog-miss shadows; on by default, opt-out (`0`/`false`) | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | -| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | -| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | +| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 996164ce14..1e57a836ae 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -52,15 +52,15 @@ disabling safe compaction. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | +| Variable | Purpose | Required? | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | 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 PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | -| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate | no | -| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | ## Exit Codes From cb10bb5ac41f8d89764fb5857b6a4ea46393ae82 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 31 Aug 2026 17:29:18 +0200 Subject: [PATCH 3/5] test(cli): pin the declarative seam suite off the default-ON shadow cache The seam integration test is the one mocked-spawner path that still ran legacyWithShadowDatabase without useLegacyShadowCacheDisabled, so the new default would take the cold-export path and mkdir under the real ~/.supabase cache dir. Co-authored-by: Cursor --- apps/cli/src/legacy/commands/db/diff/diff.handler.ts | 7 +++---- .../db/shared/legacy-pgdelta.seam.integration.test.ts | 3 +++ .../shared/db-bootstrap/shadow-cache.integration.test.ts | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index a707377cb1..664bf53381 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -693,10 +693,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }; // `legacyWithShadowDatabase` (`shadow-cache.ts`) owns the interrupt-safe lifecycle and the // cache seam — a plain create/remove pair when `SUPABASE_SHADOW_CACHE` is explicitly - // disabled (the cache is on by default). The key's - // webhooks policy must mirror what `legacyPrepareShadowSource` selects for this mode - // (legacy migrate forces `pg_net` on, next follows config), or the two engines could - // restore each other's tars. + // disabled (the cache is on by default). The key's webhooks policy must mirror what + // `legacyPrepareShadowSource` selects for this mode (legacy migrate forces `pg_net` on, + // next follows config), or the two engines could restore each other's tars. diffResult = yield* legacyWithShadowDatabase( spawner, shadowInput, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts index 3132d010a5..3b9704b4e9 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts @@ -10,6 +10,7 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockLegacyCliSettings, mockLegacyShadowContainerCliSpawner, + useLegacyShadowCacheDisabled, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -113,6 +114,8 @@ const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSslForHost: () => Effect.succeed(false), }); +useLegacyShadowCacheDisabled(); + function setup( workdir: string, opts: { readonly failCreate?: boolean; readonly dbInspectFailsWith?: string } = {}, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts index c12fb8c69d..47fc2a260c 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts @@ -56,7 +56,7 @@ const withShadowCacheEnv = (value: string | undefined, body: Effect.Eff /** * Isolates the global shadow-baseline cache under a per-test `SUPABASE_HOME` so tests never - * write into the developer's real `~/.supabase`. Nested with the opt-in/opt-out gate. + * write into the developer's real `~/.supabase`. Nested with the cache env gate. */ const withShadowCacheHome = ( value: string | undefined, From d7a1315b669a8273261ac6ed92e168534408fbc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:43:38 +0000 Subject: [PATCH 4/5] fix(cli): degrade to uncached shadow when the cache root is unusable Review follow-ups (both P3s from the PR review): - Probe the shadow-baseline cache directory with the export's own mkdir before committing to the cached lifecycle. An unwritable or root-squashed SUPABASE_HOME previously selected the cold cached path (no --rm, stop -> failed export -> restart -> warn) on every invocation now that the cache defaults on; it now warns once and falls back to the plain uncached shadow. - Let spawnSupabase/runSupabase accept `undefined` env values to remove a key from the child env entirely, and run the shadow-cache e2e with SUPABASE_SHADOW_CACHE genuinely absent (the shipped default) instead of an explicit opt-in. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uj7BjiXEnbcWy41ToxCnPs --- .../db-bootstrap/shadow-cache.e2e.test.ts | 11 +++++-- .../shadow-cache.integration.test.ts | 31 +++++++++++++++++++ .../shared/db-bootstrap/shadow-cache.ts | 30 ++++++++++++++---- apps/cli/tests/helpers/cli.ts | 18 ++++++++--- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.e2e.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.e2e.test.ts index 92a7626289..762038e8e4 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.e2e.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.e2e.test.ts @@ -6,8 +6,10 @@ * * A black-box `runSupabase` subprocess test, like the other local Docker-stack `*.e2e.test.ts` * suites: the facts it is here to prove are the ones only the real wiring can — that `db diff` - * actually routes through `legacyAcquireShadowDatabase`, that `SUPABASE_SHADOW_CACHE` and - * `${SUPABASE_HOME}/cache/shadow-baseline` survive a real process boundary, that the cache key is STABLE across two + * actually routes through `legacyAcquireShadowDatabase`, that the cache engages with + * `SUPABASE_SHADOW_CACHE` genuinely UNSET (the shipped default — the run removes the harness's + * isolation pin rather than opting in) and that + * `${SUPABASE_HOME}/cache/shadow-baseline` survives a real process boundary, that the cache key is STABLE across two * separate CLI processes (an in-process test computes it once), and that a warm-restored cluster * yields the same migration SQL as a cold-provisioned one. It replaces an earlier in-process * version of this file that called `legacyAcquireShadowDatabase` directly with a synthetic layer @@ -168,7 +170,10 @@ as $$ select 1; $$;`, home: home.dir, exitTimeoutMs: DIFF_TIMEOUT_MS, env: { - SUPABASE_SHADOW_CACHE: "1", + // Remove the harness's isolation pin (`spawnSupabase` injects `=0`) so the suite + // runs with the key GENUINELY ABSENT — the shipped default-ON state — rather than + // an explicit opt-in. + SUPABASE_SHADOW_CACHE: undefined, SUPABASE_DB_SHADOW_PORT: String(shadowPort), }, }; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts index 47fc2a260c..2cc5206ed2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts @@ -341,6 +341,37 @@ describe("legacyAcquireShadowDatabase", () => { ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); }); + it.live( + "an unusable cache root degrades to the uncached shadow, not a doomed cold export", + () => { + const docker = mockLegacyDockerDaemonCliSpawner(); + const cluster = fakeCluster(); + const out = mockOutput(); + return withShadowCacheHome( + "1", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // A regular FILE occupies the cache root's path, so its mkdir can never succeed — the + // same terminal shape as an unwritable or root-squashed `SUPABASE_HOME`. Committing to + // the cached lifecycle anyway would drop `--rm` and pay a stop → failed export → restart + // cycle on every invocation, so the acquire must degrade to the plain uncached shadow. + const cacheDir = shadowCacheDir(path); + yield* fs.makeDirectory(path.dirname(cacheDir), { recursive: true }); + yield* fs.writeFileString(cacheDir, "not a directory"); + + const handle = yield* legacyAcquireShadowDatabase(docker.spawner, shadowInput(fs, path)); + expect(handle.baselinePresent).toBe(false); + expect(handle.snapshotRequired).toBe(false); + expect(docker.calls("create")[0] ?? []).toContain("--rm"); + yield* handle.snapshotBaseline; + expect(docker.calls("stop")).toEqual([]); + expect(out.stderrText).toContain("shadow baseline cache unavailable"); + }), + ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); + }, + ); + it.live("a project dotenv opt-out (SUPABASE_SHADOW_CACHE=0) disables the default", () => { const docker = mockLegacyDockerDaemonCliSpawner(); const cluster = fakeCluster(); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts index df5452a80a..9a5e5a5c44 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts @@ -898,8 +898,9 @@ const legacyWarmShadow = ( * * The `E` in the error channel is {@link legacyResolveShadowCacheKeyInputs}'s own JWKS * resolution alone (see that function's doc comment): every OTHER failure this function's own - * body can produce while computing the key or restoring the snapshot is caught and degraded to a - * cold provision — a genuine JWKS failure is the one case that must reach the caller instead, + * body can produce while computing the key or restoring the snapshot is caught and degraded — an + * unusable cache root to the plain uncached shadow (with a warning), anything on the warm path to + * a cold provision — a genuine JWKS failure is the one case that must reach the caller instead, * since a real cold provision at this input would have failed the same way. */ export const legacyAcquireShadowDatabase = ( @@ -926,11 +927,28 @@ export const legacyAcquireShadowDatabase = ( // Interruptible: nothing acquired yet; JWKS discovery must not pin Ctrl-C. const keyInputs = yield* Effect.interruptible(legacyResolveShadowCacheKeyInputs(input, opts)); if (Option.isNone(keyInputs)) return yield* legacyUncachedShadow(spawner, input); - const key = legacyShadowCacheKey(keyInputs.value); - const tarPath = input.path.join( - legacyShadowBaselineCacheDir(input.path), - legacyShadowBaselineTarFileName(key), + + // The cache root must be usable BEFORE committing to the cached lifecycle: the cold path + // drops `--rm` and pays a stop → export → restart cycle whose write is already doomed when + // this directory cannot be created (an unwritable or root-squashed `SUPABASE_HOME`) — and + // with the cache on by default, every affected invocation would pay that cycle just to warn. + // The probe is the exact mkdir the export performs; the export keeps its own as a safety net + // for a root that vanishes mid-run. + const cacheDir = legacyShadowBaselineCacheDir(input.path); + const cacheRoot = yield* Effect.result( + input.fs.makeDirectory(cacheDir, { recursive: true, mode: 0o700 }), ); + if (Result.isFailure(cacheRoot)) { + const output = yield* Output; + yield* output.raw( + `Warning: shadow baseline cache unavailable (cannot create ${cacheDir}: ${cacheRoot.failure.message}); continuing uncached.\n`, + "stderr", + ); + return yield* legacyUncachedShadow(spawner, input); + } + + const key = legacyShadowCacheKey(keyInputs.value); + const tarPath = input.path.join(cacheDir, legacyShadowBaselineTarFileName(key)); const cached = yield* input.fs.exists(tarPath).pipe(Effect.orElseSucceed(() => false)); if (!cached) diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index 143bf0372b..7889090137 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -299,7 +299,8 @@ export function spawnSupabase( args: string[], options?: { cwd?: string; - env?: Record; + /** `undefined` REMOVES the key from the child env (base env and pins included). */ + env?: Record; /** Reuse a temp SUPABASE_HOME directory instead of creating a new one per call. */ home?: string; /** Write this string to stdin, then close it. */ @@ -322,15 +323,23 @@ export function spawnSupabase( // build artifact without needing platform wrapper packages. let execCmd: string; let execArgs: string[]; - const env: Record = { + // An `undefined` in `options.env` removes the key entirely — pins and ambient values alike — + // so a cache-subject test can run with a variable genuinely absent (the shipped default), + // not just overridden. + const mergedEnv: Record = { ...subprocessBaseEnv(), SUPABASE_HOME: homeDir, SUPABASE_NO_KEYRING: "1", SUPABASE_TELEMETRY_DISABLED: "1", - // Isolate e2e from the default-ON shadow cache. Cache-subject tests opt in via `options.env`. + // Isolate e2e from the default-ON shadow cache. Cache-subject tests opt back in (or unset + // the key with `undefined`) via `options.env`. SUPABASE_SHADOW_CACHE: "0", ...options?.env, }; + const env: Record = {}; + for (const [key, value] of Object.entries(mergedEnv)) { + if (value !== undefined) env[key] = value; + } if (entrypoint === "legacy") { assertBuildArtifactsExist("legacy", LEGACY_BINARY_PATH); env["SUPABASE_CLI_BINARY_OVERRIDE"] = LEGACY_BINARY_PATH; @@ -518,7 +527,8 @@ export async function runSupabase( args: string[], options?: { cwd?: string; - env?: Record; + /** `undefined` REMOVES the key from the child env (base env and pins included). */ + env?: Record; /** Reuse a temp SUPABASE_HOME directory instead of creating a new one per call. */ home?: string; /** Write this string to stdin, then close it. */ From b19aee281e867f1a083489bfe195b0838aa97949 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:54:05 +0000 Subject: [PATCH 5/5] fix(cli): probe cache-root write access, not just directory creation Recursive mkdir on an already-existing directory creates nothing and succeeds regardless of permission, so a pre-existing read-only cache root still selected the doomed cold cached lifecycle. Chain an access(W_OK) check after the mkdir so an existing unwritable root (EACCES for the current user, EROFS on a read-only mount, an NFS server's root-squash denial) also degrades to the plain uncached shadow with the one-line warning. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uj7BjiXEnbcWy41ToxCnPs --- .../shadow-cache.integration.test.ts | 42 +++++++++++++++++++ .../shared/db-bootstrap/shadow-cache.ts | 15 ++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts index 2cc5206ed2..de60e370b6 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.integration.test.ts @@ -10,6 +10,7 @@ * (the export must stop the container before copying and start it again afterwards). */ +import { accessSync, chmodSync, constants } from "node:fs"; import { join } from "node:path"; import type { CliConfig } from "@supabase/config"; @@ -372,6 +373,47 @@ describe("legacyAcquireShadowDatabase", () => { }, ); + it.live("a pre-existing read-only cache root also degrades to the uncached shadow", () => { + const docker = mockLegacyDockerDaemonCliSpawner(); + const cluster = fakeCluster(); + const out = mockOutput(); + return withShadowCacheHome( + "1", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Recursive mkdir on an EXISTING directory creates nothing and succeeds regardless of + // permission, so the acquire's probe must check write access explicitly — otherwise a + // read-only root selects the doomed cold cycle on every default-ON invocation. + const cacheDir = shadowCacheDir(path); + yield* fs.makeDirectory(cacheDir, { recursive: true }); + chmodSync(cacheDir, 0o500); + // chmod cannot revoke write access from a privileged user (root ignores permission + // bits), so mirror the workers-push suite's guard: assert the degrade only when the + // denial is real for the CURRENT user; otherwise the cached path proceeding is correct. + const writable = (() => { + try { + accessSync(cacheDir, constants.W_OK); + return true; + } catch { + return false; + } + })(); + + const handle = yield* legacyAcquireShadowDatabase(docker.spawner, shadowInput(fs, path)); + if (writable) { + expect(handle.snapshotRequired).toBe(true); + } else { + expect(handle.baselinePresent).toBe(false); + expect(handle.snapshotRequired).toBe(false); + expect(docker.calls("create")[0] ?? []).toContain("--rm"); + expect(out.stderrText).toContain("shadow baseline cache unavailable"); + } + chmodSync(cacheDir, 0o700); + }), + ).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer, cluster.layer))); + }); + it.live("a project dotenv opt-out (SUPABASE_SHADOW_CACHE=0) disables the default", () => { const docker = mockLegacyDockerDaemonCliSpawner(); const cluster = fakeCluster(); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts index 9a5e5a5c44..91d22e819d 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts @@ -930,18 +930,23 @@ export const legacyAcquireShadowDatabase = ( // The cache root must be usable BEFORE committing to the cached lifecycle: the cold path // drops `--rm` and pays a stop → export → restart cycle whose write is already doomed when - // this directory cannot be created (an unwritable or root-squashed `SUPABASE_HOME`) — and + // this directory cannot be written (an unwritable or root-squashed `SUPABASE_HOME`) — and // with the cache on by default, every affected invocation would pay that cycle just to warn. - // The probe is the exact mkdir the export performs; the export keeps its own as a safety net - // for a root that vanishes mid-run. + // The mkdir is the exact one the export performs (which keeps its own as a safety net for a + // root that vanishes mid-run), but alone it is not a sufficient probe: recursive mkdir on an + // ALREADY-EXISTING directory creates nothing and succeeds regardless of permission, so the + // `access(W_OK)` check is what catches a pre-existing read-only root (EACCES for the current + // user, EROFS on a read-only mount, a root-squashing NFS server's denial). const cacheDir = legacyShadowBaselineCacheDir(input.path); const cacheRoot = yield* Effect.result( - input.fs.makeDirectory(cacheDir, { recursive: true, mode: 0o700 }), + input.fs + .makeDirectory(cacheDir, { recursive: true, mode: 0o700 }) + .pipe(Effect.andThen(input.fs.access(cacheDir, { writable: true }))), ); if (Result.isFailure(cacheRoot)) { const output = yield* Output; yield* output.raw( - `Warning: shadow baseline cache unavailable (cannot create ${cacheDir}: ${cacheRoot.failure.message}); continuing uncached.\n`, + `Warning: shadow baseline cache unavailable (cannot write ${cacheDir}: ${cacheRoot.failure.message}); continuing uncached.\n`, "stderr", ); return yield* legacyUncachedShadow(spawner, input);