Skip to content

Commit df59ff6

Browse files
committed
docs(database): tighten plugin guide and CLAUDE.md; small public-API polish
1 parent a53b640 commit df59ff6

8 files changed

Lines changed: 134 additions & 56 deletions

File tree

CLAUDE.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,20 @@ const result = await pool.query('SELECT * FROM users');
267267
**ORM Integration:**
268268
Works with Drizzle, Sequelize, TypeORM - see the `@databricks/lakebase` README and `apps/dev-playground/server/lakebase-examples/` for examples.
269269

270+
### Database Plugin
271+
272+
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.
273+
274+
```typescript
275+
import { createApp, server } from '@databricks/appkit';
276+
import { database } from '@databricks/appkit/beta';
277+
278+
const app = await createApp({ plugins: [server(), database()] });
279+
const cases = await app.database.cases.where({ status: 'New' }).limit(50).toArray();
280+
```
281+
282+
CLI: `npx appkit db init | introspect | migration generate <name> | migrate up | rls <entity> <spec> | seed | setup:dev | types generate | verify`.
283+
270284
### Frontend-Backend Interaction
271285

272286
```

docs/docs/api/appkit/Interface.DataPath.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ raw<T>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
120120
```
121121

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

126126
#### Type Parameters
127127

docs/docs/plugins/database.md

Lines changed: 92 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@ sidebar_position: 4
44

55
# Database plugin (beta)
66

7+
<!-- AUTO-GENERATED: stability-banner-start -->
8+
:::warning Beta plugin
9+
This plugin is currently **beta**. APIs may change between minor releases. Import from `@databricks/appkit/beta`. See [Plugin Stability Tiers](./stability.md).
10+
:::
11+
<!-- AUTO-GENERATED: stability-banner-end -->
12+
713
The **database plugin** is the application-level layer over Lakebase. It owns
814
schema declaration, type generation, drift detection, auto-mounted CRUD
915
routes, and a typed `db` browser client — all driven by a single
1016
`config/database/schema.ts`.
1117

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

@@ -59,7 +66,7 @@ export default defineSchema(({ table }) => ({
5966

6067
## Auto-mounted routes
6168

62-
Each table gets six conventional routes plus a metadata pair:
69+
Each table gets six conventional routes plus discovery and health metadata:
6370

6471
| Method | Path | Purpose |
6572
|--------|----------------------------------|------------------------------------|
@@ -69,7 +76,6 @@ Each table gets six conventional routes plus a metadata pair:
6976
| POST | `/api/database/<e>` | Create a row (upsert via `Prefer`) |
7077
| PATCH | `/api/database/<e>/:id` | Update by primary key |
7178
| DELETE | `/api/database/<e>/:id` | Delete by primary key |
72-
| GET | `/api/database/<e>/_columns` | Public column metadata for forms |
7379
| GET | `/api/database/_entities` | Discovery — list of entities |
7480
| GET | `/api/database/_healthz` | Readiness probe (`SELECT 1`) |
7581

@@ -82,7 +88,6 @@ database({
8288
user: {
8389
list: "service", // service-principal
8490
delete: false, // disable the DELETE route entirely
85-
columns: "service" // override the metadata gate
8691
},
8792
},
8893
});
@@ -91,13 +96,16 @@ database({
9196
## CLI lifecycle
9297

9398
```bash
94-
npx appkit db init # one-command Lakebase onboarding
95-
npx appkit db generate <name> # scaffold a table (greenfield)
96-
npx appkit db introspect # pull existing schema (brownfield)
97-
npx appkit db migration generate # author a new SQL migration
98-
npx appkit db migrate up # apply migrations (advisory-locked)
99-
npx appkit db verify # detect drift between schema.ts and DB
100-
npx appkit db rls <table> <args> # scaffold a Row-Level Security policy
99+
npx appkit db init # one-command Lakebase onboarding
100+
npx appkit db introspect # pull existing schema (brownfield)
101+
npx appkit db migration generate <name> # author a new SQL migration
102+
npx appkit db migrate up # apply migrations (advisory-locked)
103+
npx appkit db migrate status # list applied vs pending migrations
104+
npx appkit db verify # detect drift between schema.ts and DB
105+
npx appkit db rls <entity> <spec> # scaffold a Row-Level Security policy
106+
npx appkit db seed # apply config/database/seed.sql
107+
npx appkit db setup:dev # provision a per-user dev branch
108+
npx appkit db types generate # regenerate typed client artifacts
101109
```
102110

103111
`db migrate up` takes a Postgres advisory lock so two concurrent deploys
@@ -110,44 +118,99 @@ without an interactive confirmation.
110118

111119
## Hooks
112120

113-
Add per-entity lifecycle hooks via `database({ hooks: { ... } })`:
121+
`ctx.userId` is the forwarded email — a label, not authz; `undefined` under
122+
SP. Guard before writing it as audit metadata:
114123

115124
```ts
116125
database({
117126
hooks: {
118127
user: {
119-
beforeCreate: async (data, ctx) => ({ ...data, createdBy: ctx.userId }),
128+
beforeCreate: async (data, ctx) => ({
129+
...data,
130+
...(ctx.userId ? { createdBy: ctx.userId } : {}),
131+
}),
120132
afterCreate: async (row) => audit(row.id, "created"),
121133
},
122134
},
123135
});
124136
```
125137

126-
`upsert` is a separate channel from `create` and `update``beforeUpsert`
127-
does **not** fan out into `beforeCreate` / `beforeUpdate`. Use a shared
128-
helper if you need the same logic in both branches.
138+
`upsert` is its own channel — `beforeUpsert` / `afterUpsert` fire on
139+
`create({ upsert: true })`; `beforeCreate` / `beforeUpdate` do **not**.
140+
141+
## Row-Level Security
142+
143+
`appkit db rls <entity> <spec>` writes a numbered migration, registers it
144+
in `meta/_journal.json`, and emits `ENABLE` + `FORCE ROW LEVEL SECURITY`
145+
(Postgres bypasses RLS for table owners by default — `FORCE` covers the SP
146+
pool). The first run also emits a helpers migration with `current_user_email()`,
147+
which reads the `app.user_id` GUC AppKit `SET`s on every OBO connection
148+
(rename via [`rls.sessionVariable`](#configuration)).
149+
150+
```bash
151+
npx appkit db rls case "owner_email:owner_email" # SELECT/UPDATE/DELETE
152+
npx appkit db rls case "owner_email:owner_email" --action insert
153+
npx appkit db rls case "tenant_id = current_setting('app.tenant_id')::uuid"
154+
```
155+
156+
`owner_email:<col>` expands to `<col> = current_user_email()`. Anything else
157+
is raw SQL (rejected on semicolons, comments, unbalanced parens). Use
158+
`--dry-run` to preview without writing.
159+
160+
`--action select,update` emits one policy per verb with derived names
161+
(`<base>_select`, `<base>_update`); `all` is exclusive.
129162
130163
## OBO and forwarded headers
131164
132-
Per-user execution reads `x-forwarded-email` and `x-forwarded-access-token`
133-
from the request. The Databricks Apps gateway strips inbound copies and
134-
injects authentic values, so the plugin trusts these headers in production.
135-
In dev the same headers are accepted from anywhere so the local loop stays
136-
unblocked.
165+
OBO reads `x-forwarded-email` and `x-forwarded-access-token`. The Databricks
166+
Apps gateway strips inbound copies and injects authentic values; the plugin
167+
trusts them in production. Dev accepts them from anywhere — **don't expose
168+
the dev server beyond loopback** unless you front it with the same trust
169+
boundary.
137170
138171
## Pool sizing
139172
140-
The service-principal (SP) pool defaults to 10 connections. Per-user (OBO)
141-
pools default to 4 connections each, and the registry caps at 25 distinct
142-
users. Worst-case fan-out is therefore `(1 + 25) × 4 + 10 = 114` connections
143-
per app instance — tune via `connection.max` and `oboPoolMax` for your
144-
Lakebase tier.
173+
SP pool: 10. OBO pools: 2 connections each, registry capped at 100 users
174+
(LRU). Worst-case fan-out per instance: `(1 + 100) × 2 + 10 = 212`. Tune via
175+
`connection.max` and `oboPoolMax`. Lakebase's PgBouncer multiplexes client
176+
connections, so effective headroom is larger than the raw tier limit.
145177
146178
## Drift detection
147179
148180
Boot fails closed in production when `schema.ts` and the live DB disagree on
149181
column types or declared-but-missing tables. Additive drift (live-only
150-
columns/tables) is logged as a warning so blue/green deploys aren't blocked.
151-
152-
Customize with `database({ checkDrift: false })` to skip the check, or
153-
`tolerateSetupFailure: true` to log-and-continue on schema-load errors.
182+
columns/tables) is logged. Policies are not compared.
183+
184+
`database({ checkDrift: false })` skips the check;
185+
`tolerateSetupFailure: true` logs schema-load errors instead of throwing.
186+
187+
## Configuration
188+
189+
| Key | Default | Notes |
190+
|----------------------------------|----------------|------------------------------------------------------------|
191+
| `connection.max` | 10 | SP pool max connections |
192+
| `oboPoolMax` | 100 | Distinct OBO pools kept alive (LRU evicts beyond this) |
193+
| `statementTimeoutMs` | 15_000 | Server-side `statement_timeout` per pooled connection |
194+
| `checkDrift` | `true` | Run drift introspection at boot |
195+
| `tolerateSetupFailure` | `false` | Log instead of throw on schema-load / drift errors |
196+
| `healthCheck` | enabled | Set `false` to suppress `/api/database/_healthz` |
197+
| `entitiesDiscovery` | enabled | Set `false` to suppress `/api/database/_entities` |
198+
| `rls.sessionVariable` | `"app.user_id"` | GUC name AppKit `SET`s on OBO connect (RLS reads it) |
199+
200+
## `column.private()` — partial
201+
202+
Filters the typegen registry, but row payloads from
203+
`select`/`find`/`update().returning()` still include the value. **Treat as a
204+
"hide from forms" hint, not authz** — keep true secrets in a separate table
205+
with stricter ACLs.
206+
207+
## Known limitations (beta)
208+
209+
- **`column.private()` is a UX hint, not authz** — see above.
210+
- **No policy drift detection**`db verify` doesn't compare `pg_policies`.
211+
- **Browser 404 semantics** — `db.<entity>.find(missingId)` and
212+
`update(missingId, ...)` return `null` (not throw).
213+
- **`in` lists capped** — URL builder bounds `in` to stay under proxy
214+
limits; partition large lists client-side.
215+
- **Dev mode trusts forwarded headers from any source** — see *OBO and
216+
forwarded headers*.

packages/appkit/src/database/runtime/data-path.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ export interface DataPath {
165165

166166
/**
167167
* Tagged-template SQL escape hatch. Values are bound as parameters; column
168-
* and identifier interpolation is intentionally not supported here — use
169-
* `getDrizzle()` from the plugin's exports for that case.
168+
* and identifier interpolation is intentionally not supported here — drop
169+
* to `appkit.database.getPool().query(...)` if you need that.
170170
*/
171171
raw<T = Row>(
172172
strings: TemplateStringsArray,

packages/appkit/src/database/schema-builder/define-schema.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,16 @@ export interface DefineSchemaOptions {
1818
}
1919

2020
/**
21-
* Define a schema. This is used to build the schema for the database.
22-
* @param build - A function that builds the schema.
23-
* @param options - Options for defining the schema.
24-
* @returns The defined schema.
21+
* Define a schema. Single source of truth for tables, types, and routes.
22+
*
23+
* @param build - Receives `{ table, enum }`.
24+
* @param options - `schemaName` defaults to `"app"`.
2525
*/
2626
export function defineSchema<T extends Record<string, AppKitTable>>(
2727
build: (ctx: SchemaBuilderContext) => T,
28-
options: DefineSchemaOptions = {},
28+
options?: DefineSchemaOptions,
2929
): Schema<T> {
30-
const schemaName = options.schemaName ?? "app";
30+
const schemaName = options?.schemaName ?? "app";
3131
const schemaInstance =
3232
schemaName === "public" ? { table: pgTable } : pgSchema(schemaName);
3333

packages/appkit/src/plugins/database/entity-proxy.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,15 +216,17 @@ export function makeEntityClient<
216216

217217
/**
218218
* Thin immutable wrapper around `DataPath`. Terminators go through
219-
* `this.run(action, fn)` → `Plugin#execute`, so telemetry, retry, cache,
220-
* and timeout flow consistently per action.
219+
* `this.run(action, fn)` → `Plugin#execute` so telemetry/retry/cache/timeout
220+
* flow per action. `implements EntityClient` catches drift at the declaration
221+
* site instead of via the factory's `as unknown as` cast.
221222
*/
222223
class EntityClientImpl<
223224
TRow extends Row = Row,
224225
TInsert = TRow,
225226
TUpdate = Partial<TRow>,
226227
TIncludes = Record<string, { row: Row }>,
227-
> {
228+
> implements EntityClient<TRow, TInsert, TUpdate, TIncludes>
229+
{
228230
constructor(
229231
private readonly deps: EntityClientDeps,
230232
private readonly state: EntityClientState,

template/appkit.plugins.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"database": {
3030
"name": "database",
3131
"displayName": "Database",
32-
"description": "Application database with schema-driven CRUD, type generation, OBO, RLS, and LLM tools",
32+
"description": "Application database with schema-driven CRUD, type generation, OBO, and RLS",
3333
"package": "@databricks/appkit",
3434
"resources": {
3535
"required": [

template/config/database/schema.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
import { defineSchema } from "@databricks/appkit";
22

33
/**
4-
* Application database schema. The database plugin auto-loads this file
5-
* (see config/database/) and uses it as the single source of truth for
6-
* - the typed `db.<entity>` browser client,
7-
* - the auto-mounted `/api/database/<entity>` REST routes,
8-
* - and runtime drift detection against the live Lakebase DB.
4+
* Application database schema. Source of truth for the typed browser client,
5+
* `/api/database/<entity>` routes, and drift detection.
96
*
10-
* Add tables under the returned object and run:
11-
* npx appkit db migration generate
12-
* npx appkit db migrate up
7+
* Add tables, then `npx appkit db migration generate <name>` + `migrate up`.
138
*
149
* Example:
15-
* user: table("user", {
16-
* id: id(),
17-
* email: text().notNull(),
18-
* createdAt: timestamp().defaultNow().notNull(),
19-
* }),
10+
* import { defineSchema, id, text, timestamp } from "@databricks/appkit";
11+
*
12+
* export default defineSchema(({ table }) => ({
13+
* user: table("user", {
14+
* id: id(),
15+
* email: text().notNull(),
16+
* createdAt: timestamp().defaultNow().notNull(),
17+
* }),
18+
* }));
2019
*/
2120
// biome-ignore lint/correctness/noEmptyPattern: schema is intentionally empty in the starter template.
2221
export default defineSchema(({}) => ({}));

0 commit comments

Comments
 (0)