toiljs 0.0.85 → 0.0.87

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +303 -293
  5. package/build/compiler/.tsbuildinfo +1 -1
  6. package/build/compiler/config.d.ts +2 -0
  7. package/build/compiler/config.js +1 -0
  8. package/build/compiler/docs.js +8 -24
  9. package/build/compiler/generate.js +4 -2
  10. package/build/compiler/index.d.ts +1 -1
  11. package/build/compiler/index.js +69 -6
  12. package/build/compiler/toil-docs.generated.js +64 -22
  13. package/build/devserver/.tsbuildinfo +1 -1
  14. package/build/devserver/analytics/index.js +7 -3
  15. package/build/devserver/db/database.d.ts +4 -0
  16. package/build/devserver/db/database.js +43 -1
  17. package/build/devserver/db/index.d.ts +1 -1
  18. package/build/devserver/db/index.js +1 -1
  19. package/build/devserver/db/types.d.ts +3 -0
  20. package/build/devserver/db/types.js +2 -0
  21. package/build/devserver/runtime/module.js +4 -1
  22. package/docs/README.md +104 -65
  23. package/docs/auth/README.md +102 -0
  24. package/docs/auth/configuration.md +94 -0
  25. package/docs/auth/extending.md +202 -0
  26. package/docs/auth/how-it-works.md +138 -0
  27. package/docs/auth/usage.md +188 -0
  28. package/docs/backend/README.md +143 -0
  29. package/docs/backend/data.md +351 -0
  30. package/docs/backend/rest.md +402 -0
  31. package/docs/backend/rpc.md +226 -0
  32. package/docs/background/README.md +114 -0
  33. package/docs/background/daemons.md +230 -0
  34. package/docs/background/derive.md +179 -0
  35. package/docs/cli/README.md +377 -0
  36. package/docs/concepts/config.md +416 -0
  37. package/docs/concepts/decorators.md +127 -0
  38. package/docs/concepts/security.md +108 -0
  39. package/docs/concepts/tiers.md +166 -0
  40. package/docs/concepts/types.md +216 -0
  41. package/docs/database/README.md +143 -0
  42. package/docs/database/capacity.md +350 -0
  43. package/docs/database/counters.md +174 -0
  44. package/docs/database/documents.md +255 -0
  45. package/docs/database/events.md +307 -0
  46. package/docs/database/membership.md +246 -0
  47. package/docs/database/setup.md +155 -0
  48. package/docs/database/unique.md +216 -0
  49. package/docs/database/views.md +246 -0
  50. package/docs/frontend/README.md +101 -0
  51. package/docs/frontend/data-fetching.md +243 -0
  52. package/docs/frontend/images.md +148 -0
  53. package/docs/frontend/metadata.md +344 -0
  54. package/docs/frontend/rendering.md +236 -0
  55. package/docs/frontend/routing.md +344 -0
  56. package/docs/frontend/scripts.md +118 -0
  57. package/docs/frontend/search.md +191 -0
  58. package/docs/frontend/styling.md +147 -0
  59. package/docs/getting-started/README.md +81 -0
  60. package/docs/getting-started/create-project.md +131 -0
  61. package/docs/getting-started/deploy.md +101 -0
  62. package/docs/getting-started/first-app.md +215 -0
  63. package/docs/getting-started/installation.md +106 -0
  64. package/docs/getting-started/migrating.md +125 -0
  65. package/docs/getting-started/project-structure.md +163 -0
  66. package/docs/introduction/README.md +41 -0
  67. package/docs/introduction/design-principles.md +55 -0
  68. package/docs/introduction/distributed.md +74 -0
  69. package/docs/introduction/how-it-works.md +74 -0
  70. package/docs/introduction/hyperscale.md +51 -0
  71. package/docs/introduction/modern-stack.md +36 -0
  72. package/docs/introduction/vs-other-frameworks.md +48 -0
  73. package/docs/introduction/why-toil.md +93 -0
  74. package/docs/llms.txt +90 -0
  75. package/docs/realtime/README.md +102 -0
  76. package/docs/realtime/channels.md +211 -0
  77. package/docs/realtime/streams.md +369 -0
  78. package/docs/services/README.md +60 -0
  79. package/docs/services/analytics.md +268 -0
  80. package/docs/services/caching.md +175 -0
  81. package/docs/services/cookies.md +235 -0
  82. package/docs/services/crypto.md +209 -0
  83. package/docs/services/email.md +289 -0
  84. package/docs/services/environment.md +141 -0
  85. package/docs/services/ratelimit.md +174 -0
  86. package/docs/services/time.md +85 -0
  87. package/examples/basic/client/routes/analytics.tsx +13 -12
  88. package/examples/basic/server/models/SiteAnalytics.ts +29 -17
  89. package/examples/basic/server/routes/Analytics.ts +15 -17
  90. package/examples/basic/server/routes/UserId.ts +22 -0
  91. package/package.json +15 -11
  92. package/scripts/gen-toil-docs.mjs +24 -35
  93. package/server/auth/AuthController.ts +336 -0
  94. package/server/auth/AuthUser.ts +23 -0
  95. package/server/auth/index.ts +16 -0
  96. package/server/globals/auth.ts +31 -0
  97. package/server/globals/userid.ts +107 -0
  98. package/src/compiler/config.ts +13 -0
  99. package/src/compiler/docs.ts +16 -33
  100. package/src/compiler/generate.ts +6 -2
  101. package/src/compiler/index.ts +114 -6
  102. package/src/compiler/toil-docs.generated.ts +64 -22
  103. package/src/devserver/analytics/index.ts +10 -4
  104. package/src/devserver/db/database.ts +67 -1
  105. package/src/devserver/db/index.ts +1 -0
  106. package/src/devserver/db/types.ts +13 -0
  107. package/src/devserver/runtime/module.ts +7 -0
  108. package/test/analytics-dev.test.ts +2 -1
  109. package/test/devserver-database.test.ts +113 -0
  110. package/docs/auth-todo.md +0 -149
  111. package/docs/auth.md +0 -322
  112. package/docs/caching.md +0 -115
  113. package/docs/cli.md +0 -17
  114. package/docs/client.md +0 -39
  115. package/docs/cookies.md +0 -457
  116. package/docs/crypto.md +0 -130
  117. package/docs/daemon.md +0 -123
  118. package/docs/data.md +0 -131
  119. package/docs/derive.md +0 -159
  120. package/docs/email.md +0 -326
  121. package/docs/environment.md +0 -97
  122. package/docs/getting-started.md +0 -128
  123. package/docs/index.md +0 -30
  124. package/docs/ratelimit.md +0 -95
  125. package/docs/routing.md +0 -259
  126. package/docs/rpc.md +0 -149
  127. package/docs/server.md +0 -61
  128. package/docs/ssr.md +0 -632
  129. package/docs/streams.md +0 -178
  130. package/docs/styling.md +0 -22
  131. package/docs/tiers.md +0 -133
  132. package/docs/time.md +0 -43
@@ -0,0 +1,350 @@
1
+ # Capacity (limited stock without overselling)
2
+
3
+ A `Capacity` collection models a **finite resource that must never be oversold**:
4
+ concert tickets, seats, limited-drop stock, rate grants. It uses a two-phase
5
+ `reserve` then `confirm`/`cancel` pattern so two buyers can never grab the same
6
+ last unit.
7
+
8
+ ## What and why
9
+
10
+ Some resources are strictly limited. There are 100 tickets, and selling 101 is a
11
+ real, expensive mistake. The classic bug is a race: two requests both read "1
12
+ left", both decide they can sell it, and both sell it. Now you have oversold.
13
+
14
+ A `Capacity` collection prevents that. Instead of "read, then decrement" (two
15
+ steps a race can slip between), it does an atomic **hold**: the check that enough
16
+ is available and the decrement happen as one indivisible step, at one place. And
17
+ it adds a second phase so a shopper who starts a checkout but never pays does not
18
+ lock up stock forever.
19
+
20
+ The lifecycle has three moves:
21
+
22
+ - **`reserve`**: hold some units for a short time. The units come out of
23
+ "available" immediately, and you get back a **reservation id**. If there is not
24
+ enough available, the reserve fails (and nothing is oversold).
25
+ - **`confirm`**: the shopper paid. Turn the hold into a permanent sale.
26
+ - **`cancel`**: the shopper backed out. Release the hold back to available.
27
+
28
+ If a hold is neither confirmed nor cancelled within its time limit (its **TTL**,
29
+ time to live), it **auto-releases**. So an abandoned checkout heals itself.
30
+
31
+ ```mermaid
32
+ sequenceDiagram
33
+ participant Buyer
34
+ participant App as Your route
35
+ participant Cap as Capacity ledger
36
+ Buyer->>App: start checkout
37
+ App->>Cap: reserve(key, 2, ttl)
38
+ Cap-->>App: reservationId (available drops by 2)
39
+ Note over Cap: 2 units are held, not yet sold
40
+ alt payment succeeds
41
+ Buyer->>App: pay
42
+ App->>Cap: confirm(key, reservationId)
43
+ Cap-->>App: true (hold becomes a permanent sale)
44
+ else buyer cancels
45
+ Buyer->>App: cancel
46
+ App->>Cap: cancel(key, reservationId)
47
+ Cap-->>App: true (2 units returned to available)
48
+ else buyer disappears
49
+ Note over Cap: TTL passes, hold auto-releases (2 units returned)
50
+ end
51
+ ```
52
+
53
+ Use `Capacity` whenever selling one too many is unacceptable: tickets, seats,
54
+ inventory for a limited drop, a pool of licences, or any "N and no more" resource.
55
+
56
+ ## Why not just a counter?
57
+
58
+ A [Counter](./counters.md) can only `add` and `get`. To sell a ticket with a
59
+ counter you would read the remaining count, check it is above zero, and then
60
+ subtract one. Those are separate steps. Two requests can both read "1 left"
61
+ before either subtracts, and both proceed: **oversold**. A counter has no way to
62
+ atomically check-and-decrement, and no notion of a temporary hold that expires.
63
+
64
+ `Capacity` fixes both problems:
65
+
66
+ - The availability check and the hold are **one atomic step**, so contenders are
67
+ serialized: the first reserve takes the unit, the second sees zero available
68
+ and fails.
69
+ - The two-phase **reserve then confirm/cancel** means an unpaid, abandoned
70
+ checkout does not permanently consume stock: its hold expires on the TTL.
71
+
72
+ If a small over- or under-count is harmless (likes, page views, an
73
+ approximate inventory display), a counter is simpler and cheaper. If overselling
74
+ by one is a real problem, use `Capacity`.
75
+
76
+ ## The type
77
+
78
+ ```ts
79
+ Capacity<K>
80
+ ```
81
+
82
+ `Capacity` has a single type parameter, the **key** (`K`): it picks *which*
83
+ resource. There is no value type, because the value is a private ledger the
84
+ platform manages for you (it tracks the total, the confirmed sales, and the live
85
+ holds). For one ticketed event, the key is the event id.
86
+
87
+ Declare it as a `@collection` field on a `@database` class:
88
+
89
+ ```ts
90
+ @data
91
+ class ShowKey {
92
+ show: string = '';
93
+ constructor(show: string = '') { this.show = show; }
94
+ }
95
+
96
+ @database
97
+ class TicketsDb {
98
+ @collection static seats: Capacity<ShowKey>;
99
+ }
100
+ ```
101
+
102
+ ## Operations
103
+
104
+ Five operations, matching the toilscript API exactly. Exact signatures:
105
+
106
+ | Operation | Signature | What it does |
107
+ | --- | --- | --- |
108
+ | `available` | `available(key: K): i64` | How many units can be reserved right now. |
109
+ | `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. |
110
+ | `confirm` | `confirm(key: K, reservationId: u64): bool` | Turn a hold into a permanent sale. Returns whether the id was valid. |
111
+ | `cancel` | `cancel(key: K, reservationId: u64): bool` | Release a hold back to available. Returns whether it was released. |
112
+ | `setTotal` | `setTotal(key: K, total: i64): void` | Set the ceiling (seed or restock). Background task only. |
113
+
114
+ The core accounting the platform maintains for you:
115
+
116
+ ```txt
117
+ available = total - confirmed - held
118
+ ```
119
+
120
+ `total` is the ceiling you set. `confirmed` is units permanently sold. `held` is
121
+ units currently reserved but not yet confirmed. `available` never goes below zero.
122
+
123
+ ### `available`
124
+
125
+ Reads how many units could be reserved right now. It is a keyed read, legal from
126
+ any handler including a `@get`.
127
+
128
+ ```ts
129
+ const left = TicketsDb.seats.available(new ShowKey('jazz-night'));
130
+ ```
131
+
132
+ ### `reserve`
133
+
134
+ Holds `amount` units for `ttlMs` milliseconds and returns a reservation id. This
135
+ is the operation that prevents overselling.
136
+
137
+ ```ts
138
+ const id = TicketsDb.seats.reserve(new ShowKey('jazz-night'), 2, 120_000); // hold 2 for 2 minutes
139
+ if (id == 0) {
140
+ // Not enough available. This is a normal outcome, NOT an error: no oversell.
141
+ return Response.conflict('sold out');
142
+ }
143
+ // id > 0: you now hold 2 units for up to 2 minutes. Keep the id.
144
+ ```
145
+
146
+ Two things to know:
147
+
148
+ - A return of `0` means "not enough available". It is the safe, expected answer
149
+ when the resource is (nearly) sold out. Always check for it.
150
+ - The hold auto-releases after `ttlMs` if you do not confirm or cancel it. Choose
151
+ a TTL that covers a realistic checkout (a couple of minutes), not hours.
152
+
153
+ `reserve` is a **write**, so call it from an action handler (`@post`, `@put`,
154
+ `@patch`, `@del`), not from a `@get`.
155
+
156
+ ### `confirm`
157
+
158
+ Turns a hold into a permanent sale. Call it once payment (or whatever finalizes
159
+ the deal) succeeds.
160
+
161
+ ```ts
162
+ const ok = TicketsDb.seats.confirm(new ShowKey('jazz-night'), id);
163
+ // ok === true -> the id was valid; those units are now permanently sold
164
+ // ok === false -> the id was unknown (never reserved, or already expired)
165
+ ```
166
+
167
+ A confirmed sale is **permanent**: it can never be cancelled and its TTL never
168
+ reclaims it. Confirm is safe to call more than once for the same id (it stays
169
+ confirmed).
170
+
171
+ ### `cancel`
172
+
173
+ Releases a hold back to available. Call it when the shopper backs out before
174
+ paying.
175
+
176
+ ```ts
177
+ const released = TicketsDb.seats.cancel(new ShowKey('jazz-night'), id);
178
+ // released === true -> the hold was released, units are available again
179
+ // released === false -> the id was unknown, already expired, OR already confirmed
180
+ ```
181
+
182
+ You **cannot** cancel a confirmed sale (a sale is final). `cancel` returns `false`
183
+ in that case.
184
+
185
+ ### `setTotal`
186
+
187
+ Sets the ceiling: the total number of units. You call it to **seed** a new
188
+ resource ("this show has 100 seats") and to **restock** ("we opened the balcony,
189
+ now 150"). Existing holds and confirmed sales are untouched; only the ceiling
190
+ moves, and `available` reflects the new total.
191
+
192
+ `setTotal` is a **privileged** operation: it may only run from a background task
193
+ (a `@job`), never from a request handler. Seeding and restocking are
194
+ administrative actions, so they live off the request path. See
195
+ [background tasks](../background/README.md).
196
+
197
+ ```ts
198
+ @database
199
+ class TicketsDb {
200
+ @collection static seats: Capacity<ShowKey>;
201
+
202
+ // A background task seeds/restocks the ceiling. This runs off the request path.
203
+ @job
204
+ openSeats(): void {
205
+ TicketsDb.seats.setTotal(new ShowKey('jazz-night'), 100);
206
+ }
207
+ }
208
+ ```
209
+
210
+ Note that lowering the total below what is already sold plus held does not
211
+ cancel anything (confirmed sales are permanent); `available` simply floors at
212
+ zero until holds clear.
213
+
214
+ ## The lifecycle in words
215
+
216
+ 1. **Seed.** A background `@job` calls `setTotal(key, 100)`. Now `available` is
217
+ 100.
218
+ 2. **Reserve.** A buyer starts checkout. Your `@post` calls `reserve(key, 1,
219
+ ttl)` and gets an id. `available` drops to 99. The unit is *held*, not sold.
220
+ 3. **Finalize, one of:**
221
+ - **Confirm.** Payment succeeds; your `@post` calls `confirm(key, id)`. The
222
+ unit is now permanently sold. `available` stays 99.
223
+ - **Cancel.** The buyer backs out; your `@post` calls `cancel(key, id)`. The
224
+ unit returns to available; `available` goes back to 100.
225
+ - **Expire.** The buyer vanishes. After `ttl` passes, the hold auto-releases;
226
+ `available` goes back to 100 on its own.
227
+
228
+ At no point can the confirmed count exceed the total. That is the guarantee.
229
+
230
+ ## Worked example: selling limited tickets
231
+
232
+ ```ts
233
+ import { ShowKey } from '../models/ShowKey';
234
+ import { ReserveRequest } from '../models/ReserveRequest';
235
+ import { FinalizeRequest } from '../models/FinalizeRequest';
236
+ import { ReserveResult } from '../models/ReserveResult';
237
+
238
+ @database
239
+ class TicketsDb {
240
+ @collection static seats: Capacity<ShowKey>;
241
+
242
+ // Background task: seed the show's capacity (and restock if you reopen it).
243
+ @job
244
+ openSeats(): void {
245
+ TicketsDb.seats.setTotal(new ShowKey('jazz-night'), 100);
246
+ }
247
+ }
248
+
249
+ @rest('tickets')
250
+ class Tickets {
251
+ // How many seats are left (a keyed read, legal in a GET).
252
+ @get('/jazz-night/available')
253
+ public left(): i64 {
254
+ return TicketsDb.seats.available(new ShowKey('jazz-night'));
255
+ }
256
+
257
+ // Start checkout: hold the seats. Returns a reservation id, or 0 if sold out.
258
+ @post('/jazz-night/reserve')
259
+ public reserve(input: ReserveRequest): ReserveResult {
260
+ const id = TicketsDb.seats.reserve(new ShowKey('jazz-night'), input.count, 120_000);
261
+ // id === 0 means not enough available. No oversell; tell the buyer honestly.
262
+ return new ReserveResult(id, id != 0);
263
+ }
264
+
265
+ // Payment succeeded: make the hold a permanent sale.
266
+ @post('/jazz-night/confirm')
267
+ public confirm(input: FinalizeRequest): bool {
268
+ return TicketsDb.seats.confirm(new ShowKey('jazz-night'), input.reservationId);
269
+ }
270
+
271
+ // Buyer backed out before paying: release the hold.
272
+ @post('/jazz-night/cancel')
273
+ public cancel(input: FinalizeRequest): bool {
274
+ return TicketsDb.seats.cancel(new ShowKey('jazz-night'), input.reservationId);
275
+ }
276
+ }
277
+ ```
278
+
279
+ The models:
280
+
281
+ ```ts
282
+ @data
283
+ export class ReserveRequest {
284
+ count: i64 = 1;
285
+ }
286
+
287
+ @data
288
+ export class FinalizeRequest {
289
+ reservationId: u64 = 0;
290
+ }
291
+
292
+ @data
293
+ export class ReserveResult {
294
+ reservationId: u64 = 0;
295
+ ok: bool = false;
296
+ constructor(reservationId: u64 = 0, ok: bool = false) {
297
+ this.reservationId = reservationId;
298
+ this.ok = ok;
299
+ }
300
+ }
301
+ ```
302
+
303
+ Even if a thousand people hit `reserve` at the same instant for the last few
304
+ seats, the ledger serializes them: the first buyers get ids, the rest get `0`.
305
+ The total is never exceeded.
306
+
307
+ ## Consistency
308
+
309
+ Unlike most ToilDB families, `Capacity` is **strongly consistent**, and that is
310
+ the whole point. Every reserve/confirm/cancel for a key is routed to that key's
311
+ one home location and applied there in order, at a single serialization point. So
312
+ even though ToilDB spans many regions, there is exactly one place that decides
313
+ whether a reserve succeeds. That is what makes "never oversell" a hard guarantee
314
+ rather than a best effort. `available` reflects that home ledger.
315
+
316
+ ## Gotchas
317
+
318
+ - **Check for `0` from `reserve`.** A `0` is "not enough available", the normal
319
+ sold-out answer, not an error. Do not treat it as a failure to retry blindly.
320
+ - **Always finalize a reservation.** After a successful `reserve`, you should
321
+ `confirm` it (paid) or `cancel` it (abandoned). If you do neither, the hold
322
+ sits until its TTL expires, temporarily reducing availability.
323
+ - **Pick a sensible TTL.** Too short and a slow-but-real checkout loses its hold
324
+ mid-payment. Too long and abandoned carts starve real buyers. A couple of
325
+ minutes is typical for a checkout.
326
+ - **Confirmed sales are permanent.** You cannot `cancel` a confirmed sale, and
327
+ its TTL never reclaims it. If you support refunds, model that as your own
328
+ restock (raise `setTotal`), not as a cancel.
329
+ - **`reserve` is not automatically deduplicated.** Each call to `reserve` creates
330
+ a new, distinct hold. If a client might retry the same reserve, avoid holding
331
+ twice: reserve once, keep the returned id, and drive `confirm`/`cancel` off
332
+ that id.
333
+ - **`setTotal` is background-only.** You cannot set the total from a route. Seed
334
+ and restock from a `@job`.
335
+ - **There is a per-key cap on live holds.** A key can carry only so many
336
+ simultaneous unconfirmed holds; a flood of holds beyond that is rejected as
337
+ "not enough available" (and abandoned holds clear on their TTL). Normal traffic
338
+ never hits this; it is a guardrail against a hold flood.
339
+
340
+ ## Related
341
+
342
+ - [Counters](./counters.md): a running total when overselling is not a concern
343
+ (and why it cannot guarantee a limit).
344
+ - [Documents](./documents.md): store the order/booking record that a confirmed
345
+ sale produces.
346
+ - [background tasks](../background/README.md): where `setTotal` (seeding/restock)
347
+ runs.
348
+ - [Data types (`@data`)](../concepts/types.md): how the capacity key is stored.
349
+ - [Decorators](../concepts/decorators.md): which handler kinds may reserve,
350
+ confirm, and cancel.
@@ -0,0 +1,174 @@
1
+ # Counters
2
+
3
+ The **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.
4
+
5
+ ## What and why
6
+
7
+ A **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.
8
+
9
+ Reach for a Counter whenever the thing you are storing is a number that many requests bump concurrently:
10
+
11
+ - likes or upvotes on a post
12
+ - page views or play counts
13
+ - a stock or quantity tally
14
+ - any "increase this by N" metric
15
+
16
+ Declare 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.
17
+
18
+ ```ts
19
+ @data
20
+ class PostId {
21
+ id: string = '';
22
+ constructor(id: string = '') { this.id = id; }
23
+ }
24
+
25
+ @database
26
+ class AppDb {
27
+ @collection static likes: Counter<PostId>;
28
+ @collection static views: Counter<PostId>;
29
+ }
30
+ ```
31
+
32
+ ## The operations
33
+
34
+ `K` is the key type. The value is always a 64-bit signed integer (`i64`).
35
+
36
+ | Operation | Signature | Returns | Use it to |
37
+ | --- | --- | --- | --- |
38
+ | `get` | `get(key: K): i64` | the current total (`0` if nothing has been added) | read the count |
39
+ | `add` | `add(key: K, delta: i64): void` | nothing | change the count by `delta` (may be negative) |
40
+
41
+ `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).
42
+
43
+ ### `get`
44
+
45
+ `get` returns the current total. A counter that has never been touched reads as `0`, not `null`: there is no "absent" state to handle.
46
+
47
+ ```ts
48
+ const likeCount: i64 = AppDb.likes.get(new PostId('p_42'));
49
+ ```
50
+
51
+ ### `add`
52
+
53
+ `add` changes the total by a delta. The delta can be positive to increase or negative to decrease:
54
+
55
+ ```ts
56
+ AppDb.likes.add(new PostId('p_42'), 1); // one more like
57
+ AppDb.likes.add(new PostId('p_42'), -1); // undo a like
58
+ AppDb.stock.add(new SkuId('sku_9'), -3); // sold three
59
+ ```
60
+
61
+ `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).
62
+
63
+ ## Why a Counter, and not a number in a Documents record?
64
+
65
+ This is the question the family exists to answer, so it is worth walking through.
66
+
67
+ Suppose 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:
68
+
69
+ ```mermaid
70
+ sequenceDiagram
71
+ participant Tokyo as Server (Tokyo)
72
+ participant Paris as Server (Paris)
73
+ participant DB as Documents record
74
+
75
+ Note over DB: likes = 10
76
+ Tokyo->>DB: read likes -> 10
77
+ Paris->>DB: read likes -> 10
78
+ Tokyo->>DB: write likes = 11
79
+ Paris->>DB: write likes = 11
80
+ Note over DB: likes = 11 (one like LOST)
81
+ ```
82
+
83
+ Both 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.
84
+
85
+ A 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.
86
+
87
+ This 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.
88
+
89
+ **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.
90
+
91
+ ## Worked example: page views and likes
92
+
93
+ Here is a small feature that counts views on every read and likes on demand, then reports both.
94
+
95
+ ```ts
96
+ import { Response, RouteContext } from 'toiljs/server/runtime';
97
+
98
+ @data
99
+ class PostId {
100
+ id: string = '';
101
+ constructor(id: string = '') { this.id = id; }
102
+ }
103
+
104
+ @data
105
+ class Stats {
106
+ views: i64 = 0;
107
+ likes: i64 = 0;
108
+ }
109
+
110
+ @database
111
+ class AppDb {
112
+ @collection static views: Counter<PostId>;
113
+ @collection static likes: Counter<PostId>;
114
+ }
115
+
116
+ @rest('posts')
117
+ class Posts {
118
+ // POST /posts/:id/view -> count a view, return the running totals (Action)
119
+ @post('/:id/view')
120
+ public view(ctx: RouteContext): Stats {
121
+ const key = new PostId(ctx.param('id'));
122
+ AppDb.views.add(key, 1);
123
+ const s = new Stats();
124
+ s.views = AppDb.views.get(key);
125
+ s.likes = AppDb.likes.get(key);
126
+ return s;
127
+ }
128
+
129
+ // POST /posts/:id/like -> add a like (Action)
130
+ @post('/:id/like')
131
+ public like(ctx: RouteContext): Stats {
132
+ const key = new PostId(ctx.param('id'));
133
+ AppDb.likes.add(key, 1);
134
+ const s = new Stats();
135
+ s.views = AppDb.views.get(key);
136
+ s.likes = AppDb.likes.get(key);
137
+ return s;
138
+ }
139
+
140
+ // GET /posts/:id/stats -> just read the totals (Query: read-only)
141
+ @get('/:id/stats')
142
+ public stats(ctx: RouteContext): Stats {
143
+ const key = new PostId(ctx.param('id'));
144
+ const s = new Stats();
145
+ s.views = AppDb.views.get(key);
146
+ s.likes = AppDb.likes.get(key);
147
+ return s;
148
+ }
149
+ }
150
+ ```
151
+
152
+ Counting 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.
153
+
154
+ ## Consistency notes
155
+
156
+ - **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.
157
+ - **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.
158
+ - **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.
159
+ - 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.
160
+
161
+ ## Gotchas
162
+
163
+ - **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.
164
+ - **`add` does not return the new total.** Call `get` after if you need it, remembering it may lag concurrent remote adds.
165
+ - **A never-touched counter reads `0`, not `null`.** There is no "does this counter exist" check; every key answers `0` until something is added.
166
+ - **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).
167
+
168
+ ## Related
169
+
170
+ - [ToilDB overview](./README.md): the seven families and how to choose.
171
+ - [Setup](./setup.md): declaring the collection and which function kinds may write.
172
+ - [Documents](./documents.md): when the number is part of a record you overwrite wholesale.
173
+ - [Events](./events.md): when you need the individual events, not just a total.
174
+ - [Data types (`@data`)](../backend/data.md): the counter's key type.