toiljs 0.0.111 → 0.0.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +1 -1
- package/CHANGELOG.md +10 -0
- package/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/toil-docs.generated.js +2 -2
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/analytics/index.d.ts +2 -0
- package/build/devserver/analytics/index.js +15 -9
- package/build/devserver/db/database.d.ts +1 -0
- package/build/devserver/db/database.js +49 -23
- package/build/devserver/runtime/module.js +1 -0
- package/docs/database/documents.md +40 -18
- package/docs/services/analytics.md +28 -17
- package/examples/basic/server/routes/Analytics.ts +6 -5
- package/package.json +3 -3
- package/src/compiler/toil-docs.generated.ts +2 -2
- package/src/devserver/analytics/index.ts +40 -17
- package/src/devserver/db/database.ts +51 -0
- package/src/devserver/runtime/module.ts +1 -0
- package/test/analytics-dev.test.ts +8 -8
|
@@ -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
|
|
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{"
|
|
89
|
-
Q1 -->|Yes|
|
|
90
|
-
Q1 -->|"No,
|
|
91
|
-
Q2 -->|"
|
|
92
|
-
Q2 -->|"
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
|
|
@@ -110,19 +110,27 @@ Every getter below is a lifetime running total: it only ever goes up, and readin
|
|
|
110
110
|
| `cacheMisses` | Cacheable responses that missed the cache |
|
|
111
111
|
| `cacheRatio` | Derived **`f64`**: `cacheHits / (cacheHits + cacheMisses)`, a fraction from 0 to 1 (0 when there were no cacheable responses) |
|
|
112
112
|
|
|
113
|
-
### Live gauges and request
|
|
113
|
+
### Live gauges and request meters
|
|
114
114
|
|
|
115
|
-
A few fields are not lifetime totals. They still read as `u64
|
|
115
|
+
A few fields are not lifetime totals. They still read as `u64` (except the derived `sustainedRpsCap`, an `f64`).
|
|
116
116
|
|
|
117
117
|
**Live gauges** are the current level right now, not a running total:
|
|
118
118
|
|
|
119
119
|
- `connectedStreams`: streams connected at this moment.
|
|
120
120
|
- `committedMemory`: wasm memory committed right now, in bytes.
|
|
121
121
|
|
|
122
|
-
**Request
|
|
122
|
+
**Request meters** show where you sit against your plan. The model is a **sustained-rate ("unmetered pipe")** one, not a fixed per-minute ceiling. Your plan has a **sustained request rate** `X` (requests per second), measured over a **3-minute rolling window**. Two important consequences:
|
|
123
123
|
|
|
124
|
-
-
|
|
125
|
-
- `
|
|
124
|
+
- **Instantaneous spikes are free.** There is no per-tier cap on how fast you may burst inside a window; you may spike right up to the box's own hardware ceiling (the `firewall.global_pps` DDoS backstop, shared across all tenants). Nothing throttles you for a brief peak.
|
|
125
|
+
- **Only sustained overuse throttles.** You get an HTTP `429` once your fleet-global request count over the *trailing* 3 minutes exceeds `X * 180` (the sustained rate integrated over the window), or once your 30-day quota is exhausted. "Fleet-global" means the count is summed across every edge location, not just the one machine serving you.
|
|
126
|
+
|
|
127
|
+
Because the window **rolls**, a burst ages out gradually over the following 3 minutes rather than being forgiven all at once on a boundary. The `Retry-After` on a `429` tells you exactly how long until enough of it has decayed.
|
|
128
|
+
|
|
129
|
+
The fields:
|
|
130
|
+
|
|
131
|
+
- `reqBurstUsed` / `reqBurstCap`: fleet-global requests in the **trailing 3-minute rolling window**, and its allowance. This is exactly the number the edge compares against, so it never disagrees with why you were throttled. The allowance is `X * 180` (the window is `BURST_WINDOW_SECS = 180` seconds). A cap of `0` means **unmetered** (no sustained cap).
|
|
132
|
+
- `req30dUsed` / `req30dCap`: fleet-global requests in the **current 30-day window**, and the 30-day quota. This one still tumbles, because it is a billing period rather than a rate. A quota of `0` means **unmetered**.
|
|
133
|
+
- `sustainedRpsCap`: a convenience `f64` derived from the burst cap, `reqBurstCap / BURST_WINDOW_SECS`. This is your plan's sustained rate `X` in requests/second (`0.0` when unmetered), so you can show "1000 rps sustained" without doing the division yourself.
|
|
126
134
|
|
|
127
135
|
There is also `nowMs`, the edge wall-clock time (in milliseconds) when the snapshot was taken, so you can show "as of" and compute how fresh the numbers are.
|
|
128
136
|
|
|
@@ -177,12 +185,12 @@ for (let i = 0; i < page.sites.length; i++) {
|
|
|
177
185
|
|
|
178
186
|
## Worked example: return everything
|
|
179
187
|
|
|
180
|
-
This route reads the full snapshot and maps it into a typed response. Mapping is mechanical: every getter is a `u64` and
|
|
188
|
+
This route reads the full snapshot and maps it into a typed response. Mapping is mechanical: nearly every getter is a `u64` and maps to a `u64` field with no cast; the only `f64` values are the derived `cacheRatio` and `sustainedRpsCap`. The example groups a representative field from each area; add the rest of the getters from the catalog above the same way.
|
|
181
189
|
|
|
182
190
|
```ts
|
|
183
191
|
import { RouteContext } from 'toiljs/server/runtime';
|
|
184
192
|
|
|
185
|
-
/** The shape returned to the client. All fields are u64 except the
|
|
193
|
+
/** The shape returned to the client. All fields are u64 except the two derived f64s (cacheRatio, sustainedRpsCap). */
|
|
186
194
|
@data
|
|
187
195
|
export class UsageReport {
|
|
188
196
|
// requests / L1
|
|
@@ -202,11 +210,12 @@ export class UsageReport {
|
|
|
202
210
|
// live gauges (current level, not a total)
|
|
203
211
|
connectedStreams: u64 = 0;
|
|
204
212
|
committedMemory: u64 = 0;
|
|
205
|
-
// request
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
// request meters (cap 0 = unmetered)
|
|
214
|
+
reqBurstUsed: u64 = 0; // requests in the current 3-minute sustained-rate bucket
|
|
215
|
+
reqBurstCap: u64 = 0; // per-bucket cap = sustained_rps * 180
|
|
216
|
+
req30dUsed: u64 = 0; // requests in the current 30-day window
|
|
217
|
+
req30dCap: u64 = 0; // 30-day quota
|
|
218
|
+
sustainedRpsCap: f64 = 0; // derived: reqBurstCap / 180 (the plan's sustained rps)
|
|
210
219
|
// when the snapshot was read (edge ms)
|
|
211
220
|
nowMs: u64 = 0;
|
|
212
221
|
}
|
|
@@ -235,10 +244,11 @@ class Usage {
|
|
|
235
244
|
out.connectedStreams = s.connectedStreams;
|
|
236
245
|
out.committedMemory = s.committedMemory;
|
|
237
246
|
|
|
238
|
-
out.
|
|
239
|
-
out.
|
|
240
|
-
out.
|
|
241
|
-
out.
|
|
247
|
+
out.reqBurstUsed = s.reqBurstUsed;
|
|
248
|
+
out.reqBurstCap = s.reqBurstCap;
|
|
249
|
+
out.req30dUsed = s.req30dUsed;
|
|
250
|
+
out.req30dCap = s.req30dCap;
|
|
251
|
+
out.sustainedRpsCap = s.sustainedRpsCap;
|
|
242
252
|
|
|
243
253
|
out.nowMs = s.nowMs;
|
|
244
254
|
return out;
|
|
@@ -255,7 +265,8 @@ Under `toiljs dev`, the real per-domain metering does not exist (there is only o
|
|
|
255
265
|
## Gotchas and limits
|
|
256
266
|
|
|
257
267
|
- **Totals never reset.** The counter getters are lifetime running totals. To see "requests in the last hour", use `series(...)` and sum the buckets, not the totals.
|
|
258
|
-
- **Values are `u64`.** Map them into `u64` fields with no casts. The
|
|
268
|
+
- **Values are `u64`.** Map them into `u64` fields with no casts. The non-integer fields are `cacheRatio` and `sustainedRpsCap` (and `Series.ratePerSec`), which are `f64`.
|
|
269
|
+
- **Spikes are free; only sustained overuse throttles.** The request meters follow a sustained-rate model: you may burst up to the box's hardware ceiling, and throttling (`429`) starts only when the current 3-minute bucket passes `sustainedRpsCap * 180`, or when the 30-day quota runs out. A cap or quota of `0` means unmetered.
|
|
259
270
|
- **Not per-user analytics.** These are infrastructure counters per domain. There is no way to record a custom event or attribute a number to a specific user.
|
|
260
271
|
- **Cross-site reads need `dacely.com`.** `Analytics.site(...)` returns `null` for everyone else. Do not build a feature that reads another site's stats.
|
|
261
272
|
- **Snapshots, not live streams.** Each call reads the current values once. `nowMs` tells you when.
|
|
@@ -23,11 +23,12 @@ class AnalyticsRoutes {
|
|
|
23
23
|
out.liveConnectedStreams = stats.connectedStreams;
|
|
24
24
|
out.liveCommittedMemoryBytes = stats.committedMemory;
|
|
25
25
|
|
|
26
|
-
// Request
|
|
27
|
-
|
|
28
|
-
out.
|
|
29
|
-
out.
|
|
30
|
-
out.
|
|
26
|
+
// Request meters vs plan cap (cap 0 = unmetered). "This minute" now surfaces the 3-minute
|
|
27
|
+
// sustained-burst bucket; "today" now surfaces the 30-day quota (the metering model changed).
|
|
28
|
+
out.requestsThisMinute = stats.reqBurstUsed;
|
|
29
|
+
out.requestsThisMinuteCap = stats.reqBurstCap;
|
|
30
|
+
out.requestsToday = stats.req30dUsed;
|
|
31
|
+
out.requestsTodayCap = stats.req30dCap;
|
|
31
32
|
|
|
32
33
|
// For historical, time-windowed values (per-bucket, not a lifetime total), see `/series` below.
|
|
33
34
|
return out;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.113",
|
|
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": {
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"nodemailer": "^9.0.3",
|
|
137
137
|
"picocolors": "^1.1.1",
|
|
138
138
|
"sharp": "^0.35.3",
|
|
139
|
-
"toilscript": "^0.1.
|
|
139
|
+
"toilscript": "^0.1.59",
|
|
140
140
|
"typescript-eslint": "^8.62.1",
|
|
141
141
|
"vite": "^8.1.3",
|
|
142
142
|
"vite-imagetools": "^10.0.1",
|
|
@@ -147,7 +147,7 @@
|
|
|
147
147
|
"prettier": ">=3.0.0",
|
|
148
148
|
"react": ">=18.0.0",
|
|
149
149
|
"react-dom": ">=18.0.0",
|
|
150
|
-
"toilscript": ">=0.1.
|
|
150
|
+
"toilscript": ">=0.1.59",
|
|
151
151
|
"typescript": ">=6.0.0"
|
|
152
152
|
},
|
|
153
153
|
"overrides": {
|