feat(playground): add a DatabasePlugin example - #529
Conversation
Exercise the typed client, the generated routes, and the three hook points against a live Postgres, and cover the assembled stack with one integration test. Signed-off-by: ditadi <victordperd@gmail.com>
| }); | ||
| return { | ||
| ...values, | ||
| body: answer.text || values.body, |
There was a problem hiding this comment.
[Medium] The redaction hook silently persists the unredacted body on an empty model response. body: answer.text || values.body falls back to the original body whenever answer.text is falsy (safety refusal, empty completion, whitespace) — breaking the guarantee stated in this file and the route prose ("the unredacted body must never reach the table"), with no signal. As example code this teaches a redaction control with a silent bypass. Throw (roll back) when redaction yields empty, rather than falling back to the raw input.
Automated review finding.
| // author_email is a private column: refused on the wire but | ||
| // writable here. A real app takes it from the session. | ||
| beforeCreate: async (values) => { | ||
| const answer = await runAgent(redactor, { |
There was a problem hiding this comment.
[Medium] The example runs an LLM call inside the mutation transaction with no timeout. runAgent(redactor, …) is awaited inside beforeCreate, so it holds a pooled DB connection + the row's locks for the full (unbounded) LLM latency — the exact "slow call inside a transaction" the plugin's own docs warn against (hook-lifecycle.tsx: "bound anything that leaves the process"; the route prose: "give it a timeout"). runAgent accepts a signal, so AbortSignal.timeout(ms) is one argument away. As the flagship example this teaches the un-bounded pattern.
Automated review finding.
| `; | ||
| res.json(rows); | ||
| } catch (error: unknown) { | ||
| res.status(500).json({ error: (error as Error).message }); |
There was a problem hiding this comment.
[Medium] Custom playground routes leak raw DB error messages to the client. Both new routes do catch (error) { res.status(500).json({ error: (error as Error).message }) } (here and at the /timeline route below), returning raw driver/query error text — constraint names, column names, pg detail — to any caller, bypassing the error sanitization the generated routes apply. As de-facto documentation this steers developers to leak internal error detail from hand-written routes; return a generic message and log the detail server-side.
Automated review finding.
Stack
Each PR targets the one above it, so the diff shown here is only the delta on top of #528. Review in order.
What
The last PR in the series. No SDK behaviour changes here: this is the dev-playground page that runs the whole plugin against a live Postgres, plus one integration test that asserts the assembled stack from the outside.
The page is a small board/annotation app — three tables, a two-edge include, all three hook points, and a private column — chosen so every claim the earlier PRs make is visible rather than described.
Changes
The schema (
config/database/schema.ts)boards → notes → note_events, which is enough to exercise a nested include.notes.author_emailis.private(), so it is writable from server code and refused on the wire — the page shows both halves of that.The plugin does not create tables. The app expects them to exist, and the page says so when they do not.
The registration (
server/index.ts)Registered conditionally on
LAKEBASE_ENDPOINT, the same waylakebase()already is in this file, so the playground still boots without a database configured.crudRoutesenablesboardsandnotesbut notnote_events: the audit trail is written by a hook and stays server-only, which is the exposure decision from #527 shown rather than explained.The
noteshooks use all three points:beforeCreateruns an agent to redact personal names from the body before the insert, so the unredacted text never reaches the table, and stamps the privateauthor_email. This is the intended shape of the hook API — a hook is server code and can call any other plugin. The comment in the file is explicit that it also holds the transaction open while the model answers, which is the trade being made.serializeships a 120-character preview on the list route and the whole body on the detail route.afterCreatewrites thenote_eventsrow inside the insert's own transaction, so the note and the event describing it commit together or not at all.Two hand-written routes (
server/index.ts)Deliberately showing what a generated route is not for: one aggregate via the tagged
sqltemplate (a generated read shapes rows, it does not group them), and one nested include reached through the typed client.The page (
client/src/routes/database.route.tsx)A board explorer over the generated routes, a panel that traces a write through its hooks, and a probe that fires the requests the plugin is supposed to refuse — writing a private column, an unknown field, an over-large
limit— and shows the status and body that come back.One integration test (
plugins/database/tests/mvp.integration.test.ts)Drives the plugin through its real assembly with only the driver mocked: config in, routes registered, request in, response out. It is the test that would catch a regression the unit suites each miss individually.
Verification
pnpm vitest run— 4166 passing, 1 skippedpnpm -r typecheck— clean across all packagespnpm run generate:types,pnpm run sync:template, andpnpm run docs:buildproduce no drift