From 258804d25247676e90f6501aa21fe9d5b6fff04b Mon Sep 17 00:00:00 2001 From: avallete Date: Sun, 16 Aug 2026 14:51:28 +0200 Subject: [PATCH] refactor(cli): single-source the shadow webhooks policy on the setup input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shadow-baseline cache key and the provisioned baseline previously expressed the Webhooks/pg_net policy independently: callers passed LegacyShadowCacheOpts.webhooks for the key while the use phase picked a provisioner that applied its own policy (legacyMigrateShadowDatabase forced "enabled", legacyMigrateNextShadowDatabase followed config, legacySetupShadowDatabase took caller options). Nothing enforced agreement, and three of five call sites had drifted before the previous commit re-aligned them by hand. Make that drift class unrepresentable: LegacyShadowDbSetupInput now carries a required webhooks policy that BOTH the cache-key resolution and every baseline provisioner read, so a published snapshot's key can never describe a different baseline than the one provisioned. The old parallel surfaces are deleted outright: LegacyShadowCacheOpts.webhooks, legacyMigrateNextShadowDatabase (one migrate function remains), the LegacySetupDatabaseOptions params on legacyOpenShadowBaselineSession/ legacySetupShadowDatabase, and legacyPrepareShadowSource's migrate dispatch. The per-engine semantics — legacy migra/pg-delta force pg_net on (Go parity), pg-delta next follows config — are named once in legacyShadowSourceWebhooksPolicy(migrationMode), which db diff/db pull use when building their shadow inputs; pgadmin, squash, the catalog exports, and pg-delta next's shadows state their fixed policies at input construction. Effective policies, cache keys, and provisioned SQL are unchanged at every call site. Co-Authored-By: Claude Fable 5 --- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 4 +- .../legacy/commands/db/diff/diff.handler.ts | 201 +++++++++--------- .../commands/db/diff/diff.integration.test.ts | 11 +- .../legacy/commands/db/pull/pull.handler.ts | 118 +++++----- .../declarative/declarative.orchestrate.ts | 3 + .../schema/declarative/sync/SIDE_EFFECTS.md | 2 +- .../legacy-pgdelta-next-shadow.layer.ts | 44 ++-- .../shared/legacy-pgdelta-next-shadow.plan.ts | 3 +- .../db/shared/legacy-shadow-source.ts | 28 ++- .../commands/migration/squash/SIDE_EFFECTS.md | 7 +- .../migration/squash/squash.handler.ts | 12 +- .../shadow-cache.integration.test.ts | 56 +++-- .../db-bootstrap/shadow-cache.live.test.ts | 1 + .../shared/db-bootstrap/shadow-cache.ts | 49 +++-- .../db-bootstrap/shadow-cache.unit.test.ts | 2 - .../shared/db-bootstrap/shadow-database.ts | 104 ++++----- .../db-bootstrap/shadow-database.unit.test.ts | 74 +++---- .../src/legacy/shared/legacy-pgdelta.cache.ts | 57 +++-- 18 files changed, 418 insertions(+), 358 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 4f171ff695..02ed3e6b17 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 92cc2d9924..c9ed0bafe1 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -33,6 +33,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"; @@ -61,7 +62,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 { @@ -572,13 +576,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, ); @@ -620,7 +629,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, @@ -633,57 +645,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, @@ -702,65 +706,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 5aed03f4c9..cea4083457 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 @@ -1115,12 +1115,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 10a4bccc07..08a7abd9a8 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 @@ -206,6 +206,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 fef7ba82f9..562b0e3bab 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 @@ -138,7 +138,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 13e292da83..029657b629 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"; @@ -222,10 +223,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, @@ -233,6 +242,7 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( built.localInputs, built.image, { ...request.toml, shadowPort: port }, + webhooks, fs, path, ), @@ -291,7 +301,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; @@ -306,7 +316,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); @@ -322,29 +332,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 @@ -354,8 +360,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, @@ -380,11 +386,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 a79a60307a..021017ea28 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 fa5317b978..ee321a2633 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 @@ -45,7 +45,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({}); @@ -340,7 +344,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", @@ -359,6 +365,7 @@ const shadowSetup = (): LegacyShadowDbSetupInput => ({ projectEnvValues: undefined, debug: false, webhooksEnabled: false, + webhooks, apiAutoExposeNewTables: Option.some(true), vault: [], }); @@ -366,7 +373,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, @@ -385,7 +396,7 @@ const shadowInput = ( path, hostname: "127.0.0.1", healthTimeoutSeconds: 2, - setup: shadowSetup(), + setup: shadowSetup(overrides.webhooks), }); const shadowCacheDir = (path: Path.Path) => legacyShadowBaselineCacheDir(path); @@ -874,24 +885,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); @@ -1135,10 +1145,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.live.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.live.test.ts index 74c0b7f70a..fcfddd62a8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.live.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.live.test.ts @@ -82,6 +82,7 @@ describeLive("shadow baseline cache (live Docker)", () => { projectEnvValues: undefined, debug: false, webhooksEnabled: false, + webhooks: "config", apiAutoExposeNewTables: Option.some(true), vault: [], }; 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 180f1b23bd..06ecec4372 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts @@ -46,10 +46,7 @@ import { legacyGetRegistryImageUrl } from "../legacy-docker-registry.ts"; import { legacyShadowBaselineCacheDir } from "../legacy-pgdelta.paths.ts"; import { legacyParseBoolEnv } from "../legacy-diff-engine.ts"; import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../legacy-local-config-values.ts"; -import { - LEGACY_START_REVOKE_API_PRIVILEGES_SQL, - type LegacySetupDatabaseOptions, -} from "./db-setup.ts"; +import { LEGACY_START_REVOKE_API_PRIVILEGES_SQL } from "./db-setup.ts"; import { LEGACY_START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; import { LEGACY_START_DB_SUPABASE_SQL } from "./templates/db-supabase.sql.ts"; import { LEGACY_START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; @@ -70,6 +67,7 @@ import { type LegacyShadowBaselineState, LegacyShadowDbError, type LegacyShadowSetupInput, + type LegacyShadowWebhooksPolicy, } from "./shadow-database.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -180,10 +178,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; @@ -334,22 +333,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 @@ -366,7 +368,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 @@ -427,7 +428,7 @@ const legacyResolveShadowCacheKeyInputs = ( storageTargetMigration: input.setup.storageTargetMigration, autoExposeNewTables: input.setup.apiAutoExposeNewTables, webhooksEnabled: legacyEffectiveShadowWebhooksEnabled( - opts.webhooks, + input.setup.webhooks, input.setup.webhooksEnabled, ), rolesSql, @@ -823,21 +824,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; } @@ -878,7 +877,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( @@ -1061,7 +1060,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 95a5d224bc..49ee7c3d53 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 db1e4702b5..d5999ae4ed 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 @@ -663,10 +673,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; @@ -744,15 +777,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, @@ -770,7 +806,7 @@ export const legacyOpenShadowBaselineSession = ( yield* legacySetupDatabase( spawner, legacyBuildShadowSetupDatabaseInput(input, setupSession, resolved), - options, + { webhooks: input.setup.webhooks }, ); }), ); @@ -784,7 +820,7 @@ export const legacyOpenShadowBaselineSession = ( yield* legacySetupDatabase( spawner, legacyBuildShadowSetupDatabaseInput(input, session, resolved), - options, + { webhooks: input.setup.webhooks }, ); } return session; @@ -800,12 +836,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, @@ -814,7 +852,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); }), ); @@ -896,11 +934,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, @@ -920,12 +963,7 @@ const migrateShadowDatabase = ( ), ); - const session = yield* legacyOpenShadowBaselineSession( - spawner, - input, - setupOptions, - baseline, - ); + const session = yield* legacyOpenShadowBaselineSession(spawner, input, baseline); yield* legacyCreateShadowTemplateDatabase(session); yield* legacyApplyMigrations( session, @@ -936,33 +974,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 755070a546..971bc0d607 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, @@ -416,6 +415,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, @@ -437,7 +439,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(); @@ -445,7 +448,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, @@ -459,7 +462,7 @@ function migrateNextShadow(webhooksEnabled: boolean) { password: "postgres", database: "postgres", }, - setup: baseShadowSetup({ webhooksEnabled }), + setup: baseShadowSetup({ webhooksEnabled, webhooks: "config" }), }); }).pipe( Effect.provide( @@ -595,7 +598,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); @@ -614,8 +619,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(() => { @@ -626,9 +631,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(() => { @@ -649,33 +654,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( @@ -772,7 +775,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 3e4bf7b918..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,7 +770,17 @@ const exportViaShadowCatalog = ( shadowInput: LegacyShadowSetupInput, ) => Effect.Effect, persist: (snapshot: string) => Effect.Effect, - 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; @@ -779,6 +789,7 @@ const exportViaShadowCatalog = ( localInputs, resolvedImage, toml, + opts.webhooks, fs, path, ); @@ -787,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, @@ -801,7 +813,7 @@ const exportViaShadowCatalog = ( }); return yield* persist(snapshot); }), - shadowCacheOpts, + opts.bypassCache === true ? { bypassCache: true } : {}, ); return path.relative(ctx.cwd, written); }); @@ -888,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" }, ); }); @@ -1002,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 }, ); }); @@ -1095,7 +1106,6 @@ const legacyProvisionBaselineShadow = ( connConfig, setup: shadowInput.setup, }, - {}, handle, ); return { sourceUrl: legacyToPostgresURL(connConfig) } satisfies LegacyProvisionedShadow; @@ -1143,7 +1153,6 @@ const legacyProvisionDeclarativeShadow = ( connConfig, setup: shadowInput.setup, }, - {}, handle, ); const targetUrl = legacyToPostgresURL(connConfig); @@ -1232,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, + }, ); }); @@ -1323,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, + }, ); });