tcmts.server
server
The server API of a tcm app, as a library: the request, cookies, sessions and rate limits, typed tables and
transactions, and background jobs, as ordinary definitions you can read, pin and upgrade. tcm ships it, so
tcm lib add tcm server adds the release your tcm has, with no network (and tcm lib upgrade server the one a newer
tcm has). It's a tcm library, not an npm package: it holds everything its code uses, so a codebase that adds it has
every definition named.
import { HttpError, enqueue, every, request, table, transaction } from "#codebase/app/lib/server";
export type Note = { title: string; body: string; author: string };
export const notes = table<Note>("notes", { index: ["author"] });
export async function addNote(title: string, body: string): Promise<string> {
const author = request().headers.get("x-user") ?? "";
if (!author) throw new HttpError(401, "sign in first");
return transaction(async () => {
const id = await notes.insert({ title, body, author });
enqueue(notify, author, id);
return id;
});
}
export async function notify(author: string, id: string): Promise<void> {
console.log(`${author} wrote ${id}`);
}
export const nightly = every("0 3 * * *", async () => console.log(await notes.count()));
What it has
- The request:
request(),cookie(name),setCookie,deleteCookie;HttpError(status, message)for a failure meant for the caller (it runs anywhere, so browser code can tell one apart). - Sessions:
session(),setSession(data),clearSession(),revokeSessions(match): the data stays on the server, the cookie holds only an id. - Rate limits:
rateLimit(key, { max, per }), a 429 past the limit. - Tables:
table<Row>(name, opts)withput,insert,get,delete,update,find,findOne,count; rows checked against the row type, migrated from older ones (migrate),uniqueandindexfields. - Transactions:
transaction(fn)over every table, jobs queued when it commits. - Jobs:
enqueue(fn, ...args),enqueueAfter(seconds, fn, ...args),every(spec, fn)for a recurring one;runJobs()andwithRequest(init, fn)for tests.
tcm's runtime
It runs on tcm's server host, #tcm/host: the call being answered, the app's SQLite files, the store's lock and
transactions, and the job queue, one per process however many libraries use it. That's version 1 of tcm's host
protocol, as its manifest says: a tcm that doesn't serve that version won't build an app using it. Its manifest gives
table and every their roles, so a table declared with it keeps its row type's schema, and a definition every
makes runs on its schedule, wherever it's mounted.
Its versions follow semver from its API, as any package's do (releases).
- test/
- 8 definitions
Types
- CookieOptions type
- FindOptions type
- HttpError typeof HttpError
- A failure meant for the caller: its status and message reach the browser (other errors are hidden in production). It runs anywhere, so browser code can tell one apart (`error.name === "HttpError"`, `error.status`).
- RecurringJob type
- RowSchema type
- The schema tcm attaches to a table where it's declared (`Symbol.for("tcm.schema")`): its row type's, with that schema's hash, and each migrate step's parameter type's.
- ServerRequest type
- The HTTP request a server function is answering.
- Table typeof Table
- A typed, durable table: rows of `T` in .tcm/data/store.sqlite, by id. The row type is erased at runtime, but tcm knows it where the table is declared and attaches its schema (with a hash) to the table. Every write is checked against it and records the schema it was written with. A read of a row written under another schema checks that it still fits, else migrates it, else refuses: stored data never silently disagrees with the code reading it. Rows from newer code mean the caller is a stale page (410).
- TableOptions type
Jobs
- every (spec: string, fn: () => unknown) => RecurringJob
- A recurring job: `export const nightly = every("0 3 * * *", cleanup)` (five cron fields, UTC) or an interval like `every("15m", sync)`. tcm start / tcm dev run the jobs in the app's namespace, once per slot.
Server
- callNow (what: string) => ServerCall
- The call being answered, or why `what` can't be used here.
- clearSession () => void
- End the caller's session.
- compareFields (a: unknown, b: unknown) => number
- How `find` orders by a field: missing first, then numbers, dates, strings, anything else by its text.
- cookie (name: string) => string | undefined
- A cookie the request came with.
- deleteCookie (name: string, opts?: { path?: string | undefined; }) => void
- enqueue <A extends unknown[]>(fn: (...args: A) => unknown, ...args: A) => string
- Run `fn(...args)` in the background, soon, with retries (5 attempts, exponential backoff); returns the job id. `fn` must be a codebase function: the job records its hash, so it runs the code version that enqueued it. Arguments must be plain data. In a transaction, the job is queued when it commits.
- enqueueAfter <A extends unknown[]>(seconds: number, fn: (...args: A) => unknown, ...args: A) => string
- Like enqueue, after `seconds`.
- request () => ServerRequest
- The HTTP request this server function is answering.
- requestNow (what: string) => ServerCall & { request: ServerRequest; }
- The call being answered, which must be a request (a background job isn't one).
- revokeSessions (match: (data: Record<string, unknown>) => boolean) => number
- End every session whose data matches, e.g. `revokeSessions((s) => s.user === "ada")`; returns how many.
- runJobs (opts?: { all?: boolean | undefined; }) => Promise<{ done: number; failed: number; }>
- For tests: run the queued jobs that are due (all of them with `{ all: true }`), inline.
- session () => Record<string, unknown>
- The caller's session data (empty if none, expired or revoked).
- sessionOf (c: ServerCall) => { id: string; data: Record<string, unknown>; } | undefined
- The session the call's request names, if it's live.
- setCookie (name: string, value: string, opts?: CookieOptions) => void
- Send a cookie with the answer: HttpOnly and SameSite=Lax unless `opts` says, for the app's base path.
- setSession (data: Record<string, unknown>, maxAge?: number) => void
- Start a new session with this data for `maxAge` seconds (default 30 days). The data is stored server-side (.tcm/data/sessions.sqlite); the cookie holds only a fresh, unguessable id.
- sqlValue (v: unknown) => string | number | null
- What `json_extract` gives for a stored value: scalars as themselves (true as 1), anything else as its text.
- table <T>(name: string, opts?: TableOptions<T> | undefined) => Table<T>
- A typed, durable table, declared at the top level: `export const users = table<User>("users")`. tcm attaches the row type's schema: writes are checked against it, and rows written under an older type are checked, migrated with `migrate` (if given) or refused when read. Data lives in .tcm/data/store.sqlite.
- transaction <R>(fn: () => R | Promise<R>) => Promise<R>
- Run `fn` as one transaction over every table: all its writes happen, or, if it throws, none do. While it runs, other requests' table calls wait, so none sees its uncommitted rows or writes in between. Jobs it enqueues are queued when it commits, and dropped if it rolls back. A transaction inside one is a savepoint. Keep it short (it holds the store), and don't await anything inside that itself waits for a table.
- withRequest <T>(init: { method?: string | undefined; url?: string | undefined; headers?: Record<string, string>…
- Run `fn` as if answering a request, for tests of server functions: no network (sessions kept in memory).
Shared
- SESSION_COOKIE "tcm_sid"
- The cookie a session's id is in: the data stays on the server, so a session can be revoked.
- fieldSql (f: string) => string
- A table row field as SQL: indexes are on exactly this expression (fields are checked, so it's inlined).
- isScalar (v: unknown) => boolean
- A value SQLite compares as itself: a string, a finite number, a boolean or null.
- manifest { name: string; npm: boolean; host: number; roles: { table: string; every: string; }; }
- The server API of a tcm app, as a library: the request, cookies, sessions, rate limits, typed tables, transactions and background jobs. It runs on tcm's server host (`#tcm/host`, host protocol 1); `table` and `every` have the roles of those names, so a table declared with `table` keeps its row type's schema, and a definition `every` makes runs on its schedule.
- rateLimit (key: string, opts: { max: number; per: number; }) => void
- Allow `max` calls per `per` seconds for `key` (e.g. "login:" + request().ip); beyond that, throws a 429.
- rateWindows Map<string, number[]>
- Rate limits' sliding windows, by key: in this process's memory.
Files
- README file