sysone: type-safe REST API and React hooks from one model
Case study: sysone gives you a type-safe REST API and React hooks from one model, with row-level access rules, a 2.6 KB client and support for five databases.
The problem: the same list endpoint, forever
Almost every app I build has the same screens: a category dropdown, a users table, a project picker, an infinite feed. Every one of them needs the same code twice.
On the server: an endpoint that reads search, filters, sort and paging from the URL, checks who may see which rows, validates what the client sends, and never leaks columns like password hashes. In the browser: fetch calls, loading and error state, debounced search, "load more", keeping the selected item visible in a dropdown even when it isn't on the loaded page, and refreshing the right lists after an edit.
It is boring to write and easy to get wrong. Forget one tenant filter on one endpoint and one customer sees another customer's data. In multi-tenant SaaS and ERP work, that is the bug I worry about most.
The idea: a type-safe REST API and React hooks from one model
Here is a model from the README:
export const category = defineModel({
name: "category",
schema: z.object({
id: z.number().int(),
name: z.string().min(1),
parentId: z.number().int().nullable(),
createdAt: z.date(),
}),
write: ["name", "parentId"],
search: ["name"],
filter: ["parentId", "createdAt"],
sort: ["name", "createdAt"],
relations: { parent: { model: "category", key: "parentId" } },
access: (ctx: Ctx) => ({ read: true, create: ctx?.admin, update: ctx?.admin }),
})
From that, the server gets GET /api/category, GET /api/category/:id, POST, PATCH and DELETE. The browser gets useSelect(db.category) for pickers, useList for tables and feeds, and useGet for detail pages. A dropdown with search, paging and "Parent / Child" labels is about twenty lines of React with no fetch code, no debounce and no paging logic.
v0 to v1: the browser gets types, not values
The first version worked, but it broke its own promises. I wrote down why in a design note before rewriting it:
- The browser imported the model value, which pulled Zod into the bundle. The "1.78 KB" client was really about 15 KB.
- Access was a yes/no callback. No tenant or owner scoping, which every real app needs.
- Each of the five database adapters re-implemented keyset paging, null handling and search escaping its own way.
- After a write, the client cache refetched only page 1.
The v1 rule that fixed most of this: the browser imports the API's type, never its value. sysone({ models }) returns an app that carries a type-only contract. createClient<Api>() is a typed Proxy, so db.category exists at compile time without any schema at runtime. The few facts the client needs at runtime, like which fields are dates, arrive in every response as meta.
To make that hard to break, the sysonejs main entry has a browser export condition pointing at a guard file that throws. If someone value-imports server code into the browser, the build fails loudly. CI also checks that Zod, models and drivers add 0 bytes to the real example app's browser bundle.
Access rules as data, closed by default
In v1, access(ctx) returns data instead of a boolean. Each action gets true, false, or a match like { tenantId: ctx.tenantId }:
access: (ctx: Ctx) =>
!ctx
? {}
: {
read: { tenantId: ctx.tenantId },
create: ctx.role !== "viewer" && { tenantId: ctx.tenantId, ownerId: ctx.userId },
update: ctx.role === "admin" ? { tenantId: ctx.tenantId } : { tenantId: ctx.tenantId, ownerId: ctx.userId },
delete: ctx.role === "admin" && { tenantId: ctx.tenantId },
},
Because rules are data, the core can enforce them the same way on all five databases:
- A model with no
accessdenies everything with403. - Reads, updates and deletes carry the match in the same database statement, so there's no read-then-check race.
- A row outside your scope looks exactly like a missing row:
404, never403. Other tenants' ids don't leak. - On create, match values are stamped into the row. A body that tries to set a different
tenantIdgets403. - Included relations pass the related model's own read rule, or come back as
null. - A missing value in
ctx, like an undefinedtenantId, fails closed instead of matching every row.
This is the same instinct as the AI-first database platform I wrote about in August, where Postgres row-level security enforced permissions. sysone moves that idea up to the application layer so it works on MySQL and MongoDB too.
One query tree, five databases
The core compiles every request (access scope, filters, ranges, search and the keyset cursor) into one small condition tree. Adapters only translate that tree. The adapter contract shrank to find, insert, update(where, patch) and remove(where), and keyset paging lives in one function instead of five.
One conformance suite, testSource(), runs against every adapter: memory, Postgres and MySQL without an ORM, Drizzle, Prisma and MongoDB. Sort fields must be non-null by type, so the different ways Postgres, MySQL and Mongo order nulls never matter.
The wire format is written down in SPEC.md with 91 JSON test cases and a runner that checks any server URL. A server in Go or Python that passes those cases works with the TypeScript client.
A client cache that doesn't lie
The browser client keeps one normalized cache: entities by model and id, and lists as arrays of ids. When you update a row, the server's response patches the entity, and every view showing it re-renders without a refetch, including views that show it through include. Lists of that model then refetch every loaded page, not just page 1, and swap in at once.
The tricky part is races. Every request records when it started, and an entity is only overwritten by a response whose request started later. A slow list request that began before your write can't put old data back on screen.
Numbers I can reproduce
From the README, measured on an Apple M4 with Node 26, each with the command to rerun it:
| What | Result |
|---|---|
| sysone in a real React app's bundle | 2.62 KB brotli |
| Runtime dependencies | 0 |
| List requests/sec, hand-written Express route | 33.9k to 40.7k |
| List requests/sec, sysone | 47.6k to 49.9k |
sysone beats the hand-written route because it formats dates itself. JSON.stringify spends 11.4 µs on 20 rows with Date fields and 1.0 µs on the same rows with ISO strings. Size budgets run in CI, so a change that bloats the browser bundle fails the build.
How it was built
The public history is one day, November 3: the v0 commit, a v1 design contract with a browser-size harness, the core rewrite, the adapters moved over one by one, docs with every code sample compiled in CI, and npm publishing from CI with provenance. Some of the v1 work was done by coding agents in separate git worktrees and merged back. Writing the docs found four real type and option bugs, all fixed in 0.1.1.
Limits, honestly
sysone is v0.1, and the limitations page is long on purpose:
- No total counts and no page numbers. Paging is keyset only.
- Only belongs-to relations, one level deep.
- No field-level access rules, no bulk writes, no optimistic updates.
- No server rendering for the hooks and no realtime sync.
- Cloudflare Workers and Deno are untested, even though the handler only uses web
RequestandResponse.
It is a good fit for admin panels, internal tools and multi-tenant SaaS screens. If you need GraphQL-style nested queries, reports with grouping, or realtime collaboration, use something else. For the delivery platform I built on Drizzle and D1, the ops and merchant lists are exactly the kind of screens sysone is for.
FAQ
How is sysone different from tRPC or Supabase?
With tRPC you write each procedure and its access checks by hand. Supabase gives you row rules through Postgres RLS, but only on Postgres. sysone generates the endpoints and hooks from one model and enforces row rules on Postgres, MySQL, MongoDB, Prisma and Drizzle.
Does sysone ship Zod to the browser?
No. The browser imports only the API's TypeScript type. CI fails if Zod, models or database drivers end up in the browser bundle.
Is sysone production ready?
It is v0.1 and the API may change before 1.0. Read the limitations page before you build on it.
Sources
- Amartuvshins0404/sysone on GitHub: README, SPEC.md, CHANGELOG.md, docs/access.md, docs/limitations.md
- sysonejs on npm
Cover image: Martin Vorel / Wikimedia Commons
Related articles
Building an Open-Source, AI-First Database Platform
An open-source experiment in flipping the stack: Database → AI → User. Describe your organization in plain language, let the database hold the model, and keep every answer permission-correct.
Shipping a Three-Sided Delivery Platform on Cloudflare
A case study of building a three-sided delivery platform — merchant, ops, and driver apps in one type-safe monorepo on Cloudflare D1, with a ten-state parcel lifecycle, photo proof of delivery, and MNT invoicing.