Skip to content
Draft
20 changes: 11 additions & 9 deletions docs/docs/plugins/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,11 @@ checkout uses the file's value.
If a branch or database reference has no value in those files, `migrate` cannot
tell which branch the reference names. Examples are a target that leaves
`postgres_branch` unset and `${workspace.current_user.short_name}`. While any such
reference exists, `migrate` applies migrations to no branch. The message names
the target and where the reference is. To continue, give the reference a value
in `databricks.yml`, or in the `variable-overrides.json` the message names. A
`variable-overrides.json` that is not a JSON object stops `migrate` too.
reference exists, `migrate` applies migrations to no branch, and `init`,
`introspect`, `clean` and `seed` refuse every branch. The message names the
target and where the reference is. To continue, give the reference a value in
`databricks.yml`, or in the `variable-overrides.json` the message names. A
`variable-overrides.json` that is not a JSON object stops these commands too.

The check reads only this checkout's files. It does not see values passed at
deploy time with `--var` or `BUNDLE_VAR_*`, or resources defined in Python. It
Expand All @@ -157,11 +158,12 @@ protected branches.
### One command at a time

While `migrate`, `introspect`, `clean` or `init` runs, it holds
`config/database/.appkit-db.lock`. A second `appkit db` command in the same
checkout fails and names the process that holds the lock. The lock goes when
the command finishes or exits. `init` holds it for its whole run, including the
clean and migrate it starts. `init --schema none` changes no migration history,
so it takes no lock.
`config/database/.appkit-db.lock`. Another of these commands in the same
checkout fails and names the process that holds the lock. `seed`, `verify` and
`clean --delete-branch` change no migration history and take no lock. The lock
goes when the command finishes or exits. `init` holds it for its whole run,
including the clean and migrate it starts. `init --schema none` changes no
migration history, so it takes no lock.

A command stopped by a signal, such as SIGKILL, can leave the file behind. The
next command then names the holder. When that process no longer runs, the
Expand Down
3 changes: 3 additions & 0 deletions packages/appkit/src/database/migrations/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,8 @@ interface MigrationDrift {
readonly pending: readonly string[];
/** Committed, not recorded, older than the last recorded one. */
readonly stranded: readonly string[];
/** Recorded, but the committed file changed since. */
readonly edited: readonly string[];
/** Recorded rows no committed migration matches: the branch is ahead of the checkout. */
readonly unknown: number;
}
Expand All @@ -592,6 +594,7 @@ function driftOf(
return {
pending: tags(history.pending),
stranded: tags(history.stranded),
edited: tags(history.edited),
unknown: history.unknown.length,
};
}
Expand Down
19 changes: 12 additions & 7 deletions packages/appkit/src/database/migrations/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,20 @@ export function readJsonFile<T>(file: string): T {
}
}

/** null when meta/_journal.json is absent (nothing committed yet). */
export function readJournal(folder: string): Journal | null {
const file = journalFile(folder);
if (!fs.existsSync(file)) return null;
const parsed = readJsonFile<Partial<Journal>>(file);
if (parsed.dialect !== "postgresql" || !Array.isArray(parsed.entries)) {
/** A parsed meta/_journal.json, checked to be drizzle-kit's PostgreSQL journal; label names it in the error. */
export function parseJournal(json: unknown, label: string): Journal {
const parsed = json as Partial<Journal> | null;
if (parsed?.dialect !== "postgresql" || !Array.isArray(parsed.entries)) {
throw new MigrationFilesError(
`${file} is not a drizzle-kit PostgreSQL migration journal`,
`${label} is not a drizzle-kit PostgreSQL migration journal`,
);
}
return parsed as Journal;
}

/** null when meta/_journal.json is absent (nothing committed yet). */
export function readJournal(folder: string): Journal | null {
const file = journalFile(folder);
if (!fs.existsSync(file)) return null;
return parseJournal(readJsonFile<unknown>(file), file);
}
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ describe.skipIf(!url)("applyPendingMigrations against Postgres", () => {
expect(await readMigrationDrift(newPool(), options(folder))).toEqual({
pending: ["0000_notes", "0001_introspect"],
stranded: [],
edited: [],
unknown: 0,
});

Expand All @@ -450,6 +451,7 @@ describe.skipIf(!url)("applyPendingMigrations against Postgres", () => {
expect(await readMigrationDrift(newPool(), options(folder))).toEqual({
pending: ["0001_introspect"],
stranded: [],
edited: [],
unknown: 1,
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/appkit/src/database/tooling/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface ToolingWorkspaceClient {
readonly currentUser: {
me(request: { attributes?: string }): Promise<{ userName?: string }>;
};
/** The resolved workspace host, which init writes to a new .env. */
/** The resolved workspace host, which init writes to a new .env and verify compares with a target's host. */
getHost(): Promise<string>;
}

Expand Down
70 changes: 54 additions & 16 deletions packages/appkit/src/database/tooling/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,57 @@ interface Classification {

/**
* Why databricks.yml makes a branch one it deploys to; null when it does not.
* A reference the files cannot resolve may name any branch, so it refuses all.
* A reference the files cannot resolve may name any branch, so it refuses all;
* everyBranchRefusal names the references.
*/
export function deployRefusal(
boundary: DeployBoundary,
branch: string,
): string | null {
if (boundary.unresolved.length > 0) {
const references = boundary.unresolved
.map(
({ target, at, written, file }) =>
`${at} in target ${target}${file ? ` with ${file}` : ""} (${written ? JSON.stringify(written) : "no value"})`,
)
.join(", ");
const where = boundary.unresolved.some(({ file }) => file)
? "databricks.yml or the variable-overrides.json named"
: "databricks.yml";
return `databricks.yml may deploy to it: the files do not resolve ${references}; give ${boundary.unresolved.length > 1 ? "them values" : "it a value"} in ${where}`;
return "databricks.yml may deploy to it through a reference this checkout's files do not resolve";
}
return boundary.branches.has(branch)
? "it is a branch databricks.yml deploys to"
: null;
}

/**
* While databricks.yml has a reference this checkout's files cannot resolve,
* any branch may be one it deploys to: `migrate` applies migrations to no
* branch, and `init`, `introspect`, `clean` and `seed` refuse every branch.
* This says so, names the references and where to give them values; null when
* every reference resolves.
*/
export function everyBranchRefusal(boundary: DeployBoundary): string | null {
if (boundary.unresolved.length === 0) return null;
const references = boundary.unresolved
.map(
({ target, at, written, file }) =>
`${at} in target ${target}${file ? ` with ${file}` : ""} (${written ? JSON.stringify(written) : "no value"})`,
)
.join(", ");
const where = boundary.unresolved.some(({ file }) => file)
? "databricks.yml or the variable-overrides.json named"
: "databricks.yml";
return `The files do not resolve ${references}, so \`appkit db migrate\` applies migrations to no branch, and \`init\`, \`introspect\`, \`clean\` and \`seed\` refuse every branch. Give ${boundary.unresolved.length > 1 ? "them values" : "it a value"} in ${where}`;
}

/**
* Why a command writes nothing to a branch: `why`, then `advice` when it
* applies. While every branch is refused (see everyBranchRefusal), advice that
* leads to `appkit db init` or another branch cannot help, so the text names
* the references to resolve instead.
*/
export function writeRefusal(
why: string,
boundary: DeployBoundary,
advice?: string,
): string {
const next = everyBranchRefusal(boundary) ?? advice;
return next ? `${why}. ${next}` : why;
}

export function classifyBranch(input: {
branch: LakebaseBranch;
userName: string;
Expand Down Expand Up @@ -86,14 +114,19 @@ export function takesWrites(kind: BranchKind): boolean {
return kind === "dev" || kind === "session";
}

/** The .env branch and what it is to this developer. */
/** The .env branch, what it is to this developer, and the boundary that decided it. */
interface EnvBranch extends Classification {
readonly branch: LakebaseBranch;
readonly boundary: DeployBoundary;
}

/** Why a command does not write to the branch. */
export function notYourBranch(env: EnvBranch): string {
return `${resourceId(env.branch.name)} is not your dev or session branch (${env.reason})`;
/** Why a command does not write to the .env branch, then `advice` when it applies (see writeRefusal). */
export function notYourBranch(env: EnvBranch, advice?: string): string {
return writeRefusal(
`${resourceId(env.branch.name)} is not your dev or session branch (${env.reason})`,
env.boundary,
advice,
);
}

/**
Expand All @@ -113,11 +146,16 @@ export async function resolveEnvBranch(
]);
if (!branch) {
throw new Error(
`The .env branch ${name} does not exist (deleted or expired). Run \`appkit db init\` to create or pick one.`,
`${writeRefusal(
`The .env branch ${name} does not exist (deleted or expired)`,
boundary,
"Run `appkit db init` to create or pick one",
)}.`,
);
}
return {
branch,
boundary,
...classifyBranch({
branch,
userName,
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/src/database/tooling/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export {
export { type MigrateOptions, type MigrateReport, runMigrate } from "./migrate";
export { runSeed } from "./seed";
export type { BranchMode } from "./targets";
export { runVerify, type VerifyOptions, type VerifyReport } from "./verify";
15 changes: 12 additions & 3 deletions packages/appkit/src/database/tooling/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
runCommand,
} from "./drizzle-kit-install";
import { refuseSymlink, updateEnvFile } from "./env-file";
import { classifyBranch, deployRefusal, takesWrites } from "./guard";
import {
classifyBranch,
deployRefusal,
takesWrites,
writeRefusal,
} from "./guard";
import { handOffAppSchema } from "./hand-off";
import { type HistoryLock, withHistoryLock } from "./history-lock";
import { currentIdentity, type Identity } from "./identity";
Expand Down Expand Up @@ -205,6 +210,10 @@ function branchLimitError(
return new Error(message, { cause });
}

/** What init says after refusing a branch, unless every branch is refused. */
const INIT_SETS_UP =
"`appkit db init` only sets up your dev branch or this checkout's session branch";

/**
* Your dev branch or this checkout's session branch: found with one GET, or
* forked from the project's default branch. The branch id is resolved and
Expand Down Expand Up @@ -235,7 +244,7 @@ async function findOrCreateBranch(
});
if (!takesWrites(verdict.kind)) {
throw new Error(
`Refusing to set up ${resourceId(branch.name)}: ${verdict.reason}. \`appkit db init\` only sets up your dev branch or this checkout's session branch.`,
`Refusing to set up ${writeRefusal(`${resourceId(branch.name)}: ${verdict.reason}`, boundary, INIT_SETS_UP)}.`,
);
}
onStep(`Using ${resourceId(branch.name)}`);
Expand All @@ -261,7 +270,7 @@ async function findOrCreateBranch(
const refusal = deployRefusal(boundary, name);
if (refusal) {
throw new Error(
`Refusing to set up ${resourceId(name)}: ${refusal}. \`appkit db init\` only sets up your dev branch or this checkout's session branch.`,
`Refusing to set up ${writeRefusal(`${resourceId(name)}: ${refusal}`, boundary, INIT_SETS_UP)}.`,
);
}
const existing = await api.getBranch(name);
Expand Down
7 changes: 5 additions & 2 deletions packages/appkit/src/database/tooling/introspect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,11 @@ export async function introspectHolding(
if (!env) throw new Error(`Did not introspect: ${NO_ENV_BRANCH}.`);
if (!takesWrites(env.kind)) {
throw new IntrospectRefusedError(
`Refusing to introspect: ${notYourBranch(env)}. Introspect records the migration it writes as applied, so it runs only on branches \`appkit db init\` created for you. ` +
`Run \`appkit db init\`: it creates your dev branch from the project's default branch, and introspects it when there is no ${DEFAULT_SCHEMA_FILE}.`,
`Refusing to introspect: ${notYourBranch(
env,
"Introspect records the migration it writes as applied, so it runs only on branches `appkit db init` created for you. " +
`Run \`appkit db init\`: it creates your dev branch from the project's default branch, and introspects it when there is no ${DEFAULT_SCHEMA_FILE}`,
)}.`,
);
}
const branchId = resourceId(env.branch.name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,11 @@ export const schema = defineSchema(({ table }) => {
});
`;

const onDevBranch = { branch: { name: DEV }, kind: "dev" };
const RESOLVED = { branches: new Set<string>(), unresolved: [] };
const onDevBranch = { branch: { name: DEV }, boundary: RESOLVED, kind: "dev" };
const onProduction = {
branch: { name: PRODUCTION, status: { default: true } },
boundary: RESOLVED,
kind: "default",
reason: "it is the project's default branch",
};
Expand Down Expand Up @@ -284,6 +286,37 @@ describe("runIntrospect", () => {
},
);

test("while databricks.yml leaves a reference unresolved, the refusal names it instead of pointing to appkit db init", async () => {
resolveEnvBranch.mockResolvedValue({
branch: { name: DEV },
boundary: {
branches: new Set<string>(),
unresolved: [
{
target: "default",
at: "variables.postgres_branch",
written: "",
},
],
},
kind: "deploy-target",
reason:
"databricks.yml may deploy to it through a reference this checkout's files do not resolve",
});
const root = tempApp({ [SCHEMA]: previousSchema });
const before = tree(root);

const error = await refusal(root);

expect(error).toBeInstanceOf(IntrospectRefusedError);
expect(error.message).toBe(
"Refusing to introspect: dev-alice-3f9a2c is not your dev or session branch (databricks.yml may deploy to it through a reference this checkout's files do not resolve). " +
"The files do not resolve variables.postgres_branch in target default (no value), so `appkit db migrate` applies migrations to no branch, and `init`, `introspect`, `clean` and `seed` refuse every branch. Give it a value in databricks.yml.",
);
expect(withBranchPool).not.toHaveBeenCalled();
expect(tree(root)).toEqual(before);
});

test("with history: writes and records the next NNNN_introspect", async () => {
const root = tempApp({ [SCHEMA]: declaredSchema });
await commitMigration(root);
Expand Down
6 changes: 5 additions & 1 deletion packages/appkit/src/database/tooling/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function hasSchemaFile(root: string): boolean {
const WITHOUT_SCHEMA = {
migrate: "there is nothing to migrate",
clean: "clean cannot tell which schema to drop",
verify: "there is nothing to verify",
} as const;

/** Without schema.ts, point to introspect instead of a load error. */
Expand Down Expand Up @@ -78,7 +79,10 @@ async function applyBlocker(ctx: DbContext): Promise<string | undefined> {
const env = await resolveEnvBranch(ctx);
if (!env) return NO_ENV_BRANCH;
if (takesWrites(env.kind)) return undefined;
return `${notYourBranch(env)}. Migrations are applied only to branches \`appkit db init\` created for you; the deployed app applies committed migrations to its own branch`;
return notYourBranch(
env,
"Migrations are applied only to branches `appkit db init` created for you; the deployed app applies committed migrations to its own branch",
);
}

/** Apply pending migrations to the `.env` branch, or say why they were not. */
Expand Down
Loading
Loading