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.
@@ -15,8 +15,19 @@ import type { DbDevState } from '../db/index.js';
15
15
  import type { MemoryRef } from '../runtime/host.js';
16
16
 
17
17
  /** Frame version + counter count, kept in lockstep with the edge (`analytics.rs` / `metric_id.rs`). */
18
- const FRAME_VERSION = 2;
18
+ const FRAME_VERSION = 3;
19
19
  const METRIC_COUNTERS = 42;
20
+
21
+ /** The sustained-rate window: requests are metered over a 3-minute ROLLING window. A plan's
22
+ * `sustained_rps` (X) allows `X * BURST_WINDOW_SECS` requests across it; instantaneous spikes above
23
+ * that are NOT capped per-tier (only the box `firewall.global_pps` DDoS backstop applies). */
24
+ export const BURST_WINDOW_SECS = 180;
25
+
26
+ /** The plan's sustained rate in requests/second, derived from the rolling window's allowance.
27
+ * `0` means unmetered (no sustained cap), and must NOT be divided into a rate. */
28
+ export function sustainedRpsCap(reqBurstCap: number): number {
29
+ return reqBurstCap === 0 ? 0 : reqBurstCap / BURST_WINDOW_SECS;
30
+ }
20
31
  /** MetricId indices we seed with sample data (others default 0). Mirrors the AUTHORITATIVE edge wire
21
32
  * contract EXACTLY (`toil-backend/src/analytics/metric_id.rs`): counter ids 0..=41, gauge ids
22
33
  * ConnectedStreamsAvg=42, CommittedMemoryAvg=44. There is NO WasmDispatches (removed + reindexed); the
@@ -43,10 +54,16 @@ interface DevTenantStats {
43
54
  life: bigint[]; // length METRIC_COUNTERS, indexed by MetricId
44
55
  connectedStreams: number;
45
56
  committedMemory: number;
46
- reqMinuteUsed: number;
47
- reqMinuteCap: number;
48
- reqDayUsed: number;
49
- reqDayCap: number;
57
+ // Fleet-global requests in the trailing 3-minute ROLLING window, and its allowance
58
+ // (= sustained_rps * BURST_WINDOW_SECS; 0 = unmetered). Spikes are not tier-capped.
59
+ reqBurstUsed: number;
60
+ reqBurstCap: number;
61
+ // Fleet-global requests in the current 30-day tumbling bucket, and the 30-day quota (0 = unmetered).
62
+ req30dUsed: number;
63
+ req30dCap: number;
64
+ // Derived (NOT part of the wire frame): the plan's sustained request rate, `reqBurstCap /
65
+ // BURST_WINDOW_SECS` (0 = unmetered). Surfaced so a dev dashboard can show "X sustained rps".
66
+ sustainedRpsCap: number;
50
67
  // LIVE per-second rates (f64), appended after the windows. Mirrors the edge `encode_stats`.
51
68
  rps: number;
52
69
  bytesInPerSec: number;
@@ -76,14 +93,18 @@ function devStats(): DevTenantStats {
76
93
  life[MID.MemGrownBytes] = 262144n;
77
94
  life[MID.CacheHits] = 900n;
78
95
  life[MID.CacheMisses] = 100n;
96
+ // Free tier: sustained 1000 rps -> a 3-minute burst cap of 1000 * 180 = 180_000, plus a 30-day
97
+ // quota of 10_000_000. `used` values are small so the dashboard shows plenty of headroom.
98
+ const reqBurstCap: number = 180_000;
79
99
  return {
80
100
  life,
81
101
  connectedStreams: 3,
82
102
  committedMemory: 65536,
83
- reqMinuteUsed: 5,
84
- reqMinuteCap: 100,
85
- reqDayUsed: 42,
86
- reqDayCap: 5000,
103
+ reqBurstUsed: 1_200,
104
+ reqBurstCap,
105
+ req30dUsed: 250_000,
106
+ req30dCap: 10_000_000,
107
+ sustainedRpsCap: sustainedRpsCap(reqBurstCap),
87
108
  // Plausible non-zero live rates so a dev dashboard shows moving gauges.
88
109
  rps: 0.7,
89
110
  bytesInPerSec: 68.2,
@@ -97,10 +118,12 @@ function devStats(): DevTenantStats {
97
118
  }
98
119
 
99
120
  /**
100
- * Encode the v2 snapshot frame BYTE-FOR-BYTE like the edge (`analytics.rs` `encode_stats`), FIXED
101
- * layout (no strings). Counters/levels are u64; the 7 trailing LIVE per-second rates are f64:
121
+ * Encode the v3 snapshot frame BYTE-FOR-BYTE like the edge (`analytics.rs` `encode_stats`), FIXED
122
+ * layout (no strings). The v2 -> v3 bump renamed two u64 pairs (req_minute_* -> req_burst_*,
123
+ * req_day_* -> req_30d_*); the BYTE LAYOUT is UNCHANGED (same field count, order, and widths).
124
+ * Counters/levels are u64; the 7 trailing LIVE per-second rates are f64:
102
125
  * u16 version | u64 now_ms | u32 count | u64 × count | u64 connectedStreams | u64 committedMemory |
103
- * u64 reqMinuteUsed | u64 reqMinuteCap | u64 reqDayUsed | u64 reqDayCap |
126
+ * u64 reqBurstUsed | u64 reqBurstCap | u64 req30dUsed | u64 req30dCap |
104
127
  * f64 rps | f64 bytesInPerSec | f64 bytesOutPerSec | f64 streamBytesInPerSec |
105
128
  * f64 streamBytesOutPerSec | f64 dbOpsPerSec | f64 gasPerSec
106
129
  * The rates are APPENDED (version-gated by length): the guest's bounds-checked reader yields 0.0 for a
@@ -120,10 +143,10 @@ function encodeStats(s: DevTenantStats): Buffer {
120
143
  }
121
144
  body.writeBigUInt64LE(BigInt(s.connectedStreams), o); o += 8;
122
145
  body.writeBigUInt64LE(BigInt(s.committedMemory), o); o += 8;
123
- body.writeBigUInt64LE(BigInt(s.reqMinuteUsed), o); o += 8;
124
- body.writeBigUInt64LE(BigInt(s.reqMinuteCap), o); o += 8;
125
- body.writeBigUInt64LE(BigInt(s.reqDayUsed), o); o += 8;
126
- body.writeBigUInt64LE(BigInt(s.reqDayCap), o); o += 8;
146
+ body.writeBigUInt64LE(BigInt(s.reqBurstUsed), o); o += 8;
147
+ body.writeBigUInt64LE(BigInt(s.reqBurstCap), o); o += 8;
148
+ body.writeBigUInt64LE(BigInt(s.req30dUsed), o); o += 8;
149
+ body.writeBigUInt64LE(BigInt(s.req30dCap), o); o += 8;
127
150
  body.writeDoubleLE(s.rps, o); o += 8;
128
151
  body.writeDoubleLE(s.bytesInPerSec, o); o += 8;
129
152
  body.writeDoubleLE(s.bytesOutPerSec, o); o += 8;
@@ -165,7 +188,7 @@ function rangeShape(range: number, metricId: number): { count: number; bucketSec
165
188
  }
166
189
 
167
190
  /**
168
- * Encode the v2 series frame BYTE-FOR-BYTE like the edge `encode_series` (points are u64 now):
191
+ * Encode the v3 series frame BYTE-FOR-BYTE like the edge `encode_series` (points are u64 now):
169
192
  * u16 version | u16 metricId | u32 bucketSecs | u64 headMs | u32 count | u64 × count
170
193
  * A synthetic gentle ramp so a dev dashboard draws a non-flat line.
171
194
  */
@@ -186,6 +186,7 @@ enum DbOp {
186
186
  Exists,
187
187
  Create,
188
188
  Patch,
189
+ Upsert,
189
190
  Delete,
190
191
  GetDelete,
191
192
  Enqueue,
@@ -211,6 +212,13 @@ enum DbOp {
211
212
  EventsSince,
212
213
  }
213
214
 
215
+ /** `data.upsert` return tags (ABI.md), mirroring the guest `UpsertResult` enum. */
216
+ enum UpsertResult {
217
+ Created = 0,
218
+ Updated = 1,
219
+ Conflict = 2,
220
+ }
221
+
214
222
  function isReadOp(op: DbOp): boolean {
215
223
  return (
216
224
  op === DbOp.Get ||
@@ -238,6 +246,7 @@ function kindAllows(kind: DbFunctionKind, op: DbOp): boolean {
238
246
  (isReadOp(op) && !isScanOp(op)) ||
239
247
  op === DbOp.Create ||
240
248
  op === DbOp.Patch ||
249
+ op === DbOp.Upsert ||
241
250
  op === DbOp.Delete ||
242
251
  op === DbOp.GetDelete ||
243
252
  op === DbOp.Enqueue ||
@@ -821,6 +830,39 @@ export class DevDatabase {
821
830
  return this.replayRecordOutcome(db, outcome);
822
831
  }
823
832
 
833
+ // Create-or-overwrite in ONE op: a blind last-writer-wins put. Returns
834
+ // UPSERT_CREATED (0) / UPSERT_UPDATED (1), or UPSERT_CONFLICT (2) when a
835
+ // @unique value is held by ANOTHER record (nothing written). A blind put is
836
+ // naturally idempotent (re-applying the same value is a no-op beyond the
837
+ // Created/Updated tag), so `idem` needs no dedup ledger; it is accepted for
838
+ // ABI parity and ignored, mirroring how the edge merely threads it into the
839
+ // oplog env.
840
+ upsert(
841
+ ref: MemoryRef,
842
+ db: DbDevState,
843
+ handle: number,
844
+ keyPtr: number,
845
+ keyLen: number,
846
+ valPtr: number,
847
+ valLen: number,
848
+ _idemPtr: number,
849
+ ): number {
850
+ const coll = collForOp(db, handle, DbOp.Upsert, CollectionFamily.Record);
851
+ if (typeof coll === 'number') return coll;
852
+ if (keyLen > MAX_KEY || valLen > MAX_VALUE) throw new Error('data: key/value too large');
853
+ const key = readKey(ref, keyPtr, keyLen);
854
+ const value = readCopy(ref, valPtr, valLen);
855
+ const sk = storeKey(coll.name, key);
856
+ // @unique: a value already held by ANOTHER record blocks the upsert (the
857
+ // one outcome a blind put cannot auto-resolve), exactly like the edge op.
858
+ if (this.uniqueConflict(coll, value, sk)) return UpsertResult.Conflict;
859
+ const existed = this.store.has(sk);
860
+ this.store.set(sk, value);
861
+ this.stampVersion(coll, sk); // stamp the value type's current schema version
862
+ this.recordWrite(db, coll);
863
+ return existed ? UpsertResult.Updated : UpsertResult.Created;
864
+ }
865
+
824
866
  delete(
825
867
  ref: MemoryRef,
826
868
  db: DbDevState,
@@ -1603,6 +1645,15 @@ export function buildDatabaseImports(
1603
1645
  idemPtr: number,
1604
1646
  ): number => devDb.patch(ref, db, handle, keyPtr, keyLen, patchPtr, patchLen, idemPtr),
1605
1647
 
1648
+ 'data.upsert': (
1649
+ handle: number,
1650
+ keyPtr: number,
1651
+ keyLen: number,
1652
+ valPtr: number,
1653
+ valLen: number,
1654
+ idemPtr: number,
1655
+ ): number => devDb.upsert(ref, db, handle, keyPtr, keyLen, valPtr, valLen, idemPtr),
1656
+
1606
1657
  'data.delete': (handle: number, keyPtr: number, keyLen: number, idemPtr: number): number =>
1607
1658
  devDb.delete(ref, db, handle, keyPtr, keyLen, idemPtr),
1608
1659
 
@@ -139,6 +139,7 @@ const PROVIDED_IMPORTS = new Set([
139
139
  'data.exists',
140
140
  'data.create',
141
141
  'data.patch',
142
+ 'data.upsert',
142
143
  'data.delete',
143
144
  'data.get_delete',
144
145
  'data.unique_lookup',
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Dev-server analytics stub parity: the `env.analytics_read` / `env.analytics_series` dev imports must
3
- * encode the v2 frames BYTE-FOR-BYTE like the edge (`analytics.rs`), so a guest's toilscript `Analytics`
3
+ * encode the v3 frames BYTE-FOR-BYTE like the edge (`analytics.rs`), so a guest's toilscript `Analytics`
4
4
  * decode is identical in dev and prod. We decode the stashed frame positionally, exactly as the guest.
5
5
  */
6
6
  import { describe, expect, it } from 'vitest';
@@ -16,7 +16,7 @@ function harness() {
16
16
  return { imports: buildAnalyticsImports(ref, db), db };
17
17
  }
18
18
 
19
- describe('dev analytics stub v2 frame parity', () => {
19
+ describe('dev analytics stub v3 frame parity', () => {
20
20
  it('analytics_read encodes the fixed-layout snapshot frame', () => {
21
21
  const { imports, db } = harness();
22
22
  const n = imports.analytics_read(0, 0);
@@ -29,7 +29,7 @@ describe('dev analytics stub v2 frame parity', () => {
29
29
  const u64 = () => { const v = buf.readBigUInt64LE(p); p += 8; return v; };
30
30
  const f64 = () => { const v = buf.readDoubleLE(p); p += 8; return v; };
31
31
 
32
- expect(u16()).toBe(2); // FRAME_VERSION
32
+ expect(u16()).toBe(3); // FRAME_VERSION
33
33
  u64(); // now_ms
34
34
  const count = u32();
35
35
  expect(count).toBe(42); // METRIC_COUNTERS (edge N_COUNTERS; no WasmDispatches)
@@ -44,10 +44,10 @@ describe('dev analytics stub v2 frame parity', () => {
44
44
  expect(life[41]).toBe(100n); // CacheMisses (id 41, was 42)
45
45
  expect(u64()).toBe(3n); // connectedStreams
46
46
  expect(u64()).toBe(65536n); // committedMemory
47
- expect(u64()).toBe(5n); // reqMinuteUsed
48
- expect(u64()).toBe(100n); // reqMinuteCap
49
- expect(u64()).toBe(42n); // reqDayUsed
50
- expect(u64()).toBe(5000n); // reqDayCap
47
+ expect(u64()).toBe(1_200n); // reqBurstUsed (current 3-min tumbling bucket)
48
+ expect(u64()).toBe(180_000n); // reqBurstCap (sustained 1000 rps * 180s window)
49
+ expect(u64()).toBe(250_000n); // req30dUsed (current 30-day bucket)
50
+ expect(u64()).toBe(10_000_000n); // req30dCap (30-day quota)
51
51
  // 7 LIVE per-second rates (f64 LE), appended after the windows.
52
52
  expect(f64()).toBeCloseTo(0.7); // rps
53
53
  expect(f64()).toBeCloseTo(68.2); // bytesInPerSec
@@ -66,7 +66,7 @@ describe('dev analytics stub v2 frame parity', () => {
66
66
  const buf = db.lastResult as unknown as Buffer;
67
67
  expect(n).toBe(buf.length);
68
68
  let p = 0;
69
- expect(buf.readUInt16LE(p)).toBe(2); p += 2; // version
69
+ expect(buf.readUInt16LE(p)).toBe(3); p += 2; // version
70
70
  expect(buf.readUInt16LE(p)).toBe(0); p += 2; // metric id
71
71
  expect(buf.readUInt32LE(p)).toBe(3600); p += 4; // bucketSecs
72
72
  p += 8; // headMs