Skip to content
Draft
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
5 changes: 5 additions & 0 deletions docs/docs/plugins/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ public column, list with an explicit non-empty `order`. A keyless table with no
sortable columns has no list route. `upsert` is available to server code but has
no generated HTTP route.

Tables live in the Postgres schema `app` unless the second argument of
`defineSchema()` names another one, for example `{ schemaName: "public" }`. The
default is not `public` because the deployed app's service principal creates and
owns `app`, but has no `CREATE` privilege on `public`.

## Schema discovery and overrides

`database()` and `database({})` use the same defaults. During setup, the plugin
Expand Down
3 changes: 2 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"vitest",
"@databricks/sdk-experimental",
"@mlflow/core",
"autoevals"
"autoevals",
"drizzle-kit"
]
},
"packages/appkit-ui": {
Expand Down
11 changes: 11 additions & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
"development": "./src/type-generator/index.ts",
"default": "./dist/type-generator/index.js"
},
"./database-tooling": {
"types": "./dist/database/tooling/index.d.ts",
"development": "./src/database/tooling/index.ts",
"default": "./dist/database/tooling/index.js"
},
"./testing": {
"types": "./dist/testing/index.d.ts",
"development": "./src/testing/index.ts",
Expand Down Expand Up @@ -108,6 +113,7 @@
"peerDependencies": {
"@mlflow/core": "0.4.0",
"autoevals": "0.3.0",
"drizzle-kit": "0.31.10",
"vitest": ">=3"
},
"peerDependenciesMeta": {
Expand All @@ -117,6 +123,9 @@
"autoevals": {
"optional": true
},
"drizzle-kit": {
"optional": true
},
"vitest": {
"optional": true
}
Expand All @@ -131,6 +140,7 @@
"@types/ws": "8.18.1",
"@vitejs/plugin-react": "5.1.1",
"autoevals": "0.3.0",
"drizzle-kit": "0.31.10",
"vitest": "3.2.4"
},
"overrides": {
Expand All @@ -143,6 +153,7 @@
"./beta": "./dist/beta.js",
"./dist/shared/src/plugin": "./dist/shared/src/plugin.d.ts",
"./type-generator": "./dist/type-generator/index.js",
"./database-tooling": "./dist/database/tooling/index.js",
"./testing": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
Expand Down
2 changes: 1 addition & 1 deletion packages/appkit/src/connectors/lakebase/endpoint-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ async function compareHosts(
}

/** Read every host the endpoint serves, read-write and read-only. */
function endpointHosts(response: unknown): string[] {
export function endpointHosts(response: unknown): string[] {
if (!response || typeof response !== "object") return [];
const status = Reflect.get(response, "status");
if (!status || typeof status !== "object") return [];
Expand Down
94 changes: 86 additions & 8 deletions packages/appkit/src/database/migrations/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator";
import type { Pool, PoolClient } from "pg";

import { createLogger } from "../../logging/logger";
import { ownershipHint } from "./ownership";
import { ownershipHint, quoteIdentifier } from "./ownership";
import {
BASELINE_TAG,
type Journal,
Expand Down Expand Up @@ -99,7 +99,7 @@ export class MigrationBaselineMismatchError extends Error {
constructor(schemaName: string, problems: readonly string[]) {
super(
`Cannot adopt ${BASELINE_TAG}: schema "${schemaName}" has no recorded migrations and only part of the baseline: ${problems.join("; ")}. ` +
"Create the missing tables and columns (or drop the partial ones), or regenerate the baseline against this database.",
"Create the missing tables and columns (or drop the partial ones), or regenerate the baseline with `appkit db introspect` against this database.",
);
this.name = "MigrationBaselineMismatchError";
this.problems = Object.freeze([...problems]);
Expand All @@ -122,10 +122,6 @@ function sqlStateOf(error: unknown): string | undefined {
return undefined;
}

function quoteIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}

/** One lock per app schema, so concurrent instances apply in turn. */
export function migrationLockKey(schemaName: string): string {
return createHash("sha256")
Expand Down Expand Up @@ -167,8 +163,7 @@ async function lastRecordedMigration(
*
* Known gap, shared with drizzle-orm: an entry older than the last recorded one
* (e.g. merged from a parallel branch) is never applied, and an applied
* migration edited later goes unnoticed. Comparing the recorded hashes with the
* journal would catch both.
* migration edited later goes unnoticed. strandedMigrations reports both.
*/
function pendingEntries(
journal: Journal,
Expand Down Expand Up @@ -391,6 +386,89 @@ async function recordMigrationsApplied(
}
}

/** Tags of the committed migrations the database has not recorded, oldest first. */
export async function pendingMigrationTags(
client: QueryClient,
options: ApplyMigrationsOptions,
): Promise<string[]> {
const journal = readJournal(options.migrationsFolder);
if (!journal) return [];
const last = await lastRecordedMigration(client, options.schemaName);
return pendingEntries(journal, last).map((entry) => entry.tag);
}

/** A committed migration drizzle-orm's migrator will never run on this database. */
export interface StrandedMigration {
readonly tag: string;
/**
* "out of order": older than the last recorded migration but not recorded
* (merged from a parallel branch). "edited": recorded, but the file changed
* since.
*/
readonly reason: "out of order" | "edited";
}

/**
* Committed migrations the migrator skips for good: it runs only entries newer
* than the last recorded one, and never compares hashes. Recorded rows carry
* the journal's `when` and the file's sha256, so each entry is matched by its
* `when`. Read-only.
*/
export async function strandedMigrations(
client: QueryClient,
options: ApplyMigrationsOptions,
): Promise<StrandedMigration[]> {
const journal = readJournal(options.migrationsFolder);
if (!journal) return [];
const last = await lastRecordedMigration(client, options.schemaName);
if (last === null) return [];
assertMigrationFiles(options.migrationsFolder, journal);
// One file per journal entry, in journal order.
const files = readMigrationFiles({
migrationsFolder: options.migrationsFolder,
});
const recorded = await client.query<{
hash: string;
created_at: string | number;
}>(
`select hash, created_at from ${quoteIdentifier(options.schemaName)}.${quoteIdentifier(MIGRATIONS_TABLE)}`,
);
const hashAt = new Map(
recorded.rows.map((row) => [Number(row.created_at), row.hash]),
);
const stranded: StrandedMigration[] = [];
journal.entries.forEach((entry, index) => {
// Newer entries are pending: the migrator runs them.
if (entry.when > last) return;
const hash = hashAt.get(entry.when);
if (hash === undefined) {
stranded.push({ tag: entry.tag, reason: "out of order" });
} else if (hash !== files[index]?.hash) {
stranded.push({ tag: entry.tag, reason: "edited" });
}
});
return stranded;
}

/** recordMigrationsApplied in one transaction under the migration lock (introspect on an AppKit branch). */
export async function markMigrationsApplied(
pool: Pick<Pool, "connect">,
options: ApplyMigrationsOptions,
tags: readonly string[],
): Promise<void> {
const journal = readJournal(options.migrationsFolder);
if (!journal) {
throw new MigrationFilesError(
`Cannot record ${tags.join(", ")} as applied: ${options.migrationsFolder} has no meta/_journal.json.`,
);
}
await withMigrationLock(pool, options.schemaName, (client) =>
inTransaction(client, () =>
recordMigrationsApplied(client, options, journal, tags),
),
);
}

/**
* Apply every committed migration the database has not recorded yet.
*
Expand Down
106 changes: 103 additions & 3 deletions packages/appkit/src/database/migrations/ownership.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Ownership model: each branch has one owner, the identity its migrations run
// as, and that identity owns the app's objects there. On the deploy branch it
// is the app's service principal, which creates the app schema on first start.
// as, and that identity owns the app's objects there:
// - deploy branch: the app's service principal, which creates the app schema
// on first start;
// - dev / session fork: the developer, once `appkit db init` hands over the
// objects the fork copied from its parent (Lakebase role API, reassign_owned_to).
// There is no shared owner role and no SET ROLE. A shared owner role is not
// possible: Lakebase rejects `GRANT … TO databricks_superuser`.

Expand All @@ -13,6 +16,103 @@ export function ownershipHint(code: string | undefined): string | undefined {
return (
"The connecting role lacks a privilege this migration needs: CREATE on the database, or ownership of the app schema and its tables. " +
"On the deploy branch that role is the app's service principal: bind the database to the app with CAN_CONNECT_AND_CREATE, " +
"and if another role created the schema, use another schemaName in schema.ts or have that role transfer the schema and its objects."
"and if another role created the schema, use another schemaName in schema.ts or have that role transfer the schema and its objects. " +
"On your dev or session branch it is you: `appkit db init` hands you the objects the branch copied from its parent."
);
}

/** A Postgres identifier in double quotes, with embedded quotes doubled. */
export function quoteIdentifier(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}

/** User schemas: everything except pg_* (pg_catalog, pg_toast, …) and information_schema. */
const USER_SCHEMAS = `n.nspname !~ '^pg_' and n.nspname <> 'information_schema'`;

/**
* Postgres roles, other than current_user, that own a schema, relation, type or
* function in this database. A fork copies its parent's objects with their
* owners; `appkit db init` hands each of these roles' objects to the developer
* (role API delete with reassign_owned_to). Roles the role API does not manage
* (pg_database_owner, databricks_control_plane, …) are filtered out by the caller.
*/
export const FOREIGN_OWNERS_SQL = `select distinct pg_catalog.pg_get_userbyid(o.owner)::text as owner
from (
select n.nspowner as owner from pg_catalog.pg_namespace n where ${USER_SCHEMAS}
union all
select c.relowner from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace where ${USER_SCHEMAS}
union all
select t.typowner from pg_catalog.pg_type t
join pg_catalog.pg_namespace n on n.oid = t.typnamespace where ${USER_SCHEMAS}
union all
select p.proowner from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace where ${USER_SCHEMAS}
) o
where o.owner <> (select r.oid from pg_catalog.pg_roles r where r.rolname = current_user)
order by 1`;

/**
* Remove every object in the app schema, including the migration journal. Runs
* as the branch's owner, in one transaction.
* - A non-public schema is dropped as a unit; the migrator recreates it.
* - `public` belongs to pg_database_owner and cannot be dropped, so each
* function, procedure, relation and standalone type in it that current_user
* owns is dropped, routines first. Sequences owned by a column (identity,
* serial) go with their table, and a table's row type goes with the table.
* Objects that belong to an extension are left to it.
*/
export function cleanStatements(schemaName: string): readonly string[] {
if (schemaName !== "public") {
return [`DROP SCHEMA IF EXISTS ${quoteIdentifier(schemaName)} CASCADE`];
}
return [
`DO $appkit$
DECLARE
r record;
BEGIN
FOR r IN
SELECT p.proname, pg_catalog.pg_get_function_identity_arguments(p.oid) AS args
FROM pg_catalog.pg_proc p
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND p.prokind IN ('f', 'p')
AND p.proowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND NOT EXISTS (
SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e')
LOOP
-- args is null when an earlier CASCADE already dropped the routine.
CONTINUE WHEN r.args IS NULL;
EXECUTE pg_catalog.format('DROP ROUTINE IF EXISTS public.%I(%s) CASCADE', r.proname, r.args);
END LOOP;
FOR r IN
SELECT c.relname, c.relkind FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public'
AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND NOT EXISTS (
SELECT 1 FROM pg_catalog.pg_depend d
WHERE d.classid = 'pg_catalog.pg_class'::regclass AND d.objid = c.oid AND d.deptype IN ('a', 'i'))
LOOP
EXECUTE pg_catalog.format('DROP %s IF EXISTS public.%I CASCADE',
CASE r.relkind WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED VIEW'
WHEN 'f' THEN 'FOREIGN TABLE' WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END,
r.relname);
END LOOP;
FOR r IN
SELECT t.typname FROM pg_catalog.pg_type t
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public'
AND t.typowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = current_user)
AND (t.typtype IN ('e', 'd')
OR (t.typtype = 'c' AND EXISTS (
SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c')))
LOOP
EXECUTE pg_catalog.format('DROP TYPE IF EXISTS public.%I CASCADE', r.typname);
END LOOP;
END
$appkit$`,
];
}
2 changes: 1 addition & 1 deletion packages/appkit/src/database/migrations/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function migrationsFolder(root: string): string {
}

/** Path of the journal inside a migrations folder. */
function journalFile(folder: string): string {
export function journalFile(folder: string): string {
return path.join(folder, "meta", "_journal.json");
}

Expand Down
Loading