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.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +303 -293
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +2 -0
- package/build/compiler/config.js +1 -0
- package/build/compiler/docs.js +8 -24
- package/build/compiler/generate.js +4 -2
- package/build/compiler/index.d.ts +1 -1
- package/build/compiler/index.js +69 -6
- package/build/compiler/toil-docs.generated.js +64 -22
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.js +7 -3
- package/build/devserver/db/database.d.ts +4 -0
- package/build/devserver/db/database.js +43 -1
- package/build/devserver/db/index.d.ts +1 -1
- package/build/devserver/db/index.js +1 -1
- package/build/devserver/db/types.d.ts +3 -0
- package/build/devserver/db/types.js +2 -0
- package/build/devserver/runtime/module.js +4 -1
- package/docs/README.md +104 -65
- package/docs/auth/README.md +102 -0
- package/docs/auth/configuration.md +94 -0
- package/docs/auth/extending.md +202 -0
- package/docs/auth/how-it-works.md +138 -0
- package/docs/auth/usage.md +188 -0
- package/docs/backend/README.md +143 -0
- package/docs/backend/data.md +351 -0
- package/docs/backend/rest.md +402 -0
- package/docs/backend/rpc.md +226 -0
- package/docs/background/README.md +114 -0
- package/docs/background/daemons.md +230 -0
- package/docs/background/derive.md +179 -0
- package/docs/cli/README.md +377 -0
- package/docs/concepts/config.md +416 -0
- package/docs/concepts/decorators.md +127 -0
- package/docs/concepts/security.md +108 -0
- package/docs/concepts/tiers.md +166 -0
- package/docs/concepts/types.md +216 -0
- package/docs/database/README.md +143 -0
- package/docs/database/capacity.md +350 -0
- package/docs/database/counters.md +174 -0
- package/docs/database/documents.md +255 -0
- package/docs/database/events.md +307 -0
- package/docs/database/membership.md +246 -0
- package/docs/database/setup.md +155 -0
- package/docs/database/unique.md +216 -0
- package/docs/database/views.md +246 -0
- package/docs/frontend/README.md +101 -0
- package/docs/frontend/data-fetching.md +243 -0
- package/docs/frontend/images.md +148 -0
- package/docs/frontend/metadata.md +344 -0
- package/docs/frontend/rendering.md +236 -0
- package/docs/frontend/routing.md +344 -0
- package/docs/frontend/scripts.md +118 -0
- package/docs/frontend/search.md +191 -0
- package/docs/frontend/styling.md +147 -0
- package/docs/getting-started/README.md +81 -0
- package/docs/getting-started/create-project.md +131 -0
- package/docs/getting-started/deploy.md +101 -0
- package/docs/getting-started/first-app.md +215 -0
- package/docs/getting-started/installation.md +106 -0
- package/docs/getting-started/migrating.md +125 -0
- package/docs/getting-started/project-structure.md +163 -0
- package/docs/introduction/README.md +41 -0
- package/docs/introduction/design-principles.md +55 -0
- package/docs/introduction/distributed.md +74 -0
- package/docs/introduction/how-it-works.md +74 -0
- package/docs/introduction/hyperscale.md +51 -0
- package/docs/introduction/modern-stack.md +36 -0
- package/docs/introduction/vs-other-frameworks.md +48 -0
- package/docs/introduction/why-toil.md +93 -0
- package/docs/llms.txt +90 -0
- package/docs/realtime/README.md +102 -0
- package/docs/realtime/channels.md +211 -0
- package/docs/realtime/streams.md +369 -0
- package/docs/services/README.md +60 -0
- package/docs/services/analytics.md +268 -0
- package/docs/services/caching.md +175 -0
- package/docs/services/cookies.md +235 -0
- package/docs/services/crypto.md +209 -0
- package/docs/services/email.md +289 -0
- package/docs/services/environment.md +141 -0
- package/docs/services/ratelimit.md +174 -0
- package/docs/services/time.md +85 -0
- package/examples/basic/client/routes/analytics.tsx +13 -12
- package/examples/basic/server/models/SiteAnalytics.ts +29 -17
- package/examples/basic/server/routes/Analytics.ts +15 -17
- package/examples/basic/server/routes/UserId.ts +22 -0
- package/package.json +15 -11
- package/scripts/gen-toil-docs.mjs +24 -35
- package/server/auth/AuthController.ts +336 -0
- package/server/auth/AuthUser.ts +23 -0
- package/server/auth/index.ts +16 -0
- package/server/globals/auth.ts +31 -0
- package/server/globals/userid.ts +107 -0
- package/src/compiler/config.ts +13 -0
- package/src/compiler/docs.ts +16 -33
- package/src/compiler/generate.ts +6 -2
- package/src/compiler/index.ts +114 -6
- package/src/compiler/toil-docs.generated.ts +64 -22
- package/src/devserver/analytics/index.ts +10 -4
- package/src/devserver/db/database.ts +67 -1
- package/src/devserver/db/index.ts +1 -0
- package/src/devserver/db/types.ts +13 -0
- package/src/devserver/runtime/module.ts +7 -0
- package/test/analytics-dev.test.ts +2 -1
- package/test/devserver-database.test.ts +113 -0
- package/docs/auth-todo.md +0 -149
- package/docs/auth.md +0 -322
- package/docs/caching.md +0 -115
- package/docs/cli.md +0 -17
- package/docs/client.md +0 -39
- package/docs/cookies.md +0 -457
- package/docs/crypto.md +0 -130
- package/docs/daemon.md +0 -123
- package/docs/data.md +0 -131
- package/docs/derive.md +0 -159
- package/docs/email.md +0 -326
- package/docs/environment.md +0 -97
- package/docs/getting-started.md +0 -128
- package/docs/index.md +0 -30
- package/docs/ratelimit.md +0 -95
- package/docs/routing.md +0 -259
- package/docs/rpc.md +0 -149
- package/docs/server.md +0 -61
- package/docs/ssr.md +0 -632
- package/docs/streams.md +0 -178
- package/docs/styling.md +0 -22
- package/docs/tiers.md +0 -133
- 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.
|