Skip to content

feat(appkit): add opt-in generated database reads - #527

Open
ditadi wants to merge 1 commit into
stack/database-mvp/02-typed-apifrom
stack/database-mvp/03-crud-reads
Open

feat(appkit): add opt-in generated database reads#527
ditadi wants to merge 1 commit into
stack/database-mvp/02-typed-apifrom
stack/database-mvp/03-crud-reads

Conversation

@ditadi

@ditadiditadi commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stack

Each PR targets the one above it, so the diff shown here is only the delta on top of #526. Review in order.

What

Adds the first HTTP surface: crudRoutes projects the typed read API from #526 onto generated GET /:table and GET /:table/:id routes. It is off by default and opt-in per table, because a generated route is reachable by anyone the app admits.

database({
schema,crudRoutes: {tables: ["notes"]},hooks: {notes: {serialize: (row)=>({ ...row,excerpt: String(row.body).slice(0,80)}),},},});
GET /api/database/notes?where={"author":{"eq":"ada"}}&order={"createdAt":"desc"}&limit=20
→ { "items": [...], "limit": 20, "offset": 0 }
GET /api/database/notes/42
→ { "id": 42, "body": "...", "createdAt": "..." }

Only writes are missing after this PR; they arrive in the next one.

Changes

Exposure is a decision per table (crud/exposure.ts)

crudRoutes accepts false (the default), true, or { tables: [...] }, and the table names are checked against the schema type, so a typo does not silently expose nothing. An enabled table is also what makes it includable from its neighbours: a relation whose target is not enabled cannot be included, so one table's data sits behind exactly one decision rather than leaking through a join.

The query grammar is bounded before any SQL runs (crud/query.ts)

where, order, select, include, limit, and offset are decoded from the raw query string — not from Express's normalized req.query, which would accept repeated and array-shaped parameters. Every decoded piece is checked against the table's compiled columns: an unknown column, an operator the column's kind does not support, or a value that fails its codec is a 400 before the plugin is asked for anything.

The budgets are explicit constants in defaults.ts and are all enforced at decode time: query string size, where nesting depth and condition count, order field count, offset ceiling, and the number of rows the include tree may materialize. limit defaults to a page and is capped by the same wire cap a typed caller sees, so HTTP cannot ask for more than server code can.

Rejections name a fixed parameter and a fixed sentence. The decoder never echoes caller-supplied text back into the response.

Rows are shaped, not forwarded (crud/contract.ts, crud/codecs.ts)

Each enabled table compiles once into a CrudTable: its public columns, their codecs, its primary key decoder, and a projection that drops private columns. A row is projected before it reaches the optional serialize hook, so a serializer cannot re-expose a column the schema marked private, and the hook's output is re-sanitized against depth and node budgets afterwards. serialize is typed to return synchronously — a Promise does not compile — because it runs inside the response path.

Responses are bounded and never cached

The encoded body is measured before it is sent, so a request that would exceed the byte budget fails as 413 instead of streaming a partial answer. Every generated read sends Cache-Control: no-store: the same URL answers differently once the table changes.

Pagination is stable

The list handler appends the primary key to whatever order the caller asked for, so rows with equal sort keys cannot reshuffle between pages. A table without a primary key has no tie-breaker to append, so it must name its own order and is told so.

Spans

Each generated read runs inside a span named for its route template, not its URL, and a failure is recorded as not_found, rejected, or failed — derived from the safe status code, so cardinality stays bounded and no caller input reaches the span.

Known limitation

Text filters accept caller-supplied like/ilike patterns, and this beta adds no statement cancellation below the connector, so an expensive pattern runs to completion while holding its pooled connection. This is documented on CrudRoutesConfig alongside the note that generated routes carry no per-user filter.

Verification

  • pnpm vitest run — 4109 passing, 1 skipped; new suites cover the query decoder and its budgets, the codecs, the row contract, the route handlers, and the read spans
  • pnpm -r typecheck — clean across all packages
  • pnpm run generate:types, pnpm run sync:template, and pnpm run docs:build produce no drift

Project the typed entity API onto default-off list and detail routes that bound query
grammar, include depth, and result cost before execution, and that shape rows through a
private-safe projection and one synchronous serializer per table. Keep the declared table
names in the schema type so exposure config cannot name a table the schema does not have.
Signed-off-by: ditadi <victordperd@gmail.com>
for (const table of tables.values()) {
const deps: ReadRouteDeps = {
table,
entity: () => entities()[table.name],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Generated routes have no per-user / per-row authorization. The handler resolves its entity via this.exports() (service principal) with no .asUser(req) and no owner/row-scope predicate — the .owner() concept was dropped in #525. So enabling a table (especially crudRoutes: true, which exposes every table) makes every non-private column of every row readable by anyone the app admits, ignoring their own DB grants. It is documented in the types.ts docstring, but there is no code-level guardrail or owner-scope seam. Consider requiring an explicit per-table authorization/owner callback (and/or OBO execution).

Automated review finding.

}

/** Measure the encoded body before sending so no partial response escapes. */
function sendJson(res: Response, body: JsonValue): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The response-size cap is enforced only after full in-memory materialization.sendJson runs JSON.stringify(body) then checks Buffer.byteLength > MAX_RESPONSE_BYTES, so up to limit rows (× 2–3 projected copies) and the entire response string are built in memory before the 413. MAX_MATERIALIZED_NODES bounds row count, not bytes, so a list over fat text/jsonb columns spikes heap regardless of the cap; N concurrent such requests → GC thrash / OOM even though each is ultimately rejected. Cap row count, or measure incrementally before building the string.

Automated review finding.

* Decode the predicate on one column against the operator matrix its kind
* allows, counting conditions so a filter cannot grow without bound.
*/
function decodeCondition(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Reads have no timeout / statement cancellation → connection-pool exhaustion.databaseReadDefaults sets no timeout, so there is no TimeoutInterceptor and no AbortSignal deadline; a caller-supplied leading-wildcard like/ilike (accepted here for string columns) forces a sequential scan that holds its pooled connection to completion. N concurrent such requests (≥ pool size) block all reads and writes. Add a statement_timeout on the connection and/or a read deadline.

Automated review finding.

for (const meta of Object.values(table.$columns)) {
const column = compileColumn(meta);
columns.set(meta.columnName, column);
if (meta.primaryKey) primaryKey = column;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low-Medium] A private primary key still powers the detail route.primaryKey is assigned here before the if (meta.isPrivate) continue, so a .private() PK is excluded from selectable/queryable (good) but still registers GET /:table/:id and decodeId. The result is an enumeration/existence oracle keyed on the id the author marked private (the value is never echoed and list rows omit it, so clients cannot discover ids — but per-id probing returns 200 vs 404). Consider treating a private-PK table as keyless for HTTP, or requiring a public id.

Automated review finding.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ditadi@MarioCadenas