toiljs 0.0.110 → 0.0.112

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.
@@ -41,11 +41,12 @@ Here is every operation, its shape, and what it gives back. `K` is your key type
41
41
  | `exists` | `exists(key: K): bool` | `true` if the record is present | check presence without reading the value |
42
42
  | `create` | `create(key: K, value: V): bool` | `true` if inserted, `false` if the key was already taken | add a **new** record without overwriting |
43
43
  | `patch` | `patch(key: K, value: V): V` | the newly stored value; **traps** if the record is absent | replace an **existing** record's value |
44
+ | `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) |
44
45
  | `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 |
45
46
  | `delete` | `delete(key: K): void` | nothing (idempotent) | remove a record |
46
47
  | `getDelete` | `getDelete(key: K): V \| null` | the value that was there, or `null`; removes it atomically | consume a record exactly once |
47
48
 
48
- Which 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`).
49
+ Which 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`).
49
50
 
50
51
  ### Reading: `get`, `require`, `exists`, `getMany`
51
52
 
@@ -79,17 +80,18 @@ const found: Array<User | null> = AppDb.users.getMany(ids);
79
80
 
80
81
  `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).
81
82
 
82
- ### Writing: `create` vs `patch` vs `enqueue`
83
+ ### Writing: `create` vs `upsert` vs `patch` vs `enqueue`
83
84
 
84
- These 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.
85
+ These 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.
85
86
 
86
87
  ```mermaid
87
88
  flowchart TD
88
- START["I want to write a record"] --> Q1{"Is this a brand-new<br/>record that must not<br/>clobber an existing one?"}
89
- Q1 -->|Yes| CREATE["create<br/>(insert only; false if the key exists)"]
90
- Q1 -->|"No, it already exists"| Q2{"Must the write fail if another<br/>request changed the record<br/>since I read it?"}
91
- Q2 -->|"Yes, guard against a lost update"| ENQUEUE["enqueue<br/>(version-checked CAS; false if<br/>someone wrote first, then retry)"]
92
- Q2 -->|"No, last write wins"| PATCH["patch<br/>(unconditional overwrite;<br/>returns the new value)"]
89
+ START["I want to write a record"] --> Q1{"Must this write fail if another<br/>request changed the record<br/>since I read it?"}
90
+ Q1 -->|"Yes, guard against a lost update"| ENQUEUE["enqueue<br/>(version-checked CAS;<br/>false = retry)"]
91
+ Q1 -->|"No, last write wins"| Q2{"Does the record<br/>already exist?"}
92
+ Q2 -->|"Must be new (never clobber)"| CREATE["create<br/>(false if the key is taken)"]
93
+ Q2 -->|"May or may not exist"| UPSERT["upsert<br/>(create-or-overwrite in one op)"]
94
+ Q2 -->|"Definitely exists"| PATCH["patch<br/>(overwrite; returns the value;<br/>traps if absent)"]
93
95
  ```
94
96
 
95
97
  **`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`.
@@ -132,14 +134,33 @@ return Response.text('too much contention, try again later', 409);
132
134
 
133
135
  Because 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.
134
136
 
135
- > 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:
136
- >
137
- > ```ts
138
- > const key = new UserId(input.id);
139
- > if (!AppDb.users.create(key, input)) {
140
- > AppDb.users.patch(key, input);
141
- > }
142
- > ```
137
+ **`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:
138
+
139
+ ```ts
140
+ const key = new UserId('u_123');
141
+ const result = AppDb.users.upsert(key, user);
142
+ // result is UpsertResult.Created (row was absent) or UpsertResult.Updated (overwrote)
143
+ ```
144
+
145
+ Before `upsert`, that meant two operations, `create` then `patch`:
146
+
147
+ ```ts
148
+ // the old two-op idiom - upsert replaces it
149
+ if (!AppDb.users.create(key, user)) {
150
+ AppDb.users.patch(key, user);
151
+ }
152
+ ```
153
+
154
+ `upsert` collapses both into a single write, so the common "already exists" path costs one round trip instead of two.
155
+
156
+ Like `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:
157
+
158
+ ```ts
159
+ const outcome = AppDb.handles.upsert(key, profile);
160
+ if (outcome == UpsertResult.Conflict) {
161
+ return Response.text('that handle is taken', 409);
162
+ }
163
+ ```
143
164
 
144
165
  ### Removing: `delete` and `getDelete`
145
166
 
@@ -234,15 +255,16 @@ That is a complete persistent CRUD entity. Run it under `toiljs dev` and the not
234
255
 
235
256
  Documents follows ToilDB's general model (see [the overview](./README.md#eventual-consistency-in-plain-words)):
236
257
 
237
- - **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.
258
+ - **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).
238
259
  - **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.
239
260
  - 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).
240
261
 
241
262
  ## Gotchas
242
263
 
243
- - **`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.
264
+ - **`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.
244
265
  - **`patch` replaces the whole value.** There is no field-level merge; read, modify, and write back the full value.
245
266
  - **`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.
267
+ - **`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).
246
268
  - **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."
247
269
  - **`getDelete`, not `get` + `delete`, for consume-once.** Only `getDelete` guarantees exactly one caller receives the value.
248
270
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "toiljs",
3
3
  "type": "module",
4
- "version": "0.0.110",
4
+ "version": "0.0.112",
5
5
  "author": "Dacely",
6
6
  "description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
7
7
  "repository": {
@@ -345,7 +345,9 @@ export namespace AuthService {
345
345
  export function userId(): ToilUserId | null {
346
346
  const bytes = getSessionBytes();
347
347
  if (bytes == null) return null;
348
- return ToilUserId.fromBytes(new DataReader(bytes).readBytes());
348
+ const r = new DataReader(bytes);
349
+ r.readU32(); // skip the @data message-boundary id that `encode()` writes first
350
+ return ToilUserId.fromBytes(r.readBytes());
349
351
  }
350
352
 
351
353
  /**
@@ -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",
@@ -186,6 +186,7 @@ enum DbOp {
186
186
  Exists,
187
187
  Create,
188
188
  Patch,
189
+ Upsert,
189
190
  Delete,
190
191
  GetDelete,
191
192
  Enqueue,
@@ -211,6 +212,13 @@ enum DbOp {
211
212
  EventsSince,
212
213
  }
213
214
 
215
+ /** `data.upsert` return tags (ABI.md), mirroring the guest `UpsertResult` enum. */
216
+ enum UpsertResult {
217
+ Created = 0,
218
+ Updated = 1,
219
+ Conflict = 2,
220
+ }
221
+
214
222
  function isReadOp(op: DbOp): boolean {
215
223
  return (
216
224
  op === DbOp.Get ||
@@ -238,6 +246,7 @@ function kindAllows(kind: DbFunctionKind, op: DbOp): boolean {
238
246
  (isReadOp(op) && !isScanOp(op)) ||
239
247
  op === DbOp.Create ||
240
248
  op === DbOp.Patch ||
249
+ op === DbOp.Upsert ||
241
250
  op === DbOp.Delete ||
242
251
  op === DbOp.GetDelete ||
243
252
  op === DbOp.Enqueue ||
@@ -821,6 +830,39 @@ export class DevDatabase {
821
830
  return this.replayRecordOutcome(db, outcome);
822
831
  }
823
832
 
833
+ // Create-or-overwrite in ONE op: a blind last-writer-wins put. Returns
834
+ // UPSERT_CREATED (0) / UPSERT_UPDATED (1), or UPSERT_CONFLICT (2) when a
835
+ // @unique value is held by ANOTHER record (nothing written). A blind put is
836
+ // naturally idempotent (re-applying the same value is a no-op beyond the
837
+ // Created/Updated tag), so `idem` needs no dedup ledger; it is accepted for
838
+ // ABI parity and ignored, mirroring how the edge merely threads it into the
839
+ // oplog env.
840
+ upsert(
841
+ ref: MemoryRef,
842
+ db: DbDevState,
843
+ handle: number,
844
+ keyPtr: number,
845
+ keyLen: number,
846
+ valPtr: number,
847
+ valLen: number,
848
+ _idemPtr: number,
849
+ ): number {
850
+ const coll = collForOp(db, handle, DbOp.Upsert, CollectionFamily.Record);
851
+ if (typeof coll === 'number') return coll;
852
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
853
+ const key = readKey(ref, keyPtr, keyLen);
854
+ const value = readCopy(ref, valPtr, valLen);
855
+ const sk = storeKey(coll.name, key);
856
+ // @unique: a value already held by ANOTHER record blocks the upsert (the
857
+ // one outcome a blind put cannot auto-resolve), exactly like the edge op.
858
+ if (this.uniqueConflict(coll, value, sk)) return UpsertResult.Conflict;
859
+ const existed = this.store.has(sk);
860
+ this.store.set(sk, value);
861
+ this.stampVersion(coll, sk); // stamp the value type's current schema version
862
+ this.recordWrite(db, coll);
863
+ return existed ? UpsertResult.Updated : UpsertResult.Created;
864
+ }
865
+
824
866
  delete(
825
867
  ref: MemoryRef,
826
868
  db: DbDevState,
@@ -1603,6 +1645,15 @@ export function buildDatabaseImports(
1603
1645
  idemPtr: number,
1604
1646
  ): number => devDb.patch(ref, db, handle, keyPtr, keyLen, patchPtr, patchLen, idemPtr),
1605
1647
 
1648
+ 'data.upsert': (
1649
+ handle: number,
1650
+ keyPtr: number,
1651
+ keyLen: number,
1652
+ valPtr: number,
1653
+ valLen: number,
1654
+ idemPtr: number,
1655
+ ): number => devDb.upsert(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
1656
+
1606
1657
  'data.delete': (handle: number, keyPtr: number, keyLen: number, idemPtr: number): number =>
1607
1658
  devDb.delete(ref, db, handle, keyPtr, keyLen, idemPtr),
1608
1659
 
@@ -139,6 +139,7 @@ const PROVIDED_IMPORTS = new Set([
139
139
  'data.exists',
140
140
  'data.create',
141
141
  'data.patch',
142
+ 'data.upsert',
142
143
  'data.delete',
143
144
  'data.get_delete',
144
145
  'data.unique_lookup',