Deno

`the_deno` is the Deno runtime, published on JSR as `@puneetxp/the`. It pairs a small, fast router with a MySQL `Model`, cookie sessions stored in Deno KV, and JSON response helpers.

Install

Keep every import in one dep.ts so that bumping the version is a one-line change:

// deno/dep.ts
export {
  compile_routes, compile_url_pattern, hash, Model, response, Router, Session, setRole, DB,
} from "jsr:@puneetxp/the@0.1.16";
export type { _Routes, relation, Route_Group_with } from "jsr:@puneetxp/the@0.1.16";

The database settings come from the environment, or from deno/.env, which the generator writes:

DBHOST=localhost
DBUSER=root
DBPWD=secret
DBNAME=my_app
# 0.1.x only: DBPORT=3306  DBPOOL=4  DBSOCKET=/tmp/mysql.sock

Server bootstrap

// deno/index.ts
import { Router, setRole } from "./dep.ts";
import { routes } from "./App/Routes/index.ts";
import { Role$ } from "./App/Model/Role.ts";

setRole((await Role$().all()).items);          // load the roles table once

Deno.serve({ port: 9000 }, async (req) =>
  await new Router(routes, req).URLPattern()?.run()
);
// deno/App/Routes/index.ts
import { _Routes, compile_routes, compile_url_pattern } from "../../dep.ts";

const route_pre: _Routes = [
  { handler: Public.Home },
  { islogin: true, child: [...islogin, ...isuper] },
  ...ipublic,
  ...Auth,
];
export const routes = compile_url_pattern(compile_routes(route_pre));

Run it with KV enabled, because sessions are stored in Deno KV:

deno run --watch --allow-all --unstable-kv index.ts

Routes

export const isuper: _Routes = [{
  path: "isuper",
  roles: ["isuper"],
  child: [
    { path: "/client", crud: { class: IsuperClientController, crud: ["c", "r", "u", "d", "a", "w"] } },
    { path: "/report/:year", handler: ReportController.year },
    { path: "/stats", group: { GET: [{ handler: Stats.all }], POST: [{ path: "/refresh", handler: Stats.refresh }] } },
  ],
}];
FieldMeaning
pathA URLPattern pathname, e.g. /:id or /book/:book_id/client. Slashes are normalised, so "isuper" works the same as "/isuper".
methodDefaults to GET.
handler(session, param) => Promise<Response>.
isloginRequires a valid session cookie. The response is 401 otherwise.
guard((req) => Promise<false | string>)[]. Return false to allow the request; a string denies it and becomes the error message.
rolesThe user needs at least one of these roles.
childNested routes. Paths are concatenated, and islogin, guard and roles are inherited.
groupRoutes keyed by HTTP method.
crud{ class, crud: [letters] }. See below.

Handlers and parameters

Every handler has the same signature. Public routes get a Session too; it just has no login.

static async show(session: Session, param: URLPatternResult) {
  const id = param.pathname.groups.id;                                   // from "/:id"
  const latest = new URL(session.req.url).searchParams.get("latest");    // query string
  const body = await session.req.json();                                  // raw Request is session.req
  return response.JSON(await Client$().find(id), session);
}

CRUD shorthand

Letter0.1.x0.0.2Handler
aGET /GET /all
rGET /:idGET /:idshow
cPOST /POST /store
wPOST /wherePOST /wherewhere
uPATCH /:id and PUT /:idPOST /:idupdate
pPATCH / and PUT /PATCH /upsert
dDELETE /:id, plus DELETE /perma_delete/:id (isuper only)DELETE /:iddelete, perma_delete

Generated delete handlers soft-delete (set deleted_at) when the model has "additional": ["delete"], and hard-delete otherwise. See Schema.

Sessions and auth

When a route has islogin, the router:

  1. reads the PHPSESSID cookie;
  2. loads the session from Deno KV at ["users", id] and checks that the user-agent matches;
  3. runs the guards;
  4. checks roles against session.Login.roles.
session.Login              // { id, name, email, roles: string[] }
session.ActiveLoginSession // { books, book, session_id, expire, ip, agent, … }
session.req                // the original Request
  • Log a user in: new Session(req).startnew(user, activeRoles, books), then return response.JSONF(login, session.returnCookie()).
  • Refresh the expiry: session.reactiveSession(). response.JSONS also refreshes it.
  • Log out: session.removeSession() and session.removeCookie().
  • Cookie settings: from the env keys ssl (domain), samesite and secure.
  • 0.1.x only: requests can also authenticate with Authorization: Bearer <key> against the api_keys table. The user with id = 1 bypasses role checks.

Model

Generated models extend Model and are exported as a factory, so each call starts a fresh query:

class Standard extends Model<Client> {
  constructor() {
    super("client", "clients", ["id"], ["name", "email", "book_id"], ["id", "name", "email", "book_id", "created_at", "updated_at"],
      { book: { table: "books", name: "book_id", key: "id", callback: () => Book$ } });
  }
}
export const Client$ = () => new Standard();

Code generated by older releases exports a shared instance instead (export const Client$ = new Standard(), called without ()). That instance keeps its query state between calls, so prefer the factory form.

(await Client$().all()).items;                               // rows
(await Client$().find(5)).item;                              // one row (or undefined)
(await Client$().where({ book_id: [3], status: ["a", "b"] }).get()).items;   // IN (...)
Client$().where({ book_id: [3] }).andWhereC([["updated_at", ">", latest]]);  // custom operators
await Client$().create({ name: "Acme", book_id: 3 });        // 0.0.2: chain .getInserted()
await Client$().where({ id: [5] }).update({ name: "Acme Ltd" });
await Client$().upsert([{ id: 5, name: "…" }, { name: "new" }]);
await Client$().delete({ id: [5] });
await (await Invoice$().where({ id: [1] }).get()).with("client");   // eager-load a relation (async)
  • Results are on .item for a single row and .items for a list.
  • Writes are filtered through fillable.
  • 0.1.x adds softDelete, withJoin(rels, where) (a SQL JOIN), paginate, count, toJSON(), clone() and an optional KV cache.

Responses

response.JSON(body, session?, status?, headers?)   // JSON + refreshed session cookie
response.JSONS(body, session?, status?, headers?)  // same, and extends the session
response.JSONF(body, headers?, status?)            // JSON with explicit headers (e.g. a login cookie)
response.OPTIONS(req)                              // 0.1.x: 204 CORS pre-flight

In 0.1.x every helper adds CORS headers that echo the request’s origin, with credentials allowed.

Pattern: per-tenant scoping

A signed-in user should only reach their own rows.

Generated from the schema

Write "islogin": { "can": [...], "under": "book" } in the model and "islogin": { "can": [...], "owner": "user_id" } in book.json (see Which rows). The generator then nests the route under /book/:book_id/ and every method starts with the same check:

// App/Controller/Islogin/ClientController.ts (generated)
static async all(session: Session, param: URLPatternResult): Promise<Response> {
   const book_id = await ownedParent(session, param, Book$, "book_id", "user_id");
   if (book_id instanceof Response) return book_id;          // 404: not your book
   const rows = await Client$().where({ book_id: [book_id] }).get();
   return response.JSON(rows.items, session);
}

// App/scope.ts (template, copied once)
export async function ownedParent(session, param, parent, key, ownerColumn) {
  const id = Number(param.pathname.groups[key]);
  if (!Number.isInteger(id) || id <= 0) return response.JSON("Not Found", session, 404);
  const row = await parent().where({ id: [id], [ownerColumn]: [session.Login.id] }).first();
  if (!row) return response.JSON("Not Found", session, 404);
  return id;
}

The generated code calls models as factories (Book$()), as the current generator writes them. Scoped controllers are only generated with param: "URLPatternResult".

Keeping hand-written code

php setup.php rewrites the route files, models and controllers on every run. Keep custom work in three places:

WhatWhereGenerator
A controller with real logicits normal App/Controller/<Role>/<Name>Controller.tsskipped when config.json has "table": { "<name>": true } (every namespace of that model)
A route that is not CRUD on a tableApp/Routes/index.ts, in a custom array next to ...isloginnever written; the template is copied once
Who can reach a table, and which rowsthe model’s crud blockwrites Routes/Islogin.ts, Isuper.ts, Ipublic.ts, <Role>.ts

config.json usually holds database credentials and is gitignored, so keep the same "table" list in a committed exampleconfig.json.

Models are factories (export const Client$ = () => new Standard()), so hand-written code calls Client$().where(...), not Client$.where(...). Each call has its own query state.

Hand-written per-tenant check (inside a kept controller)

INTAX nests the tenant id in the URL and checks ownership before every query:

// App/Controller/Islogin/_book.ts
export async function ownedBook(session: Session, param: URLPatternResult): Promise<number | Response> {
  const book_id = Number(param.pathname.groups.book_id);
  if (!book_id) return response.JSON("Book is required", session, 400);
  const book = (await Book$.find(book_id)).item;
  if (!book || book.user_id != session.Login.id) return response.JSON("Not Your Book", session, 403);
  return book_id;
}

// in a controller
const book_id = await ownedBook(session, param);
if (book_id instanceof Response) return book_id;
const rows = (await Client$.where({ book_id: [book_id] }).get()).items;

Version differences

the@0.0.2 (deno.land/x)@puneetxp/the@0.1.x (JSR)
Update verbPOST /:idPATCH or PUT /:id
Upsert verbPATCHPATCH or PUT
Permanent deletenoneDELETE /perma_delete/:id (isuper)
Route matchthe last matching route winsthe first matching route wins
Not found“Not Found” with status 200404, and a 500 on exceptions
Rolesbug: everyone gets every rolefixed
CORSnoneon every response, plus OPTIONS
API keysnoneAuthorization: Bearer
Model.createchain .getInserted()sets .item

Benchmark

This is a simple JSON route measured with wrk -t2 -c10 -d10s, on an early release:

Requests/secAvg latency
THE router28,800398 µs
Oak17,403633 µs

That makes it roughly 1.6× Oak’s throughput on this micro-benchmark.