Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 40 additions & 41 deletions apps/cli/src/command-internal/db-bootstrap/shadow-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,9 @@ export interface ShadowSourceResult {
/** The diff source Postgres URL (the provisioned shadow). */
readonly sourceUrl: string;
/**
* When set, replaces the diff target with a second database on the same shadow container
* (`contrib_regression`, cloned from `postgres` during shadow setup — see
* {@link setupShadowConn}) with declarative schemas applied, so the user's local DB is never
* diffed directly in that branch.
* Legacy migra only. When set, replaces the diff target with `contrib_regression` on the
* same shadow container (cloned from `postgres` by {@link setupShadowConn}) after declarative
* schemas are applied. pg-delta leaves this unset and diffs `postgres`.
*/
readonly targetUrlOverride: string | undefined;
}
Expand Down Expand Up @@ -462,10 +461,9 @@ export const setupShadowConn = (
});

/**
* {@link setupShadowConn}'s trailing {@link SHADOW_CREATE_TEMPLATE_SQL} step on its own. Split
* out because it's the one part a warm shadow-cache hit still has to run: the cache's PGDATA
* snapshot is taken before this statement, so a restored cluster carries the platform baseline
* but no `contrib_regression`.
* Clones `contrib_regression` for the legacy migra shadow. The cache snapshot is taken
* before this statement, so a warm hit still runs it when that caller asks for the clone.
* pg-delta does not call this.
*/
const createShadowTemplateDatabase = (
session: DbSession,
Expand Down Expand Up @@ -547,14 +545,13 @@ export const buildShadowSetupDatabaseInput = <E>(
});

/**
* Connects to the shadow, then resolves the setup prelude (JWKS/pinned image names) and runs
* {@link setupShadowConn} — the platform baseline plus the template database, no user
* migrations. Connect-then-setup so an unconnectable shadow surfaces a connect error immediately
* rather than paying for JWKS work first. The connection closes once this resolves.
* Connects to the shadow, resolves the setup prelude, and applies the platform baseline.
* No user migrations. Does not create `contrib_regression`: pg-delta diffs this database,
* and `CREATE DATABASE … TEMPLATE` crashes OrioleDB when the baseline has an enum-indexed
* OrioleDB table.
*
* `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm cache hit skips the prelude and
* setup (the restored cluster already has them) and only recreates `contrib_regression`; a
* cache-enabled cold provision snapshots between the baseline and the template.
* `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm cache hit returns immediately.
* A cache-enabled cold provision snapshots after the baseline and does not reconnect.
*/
export const setupShadowDatabase = <E>(
spawner: Spawner,
Expand All @@ -568,7 +565,8 @@ export const setupShadowDatabase = <E>(
> =>
Effect.scoped(
Effect.gen(function* () {
if (!baseline.baselinePresent && baseline.snapshotRequired) {
if (baseline.baselinePresent) return;
if (baseline.snapshotRequired) {
yield* Effect.scoped(
Effect.gen(function* () {
const setupSession = yield* connectShadowDatabase(input.connConfig);
Expand All @@ -587,22 +585,20 @@ export const setupShadowDatabase = <E>(
}),
);
yield* baseline.snapshotBaseline;
return;
}
const session = yield* connectShadowDatabase(input.connConfig);
if (!baseline.baselinePresent && !baseline.snapshotRequired) {
const resolved = yield* resolveDbSetupPrelude(input.setup);
yield* setupDatabase(
spawner,
buildShadowSetupDatabaseInput(input, session, resolved),
options,
).pipe(
// Same connect-failure classification as the snapshot branch above.
Effect.catchTag("DbConnectError", (cause) =>
Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })),
),
);
}
yield* createShadowTemplateDatabase(session);
const resolved = yield* resolveDbSetupPrelude(input.setup);
yield* setupDatabase(
spawner,
buildShadowSetupDatabaseInput(input, session, resolved),
options,
).pipe(
// Same connect-failure classification as the snapshot branch above.
Effect.catchTag("DbConnectError", (cause) =>
Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })),
),
);
}),
);

Expand Down Expand Up @@ -646,19 +642,20 @@ const SHADOW_BASELINE_COLD: ShadowBaselineState = {

/**
* Lists local migrations first, so a bad migrations directory fails before any DB connection,
* then connects, resolves the setup prelude, and runs {@link setupShadowConn} (platform baseline
* plus template database) before applying every listed migration. Connect-then-setup for the
* same reason as {@link setupShadowDatabase}.
* then connects, resolves the setup prelude, and runs the platform baseline before applying
* every listed migration. `options.createTemplateDatabase` clones `contrib_regression` for the
* legacy engine only. Connect-then-setup for the same reason as {@link setupShadowDatabase}.
*
* `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm hit skips the prelude and setup; a
* cold cache-enabled provision snapshots between the baseline and the template. Only that
* cold cache-enabled provision snapshots between the baseline and later steps. Only that
* snapshotting branch splits sessions — see {@link ShadowBaselineState.snapshotRequired}.
*/
const migrateShadowDatabaseWith = <E>(
spawner: Spawner,
input: ShadowSetupRunInput<E>,
setupOptions: SetupDatabaseOptions,
baseline: ShadowBaselineState = SHADOW_BASELINE_COLD,
options: { readonly createTemplateDatabase?: boolean } = {},
): Effect.Effect<
void,
StartSetupLocalDatabaseError | ShadowDbError | ImagePrepullError | E,
Expand Down Expand Up @@ -697,8 +694,8 @@ const migrateShadowDatabaseWith = <E>(
}
const session = yield* connectShadowDatabase(input.connConfig);
if (!baseline.baselinePresent && !baseline.snapshotRequired) {
// The established single-session flow: baseline + template + migrations all on this
// one session — see this function's own doc comment.
// The established single-session flow: baseline and migrations share this session.
// A reconnect would pick up role-level defaults `roles.sql` just installed.
const resolved = yield* resolveDbSetupPrelude(input.setup);
yield* setupDatabase(
spawner,
Expand All @@ -711,7 +708,9 @@ const migrateShadowDatabaseWith = <E>(
),
);
}
yield* createShadowTemplateDatabase(session);
if (options.createTemplateDatabase !== false) {
yield* createShadowTemplateDatabase(session);
}
yield* applyMigrations(
session,
input.fs,
Expand Down Expand Up @@ -744,9 +743,9 @@ export const migrateShadowDatabase = <E>(
> => migrateShadowDatabaseWith(spawner, input, { webhooks: "enabled" }, baseline);

/**
* Migrates a shadow for the in-process pg-delta engine. Unlike the legacy engine,
* extension activation follows project config through `setupDatabase`'s
* default options.
* Migrates a shadow for the in-process pg-delta engine. Extension activation follows
* project config. Does not create `contrib_regression`: pg-delta never connects to it,
* and the clone crashes OrioleDB when the baseline has an enum-indexed OrioleDB table.
*/
export const migrateNextShadowDatabase = <E>(
spawner: Spawner,
Expand All @@ -756,4 +755,4 @@ export const migrateNextShadowDatabase = <E>(
void,
StartSetupLocalDatabaseError | ShadowDbError | ImagePrepullError | E,
Output | DockerRun | RuntimeInfo | DbConnection
> => migrateShadowDatabaseWith(spawner, input, {}, baseline);
> => migrateShadowDatabaseWith(spawner, input, {}, baseline, { createTemplateDatabase: false });
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ describe("buildShadowSetupDatabaseInput", () => {

describe("setupShadowDatabase / migrateShadowDatabase", () => {
it.effect(
"setupShadowDatabase connects, sets up the platform baseline, and creates the template database",
"setupShadowDatabase applies the platform baseline without creating contrib_regression",
() => {
const { session, calls } = fakeSession();
const workdir = tempRoot.current;
Expand All @@ -528,7 +528,8 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => {
},
setup: baseShadowSetup(),
});
expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(true);
expect(mock.spawned.length).toBeGreaterThan(0);
expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false);
}).pipe(
Effect.provide(
Layer.mergeAll(
Expand Down Expand Up @@ -596,6 +597,7 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => {
Effect.tap(() =>
Effect.sync(() => {
expect(calls.some((call) => call.sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(false);
expect(calls.some((call) => call.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false);
}),
),
);
Expand Down Expand Up @@ -654,6 +656,7 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => {
);
expect(jwksEvaluated).toBe(false);
expect(calls.some((call) => call.sql === "drop extension if exists pg_net")).toBe(true);
expect(calls.some((call) => call.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(false);
}).pipe(
Effect.provide(
Layer.mergeAll(
Expand Down Expand Up @@ -713,63 +716,60 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => {
);
});

it.effect(
"skips the platform baseline on a warm cache hit but still creates the template",
() => {
const { session, calls } = fakeSession();
const workdir = tempRoot.current;
const mock = mockSpawner();
let jwksEvaluated = false;
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* setupShadowDatabase(
mock.spawner,
{
fs,
path,
workdir,
projectId: "proj",
container: "shadow-container-id-0123456789abcdef",
networkId: "supabase_network_proj",
connConfig: {
host: "127.0.0.1",
port: 54320,
user: "postgres",
password: "postgres",
database: "postgres",
},
setup: baseShadowSetup({
majorVersion: 17,
realtimeEnabledForSetup: true,
jwks: Effect.sync(() => {
jwksEvaluated = true;
return '{"keys":[]}';
}),
}),
},
{},
{
baselinePresent: true,
snapshotRequired: false,
snapshotBaseline: Effect.void,
it.effect("skips the platform baseline and contrib_regression on a warm cache hit", () => {
const { session, calls } = fakeSession();
const workdir = tempRoot.current;
const mock = mockSpawner();
let jwksEvaluated = false;
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* setupShadowDatabase(
mock.spawner,
{
fs,
path,
workdir,
projectId: "proj",
container: "shadow-container-id-0123456789abcdef",
networkId: "supabase_network_proj",
connConfig: {
host: "127.0.0.1",
port: 54320,
user: "postgres",
password: "postgres",
database: "postgres",
},
);
expect(jwksEvaluated).toBe(false);
expect(calls.some((c) => c.sql === SHADOW_CREATE_TEMPLATE_SQL)).toBe(true);
}).pipe(
Effect.provide(
Layer.mergeAll(
BunServices.layer,
mockOutput().layer,
mockDockerRun(),
mockRuntimeInfo(),
mockDbConnection(session),
),
),
setup: baseShadowSetup({
majorVersion: 17,
realtimeEnabledForSetup: true,
jwks: Effect.sync(() => {
jwksEvaluated = true;
return '{"keys":[]}';
}),
}),
},
{},
{
baselinePresent: true,
snapshotRequired: false,
snapshotBaseline: Effect.void,
},
);
},
);
expect(jwksEvaluated).toBe(false);
expect(calls).toEqual([]);
}).pipe(
Effect.provide(
Layer.mergeAll(
BunServices.layer,
mockOutput().layer,
mockDockerRun(),
mockRuntimeInfo(),
mockDbConnection(session),
),
),
);
});

it.effect(
"migrateShadowDatabase lists local migrations BEFORE connecting, tolerating a missing migrations directory as an empty list rather than a failure",
Expand Down
7 changes: 3 additions & 4 deletions apps/cli/src/commands/db/shared/shadow-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,9 @@ export const prepareShadowSource = <E>(
image: input.image,
});

// `handle` doubles as the baseline state: on a warm shadow-cache hit it already holds the
// platform baseline (so only the template database + user migrations run); on a
// cache-enabled cold provision it carries the snapshot step that runs between the two — see
// `shadow-cache.ts`/`ShadowBaselineState`. An uncached acquire is always-cold.
// `handle` is the baseline state. A warm hit already has the platform baseline. A
// cache-enabled cold provision snapshots after that baseline. The legacy engine then
// creates `contrib_regression`; pg-delta does not. An uncached acquire is always-cold.
const migrateShadow =
input.migrationMode === "pgdelta-next" ? migrateNextShadowDatabase : migrateShadowDatabase;
yield* migrateShadow(
Expand Down
Loading