toilscript 0.1.58 → 0.1.60

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.
@@ -72,6 +72,151 @@ function __toildbTakeGrow(): Uint8Array {
72
72
  return buf.subarray(0, n);
73
73
  }
74
74
 
75
+ /// A typed ToilDB failure, the `TDLnnn` diagnostic of spec 27.1. The host bridges
76
+ /// one as `-(1000 + nnn)`; the two small sentinels are NOT failures (`-1` output
77
+ /// buffer too small, `-2` absent), so both decode to `None`.
78
+ ///
79
+ /// Only a SUBSET can ever reach guest code. A hard fault (budget/quota, storage,
80
+ /// integrity, capability/schema) is indistinguishable from a benign outcome at the
81
+ /// ABI, so returning it would silently drop the op; the host TRAPS the request
82
+ /// instead and the guest never runs again (toil-backend `status_is_hard_fault`).
83
+ /// The members below say which is which. Every code is still listed, because
84
+ /// `Db.errorOf` decodes any raw status.
85
+ ///
86
+ /// So after a read returns `null`, `Db.lastError()` is either `None` (the row is
87
+ /// absent) or one of the four RETRYABLE availability signals. `AlreadyExists` and
88
+ /// `Conflict` are write outcomes, not read ones.
89
+ @global
90
+ export enum DbError {
91
+ /// No failure: the operation succeeded, or the row was simply absent.
92
+ None = 0,
93
+ /// TDL001: the collection handle is not one the host issued. HARD FAULT: traps.
94
+ InvalidHandle = 1,
95
+ /// TDL002: wrong tenant, or the row's MAC did not verify. HARD FAULT: traps.
96
+ TenantMismatch = 2,
97
+ /// TDL003: a `create` found the key already present. A write outcome.
98
+ AlreadyExists = 3,
99
+ /// TDL004: a compare-and-set lost, or a `@unique` value is owned elsewhere. A
100
+ /// write outcome.
101
+ Conflict = 4,
102
+ /// TDL005: the operation is not supported here. HARD FAULT: traps.
103
+ Unsupported = 5,
104
+ /// TDL006: the value did not decode against its schema. HARD FAULT: traps.
105
+ Codec = 6,
106
+ /// TDL007: an internal storage failure. HARD FAULT: traps.
107
+ Backend = 7,
108
+ /// TDL010: the op does not exist for this collection family. HARD FAULT: traps.
109
+ OpNotAllowedForFamily = 10,
110
+ /// TDL011: the op is not allowed for this kind, or at this tier. HARD FAULT: traps.
111
+ OpNotAllowedInKind = 11,
112
+ /// TDL020: too many ops, or too many keys in one multi-op. HARD FAULT: traps.
113
+ OpBudget = 20,
114
+ /// TDL021: the request or its result exceeds the byte budget. HARD FAULT: traps.
115
+ ByteBudget = 21,
116
+ /// TDL022: the value nests too deeply. HARD FAULT: traps.
117
+ DepthBudget = 22,
118
+ /// TDL025: the tenant is over its plan quota. HARD FAULT: traps, so a guest can
119
+ /// never observe this. It is here only to decode a raw status.
120
+ QuotaExceeded = 25,
121
+ /// TDL030: a backfill is throttling this read. RETRYABLE.
122
+ FillThrottled = 30,
123
+ /// TDL031: the home region, or the data, is temporarily unavailable. RETRYABLE.
124
+ Unavailable = 31,
125
+ /// TDL040: this key is a hot partition. RETRYABLE.
126
+ HotPartition = 40,
127
+ /// TDL041: too many cold reads at once. RETRYABLE.
128
+ ColdStorm = 41,
129
+ /// TDL070: the schema is temporarily unavailable. HARD FAULT: traps.
130
+ SchemaUnavailable = 70,
131
+ /// TDL090: a catalog op outside an admin context. HARD FAULT: traps.
132
+ CatalogOutsideAdmin = 90,
133
+ /// A negative status that carries no known `TDLnnn`.
134
+ Unknown = -1,
135
+ }
136
+
137
+ /// The typed failure of the last read that returned `null`/empty. See `Db`.
138
+ // @ts-ignore: decorator
139
+ @lazy
140
+ var __toildbLastError: DbError = DbError.None;
141
+
142
+ /// Decode a raw host status into a typed failure. Non-negative, `-1` (buffer too
143
+ /// small) and `-2` (absent) are all `None`: only the `<= -1000` band is a failure.
144
+ function __toildbErrorOf(status: i32): DbError {
145
+ if (status >= -2) return DbError.None;
146
+ const code = -status - 1000;
147
+ if (code < 0) return DbError.Unknown;
148
+ switch (code) {
149
+ case 1: return DbError.InvalidHandle;
150
+ case 2: return DbError.TenantMismatch;
151
+ case 3: return DbError.AlreadyExists;
152
+ case 4: return DbError.Conflict;
153
+ case 5: return DbError.Unsupported;
154
+ case 6: return DbError.Codec;
155
+ case 7: return DbError.Backend;
156
+ case 10: return DbError.OpNotAllowedForFamily;
157
+ case 11: return DbError.OpNotAllowedInKind;
158
+ case 20: return DbError.OpBudget;
159
+ case 21: return DbError.ByteBudget;
160
+ case 22: return DbError.DepthBudget;
161
+ case 25: return DbError.QuotaExceeded;
162
+ case 30: return DbError.FillThrottled;
163
+ case 31: return DbError.Unavailable;
164
+ case 40: return DbError.HotPartition;
165
+ case 41: return DbError.ColdStorm;
166
+ case 70: return DbError.SchemaUnavailable;
167
+ case 90: return DbError.CatalogOutsideAdmin;
168
+ default: return DbError.Unknown;
169
+ }
170
+ }
171
+
172
+ /// Record the outcome of a read that is about to return `null`. An absent row
173
+ /// records `None`, so `null` + `None` means "not found" and `null` + anything else
174
+ /// means "failed".
175
+ function __toildbMiss(status: i32): void {
176
+ __toildbLastError = __toildbErrorOf(status);
177
+ }
178
+
179
+ /// ToilDB diagnostics. Read `lastError()` immediately after a read returns `null`
180
+ /// to tell an absent row from a failed one; the next read overwrites it.
181
+ ///
182
+ /// ```ts
183
+ /// const u = App.users.get(id);
184
+ /// if (u == null && Db.isRetryable(Db.lastError())) {
185
+ /// // The row may well exist: the store is briefly unavailable. Retry or 503.
186
+ /// }
187
+ /// ```
188
+ ///
189
+ /// ONLY `Documents.get`, `Documents.getDelete`, `View.get` and `Unique.lookup`
190
+ /// record into this. Every other ToilDB op either succeeds or traps. The `Analytics`
191
+ /// reads also answer `null`, but on their own `-2 absent` / `-3 forbidden` statuses
192
+ /// rather than a `TDLnnn`, so they leave `lastError()` untouched: do not consult it
193
+ /// after one, or you will read a stale value from an earlier ToilDB read.
194
+ @global
195
+ export class Db {
196
+ /// The failure recorded by the most recent read that returned `null`. `None`
197
+ /// when that read simply found nothing.
198
+ static lastError(): DbError {
199
+ return __toildbLastError;
200
+ }
201
+
202
+ /// Decode a raw negative host status into its typed failure.
203
+ static errorOf(status: i32): DbError {
204
+ return __toildbErrorOf(status);
205
+ }
206
+
207
+ /// Whether `err` is a transient availability signal, so the same read may well
208
+ /// succeed later. These are the ONLY failures a read can hand back: everything
209
+ /// else traps the request before the guest resumes.
210
+ static isRetryable(err: DbError): bool {
211
+ return (
212
+ err == DbError.FillThrottled ||
213
+ err == DbError.Unavailable ||
214
+ err == DbError.HotPartition ||
215
+ err == DbError.ColdStorm
216
+ );
217
+ }
218
+ }
219
+
75
220
  /// A mutable keyed-entity collection (spec 7.1). `V` is the `@data` value type,
76
221
  /// `K` the `@data` key type.
77
222
  @global
@@ -98,7 +243,7 @@ export class Documents<K, V> {
98
243
  get(key: K): V | null {
99
244
  const kb = key.encode();
100
245
  const status = toildbHost.get(this.__handle, kb.dataStart, kb.byteLength);
101
- if (status < 0) return null;
246
+ if (status < 0) { __toildbMiss(status); return null; }
102
247
  __toildbResetMigrated();
103
248
  const v = instantiate<V>();
104
249
  v.decodeInto(__toildbTake(status));
@@ -226,7 +371,7 @@ export class Documents<K, V> {
226
371
  getDelete(key: K): V | null {
227
372
  const kb = key.encode();
228
373
  const status = toildbHost.getDelete(this.__handle, kb.dataStart, kb.byteLength, 0);
229
- if (status < 0) return null;
374
+ if (status < 0) { __toildbMiss(status); return null; }
230
375
  const v = instantiate<V>();
231
376
  v.decodeInto(__toildbTake(status));
232
377
  return v;
@@ -249,7 +394,7 @@ export class View<K, V> {
249
394
  get(key: K): V | null {
250
395
  const kb = key.encode();
251
396
  const status = toildbHost.viewGet(this.__handle, kb.dataStart, kb.byteLength);
252
- if (status < 0) return null;
397
+ if (status < 0) { __toildbMiss(status); return null; }
253
398
  const v = instantiate<V>();
254
399
  v.decodeInto(__toildbTake(status));
255
400
  return v;
@@ -295,7 +440,7 @@ export class Unique<K, V> {
295
440
  lookup(key: K): V | null {
296
441
  const kb = key.encode();
297
442
  const status = toildbHost.uniqueLookup(this.__handle, kb.dataStart, kb.byteLength);
298
- if (status < 0) return null;
443
+ if (status < 0) { __toildbMiss(status); return null; }
299
444
  const v = instantiate<V>();
300
445
  v.decodeInto(__toildbTake(status));
301
446
  return v;
@@ -531,6 +676,14 @@ export enum MetricId {
531
676
  /// section.
532
677
  export const METRIC_COUNTERS: i32 = 42;
533
678
 
679
+ /// The sustained-rate window (seconds): requests are metered over a 3-minute ROLLING window. A plan's
680
+ /// sustained request rate `X` (rps) permits `X * BURST_WINDOW_SECS` requests across it. Instantaneous
681
+ /// spikes ABOVE the sustained rate are NOT tier-capped (only the box firewall's global pps ceiling, the
682
+ /// DDoS backstop, applies); throttling (HTTP 429) begins only once the window's allowance is spent.
683
+ /// The window slides continuously rather than resetting on a boundary, so a spend does not become free
684
+ /// all at once: it ages out gradually over the following 3 minutes.
685
+ export const BURST_WINDOW_SECS: i32 = 180;
686
+
534
687
  /// A dashboard time range for `Analytics.series`. Short ranges (1h/6h) read the per-MINUTE ring; the long
535
688
  /// ranges (60d/90d) read the per-DAY ring (90-day retention); the rest read the per-HOUR ring (30-day).
536
689
  /// Append-only: never renumber (D60/D90 were added after D30, keeping the wire ids stable).
@@ -556,11 +709,14 @@ export class TenantStats {
556
709
  /// Live gauges (current level, not a total).
557
710
  connectedStreams: u64 = 0;
558
711
  committedMemory: u64 = 0;
559
- /// Request windows: current bucket usage + plan cap (0 = unlimited).
560
- reqMinuteUsed: u64 = 0;
561
- reqMinuteCap: u64 = 0;
562
- reqDayUsed: u64 = 0;
563
- reqDayCap: u64 = 0;
712
+ /// Request meters (fleet-global, across every edge location) against the plan (cap 0 = unmetered).
713
+ /// Burst: requests in the trailing 3-minute ROLLING window vs its allowance
714
+ /// (`sustained_rps * BURST_WINDOW_SECS`). 30d: requests in the current 30-day bucket vs the quota
715
+ /// (the quota still tumbles: it is a billing period, not a rate).
716
+ reqBurstUsed: u64 = 0;
717
+ reqBurstCap: u64 = 0;
718
+ req30dUsed: u64 = 0;
719
+ req30dCap: u64 = 0;
564
720
  /// LIVE per-second gauges (rates), computed edge-side from the last completed minute bucket so the
565
721
  /// guest is STATELESS (no client diffing of two snapshots). Idle -> 0.0. `f64` (a rate is fractional
566
722
  /// and bounded; f64 cannot overflow for any real value).
@@ -636,6 +792,13 @@ export class TenantStats {
636
792
  const total = hits + this.cacheMisses;
637
793
  return total > 0 ? <f64>hits / <f64>total : 0.0;
638
794
  }
795
+
796
+ /// The plan's SUSTAINED request rate (requests/second), derived from the 3-minute burst cap
797
+ /// (`reqBurstCap / BURST_WINDOW_SECS`). 0.0 means unmetered. Spikes above this rate are allowed;
798
+ /// throttling only starts once `reqBurstUsed` passes `reqBurstCap` in the current bucket.
799
+ get sustainedRpsCap(): f64 {
800
+ return this.reqBurstCap == 0 ? 0.0 : <f64>this.reqBurstCap / <f64>BURST_WINDOW_SECS;
801
+ }
639
802
  }
640
803
 
641
804
  /// One metric's time series for a range: `points` oldest→newest (per-bucket totals, `u64` — non-negative),
@@ -678,7 +841,7 @@ export class Analytics {
678
841
  const r = new DataReader(buf);
679
842
  const stats = new TenantStats();
680
843
  const version = r.readU16();
681
- if (version < 2) return stats; // pre-v2 edge: refuse rather than mis-decode
844
+ if (version < 3) return stats; // pre-v3 edge: refuse rather than mis-decode (clean break, no v2)
682
845
  stats.nowMs = r.readU64();
683
846
  const count = <i32>r.readU32();
684
847
  for (let i: i32 = 0; i < count && r.ok; i++) {
@@ -688,10 +851,10 @@ export class Analytics {
688
851
  }
689
852
  stats.connectedStreams = r.readU64();
690
853
  stats.committedMemory = r.readU64();
691
- stats.reqMinuteUsed = r.readU64();
692
- stats.reqMinuteCap = r.readU64();
693
- stats.reqDayUsed = r.readU64();
694
- stats.reqDayCap = r.readU64();
854
+ stats.reqBurstUsed = r.readU64();
855
+ stats.reqBurstCap = r.readU64();
856
+ stats.req30dUsed = r.readU64();
857
+ stats.req30dCap = r.readU64();
695
858
  // LIVE per-second rates (f64), appended after the windows. VERSION-GATE by LENGTH: a pre-rates
696
859
  // (shorter) frame from an older edge exhausts the buffer here, so `readF64` returns 0.0 for each
697
860
  // missing rate (DataReader is bounds-checked). No frame-version bump needed.
@@ -709,7 +872,7 @@ export class Analytics {
709
872
  const r = new DataReader(buf);
710
873
  const out = new Series();
711
874
  const version = r.readU16();
712
- if (version < 2) return out;
875
+ if (version < 3) return out; // pre-v3 edge: refuse rather than mis-decode (clean break, no v2)
713
876
  out.metric = <MetricId>r.readU16();
714
877
  out.bucketSecs = r.readU32();
715
878
  out.headMs = r.readU64();
@@ -333,9 +333,14 @@ declare const enum AnalyticsRange {
333
333
  H1 = 0, H6 = 1, H12 = 2, H24 = 3, D3 = 4, D7 = 5, D14 = 6, D30 = 7, D60 = 8, D90 = 9,
334
334
  }
335
335
 
336
+ /** The sustained-rate window (seconds): requests are metered over a 3-minute ROLLING window, so a plan's
337
+ * sustained rate `X` (rps) permits `X * BURST_WINDOW_SECS` requests across it. */
338
+ declare const BURST_WINDOW_SECS: i32;
339
+
336
340
  /** One tenant's analytics snapshot: lifetime totals (indexed by `MetricId`, no string keys), the two
337
- * live gauge levels, and the request windows (current usage + plan cap; cap 0 = unlimited). Read a
338
- * value by typed getter (`stats.requests`) or by id (`stats.metric(MetricId.Requests)`). */
341
+ * live gauge levels, and the request meters (burst 3-min bucket + 30-day quota, each with its cap;
342
+ * cap 0 = unmetered). Read a value by typed getter (`stats.requests`) or by id
343
+ * (`stats.metric(MetricId.Requests)`). */
339
344
  declare class TenantStats {
340
345
  metric(id: MetricId): u64;
341
346
  readonly requests: u64;
@@ -385,10 +390,15 @@ declare class TenantStats {
385
390
  readonly cacheRatio: f64;
386
391
  connectedStreams: u64;
387
392
  committedMemory: u64;
388
- reqMinuteUsed: u64;
389
- reqMinuteCap: u64;
390
- reqDayUsed: u64;
391
- reqDayCap: u64;
393
+ /** Fleet-global requests in the trailing 3-minute ROLLING window, and its allowance
394
+ * (`sustained_rps * BURST_WINDOW_SECS`; 0 = unmetered). Spikes are NOT tier-capped. */
395
+ reqBurstUsed: u64;
396
+ reqBurstCap: u64;
397
+ /** Fleet-global requests in the current 30-day tumbling bucket, and the 30-day quota (0 = unmetered). */
398
+ req30dUsed: u64;
399
+ req30dCap: u64;
400
+ /** Derived: the plan's SUSTAINED request rate (`reqBurstCap / BURST_WINDOW_SECS`), 0.0 when unmetered. */
401
+ readonly sustainedRpsCap: f64;
392
402
  /** LIVE per-second gauges (rates), computed edge-side from the last completed minute bucket (idle 0.0),
393
403
  * so no client diffing is needed. `f64` cannot overflow for any real value. */
394
404
  readonly rps: f64;
@@ -431,6 +441,120 @@ declare class Analytics {
431
441
  static listSites(cursor?: string, limit?: i32): SiteList;
432
442
  }
433
443
 
444
+ /**
445
+ * A typed ToilDB failure (`TDLnnn`, spec 27.1). A read that returns `null` either
446
+ * found nothing (`Db.lastError() == DbError.None`) or hit one of the four RETRYABLE
447
+ * availability signals. A HARD FAULT never reaches guest code: the host traps the
448
+ * request rather than hand back a status the ABI would mistake for a benign result.
449
+ */
450
+ declare const enum DbError {
451
+ None = 0,
452
+ /** TDL001. Hard fault: traps. */ InvalidHandle = 1,
453
+ /** TDL002. Hard fault: traps. */ TenantMismatch = 2,
454
+ /** TDL003. A write outcome. */ AlreadyExists = 3,
455
+ /** TDL004. A write outcome. */ Conflict = 4,
456
+ /** TDL005. Hard fault: traps. */ Unsupported = 5,
457
+ /** TDL006. Hard fault: traps. */ Codec = 6,
458
+ /** TDL007. Hard fault: traps. */ Backend = 7,
459
+ /** TDL010. Hard fault: traps. */ OpNotAllowedForFamily = 10,
460
+ /** TDL011. Hard fault: traps. */ OpNotAllowedInKind = 11,
461
+ /** TDL020. Hard fault: traps. */ OpBudget = 20,
462
+ /** TDL021. Hard fault: traps. */ ByteBudget = 21,
463
+ /** TDL022. Hard fault: traps. */ DepthBudget = 22,
464
+ /** TDL025. Hard fault: traps, so a guest never observes it. */ QuotaExceeded = 25,
465
+ /** TDL030. Retryable. */ FillThrottled = 30,
466
+ /** TDL031. Retryable. */ Unavailable = 31,
467
+ /** TDL040. Retryable. */ HotPartition = 40,
468
+ /** TDL041. Retryable. */ ColdStorm = 41,
469
+ /** TDL070. Hard fault: traps. */ SchemaUnavailable = 70,
470
+ /** TDL090. Hard fault: traps. */ CatalogOutsideAdmin = 90,
471
+ /** A negative status carrying no known `TDLnnn`. */ Unknown = -1,
472
+ }
473
+
474
+ /** ToilDB diagnostics: tell an absent row from a failed read. */
475
+ declare class Db {
476
+ /** The failure recorded by the most recent read that returned `null`. */
477
+ static lastError(): DbError;
478
+ /** Decode a raw negative host status into its typed failure. */
479
+ static errorOf(status: i32): DbError;
480
+ /** Whether `err` is a transient availability signal worth retrying. */
481
+ static isRetryable(err: DbError): bool;
482
+ }
483
+
484
+ // The daemon (L4 cold box) scheduler surface is an ambient global, like the
485
+ // ToilDB handles above (`std/assembly/daemon`). It resolves ONLY inside a
486
+ // `@daemon` cold box: its host imports live in the `daemon` wasm module, which a
487
+ // request handler's module does not get.
488
+
489
+ /** A typed daemon failure. Positive values are the host's u16 subsystem codes. */
490
+ declare const enum DaemonError {
491
+ None = 0,
492
+ /** This node does not hold the lease; the host refused without acting. */
493
+ NotLeader = 0x0401,
494
+ /** The lease was lost while the call was in flight. */
495
+ LeaseLost = 0x0402,
496
+ /** The outbound request did not complete (SSRF guard, timeout, transport). */
497
+ CallFailed = 0x0405,
498
+ /** The response did not fit the buffer. The call ALREADY happened: do not retry. */
499
+ ResponseTooLarge = -1,
500
+ /** A request over a host cap (nothing sent), or a malformed response envelope. */
501
+ BadEnvelope = -2,
502
+ /** A negative status outside every band above. */
503
+ Unknown = -3,
504
+ }
505
+
506
+ /** One `name: value` header of an outbound request or an inbound response. */
507
+ declare class DaemonHttpHeader {
508
+ name: string;
509
+ value: string;
510
+ constructor(name: string, value: string);
511
+ }
512
+
513
+ /** An outbound HTTP request. `url` must be an absolute `http(s)` URL. */
514
+ declare class DaemonHttpRequest {
515
+ method: string;
516
+ url: string;
517
+ headers: DaemonHttpHeader[];
518
+ body: Uint8Array;
519
+ constructor(method?: string, url?: string);
520
+ /** Append a header. Returns `this` so calls chain. */
521
+ header(name: string, value: string): DaemonHttpRequest;
522
+ /** The wire envelope, or `null` when a field breaks a host cap. */
523
+ encode(): Uint8Array | null;
524
+ }
525
+
526
+ /** An inbound HTTP response. `status` is 0 when the host failed before a reply. */
527
+ declare class DaemonHttpResponse {
528
+ status: u16;
529
+ headers: DaemonHttpHeader[];
530
+ body: Uint8Array;
531
+ /** A header's value (case-sensitive, first match), or `null`. */
532
+ header(name: string): string | null;
533
+ /** The body decoded as UTF-8. */
534
+ text(): string;
535
+ static decode(buf: Uint8Array, len: i32): DaemonHttpResponse | null;
536
+ }
537
+
538
+ /** The daemon scheduler surface, available inside a `@daemon` class. */
539
+ declare class Daemon {
540
+ /** Whether THIS node currently holds the lease. A snapshot: re-check it. */
541
+ static isLeader(): bool;
542
+ /** The monotonic fencing token, or -1 when this node does not hold the lease. */
543
+ static epoch(): i64;
544
+ /** The number of registered `@scheduled` tasks. */
545
+ static taskCount(): i32;
546
+ /** The next fire time for `taskId` in epoch ms, or -1 when it never fires. */
547
+ static nextFireMs(taskId: i32): i64;
548
+ /** Let the host observe a lost lease. `None` means still leader. */
549
+ static yieldNow(): DaemonError;
550
+ /** Park for `ms` (host-clamped to 3s). `LeaseLost` means the task should stop. */
551
+ static sleep(ms: i64): DaemonError;
552
+ /** Make an outbound HTTP request, or `null` on failure (see `lastError()`). */
553
+ static httpCall(request: DaemonHttpRequest, responseCap?: i32): DaemonHttpResponse | null;
554
+ /** The failure recorded by the most recent `Daemon` call. */
555
+ static lastError(): DaemonError;
556
+ }
557
+
434
558
  // Big integers, native globals implemented in std/assembly/bignum. The
435
559
  // arithmetic/bitwise/comparison operators
436
560
  // (+ - * / % & | ^ << >> == != < > <= >=) are operator overloads resolved by