feat(appkit): add opt-in generated database reads - #527
Open
ditadi wants to merge 1 commit into
Open
Conversation
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>
This was referenced Aug 10, 2026
ditadi
requested review from
MarioCadenas and
atilafassina
and removed request for
calvarjorge
August 10, 2026 22:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
crudRoutesprojects the typed read API from #526 onto generatedGET /:tableandGET /:table/:idroutes. It is off by default and opt-in per table, because a generated route is reachable by anyone the app admits.Only writes are missing after this PR; they arrive in the next one.
Changes
Exposure is a decision per table (
crud/exposure.ts)crudRoutesacceptsfalse(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, andoffsetare decoded from the raw query string — not from Express's normalizedreq.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.tsand are all enforced at decode time: query string size,wherenesting depth and condition count,orderfield count,offsetceiling, and the number of rows the include tree may materialize.limitdefaults 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 optionalserializehook, 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.serializeis typed to return synchronously — aPromisedoes 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
413instead of streaming a partial answer. Every generated read sendsCache-Control: no-store: the same URL answers differently once the table changes.Pagination is stable
The list handler appends the primary key to whatever
orderthe 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 ownorderand 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, orfailed— 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/ilikepatterns, 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 onCrudRoutesConfigalongside 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 spanspnpm -r typecheck— clean across all packagespnpm run generate:types,pnpm run sync:template, andpnpm run docs:buildproduce no drift