toiljs 0.0.111 → 0.0.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +10 -0
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/toil-docs.generated.js +2 -2
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.d.ts +2 -0
- package/build/devserver/analytics/index.js +15 -9
- package/build/devserver/db/database.d.ts +1 -0
- package/build/devserver/db/database.js +49 -23
- package/build/devserver/runtime/module.js +1 -0
- package/docs/database/documents.md +40 -18
- package/docs/services/analytics.md +28 -17
- package/examples/basic/server/routes/Analytics.ts +6 -5
- package/package.json +3 -3
- package/src/compiler/toil-docs.generated.ts +2 -2
- package/src/devserver/analytics/index.ts +40 -17
- package/src/devserver/db/database.ts +51 -0
- package/src/devserver/runtime/module.ts +1 -0
- package/test/analytics-dev.test.ts +8 -8
|
@@ -28,7 +28,7 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
28
28
|
"concepts/types.md": "# The server type system\n\nYour backend is TypeScript, but a stricter, faster dialect of it. Numbers have an exact bit width (`u32`, `i64`, `f64`, and friends) instead of one loose `number`, and a few TypeScript features do not exist. This page explains the types you write in `server/` code.\n\n## Why the server types are different\n\nYour frontend TypeScript runs in a browser, where a JavaScript engine figures out types as it goes. Your **server** code is different: the **toilscript** compiler turns it into [WebAssembly](../backend/README.md) (WASM), a compact machine-like binary. To emit that binary, the compiler must know the *exact* size of every value ahead of time. \"A number\" is not enough; it needs to know \"a 32-bit unsigned integer\" so it can pick the right machine instruction and lay out memory.\n\ntoilscript is built on **AssemblyScript**, a strict subset of TypeScript designed to compile to WASM. So the server language looks and reads like TypeScript, but every number is a fixed-width type, and there is no room for the fuzzy parts of JavaScript. The payoff is speed and safety: your code compiles to something close to hand-written native code, and whole classes of bugs cannot occur.\n\nYou already know the syntax. You only need to learn the number types and a handful of rules.\n\n## The number types\n\nThere is no plain `number` in server code. Instead you pick the exact type. The name tells you three things: signed or unsigned, how many bits, integer or float.\n\n- The letter: `u` = unsigned integer (zero and up), `i` = signed integer (can be negative), `f` = floating-point (has a fractional part).\n- The number: how many bits wide it is.\n\n| Type | Kind | Bits | Range | Use it for |\n| --- | --- | --- | --- | --- |\n| `bool` | boolean | 1 | `true` / `false` | flags, yes/no |\n| `u8` | unsigned int | 8 | 0 to 255 | a byte, a small enum |\n| `u16` | unsigned int | 16 | 0 to 65,535 | ports, small counts |\n| `u32` | unsigned int | 32 | 0 to ~4.29 billion | sizes, ids, most counts |\n| `u64` | unsigned int | 64 | 0 to ~1.8e19 | timestamps (ms), big counters, large ids |\n| `i8` | signed int | 8 | -128 to 127 | tiny signed values |\n| `i16` | signed int | 16 | -32,768 to 32,767 | small signed values |\n| `i32` | signed int | 32 | ~-2.1 to 2.1 billion | the default integer; loop counters, deltas |\n| `i64` | signed int | 64 | ~-9.2e18 to 9.2e18 | large signed counts, database counters |\n| `f32` | float | 32 | ~7 decimal digits | low-precision decimals (rare) |\n| `f64` | float | 64 | ~15-16 decimal digits | any value with a fraction: prices, ratios, math |\n\nThere are also 128-bit and 256-bit integers (`u128`, `i128`, `u256`, `i256`) for cryptography and very large ids. See [Big integers](#big-integers-u128-to-u256) below.\n\n### Which one should I pick?\n\nWhen in doubt, use these defaults and you will rarely be wrong:\n\n- A counter, a size, a loop index, an id you do not do math on: **`u32`** (or `i32` if it can go negative).\n- A millisecond timestamp, a like count that might grow huge, or a value that must never wrap: **`u64`** / **`i64`**.\n- Anything with a decimal point (money, averages, percentages): **`f64`**.\n- A true/false flag: **`bool`**.\n- One raw byte, or an element of a byte buffer: **`u8`**.\n\nSmaller types do not make your program meaningfully faster; they exist to match binary layouts and save memory in big arrays. Default to 32-bit unless a value is genuinely large or genuinely a byte.\n\n## Integer overflow: it wraps, it does not throw\n\nThis is the single most important difference from everyday JavaScript. Integer math is **modular**: if a value goes past the top of its range, it silently wraps around to the bottom (two's complement). It never throws an error and never turns into a bigger type on its own.\n\n```ts\nlet x: u8 = 255;\nx = x + 1; // x is now 0, not 256 (wrapped around)\n\nlet y: u8 = 0;\ny = y - 1; // y is now 255 (wrapped the other way)\n\nlet z: i32 = 2147483647; // the largest i32\nz = z + 1; // z is now -2147483648 (wrapped to the minimum)\n```\n\nThe lesson: choose a type wide enough that your values cannot reach its edge. A counter that could exceed ~4.29 billion needs `u64`, not `u32`. Timestamps in milliseconds always use `u64`.\n\nFloating-point (`f32` / `f64`) does not wrap; it follows the usual IEEE rules (very large values become `Infinity`, `0/0` is `NaN`).\n\n## Casting between number types\n\nBecause types are explicit, the compiler will not silently mix them. To convert, cast. There are two equivalent spellings; use whichever reads better:\n\n```ts\nconst big: u64 = 300;\nconst small: u8 = big as u8; // \"as\" form\nconst small2 = u8(big); // call the type like a function\n```\n\nCasting a wider integer into a narrower one **truncates**: it keeps only the low bits, so `u8(300)` is `44` (300 wraps modulo 256). Casting a float to an integer drops the fraction toward zero (`i32(3.9)` is `3`). None of these throw, so cast deliberately.\n\n```ts\nconst f: f64 = 3.9;\nconst i = i32(f); // 3 (fraction dropped)\nconst n = u8(300); // 44 (truncated to 8 bits)\n```\n\nInteger division also truncates (it is not float division):\n\n```ts\nconst half = 5 / 2; // both operands are i32 -> result is 2, not 2.5\nconst real = 5.0 / 2.0; // f64 division -> 2.5\n```\n\n## `bool`\n\n`bool` is a real 1-bit type, not \"any truthy value\". Comparisons (`==`, `<`, `>=`) produce a `bool`, and `if`/`while` expect one. It stores as 0 or 1.\n\n## Big integers: `u128` to `u256`\n\nFor values too large for 64 bits, toilscript provides `u128`, `i128`, `u256`, and `i256` as native global types (no import needed). They support the normal operators (`+`, `-`, `*`, `/`, `==`, `<`, `<<`, and so on) through operator overloading, plus static helpers like `u256.fromString(...)` and instance methods like `.toString()`, `.toBytes()`, and `.toUint8Array()`.\n\n```ts\nconst a = u256.fromString('123456789012345678901234567890');\nconst b = a + u256.One;\nconst hex = b.toString(16);\n```\n\nThese are mainly for cryptography and very large ids. Everyday counts and sizes should stay in `u32` / `u64`.\n\n### `ToilUserId`: a 256-bit identity\n\nThe built-in auth system represents a logged-in user as a **`ToilUserId`**, a stable 256-bit value (four `u64` words) derived from their key, identifier, and your domain. It is a global type, like `crypto`. It is the right key to store per-user data on. Comparison is overloaded and O(1), and `.toHex()` gives you a 64-character string key.\n\n```ts\nconst id: ToilUserId = AuthService.userId()!; // the current user's stable id\nconst key = id.toHex(); // a convenient string key\n```\n\nFull reference (including the `==` null-check gotcha) is in [Extending auth](../auth/extending.md).\n\n## Strings\n\nStrings work as you expect: `'hello'`, template literals, `.length`, `.substring(...)`, `+` to concatenate. Under the hood a server string is **UTF-16** (the same 16-bit code units as JavaScript), so `.length` counts code units, not visible characters or bytes.\n\nWhen you need the **bytes** of a string (to hash it, write it to a binary body, or send it over a stream), encode it explicitly. UTF-8 is almost always what you want on the wire:\n\n```ts\nconst buf: ArrayBuffer = String.UTF8.encode('hello'); // UTF-8 bytes\nconst text: string = String.UTF8.decode(buf); // back to a string\n```\n\n`String.UTF8.byteLength(s)` gives the encoded byte length. There is a matching `String.UTF16` namespace when you need raw UTF-16 bytes.\n\n## Binary data: `Uint8Array`\n\nRaw bytes are a `Uint8Array` (a fixed-length array of `u8`), exactly like the browser type. It is the standard currency for request bodies, hashes, crypto keys, and stream packets.\n\n```ts\nconst bytes = new Uint8Array(32);\nbytes[0] = 0xff; // each element is a u8\nconst n: i32 = bytes.length;\n```\n\nYou will also see `StaticArray<u8>` (a fixed-size, lower-overhead byte array) and `ArrayBuffer` (a raw buffer that `Uint8Array` and the encoders view). For most app code, `Uint8Array` is all you need.\n\n## Arrays and collections\n\nTyped arrays and the familiar collection types are available and must be typed:\n\n```ts\nconst nums: i32[] = [1, 2, 3]; // Array<i32>\nconst names = new Array<string>();\nconst seen = new Map<string, u32>();\nconst tags = new Set<string>();\n```\n\n`Array`, `Map`, and `Set` behave like their JavaScript counterparts (`.push`, `.get`, `.has`, `.forEach`), but every element type is fixed at compile time. There is no mixed-type array.\n\n## Objects: `@data` classes, not object literals\n\nServer code has no free-form object type. A structured value is a **class**, and to send it between the browser and the server (or in and out of the database) you tag it `@data`. That makes the compiler generate a binary codec plus a matching client type. See [Data types](../backend/data.md).\n\n```ts\n@data\nclass Player {\n username: string = '';\n score: u64 = 0;\n constructor(username: string = '', score: u64 = 0) {\n this.username = username;\n this.score = score;\n }\n}\n```\n\n## Key differences from normal TypeScript\n\n| Thing | Normal TypeScript | Server (toilscript) |\n| --- | --- | --- |\n| Numbers | one `number` | explicit `u8` / `i32` / `u64` / `f64` / ... |\n| `number` type | everywhere | avoid it; pick a fixed-width type |\n| `any` | allowed | not allowed: everything is typed |\n| Integer overflow | numbers just get bigger | wraps around silently |\n| `/` on integers | `5 / 2` is `2.5` | `5 / 2` is `2` (truncates) |\n| Mixing number types | implicit | explicit cast (`x as u8` / `u8(x)`) |\n| npm packages | `import` anything | only toilscript's standard library and toiljs APIs |\n| `undefined` | common | use `null` (a `T | null`), not `undefined` |\n| Objects | `{ a: 1 }` literals | a `class` (usually `@data`) |\n| Strings | UTF-16 | UTF-16 (same), encode to bytes for the wire |\n\nA few rules worth stating plainly:\n\n- **No `any`.** Every value has a concrete type. This is what makes the code compile to WASM at all.\n- **No arbitrary npm.** Server code cannot pull in npm packages; it uses the toilscript standard library (the types on this page) plus the toiljs host APIs (database, crypto, email, and so on). This is the sandbox that keeps the edge safe.\n- **No plain `number`.** If you write `number`, it resolves to `f64` (a float), which is almost never what you want for an id or a count. Always pick an explicit type.\n- **Use `null`, not `undefined`,** for \"no value\": a nullable is written `T | null` and you narrow it with an `if (x != null)` check or a `!` assertion when you are sure.\n\n## How server types cross to the browser\n\nWhen a `@data` value or an RPC result travels to your frontend, the compiler maps each server type to a matching TypeScript type in the generated client. You do not write this mapping; it is generated. It matters because it tells you what your React code receives.\n\n| Server type | Browser (generated TS) |\n| --- | --- |\n| `u8`, `u16`, `u32`, `i8`, `i16`, `i32`, `f32`, `f64` | `number` |\n| `u64`, `i64`, `u128`, `i128`, `u256`, `i256` | `bigint` |\n| `bool` | `boolean` |\n| `string` | `string` |\n| a `@data` class `T` | the generated class `T` |\n| `T[]` | `T[]` |\n\nThe important row is the 64-bit-and-larger integers: they become `bigint` on the client and travel as decimal strings on the JSON wire, so they stay **exact at any size** (they never lose precision the way a giant JavaScript `number` would). See [RPC](../backend/rpc.md) for the generated client.\n\n## Related\n\n- [Data types (`@data`)](../backend/data.md): defining structs that cross the wire and the database.\n- [RPC and the generated client](../backend/rpc.md): how server types map to browser types.\n- [Extending auth](../auth/extending.md): the `ToilUserId` 256-bit identity in full.\n- [The database (ToilDB)](../database/README.md): the collections your typed keys and values live in.\n- [Decorators](./decorators.md): the decorators referenced throughout this page.\n",
|
|
29
29
|
"database/capacity.md": "# Capacity (limited stock without overselling)\n\nA `Capacity` collection models a **finite resource that must never be oversold**:\nconcert tickets, seats, limited-drop stock, rate grants. It uses a two-phase\n`reserve` then `confirm`/`cancel` pattern so two buyers can never grab the same\nlast unit.\n\n## What and why\n\nSome resources are strictly limited. There are 100 tickets, and selling 101 is a\nreal, expensive mistake. The classic bug is a race: two requests both read \"1\nleft\", both decide they can sell it, and both sell it. Now you have oversold.\n\nA `Capacity` collection prevents that. Instead of \"read, then decrement\" (two\nsteps a race can slip between), it does an atomic **hold**: the check that enough\nis available and the decrement happen as one indivisible step, at one place. And\nit adds a second phase so a shopper who starts a checkout but never pays does not\nlock up stock forever.\n\nThe lifecycle has three moves:\n\n- **`reserve`**: hold some units for a short time. The units come out of\n \"available\" immediately, and you get back a **reservation id**. If there is not\n enough available, the reserve fails (and nothing is oversold).\n- **`confirm`**: the shopper paid. Turn the hold into a permanent sale.\n- **`cancel`**: the shopper backed out. Release the hold back to available.\n\nIf a hold is neither confirmed nor cancelled within its time limit (its **TTL**,\ntime to live), it **auto-releases**. So an abandoned checkout heals itself.\n\n```mermaid\nsequenceDiagram\n participant Buyer\n participant App as Your route\n participant Cap as Capacity ledger\n Buyer->>App: start checkout\n App->>Cap: reserve(key, 2, ttl)\n Cap-->>App: reservationId (available drops by 2)\n Note over Cap: 2 units are held, not yet sold\n alt payment succeeds\n Buyer->>App: pay\n App->>Cap: confirm(key, reservationId)\n Cap-->>App: true (hold becomes a permanent sale)\n else buyer cancels\n Buyer->>App: cancel\n App->>Cap: cancel(key, reservationId)\n Cap-->>App: true (2 units returned to available)\n else buyer disappears\n Note over Cap: TTL passes, hold auto-releases (2 units returned)\n end\n```\n\nUse `Capacity` whenever selling one too many is unacceptable: tickets, seats,\ninventory for a limited drop, a pool of licences, or any \"N and no more\" resource.\n\n## Why not just a counter?\n\nA [Counter](./counters.md) can only `add` and `get`. To sell a ticket with a\ncounter you would read the remaining count, check it is above zero, and then\nsubtract one. Those are separate steps. Two requests can both read \"1 left\"\nbefore either subtracts, and both proceed: **oversold**. A counter has no way to\natomically check-and-decrement, and no notion of a temporary hold that expires.\n\n`Capacity` fixes both problems:\n\n- The availability check and the hold are **one atomic step**, so contenders are\n serialized: the first reserve takes the unit, the second sees zero available\n and fails.\n- The two-phase **reserve then confirm/cancel** means an unpaid, abandoned\n checkout does not permanently consume stock: its hold expires on the TTL.\n\nIf a small over- or under-count is harmless (likes, page views, an\napproximate inventory display), a counter is simpler and cheaper. If overselling\nby one is a real problem, use `Capacity`.\n\n## The type\n\n```ts\nCapacity<K>\n```\n\n`Capacity` has a single type parameter, the **key** (`K`): it picks *which*\nresource. There is no value type, because the value is a private ledger the\nplatform manages for you (it tracks the total, the confirmed sales, and the live\nholds). For one ticketed event, the key is the event id.\n\nDeclare it as a `@collection` field on a `@database` class:\n\n```ts\n@data\nclass ShowKey {\n show: string = '';\n constructor(show: string = '') { this.show = show; }\n}\n\n@database\nclass TicketsDb {\n @collection static seats: Capacity<ShowKey>;\n}\n```\n\n## Operations\n\nFive operations, matching the toilscript API exactly. Exact signatures:\n\n| Operation | Signature | What it does |\n| --- | --- | --- |\n| `available` | `available(key: K): i64` | How many units can be reserved right now. |\n| `reserve` | `reserve(key: K, amount: i64, ttlMs: i64): u64` | Hold `amount` units for `ttlMs` milliseconds. Returns a reservation id (> 0), or `0` if there was not enough. |\n| `confirm` | `confirm(key: K, reservationId: u64): bool` | Turn a hold into a permanent sale. Returns whether the id was valid. |\n| `cancel` | `cancel(key: K, reservationId: u64): bool` | Release a hold back to available. Returns whether it was released. |\n| `setTotal` | `setTotal(key: K, total: i64): void` | Set the ceiling (seed or restock). Background task only. |\n\nThe core accounting the platform maintains for you:\n\n```txt\navailable = total - confirmed - held\n```\n\n`total` is the ceiling you set. `confirmed` is units permanently sold. `held` is\nunits currently reserved but not yet confirmed. `available` never goes below zero.\n\n### `available`\n\nReads how many units could be reserved right now. It is a keyed read, legal from\nany handler including a `@get`.\n\n```ts\nconst left = TicketsDb.seats.available(new ShowKey('jazz-night'));\n```\n\n### `reserve`\n\nHolds `amount` units for `ttlMs` milliseconds and returns a reservation id. This\nis the operation that prevents overselling.\n\n```ts\nconst id = TicketsDb.seats.reserve(new ShowKey('jazz-night'), 2, 120_000); // hold 2 for 2 minutes\nif (id == 0) {\n // Not enough available. This is a normal outcome, NOT an error: no oversell.\n return Response.conflict('sold out');\n}\n// id > 0: you now hold 2 units for up to 2 minutes. Keep the id.\n```\n\nTwo things to know:\n\n- A return of `0` means \"not enough available\". It is the safe, expected answer\n when the resource is (nearly) sold out. Always check for it.\n- The hold auto-releases after `ttlMs` if you do not confirm or cancel it. Choose\n a TTL that covers a realistic checkout (a couple of minutes), not hours.\n\n`reserve` is a **write**, so call it from an action handler (`@post`, `@put`,\n`@patch`, `@del`), not from a `@get`.\n\n### `confirm`\n\nTurns a hold into a permanent sale. Call it once payment (or whatever finalizes\nthe deal) succeeds.\n\n```ts\nconst ok = TicketsDb.seats.confirm(new ShowKey('jazz-night'), id);\n// ok === true -> the id was valid; those units are now permanently sold\n// ok === false -> the id was unknown (never reserved, or already expired)\n```\n\nA confirmed sale is **permanent**: it can never be cancelled and its TTL never\nreclaims it. Confirm is safe to call more than once for the same id (it stays\nconfirmed).\n\n### `cancel`\n\nReleases a hold back to available. Call it when the shopper backs out before\npaying.\n\n```ts\nconst released = TicketsDb.seats.cancel(new ShowKey('jazz-night'), id);\n// released === true -> the hold was released, units are available again\n// released === false -> the id was unknown, already expired, OR already confirmed\n```\n\nYou **cannot** cancel a confirmed sale (a sale is final). `cancel` returns `false`\nin that case.\n\n### `setTotal`\n\nSets the ceiling: the total number of units. You call it to **seed** a new\nresource (\"this show has 100 seats\") and to **restock** (\"we opened the balcony,\nnow 150\"). Existing holds and confirmed sales are untouched; only the ceiling\nmoves, and `available` reflects the new total.\n\n`setTotal` is a **privileged** operation: it may only run from a background task\n(a `@job`), never from a request handler. Seeding and restocking are\nadministrative actions, so they live off the request path. See\n[background tasks](../background/README.md).\n\n```ts\n@database\nclass TicketsDb {\n @collection static seats: Capacity<ShowKey>;\n\n // A background task seeds/restocks the ceiling. This runs off the request path.\n @job\n openSeats(): void {\n TicketsDb.seats.setTotal(new ShowKey('jazz-night'), 100);\n }\n}\n```\n\nNote that lowering the total below what is already sold plus held does not\ncancel anything (confirmed sales are permanent); `available` simply floors at\nzero until holds clear.\n\n## The lifecycle in words\n\n1. **Seed.** A background `@job` calls `setTotal(key, 100)`. Now `available` is\n 100.\n2. **Reserve.** A buyer starts checkout. Your `@post` calls `reserve(key, 1,\n ttl)` and gets an id. `available` drops to 99. The unit is *held*, not sold.\n3. **Finalize, one of:**\n - **Confirm.** Payment succeeds; your `@post` calls `confirm(key, id)`. The\n unit is now permanently sold. `available` stays 99.\n - **Cancel.** The buyer backs out; your `@post` calls `cancel(key, id)`. The\n unit returns to available; `available` goes back to 100.\n - **Expire.** The buyer vanishes. After `ttl` passes, the hold auto-releases;\n `available` goes back to 100 on its own.\n\nAt no point can the confirmed count exceed the total. That is the guarantee.\n\n## Worked example: selling limited tickets\n\n```ts\nimport { ShowKey } from '../models/ShowKey';\nimport { ReserveRequest } from '../models/ReserveRequest';\nimport { FinalizeRequest } from '../models/FinalizeRequest';\nimport { ReserveResult } from '../models/ReserveResult';\n\n@database\nclass TicketsDb {\n @collection static seats: Capacity<ShowKey>;\n\n // Background task: seed the show's capacity (and restock if you reopen it).\n @job\n openSeats(): void {\n TicketsDb.seats.setTotal(new ShowKey('jazz-night'), 100);\n }\n}\n\n@rest('tickets')\nclass Tickets {\n // How many seats are left (a keyed read, legal in a GET).\n @get('/jazz-night/available')\n public left(): i64 {\n return TicketsDb.seats.available(new ShowKey('jazz-night'));\n }\n\n // Start checkout: hold the seats. Returns a reservation id, or 0 if sold out.\n @post('/jazz-night/reserve')\n public reserve(input: ReserveRequest): ReserveResult {\n const id = TicketsDb.seats.reserve(new ShowKey('jazz-night'), input.count, 120_000);\n // id === 0 means not enough available. No oversell; tell the buyer honestly.\n return new ReserveResult(id, id != 0);\n }\n\n // Payment succeeded: make the hold a permanent sale.\n @post('/jazz-night/confirm')\n public confirm(input: FinalizeRequest): bool {\n return TicketsDb.seats.confirm(new ShowKey('jazz-night'), input.reservationId);\n }\n\n // Buyer backed out before paying: release the hold.\n @post('/jazz-night/cancel')\n public cancel(input: FinalizeRequest): bool {\n return TicketsDb.seats.cancel(new ShowKey('jazz-night'), input.reservationId);\n }\n}\n```\n\nThe models:\n\n```ts\n@data\nexport class ReserveRequest {\n count: i64 = 1;\n}\n\n@data\nexport class FinalizeRequest {\n reservationId: u64 = 0;\n}\n\n@data\nexport class ReserveResult {\n reservationId: u64 = 0;\n ok: bool = false;\n constructor(reservationId: u64 = 0, ok: bool = false) {\n this.reservationId = reservationId;\n this.ok = ok;\n }\n}\n```\n\nEven if a thousand people hit `reserve` at the same instant for the last few\nseats, the ledger serializes them: the first buyers get ids, the rest get `0`.\nThe total is never exceeded.\n\n## Consistency\n\nUnlike most ToilDB families, `Capacity` is **strongly consistent**, and that is\nthe whole point. Every reserve/confirm/cancel for a key is routed to that key's\none home location and applied there in order, at a single serialization point. So\neven though ToilDB spans many regions, there is exactly one place that decides\nwhether a reserve succeeds. That is what makes \"never oversell\" a hard guarantee\nrather than a best effort. `available` reflects that home ledger.\n\n## Gotchas\n\n- **Check for `0` from `reserve`.** A `0` is \"not enough available\", the normal\n sold-out answer, not an error. Do not treat it as a failure to retry blindly.\n- **Always finalize a reservation.** After a successful `reserve`, you should\n `confirm` it (paid) or `cancel` it (abandoned). If you do neither, the hold\n sits until its TTL expires, temporarily reducing availability.\n- **Pick a sensible TTL.** Too short and a slow-but-real checkout loses its hold\n mid-payment. Too long and abandoned carts starve real buyers. A couple of\n minutes is typical for a checkout.\n- **Confirmed sales are permanent.** You cannot `cancel` a confirmed sale, and\n its TTL never reclaims it. If you support refunds, model that as your own\n restock (raise `setTotal`), not as a cancel.\n- **`reserve` is not automatically deduplicated.** Each call to `reserve` creates\n a new, distinct hold. If a client might retry the same reserve, avoid holding\n twice: reserve once, keep the returned id, and drive `confirm`/`cancel` off\n that id.\n- **`setTotal` is background-only.** You cannot set the total from a route. Seed\n and restock from a `@job`.\n- **There is a per-key cap on live holds.** A key can carry only so many\n simultaneous unconfirmed holds; a flood of holds beyond that is rejected as\n \"not enough available\" (and abandoned holds clear on their TTL). Normal traffic\n never hits this; it is a guardrail against a hold flood.\n\n## Related\n\n- [Counters](./counters.md): a running total when overselling is not a concern\n (and why it cannot guarantee a limit).\n- [Documents](./documents.md): store the order/booking record that a confirmed\n sale produces.\n- [background tasks](../background/README.md): where `setTotal` (seeding/restock)\n runs.\n- [Data types (`@data`)](../concepts/types.md): how the capacity key is stored.\n- [Decorators](../concepts/decorators.md): which handler kinds may reserve,\n confirm, and cancel.\n",
|
|
30
30
|
"database/counters.md": "# Counters\n\nThe **Counter** family is a distributed running total that many callers can increase at the same time, from anywhere in the world, without ever losing a count. It is how you build likes, view counts, and inventory tallies correctly.\n\n## What and why\n\nA **Counter collection** maps a `@data` key to a single whole number. You do just two things with it: read the current total (`get`) and add to it (`add`). There is deliberately **no \"set to this value\"** operation, and that missing operation is the entire point of the family, as the next section explains.\n\nReach for a Counter whenever the thing you are storing is a number that many requests bump concurrently:\n\n- likes or upvotes on a post\n- page views or play counts\n- a stock or quantity tally\n- any \"increase this by N\" metric\n\nDeclare one by typing a `@collection` as `Counter<Key>`. Notice there is no value type: the value is always a number the database manages for you.\n\n```ts\n@data\nclass PostId {\n id: string = '';\n constructor(id: string = '') { this.id = id; }\n}\n\n@database\nclass AppDb {\n @collection static likes: Counter<PostId>;\n @collection static views: Counter<PostId>;\n}\n```\n\n## The operations\n\n`K` is the key type. The value is always a 64-bit signed integer (`i64`).\n\n| Operation | Signature | Returns | Use it to |\n| --- | --- | --- | --- |\n| `get` | `get(key: K): i64` | the current total (`0` if nothing has been added) | read the count |\n| `add` | `add(key: K, delta: i64): void` | nothing | change the count by `delta` (may be negative) |\n\n`get` is a read (works in any function). `add` is a write, so it needs an **Action** (a `@post` route or an `@action`); see [Setup](./setup.md#how-access-is-gated-query-action-and-friends).\n\n### `get`\n\n`get` returns the current total. A counter that has never been touched reads as `0`, not `null`: there is no \"absent\" state to handle.\n\n```ts\nconst likeCount: i64 = AppDb.likes.get(new PostId('p_42'));\n```\n\n### `add`\n\n`add` changes the total by a delta. The delta can be positive to increase or negative to decrease:\n\n```ts\nAppDb.likes.add(new PostId('p_42'), 1); // one more like\nAppDb.likes.add(new PostId('p_42'), -1); // undo a like\nAppDb.stock.add(new SkuId('sku_9'), -3); // sold three\n```\n\n`add` saturates at the `i64` limits: it will not wrap around from a huge positive to a negative if you somehow overflow. Note that `add` returns nothing, not the new total. If you need the total right after adding, call `get` (bearing in mind the consistency note below).\n\n## Why a Counter, and not a number in a Documents record?\n\nThis is the question the family exists to answer, so it is worth walking through.\n\nSuppose you stored the like count as a field in a Documents record and incremented it the obvious way: read the record, add one, write it back. Now two servers on opposite sides of the world each get a like at the same moment:\n\n```mermaid\nsequenceDiagram\n participant Tokyo as Server (Tokyo)\n participant Paris as Server (Paris)\n participant DB as Documents record\n\n Note over DB: likes = 10\n Tokyo->>DB: read likes -> 10\n Paris->>DB: read likes -> 10\n Tokyo->>DB: write likes = 11\n Paris->>DB: write likes = 11\n Note over DB: likes = 11 (one like LOST)\n```\n\nBoth read `10`, both wrote `11`, and one like vanished. This is a **lost update**, and it happens whenever two callers do read-modify-write on the same value at once. It is not a rare edge case on a busy post; it is the normal case.\n\nA Counter avoids this entirely because you never send a final value, you send a **delta**. Each server just says \"add 1.\" The database merges the two deltas into \"add 1, add 1 = add 2,\" so the total goes from 10 to 12 and nothing is lost. Deltas are **commutative** (the order they arrive in does not matter) and **additive**, so concurrent increments from anywhere in the world always combine correctly. That property is what \"conflict-free\" means: there is no conflict to resolve, because two adds are never at odds.\n\nThis is also why there is no `set` operation. A `set` would reintroduce the exact race above (two callers overwriting each other). By offering only `add`, the Counter family makes the lost-update bug impossible to write.\n\n**Use a Counter when** the number is bumped concurrently by many requests. **Use a Documents field instead when** the number is only ever changed by a single logical owner in a controlled way (for example, a value you always overwrite with a freshly computed absolute figure, not an increment), where you would use `patch` to store the whole new value.\n\n## Worked example: page views and likes\n\nHere is a small feature that counts views on every read and likes on demand, then reports both.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@data\nclass PostId {\n id: string = '';\n constructor(id: string = '') { this.id = id; }\n}\n\n@data\nclass Stats {\n views: i64 = 0;\n likes: i64 = 0;\n}\n\n@database\nclass AppDb {\n @collection static views: Counter<PostId>;\n @collection static likes: Counter<PostId>;\n}\n\n@rest('posts')\nclass Posts {\n // POST /posts/:id/view -> count a view, return the running totals (Action)\n @post('/:id/view')\n public view(ctx: RouteContext): Stats {\n const key = new PostId(ctx.param('id'));\n AppDb.views.add(key, 1);\n const s = new Stats();\n s.views = AppDb.views.get(key);\n s.likes = AppDb.likes.get(key);\n return s;\n }\n\n // POST /posts/:id/like -> add a like (Action)\n @post('/:id/like')\n public like(ctx: RouteContext): Stats {\n const key = new PostId(ctx.param('id'));\n AppDb.likes.add(key, 1);\n const s = new Stats();\n s.views = AppDb.views.get(key);\n s.likes = AppDb.likes.get(key);\n return s;\n }\n\n // GET /posts/:id/stats -> just read the totals (Query: read-only)\n @get('/:id/stats')\n public stats(ctx: RouteContext): Stats {\n const key = new PostId(ctx.param('id'));\n const s = new Stats();\n s.views = AppDb.views.get(key);\n s.likes = AppDb.likes.get(key);\n return s;\n }\n}\n```\n\nCounting a view lives in a `@post` because it is a write (a counter `add`), even though it feels like part of a read. The `@get` stats route only reads, so it runs as a Query.\n\n## Consistency notes\n\n- **Counters are eventually consistent.** `get` returns the total known to the copy nearest you, which may briefly trail increments applied moments ago in other regions. The copies converge quickly, so the count catches up on its own.\n- **No increment is ever lost.** That is the strong guarantee: every `add` from anywhere is eventually included in the total, because deltas merge. The total may be a little behind, but it is never wrong in the \"lost a like\" sense.\n- **Monotonic when you only add positives.** If every `add` is positive, the total only ever goes up as it converges. Mixing in negative `add`s (undoing a like, selling stock) is fine and correct; it simply means the total is not monotonic.\n- Because of the lag, do not treat the value `get` returns immediately after an `add` as a globally final figure. It is a fast, close-enough total, which is exactly right for likes and views.\n\n## Gotchas\n\n- **There is no `set`.** To move a counter to a specific number, add the difference (`add(key, target - get(key))`), but be aware that read-then-add reintroduces a race if two callers do it at once. Prefer to think in deltas.\n- **`add` does not return the new total.** Call `get` after if you need it, remembering it may lag concurrent remote adds.\n- **A never-touched counter reads `0`, not `null`.** There is no \"does this counter exist\" check; every key answers `0` until something is added.\n- **Counters hold a number, not a record.** If you need per-key fields alongside the count, keep the record in [Documents](./documents.md) and the tally in a Counter, keyed the same way (the example above effectively does this with two counters).\n\n## Related\n\n- [ToilDB overview](./README.md): the seven families and how to choose.\n- [Setup](./setup.md): declaring the collection and which function kinds may write.\n- [Documents](./documents.md): when the number is part of a record you overwrite wholesale.\n- [Events](./events.md): when you need the individual events, not just a total.\n- [Data types (`@data`)](../backend/data.md): the counter's key type.\n",
|
|
31
|
-
"database/documents.md": "# Documents\n\nThe **Documents** family is ToilDB's general-purpose record store: you keep a value under a key and look it up, update it, or delete it by that key. It is the family you will use most, and the one to reach for whenever the other six do not obviously fit.\n\n## What and why\n\nA **Documents collection** maps a `@data` key to a `@data` value: one value per key, which you can read, replace, and remove. Think users by user id, posts by post id, orders by order number. If you are storing \"a thing with fields that I look up and update by its id,\" this is the family.\n\nDeclare one by typing a `@collection` as `Documents<Key, Value>`:\n\n```ts\n@data\nclass UserId {\n id: string = '';\n constructor(id: string = '') { this.id = id; }\n}\n\n@data\nclass User {\n id: string = '';\n name: string = '';\n email: string = '';\n score: u64 = 0;\n}\n\n@database\nclass AppDb {\n @collection static users: Documents<UserId, User>;\n}\n```\n\n## The operations\n\nHere is every operation, its shape, and what it gives back. `K` is your key type, `V` your value type.\n\n| Operation | Signature | Returns | Use it to |\n| --- | --- | --- | --- |\n| `get` | `get(key: K): V \\| null` | the value, or `null` if absent | read one record |\n| `require` | `require(key: K): V` | the value; **traps** if absent | read a record you are sure exists |\n| `getMany` | `getMany(keys: K[]): Array<V \\| null>` | one entry per key, in order, each value or `null` | read several records in one call |\n| `exists` | `exists(key: K): bool` | `true` if the record is present | check presence without reading the value |\n| `create` | `create(key: K, value: V): bool` | `true` if inserted, `false` if the key was already taken | add a **new** record without overwriting |\n| `patch` | `patch(key: K, value: V): V` | the newly stored value; **traps** if the record is absent | replace an **existing** record's value |\n| `enqueue` | `enqueue(key: K, value: V): bool` | `true` if applied, `false` if a concurrent write won first or the record is absent | a version-checked (compare-and-swap) overwrite of an existing record |\n| `delete` | `delete(key: K): void` | nothing (idempotent) | remove a record |\n| `getDelete` | `getDelete(key: K): V \\| null` | the value that was there, or `null`; removes it atomically | consume a record exactly once |\n\nWhich kind of function may call which operation is covered in [Setup](./setup.md#how-access-is-gated-query-action-and-friends). In short: reads (`get`, `getMany`, `exists`) work anywhere; writes (`create`, `patch`, `enqueue`, `delete`, `getDelete`) need an **Action** (a `@post` route or an `@action`).\n\n### Reading: `get`, `require`, `exists`, `getMany`\n\n`get` is the everyday read. It returns the value or `null`, so you handle \"not found\" explicitly:\n\n```ts\nconst user = AppDb.users.get(new UserId('u_123'));\nif (user == null) {\n return Response.notFound();\n}\n// user is a fully typed User here\n```\n\n`require` is `get` for the case where absence is a bug, not a normal outcome: it returns the value directly and traps (aborts the request) if the record is missing. Use it only when you have already guaranteed the record exists.\n\n`exists` answers \"is there a record here?\" without paying to decode the value. It is handy as a cheap precondition:\n\n```ts\nif (AppDb.users.exists(new UserId(name))) {\n // username already registered, do not overwrite\n}\n```\n\n`getMany` reads several keys in a single operation. You hand it an array of keys; you get back an array the same length and in the same order, each entry either the value or `null` for a key that was absent. Reach for it instead of a loop of `get` calls when you already know the handful of keys you need.\n\n```ts\nconst ids = [new UserId('a'), new UserId('b'), new UserId('c')];\nconst found: Array<User | null> = AppDb.users.getMany(ids);\n// found[0] lines up with ids[0], and so on; each is a User or null\n```\n\n`getMany` is a **bounded batch of point reads**, not a scan: the number of keys you may pass is capped by the request budget, and it never walks the whole collection. There is no \"get all records\" operation on a request path, by design (an unbounded scan could fan out across a huge collection). If you need \"the latest N of something,\" model it as [Events](./events.md) or precompute a [View](./views.md).\n\n### Writing: `create` vs `patch` vs `enqueue`\n\nThese three all put a value under a key, but they differ in one important way each. Choosing correctly is the heart of using this family.\n\n```mermaid\nflowchart TD\n START[\"I want to write a record\"] --> Q1{\"Is this a brand-new<br/>record that must not<br/>clobber an existing one?\"}\n Q1 -->|Yes| CREATE[\"create<br/>(insert only; false if the key exists)\"]\n Q1 -->|\"No, it already exists\"| Q2{\"Must the write fail if another<br/>request changed the record<br/>since I read it?\"}\n Q2 -->|\"Yes, guard against a lost update\"| ENQUEUE[\"enqueue<br/>(version-checked CAS; false if<br/>someone wrote first, then retry)\"]\n Q2 -->|\"No, last write wins\"| PATCH[\"patch<br/>(unconditional overwrite;<br/>returns the new value)\"]\n```\n\n**`create` inserts a new record.** It only writes if the key is free. If the key already has a record, `create` does nothing and returns `false`. This is your tool for \"sign up a new user\" or \"claim this order id,\" where accidentally overwriting an existing record would be a bug. Because every key is serialized at its home (see [eventual consistency](./README.md#eventual-consistency-in-plain-words)), `create` is race-safe: if two requests create the same key at the same instant, exactly one gets `true` and the other gets `false`.\n\n```ts\nconst ok = AppDb.users.create(new UserId(input.id), input);\nif (!ok) {\n return Response.text('that id is taken', 409);\n}\n```\n\n**`patch` overwrites an existing record** and returns the value now stored. The record **must already exist**: `patch` on a missing key traps (aborts the request), so create it first. Despite the name, `patch` replaces the whole value; there is no field-level partial update, so read the current value, change the fields you want, and patch the whole thing back:\n\n```ts\nconst current = AppDb.users.get(new UserId('u_123'));\nif (current == null) return Response.notFound();\ncurrent.score = current.score + 10;\nconst saved: User = AppDb.users.patch(new UserId('u_123'), current);\n// saved is what is now stored\n```\n\n**`enqueue` is a version-checked overwrite (a compare-and-swap).** Like `patch`, it replaces the whole value of an **existing** record, but it does so *only if the record has not changed since you read it*. A **compare-and-swap** (CAS) is exactly that: \"write my new value, but only if the current value is still the one I saw.\" It returns a `bool`: `true` means your write was applied; `false` means either a concurrent write changed the record first (someone else beat you to it) or the record is absent. A `false` is **not an error**; it is the signal to **re-read and try again**. This approach is called **optimistic concurrency**: rather than locking the record, you assume nobody else will touch it, and you simply re-run the update on the rare occasion someone did.\n\nReach for `enqueue` when several requests may update the *same* record at once and you must not silently lose any of their changes. A plain `patch` cannot promise that: two overlapping patches clobber each other (the last writer wins and the earlier update just vanishes). The intended pattern for `enqueue` is always a read-modify-CAS **retry loop**:\n\n```ts\nconst key = new UserId('u_123');\nfor (let attempt = 0; attempt < 5; attempt++) {\n const current = AppDb.users.get(key); // 1. read the current value\n if (current == null) return Response.notFound();\n current.score = current.score + 10; // 2. modify your copy\n if (AppDb.users.enqueue(key, current)) { // 3. try to commit it\n return Response.text('ok'); // true: applied, we are done\n }\n // false: someone wrote between our get and our enqueue.\n // Loop: re-read the now-newer value and reapply the change on top of it.\n}\nreturn Response.text('too much contention, try again later', 409);\n```\n\nBecause every retry re-reads the latest value, the two updates **compose** (both `+10`s land) instead of one silently overwriting the other. If you do not need this guard (only one writer touches the key, or last-write-wins is genuinely fine), a plain `patch` is simpler and also hands you the stored value back directly.\n\n> There is no single \"create or overwrite\" (upsert) call. To get that behavior, try `create` first and fall back to `patch` if the key was taken:\n>\n> ```ts\n> const key = new UserId(input.id);\n> if (!AppDb.users.create(key, input)) {\n> AppDb.users.patch(key, input);\n> }\n> ```\n\n### Removing: `delete` and `getDelete`\n\n`delete` removes a record. It is **idempotent**: deleting a key that is already gone is not an error, it just does nothing. So you can call it without first checking that the record exists.\n\n```ts\nAppDb.users.delete(new UserId('u_123'));\n```\n\n`getDelete` is the atomic **fetch-and-remove**: in one indivisible step it reads the current value and deletes it, returning what it removed (or `null` if there was nothing). \"Atomic\" here means no other request can slip in between the read and the delete, so exactly one caller can ever receive a given value. That makes it the right tool for **consume-once** data: one-time login challenges, single-use invite codes, password-reset tokens. The PQ-auth demo uses it to consume a login challenge exactly once, so a challenge cannot be replayed:\n\n```ts\nconst challenge = AppDb.challenges.getDelete(new ChallengeId(cid));\nif (challenge == null) return fail(); // unknown, already used, or expired\n// ...verify against challenge...\n```\n\nIf two requests race to `getDelete` the same key, only one gets the value; the other gets `null`. That is the guarantee a plain `get` then `delete` cannot give you, because two racers could both `get` the value before either `delete`s it.\n\n## A full worked example: a small CRUD entity\n\nPutting it together, here is a complete `notes` resource: create, read, update, and delete, backed by a Documents collection.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n// ---- key + value ----\n@data\nclass NoteId {\n id: string = '';\n constructor(id: string = '') { this.id = id; }\n}\n\n@data\nclass Note {\n id: string = '';\n title: string = '';\n body: string = '';\n updatedAt: u64 = 0;\n}\n\n// ---- database ----\n@database\nclass NotesDb {\n @collection static notes: Documents<NoteId, Note>;\n}\n\n// ---- routes ----\n@rest('notes')\nclass Notes {\n // GET /notes/:id -> read one (Query: read-only)\n @get('/:id')\n public read(ctx: RouteContext): Response {\n const note = NotesDb.notes.get(new NoteId(ctx.param('id')));\n if (note == null) return Response.notFound();\n return Response.json(note.toJSON().toString());\n }\n\n // POST /notes -> create a new one, refusing a duplicate id (Action: may write)\n @post('/')\n public create(input: Note): Response {\n input.updatedAt = <u64>(Date.now() / 1000);\n if (!NotesDb.notes.create(new NoteId(input.id), input)) {\n return Response.text('id already exists', 409);\n }\n return Response.json(input.toJSON().toString());\n }\n\n // POST /notes/:id -> overwrite an existing note (Action)\n @post('/:id')\n public update(input: Note, ctx: RouteContext): Response {\n const key = new NoteId(ctx.param('id'));\n if (!NotesDb.notes.exists(key)) return Response.notFound();\n input.id = ctx.param('id');\n input.updatedAt = <u64>(Date.now() / 1000);\n const saved = NotesDb.notes.patch(key, input);\n return Response.json(saved.toJSON().toString());\n }\n\n // POST /notes/:id/delete -> remove one (Action)\n @post('/:id/delete')\n public remove(ctx: RouteContext): Response {\n NotesDb.notes.delete(new NoteId(ctx.param('id')));\n return Response.text('deleted');\n }\n}\n```\n\nThat is a complete persistent CRUD entity. Run it under `toiljs dev` and the notes survive across requests; deploy it and the same code stores them worldwide on the edge.\n\n## Consistency notes\n\nDocuments follows ToilDB's general model (see [the overview](./README.md#eventual-consistency-in-plain-words)):\n\n- **Writes to one key are serialized at that key's home**, so `create` is race-safe and `patch`/`enqueue`/`getDelete` never corrupt a record under concurrency.\n- **Reads are eventually consistent across regions.** Right after a write, a read from a far-away region may briefly still see the old value (or, for a just-created record, not see it yet). The copies converge within moments.\n- Because `patch` replaces the whole value, two updates to *different* fields of the same record can clobber each other if they overlap (read-modify-write races). `enqueue`'s version check is exactly the guard against that: use the read-modify-CAS retry loop shown above so a lost update turns into a retry instead of silent data loss. If you find yourself contending on one hot record a lot, a counter or a set is often a better fit than a Documents value. See [Counters](./counters.md) and [Membership](./membership.md).\n\n## Gotchas\n\n- **`patch` requires an existing record.** Calling it on a missing key traps the request. Use `create` for new records, or the `create`-then-`patch` upsert pattern above.\n- **`patch` replaces the whole value.** There is no field-level merge; read, modify, and write back the full value.\n- **`enqueue` returning `false` is not a failure.** It means a concurrent write beat you to the record (or the record is absent), so re-read and retry in a loop; never ignore the return value or treat `false` as a hard error. `enqueue` also does not hand back the stored value; use `patch` when you want the value returned and last-write-wins is acceptable.\n- **No \"get all.\"** There is no scan on the request path. Use `getMany` for known keys, and [Events](./events.md) or a [View](./views.md) for \"the latest N.\"\n- **`getDelete`, not `get` + `delete`, for consume-once.** Only `getDelete` guarantees exactly one caller receives the value.\n\n## Related\n\n- [ToilDB overview](./README.md): the seven families and how to choose.\n- [Setup](./setup.md): declaring the collection and which function kinds may write.\n- [Data types (`@data`)](../backend/data.md): keys and values.\n- [Counters](./counters.md): when you are really just counting.\n- [Events](./events.md) and [Views](./views.md): for \"the latest N\" and precomputed reads.\n",
|
|
31
|
+
"database/documents.md": "# Documents\n\nThe **Documents** family is ToilDB's general-purpose record store: you keep a value under a key and look it up, update it, or delete it by that key. It is the family you will use most, and the one to reach for whenever the other six do not obviously fit.\n\n## What and why\n\nA **Documents collection** maps a `@data` key to a `@data` value: one value per key, which you can read, replace, and remove. Think users by user id, posts by post id, orders by order number. If you are storing \"a thing with fields that I look up and update by its id,\" this is the family.\n\nDeclare one by typing a `@collection` as `Documents<Key, Value>`:\n\n```ts\n@data\nclass UserId {\n id: string = '';\n constructor(id: string = '') { this.id = id; }\n}\n\n@data\nclass User {\n id: string = '';\n name: string = '';\n email: string = '';\n score: u64 = 0;\n}\n\n@database\nclass AppDb {\n @collection static users: Documents<UserId, User>;\n}\n```\n\n## The operations\n\nHere is every operation, its shape, and what it gives back. `K` is your key type, `V` your value type.\n\n| Operation | Signature | Returns | Use it to |\n| --- | --- | --- | --- |\n| `get` | `get(key: K): V \\| null` | the value, or `null` if absent | read one record |\n| `require` | `require(key: K): V` | the value; **traps** if absent | read a record you are sure exists |\n| `getMany` | `getMany(keys: K[]): Array<V \\| null>` | one entry per key, in order, each value or `null` | read several records in one call |\n| `exists` | `exists(key: K): bool` | `true` if the record is present | check presence without reading the value |\n| `create` | `create(key: K, value: V): bool` | `true` if inserted, `false` if the key was already taken | add a **new** record without overwriting |\n| `patch` | `patch(key: K, value: V): V` | the newly stored value; **traps** if the record is absent | replace an **existing** record's value |\n| `upsert` | `upsert(key: K, value: V): UpsertResult` | `UpsertResult.Created` or `.Updated`; `.Conflict` on a `@unique` collision (nothing written) | create **or** overwrite in one op (last-writer-wins) |\n| `enqueue` | `enqueue(key: K, value: V): bool` | `true` if applied, `false` if a concurrent write won first or the record is absent | a version-checked (compare-and-swap) overwrite of an existing record |\n| `delete` | `delete(key: K): void` | nothing (idempotent) | remove a record |\n| `getDelete` | `getDelete(key: K): V \\| null` | the value that was there, or `null`; removes it atomically | consume a record exactly once |\n\nWhich kind of function may call which operation is covered in [Setup](./setup.md#how-access-is-gated-query-action-and-friends). In short: reads (`get`, `getMany`, `exists`) work anywhere; writes (`create`, `patch`, `upsert`, `enqueue`, `delete`, `getDelete`) need an **Action** (a `@post` route or an `@action`).\n\n### Reading: `get`, `require`, `exists`, `getMany`\n\n`get` is the everyday read. It returns the value or `null`, so you handle \"not found\" explicitly:\n\n```ts\nconst user = AppDb.users.get(new UserId('u_123'));\nif (user == null) {\n return Response.notFound();\n}\n// user is a fully typed User here\n```\n\n`require` is `get` for the case where absence is a bug, not a normal outcome: it returns the value directly and traps (aborts the request) if the record is missing. Use it only when you have already guaranteed the record exists.\n\n`exists` answers \"is there a record here?\" without paying to decode the value. It is handy as a cheap precondition:\n\n```ts\nif (AppDb.users.exists(new UserId(name))) {\n // username already registered, do not overwrite\n}\n```\n\n`getMany` reads several keys in a single operation. You hand it an array of keys; you get back an array the same length and in the same order, each entry either the value or `null` for a key that was absent. Reach for it instead of a loop of `get` calls when you already know the handful of keys you need.\n\n```ts\nconst ids = [new UserId('a'), new UserId('b'), new UserId('c')];\nconst found: Array<User | null> = AppDb.users.getMany(ids);\n// found[0] lines up with ids[0], and so on; each is a User or null\n```\n\n`getMany` is a **bounded batch of point reads**, not a scan: the number of keys you may pass is capped by the request budget, and it never walks the whole collection. There is no \"get all records\" operation on a request path, by design (an unbounded scan could fan out across a huge collection). If you need \"the latest N of something,\" model it as [Events](./events.md) or precompute a [View](./views.md).\n\n### Writing: `create` vs `upsert` vs `patch` vs `enqueue`\n\nThese four all put a value under a key, but they differ in one important way each. Choosing correctly is the heart of using this family.\n\n```mermaid\nflowchart TD\n START[\"I want to write a record\"] --> Q1{\"Must this write fail if another<br/>request changed the record<br/>since I read it?\"}\n Q1 -->|\"Yes, guard against a lost update\"| ENQUEUE[\"enqueue<br/>(version-checked CAS;<br/>false = retry)\"]\n Q1 -->|\"No, last write wins\"| Q2{\"Does the record<br/>already exist?\"}\n Q2 -->|\"Must be new (never clobber)\"| CREATE[\"create<br/>(false if the key is taken)\"]\n Q2 -->|\"May or may not exist\"| UPSERT[\"upsert<br/>(create-or-overwrite in one op)\"]\n Q2 -->|\"Definitely exists\"| PATCH[\"patch<br/>(overwrite; returns the value;<br/>traps if absent)\"]\n```\n\n**`create` inserts a new record.** It only writes if the key is free. If the key already has a record, `create` does nothing and returns `false`. This is your tool for \"sign up a new user\" or \"claim this order id,\" where accidentally overwriting an existing record would be a bug. Because every key is serialized at its home (see [eventual consistency](./README.md#eventual-consistency-in-plain-words)), `create` is race-safe: if two requests create the same key at the same instant, exactly one gets `true` and the other gets `false`.\n\n```ts\nconst ok = AppDb.users.create(new UserId(input.id), input);\nif (!ok) {\n return Response.text('that id is taken', 409);\n}\n```\n\n**`patch` overwrites an existing record** and returns the value now stored. The record **must already exist**: `patch` on a missing key traps (aborts the request), so create it first. Despite the name, `patch` replaces the whole value; there is no field-level partial update, so read the current value, change the fields you want, and patch the whole thing back:\n\n```ts\nconst current = AppDb.users.get(new UserId('u_123'));\nif (current == null) return Response.notFound();\ncurrent.score = current.score + 10;\nconst saved: User = AppDb.users.patch(new UserId('u_123'), current);\n// saved is what is now stored\n```\n\n**`enqueue` is a version-checked overwrite (a compare-and-swap).** Like `patch`, it replaces the whole value of an **existing** record, but it does so *only if the record has not changed since you read it*. A **compare-and-swap** (CAS) is exactly that: \"write my new value, but only if the current value is still the one I saw.\" It returns a `bool`: `true` means your write was applied; `false` means either a concurrent write changed the record first (someone else beat you to it) or the record is absent. A `false` is **not an error**; it is the signal to **re-read and try again**. This approach is called **optimistic concurrency**: rather than locking the record, you assume nobody else will touch it, and you simply re-run the update on the rare occasion someone did.\n\nReach for `enqueue` when several requests may update the *same* record at once and you must not silently lose any of their changes. A plain `patch` cannot promise that: two overlapping patches clobber each other (the last writer wins and the earlier update just vanishes). The intended pattern for `enqueue` is always a read-modify-CAS **retry loop**:\n\n```ts\nconst key = new UserId('u_123');\nfor (let attempt = 0; attempt < 5; attempt++) {\n const current = AppDb.users.get(key); // 1. read the current value\n if (current == null) return Response.notFound();\n current.score = current.score + 10; // 2. modify your copy\n if (AppDb.users.enqueue(key, current)) { // 3. try to commit it\n return Response.text('ok'); // true: applied, we are done\n }\n // false: someone wrote between our get and our enqueue.\n // Loop: re-read the now-newer value and reapply the change on top of it.\n}\nreturn Response.text('too much contention, try again later', 409);\n```\n\nBecause every retry re-reads the latest value, the two updates **compose** (both `+10`s land) instead of one silently overwriting the other. If you do not need this guard (only one writer touches the key, or last-write-wins is genuinely fine), a plain `patch` is simpler and also hands you the stored value back directly.\n\n**`upsert` creates the record or overwrites it, in one operation.** It is the \"just store this value, I do not care whether a row was already there\" call, and it is the right tool for a save that runs repeatedly (a profile edit, a settings blob) where the first save inserts and every later save replaces. It returns an `UpsertResult` telling you which happened:\n\n```ts\nconst key = new UserId('u_123');\nconst result = AppDb.users.upsert(key, user);\n// result is UpsertResult.Created (row was absent) or UpsertResult.Updated (overwrote)\n```\n\nBefore `upsert`, that meant two operations, `create` then `patch`:\n\n```ts\n// the old two-op idiom - upsert replaces it\nif (!AppDb.users.create(key, user)) {\n AppDb.users.patch(key, user);\n}\n```\n\n`upsert` collapses both into a single write, so the common \"already exists\" path costs one round trip instead of two.\n\nLike `patch`, it is a **last-writer-wins** overwrite, not a compare-and-swap: concurrent upserts never fail or retry, the later one simply wins. That makes it correct for writing a **whole value**, and wrong for a read-modify-write of accumulating state (a running total, a like count) where a concurrent write would be silently lost, use `enqueue`'s retry loop or a [Counter](./counters.md) there. On a collection with a `@unique` field, `upsert` returns `UpsertResult.Conflict` (and writes nothing) when the value's unique field is already held by a **different** record, the one case it cannot resolve by overwriting:\n\n```ts\nconst outcome = AppDb.handles.upsert(key, profile);\nif (outcome == UpsertResult.Conflict) {\n return Response.text('that handle is taken', 409);\n}\n```\n\n### Removing: `delete` and `getDelete`\n\n`delete` removes a record. It is **idempotent**: deleting a key that is already gone is not an error, it just does nothing. So you can call it without first checking that the record exists.\n\n```ts\nAppDb.users.delete(new UserId('u_123'));\n```\n\n`getDelete` is the atomic **fetch-and-remove**: in one indivisible step it reads the current value and deletes it, returning what it removed (or `null` if there was nothing). \"Atomic\" here means no other request can slip in between the read and the delete, so exactly one caller can ever receive a given value. That makes it the right tool for **consume-once** data: one-time login challenges, single-use invite codes, password-reset tokens. The PQ-auth demo uses it to consume a login challenge exactly once, so a challenge cannot be replayed:\n\n```ts\nconst challenge = AppDb.challenges.getDelete(new ChallengeId(cid));\nif (challenge == null) return fail(); // unknown, already used, or expired\n// ...verify against challenge...\n```\n\nIf two requests race to `getDelete` the same key, only one gets the value; the other gets `null`. That is the guarantee a plain `get` then `delete` cannot give you, because two racers could both `get` the value before either `delete`s it.\n\n## A full worked example: a small CRUD entity\n\nPutting it together, here is a complete `notes` resource: create, read, update, and delete, backed by a Documents collection.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n// ---- key + value ----\n@data\nclass NoteId {\n id: string = '';\n constructor(id: string = '') { this.id = id; }\n}\n\n@data\nclass Note {\n id: string = '';\n title: string = '';\n body: string = '';\n updatedAt: u64 = 0;\n}\n\n// ---- database ----\n@database\nclass NotesDb {\n @collection static notes: Documents<NoteId, Note>;\n}\n\n// ---- routes ----\n@rest('notes')\nclass Notes {\n // GET /notes/:id -> read one (Query: read-only)\n @get('/:id')\n public read(ctx: RouteContext): Response {\n const note = NotesDb.notes.get(new NoteId(ctx.param('id')));\n if (note == null) return Response.notFound();\n return Response.json(note.toJSON().toString());\n }\n\n // POST /notes -> create a new one, refusing a duplicate id (Action: may write)\n @post('/')\n public create(input: Note): Response {\n input.updatedAt = <u64>(Date.now() / 1000);\n if (!NotesDb.notes.create(new NoteId(input.id), input)) {\n return Response.text('id already exists', 409);\n }\n return Response.json(input.toJSON().toString());\n }\n\n // POST /notes/:id -> overwrite an existing note (Action)\n @post('/:id')\n public update(input: Note, ctx: RouteContext): Response {\n const key = new NoteId(ctx.param('id'));\n if (!NotesDb.notes.exists(key)) return Response.notFound();\n input.id = ctx.param('id');\n input.updatedAt = <u64>(Date.now() / 1000);\n const saved = NotesDb.notes.patch(key, input);\n return Response.json(saved.toJSON().toString());\n }\n\n // POST /notes/:id/delete -> remove one (Action)\n @post('/:id/delete')\n public remove(ctx: RouteContext): Response {\n NotesDb.notes.delete(new NoteId(ctx.param('id')));\n return Response.text('deleted');\n }\n}\n```\n\nThat is a complete persistent CRUD entity. Run it under `toiljs dev` and the notes survive across requests; deploy it and the same code stores them worldwide on the edge.\n\n## Consistency notes\n\nDocuments follows ToilDB's general model (see [the overview](./README.md#eventual-consistency-in-plain-words)):\n\n- **Writes to one key are serialized at that key's home**, so `create` is race-safe and `patch`/`upsert`/`enqueue`/`getDelete` never corrupt a record under concurrency. `upsert` is last-writer-wins like `patch`: serialization means the writes do not tear, but the later one still overwrites the earlier, so it does not by itself prevent a lost update (use `enqueue` for that).\n- **Reads are eventually consistent across regions.** Right after a write, a read from a far-away region may briefly still see the old value (or, for a just-created record, not see it yet). The copies converge within moments.\n- Because `patch` replaces the whole value, two updates to *different* fields of the same record can clobber each other if they overlap (read-modify-write races). `enqueue`'s version check is exactly the guard against that: use the read-modify-CAS retry loop shown above so a lost update turns into a retry instead of silent data loss. If you find yourself contending on one hot record a lot, a counter or a set is often a better fit than a Documents value. See [Counters](./counters.md) and [Membership](./membership.md).\n\n## Gotchas\n\n- **`patch` requires an existing record.** Calling it on a missing key traps the request. Use `create` for new records, or `upsert` when the record may or may not exist yet.\n- **`patch` replaces the whole value.** There is no field-level merge; read, modify, and write back the full value.\n- **`enqueue` returning `false` is not a failure.** It means a concurrent write beat you to the record (or the record is absent), so re-read and retry in a loop; never ignore the return value or treat `false` as a hard error. `enqueue` also does not hand back the stored value; use `patch` when you want the value returned and last-write-wins is acceptable.\n- **`upsert` is last-writer-wins, not a merge.** It writes the whole value you pass, so it is for storing a complete value, not a read-modify-write of one field of a shared record. Two overlapping upserts do not error, but the earlier one is overwritten (lost). When several writers contend on one record and must not lose each other's changes, use `enqueue`'s retry loop or a [Counter](./counters.md).\n- **No \"get all.\"** There is no scan on the request path. Use `getMany` for known keys, and [Events](./events.md) or a [View](./views.md) for \"the latest N.\"\n- **`getDelete`, not `get` + `delete`, for consume-once.** Only `getDelete` guarantees exactly one caller receives the value.\n\n## Related\n\n- [ToilDB overview](./README.md): the seven families and how to choose.\n- [Setup](./setup.md): declaring the collection and which function kinds may write.\n- [Data types (`@data`)](../backend/data.md): keys and values.\n- [Counters](./counters.md): when you are really just counting.\n- [Events](./events.md) and [Views](./views.md): for \"the latest N\" and precomputed reads.\n",
|
|
32
32
|
"database/events.md": "# Events (append-only logs)\n\nAn `Events` collection is an **append-only log**: a growing list of immutable\nfacts under a key. You add events to the end and read the newest ones back. You\nnever edit or delete an event once it is written.\n\n## What and why\n\nAn \"append-only log\" means exactly two things happen to it:\n\n1. You **append** a new event (it goes on the end).\n2. You **read** events back (newest first).\n\nThat is it. There is no \"update event 5\" and no \"delete event 3\". Each event is a\npermanent record of something that happened: \"Ada signed the guestbook\", \"order\n1234 was refunded\", \"user logged in from a new device\".\n\nUse an `Events` log when you care about the **history**, not just the latest\nstate:\n\n- **Activity feeds:** \"what happened in this room, newest first\".\n- **Audit trails:** a tamper-resistant record of every important action.\n- **Event sourcing:** treat the log as the source of truth and compute other\n values (totals, summaries, leaderboards) from it.\n\nIf you only ever need the *current* value of something (a user's profile, a\nshopping cart), use [Documents](./documents.md) instead. If you only need a\nrunning total (likes, page views), use a [Counter](./counters.md). Events are\nfor keeping the full list of what happened.\n\n```mermaid\nflowchart LR\n A[\"append(user, event)\"] --> L[(\"Event log for user<br/>(oldest ... newest)\")]\n B[\"append(user, event)\"] --> L\n L --> R[\"latest(user, 20)<br/>returns newest 20\"]\n```\n\n## The type\n\nAn `Events` collection has two type parameters: the **key** (which log) and the\n**event** (what each entry stores).\n\n```ts\nEvents<K, V>\n```\n\n- `K` is the key type: it picks *which* log you are appending to. One key, one\n log. For a per-user activity feed, the key is the user id.\n- `V` is the event type: the shape of a single entry. Both `K` and `V` are\n [`@data`](../concepts/types.md) classes so they can be stored as bytes.\n\nYou declare it as a `@collection` field inside a `@database` class:\n\n```ts\n@data\nclass UserKey {\n id: string = '';\n constructor(id: string = '') { this.id = id; }\n}\n\n@data\nclass Activity {\n action: string = ''; // 'login', 'post.create', 'comment', ...\n detail: string = '';\n at: u64 = 0; // unix seconds\n}\n\n@database\nclass FeedDb {\n @collection static activity: Events<UserKey, Activity>;\n}\n```\n\nThe field name (`activity`) names the collection; the class name (`FeedDb`) names\nthe database. You reach the log through `FeedDb.activity`.\n\n## Operations\n\nThere are three operations, exactly matching the [toilscript](../concepts/decorators.md)\nAPI. Their exact signatures:\n\n| Operation | Signature | What it does |\n| --- | --- | --- |\n| `append` | `append(key: K, event: V): void` | Add one event to the end of the log. |\n| `appendOnce` | `appendOnce(key: K, eventId: string, event: V): bool` | Add one event, but only if that `eventId` was never seen before. |\n| `latest` | `latest(key: K, limit: i32): V[]` | Read up to `limit` events, newest first. |\n| `since` | `since(key: K, limit: i32): V[]` | Read the NEXT batch of events past a `@derive`'s checkpoint, oldest first. For folding a growing log incrementally. |\n\n### `append`\n\nThe everyday operation. It adds one event and returns nothing.\n\n```ts\nFeedDb.activity.append(\n new UserKey('ada'),\n new Activity('login', 'from Paris', now),\n);\n```\n\n`append` is a **write**, so you may call it from an action handler (`@post`,\n`@put`, `@patch`, `@del`) but not from a read-only `@get`. See\n[decorators](../concepts/decorators.md) for what each handler kind may do.\n\n### `appendOnce` (idempotent append)\n\n`appendOnce` adds an event **at most once** for a given id. \"Idempotent\" means\nrunning it more than once has the same effect as running it once: the extra calls\nchange nothing.\n\nWhy you want this: networks retry. A client (or another service) may send the\nsame request twice because the first reply got lost. With plain `append`, that\ndouble-delivery writes the event twice. With `appendOnce`, you pass a stable\n`eventId` (a string you choose that is unique for that logical event), and the\nlog records it only once.\n\n```ts\n// `orderId` uniquely identifies this order event. If the request is retried,\n// the second call is a no-op and the log still has exactly one entry.\nconst first = FeedDb.activity.appendOnce(\n new UserKey('ada'),\n `order-refunded:${orderId}`, // the idempotency id\n new Activity('order.refund', orderId, now),\n);\n// first === true -> this call appended the event\n// first === false -> a previous call already appended it; nothing happened now\n```\n\nThe return value tells you which happened:\n\n- `true`: this call appended the event (it was the first with that id).\n- `false`: a call with the same id already appended it, so this was a no-op.\n\nPick an `eventId` that is truly unique per event: an order id, a payment id, a\nmessage uuid. Do not reuse one id for two different events, or the second will be\nsilently dropped as a \"duplicate\".\n\n### `latest`\n\nReads the newest events back, up to a limit, newest first.\n\n```ts\nconst recent = FeedDb.activity.latest(new UserKey('ada'), 20);\n// recent[0] is the most recent event, recent[1] the next, ...\n```\n\n`latest` is a **scan**: it can walk many rows. Scans are barred on the request\npath, so you **cannot** call `latest` from a `@get` or a `@post`. This is a\ndeliberate guardrail: a log can grow without bound, and a per-request scan would\nget slower and more expensive as the log grows.\n\nInstead you scan the log **off** the request path, in a\n[`@derive`](../background/derive.md), and publish a small precomputed\n[View](./views.md) that your routes read cheaply. The next section shows the full\npattern.\n\n### `since` (incremental read for a `@derive`)\n\n`latest` rescans the tail every time. For a log that grows without bound, that gets slower and slower. `since`\nreads only the events you have **not folded yet**: the next batch past a saved cursor, oldest first, up to a\nlimit. It is how a [`@derive`](../background/derive.md) folds a huge log incrementally instead of rescanning\nit from the start on every change.\n\nYou do not pass or manage the cursor. The host owns it: it seeds `since` from a durable checkpoint, advances\nit as it hands you events, and saves it after your fold's view publish lands. So you just loop until the\nbatch is empty:\n\n```ts\n@derive\nrollup(): void {\n const key = new StatsKey('global');\n const view = StatsDb.summary.get(key) ?? new Summary(); // the running view\n let batch = StatsDb.events.since(key, 500);\n while (batch.length > 0) { // drain in bounded batches\n for (let i = 0; i < batch.length; i++) view.apply(batch[i]);\n batch = StatsDb.events.since(key, 500);\n }\n StatsDb.summary.publish(key, view);\n}\n```\n\nTwo rules:\n\n- **`@derive` or `@job` only.** Like `latest`, `since` is a scan, so it is barred on the request path (`@get`\n / `@post`). Calling it there is rejected.\n- **Your fold must be idempotent per event.** A rare crash-recovery case (a \"healed\" event that arrives at an\n older position) makes the host re-read the log from the start that one run, so applying an event twice must\n not change the result. Use set-style updates (`view.byId[e.id] = e.value`), not blind accumulation\n (`view.count += 1`). If your fold cannot be idempotent, use `latest` and recompute instead.\n\n`since` is the efficient path; `latest` (recompute) is the simple path. See [`@derive`](../background/derive.md)\nfor the full picture.\n\n## Worked example: an activity feed\n\nHere is the whole loop: an action appends an event, a `@derive` folds the log\ninto a view, and a `@get` serves the view.\n\n```ts\nimport { Activity } from '../models/Activity';\nimport { UserKey } from '../models/UserKey';\nimport { FeedView } from '../models/FeedView';\nimport { NewActivity } from '../models/NewActivity';\n\n@database\nclass FeedDb {\n // The source of truth: every activity, appended forever.\n @collection static activity: Events<UserKey, Activity>;\n // The precomputed snapshot the GET serves: the newest 20 activities.\n @collection static feed: View<UserKey, FeedView>;\n\n // A @derive runs OFF the request path, so it is allowed to scan the log\n // (`latest`) and publish the view. The runtime runs it right after an append,\n // so the view reflects the new event on the next read.\n @derive\n rebuild(): void {\n const key = new UserKey('ada'); // (real code would loop per active user)\n const view = new FeedView();\n view.items = FeedDb.activity.latest(key, 20);\n FeedDb.feed.publish(key, view);\n }\n}\n\n@rest('feed')\nclass Feed {\n // GET reads the precomputed view: a single keyed read, NOT a scan.\n @get('/')\n public list(): FeedView {\n const view = FeedDb.feed.get(new UserKey('ada'));\n return view == null ? new FeedView() : view;\n }\n\n // POST appends one activity. The @derive republishes `feed` right after.\n @post('/')\n public record(input: NewActivity): FeedView {\n const at = <u64>(Date.now() / 1000);\n FeedDb.activity.append(new UserKey('ada'), new Activity(input.action, input.detail, at));\n // We do NOT scan here (that would be barred). We just acknowledge; the GET\n // serves the updated list from the view the derive rebuilds.\n return new FeedView();\n }\n}\n```\n\nThe two `@data` models:\n\n```ts\n@data\nexport class Activity {\n action: string = '';\n detail: string = '';\n at: u64 = 0;\n}\n\n@data\nexport class FeedView {\n items: Activity[] = [];\n}\n```\n\nThe key idea: the **log** is the durable history, and the **view** is a small,\nread-cheap snapshot derived from it. Reads hit the view; the log grows quietly in\nthe background.\n\n## Ordering and consistency\n\nToilDB is a **worldwide** database: your data lives in many regions at once. A\nfew honest details about what that means for events:\n\n- **Ordering within a log is stable.** Each key (each log) has one \"home\" location\n where appends are serialized and stamped with an increasing sequence number. So\n `latest` always returns a consistent newest-first order for that key.\n- **`latest` is newest first.** Index `0` is the most recent event.\n- **Reads can lag slightly across regions.** An append is applied at the log's\n home first, then copied out to other regions in the background (this is called\n *asynchronous replication*, and the result is *eventual consistency*: given a\n little time, every region converges to the same log). A read served from a far\n region may briefly miss the very newest event. A `@derive` runs right after the\n append that triggered it, so the view it publishes reflects that write.\n- **`appendOnce` dedups by your id.** The at-most-once guarantee is anchored to\n the `eventId` you pass, so retries are safe even across a flaky network.\n- **A log grows without bound.** Appending never shrinks it. Keep events small,\n and read them through a bounded `latest(key, N)` in a derive rather than trying\n to hold the whole log in memory.\n\n## Gotchas\n\n- **You cannot read the log from a route.** `latest` is a scan and is only legal\n in a `@derive` or a `@job`. If you try to call it from a `@get`/`@post`, the\n compiler rejects it. Read a [View](./views.md) from your route instead.\n- **Events are immutable.** There is no \"edit\" or \"delete an event\". If a fact\n changes, append a new event that records the change (for example, an\n `order.refund` event, not an edit to the original `order.create`).\n- **Choose `eventId` carefully for `appendOnce`.** It must be unique per logical\n event. Reusing an id drops the new event as a duplicate.\n- **Do not treat a counter as a log.** A [Counter](./counters.md) gives you a\n running total but forgets the individual events. If you need the list, use\n `Events`.\n\n## Related\n\n- [Documents](./documents.md): mutable, one value per key (edit in place).\n- [Counters](./counters.md): a single running total per key.\n- [Views](./views.md): the precomputed snapshot your routes read.\n- [`@derive`](../background/derive.md): folds an event log into a View, off the\n request path.\n- [Data types (`@data`)](../concepts/types.md): how keys and events are stored.\n- [Decorators](../concepts/decorators.md): which handler kinds may append vs scan.\n",
|
|
33
33
|
"database/membership.md": "# Membership (sets and relationships)\n\nA `Membership` collection stores **sets**: unordered groups of members under a\nkey. You add and remove members, ask whether a member is in the set, and list a\nset's members. It is the natural fit for many-to-many relationships like group\nmembers, followers, and tags.\n\n## What and why\n\nA \"set\" is a collection with no duplicates and no order: a member is either in it\nor not. `Membership` gives you one set per key. The key names the set; each member\nis one item in it.\n\nThis maps directly onto **many-to-many relationships**, where each thing on one\nside can relate to many things on the other:\n\n- **Group members:** the key is a group id, the members are user ids.\n- **Followers:** the key is a user id, the members are the ids of their followers.\n- **Tags:** the key is an article id, the members are tag names.\n- **Access control:** the key is a resource id, the members are the users allowed\n in.\n\nYou could try to store these as an array field inside a [Document](./documents.md)\n(for example `group.memberIds: string[]`). That works for **small, rarely-changed**\nsets, but it has real limits:\n\n| | Array in a Document | `Membership` set |\n| --- | --- | --- |\n| Add/remove one member | Read the whole array, edit it, write it all back | One direct `add`/`remove`, no read-modify-write |\n| Concurrent edits | Two writers can clobber each other's array | Each `add`/`remove` is its own operation |\n| Big sets | The whole array is loaded on every read/write | Members are stored separately; you read a bounded page |\n| \"Is X a member?\" | Load the array and search it | One direct `contains` check |\n\nRule of thumb: a **handful** of stable items (a user's two or three roles) is fine\nas an array in a Document. A set that **grows** or is **edited concurrently**\n(followers, group members, tags across many articles) should be a `Membership`.\n\n```mermaid\nflowchart LR\n K[\"Group key: 'eng'\"] --> S[(\"Membership set\")]\n S --> M1[\"ada\"]\n S --> M2[\"grace\"]\n S --> M3[\"linus\"]\n```\n\n## The type\n\n```ts\nMembership<K, M>\n```\n\n- `K` is the key type: it picks *which* set. For group membership, the key is the\n group id.\n- `M` is the member type: the shape of one member. For user membership, that is a\n user id. Both are [`@data`](../concepts/types.md) classes.\n\nDeclare it as a `@collection` field on a `@database` class:\n\n```ts\n@data\nclass GroupKey {\n group: string = '';\n constructor(group: string = '') { this.group = group; }\n}\n\n@data\nclass Member {\n userId: string = '';\n constructor(userId: string = '') { this.userId = userId; }\n}\n\n@database\nclass GroupsDb {\n @collection static members: Membership<GroupKey, Member>;\n}\n```\n\n## Operations\n\nFour operations, matching the toilscript API exactly. Exact signatures:\n\n| Operation | Signature | What it does |\n| --- | --- | --- |\n| `contains` | `contains(key: K, member: M): bool` | Is `member` in the set? |\n| `add` | `add(key: K, member: M): void` | Put `member` in the set (idempotent). |\n| `remove` | `remove(key: K, member: M): void` | Take `member` out of the set (idempotent). |\n| `list` | `list(key: K, limit: i32): M[]` | Up to `limit` members of the set. |\n\n### `contains`\n\nA direct membership check. It is a keyed read, so it is allowed from any handler,\nincluding a read-only `@get`.\n\n```ts\nif (GroupsDb.members.contains(new GroupKey('eng'), new Member('ada'))) {\n // ada is in the 'eng' group\n}\n```\n\n### `add` and `remove`\n\nBoth are **writes**, so you call them from an action handler (`@post`, `@put`,\n`@patch`, `@del`), not from a `@get`. Both are **idempotent**: \"idempotent\"\nmeans doing it again has no extra effect.\n\n- `add` on a member already in the set: no change, no error.\n- `remove` on a member not in the set: no change, no error.\n\n```ts\nGroupsDb.members.add(new GroupKey('eng'), new Member('ada')); // ada joins\nGroupsDb.members.add(new GroupKey('eng'), new Member('ada')); // no-op, still one 'ada'\nGroupsDb.members.remove(new GroupKey('eng'), new Member('linus')); // linus leaves (or no-op)\n```\n\nBecause they are idempotent, you do not need to check `contains` before calling\nthem. Just `add` to join and `remove` to leave.\n\n### `list`\n\nReturns up to `limit` members of a set.\n\n```ts\nconst roster = GroupsDb.members.list(new GroupKey('eng'), 100);\n```\n\n`list` is a **scan** (it can walk many rows), so it is barred on the request\npath: you **cannot** call it from a `@get` or a `@post`. Like reading a whole\nevent log, listing a whole set belongs off the request path, in a\n[`@derive`](../background/derive.md) or a `@job`, which publishes a\n[View](./views.md) your routes read. The [worked example](#worked-example-group-membership)\nbelow shows this.\n\n`list` returns up to `limit` members. A set can be larger than one page, so treat\nthe result as \"a bounded page of members\", not necessarily \"every member\".\n\n## Worked example: group membership\n\nA user joins or leaves a group from a route; a derive lists the roster into a\nview; a route reads the view. Membership checks happen directly in the route.\n\n```ts\nimport { GroupKey } from '../models/GroupKey';\nimport { Member } from '../models/Member';\nimport { Roster } from '../models/Roster';\nimport { JoinRequest } from '../models/JoinRequest';\n\n@database\nclass GroupsDb {\n // The set: who is in each group.\n @collection static members: Membership<GroupKey, Member>;\n // A precomputed roster for the GET (listing is a scan, barred on routes).\n @collection static roster: View<GroupKey, Roster>;\n\n @derive\n rebuild(): void {\n const key = new GroupKey('eng');\n const r = new Roster();\n r.users = GroupsDb.members.list(key, 500); // scan, allowed in a derive\n GroupsDb.roster.publish(key, r);\n }\n}\n\n@rest('groups')\nclass Groups {\n // GET the roster from the view (a keyed read, not a scan).\n @get('/eng')\n public list(): Roster {\n const r = GroupsDb.roster.get(new GroupKey('eng'));\n return r == null ? new Roster() : r;\n }\n\n // Direct membership check: `contains` is a keyed read, legal in a GET.\n @get('/eng/is-member')\n public isMember(userId: string): bool {\n return GroupsDb.members.contains(new GroupKey('eng'), new Member(userId));\n }\n\n // Join: `add` is idempotent, so joining twice is harmless.\n @post('/eng/join')\n public join(input: JoinRequest): bool {\n GroupsDb.members.add(new GroupKey('eng'), new Member(input.userId));\n return true; // the @derive rebuilds the roster view right after\n }\n\n // Leave: `remove` is idempotent, so leaving when not a member is harmless.\n @post('/eng/leave')\n public leave(input: JoinRequest): bool {\n GroupsDb.members.remove(new GroupKey('eng'), new Member(input.userId));\n return true;\n }\n}\n```\n\nThe models:\n\n```ts\n@data\nexport class Member {\n userId: string = '';\n}\n\n@data\nexport class Roster {\n users: Member[] = [];\n}\n```\n\nThe split is the point: `contains`/`add`/`remove` act on a single member and are\nlegal on the request path; `list` scans the whole set and lives in a derive that\nfeeds a view.\n\n## Consistency\n\n- **Add and remove serialize at the set's home.** Each set key has one home\n location where its writes are applied in order, so an `add` followed by a\n `remove` of the same member lands in that order and the set ends up correct.\n- **Reads can lag slightly across regions.** ToilDB is worldwide. A change made\n at a set's home is copied to other regions in the background (asynchronous\n replication, giving eventual consistency: every region converges after a short\n delay). A `contains` or a `list` served from a far region may briefly miss a\n just-made change.\n- **`add`/`remove` are idempotent**, so retries are safe: re-adding an existing\n member or re-removing an absent one does nothing.\n- **A set can grow without bound.** Adding never shrinks it. Read it through a\n bounded `list(key, N)` in a derive, not all at once.\n\n## Gotchas\n\n- **You cannot `list` from a route.** `list` is a scan, legal only in a `@derive`\n or `@job`. To show a roster on a page, publish it to a [View](./views.md) from a\n derive and `get` that view in the route.\n- **`list` returns a bounded page.** A large set may have more members than your\n `limit`. Do not assume the returned array is the entire set.\n- **Sets are unordered.** Do not rely on the order `list` returns members in.\n- **Use `Membership` for growth or concurrency.** A small, stable set is fine as\n an array in a Document. Reach for `Membership` when the set grows, is edited\n concurrently, or you frequently ask \"is X a member?\".\n\n## Related\n\n- [Documents](./documents.md): when a small array field is enough (and when it is\n not).\n- [Views](./views.md): where a roster/list belongs so routes can read it cheaply.\n- [`@derive`](../background/derive.md): runs `list` off the request path.\n- [Data types (`@data`)](../concepts/types.md): how set keys and members are stored.\n- [Decorators](../concepts/decorators.md): which handler kinds may add/remove vs scan.\n",
|
|
34
34
|
"database/README.md": "# ToilDB\n\nToilDB is the database built into toiljs. It is **global**, it needs **no setup or connection string**, and your backend talks to it with plain typed method calls.\n\nIf you have used Postgres, MySQL, or MongoDB before, ToilDB will feel a little different, and this page explains why. If you have never used a database, that is fine too: read on and every term is defined as it appears.\n\n## What \"a globally distributed edge database\" means\n\nLet us take that phrase one word at a time.\n\n- A **database** is a place your program stores data so it is still there on the next request. Your handler runs, finishes, and forgets everything in memory (a fresh WebAssembly instance runs each request), so anything you want to keep, a user account, a like count, a guestbook entry, has to go into a database.\n- The **edge** is a network of servers spread around the world, each one physically close to some of your users. Your compiled backend runs on the edge server nearest whoever is calling it. (For the full picture of where code runs, see [Tiers](../concepts/tiers.md).)\n- **Globally distributed** means the database is not one machine in one city. Its data lives on many machines in many regions at once. A user in Tokyo and a user in Paris each read from a copy near them, so reads are fast everywhere instead of fast in one place and slow everywhere else.\n\nPut together: ToilDB is storage that lives out on that same worldwide edge, next to your code, so a database read does not have to fly across an ocean and back.\n\n```mermaid\nflowchart LR\n subgraph edge[\"Dacely edge (worldwide)\"]\n direction LR\n H1[\"Your backend<br/>(Tokyo)\"] --> D1[(\"ToilDB<br/>copy near Tokyo\")]\n H2[\"Your backend<br/>(Paris)\"] --> D2[(\"ToilDB<br/>copy near Paris\")]\n end\n D1 <-->|\"replication<br/>(keeps copies in sync)\"| D2\n UA[\"User in Tokyo\"] --> H1\n UB[\"User in Paris\"] --> H2\n```\n\nYou never pick a region, open a connection, or run a migration script. You declare what you want to store (see [Setup](./setup.md)) and ToilDB is there.\n\n## Why seven families instead of one big \"table\"\n\nMost databases give you one general-purpose tool: a table (or a collection) that you read and write however you like. That is flexible, but on a system spread across the whole planet, the flexible tool is also the slow and error-prone tool. The classic example is a counter. If two servers on opposite sides of the world both try to do \"read the number, add one, write it back\" at the same moment, one of the two increments is silently lost, because each read the same starting value.\n\nToilDB solves this by giving you **seven specialized collection types**, called **families**. Each family is tuned for one job and exposes only the operations that are safe and fast for that job. A counter family, for instance, has no \"set to this value\" operation at all: you can only `add` a delta, and the database merges concurrent deltas from around the world without losing any. You cannot misuse it, because the unsafe operation does not exist.\n\nSo instead of one generic table you reach for, you pick the family that matches what you are doing. Picking the right one is the main skill, and the guide below walks you through it.\n\nThe seven families are:\n\n| Family | It stores | Read a page |\n| --- | --- | --- |\n| **Documents** | Records you look up by id (users, posts, orders). | [Documents](./documents.md) |\n| **Unique** | A claim on a value that must be one-of-a-kind (usernames, emails, slugs). | [Unique](./unique.md) |\n| **Counter** | A running total that many callers increment at once (likes, views). | [Counters](./counters.md) |\n| **Events** | An append-only log of things that happened (feeds, audit trails). | [Events](./events.md) |\n| **Capacity** | A limited quantity you hand out without overselling (tickets, seats). | [Capacity](./capacity.md) |\n| **Membership** | Sets of \"who belongs to what\" (followers, tags, room members). | [Membership](./membership.md) |\n| **View** | A precomputed, read-optimized snapshot (home pages, leaderboards). | [Views](./views.md) |\n\n## The one idea shared by all seven: key then value\n\nEvery family, underneath, works the same way: it maps a **key** to a **value**.\n\n- A **key** is how you find your data. Think of it as the label on a drawer: a user id, a username, a room name.\n- A **value** is what is in the drawer: the user record, the owner of a username, the members of a room.\n\nIn ToilDB, both the key and the value are ordinary TypeScript classes that you tag with `@data`. The `@data` tag tells toilscript (the compiler that turns your backend into WebAssembly) how to pack that class into bytes for storage and unpack it again. You write a normal class with normal fields, give each field a default, and the compiler does the rest.\n\n```ts\n// A key: how you address one record.\n@data\nclass UserId {\n id: string = '';\n constructor(id: string = '') { this.id = id; }\n}\n\n// A value: what you store under that key.\n@data\nclass User {\n id: string = '';\n name: string = '';\n score: u64 = 0;\n}\n```\n\nYou then declare a collection that maps that key type to that value type, for example `Documents<UserId, User>`. Every family is generic over its key and value types in exactly this way, so once you understand `@data` keys and values you understand all seven. The full `@data` reference is on the [data types page](../backend/data.md); how to declare collections is on [Setup](./setup.md).\n\n## Eventual consistency, in plain words\n\nBecause ToilDB keeps copies of your data in many regions, there is one honest trade-off you need to understand: **eventual consistency**.\n\nEvery key has one **home**: a single region that officially owns that key's data. All **writes** to a key travel to its home, where they are applied one at a time, in order. That is what makes writes safe: even if a thousand servers write the same key at once, the home lines them up so nothing is lost or corrupted.\n\n**Reads**, on the other hand, are served from the copy nearest the reader, which is fast but may be a beat behind. After a write lands at the home, it takes a brief moment (usually milliseconds) to fan out to the other regions' copies. During that moment, a reader in another region might still see the previous value. The copies always catch up; they are *eventually* consistent, not *instantly* consistent everywhere.\n\nWhat this means in practice:\n\n- **It is usually invisible.** The lag is tiny, and most apps never notice.\n- **Do not assume a write is visible everywhere the instant it returns.** For example, right after you create a record, a read from a far-away region might briefly not see it yet.\n- **Some families are stronger.** Because writes to a single key are serialized at its home, operations that must never race, claiming a unique username, reserving the last ticket, are decided at the home and are safe. The Unique and Capacity families lean on this so two callers can never both win. See [Unique](./unique.md) and [Capacity](./capacity.md).\n\nEach family page spells out its own consistency behavior, so you always know what to expect.\n\n## Choosing a family: the decision guide\n\nStart from what you are trying to do, not from a data structure. This table maps a real need to the family that was built for it.\n\n| I need to... | Use | Why |\n| --- | --- | --- |\n| Store and update a thing I look up by id (a user, a post, an order). | **Documents** | The general-purpose record store: create, read, update, delete by key. |\n| Guarantee a value is used by only one owner across the whole world (username, email, slug). | **Unique** | Claims are decided at the key's home, so two people cannot claim the same name. |\n| Count something that many people bump at the same time (likes, page views, stock tally). | **Counter** | Concurrent `add`s from anywhere merge without ever losing an increment. |\n| Keep a growing list of things that happened, in order (activity feed, audit log). | **Events** | Append-only: you add events and read the newest, but never edit history. |\n| Hand out a limited quantity and never sell more than exist (tickets, seats, inventory). | **Capacity** | Reserve/confirm/cancel holds at the home prevent overselling. |\n| Track which members belong to a set (followers, tags, room members, permissions). | **Membership** | Add/remove/check membership without loading a whole record to edit a list. |\n| Serve a precomputed, ready-to-render result quickly (leaderboard, home page snapshot). | **View** | A background job computes it once; requests read it with a single fast lookup. |\n\nIf two families seem to fit, this flowchart resolves it. Read it top to bottom.\n\n```mermaid\nflowchart TD\n START[\"What are you storing?\"] --> Q1{\"Is it a running<br/>total (a number many<br/>callers increase)?\"}\n Q1 -->|Yes| COUNTER[\"Counter\"]\n Q1 -->|No| Q2{\"Must a value be<br/>one-of-a-kind across<br/>everyone (username,<br/>email, slug)?\"}\n Q2 -->|Yes| UNIQUE[\"Unique\"]\n Q2 -->|No| Q3{\"Is it a limited stock<br/>you must not oversell<br/>(tickets, seats)?\"}\n Q3 -->|Yes| CAPACITY[\"Capacity\"]\n Q3 -->|No| Q4{\"Is it an append-only<br/>log of events that<br/>happened, kept in order?\"}\n Q4 -->|Yes| EVENTS[\"Events\"]\n Q4 -->|No| Q5{\"Is it a set of members<br/>you add/remove/check<br/>(followers, tags)?\"}\n Q5 -->|Yes| MEMBERSHIP[\"Membership\"]\n Q5 -->|No| Q6{\"Is it a precomputed<br/>read-only snapshot a<br/>background job builds?\"}\n Q6 -->|Yes| VIEW[\"View\"]\n Q6 -->|No| DOCUMENTS[\"Documents<br/>(the default record store)\"]\n```\n\nA quick sanity check when you land somewhere: **Documents is the default.** If you are storing \"a thing with fields that I update by id,\" it is almost always Documents. The other six exist for the specific jobs above where Documents would be slow, unsafe under concurrency, or awkward.\n\nReal apps mix families freely. The demo guestbook, for example, uses **Events** for the log of signatures, a **Counter** for the running total, and a **View** for the ready-to-serve snapshot, all in one small feature.\n\n## Where to go next\n\n- [Setup](./setup.md): declare a database, its collections, and reach them from a handler.\n- [Documents](./documents.md): the general-purpose record store (start here).\n- [Unique](./unique.md), [Counters](./counters.md), [Events](./events.md), [Views](./views.md), [Membership](./membership.md), [Capacity](./capacity.md): the specialized families.\n\n## Related\n\n- [Setup](./setup.md): how to declare `@database`, `@collection`, and `@data` types.\n- [Data types (`@data`)](../backend/data.md): the typed keys and values every family uses.\n- [Tiers](../concepts/tiers.md): where your backend and its data run.\n- [Decorators](../concepts/decorators.md): `@query`, `@action`, `@derive`, and friends.\n",
|
|
@@ -65,7 +65,7 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
65
65
|
"realtime/channels.md": "# Channels (`useChannel`)\n\n`useChannel` is the client-side React hook for realtime. It opens a live connection from the browser to a server `@stream` box, tracks whether it is connected, collects the messages that arrive, and gives you a `send` function. It reconnects on its own if the connection drops.\n\n## What a channel is\n\nA **channel** is simply an open, two-way connection between one browser and the server, viewed from the client's side. On the server that connection is handled by a [`@stream`](./streams.md) box. On the client you hold the other end with `useChannel` (a React hook) or `connectChannel` (a plain function, no React).\n\nYou have already met the two-piece model in the [realtime overview](./README.md): a `@stream` on the server, a channel on the client. This page is the client half.\n\n## The `useChannel` hook\n\n`useChannel` is available on the global `Toil` object in your route files, so no import is needed:\n\n```tsx\nexport default function Ping() {\n const chan = Toil.useChannel({ path: '/echo' });\n\n return (\n <main>\n <p>Status: <strong>{chan.connected ? 'connected' : 'offline'}</strong></p>\n <button type=\"button\" onClick={() => chan.send('ping')}>Send ping</button>\n <ul>\n {chan.messages.map((m, i) => (\n <li key={i}><code>{typeof m === 'string' ? m : '(binary frame)'}</code></li>\n ))}\n </ul>\n </main>\n );\n}\n```\n\n### What it returns\n\n`useChannel(options?)` returns an object with three things:\n\n| Field | Type | What it is |\n| ----------- | -------------------------- | ---------------------------------------------------------------------- |\n| `connected` | `boolean` | `true` while the socket is open. Re-renders when it changes. |\n| `messages` | `(string \\| ArrayBuffer)[]`| every frame received so far, in order. A new frame re-renders. |\n| `send` | `(data) => void` | send one frame to the server (a no-op until the socket is open). |\n\nA frame is either a **string** (text) or an **`ArrayBuffer`** (binary). Send accepts a string or binary data. To send or read structured data, encode and decode it yourself:\n\n```ts\nchan.send(new TextEncoder().encode('hello')); // send bytes\nconst text = (m: string | ArrayBuffer) =>\n typeof m === 'string' ? m : new TextDecoder().decode(m); // read either kind as text\n```\n\n### Options\n\n```ts\nToil.useChannel({\n path: '/echo', // which @stream route to connect to. Default: '/_toil'\n url: 'wss://...', // full ws(s):// override (wins over path). Usually omit this.\n reconnect: true, // auto-reconnect after an unexpected close. Default: true\n reconnectDelay: 1000 // ms to wait before each reconnect attempt. Default: 1000\n});\n```\n\nThe most important option is **`path`**: it points the channel at a specific `@stream` route. `{ path: '/echo' }` connects to a stream mounted with `@stream('echo')`. If you omit `path`, it connects to the default `/_toil` channel.\n\nYou do not build the `ws://` or `wss://` URL yourself. The hook derives it from the current page (a page served over `https` uses `wss`, otherwise `ws`), and on the production edge the transport is upgraded to WebTransport for you. Your code stays the same.\n\n### Lifecycle and cleanup\n\n`useChannel` connects when the component mounts and closes when it unmounts, so a channel lives exactly as long as the page that uses it is on screen. React's rules apply: call it at the top level of your component, not inside a loop or condition.\n\n### `connectChannel`: the non-React version\n\nIf you need a channel outside a React component (in a plain module, a store, an event handler), use `connectChannel`. It takes a callback for each incoming frame and returns a handle:\n\n```ts\nimport { connectChannel } from 'toiljs/client';\n\nconst chan = connectChannel(\n (frame) => console.log('got', frame),\n { path: '/echo' }\n);\nchan.send('hello');\nchan.close(); // stop and stop reconnecting\n```\n\n`useChannel` is `connectChannel` wrapped for React (it manages the `connected` / `messages` state for you).\n\n## The typed client: `Server.Stream`\n\n`useChannel` deals in raw frames. For a typed experience, toiljs also generates a client per `@stream` class at `Server.Stream.<ClassName>`, described in full on the [Streams](./streams.md#reaching-a-stream-from-the-browser) page. Use whichever fits: `useChannel` for a quick reactive hook, `Server.Stream.<Name>.connect()` for typed messages and explicit `onMessage` / `onClose` callbacks. Both open the same kind of connection to the same box.\n\n## A full chat-style example\n\nHere is a chat UI wired end to end: a React page that sends what you type and shows every reply, plus the server box it talks to.\n\n### Client (`client/routes/chat.tsx`)\n\n```tsx\nimport { useState } from 'react';\n\nexport default function Chat() {\n const chan = Toil.useChannel({ path: '/room' });\n const [draft, setDraft] = useState('');\n\n const asText = (m: string | ArrayBuffer): string =>\n typeof m === 'string' ? m : new TextDecoder().decode(m);\n\n function submit(): void {\n if (draft.length === 0) return;\n chan.send(draft); // one send() becomes one @message on the server\n setDraft('');\n }\n\n return (\n <main>\n <h1>Chat</h1>\n <p>Status: <strong>{chan.connected ? 'connected' : 'offline'}</strong></p>\n <ul>\n {chan.messages.map((m, i) => <li key={i}>{asText(m)}</li>)}\n </ul>\n <input\n value={draft}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}\n placeholder=\"Say something\"\n />\n <button type=\"button\" onClick={submit}>Send</button>\n </main>\n );\n}\n```\n\n### Server (`server/streams/Room.ts`)\n\n```ts\n@stream('room')\nclass Room {\n private seen: i32 = 0;\n\n @connect\n onConnect(): void {\n this.seen = 0;\n }\n\n @message\n onMessage(packet: StreamPacket): StreamOutbound {\n this.seen = this.seen + 1;\n const text = new TextDecoder().decode(packet.bytes());\n const reply = '#' + this.seen.toString() + ': ' + text;\n return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(reply)));\n }\n}\n```\n\nDo not forget to import the stream in `server/main.stream.ts` (see [Streams](./streams.md#the-mainstreamts-file-a-separate-tier)):\n\n```ts\nimport './streams/Room';\n```\n\nRun `toiljs dev`, open the page, and every line you send comes back numbered, live.\n\n### An honest limitation: this echoes, it does not broadcast\n\nRead the example carefully. Each browser has its **own** `Room` box, and that box replies only to **its own** connection. So in this version, two people in \"the same room\" do **not** see each other's messages: each just sees their own, echoed back. That is enough for a private assistant, a progress feed, or a single-player game, but it is **not** a shared chat room where one message reaches everyone.\n\nTo send one message to **many** connected users (true broadcast, the heart of a group chat or a live feed), you need the server to **fan out** a message to every subscriber. That is what `@channel` is for.\n\n## `@channel`: server broadcast (not yet available)\n\n> **Status: planned, not live in the current runtime.** A `@stream` class that declares a `@channel` hook is **rejected** by the edge today (\"stream channels are not a v1 runtime ABI\"), and the compiler does not yet emit it. The information below describes the intended shape so you can plan for it, not an API you can call right now.\n\nThe idea is **publish/subscribe** (\"pub/sub\"), a standard pattern for broadcast:\n\n- A **channel** is a named topic, for example a chat room id.\n- A connection **subscribes** to a channel to start receiving everything sent to it.\n- Anyone can **publish** a message to the channel, and every subscriber receives it.\n- A connection **unsubscribes** (or disconnects) to stop.\n\nWith `@channel`, the server box would join a connection to a named topic and publish messages to it, and the edge would deliver each published message to every subscriber across all the connections in that topic, no matter which worker or node each one landed on. That is the missing \"one to many\" piece that turns the echo example above into a real shared room. The plan already reserves per-plan limits for it (a cap on subscribers per channel and on message size), so the surface is designed; it is the runtime delivery that is not shipped yet.\n\n### The picture: world-wide sync (the design)\n\nWhen `@channel` ships, one `publish` will fan out across the whole edge mesh, reaching every subscriber wherever they are, at nearly the same moment. That is the **world-wide sync** idea: a live session where everyone sees the same update together, whether they are in the same city or on opposite sides of the planet.\n\n```mermaid\nflowchart TB\n Pub[\"One publish()<br/>(a chat line, a goal, a game tick)\"] --> Mesh[\"Dacely edge mesh<br/>(routes to every subscriber's core)\"]\n Mesh --> A[\"Subscriber in Tokyo\"]\n Mesh --> B[\"Subscriber in Paris\"]\n Mesh --> C[\"Subscriber in New York\"]\n Mesh --> D[\"...every other subscriber, at once\"]\n```\n\nThe mechanism meant to make this fast is the same one that already spreads plain connections across the edge: because the edge always knows where each subscriber's connection lives, a broadcast is a direct fan-out to a known set of destinations, not a search for who is listening. For the wider picture, see [Built for massive fan-out and world-wide sync](./README.md#built-for-massive-fan-out-and-world-wide-sync) in the overview.\n\nKeep the status in mind: the diagram above is the **intended shape**. The delivery runtime is not shipped yet, so treat it as the plan, not an API you can call today.\n\n**What you can build today:** anything where each user talks to their own box (assistants, per-user live updates, single-player sync, progress streams). **What needs `@channel`:** shared rooms and one-to-many broadcast. Until it ships, a common workaround is to persist messages to [the database](../database/README.md) and have clients poll or re-read, which is simpler but not instant.\n\n## Gotchas\n\n- **Frames are `string` or `ArrayBuffer`.** Decode binary frames with `TextDecoder`; there is no automatic parsing in `useChannel` (the typed `Server.Stream` client is the structured option).\n- **`send` before \"connected\" is dropped.** It is a no-op until the socket is open. Guard on `chan.connected`, or expect the first eager sends to be lost.\n- **`messages` grows forever.** It keeps every frame received. For a long-lived page, slice it or keep your own bounded list.\n- **One box per connection.** `useChannel` does not broadcast between users; that is the `@channel` feature described above.\n\n## Related\n\n- [Realtime overview](./README.md): the two-piece model and when to use realtime.\n- [Streams](./streams.md): the server `@stream` class this hook talks to.\n- [The database (ToilDB)](../database/README.md): where to persist messages that must outlive a connection or reach users who are offline.\n",
|
|
66
66
|
"realtime/README.md": "# Realtime\n\nRealtime is how your app pushes data to the browser the instant it happens, instead of the browser having to ask again and again. In toiljs you get realtime from two small pieces: a server class marked `@stream`, and a client React hook called `useChannel`.\n\n## What \"realtime\" means\n\nA normal web request is one round trip. The browser asks (\"give me the todos\"), the server answers, and the connection closes. If you want fresh data a second later, you have to ask again. That is fine for a page load or a form submit, but it is a poor fit for anything that changes on its own: a live chat, a game, a price ticker, a progress bar, a presence indicator (\"3 people online\").\n\nRealtime flips it around. The browser opens **one long-lived connection** and keeps it open. After that, either side can send a message at any time, as many times as it likes, with no new handshake. That open connection is often called a **socket**.\n\n## WebTransport in plain words\n\nTo hold that long-lived connection open, the browser and the Dacely edge speak a protocol called **WebTransport**.\n\nHere is all you need to know as a beginner:\n\n- WebTransport is a modern, built-in browser feature (like `fetch`, but for a persistent two-way connection).\n- It runs on top of **HTTP/3** and **QUIC**, which are the newest, fastest versions of the web's plumbing. They are built for low latency (messages arrive quickly) and for surviving network changes (your phone switching from Wi-Fi to cellular without dropping the connection).\n- You do not write any WebTransport code by hand. toiljs gives you a tiny client API, and it uses WebTransport underneath on the production edge.\n\nOne helpful detail for later: in local development (`toiljs dev`) the same client API runs over a **WebSocket** instead, because a WebSocket is simpler to serve from one local process. A WebSocket is the older, widely supported \"keep a socket open\" browser feature. The point is that **your code is identical** in dev and in production; only the transport underneath differs, and toiljs picks it for you.\n\n## When to use realtime (and when not to)\n\nReach for realtime when **the server needs to talk first**, or when messages fly back and forth quickly:\n\n| Use realtime when... | Use a plain HTTP request when... |\n| --------------------------------------------- | ------------------------------------------------ |\n| A chat or comment thread updates live. | You load a page or a list once. |\n| A multiplayer game syncs moves and positions. | You submit a form and get one answer. |\n| You show live presence or typing indicators. | You fetch data on a button click. |\n| You stream progress of a long job. | The data rarely changes, or the user pulls it. |\n\n**Games are a first-class use case, not an afterthought.** Realtime multiplayer (player moves, low-latency input, live presence, per-player state) is one of the hardest things to build on the plain web, and it is exactly what toil streaming is designed for. More on why, and how far it is built to scale, in [Built for massive fan-out and world-wide sync](#built-for-massive-fan-out-and-world-wide-sync) below.\n\nIf a single request-and-response does the job, prefer that: it is simpler, it caches well, and it needs no open connection. Plain requests in toiljs are [HTTP routes](../backend/rest.md) and [typed RPC](../backend/rpc.md). Reach for realtime only when the \"ask again and again\" model genuinely gets in your way.\n\n## The two pieces\n\nRealtime in toiljs is always a pair:\n\n1. **The server: a `@stream` class.** You write a small class and mark it `@stream`. The edge turns it into a **resident box**: a live instance that is created when a connection opens, handles every message on that connection, and is torn down when it closes. It has four lifecycle hooks (`@connect`, `@message`, `@close`, `@disconnect`). See [Streams](./streams.md).\n\n2. **The client: a hook.** From your React UI you open the connection and send or receive messages. The low-level way is the `useChannel` hook; the typed, generated way is `Server.Stream.<ClassName>.connect()`. See [Channels](./channels.md).\n\n```mermaid\nsequenceDiagram\n participant B as Browser (React)\n participant E as Dacely edge\n participant S as Your @stream box\n B->>E: open connection (WebTransport, or WebSocket in dev)\n E->>S: create the box, run @connect\n Note over S: the box is now resident for this connection\n B->>E: send \"hello\"\n E->>S: @message(\"hello\")\n S-->>B: reply \"hi back\"\n B->>E: send \"still here\"\n E->>S: @message(\"still here\")\n S-->>B: reply \"yep\"\n B->>E: close\n E->>S: run @close, then destroy the box\n```\n\nNotice that the box lives across **many** messages in that diagram. That is the whole idea: because it is the same instance every time, it can remember things between messages (a counter, who you are, what room you joined). A normal HTTP handler forgets everything after each request; a stream box does not, for as long as the connection stays open.\n\n## Built for massive fan-out and world-wide sync\n\nRealtime gets exciting when a single event reaches **many** people at nearly the same moment. Picture a live sports app: a goal is scored, and every fan watching sees it light up together. Picture a multiplayer game: one player moves, and everyone in the match sees it happen right away. That shape, one event fanning out to a huge live audience, is what toil streaming is built for. We call it **world-wide packet sync**: everyone in the same live session gets the same update at nearly the same time, wherever they are on the planet.\n\n```mermaid\nflowchart TB\n P[\"One event<br/>(a goal, a chat line, a game move)\"] --> E[\"Dacely edge<br/>(spread across cores and cities)\"]\n E --> S1[\"Player in Tokyo\"]\n E --> S2[\"Player in Paris\"]\n E --> S3[\"Player in Sao Paulo\"]\n E --> Sn[\"...many more, at once\"]\n```\n\n### Why it can go so wide\n\nThree design choices make massive scale possible, without any single machine or single lock becoming the ceiling.\n\n1. **Connections spread across the fleet in parallel.** Every new connection is handled on its own, with no shared lock in the middle for connections to fight over. There is no central bottleneck that every connection has to pass through, so adding machines adds capacity in a straight line.\n2. **The session follows the user.** A phone that switches from Wi-Fi to cellular keeps its exact session, in-memory game state and all. A network change does not drop the connection or reset the box.\n3. **The session sits near the user.** The edge is a fleet of servers in many cities. A `@stream` runs at a regional or continental node (see [Compute tiers](../concepts/tiers.md)), so the round trip to a player stays short no matter where on the map they are.\n\n### An honest word on numbers\n\nThe framework is **designed** for very large live audiences, aiming at the scale of millions of concurrent connections in a single live session as its ambition. That is the target the architecture is built toward, not a number measured in these docs. Real capacity depends entirely on your deployment: how many machines and cores you run, how big each message is, how often it is sent, and where your users are. We do not publish a benchmarked figure here. What we can say honestly is **why** it scales (the three design choices above), and that the design removes the usual single-machine and single-lock ceilings that cap other realtime stacks.\n\nOne more honest note. Fanning a single message out to **other** users' connections (true one-to-many broadcast, the arrows in the diagram above) is the job of `@channel`. The client side of that (`useChannel`, `Server.Stream`) is live today. The server-side broadcast that reaches every subscriber across the mesh is **planned, not shipped yet**. See [Channels](./channels.md) for exactly what works now and what is coming, so you can build on solid ground.\n\n## Where to go next\n\n- [Streams](./streams.md): the server side. How to declare a `@stream` class, the four lifecycle hooks, per-connection state, replying, and the separate `main.stream.ts` file.\n- [Channels](./channels.md): the client side. The `useChannel` React hook, the generated typed client, and a chat-style example. Also covers the planned `@channel` broadcast feature.\n\n## Related\n\n- [Compute tiers (L1 to L4)](../concepts/tiers.md): where a stream box runs on the edge.\n- [HTTP routes (`@rest`)](../backend/rest.md) and [Typed RPC](../backend/rpc.md): the plain request-and-response alternatives.\n- [Daemons](../background/daemons.md): long-lived background work that is not tied to a browser connection.\n",
|
|
67
67
|
"realtime/streams.md": "# Streams (`@stream`)\n\nA `@stream` is a server class that handles one live connection from start to finish. You mark a class with `@stream`, add lifecycle hooks, and the Dacely edge keeps one instance of that class alive for as long as the browser stays connected.\n\n## What a stream is (and why it is different)\n\nWhen you write an [HTTP route](../backend/rest.md), the server builds a **fresh** handler for every request and throws it away afterward. Anything you stored on the handler's fields is gone the moment the response is sent. That is perfect for one-shot requests, but it means the handler cannot \"remember\" anything between requests on its own.\n\nA `@stream` is the opposite. It is a **resident box**: one live instance, created when a connection opens and kept alive until it closes. Because it is the *same* instance for every message on that connection, values you store on its fields **persist across messages**. That is what makes it the right tool for a conversation, a game session, or anything stateful that lasts for the life of a connection.\n\nThe word \"resident\" just means \"stays in memory and keeps running.\" The word \"box\" is toiljs's name for one sandboxed instance of your compiled server code.\n\n```ts\n@stream('echo')\nclass Echo {\n private count: i32 = 0;\n\n @connect\n onConnect(): void {\n this.count = 0; // a fresh connection starts a fresh box, so count begins at 0\n }\n\n @message\n onMessage(packet: StreamPacket): StreamOutbound {\n this.count = this.count + 1; // survives across messages: same box every time\n const text = 'pong #' + this.count.toString();\n return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(text)));\n }\n\n @close\n onClose(): void {\n // the box is destroyed after this hook runs\n }\n}\n```\n\nConnect, send three messages, and you get back `pong #1`, `pong #2`, `pong #3`. The advancing number is proof that the same box handled all three.\n\n## Declaring a stream\n\nMark a class with `@stream` and give it a **name**. The name becomes the route the browser connects to.\n\n```ts\n@stream('echo') // mounted at /echo\nclass Echo { /* ... */ }\n\n@stream // bare form: the route is the class name (/Echo)\nclass Echo { /* ... */ }\n```\n\nThere are three forms:\n\n- `@stream('name')`: an explicit mount name (connect at `/name`).\n- `@stream` (bare): the mount name is the class name.\n- `@stream({ ... })`: a config object (see [Configuration](#configuration) below).\n\n## The four lifecycle hooks\n\nA stream method becomes a lifecycle hook when you tag it with one of these decorators. Every hook is **optional**: declare only the ones you need, and a missing hook is simply a no-op (it does nothing, it never crashes).\n\n| Decorator | Fires when... |\n| ------------- | ----------------------------------------------------------------------- |\n| `@connect` | the connection opens (the box has just been created). |\n| `@message` | an inbound frame arrives from the browser. |\n| `@close` | the connection closes cleanly (the box is destroyed after this hook). |\n| `@disconnect` | the connection is lost abruptly (network dropped, browser killed). |\n\nA **frame** is one message: one call to `send()` on the client becomes one `@message` on the server.\n\n```mermaid\nsequenceDiagram\n participant B as Browser\n participant S as @stream box\n B->>S: open\n activate S\n Note over S: @connect runs, box is created\n B->>S: frame \"a\"\n S-->>B: @message -> reply\n B->>S: frame \"b\"\n S-->>B: @message -> reply\n B->>S: close\n Note over S: @close runs, box is destroyed\n deactivate S\n```\n\n### `@connect`\n\nRuns once, right after the box is created. Use it to set up per-connection state (reset a counter, read the requested path, decide whether to accept the connection). It can return a `StreamOutbound` to accept or reject (see below). The Echo example uses it to zero its counter.\n\n**What it receives.** `@connect` is handed a `StreamInbound`, a small read-only object the host fills in with the details of the connection that just opened. It lets you look at *where* the connection came from before you decide to keep it.\n\n| Member | Type | What it gives you |\n| ------------- | -------- | ----------------------------------------------------------------------- |\n| `streamId` | `u64` | A unique id for this connection. |\n| `transport` | `i32` | A numeric tag for the transport that carried the connection. |\n| `authority()` | `string` | The host the browser connected to (for example `example.com`). |\n| `path()` | `string` | The path the browser opened (for example `/echo`). |\n\nWatch the shape: `authority()` and `path()` are **methods** (call them with `()`), while `streamId` and `transport` are plain properties (no parentheses).\n\n```ts\n@connect\nonConnect(info: StreamInbound): StreamOutbound {\n // Inspect where the connection is headed, then decide whether to keep it.\n if (info.path() != '/echo') {\n return StreamOutbound.reject(1); // refuse: any u16 reason code\n }\n this.count = 0; // fresh connection, fresh state\n return StreamOutbound.accept(); // keep the connection open\n}\n```\n\nYou do not have to take the argument. If the hook needs none of these details, declare it with no parameter (`onConnect(): void`), exactly like the Echo example above.\n\n### `@message`\n\nRuns for every inbound frame. This is where most of your logic lives. It receives the frame and may reply. Details in [Reading and replying](#reading-and-replying-to-messages).\n\n### `@close` and `@disconnect`\n\nBoth mean \"the connection is over,\" and both are your chance to clean up. The difference is *how* it ended:\n\n- `@close` is a **graceful** close: the browser (or your server) ended it on purpose.\n- `@disconnect` is an **abrupt** loss: the network dropped or the tab was killed with no goodbye.\n\nAfter either one, the box is destroyed.\n\n**What they receive.** Both hooks are handed a `StreamConnectionEvent`, a read-only summary of the connection that just ended.\n\n| Member | Type | What it gives you |\n| -------------- | ----- | ---------------------------------------------------------- |\n| `connectionId` | `u64` | The id of the connection that ended. |\n| `reason` | `u16` | A numeric close code explaining why it ended. |\n| `durationMs` | `u64` | How long the connection stayed open, in milliseconds. |\n\nAll three are plain properties (getters), so read them without `()`.\n\n```ts\n@close\nonClose(ev: StreamConnectionEvent): void {\n // Last chance to clean up. `ev` tells you how the connection ended.\n const heldSeconds = ev.durationMs / 1000;\n // e.g. record the session length or flush a buffer here.\n}\n\n@disconnect\nonDisconnect(ev: StreamConnectionEvent): void {\n // Same shape, but this fired because the connection dropped abruptly.\n // ev.reason carries the close code the edge assigned to the drop.\n}\n```\n\nAs with `@connect`, the argument is optional: declare `onClose(): void` if you do not need it.\n\n## Per-connection state (and its limits)\n\nState on the box's fields lasts for **one connection**. It does **not** survive:\n\n- a **reconnect**: if the browser drops and reopens, it gets a brand-new box that starts clean.\n- a **different user**: every connection gets its own box, so one connection's state can never leak into another. This is a safety property, not just a convenience.\n\nSo treat box fields as **per-connection scratch space** only. For anything that must outlive the connection (a saved message, a score, who a user is across reconnects), write it to [the database](../database/README.md), not to a class field.\n\n## Reading and replying to messages\n\nBy default, a `@message` receives a `StreamPacket`, which is a thin view over the raw bytes that arrived, and returns a `StreamOutbound`, which stages the reply.\n\n```ts\n@message\nonMessage(packet: StreamPacket): StreamOutbound {\n const raw = packet.bytes(); // the inbound frame as bytes\n return StreamOutbound.reply(raw); // echo the same bytes back\n}\n```\n\n`StreamPacket` (the inbound frame):\n\n| Member | What it gives you |\n| ------------ | ---------------------------------------------------------- |\n| `bytes()` | the whole frame as a `Uint8Array` (copy it if you keep it). |\n| `length` | the number of bytes in the frame. |\n| `at(i)` | the byte at index `i`. |\n\n`StreamOutbound` (what you return):\n\n| Call | Meaning |\n| ----------------------------- | ------------------------------------------------------------------ |\n| `StreamOutbound.reply(bytes)` | send one frame back to the browser. |\n| `StreamOutbound.empty()` | accept the frame and send nothing back. |\n| `StreamOutbound.reject(code)` | refuse (used from `@connect` to turn a connection away). |\n| `StreamOutbound.accept()` | accept a connection with no reply frame. |\n\nA `@message` may also return `void` when it never replies.\n\n> **Bytes, not strings.** A frame is raw bytes on the wire. To send text, encode it with `String.UTF8.encode(...)` (server) or `new TextEncoder().encode(...)` (browser), and decode it with `new TextDecoder().decode(...)` on the other side.\n\n### Typed messages\n\nRaw bytes are flexible but fiddly. If your messages are structured, declare a [`@data`](../backend/data.md) class and pass it as the stream's `message` type. Your `@message` hook then receives the **decoded object** instead of raw bytes.\n\n```ts\n@data\nclass ChatMsg {\n text: string = '';\n constructor(text: string = '') { this.text = text; }\n}\n\n@stream({ message: ChatMsg })\nclass Chat {\n @message\n onMessage(msg: ChatMsg): StreamOutbound { // decoded @data, not raw bytes\n const echoed = 'you said: ' + msg.text;\n return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(echoed)));\n }\n}\n```\n\nThe reply is still raw (`StreamOutbound` deals in bytes). Only the **inbound** side is decoded for you. On the client, a typed stream lets you `send(new ChatMsg('hi'))` and toiljs encodes it for you.\n\n## Games and interactive apps\n\nA stream box remembers state for the life of a connection, which is exactly what a game session needs. Each connected player gets their **own** box, and that box holds the player's live state (position, health, score) in memory, right next to the core handling their packets. The lifecycle hooks map cleanly onto a session: `@connect` spawns the player, `@message` handles each input, `@close` and `@disconnect` remove them.\n\n```ts\n// server/streams/Match.ts\n@data\nclass Move { // the input a player sends each tick\n dx: i32 = 0;\n dy: i32 = 0;\n constructor(dx: i32 = 0, dy: i32 = 0) { this.dx = dx; this.dy = dy; }\n}\n\n@stream({ message: Move, scope: StreamScope.Regional })\nclass Match {\n // Per-connection state: this player's position. It lives in memory for the\n // whole session, on the one core that owns this connection.\n private x: i32 = 0;\n private y: i32 = 0;\n private moves: u32 = 0;\n\n @connect\n onConnect(info: StreamInbound): StreamOutbound {\n this.x = 50; // spawn point\n this.y = 50;\n this.moves = 0;\n return StreamOutbound.accept();\n }\n\n @message\n onMove(move: Move): StreamOutbound {\n // Apply the input to this player's live position, then confirm it.\n this.x = this.x + move.dx;\n this.y = this.y + move.dy;\n this.moves = this.moves + 1;\n const state = '{\"x\":' + this.x.toString() + ',\"y\":' + this.y.toString() + '}';\n return StreamOutbound.reply(Uint8Array.wrap(String.UTF8.encode(state)));\n }\n\n @disconnect\n onDrop(ev: StreamConnectionEvent): void {\n // The player left (tab closed, network died). Their box is destroyed\n // next. Persist a score here if it must outlive the session.\n }\n}\n```\n\nEvery input a player sends is one `@message`, applied to **their** box's position and confirmed straight back to them with low latency. Because the box is resident, the player's position is always there in memory, on the same core, with no database round trip per move. That is what makes fast input loops (a game tick, a cursor drag, a live editor) feel instant.\n\n**What this version does, and does not, do.** Each player has their own box, so this handles per-player input, per-player state, and instant confirmation back to that player. To make players see **each other** (the other half of multiplayer), one player's move must fan out to everyone else in the match. That cross-connection broadcast is the `@channel` feature, which is **planned, not live yet** (see [Channels](./channels.md)). Until it ships, the common patterns are to write shared match state to [the database](../database/README.md) and have clients read it, or to keep a match to a single authoritative box. The per-connection pieces above (input, state, and presence via `@connect` / `@disconnect`) work today.\n\nFor **presence** (\"who is online\"), `@connect` and `@disconnect` are your join and leave signals: bump a counter or write a row when a player connects, and undo it when they drop.\n\n## The `main.stream.ts` file (a separate tier)\n\nStreams live in their **own entry file**, `server/main.stream.ts`, separate from the request entry `server/main.ts`. Importing your `@stream` classes there pulls them into a **separate compiled artifact**, `build/server/release-stream.wasm`.\n\n```ts\n// server/main.stream.ts\nimport { revertOnError } from 'toiljs/server/runtime/abort/abort';\n\nimport './streams/Echo'; // add each @stream module here as you grow\nimport './streams/Chat';\n\n// Re-export the WASM entry points the host binds, exactly like main.ts.\nexport * from 'toiljs/server/runtime/exports';\nexport function abort(message: string, fileName: string, line: u32, column: u32): void {\n revertOnError(message, fileName, line, column);\n}\n```\n\nWhy a separate file? A stream box and a request handler are **different kinds of program** that run on different parts of the edge (see [Compute tiers](../concepts/tiers.md)). toiljs compiles each into its own `.wasm`:\n\n```sh\n$ toiljs build\n$ ls build/server/*.wasm\nbuild/server/release.wasm # L1 request (@rest / @service)\nbuild/server/release-stream.wasm # L2/L3 stream (@stream)\nbuild/server/release-cold.wasm # L4 daemon (@daemon)\n```\n\nYou do not run this by hand. `toiljs build` produces `release-stream.wasm` automatically whenever your project has a `@stream` surface, and shared helper code and `@data` types are compiled into every artifact.\n\n> **One file cannot be both a stream and an RPC surface.** A single source file may not declare both a `@stream` and a `@service` / `@remote` ([RPC](../backend/rpc.md)), because one compiled artifact cannot be two tiers at once. Keep them in separate files: `@stream` in `main.stream.ts`, `@rest` / `@service` in `main.ts`. They coexist happily side by side in the same project, just not in the same file.\n\n## Configuration\n\nThe config-object form lets you tune a stream:\n\n```ts\n@stream({\n scope: StreamScope.Regional, // where the box runs (see below)\n message: ChatMsg, // decode inbound frames into this @data type\n maxFrameBytes: 65536, // reject frames larger than this\n ingressRingBytes: 262144 // size of the inbound buffer\n})\nclass Chat { /* ... */ }\n```\n\n- **`scope`** picks how close to the user the box runs. `StreamScope.Regional` (the default) runs it at a regional node; `StreamScope.Continental` runs it at a wider continental node. See [Compute tiers](../concepts/tiers.md) for what L2 and L3 mean.\n- **`message`** is the typed-message shortcut described above.\n- **`maxFrameBytes`** and **`ingressRingBytes`** cap frame size and buffer size to protect the box from oversized or flooding input.\n\n## Reaching a stream from the browser\n\nEvery `@stream` class gets a generated, typed client at `Server.Stream.<ClassName>`, wired up for you in `shared/server.ts` (the same place the [RPC](../backend/rpc.md) client lands). Call `connect()` to open the connection:\n\n```ts\nimport '../shared/server'; // attaches globalThis.Server (browser-only)\n\nconst chat = await Server.Stream.Echo.connect();\nchat.onMessage((bytes) => { /* a reply frame, always raw bytes */ });\nchat.send(new TextEncoder().encode('hello'));\nchat.onClose((code) => { /* the connection ended */ });\nchat.close();\n```\n\nThe client is keyed by the **class name** (`Server.Stream.Echo`) and connects to the class's **mount route** (`/echo`). Inbound replies are always raw bytes. The full client walkthrough, plus the lower-level `useChannel` hook, is in [Channels](./channels.md).\n\n## How placement works (you do not manage it)\n\nOn the production edge, your box lives in **one place** for the connection's whole life. Every message from that connection is routed to the exact instance holding your box, so its in-memory state is always there. You never configure this; the edge does it automatically. In `toiljs dev` there is only one process, so this is a non-issue.\n\n## Why this scales\n\nA `@stream` box is deliberately cheap to run at scale, and the edge is built to spread a very large number of them across many machines and cities. Three design choices do the heavy lifting:\n\n- **Connections spread across the fleet in parallel.** Each connection is handled on its own, with no shared lock in the middle for connections to fight over. There is no central bottleneck every connection has to pass through, so adding machines adds capacity in a straight line.\n- **The connection survives network changes.** If a client's network changes (Wi-Fi to cellular, or a NAT rebind), the edge keeps the connection attached to the same box. The user keeps their session and their in-memory state; the box is never orphaned.\n- **Each box is isolated and bounded.** Every connection gets its own sandboxed box with its own linear memory. One tenant's boxes are capped to a slice of the node's RAM (a noisy-neighbor guard), and a single box is hard-capped (64 MiB) so a runaway connection can only fill itself, then it traps. Each lifecycle hook also runs under a hard compute cap, so a hook that loops forever is stopped instead of hogging the machine.\n\nPut together: connections spread across the fleet, run in parallel, the session sticks to its box even when the network moves, and each box is walled off from the others. That is what lets one deployment hold a very large number of live connections at once. How large depends on your hardware and message sizes; these docs do not publish a benchmarked number.\n\n> **The wider picture.** For how the edge spreads connections across the fleet and the mesh adds up to world-wide fan-out, see [Built for massive fan-out and world-wide sync](./README.md#built-for-massive-fan-out-and-world-wide-sync) in the overview.\n\n## Gotchas\n\n- **Box fields are per-connection only.** They reset on reconnect and are never shared between users. Persist anything durable to [the database](../database/README.md).\n- **Frames are bytes.** Encode and decode text yourself, or use a typed `message` so toiljs does it.\n- **Copy `packet.bytes()` if you keep it.** The inbound buffer is reused after the hook returns, so store a copy if you need the bytes later.\n- **A file cannot mix `@stream` with `@service` / `@remote`.** Keep streams in `main.stream.ts`.\n- **`@channel` is not live yet.** A stream that declares a `@channel` hook is rejected by the edge today. Broadcasting to many subscribers is a planned feature; see [Channels](./channels.md).\n\n## Related\n\n- [Realtime overview](./README.md): the big picture and when to reach for realtime.\n- [Channels](./channels.md): the client `useChannel` hook and a chat-style example.\n- [Compute tiers (L1 to L4)](../concepts/tiers.md): where the stream artifact runs.\n- [Data types (`@data`)](../backend/data.md): typed messages.\n- [The database (ToilDB)](../database/README.md): where to keep state that outlives a connection.\n",
|
|
68
|
-
"services/analytics.md": "# Analytics\n\nRead your site's own usage numbers (requests, bytes, cache hits, database ops, errors, and more) straight from a route, with no extra code and no third-party service.\n\nReach for analytics to build a status or usage dashboard for a site: how much traffic it serves, how effective its cache is, how many database operations it runs. It is **infrastructure metering per domain**, not per-user product analytics. There are no custom events; you read a fixed catalog of counters the edge already keeps for you.\n\n`Analytics` is an **ambient global**: use it with no import, like [`crypto`](./crypto.md) or `Time`.\n\n## What \"metering\" means\n\nEvery time the edge (the Dacely server running your code) serves a request, runs a database operation, opens a stream, or sends an email, it **counts** that into a set of per-domain counters. Those counters live in the same worldwide, distributed database that backs [ToilDB](../database/README.md), so the total for your site is correct across every edge location, not just the one machine that happened to serve a request.\n\n`Analytics.self()` reads a **snapshot** of your site's current counter values. You do not record anything yourself; the numbers are already there.\n\n```mermaid\nflowchart LR\n R[\"Every request, DB op,<br/>stream, email\"] --> C[\"Edge counts it into<br/>per-domain counters\"]\n C --> D[(\"Distributed counters,<br/>worldwide\")]\n D --> S[\"Analytics.self()<br/>reads a snapshot\"]\n S --> Y[\"Your route returns<br/>the numbers\"]\n```\n\n## Read your site's stats\n\n`Analytics.self()` returns a `TenantStats` object. Every counter is a **named getter** that returns a `u64` (an unsigned 64-bit integer, always zero or positive), so mapping the numbers into a response needs no casts at all.\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n@rest('metrics')\nclass Metrics {\n @get('/')\n public overview(ctx: RouteContext): string {\n const s = Analytics.self();\n // Every getter is a u64. cacheRatio is the one f64 (a fraction 0..1).\n return `requests=${s.requests} cacheHits=${s.cacheHits} ratio=${s.cacheRatio}`;\n }\n}\n```\n\nYou can also read any counter by its numeric id with `s.metric(MetricId.Requests)`, which returns `0` for an unknown id. The named getters are preferred: they are self-documenting and there are no magic strings to mistype.\n\n### The counter catalog\n\nEvery getter below is a lifetime running total: it only ever goes up, and reading it never resets it. They are grouped by area. (`MetricId` is the matching numeric id, an ambient enum you can use with no import.)\n\n**Requests and the L1 edge**\n\n| Getter | What it counts |\n| --- | --- |\n| `requests` | HTTP requests served |\n| `bytesOutL1` | Response bytes sent |\n| `bytesInL1` | Request bytes received |\n| `status2xx` | 2xx responses (success) |\n| `status3xx` | 3xx responses (redirects) |\n| `status4xx` | 4xx responses (client errors) |\n| `status5xx` | 5xx responses (server errors) |\n| `staticHits` | Static assets served without running your code |\n| `wasmDispatches` | Times your compiled handler ran |\n| `executorFullRejects` | Requests rejected because the worker pool was full |\n| `unknownHostRejects` | Requests rejected for an unknown host |\n| `rateLimitedRejects` | Requests rejected by rate limiting |\n| `gasUsed` | Total compute \"gas\" your handlers consumed |\n\n**Database (ToilDB)**\n\n| Getter | What it counts |\n| --- | --- |\n| `dbOps` | Database operations issued |\n| `dbReads` | Read operations |\n| `dbWrites` | Write operations |\n| `dbErrors` | Operations that errored |\n| `dbLatencyNsSum` | Summed operation latency, in nanoseconds |\n| `meanDbLatencyNs` | Derived: `dbLatencyNsSum / dbOps` (0 with no ops) |\n\n**Streams (WebTransport realtime)**\n\n| Getter | What it counts |\n| --- | --- |\n| `streamAccepts` | Stream connections accepted |\n| `streamRejectWrongNode` | Streams that reached the wrong node |\n| `streamRejectCapacity` | Streams rejected for capacity |\n| `streamRejectArtifact` | Streams rejected for a missing or invalid build |\n| `streamRejectGuest` | Streams your code rejected |\n| `streamTraps` | Stream handler crashes |\n| `streamIdleTimeouts` | Streams closed for being idle |\n| `streamBytesIn` / `streamBytesOut` | Bytes received / sent on streams |\n| `streamBackpressureEvents` | Times a stream had to slow down |\n| `streamCloses` | Clean closes |\n| `streamDisconnects` | Abrupt disconnects |\n\n**Daemons (L4 background jobs)**\n\n| Getter | What it counts |\n| --- | --- |\n| `daemonStarts` / `daemonStartFailures` | Daemon starts / failed starts |\n| `daemonTicksFired` | Scheduled ticks that ran |\n| `daemonTicksSkippedNotLeader` | Ticks skipped because this node was not the leader |\n| `daemonTicksFailed` | Ticks that failed |\n| `daemonLeaderAcquires` / `daemonLeaderFenced` | Leadership gained / lost |\n| `daemonHttpCallAttempts` / `daemonHttpCallFailures` | Outbound HTTP calls attempted / failed |\n\n**Memory, email, and cache**\n\n| Getter | What it counts |\n| --- | --- |\n| `memGrownBytes` | Wasm memory grown, in bytes |\n| `emails` | Emails sent |\n| `cacheHits` | Responses served from the edge cache |\n| `cacheMisses` | Cacheable responses that missed the cache |\n| `cacheRatio` | Derived **`f64`**: `cacheHits / (cacheHits + cacheMisses)`, a fraction from 0 to 1 (0 when there were no cacheable responses) |\n\n### Live gauges and request windows\n\nA few fields are not lifetime totals. They still read as `u64`.\n\n**Live gauges** are the current level right now, not a running total:\n\n- `connectedStreams`: streams connected at this moment.\n- `committedMemory`: wasm memory committed right now, in bytes.\n\n**Request windows** show your current rate-limit usage against your plan cap (a cap of `0` means unlimited):\n\n- `reqMinuteUsed` / `reqMinuteCap`: requests used and the cap for the current 1-minute window.\n- `reqDayUsed` / `reqDayCap`: requests used and the cap for the current 24-hour window.\n\nThere is also `nowMs`, the edge wall-clock time (in milliseconds) when the snapshot was taken, so you can show \"as of\" and compute how fresh the numbers are.\n\n## Time series (for graphs)\n\nThe totals above are single numbers. To draw a graph over time, use `Analytics.series(metric, range)`, which returns a `Series`: a list of per-bucket totals from oldest to newest.\n\n```ts\n@get('/requests-24h')\npublic requests24h(ctx: RouteContext): string {\n const s = Analytics.series(MetricId.Requests, AnalyticsRange.H24);\n // s.points -> per-bucket totals, oldest to newest\n // s.bucketSecs -> seconds each bucket covers\n // s.headMs -> end time (ms) of the newest bucket\n // s.ratePerSec(i) -> requests/second for bucket i (points[i] / bucketSecs)\n return `buckets=${s.points.length} bucketSecs=${s.bucketSecs}`;\n}\n```\n\n`AnalyticsRange` picks the window and the resolution. Short ranges use per-minute buckets for a smooth line; the rest use per-hour buckets (kept for 30 days):\n\n| Range | Span | Bucket width |\n| --- | --- | --- |\n| `H1` | 1 hour | 1 minute |\n| `H6` | 6 hours | 1 minute |\n| `H12` | 12 hours | 1 hour |\n| `H24` | 24 hours | 1 hour |\n| `D3` | 3 days | 1 hour |\n| `D7` | 7 days | 1 hour |\n| `D14` | 14 days | 1 hour |\n| `D30` | 30 days | 1 hour |\n\nThe series stores **totals per bucket**, never rates. A rate is derived: `ratePerSec(i)` divides bucket `i` by its width for you, or you can compute `points[i] / bucketSecs` yourself. Most metrics you will chart are counters (`MetricId.Requests`, `MetricId.CacheHits`, and so on).\n\n## Permissions: who can read what\n\n- **Your own site.** `Analytics.self()` and `Analytics.series(...)` always read the site the request landed on. The calling domain is decided by the edge; your code cannot forge it.\n- **Cross-site reads are for `dacely.com` only.** The privileged `dacely.com` domain (the platform dashboard) may read any site with `Analytics.site(domain)`, `Analytics.siteSeries(domain, ...)`, and `Analytics.listSites(...)`. For any other caller, or an unknown domain, `site` and `siteSeries` return `null` and `listSites` returns an empty page. There is no way for a normal site to read another site's numbers.\n\n`Analytics.listSites(cursor, limit)` enumerates site names for the dashboard, one page at a time. It returns a `SiteList` with `sites: string[]` and `hasMore: bool`. When `hasMore` is true, pass the last name back as the next `cursor` to get the following page.\n\n```ts\n// Only meaningful when the calling domain is dacely.com.\nconst page = Analytics.listSites('', 100); // first 100 site names\nfor (let i = 0; i < page.sites.length; i++) {\n const other = Analytics.site(page.sites[i]); // TenantStats | null\n if (other != null) {\n // read other.requests, other.cacheRatio, ...\n }\n}\n```\n\n## Worked example: return everything\n\nThis route reads the full snapshot and maps it into a typed response. Mapping is mechanical: every getter is a `u64` and every response field is a `u64`, so there are no casts. The example groups a representative field from each area; add the rest of the getters from the catalog above the same way.\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n/** The shape returned to the client. All fields are u64 except the one ratio. */\n@data\nexport class UsageReport {\n // requests / L1\n requests: u64 = 0;\n bytesOut: u64 = 0;\n status2xx: u64 = 0;\n status5xx: u64 = 0;\n gasUsed: u64 = 0;\n // database\n dbOps: u64 = 0;\n dbErrors: u64 = 0;\n meanDbLatencyNs: u64 = 0;\n // cache\n cacheHits: u64 = 0;\n cacheMisses: u64 = 0;\n cacheRatio: f64 = 0;\n // live gauges (current level, not a total)\n connectedStreams: u64 = 0;\n committedMemory: u64 = 0;\n // request windows (cap 0 = unlimited)\n requestsThisMinute: u64 = 0;\n requestsThisMinuteCap: u64 = 0;\n requestsToday: u64 = 0;\n requestsTodayCap: u64 = 0;\n // when the snapshot was read (edge ms)\n nowMs: u64 = 0;\n}\n\n@rest('usage')\nclass Usage {\n @get('/')\n public report(ctx: RouteContext): UsageReport {\n const s = Analytics.self();\n const out = new UsageReport();\n\n out.requests = s.requests;\n out.bytesOut = s.bytesOutL1;\n out.status2xx = s.status2xx;\n out.status5xx = s.status5xx;\n out.gasUsed = s.gasUsed;\n\n out.dbOps = s.dbOps;\n out.dbErrors = s.dbErrors;\n out.meanDbLatencyNs = s.meanDbLatencyNs;\n\n out.cacheHits = s.cacheHits;\n out.cacheMisses = s.cacheMisses;\n out.cacheRatio = s.cacheRatio;\n\n out.connectedStreams = s.connectedStreams;\n out.committedMemory = s.committedMemory;\n\n out.requestsThisMinute = s.reqMinuteUsed;\n out.requestsThisMinuteCap = s.reqMinuteCap;\n out.requestsToday = s.reqDayUsed;\n out.requestsTodayCap = s.reqDayCap;\n\n out.nowMs = s.nowMs;\n return out;\n }\n}\n```\n\nBecause this is a `@data` return type, the client gets a fully typed result from `Server.REST.usage.report()`. See [structured data types](../backend/data.md) for how `@data` works.\n\n## Local dev behavior\n\nUnder `toiljs dev`, the real per-domain metering does not exist (there is only one machine and no fleet), so the dev server returns a **fixed sample** `TenantStats` and a gentle synthetic ramp for `series(...)`. That lets you build and style a dashboard locally. Dev is also permissive: it returns sample data for any domain, because the real `dacely.com`-only check is enforced on the edge. Your code is unchanged; only the numbers differ.\n\n## Gotchas and limits\n\n- **Totals never reset.** The counter getters are lifetime running totals. To see \"requests in the last hour\", use `series(...)` and sum the buckets, not the totals.\n- **Values are `u64`.** Map them into `u64` fields with no casts. The only non-integer field is `cacheRatio` (and `Series.ratePerSec`), which are `f64`.\n- **Not per-user analytics.** These are infrastructure counters per domain. There is no way to record a custom event or attribute a number to a specific user.\n- **Cross-site reads need `dacely.com`.** `Analytics.site(...)` returns `null` for everyone else. Do not build a feature that reads another site's stats.\n- **Snapshots, not live streams.** Each call reads the current values once. `nowMs` tells you when.\n\n## Related\n\n- [Caching](./caching.md), the source of `cacheHits`, `cacheMisses`, and `cacheRatio`.\n- [ToilDB database](../database/README.md), the source of the `db*` counters and where these counters are stored.\n- [Structured data types](../backend/data.md), for the `@data` return type in the worked example.\n- [Compute tiers](../concepts/tiers.md), for what \"L1\", \"streams\", and \"daemons\" mean in the catalog.\n",
|
|
68
|
+
"services/analytics.md": "# Analytics\n\nRead your site's own usage numbers (requests, bytes, cache hits, database ops, errors, and more) straight from a route, with no extra code and no third-party service.\n\nReach for analytics to build a status or usage dashboard for a site: how much traffic it serves, how effective its cache is, how many database operations it runs. It is **infrastructure metering per domain**, not per-user product analytics. There are no custom events; you read a fixed catalog of counters the edge already keeps for you.\n\n`Analytics` is an **ambient global**: use it with no import, like [`crypto`](./crypto.md) or `Time`.\n\n## What \"metering\" means\n\nEvery time the edge (the Dacely server running your code) serves a request, runs a database operation, opens a stream, or sends an email, it **counts** that into a set of per-domain counters. Those counters live in the same worldwide, distributed database that backs [ToilDB](../database/README.md), so the total for your site is correct across every edge location, not just the one machine that happened to serve a request.\n\n`Analytics.self()` reads a **snapshot** of your site's current counter values. You do not record anything yourself; the numbers are already there.\n\n```mermaid\nflowchart LR\n R[\"Every request, DB op,<br/>stream, email\"] --> C[\"Edge counts it into<br/>per-domain counters\"]\n C --> D[(\"Distributed counters,<br/>worldwide\")]\n D --> S[\"Analytics.self()<br/>reads a snapshot\"]\n S --> Y[\"Your route returns<br/>the numbers\"]\n```\n\n## Read your site's stats\n\n`Analytics.self()` returns a `TenantStats` object. Every counter is a **named getter** that returns a `u64` (an unsigned 64-bit integer, always zero or positive), so mapping the numbers into a response needs no casts at all.\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n@rest('metrics')\nclass Metrics {\n @get('/')\n public overview(ctx: RouteContext): string {\n const s = Analytics.self();\n // Every getter is a u64. cacheRatio is the one f64 (a fraction 0..1).\n return `requests=${s.requests} cacheHits=${s.cacheHits} ratio=${s.cacheRatio}`;\n }\n}\n```\n\nYou can also read any counter by its numeric id with `s.metric(MetricId.Requests)`, which returns `0` for an unknown id. The named getters are preferred: they are self-documenting and there are no magic strings to mistype.\n\n### The counter catalog\n\nEvery getter below is a lifetime running total: it only ever goes up, and reading it never resets it. They are grouped by area. (`MetricId` is the matching numeric id, an ambient enum you can use with no import.)\n\n**Requests and the L1 edge**\n\n| Getter | What it counts |\n| --- | --- |\n| `requests` | HTTP requests served |\n| `bytesOutL1` | Response bytes sent |\n| `bytesInL1` | Request bytes received |\n| `status2xx` | 2xx responses (success) |\n| `status3xx` | 3xx responses (redirects) |\n| `status4xx` | 4xx responses (client errors) |\n| `status5xx` | 5xx responses (server errors) |\n| `staticHits` | Static assets served without running your code |\n| `wasmDispatches` | Times your compiled handler ran |\n| `executorFullRejects` | Requests rejected because the worker pool was full |\n| `unknownHostRejects` | Requests rejected for an unknown host |\n| `rateLimitedRejects` | Requests rejected by rate limiting |\n| `gasUsed` | Total compute \"gas\" your handlers consumed |\n\n**Database (ToilDB)**\n\n| Getter | What it counts |\n| --- | --- |\n| `dbOps` | Database operations issued |\n| `dbReads` | Read operations |\n| `dbWrites` | Write operations |\n| `dbErrors` | Operations that errored |\n| `dbLatencyNsSum` | Summed operation latency, in nanoseconds |\n| `meanDbLatencyNs` | Derived: `dbLatencyNsSum / dbOps` (0 with no ops) |\n\n**Streams (WebTransport realtime)**\n\n| Getter | What it counts |\n| --- | --- |\n| `streamAccepts` | Stream connections accepted |\n| `streamRejectWrongNode` | Streams that reached the wrong node |\n| `streamRejectCapacity` | Streams rejected for capacity |\n| `streamRejectArtifact` | Streams rejected for a missing or invalid build |\n| `streamRejectGuest` | Streams your code rejected |\n| `streamTraps` | Stream handler crashes |\n| `streamIdleTimeouts` | Streams closed for being idle |\n| `streamBytesIn` / `streamBytesOut` | Bytes received / sent on streams |\n| `streamBackpressureEvents` | Times a stream had to slow down |\n| `streamCloses` | Clean closes |\n| `streamDisconnects` | Abrupt disconnects |\n\n**Daemons (L4 background jobs)**\n\n| Getter | What it counts |\n| --- | --- |\n| `daemonStarts` / `daemonStartFailures` | Daemon starts / failed starts |\n| `daemonTicksFired` | Scheduled ticks that ran |\n| `daemonTicksSkippedNotLeader` | Ticks skipped because this node was not the leader |\n| `daemonTicksFailed` | Ticks that failed |\n| `daemonLeaderAcquires` / `daemonLeaderFenced` | Leadership gained / lost |\n| `daemonHttpCallAttempts` / `daemonHttpCallFailures` | Outbound HTTP calls attempted / failed |\n\n**Memory, email, and cache**\n\n| Getter | What it counts |\n| --- | --- |\n| `memGrownBytes` | Wasm memory grown, in bytes |\n| `emails` | Emails sent |\n| `cacheHits` | Responses served from the edge cache |\n| `cacheMisses` | Cacheable responses that missed the cache |\n| `cacheRatio` | Derived **`f64`**: `cacheHits / (cacheHits + cacheMisses)`, a fraction from 0 to 1 (0 when there were no cacheable responses) |\n\n### Live gauges and request meters\n\nA few fields are not lifetime totals. They still read as `u64` (except the derived `sustainedRpsCap`, an `f64`).\n\n**Live gauges** are the current level right now, not a running total:\n\n- `connectedStreams`: streams connected at this moment.\n- `committedMemory`: wasm memory committed right now, in bytes.\n\n**Request meters** show where you sit against your plan. The model is a **sustained-rate (\"unmetered pipe\")** one, not a fixed per-minute ceiling. Your plan has a **sustained request rate** `X` (requests per second), measured over a **3-minute rolling window**. Two important consequences:\n\n- **Instantaneous spikes are free.** There is no per-tier cap on how fast you may burst inside a window; you may spike right up to the box's own hardware ceiling (the `firewall.global_pps` DDoS backstop, shared across all tenants). Nothing throttles you for a brief peak.\n- **Only sustained overuse throttles.** You get an HTTP `429` once your fleet-global request count over the *trailing* 3 minutes exceeds `X * 180` (the sustained rate integrated over the window), or once your 30-day quota is exhausted. \"Fleet-global\" means the count is summed across every edge location, not just the one machine serving you.\n\nBecause the window **rolls**, a burst ages out gradually over the following 3 minutes rather than being forgiven all at once on a boundary. The `Retry-After` on a `429` tells you exactly how long until enough of it has decayed.\n\nThe fields:\n\n- `reqBurstUsed` / `reqBurstCap`: fleet-global requests in the **trailing 3-minute rolling window**, and its allowance. This is exactly the number the edge compares against, so it never disagrees with why you were throttled. The allowance is `X * 180` (the window is `BURST_WINDOW_SECS = 180` seconds). A cap of `0` means **unmetered** (no sustained cap).\n- `req30dUsed` / `req30dCap`: fleet-global requests in the **current 30-day window**, and the 30-day quota. This one still tumbles, because it is a billing period rather than a rate. A quota of `0` means **unmetered**.\n- `sustainedRpsCap`: a convenience `f64` derived from the burst cap, `reqBurstCap / BURST_WINDOW_SECS`. This is your plan's sustained rate `X` in requests/second (`0.0` when unmetered), so you can show \"1000 rps sustained\" without doing the division yourself.\n\nThere is also `nowMs`, the edge wall-clock time (in milliseconds) when the snapshot was taken, so you can show \"as of\" and compute how fresh the numbers are.\n\n## Time series (for graphs)\n\nThe totals above are single numbers. To draw a graph over time, use `Analytics.series(metric, range)`, which returns a `Series`: a list of per-bucket totals from oldest to newest.\n\n```ts\n@get('/requests-24h')\npublic requests24h(ctx: RouteContext): string {\n const s = Analytics.series(MetricId.Requests, AnalyticsRange.H24);\n // s.points -> per-bucket totals, oldest to newest\n // s.bucketSecs -> seconds each bucket covers\n // s.headMs -> end time (ms) of the newest bucket\n // s.ratePerSec(i) -> requests/second for bucket i (points[i] / bucketSecs)\n return `buckets=${s.points.length} bucketSecs=${s.bucketSecs}`;\n}\n```\n\n`AnalyticsRange` picks the window and the resolution. Short ranges use per-minute buckets for a smooth line; the rest use per-hour buckets (kept for 30 days):\n\n| Range | Span | Bucket width |\n| --- | --- | --- |\n| `H1` | 1 hour | 1 minute |\n| `H6` | 6 hours | 1 minute |\n| `H12` | 12 hours | 1 hour |\n| `H24` | 24 hours | 1 hour |\n| `D3` | 3 days | 1 hour |\n| `D7` | 7 days | 1 hour |\n| `D14` | 14 days | 1 hour |\n| `D30` | 30 days | 1 hour |\n\nThe series stores **totals per bucket**, never rates. A rate is derived: `ratePerSec(i)` divides bucket `i` by its width for you, or you can compute `points[i] / bucketSecs` yourself. Most metrics you will chart are counters (`MetricId.Requests`, `MetricId.CacheHits`, and so on).\n\n## Permissions: who can read what\n\n- **Your own site.** `Analytics.self()` and `Analytics.series(...)` always read the site the request landed on. The calling domain is decided by the edge; your code cannot forge it.\n- **Cross-site reads are for `dacely.com` only.** The privileged `dacely.com` domain (the platform dashboard) may read any site with `Analytics.site(domain)`, `Analytics.siteSeries(domain, ...)`, and `Analytics.listSites(...)`. For any other caller, or an unknown domain, `site` and `siteSeries` return `null` and `listSites` returns an empty page. There is no way for a normal site to read another site's numbers.\n\n`Analytics.listSites(cursor, limit)` enumerates site names for the dashboard, one page at a time. It returns a `SiteList` with `sites: string[]` and `hasMore: bool`. When `hasMore` is true, pass the last name back as the next `cursor` to get the following page.\n\n```ts\n// Only meaningful when the calling domain is dacely.com.\nconst page = Analytics.listSites('', 100); // first 100 site names\nfor (let i = 0; i < page.sites.length; i++) {\n const other = Analytics.site(page.sites[i]); // TenantStats | null\n if (other != null) {\n // read other.requests, other.cacheRatio, ...\n }\n}\n```\n\n## Worked example: return everything\n\nThis route reads the full snapshot and maps it into a typed response. Mapping is mechanical: nearly every getter is a `u64` and maps to a `u64` field with no cast; the only `f64` values are the derived `cacheRatio` and `sustainedRpsCap`. The example groups a representative field from each area; add the rest of the getters from the catalog above the same way.\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n/** The shape returned to the client. All fields are u64 except the two derived f64s (cacheRatio, sustainedRpsCap). */\n@data\nexport class UsageReport {\n // requests / L1\n requests: u64 = 0;\n bytesOut: u64 = 0;\n status2xx: u64 = 0;\n status5xx: u64 = 0;\n gasUsed: u64 = 0;\n // database\n dbOps: u64 = 0;\n dbErrors: u64 = 0;\n meanDbLatencyNs: u64 = 0;\n // cache\n cacheHits: u64 = 0;\n cacheMisses: u64 = 0;\n cacheRatio: f64 = 0;\n // live gauges (current level, not a total)\n connectedStreams: u64 = 0;\n committedMemory: u64 = 0;\n // request meters (cap 0 = unmetered)\n reqBurstUsed: u64 = 0; // requests in the current 3-minute sustained-rate bucket\n reqBurstCap: u64 = 0; // per-bucket cap = sustained_rps * 180\n req30dUsed: u64 = 0; // requests in the current 30-day window\n req30dCap: u64 = 0; // 30-day quota\n sustainedRpsCap: f64 = 0; // derived: reqBurstCap / 180 (the plan's sustained rps)\n // when the snapshot was read (edge ms)\n nowMs: u64 = 0;\n}\n\n@rest('usage')\nclass Usage {\n @get('/')\n public report(ctx: RouteContext): UsageReport {\n const s = Analytics.self();\n const out = new UsageReport();\n\n out.requests = s.requests;\n out.bytesOut = s.bytesOutL1;\n out.status2xx = s.status2xx;\n out.status5xx = s.status5xx;\n out.gasUsed = s.gasUsed;\n\n out.dbOps = s.dbOps;\n out.dbErrors = s.dbErrors;\n out.meanDbLatencyNs = s.meanDbLatencyNs;\n\n out.cacheHits = s.cacheHits;\n out.cacheMisses = s.cacheMisses;\n out.cacheRatio = s.cacheRatio;\n\n out.connectedStreams = s.connectedStreams;\n out.committedMemory = s.committedMemory;\n\n out.reqBurstUsed = s.reqBurstUsed;\n out.reqBurstCap = s.reqBurstCap;\n out.req30dUsed = s.req30dUsed;\n out.req30dCap = s.req30dCap;\n out.sustainedRpsCap = s.sustainedRpsCap;\n\n out.nowMs = s.nowMs;\n return out;\n }\n}\n```\n\nBecause this is a `@data` return type, the client gets a fully typed result from `Server.REST.usage.report()`. See [structured data types](../backend/data.md) for how `@data` works.\n\n## Local dev behavior\n\nUnder `toiljs dev`, the real per-domain metering does not exist (there is only one machine and no fleet), so the dev server returns a **fixed sample** `TenantStats` and a gentle synthetic ramp for `series(...)`. That lets you build and style a dashboard locally. Dev is also permissive: it returns sample data for any domain, because the real `dacely.com`-only check is enforced on the edge. Your code is unchanged; only the numbers differ.\n\n## Gotchas and limits\n\n- **Totals never reset.** The counter getters are lifetime running totals. To see \"requests in the last hour\", use `series(...)` and sum the buckets, not the totals.\n- **Values are `u64`.** Map them into `u64` fields with no casts. The non-integer fields are `cacheRatio` and `sustainedRpsCap` (and `Series.ratePerSec`), which are `f64`.\n- **Spikes are free; only sustained overuse throttles.** The request meters follow a sustained-rate model: you may burst up to the box's hardware ceiling, and throttling (`429`) starts only when the current 3-minute bucket passes `sustainedRpsCap * 180`, or when the 30-day quota runs out. A cap or quota of `0` means unmetered.\n- **Not per-user analytics.** These are infrastructure counters per domain. There is no way to record a custom event or attribute a number to a specific user.\n- **Cross-site reads need `dacely.com`.** `Analytics.site(...)` returns `null` for everyone else. Do not build a feature that reads another site's stats.\n- **Snapshots, not live streams.** Each call reads the current values once. `nowMs` tells you when.\n\n## Related\n\n- [Caching](./caching.md), the source of `cacheHits`, `cacheMisses`, and `cacheRatio`.\n- [ToilDB database](../database/README.md), the source of the `db*` counters and where these counters are stored.\n- [Structured data types](../backend/data.md), for the `@data` return type in the worked example.\n- [Compute tiers](../concepts/tiers.md), for what \"L1\", \"streams\", and \"daemons\" mean in the catalog.\n",
|
|
69
69
|
"services/caching.md": "# Caching\n\n**Caching lets the edge keep a copy of a response and hand it back instantly, without re-running your code.** You opt in per route, either with the `@cache` decorator or by calling `Response.cache(...)`.\n\n## What \"cache\" means here\n\nA **cache** is a fast, temporary store of a previous answer. When a request comes in, the edge can check: \"have I already computed this exact response recently?\" If yes, that is a **cache hit**: it returns the saved copy in microseconds and never wakes up your WebAssembly program. If no, that is a **cache miss**: it runs your handler, sends the result, and (if you asked it to) saves a copy for next time.\n\nThere are two places a response can be cached:\n\n- **The edge cache** is shared: one saved copy on an edge server answers many users. This is where the big speedups come from.\n- **The browser cache** is private to one user's browser. You control it with a standard `Cache-Control` header.\n\nYou can use one or both.\n\n## When to cache (and when not)\n\nCache a response only when it is the **same for everyone** and safe to reuse for a little while:\n\n- Good: a public leaderboard, a product catalog, a blog index, an exchange-rate snapshot, a \"server status\" endpoint.\n- Bad: anything personalized (a user's dashboard, their cart, \"hello, Alice\"). If you cached that, one user could be served another user's data. The edge has strong safety rails against this (below), but the simplest rule is: **do not cache per-user data on the shared edge.**\n\nCaching is **always opt-in**. A response you do not mark is never stored. There is no \"cache every GET\" mode, because the edge cannot tell a personalized response from a public one on its own.\n\n## How: the `@cache` decorator\n\nA **decorator** is an annotation you put right above a route method. `@cache` tells the edge how long it may reuse the response. The numbers are positional:\n\n```ts\nimport { Response } from 'toiljs/server/runtime';\n\n@rest('leaderboard')\nclass Leaderboard {\n @cache(60) // 60 MINUTES at the edge\n @get('/')\n public top(): Response {\n // ...expensive query...\n return Response.json(standings);\n }\n}\n```\n\nThe full form is `@cache(edgeTtlMinutes, browserTtlSeconds?, privateScope?, allowAuth?)`:\n\n```ts\n@cache(60) // 60 minutes at the edge, browser does not cache\n@cache(60, 300) // + tell the browser to cache for 5 minutes (300s)\n@cache(60, 300, true) // + mark it \"private\": browser only, never the shared edge\n@cache(60, 300, true, true) // + allow caching even for logged-in requests (see rails)\n```\n\n**TTL** means \"time to live\": how long a saved copy stays fresh before the edge throws it away and recomputes. Notice the units differ on purpose: the edge TTL is in **minutes**, the browser TTL is in **seconds**.\n\nEvery argument must be a plain number or `true`/`false` literal. If you pass something computed (a variable, a function call), the decorator safely degrades to \"not cached\" instead of misbehaving.\n\n## How: `Response.cache(...)`\n\n`@cache` is just a shorthand. You can do the same thing by hand on any `Response`, which is handy when you decide at runtime:\n\n```ts\npublic cache(\n edgeTtlMinutes: u16,\n browserTtlSeconds: u32 = 0,\n privateScope: bool = false,\n allowAuth: bool = false,\n): Response\n```\n\n```ts\nreturn Response.json(body).cache(60, 300);\n```\n\n`cache(...)` returns the same `Response`, so it chains. There is also a shorthand for \"edge only, no browser caching\":\n\n```ts\nreturn Response.bytes(blob).cacheFor(5); // edge-cache 5 minutes\n```\n\nUnder the hood, both set one header, `Dacely-Cache-Control`, that the edge reads and then strips (it never reaches the client).\n\n## The parameters, in detail\n\n| Parameter | What it does |\n| --- | --- |\n| `edgeTtlMinutes` | How long the shared edge may serve the saved copy. Clamped to a 24-hour maximum, no matter what you pass. `0` means no edge caching. |\n| `browserTtlSeconds` | The `max-age` sent to the browser. `0` (default) means the browser is told not to cache. |\n| `privateScope` | Marks the response `private`: it may only live in the browser's own cache, never the shared edge or a CDN. |\n| `allowAuth` | Permits caching a response to a logged-in request. Off by default (see safety rails). |\n\n## A hit vs a miss, visually\n\n```mermaid\nflowchart TD\n R[\"Request arrives<br/>at the edge\"] --> Q{\"Saved copy<br/>still fresh?\"}\n Q -->|Yes: cache HIT| H[\"Return the saved response<br/>(microseconds, no code runs)\"]\n Q -->|No: cache MISS| M[\"Run your WebAssembly handler\"]\n M --> D{\"Marked cacheable<br/>and eligible?\"}\n D -->|Yes| S[\"Save a copy for the TTL,<br/>then return it\"]\n D -->|No| X[\"Return it, save nothing\"]\n```\n\nEvery response the edge sends carries a `Dacely-Cache` header so you can see what happened while debugging:\n\n| `Dacely-Cache` value | Meaning |\n| --- | --- |\n| `HIT` | Served from the cache; your code did not run. |\n| `MISS` | Your code ran, and the response was saved (the next identical request should `HIT`). |\n| `DYNAMIC` | Your code ran, and the response was not cached (no directive, or not eligible). |\n| `BYPASS` | A fallback path (for example the compute layer was momentarily unavailable); not a cached answer. |\n\n## Safety rails (why cross-user leaks cannot happen)\n\nThe edge refuses to store certain responses even if you asked it to. These rules exist because a shared cache is exactly where one user's data could accidentally leak to another:\n\n- **5xx responses are never cached.** A server error is temporary; caching a `500` would keep serving the failure for the whole TTL. (2xx, 3xx, and 4xx are cacheable: a redirect or a `404`/`410` is a predictable function of the request.)\n- **A response with a `Set-Cookie` is never cached.** A cookie is per-user state.\n- **A response to a logged-in request is not cached** unless you pass `allowAuth = true`. This stops one user's personalized response from being handed to someone else. Even with `allowAuth = true`, such an entry is only ever served back to other **authenticated** requests, never to an anonymous one (which would skip the auth check entirely).\n- **The edge TTL is clamped to 24 hours.**\n\nThe cache is also keyed precisely: an entry is identified by the **host, method, path, and the exact request body**. So a `POST` with body `A` never returns the copy saved for body `B`, and a `GET` never returns a `POST`'s copy.\n\nBecause auth checks and body decoding run **before** the cache directive is applied, an unauthorized request is rejected with `401` before anything is stored, and a cached copy only ever comes from a handler that actually ran successfully.\n\n## ETag, Last-Modified, and 304 (static files)\n\nThe `@cache` decorator above is for **dynamic** responses (ones your code computes). Your project's **static files** (the built HTML, JS, CSS, and images) get a second, automatic layer of caching that you do not configure. It is worth understanding because it is what makes repeat page loads cheap.\n\nEvery static file the edge serves carries three headers:\n\n- **`ETag`**: a short fingerprint of the file's contents (a \"version tag\"). toiljs uses a *weak* ETag (it starts with `W/`), which survives compression by a CDN in front of the edge.\n- **`Last-Modified`**: when the file last changed.\n- **`Cache-Control`**: content-hashed assets (files whose name contains their content hash, like `app-CfvHW67Y.js`) get `immutable` with a one-year max-age, because the URL itself changes whenever the content does. Everything else (HTML, non-hashed assets) gets `must-revalidate`, so the browser always checks freshness.\n\n**Revalidation** is the trick. Instead of re-downloading a file it already has, a browser asks \"has this changed?\" by echoing the tag back in an `If-None-Match` header (or the date in `If-Modified-Since`). If the file is unchanged, the edge replies **`304 Not Modified`** with an empty body, and the browser reuses its copy. A `304` is tiny and fast.\n\n```mermaid\nsequenceDiagram\n participant B as Browser\n participant E as Dacely edge\n B->>E: GET /app.js<br/>If-None-Match: W/\"1a2b-c3d4\"\n alt file unchanged\n E-->>B: 304 Not Modified<br/>(no body)\n else file changed\n E-->>B: 200 OK<br/>(new bytes + new ETag)\n end\n```\n\nThe precedence follows the HTTP spec (RFC 7232): if the request has `If-None-Match`, that decides it; only when it is absent does `If-Modified-Since` apply. You get all of this for free; there is nothing to turn on.\n\n## Where cached bodies live (memory bounds)\n\nYou do not need this to use caching, but it explains the limits you may hit.\n\nThe edge response cache is bounded and hard-capped so it can never exhaust a node's memory:\n\n- **RAM tier**: small, short-lived responses live in memory, within a bounded budget (on the order of ~128 MB) plus a cap on the number of entries. A single response over ~256 KB does not go in RAM.\n- **Disk tier (optional)**: when the operator enables an on-disk spill directory, a **big** response (over ~256 KB) or a **long-lived** one (TTL of 10 minutes or more) is written to disk and served back with near-zero RAM, the same way static files are. If disk spill is not enabled, a big response is simply not cached (you will see `Dacely-Cache: DYNAMIC`).\n\nExpired entries are dropped on read (a stale entry is treated as a miss) and reclaimed when the cache needs room. Nothing survives a process restart.\n\n## Gotchas\n\n- **Units differ.** Edge TTL is in minutes; browser TTL is in seconds. `@cache(60)` is one hour at the edge, not one minute.\n- **Do not cache personalized data on the shared edge.** If a response depends on who is asking, either do not cache it, or use `privateScope` so it is browser-only.\n- **A `Set-Cookie` disables edge caching for that response.** If you set a cookie, that response will not be edge-cached, by design.\n- **Very large or slow-to-change bodies may not be cached** unless disk spill is enabled on the node. Check the `Dacely-Cache` header if a response you expected to cache shows `DYNAMIC`.\n- **Nothing persists across restarts.** The cache is a speed optimization, never a source of truth.\n\n## Related\n\n- [Rate limiting](./ratelimit.md): protect a route before it runs.\n- [Analytics](./analytics.md): see your cache hit ratio per site.\n- [Cookies](./cookies.md): why a `Set-Cookie` opts a response out of edge caching.\n- [Auth, sessions, and `@user`](../auth/usage.md): why logged-in responses are not cached by default.\n- [Every decorator](../concepts/decorators.md).\n",
|
|
70
70
|
"services/cookies.md": "# Cookies\n\n**Cookies let you store a small piece of state in the user's browser and read it back on the next request.** toiljs ships a complete cookie layer: read incoming cookies, set them on a response, and (optionally) sign or encrypt their values so they cannot be tampered with.\n\n## What a cookie is\n\nA **cookie** is a tiny named value the browser holds for your site. The browser sends it back on every request to your site (in a `Cookie` request header), and you can change or add cookies by putting a `Set-Cookie` header on your response. Cookies are how a site remembers things between requests: a theme preference, a feature flag, a session id.\n\nTwo directions to keep straight:\n\n- **Reading** happens on the `Request` (the browser sent its cookies to you).\n- **Writing** happens on the `Response` (you tell the browser to store or clear a cookie).\n\n```mermaid\nsequenceDiagram\n participant B as Browser\n participant S as Your handler\n B->>S: Request<br/>Cookie: theme=dark\n Note over S: req.cookie('theme') returns \"dark\"\n S-->>B: Response<br/>Set-Cookie: theme=light\n Note over B: browser stores the new cookie<br/>and sends it next time\n```\n\nThe cookie types (`Cookie`, `Cookies`, `CookieMap`, `SecureCookies`, and the `SameSite` / `CookieEncoding` / `CookiePrefix` enums) are **ambient globals**: use them with no import, like `crypto`. They are also exported from `toiljs/server/runtime` if you prefer an explicit import.\n\n## Reading incoming cookies\n\nOn the `Request`, two methods cover almost everything:\n\n```ts\nconst theme = req.cookie('theme'); // one value: string | null\nconst jar = req.cookies(); // all of them, parsed once and cached\njar.get('theme'); // \"dark\", or null\njar.has('theme'); // true / false\n```\n\n`req.cookies()` returns a `CookieMap`, an ordered name-to-value view. It is parsed once per request and cached, so calling it repeatedly is cheap.\n\n## Setting a cookie on a response\n\nBuild a cookie with the fluent `Cookie` builder, then attach it with `setCookie`. Every setter returns the cookie, so calls chain:\n\n```ts\nimport { Response } from 'toiljs/server/runtime';\n\nreturn Response.json('{\"ok\":true}').setCookie(\n Cookie.create('theme', 'dark')\n .path('/')\n .maxAge(60 * 60 * 24 * 365) // one year, in seconds\n .sameSite(SameSite.Lax)\n .secure(),\n);\n```\n\nShorthands for the common cases:\n\n```ts\nresp.setCookieKV('theme', 'dark'); // name=value, no attributes\nresp.clearCookie('theme'); // delete it (empty value, expired)\n```\n\nEach `setCookie` adds its own `Set-Cookie` header; cookies are never folded into one header.\n\n### The attributes you will actually use\n\n| Method | Sets | What it means |\n| --- | --- | --- |\n| `path('/')` | `Path` | Which URL paths the cookie applies to. `/` means the whole site. |\n| `maxAge(seconds)` | `Max-Age` | How long the browser keeps it. `0` or negative deletes it now. |\n| `secure()` | `Secure` | Only send over HTTPS. Use it for anything that matters. |\n| `httpOnly()` | `HttpOnly` | Hide it from JavaScript in the browser (blocks theft via XSS). Use it for session cookies. |\n| `sameSite(SameSite.Lax)` | `SameSite` | Controls whether the cookie is sent on cross-site requests. `Lax` is a good default; `Strict` is tighter; `None` allows cross-site (and forces `Secure`). |\n\nYou rarely need the rest, but they exist: `domain`, `expires` (an absolute time instead of a duration), `partitioned` (CHIPS), `priority`, and `extension` for anything custom.\n\n## The `__Host-` and `__Secure-` prefixes\n\nTwo special name prefixes give the browser extra guarantees. They are not magic strings you invent; browsers recognize them and **refuse** to accept a cookie that carries the prefix without meeting its rules.\n\n- **`__Secure-`**: the cookie must be `Secure` (HTTPS only).\n- **`__Host-`**: the cookie must be `Secure`, have `Path=/`, and have **no** `Domain`. This locks the cookie to exactly your host, so a sibling subdomain cannot set or read it. It is the strongest option and the right choice for session cookies.\n\nYou do not spell the prefix yourself. Two helpers apply it and enforce the rules for you:\n\n```ts\nCookie.create('sid', 'abc123')\n .httpOnly()\n .sameSite(SameSite.Lax)\n .maxAge(3600)\n .asHostPrefixed(); // prepends __Host-, forces Secure + Path=/ + no Domain\n```\n\n`asSecurePrefixed()` is the lighter version (prepends `__Secure-` and forces `Secure`).\n\n> Under `toiljs dev`, browsers treat `http://localhost` as a secure context, so `Secure` and `__Host-` cookies work over plain HTTP locally. You do not need HTTPS to test them.\n\n## Worked example: a theme preference cookie\n\nA tiny handler that reads a `theme` cookie and lets the user flip it. This is the canonical \"remember a preference\" pattern: a plain, non-secret value.\n\n```ts\nimport { Response, RouteContext } from 'toiljs/server/runtime';\n\n@rest('prefs')\nclass Prefs {\n // GET /prefs/theme -> current theme (defaults to \"light\")\n @get('/theme')\n read(ctx: RouteContext): Response {\n const theme = ctx.request.cookie('theme');\n return Response.text((theme != null ? theme : 'light') + '\\n');\n }\n\n // POST /prefs/theme/dark -> remember \"dark\" for a year\n @post('/theme/dark')\n setDark(ctx: RouteContext): Response {\n return Response.text('saved\\n').setCookie(\n Cookie.create('theme', 'dark')\n .path('/')\n .maxAge(60 * 60 * 24 * 365)\n .sameSite(SameSite.Lax)\n .secure(),\n );\n }\n}\n```\n\nA preference like this does not need to be secret or tamper-proof (the worst a user can do is change their own theme), so a plain cookie is fine. When the value **does** matter, reach for `SecureCookies`.\n\n## Secure cookies: signing and encryption\n\nSometimes a cookie value must not be forged or read by the user. `SecureCookies` covers both, built on the `crypto` global (no extra setup):\n\n- **Signed** (`SecureCookies.signed(key)`): the value stays readable but is stamped with a signature (HMAC-SHA256) bound to the cookie's name. The user can see it but cannot change it or move it to another cookie without the signature failing. Use this for a value the client may read but must not tamper with.\n- **Encrypted** (`SecureCookies.encrypted(key)`): the value is scrambled (AES-GCM) so the user cannot read **or** change it. Use this for something confidential.\n\nThe `key` is raw secret bytes you supply (load it from a secret via [`Environment.getSecure`](./environment.md), never hard-code it):\n\n```ts\n// In a real app, load this from a secret, do not inline it.\nconst key = Uint8Array.wrap(String.UTF8.encode('0123456789abcdef0123456789abcdef'));\n\n// Signed: readable but tamper-proof.\nconst signer = SecureCookies.signed(key);\nconst sealed = signer.sign('session', 'user-42');\nconst user = signer.unsign('session', sealed); // \"user-42\", or null if tampered\n\n// Encrypted: unreadable and authenticated.\nconst box = SecureCookies.encrypted(key);\nresp.setCookie(box.seal(Cookie.create('secret', 'top-secret').httpOnly()));\nconst secret = box.open(req.cookies(), 'secret'); // \"top-secret\", or null\n```\n\nTwo important safety properties:\n\n- **Verification never crashes.** `unsign` and `decrypt` return `null` on a tampered, truncated, or wrong-key value; they never throw. That makes them safe to call directly on attacker-controlled input.\n- **Values are bound to the cookie name.** A sealed value made for cookie `a` will not verify or decrypt under cookie `b`.\n\nFor HMAC keys, use 32 or more bytes. For AES, the key must be exactly 16 or 32 bytes (a wrong length is rejected immediately). You can rotate keys by sealing with a new key while still accepting an old one:\n\n```ts\nconst signer = SecureCookies.signed(newKey).addKey(oldKey); // seal with new, open with either\n```\n\n## Encoding is not encryption\n\nOne common mix-up. **Encoding** (the `CookieEncoding` on a `Cookie`, default percent-encoding) only makes a value safe to put on the wire; anyone can reverse it. **Signing / encryption** (`SecureCookies`) is the cryptographic protection and needs a secret key. If you want a value the user cannot forge, you need `SecureCookies`, not a fancier encoding.\n\n## Relationship to auth session cookies\n\nYou do not manage login cookies by hand. The [auth system](../auth/usage.md) sets and reads its own hardened, `__Host-` prefixed, signed session cookie for you, and enforces access with the `@auth` decorator. This page is for **your own** cookies (preferences, flags, small bits of state). If you find yourself building a login cookie from scratch, use auth instead; it already does the hard, security-sensitive parts correctly.\n\n## Advanced reference: `Cookie` and `Cookies` helpers\n\nMost handlers only need the builders above. These extra members are here for lower-level work: validating a cookie before you send it, parsing a `Set-Cookie` header back into a `Cookie` (for a proxy or a test), or controlling the exact wire encoding.\n\n### More `Cookie` methods\n\n| Method | Returns | What it does |\n| --- | --- | --- |\n| `validate()` | `CookieValidation` | Check the cookie against RFC 6265bis (name is a token, name+value within the 4096-byte cap, `Path` form, prefix rules, `SameSite=None` / `Partitioned` imply `Secure`, the 400-day lifetime cap) **without** sending it. Never throws. |\n| `serialize(strict)` | `string` | Build the `Set-Cookie` value. Lenient by default (always emits a best-effort cookie); pass `serialize(true)` to **throw** on a hard violation instead. `setCookie(...)` calls this for you. |\n| `withEncoding(enc)` | `Cookie` | Choose how the value goes on the wire: `CookieEncoding.Percent` (default), `.Raw`, or `.Base64Url`. Chains like the other setters. |\n| `detectedPrefix()` | `CookiePrefix` | Report which special prefix the name carries (`CookiePrefix.Host`, `.Secure`, or `.None`), detected case-insensitively. |\n| `encodedValue()` | `string` | The value after the chosen encoding is applied, exactly as it will appear on the wire. |\n| `expiresRaw(date)` | `Cookie` | Set `Expires` to a verbatim date string (an escape hatch; it is **not** validated, unlike `expires(epochSeconds)`). |\n\n`validate()` returns a `CookieValidation`, a small result object:\n\n- `valid: bool` is `true` when there were no problems.\n- `errors: Array<string>` holds one human-readable message per problem (empty when valid).\n\n```ts\n// A __Host- cookie must be Secure with Path=/; here we forgot both.\nconst c = Cookie.create('__Host-sid', 'abc');\nconst check = c.validate();\nif (!check.valid) {\n // check.errors includes \"__Host- prefix requires the Secure attribute\"\n // (asHostPrefixed() would have set those attributes for you)\n}\n```\n\n### `Cookies` static helpers\n\n`Cookies` is the read side, plus a couple of codec shortcuts. Every method is static, so call them as `Cookies.xxx(...)`.\n\n| Call | Returns | What it does |\n| --- | --- | --- |\n| `Cookies.parse(header)` | `CookieMap` | Parse a request `Cookie` header (`a=1; b=2`) into a name-to-value map. Values are percent-decoded; malformed pairs are skipped, never thrown. |\n| `Cookies.get(header, name)` | `string \\| null` | Shorthand: parse `header` and return one value (or `null`). |\n| `Cookies.serialize(name, value)` | `string` | One-shot `Set-Cookie` value for `name=value` with no attributes. For attributes, build a `Cookie` and call `serialize()`. |\n| `Cookies.parseSetCookie(header)` | `Cookie` | Parse a `Set-Cookie` field value back into a `Cookie` (handy for proxies or tests). The value is kept verbatim (`CookieEncoding.Raw`) so re-serializing reproduces the original. |\n| `Cookies.encodeValue(raw)` | `string` | Percent-encode a value the way the default `Cookie` encoding would. |\n| `Cookies.decodeValue(enc)` | `string` | Percent-decode a value (the inverse of `encodeValue`). |\n\n```ts\n// Round-trip a Set-Cookie header (for example, inspecting an upstream response in a proxy):\nconst cookie = Cookies.parseSetCookie('sid=abc123; Path=/; HttpOnly; SameSite=Lax');\ncookie.name; // \"sid\"\ncookie.serialize(); // \"sid=abc123; Path=/; SameSite=Lax; HttpOnly\"\n```\n\n## Gotchas\n\n- **Read on the `Request`, write on the `Response`.** Setting a cookie does not change what `req.cookie(...)` returns for the current request; it takes effect on the browser's next request.\n- **`maxAge` is in seconds.** `maxAge(3600)` is one hour, not one millisecond.\n- **To delete a cookie, the `path` (and `domain`) must match** the ones you set it with. Use `clearCookie(name, path, domain)`.\n- **A `Set-Cookie` opts a response out of edge caching.** By design, a response that sets a cookie is never edge-cached, because a cookie is per-user state. See [Caching](./caching.md).\n- **Do not put secrets in a plain cookie value.** Use `SecureCookies.encrypted(...)`, and load the key from a [secret](./environment.md).\n\n## Related\n\n- [Auth, sessions, and `@user`](../auth/usage.md): the built-in, hardened session cookie.\n- [Crypto](./crypto.md): the primitives under `SecureCookies`.\n- [Environment and secrets](./environment.md): where to store your signing / encryption key.\n- [Caching](./caching.md): why setting a cookie disables edge caching.\n",
|
|
71
71
|
"services/crypto.md": "# Crypto\n\nA small, safe cryptography toolkit (hashing, random bytes, HMAC, symmetric encryption, key derivation, and signatures) available with no import through the ambient `crypto` global.\n\nReach for `crypto` to fingerprint a value (hashing), generate an unguessable token or ID (random bytes), prove a value has not been tampered with (HMAC or a signature), or encrypt data you store yourself (AES). Skip it for **login passwords**: use [auth](../auth/README.md), which is purpose-built and does the hard parts for you. `crypto` is also the engine under [signed cookies](./cookies.md) and auth, so most apps use it indirectly without ever calling it.\n\n## The shape of the API\n\n`crypto` mirrors the browser Web Crypto API, with one big difference: **there are no Promises**. ToilScript (the language your backend is written in) has no `async`, so every call returns its result **directly**, right away.\n\n```ts\nconst digest = crypto.sha256Text('hello'); // Uint8Array, no await\nconst id = crypto.randomUUID(); // string, no await\n```\n\nThere are two layers:\n\n- **`crypto.*`**: ergonomic one-call helpers (hash this string, give me a UUID, HMAC these bytes). Start here.\n- **`crypto.subtle`**: the full primitive surface (import a key, encrypt, sign, derive bits) for when you need more control.\n\nBoth are ambient globals (no import). The small helper classes and the `ALG_*` / `FMT_*` / `USAGE_*` / `CURVE_*` constants you pass to `subtle` are imported from the `'crypto'` module.\n\n## Quick helpers (`crypto.*`)\n\nEvery helper is synchronous and returns its value directly.\n\n| Helper | Signature | What it does |\n| --- | --- | --- |\n| `getRandomValues` | `(array: Uint8Array): void` | Fill the array with cryptographically strong random bytes. |\n| `randomUUID` | `(): string` | A random RFC 4122 v4 UUID string. |\n| `sha1` / `sha256` / `sha384` / `sha512` | `(data: Uint8Array): Uint8Array` | Hash raw bytes. |\n| `sha1Text` … `sha512Text` | `(s: string): Uint8Array` | UTF-8 encode the string, then hash. |\n| `hmacSha256` | `(key: Uint8Array, msg: Uint8Array): Uint8Array` | Keyed fingerprint (HMAC-SHA-256) of bytes. |\n| `hmacSha256Text` | `(key: Uint8Array, msg: string): Uint8Array` | HMAC-SHA-256 over a string. |\n| `toHex` | `(bytes: Uint8Array): string` | Lowercase hex string (for display or storage). |\n| `subtle` | `SubtleCrypto` | The full primitive surface (below). |\n\n**Hashing** turns any input into a fixed-size fingerprint. The same input always gives the same output, and you cannot work backward from the fingerprint to the input. Use it to compare or index a value without storing the original (for example, keying a cache by the hash of a URL).\n\n**Random bytes** come from a CSPRNG (a cryptographically secure random generator), which means the output is unpredictable and safe for tokens, session IDs, and IVs. Never use `Math.random()` for anything security-related.\n\n**HMAC** is a fingerprint computed *with a secret key*. Anyone can hash a value, but only someone who holds the key can produce the correct HMAC, so it proves a value came from you and was not altered. This is exactly what [`TwoFactor`](./email.md) tokens and [signed cookies](./cookies.md) use.\n\n## Worked example: hash a value and generate a token\n\n```ts\nimport { RouteContext } from 'toiljs/server/runtime';\n\n@rest('demo')\nclass Demo {\n @get('/')\n public example(ctx: RouteContext): string {\n // Hash a value to a hex fingerprint (safe to log or use as a key).\n const digest = crypto.sha256Text('alice@example.com');\n const fingerprint = crypto.toHex(digest); // 64 hex chars\n\n // Generate a 128-bit unguessable token as hex.\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n const token = crypto.toHex(bytes); // 32 hex chars\n\n // A ready-made unique id.\n const id = crypto.randomUUID();\n\n return `fp=${fingerprint} token=${token} id=${id}`;\n }\n}\n```\n\nTo fingerprint a value *with a secret* (so only your server can produce or check it), use HMAC:\n\n```ts\nconst key = Uint8Array.wrap(String.UTF8.encode(Environment.getSecure('SIGNING_KEY')!));\nconst mac = crypto.hmacSha256Text(key, 'order:42');\nconst macHex = crypto.toHex(mac);\n```\n\n## The primitive surface (`crypto.subtle`)\n\nUse `subtle` when the helpers are not enough: symmetric encryption, signatures, or key derivation. Every method is synchronous.\n\n| Method | Signature |\n| --- | --- |\n| `digest` | `digest(algorithm: i32, data: Uint8Array): Uint8Array` |\n| `importKey` | `importKey(format: i32, keyData: Uint8Array, algorithm: AlgorithmParams, extractable: bool, usages: i32): CryptoKey` |\n| `exportKey` | `exportKey(format: i32, key: CryptoKey): Uint8Array` |\n| `encrypt` | `encrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `decrypt` | `decrypt(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `sign` | `sign(algorithm: AlgorithmParams, key: CryptoKey, data: Uint8Array): Uint8Array` |\n| `verify` | `verify(algorithm: AlgorithmParams, key: CryptoKey, signature: Uint8Array, data: Uint8Array): bool` |\n| `deriveBits` | `deriveBits(algorithm: AlgorithmParams, baseKey: CryptoKey, length: i32): Uint8Array` |\n| `deriveKey` | `deriveKey(algorithm, baseKey, lengthBits, derivedKeyAlgorithm, extractable, usages): CryptoKey` |\n\nTwo things differ from the browser API, both because of the missing-Promise design:\n\n- **`algorithm` and `format` are integer constants, not strings.** You pass `ALG_SHA_256` (not `\"SHA-256\"`) and `FMT_RAW` (not `\"raw\"`). The constants are listed below.\n- **`verify` returns a `bool`.** A signature mismatch returns `false` (it does not throw). A real error, like an invalid key, does throw.\n\n### Keys are per-request handles\n\nYou never hold raw key bytes in your code for long. `importKey` gives you back a **`CryptoKey`**, which is an opaque handle into a host-side keystore. That keystore is **wiped when the request ends**, so a `CryptoKey` is valid only within the request that created it. Never cache one across requests; import it again each time.\n\n```mermaid\nsequenceDiagram\n participant G as Your handler\n participant H as Host keystore (per request)\n G->>H: importKey(...) -> a CryptoKey handle\n G->>H: sign(params, key, data)\n H-->>G: signature bytes\n Note over H: the keystore is wiped when the request ends\n```\n\n### Algorithm parameter classes\n\nEach algorithm takes a small parameters object you build and pass in. Import the class you need from `'crypto'`:\n\n```ts\nimport { AesGcmParams, HmacImportParams, ALG_SHA_256, USAGE_ENCRYPT, USAGE_DECRYPT } from 'crypto';\n```\n\n| Class | Constructor | Used for |\n| --- | --- | --- |\n| `AesGcmParams` | `(iv, additionalData?, tagLength = 128)` | AES-GCM encrypt/decrypt |\n| `AesCbcParams` | `(iv)` | AES-CBC |\n| `AesCtrParams` | `(counter, length = 128)` | AES-CTR |\n| `HmacImportParams` | `(hash)` | importing an HMAC key |\n| `HmacParams` | `()` | HMAC sign/verify (hash comes from the key) |\n| `Pbkdf2Params` | `(hash, salt, iterations)` | deriving a key from a password |\n| `HkdfParams` | `(hash, salt, info?)` | deriving a key from key material |\n| `EcdsaParams` | `(hash)` | ECDSA sign/verify |\n| `EcKeyImportParams` | `(alg, namedCurve)` | importing an EC key |\n| `Ed25519Params` | `()` | Ed25519 sign/verify |\n| `X25519ImportParams` | `()` | importing an X25519 key |\n| `EcdhParams` | `(alg, publicKeyHandle)` | ECDH / X25519 key agreement |\n\n### Constants\n\n- **Hashes and algorithms:** `ALG_SHA_1`, `ALG_SHA_256`, `ALG_SHA_384`, `ALG_SHA_512`, `ALG_SHA3_256`, `ALG_SHA3_384`, `ALG_SHA3_512`, `ALG_AES_GCM`, `ALG_AES_CBC`, `ALG_AES_CTR`, `ALG_AES_KW`, `ALG_HMAC`, `ALG_ECDSA`, `ALG_ED25519`, `ALG_ECDH`, `ALG_X25519`, `ALG_HKDF`, `ALG_PBKDF2`.\n- **Key formats:** `FMT_RAW`, `FMT_PKCS8`, `FMT_SPKI`. (`FMT_JWK` is rejected.)\n- **Key usages (a bitmask, OR them together):** `USAGE_ENCRYPT`, `USAGE_DECRYPT`, `USAGE_SIGN`, `USAGE_VERIFY`, `USAGE_DERIVE_KEY`, `USAGE_DERIVE_BITS`, `USAGE_WRAP_KEY`, `USAGE_UNWRAP_KEY`.\n- **Named curves:** `CURVE_P256`, `CURVE_P384`. (`CURVE_P521` is not supported.)\n\nThe `digest` selector also accepts the SHA3 constants, so `crypto.subtle.digest(ALG_SHA3_256, data)` works even though there is no `sha3` quick helper.\n\n### `CryptoKey`\n\nA `CryptoKey` is a handle plus metadata: `handle: i32`, `type: string` (`\"secret\"`, `\"public\"`, or `\"private\"`), `extractable: bool`, `algorithm: i32`, and `usages: i32`, with `algorithmName()` and `hasUsage(u)` helpers. Remember it is only valid within the current request.\n\n## Example: AES-256-GCM encrypt and decrypt\n\nAES-GCM is authenticated encryption: it both hides the data and detects tampering. It needs a 32-byte key and a fresh 12-byte IV (a \"number used once\"). **Never reuse an IV with the same key**; generate a new one every time.\n\n```ts\nimport { AesGcmParams, ALG_AES_GCM, FMT_RAW, USAGE_ENCRYPT, USAGE_DECRYPT } from 'crypto';\n\n// 32-byte key and a fresh 12-byte IV.\nconst rawKey = new Uint8Array(32); crypto.getRandomValues(rawKey);\nconst iv = new Uint8Array(12); crypto.getRandomValues(iv);\n\n// Import the key once; grant it both usages so it can round-trip.\nconst key = crypto.subtle.importKey(\n FMT_RAW, rawKey, new AesGcmParams(iv), false, USAGE_ENCRYPT | USAGE_DECRYPT,\n);\n\nconst plaintext = Uint8Array.wrap(String.UTF8.encode('secret note'));\nconst ciphertext = crypto.subtle.encrypt(new AesGcmParams(iv), key, plaintext);\nconst recovered = crypto.subtle.decrypt(new AesGcmParams(iv), key, ciphertext);\n// recovered == plaintext\n```\n\nStore the `iv` next to the ciphertext (it is not secret); you need the same IV to decrypt.\n\n## Example: HMAC with `subtle`\n\nThe quick `crypto.hmacSha256` helper does this in one line, but here is the explicit form, which mirrors how [`TwoFactor`](./email.md) signs its tokens:\n\n```ts\nimport { HmacImportParams, HmacParams, ALG_SHA_256, FMT_RAW, USAGE_SIGN, USAGE_VERIFY } from 'crypto';\n\nconst key = crypto.subtle.importKey(\n FMT_RAW, rawKeyBytes, new HmacImportParams(ALG_SHA_256), false, USAGE_SIGN | USAGE_VERIFY,\n);\n\nconst mac = crypto.subtle.sign(new HmacParams(), key, message);\nconst ok: bool = crypto.subtle.verify(new HmacParams(), key, mac, message); // true\n\n// verify compares in constant time and returns false on a mismatch (it does not throw).\n```\n\n## Limitations\n\n- **No Promises.** Every call is synchronous.\n- **No RSA.** It was dropped because the only pure-Rust implementation had an unfixable timing weakness. Use ECDSA or Ed25519 for signatures.\n- **No JWK key format.** Use `FMT_RAW`, `FMT_PKCS8`, or `FMT_SPKI`.\n- **No on-host key generation.** Keys are always **imported**, never generated on the server. Generate them elsewhere and import the bytes.\n- **No P-521.** `CURVE_P256` and `CURVE_P384` are supported.\n- **Keys do not survive the request.** A `CryptoKey` is a per-request handle; re-import each request.\n- **Every call is metered.** Crypto operations cost compute (charged up front from the sizes involved), so an over-budget call fails cleanly instead of burning CPU.\n\n### Post-quantum signature verify\n\nAuth's login uses a post-quantum signature scheme (ML-DSA) that the host can **verify** (it never holds a private key). This underpins the [auth login stack](../auth/how-it-works.md); you reach it through `AuthService`, not through `crypto` directly, so it is documented there rather than here.\n\n## Related\n\n- [Auth: how it works](../auth/how-it-works.md), the post-quantum login stack built on host-side verify.\n- [Cookies](./cookies.md), signed and encrypted cookies built on `crypto`.\n- [Email](./email.md), whose `TwoFactor` codes are HMAC tokens.\n- [Environment and secrets](./environment.md), where you keep signing and encryption keys.\n",
|