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.
@@ -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
 
@@ -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 windows
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 windows** show your current rate-limit usage against your plan cap (a cap of `0` means unlimited):
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
- - `reqMinuteUsed` / `reqMinuteCap`: requests used and the cap for the current 1-minute window.
125
- - `reqDayUsed` / `reqDayCap`: requests used and the cap for the current 24-hour window.
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 every response field is a `u64`, so there are no casts. The example groups a representative field from each area; add the rest of the getters from the catalog above the same way.
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 one ratio. */
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 windows (cap 0 = unlimited)
206
- requestsThisMinute: u64 = 0;
207
- requestsThisMinuteCap: u64 = 0;
208
- requestsToday: u64 = 0;
209
- requestsTodayCap: u64 = 0;
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.requestsThisMinute = s.reqMinuteUsed;
239
- out.requestsThisMinuteCap = s.reqMinuteCap;
240
- out.requestsToday = s.reqDayUsed;
241
- out.requestsTodayCap = s.reqDayCap;
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 only non-integer field is `cacheRatio` (and `Series.ratePerSec`), which are `f64`.
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 windows: current usage vs plan cap (cap 0 = unlimited).
27
- out.requestsThisMinute = stats.reqMinuteUsed;
28
- out.requestsThisMinuteCap = stats.reqMinuteCap;
29
- out.requestsToday = stats.reqDayUsed;
30
- out.requestsTodayCap = stats.reqDayCap;
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.111",
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.56",
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.56",
150
+ "toilscript": ">=0.1.59",
151
151
  "typescript": ">=6.0.0"
152
152
  },
153
153
  "overrides": {