All articles
Case StudiesNovember 7, 20267 min read

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.

sysonetypescriptreact hooksrest apizodmulti-tenantaccess controlopen source
sysone: type-safe REST API and React hooks from one model

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 access denies everything with 403.
  • 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, never 403. Other tenants' ids don't leak.
  • On create, match values are stamped into the row. A body that tries to set a different tenantId gets 403.
  • Included relations pass the related model's own read rule, or come back as null.
  • A missing value in ctx, like an undefined tenantId, 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:

WhatResult
sysone in a real React app's bundle2.62 KB brotli
Runtime dependencies0
List requests/sec, hand-written Express route33.9k to 40.7k
List requests/sec, sysone47.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 Request and Response.

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

Cover image: Martin Vorel / Wikimedia Commons

Related articles