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
2 changes: 1 addition & 1 deletion apps/dev-playground/client/src/routes/database.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ function DatabaseRoute() {
<Header
title="Database"
description="Declare a Postgres schema once and get typed entities, generated CRUD routes, and transactional hooks — without writing a controller."
tooltip="The DatabasePlugin reads an explicit schema. It never creates, migrates, or introspects tables."
tooltip="The DatabasePlugin reads an explicit schema. It never creates or migrates tables; at startup it checks that every declared table and column exists."
/>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
Expand Down
8 changes: 6 additions & 2 deletions docs/docs/plugins/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,12 @@ server routes. App admission alone does not provide row-level isolation.

Configure a Lakebase `postgres` resource and its connection environment variables
as described in [Lakebase configuration](./lakebase.md#environment-variables).
The database tables must already exist and match the declared schema. This plugin
checks connectivity during setup; it does not create or migrate tables.

When a query fails at runtime, the client receives only a stable message such as
`Database operation failed`. The server log records the SQLSTATE and only
identifier-only messages for known missing-column or missing-table errors
(for example `column notes.board_id does not exist`). Other driver messages,
details, and hints are omitted because they may contain row values.

Apps scaffolded with the Database plugin selected include an empty
`config/database/schema.ts`, so `database()` can start without requiring sample
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/plugins/lakebase.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ env:

For local development, the `.env` file is automatically generated by `databricks apps init` with the correct values for your Lakebase project.

For OAuth pools, `PGHOST` must be a host of `LAKEBASE_ENDPOINT`. Credentials are issued for the endpoint, but the pool connects to `PGHOST`, so a host left over from another branch can serve that branch's tables. At startup, `lakebase()` and `database()` look the endpoint up; a confirmed mismatch fails setup and names both hosts. Native password pools skip this check. If the lookup fails or takes more than three seconds, AppKit warns that it could not verify the host but does not block startup. A missing endpoint produces a warning. When you switch branches, update both variables; `databricks postgres list-endpoints projects/{project}/branches/{branch}` shows the endpoint name and its host. Direct `createLakebasePool()` calls do not perform this asynchronous check.

For the full configuration reference (SSL, pool size, timeouts, logging, ORM examples), see the [`@databricks/lakebase` README](https://github.com/databricks/appkit/blob/main/packages/lakebase/README.md).

### Pool configuration
Expand Down
149 changes: 149 additions & 0 deletions packages/appkit/src/connectors/lakebase/endpoint-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { LakebasePoolConfig } from "@databricks/lakebase";

import { ConfigurationError } from "../../errors";
import { createLogger } from "../../logging/logger";
import { contextFromAbortSignal } from "../context";

const logger = createLogger("connectors:lakebase");

/** An unavailable lookup must not delay startup indefinitely. */
const HOST_CHECK_TIMEOUT_MS = 3_000;
const ENDPOINT_NAME =
/^projects\/[A-Za-z0-9._-]+\/branches\/[A-Za-z0-9._-]+\/endpoints\/[A-Za-z0-9._-]+$/;
const HOST_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$/;

/** Share in-flight lookups only between pools using the same workspace identity. */
type Client = NonNullable<LakebasePoolConfig["workspaceClient"]>;
const checks = new WeakMap<Client, Map<string, Promise<void>>>();

type HostCheckConfig = Pick<
Partial<LakebasePoolConfig>,
"endpoint" | "host" | "workspaceClient" | "password"
>;

/**
* Refuse a confirmed OAuth host mismatch before the pool can access another branch.
* An endpoint that cannot be read is not proof of a mismatch, so it skips the
* check without blocking startup.
*/
export function assertEndpointHostMatches(
config: HostCheckConfig,
): Promise<void> {
const endpoint = config.endpoint ?? process.env.LAKEBASE_ENDPOINT;
const host = config.host ?? process.env.PGHOST;
const client = config.workspaceClient;
if (
!endpoint ||
!host ||
!client ||
config.password !== undefined ||
!ENDPOINT_NAME.test(endpoint) ||
!HOST_NAME.test(host)
) {
return Promise.resolve();
}
const key = `${endpoint}\n${host.toLowerCase()}`;
let clientChecks = checks.get(client);
if (!clientChecks) {
clientChecks = new Map();
checks.set(client, clientChecks);
}
let check = clientChecks.get(key);
if (!check) {
check = compareHosts(client, endpoint, host).finally(() => {
clientChecks.delete(key);
});
clientChecks.set(key, check);
}
return check;
}

async function compareHosts(
client: NonNullable<HostCheckConfig["workspaceClient"]>,
endpoint: string,
host: string,
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
const controller = new AbortController();
let hosts: string[];
const warnUnverified = (reason: string) =>
logger.warn(
"Could not verify PGHOST %s against LAKEBASE_ENDPOINT %s (%s). Check the endpoint host manually before using this pool.",
host,
endpoint,
reason,
);
try {
const response = await Promise.race([
client.apiClient.request(
{
path: `/api/2.0/postgres/${endpoint}`,
method: "GET",
headers: new Headers({ Accept: "application/json" }),
raw: false,
},
contextFromAbortSignal(controller.signal),
),
new Promise<undefined>((resolve) => {
timer = setTimeout(() => {
controller.abort();
resolve(undefined);
}, HOST_CHECK_TIMEOUT_MS);
}),
]);
hosts = endpointHosts(response);
} catch (error) {
// An endpoint that no longer exists is itself the misconfiguration.
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
error.statusCode === 404
) {
logger.warn(
"LAKEBASE_ENDPOINT %s was not found. Check the project, branch, and endpoint names; `databricks postgres list-endpoints projects/{project}/branches/{branch}` lists them with their hosts.",
endpoint,
);
return;
}
warnUnverified(
controller.signal.aborted ? "lookup timed out" : "lookup failed",
);
return;
} finally {
clearTimeout(timer);
}
if (hosts.length === 0) {
warnUnverified(
controller.signal.aborted
? "lookup timed out"
: "endpoint returned no hosts",
);
return;
}
if (hosts.includes(host.toLowerCase())) return;
logger.error(
"PGHOST %s is not a host of LAKEBASE_ENDPOINT %s (expected %s). Refusing to connect to the wrong branch.",
host,
endpoint,
hosts.join(" or "),
);
throw new ConfigurationError(
`PGHOST ${host} is not a host of LAKEBASE_ENDPOINT ${endpoint} (expected ${hosts.join(" or ")}). Set PGHOST to a host of the configured endpoint before starting the app.`,
);
}

/** Read every host the endpoint serves, read-write and read-only. */
function endpointHosts(response: unknown): string[] {
if (!response || typeof response !== "object") return [];
const status = Reflect.get(response, "status");
if (!status || typeof status !== "object") return [];
const hosts = Reflect.get(status, "hosts");
if (!hosts || typeof hosts !== "object") return [];
return Object.values(hosts)
.filter(
(value): value is string =>
typeof value === "string" && HOST_NAME.test(value),
)
.map((value) => value.toLowerCase());
}
7 changes: 6 additions & 1 deletion packages/appkit/src/connectors/lakebase/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ServiceContext } from "../../context/service-context";
import { ConfigurationError } from "../../errors";
import { createLogger } from "../../logging/logger";
import { createWorkspaceClient } from "../../workspace-client";
import { assertEndpointHostMatches } from "./endpoint-host";

/**
* Create a Lakebase pool with appkit's logger integration.
Expand Down Expand Up @@ -47,7 +48,10 @@ export async function initializeLakebasePool(
: createWorkspaceClient({ clientOptions: getClientOptions() });
resolved.workspaceClient = client.toLegacyWorkspaceClient();
}
const user = await getUsernameWithApiLookup(resolved);
const [user] = await Promise.all([
getUsernameWithApiLookup(resolved),
assertEndpointHostMatches(resolved),
]);
if (!user) {
throw ConfigurationError.invalidConnection(
"Lakebase",
Expand All @@ -72,6 +76,7 @@ export {
type RequestedResource,
} from "@databricks/lakebase";

export { assertEndpointHostMatches } from "./endpoint-host";
export {
createLakebasePoolManager,
type LakebasePoolManager,
Expand Down
Loading
Loading