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
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,20 @@ const result = await pool.query('SELECT * FROM users');
**ORM Integration:**
Works with Drizzle, Sequelize, TypeORM - see the `@databricks/lakebase` README and `apps/dev-playground/server/lakebase-examples/` for examples.

### Database Plugin

Application-level layer over Lakebase (beta). Owns schema declaration, type generation, drift detection, auto-mounted CRUD routes, and a typed `db` browser client β€” all driven by `config/database/schema.ts`. See [`docs/docs/plugins/database.md`](./docs/docs/plugins/database.md) for the full guide.

```typescript
import { createApp, server } from '@databricks/appkit';
import { database } from '@databricks/appkit/beta';

const app = await createApp({ plugins: [server(), database()] });
const cases = await app.database.cases.where({ status: 'New' }).limit(50).toArray();
```

CLI: `npx appkit db init | introspect | migration generate <name> | migrate up | rls <entity> <spec> | seed | setup:dev | types generate | verify`.

### Frontend-Backend Interaction

```
Expand Down
19 changes: 19 additions & 0 deletions docs/docs/api/appkit/Function.createLakebasePostgrestClient.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Function: createLakebasePostgrestClient()

```ts
function createLakebasePostgrestClient(config: LakebasePostgrestClientConfig): unknown;
```

Create a Lakebase PostgREST client.

## Parameters

| Parameter | Type | Description |
| ------ | ------ | ------ |
| `config` | [`LakebasePostgrestClientConfig`](Interface.LakebasePostgrestClientConfig.md) | Configuration for creating a Lakebase PostgREST client. |

## Returns

`unknown`

A Lakebase PostgREST client.
20 changes: 20 additions & 0 deletions docs/docs/api/appkit/Function.enumeration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Function: enumeration()

```ts
function enumeration(name: string, values: readonly string[]): AppKitColumnChain;
```

Create an enum column.

## Parameters

| Parameter | Type | Description |
| ------ | ------ | ------ |
| `name` | `string` | The name of the enum. |
| `values` | readonly `string`[] | The values of the enum. |

## Returns

[`AppKitColumnChain`](Interface.AppKitColumnChain.md)

The wrapped column chain.
4 changes: 2 additions & 2 deletions docs/docs/api/appkit/Interface.DataPath.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ raw<T>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
```

Tagged-template SQL escape hatch. Values are bound as parameters; column
and identifier interpolation is intentionally not supported here β€” use
`getDrizzle()` from the plugin's exports for that case.
and identifier interpolation is intentionally not supported here β€” drop
to `appkit.database.getPool().query(...)` if you need that.

#### Type Parameters

Expand Down
46 changes: 46 additions & 0 deletions docs/docs/api/appkit/Interface.LakebasePostgrestClientConfig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Interface: LakebasePostgrestClientConfig

Configuration for creating a Lakebase PostgREST client.

## Properties

### dataApiUrl?

```ts
optional dataApiUrl: string;
```

***

### fetch()?

```ts
optional fetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
```

#### Parameters

| Parameter | Type |
| ------ | ------ |
| `input` | `string` \| `URL` \| `Request` |
| `init?` | `RequestInit` |

#### Returns

`Promise`\<`Response`\>

***

### resolveToken

```ts
resolveToken: LakebaseTokenResolver;
```

***

### schema?

```ts
optional schema: string;
```
5 changes: 5 additions & 0 deletions docs/docs/api/appkit/TypeAlias.LakebasePostgrestClient.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Type Alias: LakebasePostgrestClient

```ts
type LakebasePostgrestClient = unknown;
```
15 changes: 15 additions & 0 deletions docs/docs/api/appkit/TypeAlias.LakebaseTokenResolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Type Alias: LakebaseTokenResolver()

```ts
type LakebaseTokenResolver = () => Promise<string | null>;
```

A function that resolves a Lakebase token.

The default `database` plugin runtime no longer uses the Data API path,
so this is reserved for callers that opt into the PostgREST client
directly via `createLakebasePostgrestClient`.

## Returns

`Promise`\<`string` \| `null`\>
216 changes: 216 additions & 0 deletions docs/docs/plugins/database.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
---
sidebar_position: 4
---

# Database plugin (beta)

<!-- AUTO-GENERATED: stability-banner-start -->
:::warning Beta plugin
This plugin is currently **beta**. APIs may change between minor releases. Import from `@databricks/appkit/beta`. See [Plugin Stability Tiers](./stability.md).
:::
<!-- AUTO-GENERATED: stability-banner-end -->

The **database plugin** is the application-level layer over Lakebase. It owns
schema declaration, type generation, drift detection, auto-mounted CRUD
routes, and a typed `db` browser client β€” all driven by a single
`config/database/schema.ts`.

> **Beta:** the manifest declares `stability: "beta"`. The CLI and runtime
> APIs are stable enough for non-critical workloads but may change before GA.
> See [Known limitations](#known-limitations-beta) for what is not yet covered.

**Key features:**

- Single source of truth: `config/database/schema.ts` declares tables once.
- Auto-mounted REST surface at `/api/database/<entity>` per table.
- Typed `db.<entity>` browser client (no hand-written types).
- Live schema drift detection at boot β€” fail-closed in production.
- On-Behalf-Of (OBO) execution: `appkit.database.<entity>.asUser(req)`.
- Optional Row-Level Security helpers via `appkit db rls`.

## Basic usage

```ts
// server
import { createApp, server } from "@databricks/appkit";
import { database } from "@databricks/appkit/beta";

const app = await createApp({ plugins: [server(), database()] });
const cases = await app.database.cases.where({ status: "New" }).limit(50).toArray();
```

```ts
// browser (types come from the generated DatabaseRegistry)
import { db } from "@databricks/appkit-ui/js";
const cases = await db.cases.where({ status: "New" }).limit(50).toArray();
```

## Convention

The plugin auto-loads `config/database/schema.ts` (one of these paths is
probed: `config/database/schema.ts`, `config/database/schema/index.ts`, or
the `dist/` build artifacts).

```ts
// config/database/schema.ts
import { defineSchema, id, text, timestamp } from "@databricks/appkit";

export default defineSchema(({ table }) => ({
user: table("user", {
id: id(),
email: text().notNull(),
createdAt: timestamp().defaultNow().notNull(),
}),
}));
```

## Auto-mounted routes

Each table gets six conventional routes plus discovery and health metadata:

| Method | Path | Purpose |
|--------|----------------------------------|------------------------------------|
| GET | `/api/database/<e>` | List rows (filters, order, paging) |
| GET | `/api/database/<e>/count` | Count rows matching filters |
| GET | `/api/database/<e>/:id` | Find one row by primary key |
| POST | `/api/database/<e>` | Create a row (upsert via `Prefer`) |
| PATCH | `/api/database/<e>/:id` | Update by primary key |
| DELETE | `/api/database/<e>/:id` | Delete by primary key |
| GET | `/api/database/_entities` | Discovery β€” list of entities |
| GET | `/api/database/_healthz` | Readiness probe (`SELECT 1`) |

By default every verb runs OBO (on-behalf-of the forwarded user). Override
per-entity via the `http` config:

```ts
database({
http: {
user: {
list: "service", // service-principal
delete: false, // disable the DELETE route entirely
},
},
});
```

## CLI lifecycle

```bash
npx appkit db init # one-command Lakebase onboarding
npx appkit db introspect # pull existing schema (brownfield)
npx appkit db migration generate <name> # author a new SQL migration
npx appkit db migrate up # apply migrations (advisory-locked)
npx appkit db migrate status # list applied vs pending migrations
npx appkit db verify # detect drift between schema.ts and DB
npx appkit db rls <entity> <spec> # scaffold a Row-Level Security policy
npx appkit db seed # apply config/database/seed.sql
npx appkit db setup:dev # provision a per-user dev branch
npx appkit db types generate # regenerate typed client artifacts
```

`db migrate up` takes a Postgres advisory lock so two concurrent deploys
cannot race the same migration. The flag `--dry-run` prints the plan
without applying.

`db init` prints an env-diff before touching `.env`, backs up the existing
file to `.env.bak`, and refuses to drop a branch under `--from reset`
without an interactive confirmation.

## Hooks

`ctx.userId` is the forwarded email β€” a label, not authz; `undefined` under
SP. Guard before writing it as audit metadata:

```ts
database({
hooks: {
user: {
beforeCreate: async (data, ctx) => ({
...data,
...(ctx.userId ? { createdBy: ctx.userId } : {}),
}),
afterCreate: async (row) => audit(row.id, "created"),
},
},
});
```

`upsert` is its own channel β€” `beforeUpsert` / `afterUpsert` fire on
`create({ upsert: true })`; `beforeCreate` / `beforeUpdate` do **not**.

## Row-Level Security

`appkit db rls <entity> <spec>` writes a numbered migration, registers it
in `meta/_journal.json`, and emits `ENABLE` + `FORCE ROW LEVEL SECURITY`
(Postgres bypasses RLS for table owners by default β€” `FORCE` covers the SP
pool). The first run also emits a helpers migration with `current_user_email()`,
which reads the `app.user_id` GUC AppKit `SET`s on every OBO connection
(rename via [`rls.sessionVariable`](#configuration)).

```bash
npx appkit db rls case "owner_email:owner_email" # SELECT/UPDATE/DELETE
npx appkit db rls case "owner_email:owner_email" --action insert
npx appkit db rls case "tenant_id = current_setting('app.tenant_id')::uuid"
```

`owner_email:<col>` expands to `<col> = current_user_email()`. Anything else
is raw SQL (rejected on semicolons, comments, unbalanced parens). Use
`--dry-run` to preview without writing.

`--action select,update` emits one policy per verb with derived names
(`<base>_select`, `<base>_update`); `all` is exclusive.

## OBO and forwarded headers

OBO reads `x-forwarded-email` and `x-forwarded-access-token`. The Databricks
Apps gateway strips inbound copies and injects authentic values; the plugin
trusts them in production. Dev accepts them from anywhere β€” **don't expose
the dev server beyond loopback** unless you front it with the same trust
boundary.

## Pool sizing

SP pool: 10. OBO pools: 2 connections each, registry capped at 100 users
(LRU). Worst-case fan-out per instance: `(1 + 100) Γ— 2 + 10 = 212`. Tune via
`connection.max` and `oboPoolMax`. Lakebase's PgBouncer multiplexes client
connections, so effective headroom is larger than the raw tier limit.

## Drift detection

Boot fails closed in production when `schema.ts` and the live DB disagree on
column types or declared-but-missing tables. Additive drift (live-only
columns/tables) is logged. Policies are not compared.

`database({ checkDrift: false })` skips the check;
`tolerateSetupFailure: true` logs schema-load errors instead of throwing.

## Configuration

| Key | Default | Notes |
|----------------------------------|----------------|------------------------------------------------------------|
| `connection.max` | 10 | SP pool max connections |
| `oboPoolMax` | 100 | Distinct OBO pools kept alive (LRU evicts beyond this) |
| `statementTimeoutMs` | 15_000 | Server-side `statement_timeout` per pooled connection |
| `checkDrift` | `true` | Run drift introspection at boot |
| `tolerateSetupFailure` | `false` | Log instead of throw on schema-load / drift errors |
| `healthCheck` | enabled | Set `false` to suppress `/api/database/_healthz` |
| `entitiesDiscovery` | enabled | Set `false` to suppress `/api/database/_entities` |
| `rls.sessionVariable` | `"app.user_id"` | GUC name AppKit `SET`s on OBO connect (RLS reads it) |

## `column.private()` β€” partial

Filters the typegen registry, but row payloads from
`select`/`find`/`update().returning()` still include the value. **Treat as a
"hide from forms" hint, not authz** β€” keep true secrets in a separate table
with stricter ACLs.

## Known limitations (beta)

- **`column.private()` is a UX hint, not authz** β€” see above.
- **No policy drift detection** β€” `db verify` doesn't compare `pg_policies`.
- **Browser 404 semantics** β€” `db.<entity>.find(missingId)` and
`update(missingId, ...)` return `null` (not throw).
- **`in` lists capped** β€” URL builder bounds `in` to stay under proxy
limits; partition large lists client-side.
- **Dev mode trusts forwarded headers from any source** β€” see *OBO and
forwarded headers*.
Loading