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 0c7a4c6cef..0bf2706dcf 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -248,8 +248,8 @@ on release). A cache anomaly never fails the command — a warm-path anomaly col a cold export failure only warns and leaves the run uncached (one exception: a shadow that fails to come back up after the snapshot fails the run rather than reporting a false success). See `shared/db-bootstrap/ shadow-cache.ts`'s doc comment for the mechanics. `--use-pgadmin` shares this cache and these -snapshots: its shadow runs the same forced-on Webhooks/`pg_net` baseline -(`legacyMigrateShadowDatabase`), so it keys to the same tars as the native branch. +snapshots: its shadow input states the same forced-on Webhooks/`pg_net` policy +(`webhooks: "enabled"`) the native branch's legacy-engine runs do, so it keys to the same tars. ### `--use-pgadmin` parity quirks and deliberate divergence (CLI-1968) 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 dede679c2e..cb16506149 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -34,6 +34,7 @@ import { legacyWithShadowDatabase } from "../../../shared/db-bootstrap/shadow-ca import { legacyMigrateShadowDatabase, legacyShadowRunInputFromLocalContainerInputs, + type LegacyShadowWebhooksPolicy, } from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; @@ -62,7 +63,10 @@ import { legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, } from "../../../shared/legacy-pgdelta.ts"; -import { legacyPrepareShadowSource } from "../shared/legacy-shadow-source.ts"; +import { + legacyPrepareShadowSource, + legacyShadowSourceWebhooksPolicy, +} from "../shared/legacy-shadow-source.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyClassifyExplicitRef, legacyUnknownTargetMessage } from "./diff.explicit.ts"; import { @@ -581,13 +585,18 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // Shared by both branches below (pgAdmin's `shadowBase` and the migra/pg-delta // `shadowInput`'s own spread) — resolving the image is the actual provisioning work each // branch's own "Creating shadow database..." banner announces, so every call site still - // emits its banner FIRST and only then invokes this. - const resolveShadowRunInput = Effect.fnUntraced(function* () { + // emits its banner FIRST and only then invokes this. `webhooks` is each branch's own + // baseline policy, which lands on `setup.webhooks` — the single field the provisioner + // applies and the shadow baseline cache keys on. + const resolveShadowRunInput = Effect.fnUntraced(function* ( + webhooks: LegacyShadowWebhooksPolicy, + ) { const resolvedShadowImage = yield* localInputs.resolvePostgresImage; return legacyShadowRunInputFromLocalContainerInputs( localInputs, resolvedShadowImage, cfg, + webhooks, fs, path, ); @@ -629,7 +638,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ); } yield* emitStatus("Creating shadow database..."); - const shadowBase = yield* resolveShadowRunInput(); + // Forced-on `pg_net`, the baseline `legacyMigrateShadowDatabase` has always run for the + // legacy engine — same policy the native branch's legacy-engine runs use, so the two + // share snapshots rather than keying a second, pgAdmin-only set. + const shadowBase = yield* resolveShadowRunInput("enabled"); const shadowConnConfig: LegacyPgConnInput = { host: shadowBase.hostname, port: shadowBase.shadowPort, @@ -642,57 +654,49 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // `shadow-cache.ts`) — see that call site's comment for the full rationale. The migrate // step is untouched: it still receives the whole local migration set through the SAME // `legacyMigrateShadowDatabase`, only now told (via `handle`) whether the cluster already - // carries the platform baseline. `webhooks: "enabled"` matches that function's own forced - // `pg_net` baseline, so this shares the snapshots the native branch below keys for the SAME - // forced-on baseline (its legacy-engine runs) rather than a second, pgAdmin-only set. It - // deliberately does NOT share with next's config-following migrate — see that branch's own - // `migrationMode`-conditional `webhooks` opt. - const sql = yield* legacyWithShadowDatabase( - spawner, - shadowBase, - (handle) => - Effect.gen(function* () { - yield* legacyWaitForShadowReady(spawner, handle.containerId, shadowConnConfig, { - timeoutSeconds: shadowBase.healthTimeoutSeconds, - image: shadowBase.image, - }); - yield* legacyMigrateShadowDatabase( - spawner, - { - fs, - path, - workdir: cliConfig.workdir, - projectId: shadowBase.projectId, - container: handle.containerId, - networkId: shadowBase.networkId, - connConfig: shadowConnConfig, - setup: shadowBase.setup, - }, - handle, - ); - yield* emitStatus("Diffing local database with current migrations..."); - return yield* legacyDiffSchemaPgAdmin({ - // `source`/`target` are INVERTED relative to the migra/pg-delta path below: - // `source` is the USER'S db, `target` is the SHADOW. - source: targetUrl, - // Deliberately hardcoded, not built via `legacyToPostgresURL`: 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`, - schema: flags.schema, + // carries the platform baseline. + const sql = yield* legacyWithShadowDatabase(spawner, shadowBase, (handle) => + Effect.gen(function* () { + yield* legacyWaitForShadowReady(spawner, handle.containerId, shadowConnConfig, { + timeoutSeconds: shadowBase.healthTimeoutSeconds, + image: shadowBase.image, + }); + yield* legacyMigrateShadowDatabase( + spawner, + { + fs, + path, + workdir: cliConfig.workdir, projectId: shadowBase.projectId, + container: handle.containerId, networkId: shadowBase.networkId, - extraHosts: shadowBase.extraHosts, - emitStatus, - }); - }), - { webhooks: "enabled" }, + connConfig: shadowConnConfig, + setup: shadowBase.setup, + }, + handle, + ); + yield* emitStatus("Diffing local database with current migrations..."); + return yield* legacyDiffSchemaPgAdmin({ + // `source`/`target` are INVERTED relative to the migra/pg-delta path below: + // `source` is the USER'S db, `target` is the SHADOW. + source: targetUrl, + // Deliberately hardcoded, not built via `legacyToPostgresURL`: 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`, + schema: flags.schema, + projectId: shadowBase.projectId, + networkId: shadowBase.networkId, + extraHosts: shadowBase.extraHosts, + emitStatus, + }); + }), ); diffResult = { sql, files: undefined }; } else { yield* output.raw("Creating shadow database...\n", "stderr"); const migrationMode: "legacy" | "pgdelta-next" = usesPgDeltaNext ? "pgdelta-next" : "legacy"; const shadowInput = { - ...(yield* resolveShadowRunInput()), + ...(yield* resolveShadowRunInput(legacyShadowSourceWebhooksPolicy(migrationMode))), targetLocal: resolved.isLocal, usePgDelta: useDelta, migrationMode, @@ -711,65 +715,56 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // why the cache seam sits here (with `SUPABASE_SHADOW_CACHE` unset it IS today's // create/remove pair; otherwise a key-matching PGDATA snapshot is restored into the fresh // container in a few seconds instead of cold-provisioning the baseline in ~15s). - // The `webhooks` policy MUST describe the baseline the `use` callback below actually - // provisions, because that is what the cache key hashes: `legacyPrepareShadowSource` - // dispatches on `migrationMode`, running `legacyMigrateShadowDatabase` (forced `pg_net`) for - // the legacy engine but `legacyMigrateNextShadowDatabase` (config-following) for pg-delta - // next. Hardcoding `"enabled"` for both would make a next-mode cold run on a - // webhooks-disabled project publish a `pg_net`-less cluster under the - // `webhooks_enabled=true` key that the pgAdmin branch above (whose baseline really is - // forced-on) could then warm-restore, and vice versa. - diffResult = yield* legacyWithShadowDatabase( - spawner, - shadowInput, - (handle) => - Effect.gen(function* () { - const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - flags.schema.length > 0 - ? `Diffing schemas: ${flags.schema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - if (useDelta) { - const result = yield* pgDelta.diffDatabase({ - context: ctx, - source: { - kind: "database", - ref: shadow.sourceUrl, - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - target: { - kind: "database", - ref: target, - ...(shadow.targetUrlOverride === undefined ? { connection: resolved.conn } : {}), - connectOptions: { - isLocal: shadow.targetUrlOverride !== undefined || resolved.isLocal, - dnsResolver, - }, + // The engine's Webhooks policy rides on `shadowInput.setup.webhooks` above, which is both + // what `legacyPrepareShadowSource`'s baseline applies and what the cache key hashes — the + // two cannot describe different clusters. + diffResult = yield* legacyWithShadowDatabase(spawner, shadowInput, (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); + const target = shadow.targetUrlOverride ?? targetUrl; + yield* output.raw( + flags.schema.length > 0 + ? `Diffing schemas: ${flags.schema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (useDelta) { + const result = yield* pgDelta.diffDatabase({ + context: ctx, + source: { + kind: "database", + ref: shadow.sourceUrl, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + target: { + kind: "database", + ref: target, + ...(shadow.targetUrlOverride === undefined ? { connection: resolved.conn } : {}), + connectOptions: { + isLocal: shadow.targetUrlOverride !== undefined || resolved.isLocal, + dnsResolver, }, - schema: flags.schema, - formatOptions, - debug: legacyIsPgDeltaDebugEnabled(), - strictCoverage: flags.strictCoverage, - }); - // Keep the per-unit plan files so a multi-unit plan can be written as one - // migration file each; `sql` stays the flattened join for stdout review + - // machine payloads. - return { sql: result.sql, files: result.files, hazards: result.hazards }; - } - const sql = yield* legacyDiffMigra(ctx, { - source: shadow.sourceUrl, - target, + }, schema: flags.schema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + formatOptions, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, }); - // The migra engine has no execution-aware plan units, so it always writes a - // single migration file. - return { sql, files: undefined }; - }), - migrationMode === "pgdelta-next" ? {} : { webhooks: "enabled" }, + // Keep the per-unit plan files so a multi-unit plan can be written as one + // migration file each; `sql` stays the flattened join for stdout review + + // machine payloads. + return { sql: result.sql, files: result.files, hazards: result.hazards }; + } + const sql = yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target, + schema: flags.schema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + // The migra engine has no execution-aware plan units, so it always writes a + // single migration file. + return { sql, files: undefined }; + }), ); } const out = diffResult.sql; diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 1e7caf9d3d..7aef569672 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -1136,12 +1136,11 @@ describe("legacy db diff", () => { ); // The two native branches provision DIFFERENT clusters on a webhooks-disabled project: - // `--use-pgadmin` migrates through `legacyMigrateShadowDatabase`, whose baseline installs - // `pg_net` unconditionally, while pg-delta next migrates through - // `legacyMigrateNextShadowDatabase`, which follows `config.toml` (webhooks absent = off). If the - // cache key described the caller's literal `webhooks` opt rather than the baseline the run - // actually builds, one would warm-restore the other's snapshot and silently diff against a - // cluster with the wrong extension set. + // `--use-pgadmin` states `webhooks: "enabled"` on its shadow input, so its baseline installs + // `pg_net` unconditionally, while pg-delta next states `"config"` and follows `config.toml` + // (webhooks absent = off). That one field is what the cache key hashes, so neither can + // warm-restore the other's snapshot and silently diff against a cluster with the wrong + // extension set. it.effect( "--use-pgadmin does not reuse the pg-delta next baseline when config leaves webhooks disabled", () => diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index 73fbc8b5ce..aa290e3c23 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -89,7 +89,10 @@ import { legacyResolvePgDeltaProjectId, } from "../../../shared/legacy-pgdelta.ts"; import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; -import { legacyPrepareShadowSource } from "../shared/legacy-shadow-source.ts"; +import { + legacyPrepareShadowSource, + legacyShadowSourceWebhooksPolicy, +} from "../shared/legacy-shadow-source.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { LegacyDbPullDumpError, @@ -533,6 +536,9 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy declLocalInputs, resolvedDeclShadowImage, toml, + // `legacyPrepareRawShadow` runs no platform baseline at all (and no cache), + // so this policy is never applied; `"config"` states the inert default. + "config", fs, path, ); @@ -773,6 +779,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy pullLocalInputs, resolvedPullShadowImage, toml, + legacyShadowSourceWebhooksPolicy(migrationMode), fs, path, ), @@ -795,67 +802,58 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // pooler-retry attempt still acquires and releases its own shadow — on the warm path // each attempt restores its own fresh container from the same cached snapshot, // sequentially. - // The `webhooks` policy MUST describe the baseline the `use` callback below actually - // provisions, because that is what the cache key hashes: `legacyPrepareShadowSource` - // dispatches on `migrationMode`, running `legacyMigrateShadowDatabase` (forced - // `pg_net`) for the legacy engine but `legacyMigrateNextShadowDatabase` - // (config-following) for pg-delta next. Hardcoding `"enabled"` for both would make a - // next-mode cold run on a webhooks-disabled project publish a `pg_net`-less cluster - // under the `webhooks_enabled=true` key, which `db diff --use-pgadmin` (whose baseline - // really is forced-on) could then warm-restore, and vice versa. - return yield* legacyWithShadowDatabase( - spawner, - shadowInput, - (handle) => - Effect.gen(function* () { - const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); - const target = shadow.targetUrlOverride ?? targetEndpoint.ref; - yield* output.raw( - diffSchema.length > 0 - ? `Diffing schemas: ${diffSchema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - if (usePgDeltaDiff) { - return yield* pgDeltaEngine.diffDatabase({ - context: ctx, - source: { - kind: "database", - ref: shadow.sourceUrl, - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - target: { - kind: "database", - ref: target, - ...(shadow.targetUrlOverride === undefined - ? { - ...(targetEndpoint.connection !== undefined - ? { connection: targetEndpoint.connection } - : {}), - connectOptions: targetEndpoint.connectOptions, - } - : { - connectOptions: { isLocal: true, dnsResolver }, - }), - }, - schema: diffSchema, - formatOptions, - debug: legacyIsPgDeltaDebugEnabled(), - strictCoverage: flags.strictCoverage, - }); - } - const sql = yield* legacyDiffMigra(ctx, { - source: shadow.sourceUrl, - target, + // The engine's Webhooks policy rides on `shadowInput.setup.webhooks` above, which is + // both what `legacyPrepareShadowSource`'s baseline applies and what the cache key + // hashes — the two cannot describe different clusters. + return yield* legacyWithShadowDatabase(spawner, shadowInput, (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); + const target = shadow.targetUrlOverride ?? targetEndpoint.ref; + yield* output.raw( + diffSchema.length > 0 + ? `Diffing schemas: ${diffSchema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (usePgDeltaDiff) { + return yield* pgDeltaEngine.diffDatabase({ + context: ctx, + source: { + kind: "database", + ref: shadow.sourceUrl, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + target: { + kind: "database", + ref: target, + ...(shadow.targetUrlOverride === undefined + ? { + ...(targetEndpoint.connection !== undefined + ? { connection: targetEndpoint.connection } + : {}), + connectOptions: targetEndpoint.connectOptions, + } + : { + connectOptions: { isLocal: true, dnsResolver }, + }), + }, schema: diffSchema, - connectOptions: - shadow.targetUrlOverride === undefined - ? targetEndpoint.connectOptions - : { isLocal: true, dnsResolver }, + formatOptions, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, }); - return { sql, files: undefined, debug: undefined }; - }), - migrationMode === "pgdelta-next" ? {} : { webhooks: "enabled" }, + } + const sql = yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target, + schema: diffSchema, + connectOptions: + shadow.targetUrlOverride === undefined + ? targetEndpoint.connectOptions + : { isLocal: true, dnsResolver }, + }); + return { sql, files: undefined, debug: undefined }; + }), ); }); const diffOutcome = yield* withPoolerFallback(targetEndpoint, runShadowDiff); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts index d065b1bf5a..5bfb049999 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts @@ -211,6 +211,9 @@ export const legacyGenerateDeclarativeOutput = Effect.fnUntraced(function* ( localInputs, resolvedImage, toml, + // `legacyPrepareRawShadow` runs no platform baseline at all (and no cache), so this policy + // is never applied; `"config"` states the inert default. + "config", fs, path, ); 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 edb37e6a17..e89f653829 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 @@ -149,7 +149,7 @@ shadow; the cache key hashes the cluster recipe (including the effective Webhook policy), not the published port, so worktrees and repeated syncs with the same settings share a warm hit. The migrations shadow follows project config; the declarative shadow forces `pg_net` off — those are distinct keys when Webhooks are enabled. A warm hit skips the -platform baseline on both shadows (`legacyMigrateNextShadowDatabase` / +platform baseline on both shadows (`legacyMigrateShadowDatabase` / `legacySetupShadowDatabase` are baseline-state-aware). Artifact: `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` (~90MB; `SUPABASE_HOME` overrides the root), keyed by a hash of every input baked into the cluster (including the effective diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts index e05d2ddf1a..1edfabcec6 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts @@ -34,10 +34,11 @@ import { } from "./legacy-pgdelta-next-shadow.plan.ts"; import { legacyConnectShadowDatabase, - legacyMigrateNextShadowDatabase, + legacyMigrateShadowDatabase, legacyRemoveShadowDatabase, legacyShadowRunInputFromLocalContainerInputs, legacySetupShadowDatabase, + type LegacyShadowWebhooksPolicy, } from "../../../shared/db-bootstrap/shadow-database.ts"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; @@ -223,10 +224,18 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( return { localInputs, image } satisfies NativeShadowBase; }).pipe(Effect.provide(runtime)); + /** + * `webhooks` is the baseline policy the shadow this input describes is really provisioned + * under — `"config"` for a migrations shadow (next follows project config), `"disabled"` for + * a declarative one. It lands on `base.setup.webhooks`, which is both what the provisioner + * applies and what the shadow baseline cache keys on, so the two shadows' snapshots can only + * share a key when their effective policies genuinely agree. + */ const buildNativeInput = ( request: LegacyPgDeltaNextShadowInput, built: NativeShadowBase, port: number, + webhooks: LegacyShadowWebhooksPolicy, ): NativeShadowInput => ({ spawner, localInputs: built.localInputs, @@ -234,6 +243,7 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( built.localInputs, built.image, { ...request.toml, shadowPort: port }, + webhooks, fs, path, ), @@ -292,7 +302,7 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( if (!seamWillRun) yield* onBaselineSeam; yield* awaitShadowReady(input, seamHandle); const setup = setupRunInput(input, seamHandle); - yield* legacyMigrateNextShadowDatabase(input.spawner, setup, seamHandle); + yield* legacyMigrateShadowDatabase(input.spawner, setup, seamHandle); return { migrationsUrl: legacyToPostgresURL(setup.connConfig), } satisfies LegacyPgDeltaNextMigrationsShadow; @@ -307,7 +317,7 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( const handle = yield* acquireShadow(input, opts); yield* awaitShadowReady(input, handle); const setup = setupRunInput(input, handle); - yield* legacySetupShadowDatabase(input.spawner, setup, { webhooks: "disabled" }, handle); + yield* legacySetupShadowDatabase(input.spawner, setup, handle); yield* Effect.scoped( Effect.gen(function* () { const session = yield* legacyConnectShadowDatabase(setup.connConfig); @@ -324,29 +334,25 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( } satisfies ProvisionedDeclarativeShadow; }).pipe(Effect.provide(runtimeWith(outputService)), Effect.mapError(nextShadowError)); - const cacheOpts = ( - opts: LegacyPgDeltaNextShadowInput, - webhooks: NonNullable, - ): LegacyShadowCacheOpts => ({ - webhooks, - ...(opts.bypassCache === true ? { bypassCache: true } : {}), - }); + /** The only per-invocation cache control left here — the Webhooks policy rides on the input's own `setup.webhooks` (see {@link buildNativeInput}). */ + const cacheOpts = (opts: LegacyPgDeltaNextShadowInput): LegacyShadowCacheOpts => + opts.bypassCache === true ? { bypassCache: true } : {}; return LegacyPgDeltaNextShadow.of({ provisionMigrations: (opts) => Effect.gen(function* () { const port = yield* nextPort(); const built = yield* buildNativeBase(opts); - const input = buildNativeInput(opts, built, port); - return yield* provisionMigrations(input, cacheOpts(opts, "config")); + const input = buildNativeInput(opts, built, port, "config"); + return yield* provisionMigrations(input, cacheOpts(opts)); }).pipe(Effect.mapError(nextShadowError)), provisionPlan: (opts) => Effect.gen(function* () { const migrationsPort = yield* nextPort(); const declarativePort = yield* nextPort(migrationsPort); const built = yield* buildNativeBase(opts); - const migrationsInput = buildNativeInput(opts, built, migrationsPort); - const declarativeInput = buildNativeInput(opts, built, declarativePort); + const migrationsInput = buildNativeInput(opts, built, migrationsPort, "config"); + const declarativeInput = buildNativeInput(opts, built, declarativePort, "disabled"); // The two shadows are independent — anonymous containers on the distinct host ports // allocated above, per-invocation scoped temp dirs, and a race-tolerant network // ensure — so warm provisions run fully concurrently. How much can safely overlap @@ -356,8 +362,8 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( // cache-key inputs once; passing them back through `precomputedKeyInputs` keeps the // acquire from repeating a live JWKS discovery request. const [migrationsPeek, declarativePeek] = yield* Effect.all([ - legacyPeekShadowBaseline(migrationsInput.base, cacheOpts(opts, "config")), - legacyPeekShadowBaseline(declarativeInput.base, cacheOpts(opts, "disabled")), + legacyPeekShadowBaseline(migrationsInput.base, cacheOpts(opts)), + legacyPeekShadowBaseline(declarativeInput.base, cacheOpts(opts)), ]); const withPeek = ( cache: LegacyShadowCacheOpts, @@ -382,11 +388,9 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( // always carry the SAME value and cannot diverge; a delayed acquire keeps the // command-start JWKS, well inside the staleness the snapshot cache accepts by design // (a warm hit serves a tar up to 14 days old under its matching key). - const migrationsOpts = withPeek(cacheOpts(opts, "config"), migrationsPeek); + const migrationsOpts = withPeek(cacheOpts(opts), migrationsPeek); const declarativeOpts = - strategy === "parallel" - ? withPeek(cacheOpts(opts, "disabled"), declarativePeek) - : cacheOpts(opts, "disabled"); + strategy === "parallel" ? withPeek(cacheOpts(opts), declarativePeek) : cacheOpts(opts); // In the concurrent strategies the declarative fiber's writes are buffered and // flushed after the join, so nothing can land between two of the migrations fiber's diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.plan.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.plan.ts index 10d92a3997..0b4a7bd994 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.plan.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.plan.ts @@ -12,7 +12,8 @@ * provision skips the platform baseline entirely (`legacySetupShadowDatabase`'s * `baselinePresent` branch), so the declarative fiber prints nothing and the migrations fiber's * `Applying migration ...` lines stream live and in order. - * - `baseline-handoff` — both are cold with the SAME cache key (webhooks agree): the baseline is + * - `baseline-handoff` — both are cold with the SAME cache key (their `setup.webhooks` policies + * resolve to the same effective boolean): the baseline is * paid exactly once. The migrations shadow cold-provisions; its snapshot export runs at the * baseline seam (after platform setup, before migration replay) and signals the declarative * fiber, which then warm-restores from the just-published tar CONCURRENTLY with the migration diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts index a01b105325..631b91e040 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts @@ -49,10 +49,10 @@ import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import type { LegacyShadowAcquiredHandle } from "../../../shared/db-bootstrap/shadow-cache.ts"; import { legacyMigrateShadowDatabase, - legacyMigrateNextShadowDatabase, LegacyShadowDbError, type LegacyShadowSetupInput, type LegacyShadowSourceResult, + type LegacyShadowWebhooksPolicy, } from "../../../shared/db-bootstrap/shadow-database.ts"; import type { LegacyStartSetupLocalDatabaseError } from "../../../shared/db-bootstrap/db-setup.ts"; import { @@ -84,6 +84,22 @@ export interface LegacyPrepareShadowSourceInput extends LegacyShadowSetupInpu readonly ctx: LegacyPgDeltaContext; } +/** + * The Webhooks/`pg_net` policy each engine's shadow baseline has always applied: the legacy + * migra/pg-delta engines include `pg_net` in the platform baseline regardless of project config + * (Go parity — those Go-backed workflows always ran `SetupDatabase` with webhooks forced on), + * while pg-delta next follows `config.toml` through `legacySetupDatabase`'s own default. + * + * Callers building a {@link legacyPrepareShadowSource} input pass this into + * `legacyShadowRunInputFromLocalContainerInputs` so `setup.webhooks` states the engine's real + * policy exactly once — the shadow baseline cache then hashes the very field the provisioner + * applies, instead of a separately-supplied opt that could describe a different baseline (see + * `LegacyShadowDbSetupInput.webhooks`, `shadow-database.ts`). + */ +export const legacyShadowSourceWebhooksPolicy = ( + migrationMode: LegacyPrepareShadowSourceInput["migrationMode"], +): LegacyShadowWebhooksPolicy => (migrationMode === "pgdelta-next" ? "config" : "enabled"); + /** Every failure {@link legacyPrepareShadowSource} can produce, beyond its own `E` (JWKS resolution). */ export type LegacyPrepareShadowSourceError = | LegacyShadowDbError @@ -165,12 +181,10 @@ export const legacyPrepareShadowSource = ( // already holds the platform baseline (so only the template database + user migrations run), // and on a cache-enabled cold provision it carries the snapshot step that runs between the // two — see `shadow-cache.ts`/`LegacyShadowBaselineState`. An uncached acquire hands over the - // always-cold state, which reproduces today's sequence exactly. - const migrateShadow = - input.migrationMode === "pgdelta-next" - ? legacyMigrateNextShadowDatabase - : legacyMigrateShadowDatabase; - yield* migrateShadow( + // always-cold state, which reproduces today's sequence exactly. The per-engine Webhooks + // difference rides on `input.setup.webhooks` (built via + // {@link legacyShadowSourceWebhooksPolicy}), so there is one migrate function, not two. + yield* legacyMigrateShadowDatabase( spawner, { fs: input.fs, diff --git a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md index fdc63f87ac..27e2a9b2f4 100644 --- a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md @@ -73,9 +73,10 @@ Two squash-specific points: one-shot realtime/storage/auth migrate jobs do not run — and then resumes at exactly the same seam as a cold run: the before-migration `auth`/`storage` dump, the migrations up to the target, the after-migration dump, and the full dump are all unchanged. -- Squash's `SetupDatabase` follows `config.toml` for Webhooks/`pg_net`, unlike `db diff`/`db -pull`'s forced-on `legacyMigrateShadowDatabase` baseline. That effective policy is part of the - cache key, so squash keys to its own tars and can never warm-restore a `pg_net`-forced cluster. +- Squash's `SetupDatabase` follows `config.toml` for Webhooks/`pg_net` (its shadow input states + `webhooks: "config"`), unlike `db diff`/`db pull`'s forced-on legacy-engine baseline. That one + field is both what the baseline applies and what the cache key hashes, so squash keys to its own + tars and can never warm-restore a `pg_net`-forced cluster. ## API Routes diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts index 9198a3ea96..3229a635b4 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts @@ -94,10 +94,10 @@ type Spawner = ChildProcessSpawnerType["Service"]; * creation and the finalizer being attached) and why the cache seam sits here (with * `SUPABASE_SHADOW_CACHE` unset it IS today's create/remove pair; otherwise a key-matching PGDATA * snapshot is restored into the fresh container in a few seconds instead of cold-provisioning the - * baseline in ~15s). No `webhooks` override, unlike `db diff`/`db pull`'s forced-on - * `legacyMigrateShadowDatabase` baseline: squash's `SetupDatabase` call has always followed - * `config.toml`, so the cache key must hash the config-following policy or a squash run would - * warm-restore a `pg_net`-forced cluster. + * baseline in ~15s). The shadow input states `webhooks: "config"`, unlike `db diff`/`db pull`'s + * forced-on legacy-engine baseline: squash's `SetupDatabase` call has always followed + * `config.toml`. That one field is both what {@link legacyOpenShadowBaselineSession} applies and + * what the cache key hashes, so a squash run cannot warm-restore a `pg_net`-forced cluster. * * The baseline is the ONLY thing the cache covers, and * {@link legacyOpenShadowBaselineSession} hands back the open session at exactly that seam — so @@ -119,6 +119,9 @@ const squashMigrations = Effect.fnUntraced(function* ( localInputs, resolvedShadowImage, toml, + // Squash's `SetupDatabase` has always followed `config.toml` — see this function's own doc + // comment. + "config", fs, path, ); @@ -155,7 +158,6 @@ const squashMigrations = Effect.fnUntraced(function* ( connConfig, setup: shadowInput.setup, }, - {}, handle, ); 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 4e90ae87c3..38d432a855 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 @@ -46,7 +46,11 @@ import { } from "./shadow-cache.ts"; import { LEGACY_SHADOW_DEBUG_ENV } from "./shadow-debug.ts"; import { legacyRemoveShadowDatabase } from "./shadow-database.ts"; -import type { LegacyShadowDbSetupInput, LegacyShadowSetupInput } from "./shadow-database.ts"; +import type { + LegacyShadowDbSetupInput, + LegacyShadowSetupInput, + LegacyShadowWebhooksPolicy, +} from "./shadow-database.ts"; const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultConfig: ProjectConfig = decodeConfig({}); @@ -128,7 +132,9 @@ function fakeCluster(opts: { readonly failConnect?: boolean } = {}) { // Inputs // --------------------------------------------------------------------------- -const shadowSetup = (): LegacyShadowDbSetupInput => ({ +const shadowSetup = ( + webhooks: LegacyShadowWebhooksPolicy = "config", +): LegacyShadowDbSetupInput => ({ majorVersion: 17, config: defaultConfig, dbUrl: "postgresql://postgres:postgres@127.0.0.1:54320/postgres", @@ -147,6 +153,7 @@ const shadowSetup = (): LegacyShadowDbSetupInput => ({ projectEnvValues: undefined, debug: false, webhooksEnabled: false, + webhooks, apiAutoExposeNewTables: Option.some(true), vault: [], }); @@ -154,7 +161,11 @@ const shadowSetup = (): LegacyShadowDbSetupInput => ({ const shadowInput = ( fs: FileSystem.FileSystem, path: Path.Path, - overrides: { readonly shadowPort?: number; readonly jwtExpiry?: number } = {}, + overrides: { + readonly shadowPort?: number; + readonly jwtExpiry?: number; + readonly webhooks?: LegacyShadowWebhooksPolicy; + } = {}, ): LegacyShadowSetupInput => ({ db: { major_version: 17, settings: {} }, experimental: defaultConfig.experimental, @@ -173,7 +184,7 @@ const shadowInput = ( path, hostname: "127.0.0.1", healthTimeoutSeconds: 2, - setup: shadowSetup(), + setup: shadowSetup(overrides.webhooks), }); const shadowCacheDir = (path: Path.Path) => legacyShadowBaselineCacheDir(path); @@ -698,24 +709,23 @@ describe("legacyAcquireShadowDatabase", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const input = shadowInput(fs, path); - // `shadowSetup.webhooksEnabled` is false, so `"config"` (next migrate) and - // `"disabled"` (next declarative) bake the same cluster; `"enabled"` (legacy - // migrate) does not and must not restore that tar. - yield* coldRun(docker, input, { webhooks: "config" }); + // `setup.webhooksEnabled` is false, so `"config"` (next migrate) and `"disabled"` + // (next declarative) bake the same cluster; `"enabled"` (legacy migrate) does not and + // must not restore that tar. + const viaConfig = shadowInput(fs, path, { webhooks: "config" }); + yield* coldRun(docker, viaConfig); expect(yield* soleTarName(fs, path)).toHaveLength(1); - const forcedOn = yield* coldRun(docker, input, { webhooks: "enabled" }); + const forcedOn = yield* coldRun(docker, shadowInput(fs, path, { webhooks: "enabled" })); expect(forcedOn.baselinePresent).toBe(false); expect(yield* soleTarName(fs, path)).toHaveLength(2); - const warmConfig = yield* legacyAcquireShadowDatabase(docker.spawner, input, { - webhooks: "config", - }); + const warmConfig = yield* legacyAcquireShadowDatabase(docker.spawner, viaConfig); expect(warmConfig.baselinePresent).toBe(true); - const warmDisabled = yield* legacyAcquireShadowDatabase(docker.spawner, input, { - webhooks: "disabled", - }); + const warmDisabled = yield* legacyAcquireShadowDatabase( + docker.spawner, + shadowInput(fs, path, { webhooks: "disabled" }), + ); expect(warmDisabled.baselinePresent).toBe(true); expect(yield* soleTarName(fs, path)).toHaveLength(2); yield* legacyRemoveShadowDatabase(docker.spawner, warmConfig.containerId); @@ -1114,10 +1124,16 @@ describe("legacyPeekShadowBaseline", () => { } // The handoff precondition: with config webhooks OFF, the migrations ("config") and - // declarative ("disabled") opts hash to the SAME key; forcing webhooks on re-keys. - const viaConfig = yield* legacyPeekShadowBaseline(input, { webhooks: "config" }); - const viaDisabled = yield* legacyPeekShadowBaseline(input, { webhooks: "disabled" }); - const viaEnabled = yield* legacyPeekShadowBaseline(input, { webhooks: "enabled" }); + // declarative ("disabled") inputs hash to the SAME key; forcing webhooks on re-keys. + const viaConfig = yield* legacyPeekShadowBaseline( + shadowInput(fs, path, { webhooks: "config" }), + ); + const viaDisabled = yield* legacyPeekShadowBaseline( + shadowInput(fs, path, { webhooks: "disabled" }), + ); + const viaEnabled = yield* legacyPeekShadowBaseline( + shadowInput(fs, path, { webhooks: "enabled" }), + ); if ( viaConfig.state !== "uncachable" && viaDisabled.state !== "uncachable" && 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 4024bafd4a..bc2c87a6c8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts @@ -49,7 +49,6 @@ import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../legacy-local-config-values. import { LEGACY_START_ENABLE_DATABASE_WEBHOOKS_SQL, LEGACY_START_REVOKE_API_PRIVILEGES_SQL, - type LegacySetupDatabaseOptions, } from "./db-setup.ts"; import { LEGACY_START_INTERNAL_DB_NAME, @@ -88,6 +87,7 @@ import { type LegacyShadowBaselineState, LegacyShadowDbError, type LegacyShadowSetupInput, + type LegacyShadowWebhooksPolicy, } from "./shadow-database.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -208,10 +208,11 @@ export interface LegacyShadowCacheKeyInputs { readonly autoExposeNewTables: Option.Option; /** * Effective Webhooks/`pg_net` policy baked into the cluster — the same boolean - * `legacySetupDatabase` applies (`options.webhooks` resolved against - * `setup.webhooksEnabled`). Distinct from the raw config flag: legacy migrate - * forces enabled, next declarative forces disabled, and next migrate follows - * config. Hashed so those callers cannot share a snapshot (review: Codex/ + * `legacySetupDatabase` applies, resolved from the ONE field every baseline provisioner + * also reads (`setup.webhooks` resolved against `setup.webhooksEnabled` — see + * `LegacyShadowDbSetupInput.webhooks`, `shadow-database.ts`). Distinct from the raw config flag: the legacy + * migrate engine forces enabled, next's declarative shadow forces disabled, and next's + * migrate follows config. Hashed so those callers cannot share a snapshot (review: Codex/ * depthfirst on #6184). */ readonly webhooksEnabled: boolean; @@ -390,22 +391,25 @@ export function legacyShadowCacheKey(inputs: LegacyShadowCacheKeyInputs): string /** * Same resolution `legacySetupDatabase` applies (`db-setup.ts`): `"enabled"` always - * installs `pg_net`, `"disabled"` always removes it, `"config"` (the default) follows - * `setup.webhooksEnabled`. + * installs `pg_net`, `"disabled"` always removes it, `"config"` follows + * `setup.webhooksEnabled`. `policy` is always an explicit `LegacyShadowDbSetupInput.webhooks` + * — no defaulting here, so the boolean this returns cannot describe a policy no provisioner ran. */ export function legacyEffectiveShadowWebhooksEnabled( - policy: LegacySetupDatabaseOptions["webhooks"], + policy: LegacyShadowWebhooksPolicy, webhooksEnabled: boolean, ): boolean { - const webhooks = policy ?? "config"; - return webhooks === "enabled" || (webhooks === "config" && webhooksEnabled); + return policy === "enabled" || (policy === "config" && webhooksEnabled); } /** * Resolves {@link LegacyShadowCacheKeyInputs} from the same run input the shadow container * itself is built from, plus `supabase/roles.sql` off disk. The service enabled flags come from * `setup.config` (NOT the `*EnabledForSetup` fields, which only gate JWKS resolution) because - * `legacySetupDatabase`'s own one-shot job gates read exactly those config fields. + * `legacySetupDatabase`'s own one-shot job gates read exactly those config fields. The Webhooks/ + * `pg_net` policy likewise comes from `input.setup.webhooks` — the SAME field every baseline + * provisioner passes to `legacySetupDatabase`, so the key cannot describe a policy the cluster + * was not provisioned under (see `LegacyShadowDbSetupInput.webhooks`, `shadow-database.ts`). * * Returns `Option.none` (never a failure) for the two conditions that make caching * unavailable — an OrioleDB cluster (whose state is partly external, see the body's own @@ -422,7 +426,6 @@ export function legacyEffectiveShadowWebhooksEnabled( const legacyResolveShadowCacheKeyInputs = ( input: LegacyShadowSetupInput, - opts: LegacyShadowCacheOpts = {}, ): Effect.Effect, E> => Effect.gen(function* () { // OrioleDB (`experimental.orioledb_version`) makes the WHOLE cache ineligible, not just a @@ -483,7 +486,7 @@ const legacyResolveShadowCacheKeyInputs = ( storageTargetMigration: input.setup.storageTargetMigration, autoExposeNewTables: input.setup.apiAutoExposeNewTables, webhooksEnabled: legacyEffectiveShadowWebhooksEnabled( - opts.webhooks, + input.setup.webhooks, input.setup.webhooksEnabled, ), rolesSql, @@ -894,21 +897,19 @@ const legacyExportShadowBaseline = ( * it must neither restore an existing snapshot nor publish a new one — exactly the uncached * lifecycle, regardless of the env gate (review: Codex on #6184). * - * `webhooks` is the same policy the caller will pass to `legacySetupDatabase` / - * `legacySetupShadowDatabase` / `legacyMigrate*ShadowDatabase`. Defaults to `"config"` - * (follow `setup.webhooksEnabled`), matching {@link LegacySetupDatabaseOptions}. Hashed as - * the effective boolean so a forced-on legacy migrate snapshot cannot warm-restore into a - * next path that follows config, or a declarative shadow that forces webhooks off. + * Deliberately carries NO Webhooks policy: that is not a per-invocation cache control but a + * property of the baseline itself, stated once on `input.setup.webhooks` and read by BOTH the + * provisioners and the key resolution — see `LegacyShadowDbSetupInput.webhooks` + * (`shadow-database.ts`) for why the two must not be expressible separately. */ export interface LegacyShadowCacheOpts { readonly bypassCache?: boolean; - readonly webhooks?: LegacySetupDatabaseOptions["webhooks"]; /** * Key inputs a caller already resolved via {@link legacyPeekShadowBaseline}, so * {@link legacyAcquireShadowDatabase} does not resolve them a second time. Resolution is not * idempotent-cheap: it can include a live JWKS discovery request (realtime on PG15+), so a * peek-then-acquire caller passing this through halves that traffic. MUST have been computed - * from the same `input`/`opts` pair, or the acquire keys against the wrong snapshot. + * from the same `input`, or the acquire keys against the wrong snapshot. */ readonly precomputedKeyInputs?: LegacyShadowCacheKeyInputs; } @@ -949,7 +950,7 @@ export const legacyPeekShadowBaseline = ( ) { return { state: "uncachable" } as const; } - const keyInputs = yield* legacyResolveShadowCacheKeyInputs(input, opts); + const keyInputs = yield* legacyResolveShadowCacheKeyInputs(input); if (Option.isNone(keyInputs)) return { state: "uncachable" } as const; const key = legacyShadowCacheKey(keyInputs.value); const tarPath = input.path.join( @@ -1191,7 +1192,7 @@ export const legacyAcquireShadowDatabase = ( const keyInputs = opts.precomputedKeyInputs !== undefined ? Option.some(opts.precomputedKeyInputs) - : yield* Effect.interruptible(legacyResolveShadowCacheKeyInputs(input, opts)); + : yield* Effect.interruptible(legacyResolveShadowCacheKeyInputs(input)); if (Option.isNone(keyInputs)) return yield* legacyUncachedShadow(spawner, input); const key = legacyShadowCacheKey(keyInputs.value); const tarPath = input.path.join( diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.unit.test.ts index 7ec071700a..edaab31148 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.unit.test.ts @@ -72,8 +72,6 @@ describe("legacyEffectiveShadowWebhooksEnabled", () => { expect(legacyEffectiveShadowWebhooksEnabled("disabled", false)).toBe(false); expect(legacyEffectiveShadowWebhooksEnabled("config", true)).toBe(true); expect(legacyEffectiveShadowWebhooksEnabled("config", false)).toBe(false); - expect(legacyEffectiveShadowWebhooksEnabled(undefined, true)).toBe(true); - expect(legacyEffectiveShadowWebhooksEnabled(undefined, false)).toBe(false); }); }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts index f4870358a1..42542b99eb 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -449,6 +449,12 @@ function legacyMemoizeSuccess(effect: Effect.Effect): Effect.Effect< * `legacy-shadow-source.ts` would drag its whole pg-delta/migra/declarative stack into a * command that has no diff engine at all. * + * `webhooks` is the one policy the caller must decide rather than read out of config — it lands + * on `setup.webhooks`, which is both what the baseline provisioners apply and what the shadow + * baseline cache keys on (see {@link LegacyShadowDbSetupInput.webhooks}). Callers building a + * `legacyPrepareShadowSource` input take it from `legacyShadowSourceWebhooksPolicy` + * (`legacy-shadow-source.ts`) so the engine's real policy is named in exactly one place. + * * On `db diff --linked`/`db pull` (linked), the caller passes its own resolved ref straight * through to `legacyBuildLocalDbContainerInputs` (its own `projectRef` parameter — see * that function's doc comment), which threads it into `legacyLoadLocalProjectContext` -> @@ -473,6 +479,7 @@ export function legacyShadowRunInputFromLocalContainerInputs( readonly baseline: { readonly apiAutoExposeNewTables: Option.Option }; readonly vault: ReadonlyArray; }, + webhooks: LegacyShadowWebhooksPolicy, fs: FileSystem.FileSystem, path: Path.Path, ): LegacyShadowSetupInput { @@ -503,6 +510,9 @@ export function legacyShadowRunInputFromLocalContainerInputs( majorVersion: localInputs.setup.majorVersion, config: localInputs.setup.config, webhooksEnabled: toml.webhooksEnabled, + // The caller's own engine policy, stated once: every baseline provisioner reads it, and + // so does the cache key — see {@link LegacyShadowDbSetupInput.webhooks}. + webhooks, // NOT `localInputs.setup.dbUrl` — that carries the REGULAR local container's own // hardcoded-"postgres" password (`legacy-local-config-values.ts`'s `DEFAULT_DB_PASSWORD`), // for a DIFFERENT container. The shadow's own one-shot setup jobs @@ -670,10 +680,33 @@ const legacyCreateShadowTemplateDatabase = ( */ export type LegacyShadowDbSetupInput = Omit, "experimental"> & { readonly webhooksEnabled: LegacySetupDatabaseInput["webhooksEnabled"]; + /** + * The Webhooks/`pg_net` POLICY this shadow's platform baseline runs under — the single source + * of truth for it. Every baseline provisioner here ({@link legacySetupShadowDatabase}, + * {@link legacyMigrateShadowDatabase}, {@link legacyOpenShadowBaselineSession}) passes exactly + * this value to {@link legacySetupDatabase}, AND `shadow-cache.ts` hashes exactly this value + * (as the effective boolean, resolved against {@link webhooksEnabled} by + * `legacyEffectiveShadowWebhooksEnabled`) into the shadow-baseline cache key. One field rather + * than a provisioner argument plus a cache option, so a published snapshot's key can never + * describe a different baseline than the one actually provisioned — the drift class three of + * five call sites had accumulated back when the policy was a per-call-site + * `LegacyShadowCacheOpts.webhooks` the `use` phase's provisioner was merely trusted to match. + * + * Required, with no default: a caller must state its engine's real policy — see + * `legacy-shadow-source.ts`'s `legacyShadowSourceWebhooksPolicy` for the per-engine answer. + */ + readonly webhooks: LegacyShadowWebhooksPolicy; readonly apiAutoExposeNewTables: LegacySetupDatabaseInput["apiAutoExposeNewTables"]; readonly vault: LegacySetupDatabaseInput["vault"]; }; +/** + * The Webhooks/`pg_net` policy a shadow baseline can run under — {@link LegacySetupDatabaseOptions}'s + * own `webhooks` field with its optionality removed, since a shadow always states one explicitly + * ({@link LegacyShadowDbSetupInput.webhooks}). + */ +export type LegacyShadowWebhooksPolicy = NonNullable; + /** Common caller-supplied plumbing for {@link legacySetupShadowDatabase}/{@link legacyMigrateShadowDatabase}. */ interface LegacyShadowSetupRunInput { readonly fs: FileSystem.FileSystem; @@ -751,15 +784,18 @@ export const legacyBuildShadowSetupDatabaseInput = ( * container, and returns a second session opened against the restarted one. * * Shared by all three baseline-running shadow compositions — {@link legacySetupShadowDatabase} - * and {@link migrateShadowDatabase} here, plus `migration squash`'s own dump/apply/dump sequence - * (`migration/squash/squash.handler.ts`), which needs the baseline WITHOUT the template database - * (see {@link legacySetupShadowConn}'s own doc comment) and keeps the session open across its - * mid-sequence `pg_dump`s. + * and {@link legacyMigrateShadowDatabase} here, plus `migration squash`'s own dump/apply/dump + * sequence (`migration/squash/squash.handler.ts`), which needs the baseline WITHOUT the template + * database (see {@link legacySetupShadowConn}'s own doc comment) and keeps the session open + * across its mid-sequence `pg_dump`s. + * + * The Webhooks/`pg_net` policy {@link legacySetupDatabase} runs under is NOT a parameter: it is + * read off `input.setup.webhooks`, the same field the shadow baseline cache keys on — see + * {@link LegacyShadowDbSetupInput.webhooks}. */ export const legacyOpenShadowBaselineSession = ( spawner: Spawner, input: LegacyShadowSetupRunInput, - options: LegacySetupDatabaseOptions = {}, baseline: LegacyShadowBaselineState = LEGACY_SHADOW_BASELINE_COLD, ): Effect.Effect< LegacyDbSession, @@ -777,7 +813,7 @@ export const legacyOpenShadowBaselineSession = ( yield* legacySetupDatabase( spawner, legacyBuildShadowSetupDatabaseInput(input, setupSession, resolved), - options, + { webhooks: input.setup.webhooks }, ).pipe( // The baseline's batched SQL files check their own connection out of the pool; // failing to acquire one is a shadow CONNECT failure, like @@ -798,7 +834,7 @@ export const legacyOpenShadowBaselineSession = ( yield* legacySetupDatabase( spawner, legacyBuildShadowSetupDatabaseInput(input, session, resolved), - options, + { webhooks: input.setup.webhooks }, ).pipe( // Same pooled-connection failure mapping as the snapshotting branch above. Effect.catchTag("LegacyDbConnectError", (cause) => @@ -819,12 +855,14 @@ export const legacyOpenShadowBaselineSession = ( * `baseline` defaults to {@link LEGACY_SHADOW_BASELINE_COLD}. A warm shadow-cache hit skips the * prelude + `SetupDatabase` (the restored cluster already has them) and only recreates * `contrib_regression`; a cache-enabled COLD provision snapshots between the baseline and the - * template, matching {@link migrateShadowDatabase}. + * template, matching {@link legacyMigrateShadowDatabase}. + * + * The baseline's Webhooks/`pg_net` policy comes from `input.setup.webhooks` — see + * {@link LegacyShadowDbSetupInput.webhooks}. */ export const legacySetupShadowDatabase = ( spawner: Spawner, input: LegacyShadowSetupRunInput, - options: LegacySetupDatabaseOptions = {}, baseline: LegacyShadowBaselineState = LEGACY_SHADOW_BASELINE_COLD, ): Effect.Effect< void, @@ -833,7 +871,7 @@ export const legacySetupShadowDatabase = ( > => Effect.scoped( Effect.gen(function* () { - const session = yield* legacyOpenShadowBaselineSession(spawner, input, options, baseline); + const session = yield* legacyOpenShadowBaselineSession(spawner, input, baseline); yield* legacyCreateShadowTemplateDatabase(session); }), ); @@ -915,11 +953,16 @@ export const LEGACY_SHADOW_BASELINE_COLD: LegacyShadowBaselineState = { * The one structural divergence from Go is confined to the SNAPSHOTTING cold branch and is owned * by {@link legacyOpenShadowBaselineSession} — see its doc comment. The SQL every path issues is * unchanged. + * + * ONE function for every migrate-shadow caller, legacy engine and pg-delta next alike: the + * per-engine Webhooks/`pg_net` difference (legacy forces it on, next follows config) is not a + * behavior of this function but a value on `input.setup.webhooks`, chosen by the caller via + * `legacyShadowSourceWebhooksPolicy` (`legacy-shadow-source.ts`) and hashed into the shadow + * baseline cache key — see {@link LegacyShadowDbSetupInput.webhooks}. */ -const migrateShadowDatabase = ( +export const legacyMigrateShadowDatabase = ( spawner: Spawner, input: LegacyShadowSetupRunInput, - setupOptions: LegacySetupDatabaseOptions, baseline: LegacyShadowBaselineState = LEGACY_SHADOW_BASELINE_COLD, ): Effect.Effect< void, @@ -939,12 +982,7 @@ const migrateShadowDatabase = ( ), ); - const session = yield* legacyOpenShadowBaselineSession( - spawner, - input, - setupOptions, - baseline, - ); + const session = yield* legacyOpenShadowBaselineSession(spawner, input, baseline); yield* legacyCreateShadowTemplateDatabase(session); yield* legacyApplyMigrations( session, @@ -962,33 +1000,3 @@ const migrateShadowDatabase = ( ); }), ); - -/** - * Migrates a shadow for migra and the legacy pg-delta engine. Those Go-backed - * workflows historically include `pg_net` in the platform baseline regardless of - * project config, so preserve that baseline while sharing the native TS setup path. - */ -export const legacyMigrateShadowDatabase = ( - spawner: Spawner, - input: LegacyShadowSetupRunInput, - baseline: LegacyShadowBaselineState = LEGACY_SHADOW_BASELINE_COLD, -): Effect.Effect< - void, - LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, - Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection -> => migrateShadowDatabase(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 `legacySetupDatabase`'s - * default options. - */ -export const legacyMigrateNextShadowDatabase = ( - spawner: Spawner, - input: LegacyShadowSetupRunInput, - baseline: LegacyShadowBaselineState = LEGACY_SHADOW_BASELINE_COLD, -): Effect.Effect< - void, - LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, - Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection -> => migrateShadowDatabase(spawner, input, {}, baseline); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts index 3a785d7383..5ce25ce5d7 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts @@ -23,7 +23,6 @@ import { legacyConnectShadowDatabase, legacyCreateShadowDatabase, legacyMigrateShadowDatabase, - legacyMigrateNextShadowDatabase, legacyRemoveShadowDatabase, legacySetupShadowConn, legacySetupShadowDatabase, @@ -420,6 +419,9 @@ function baseShadowSetup( majorVersion: 17, config: defaultConfig, webhooksEnabled: false, + // The engine policy every baseline provisioner reads (and the cache keys on) — default to + // the config-following one, and let a scenario state its own. + webhooks: "config", dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", jwks: Effect.succeed('{"keys":[]}') as Effect.Effect, @@ -441,7 +443,8 @@ function baseShadowSetup( }; } -function migrateNextShadow(webhooksEnabled: boolean) { +/** A migrate-shadow run under the config-following policy pg-delta next's shadows state. */ +function migrateConfigFollowingShadow(webhooksEnabled: boolean) { const { session, calls } = fakeSession(); const workdir = tempRoot.current; const mock = mockSpawner(); @@ -449,7 +452,7 @@ function migrateNextShadow(webhooksEnabled: boolean) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(path.join(workdir, "supabase", "migrations"), { recursive: true }); - yield* legacyMigrateNextShadowDatabase(mock.spawner, { + yield* legacyMigrateShadowDatabase(mock.spawner, { fs, path, workdir, @@ -463,7 +466,7 @@ function migrateNextShadow(webhooksEnabled: boolean) { password: "postgres", database: "postgres", }, - setup: baseShadowSetup({ webhooksEnabled }), + setup: baseShadowSetup({ webhooksEnabled, webhooks: "config" }), }); }).pipe( Effect.provide( @@ -599,7 +602,9 @@ describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { password: "postgres", database: "postgres", }, - setup: baseShadowSetup(), + // The legacy engine's forced-on baseline (`legacyShadowSourceWebhooksPolicy`), which + // installs `pg_net` even though `webhooksEnabled` is false. + setup: baseShadowSetup({ webhooks: "enabled" }), }); expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); expect(calls.some((c) => c.sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(true); @@ -618,8 +623,8 @@ describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { }, ); - it.effect("next migrated shadows keep pg_net activation config-gated", () => { - const { calls, effect } = migrateNextShadow(false); + it.effect("config-following migrated shadows keep pg_net activation config-gated", () => { + const { calls, effect } = migrateConfigFollowingShadow(false); return effect.pipe( Effect.tap(() => Effect.sync(() => { @@ -630,9 +635,9 @@ describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { }); it.effect( - "next migrated shadows install pg_net when effective Webhooks config is enabled", + "config-following migrated shadows install pg_net when effective Webhooks config is enabled", () => { - const { calls, effect } = migrateNextShadow(true); + const { calls, effect } = migrateConfigFollowingShadow(true); return effect.pipe( Effect.tap(() => Effect.sync(() => { @@ -653,33 +658,31 @@ describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* legacySetupShadowDatabase( - 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: 14, - realtimeEnabledForSetup: true, - jwks: Effect.sync(() => { - jwksEvaluated = true; - return '{"keys":[]}'; - }), - }), + yield* legacySetupShadowDatabase(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", }, - { webhooks: "disabled" }, - ); + setup: baseShadowSetup({ + majorVersion: 14, + realtimeEnabledForSetup: true, + // pg-delta next's declarative shadow policy. + webhooks: "disabled", + jwks: Effect.sync(() => { + jwksEvaluated = true; + return '{"keys":[]}'; + }), + }), + }); expect(jwksEvaluated).toBe(false); expect(calls.some((call) => call.sql === "drop extension if exists pg_net")).toBe(true); }).pipe( @@ -776,7 +779,6 @@ describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { }), }), }, - {}, { baselinePresent: true, snapshotRequired: false, diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index 5d32e9fda5..f55fc7fd77 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -26,12 +26,12 @@ import { legacyWaitForShadowReady } from "./db-bootstrap/health-check.ts"; import { legacyWithShadowDatabase, type LegacyShadowAcquiredHandle, - type LegacyShadowCacheOpts, } from "./db-bootstrap/shadow-cache.ts"; import { legacySetupShadowDatabase, legacyShadowRunInputFromLocalContainerInputs, type LegacyShadowSetupInput, + type LegacyShadowWebhooksPolicy, } from "./db-bootstrap/shadow-database.ts"; import { legacyPgDeltaTempPath } from "./legacy-pgdelta.paths.ts"; import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; @@ -770,9 +770,17 @@ const exportViaShadowCatalog = ( shadowInput: LegacyShadowSetupInput, ) => Effect.Effect, persist: (snapshot: string) => Effect.Effect, - // No default: every caller must declare its provisioner's effective webhooks policy, or the - // key records config-following while the baseline forced `pg_net` on (review: Codex on #6184). - shadowCacheOpts: LegacyShadowCacheOpts, + opts: { + /** + * The baseline policy this caller's own `provision` really runs under — no default, so a + * new caller cannot inherit a policy it never applies. Lands on `setup.webhooks`, which is + * what the provisioner applies AND what the shadow baseline cache keys on (see + * `LegacyShadowDbSetupInput.webhooks`, `db-bootstrap/shadow-database.ts`). + */ + readonly webhooks: LegacyShadowWebhooksPolicy; + /** `db schema declarative sync --no-cache`'s bypass — see `LegacyShadowCacheOpts`. */ + readonly bypassCache?: boolean; + }, ) => Effect.gen(function* () { const { spawner, localInputs } = built; @@ -781,6 +789,7 @@ const exportViaShadowCatalog = ( localInputs, resolvedImage, toml, + opts.webhooks, fs, path, ); @@ -789,8 +798,9 @@ const exportViaShadowCatalog = ( // `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`. + // call sites make. The only per-invocation cache control left here is `sync --no-cache`'s + // bypass; the Webhooks policy rides on `shadowInput.setup` above — see + // `LegacyShadowCacheOpts`. const written = yield* legacyWithShadowDatabase( spawner, shadowInput, @@ -803,7 +813,7 @@ const exportViaShadowCatalog = ( }); return yield* persist(snapshot); }), - shadowCacheOpts, + opts.bypassCache === true ? { bypassCache: true } : {}, ); return path.relative(ctx.cwd, written); }); @@ -890,12 +900,9 @@ export const legacyResolveMigrationsCatalogRef = Effect.fnUntraced(function* ( timestamp, ); }), - // Same forced-on policy {@link legacyGetMigrationsCatalogRef} passes below, for the same - // reason: `legacyProvisionMigrationsShadow` migrates through `legacyMigrateShadowDatabase`, - // whose baseline installs `pg_net` regardless of `config.toml`. Leaving this at the - // config-following default would key the published tar as `webhooks_enabled=false` on a - // webhooks-disabled project even though the snapshotted cluster HAS `pg_net` — poisoning - // every other config-following consumer of that key. + // Forced-on, same as {@link legacyGetMigrationsCatalogRef} below: + // `legacyProvisionMigrationsShadow` goes through `legacyPrepareShadowSource`'s legacy + // engine, whose baseline installs `pg_net` regardless of `config.toml`. { webhooks: "enabled" }, ); }); @@ -1004,10 +1011,12 @@ export const legacyGetMigrationsCatalogRef = Effect.fnUntraced(function* ( timestamp, ); }), - // `--no-cache` promises "force fresh shadow database setup" (`declarative.shared.ts`), so it - // must ALSO bypass the shadow baseline snapshot, not just the catalog cache — otherwise a - // warm tar would skip the very setup the flag exists to force (review: Codex on #6184). - { bypassCache: params.noCache, webhooks: "enabled" }, + // Forced-on for the same reason as {@link legacyResolveMigrationsCatalogRef} above (the + // legacy engine's `pg_net` baseline). `--no-cache` promises "force fresh shadow database + // setup" (`declarative.shared.ts`), so it must ALSO bypass the shadow baseline snapshot, not + // just the catalog cache — otherwise a warm tar would skip the very setup the flag exists to + // force (review: Codex on #6184). + { webhooks: "enabled", bypassCache: params.noCache }, ); }); @@ -1097,7 +1106,6 @@ const legacyProvisionBaselineShadow = ( connConfig, setup: shadowInput.setup, }, - {}, handle, ); return { sourceUrl: legacyToPostgresURL(connConfig) } satisfies LegacyProvisionedShadow; @@ -1145,7 +1153,6 @@ const legacyProvisionDeclarativeShadow = ( connConfig, setup: shadowInput.setup, }, - {}, handle, ); const targetUrl = legacyToPostgresURL(connConfig); @@ -1234,7 +1241,12 @@ export const legacyExportBaselineCatalogRef = ( snapshot, ) : legacyWriteCatalogFile(fs, tempDir, cachePath, snapshot), - { bypassCache: params.noCache, webhooks: "config" }, + { + // Config-following: these two provisions run the bare platform baseline through + // `legacySetupShadowDatabase`, which has no `pg_net` override of its own. + webhooks: "config", + bypassCache: params.noCache, + }, ); }); @@ -1325,6 +1337,11 @@ export const legacyExportDeclarativeCatalogRef = ( timestamp, ); }), - { bypassCache: params.noCache, webhooks: "config" }, + { + // Config-following: these two provisions run the bare platform baseline through + // `legacySetupShadowDatabase`, which has no `pg_net` override of its own. + webhooks: "config", + bypassCache: params.noCache, + }, ); });