@sproutboat/runtime 0.28.0 → 0.29.1

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 CHANGED
@@ -1,5 +1,66 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.29.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 65f03ae: Cold standalone builds are fast again. Runtime 0.29.0's Durable Object storage codec named
8
+ eight typed-array constructors, which linked each builtin into every app and added about
9
+ 7 s (45%) to an uncached build (16 s to 23 s for a one-line app on an M-series Mac). The
10
+ CLI's native-restore test passed its 600 s CI timeout.
11
+
12
+ From compatibility date `2026-10-28`, Durable Object storage round-trips `Uint8Array`,
13
+ `DataView` and `ArrayBuffer`. Any other typed array now throws a DataCloneError in
14
+ `put()` instead of being stored: keep its bytes in a `Uint8Array`. Before this change
15
+ they came back with the wrong type. Earlier dates are unchanged.
16
+
17
+ ## 0.29.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 8df17ef: Durable Objects like workerd from compatibility date `2026-10-28`:
22
+
23
+ - **Input gate.** While an object's storage read is in flight, no other event
24
+ (`stub.fetch()`, an alarm) enters it, so `const v = await storage.get("n"); await
25
+ storage.put("n", v + 1)` is safe under concurrent calls. Ten concurrent increments used
26
+ to end at 1. An event that awaits anything else (a fetch, a timer) lets the next one
27
+ in, as in workerd. `blockConcurrencyWhile()` and `transaction()` hold the gate until
28
+ they settle, and `stub.fetch()` always returns a Promise.
29
+ - **Storage returns Promises** (`get`, `put`, `delete`, `deleteAll`, `list`, the alarm
30
+ methods), and adds `sync()` and `transaction()` (with `txn.rollback()`; a throw rolls
31
+ back; a transaction reads its own writes).
32
+ - **Structured values.** Date, Map, Set, RegExp, ArrayBuffer and typed arrays, BigInt,
33
+ `undefined` inside containers, NaN, the infinities and -0 round-trip.
34
+ `put(k, undefined)` throws a TypeError and a function throws a DataCloneError, before
35
+ anything is written.
36
+ - `list()` takes `start`, `startAfter`, `end` and `reverse`, and `limit: 0` throws.
37
+ `setAlarm()` refuses a value that is not a Date or a number. `deleteAll()` also drops
38
+ the alarm.
39
+
40
+ Earlier dates keep synchronous JSON storage and no gate. Values written from
41
+ `2026-10-28` start with `#sb1:`, and every date reads both formats.
42
+
43
+ Both transports' `do.storage.list` take `start`, `startAfter`, `end` and `reverse`, and
44
+ match the prefix exactly (SQL `LIKE` also matched `_` and `%` as wildcards, ignoring
45
+ case, so a prefix such as `user_` listed other keys).
46
+
47
+ ### Patch Changes
48
+
49
+ - 8df17ef: Queues fixes at every compatibility date (data loss, so not gated):
50
+
51
+ - On the embedded transport (standalone sprouts and sproutd), `sendBatch()` stored every
52
+ body as the text `"[object Object]"`. It now stores the body it was given, and both
53
+ `send()` and `sendBatch()` honour `delaySeconds` there, as the broker already did.
54
+ - A consumer whose `queue()` throws now retries every message it had not explicitly
55
+ acked or retried, as on Workers. The embedded transport used to ack the whole batch,
56
+ so a failing consumer lost its messages. Explicit `ack()` / `ackAll()` calls made
57
+ before the throw still count (Cloudflare's documented behaviour; miniflare retries
58
+ those too).
59
+
60
+ From compatibility date `2026-10-28`, `sendBatch(messages, { delaySeconds })` delays
61
+ every message whose own `delaySeconds` is unset or 0. Earlier dates ignore the batch
62
+ option.
63
+
3
64
  ## 0.28.0
4
65
 
5
66
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5905,11 +5905,18 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5905
5905
  delaySeconds: o.delaySeconds || 0,
5906
5906
  });
5907
5907
  },
5908
- sendBatch(messages) {
5908
+ sendBatch(messages, options) {
5909
+ // From 2026-10-28 sendBatch(messages, { delaySeconds }) delays every message that
5910
+ // sets none of its own, as on Workers; before, the batch option was ignored.
5911
+ const batchDelay = globalThis.__sbCompat >= "2026-10-28" ? (options && options.delaySeconds) || 0 : 0;
5909
5912
  const list = [];
5910
5913
  for (let j = 0; j < (messages || []).length; j++) {
5911
5914
  const m = messages[j];
5912
- list.push({ body: __sbIsStr(m.body) ? m.body : JSON.stringify(m.body), delaySeconds: m.delaySeconds || 0 });
5915
+ list.push({
5916
+ body: __sbIsStr(m.body) ? m.body : JSON.stringify(m.body),
5917
+ // A message delay of 0 (or none) takes the batch delay, as miniflare does.
5918
+ delaySeconds: m.delaySeconds || batchDelay,
5919
+ });
5913
5920
  }
5914
5921
  __sbRpc("queue.send_batch", { queue: name, messages: list });
5915
5922
  },
@@ -6514,14 +6521,14 @@ globalThis.__sbInstallFetch = function () {
6514
6521
 
6515
6522
  // ---------------------------------------------------------------------------
6516
6523
  // Durable Objects. The class runs here in the sandboxed sprout. There is exactly
6517
- // one sprout process per deployment (the supervisor model) and the native-fetch
6518
- // runtime processes one turn at a time, so calls to a given object id are
6519
- // already serialized — `env.<NS>.get(id).fetch()` invokes the instance directly,
6520
- // no round-trip. Only `state.storage.*` goes to the broker (so object state
6521
- // outlives a sprout restart), scoped to (class, id).
6522
- // ponytail: serialization relies on the single sprout process; a multi-sprout
6523
- // deployment needs the broker to hold a per-id lock (cloud). Storage ops are one
6524
- // key at a time; blockConcurrencyWhile just runs the fn.
6524
+ // one sprout process per deployment (the supervisor model), so one instance per
6525
+ // id: `env.<NS>.get(id).fetch()` invokes the instance directly, no round-trip.
6526
+ // Events still interleave at every await; from 2026-10-28 an input gate (below,
6527
+ // __sbDOEnter) keeps them out while a storage read is in flight, as in workerd.
6528
+ // Only `state.storage.*` goes to the broker (so object state outlives a sprout
6529
+ // restart), scoped to (class, id).
6530
+ // ponytail: one instance per id relies on the single sprout process; a
6531
+ // multi-sprout deployment needs the broker to hold a per-id lock (cloud).
6525
6532
 
6526
6533
  function __sbMakeDONamespace(binding, className) {
6527
6534
  return {
@@ -6570,9 +6577,14 @@ function __sbMakeDONamespace(binding, className) {
6570
6577
  if (opts.body != null) req.body = String(opts.body);
6571
6578
  }
6572
6579
  const inst = __sbGetDOInstance(className, idStr);
6573
- const res = inst.fetch(req);
6574
- if (inst.__sbTasks.length || (res && __sbIsFn(res.then)) || __sbHasStreamBody(res)) return __sbFetchWithDrain(res, inst.__sbTasks);
6575
- return res;
6580
+ const deliver = () => {
6581
+ const res = inst.fetch(req);
6582
+ if (inst.__sbTasks.length || (res && __sbIsFn(res.then)) || __sbHasStreamBody(res)) return __sbFetchWithDrain(res, inst.__sbTasks);
6583
+ return res;
6584
+ };
6585
+ // From 2026-10-28 the call waits at the object's input gate, as in workerd.
6586
+ if (__sbDOGated()) return __sbDOEnter(inst.__sbGate, deliver);
6587
+ return deliver();
6576
6588
  },
6577
6589
  };
6578
6590
  },
@@ -6596,15 +6608,39 @@ function __sbGetDOInstance(cls, id) {
6596
6608
  // `__sbDOInstances`), so the queue lives on `state` too and is drained by
6597
6609
  // whichever call site invoked `fetch`/`alarm` this turn.
6598
6610
  const __sbTasks = [];
6611
+ const gate = __sbDOMakeGate();
6599
6612
  const state = {
6600
6613
  id: {
6601
6614
  toString() {
6602
6615
  return id;
6603
6616
  },
6604
6617
  },
6605
- storage: __sbDOStorage(cls, id),
6618
+ storage: __sbDOGated() ? __sbDOAsyncStorage(cls, id, gate) : __sbDOStorage(cls, id),
6606
6619
  blockConcurrencyWhile(fn) {
6607
- return fn();
6620
+ if (!__sbDOGated()) return fn();
6621
+ // No event enters until fn settles (2026-10-28).
6622
+ gate.locks++;
6623
+ const done = () => {
6624
+ gate.locks--;
6625
+ __sbDOPump(gate);
6626
+ };
6627
+ let r;
6628
+ try {
6629
+ r = fn();
6630
+ } catch (error) {
6631
+ done();
6632
+ return Promise.reject(error);
6633
+ }
6634
+ return Promise.resolve(r).then(
6635
+ (v) => {
6636
+ done();
6637
+ return v;
6638
+ },
6639
+ (error) => {
6640
+ done();
6641
+ throw error;
6642
+ },
6643
+ );
6608
6644
  },
6609
6645
  waitUntil(p) {
6610
6646
  if (p && __sbIsFn(p.then)) __sbTasks.push(p);
@@ -6612,6 +6648,7 @@ function __sbGetDOInstance(cls, id) {
6612
6648
  };
6613
6649
  inst = new Ctor(state, globalThis.env);
6614
6650
  inst.__sbTasks = __sbTasks;
6651
+ inst.__sbGate = gate;
6615
6652
  __sbDOInstances[cacheKey] = inst;
6616
6653
  }
6617
6654
  return inst;
@@ -6624,12 +6661,12 @@ function __sbDOStorage(cls, id) {
6624
6661
  const out = new Map();
6625
6662
  for (let i = 0; i < key.length; i++) {
6626
6663
  const r = __sbRpc("do.storage.get", { cls, id, key: String(key[i]) });
6627
- if (r.found) out.set(key[i], JSON.parse(r.value));
6664
+ if (r.found) out.set(key[i], __sbDODecode(r.value));
6628
6665
  }
6629
6666
  return out;
6630
6667
  }
6631
6668
  const r = __sbRpc("do.storage.get", { cls, id, key: String(key) });
6632
- return r.found ? JSON.parse(r.value) : undefined;
6669
+ return r.found ? __sbDODecode(r.value) : undefined;
6633
6670
  },
6634
6671
  put(key, value) {
6635
6672
  if (key != null && __sbIsObj(key)) {
@@ -6672,12 +6709,434 @@ function __sbDOStorage(cls, id) {
6672
6709
  limit: o.limit == null ? 1000 : o.limit,
6673
6710
  });
6674
6711
  const out = new Map();
6675
- for (let i = 0; i < (r.entries || []).length; i++) out.set(r.entries[i][0], JSON.parse(r.entries[i][1]));
6712
+ for (let i = 0; i < (r.entries || []).length; i++) out.set(r.entries[i][0], __sbDODecode(r.entries[i][1]));
6676
6713
  return out;
6677
6714
  },
6678
6715
  };
6679
6716
  }
6680
6717
 
6718
+
6719
+ // From compatibility date 2026-10-28 Durable Objects behave like workerd's in three ways:
6720
+ //
6721
+ // - Input gate. While a storage read is in flight, no other event (stub.fetch(), an alarm)
6722
+ // enters the object; the reader's continuation runs first. Reads here are synchronous, so
6723
+ // the gate stays shut until the next timer turn, by which point every microtask the read
6724
+ // woke (the reader's code up to its next real await) has run. So
6725
+ // `const v = await storage.get("n"); await storage.put("n", v + 1)` is safe under
6726
+ // concurrent calls; it used to lose updates (10 concurrent increments ended at 1). An
6727
+ // event that awaits anything else (fetch, a timer) lets the next one in, as in workerd.
6728
+ // blockConcurrencyWhile() and transaction() shut it until they settle.
6729
+ // - Storage methods return Promises.
6730
+ // - Values are structured-cloned (Date, Map, Set, RegExp, ArrayBuffer and views, BigInt,
6731
+ // undefined inside containers, NaN and the infinities), not JSON: put(k, undefined) and
6732
+ // functions throw, and list() takes start, startAfter, end and reverse.
6733
+ //
6734
+ // Before that date storage is synchronous JSON and events interleave at every await.
6735
+ // Values written in the new format start with __sbDOTag (no JSON text starts with "#"),
6736
+ // so either side reads both.
6737
+ function __sbDOGated() {
6738
+ return globalThis.__sbCompat >= "2026-10-28";
6739
+ }
6740
+
6741
+ function __sbDOMakeGate() {
6742
+ return { locks: 0, waiting: [] };
6743
+ }
6744
+
6745
+ // Deliver queued events while the gate is open. A delivered event that reads storage shuts
6746
+ // the gate again, which stops the loop.
6747
+ function __sbDOPump(gate) {
6748
+ while (gate.locks === 0 && gate.waiting.length > 0) gate.waiting.shift()();
6749
+ }
6750
+
6751
+ // Shut the gate until the next timer turn (see above).
6752
+ function __sbDOHold(gate) {
6753
+ gate.locks++;
6754
+ setTimeout(function () {
6755
+ gate.locks--;
6756
+ __sbDOPump(gate);
6757
+ }, 0);
6758
+ }
6759
+
6760
+ // Run an event now if the gate is open and nothing is queued ahead of it, else queue it.
6761
+ // Always a Promise, like workerd's stub.fetch().
6762
+ function __sbDOEnter(gate, run) {
6763
+ if (gate.locks === 0 && gate.waiting.length === 0) {
6764
+ try {
6765
+ return Promise.resolve(run());
6766
+ } catch (error) {
6767
+ return Promise.reject(error);
6768
+ }
6769
+ }
6770
+ return new Promise(function (resolve, reject) {
6771
+ gate.waiting.push(function () {
6772
+ try {
6773
+ resolve(run());
6774
+ } catch (error) {
6775
+ reject(error);
6776
+ }
6777
+ });
6778
+ });
6779
+ }
6780
+
6781
+ const __sbDOTag = "#sb1:";
6782
+
6783
+ function __sbDOCloneError(value) {
6784
+ let text = "value";
6785
+ try {
6786
+ text = String(value);
6787
+ } catch {
6788
+ /* a value whose String() throws still gets the error below */
6789
+ }
6790
+ const error = new Error(text + " could not be cloned.");
6791
+ error.name = "DataCloneError";
6792
+ return error;
6793
+ }
6794
+
6795
+ function __sbDOHex(bytes) {
6796
+ const digits = "0123456789abcdef";
6797
+ let out = "";
6798
+ for (let i = 0; i < bytes.length; i++) out += digits[bytes[i] >> 4] + digits[bytes[i] & 15];
6799
+ return out;
6800
+ }
6801
+
6802
+ function __sbDOUnhex(text) {
6803
+ const out = new Uint8Array(text.length / 2);
6804
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(text.slice(i * 2, i * 2 + 2), 16);
6805
+ return out;
6806
+ }
6807
+
6808
+ // Uint8Array and DataView only. Each other typed-array constructor named here links that
6809
+ // builtin into every app: the eight of them added about 7 s (45%) to a cold standalone
6810
+ // build. __sbDOPack refuses the other views instead of changing their type.
6811
+ function __sbDOView(name, bytes) {
6812
+ return name === "DataView" ? new DataView(bytes.buffer) : bytes;
6813
+ }
6814
+
6815
+ // A value as JSON: null, booleans, strings and finite numbers are themselves; everything
6816
+ // else is an array whose first element names its type, so no plain value is ambiguous.
6817
+ /* oxlint-disable anti-slop/no-runtime-typeof -- structured clone branches on the value's type, as the HTML spec's StructuredSerialize does. */
6818
+ function __sbDOPack(v, seen) {
6819
+ if (v === null || v === true || v === false || __sbIsStr(v)) return v;
6820
+ const t = typeof v;
6821
+ if (t === "number") {
6822
+ if (v === 0 && 1 / v < 0) return ["N", "-0"];
6823
+ return Number.isFinite(v) ? v : ["N", String(v)];
6824
+ }
6825
+ if (t === "bigint") return ["B", String(v)];
6826
+ if (t === "undefined") return ["U"];
6827
+ if (t !== "object") throw __sbDOCloneError(v);
6828
+ if (seen.indexOf(v) !== -1) throw __sbDOCloneError("a circular value");
6829
+ seen.push(v);
6830
+ let out;
6831
+ if (v instanceof Date) out = ["D", v.getTime()];
6832
+ else if (v instanceof Map) {
6833
+ out = ["M"];
6834
+ v.forEach((x, k) => {
6835
+ out.push(__sbDOPack(k, seen), __sbDOPack(x, seen));
6836
+ });
6837
+ } else if (v instanceof Set) {
6838
+ out = ["S"];
6839
+ v.forEach((x) => {
6840
+ out.push(__sbDOPack(x, seen));
6841
+ });
6842
+ } else if (v instanceof ArrayBuffer) out = ["AB", __sbDOHex(new Uint8Array(v))];
6843
+ else if (ArrayBuffer.isView(v)) {
6844
+ const name = Object.prototype.toString.call(v).slice(8, -1);
6845
+ if (name !== "Uint8Array" && name !== "DataView") {
6846
+ const error = new Error(name + " is not supported in Durable Object storage here yet; store a Uint8Array of its bytes.");
6847
+ error.name = "DataCloneError";
6848
+ throw error;
6849
+ }
6850
+ out = ["V", name, __sbDOHex(new Uint8Array(v.buffer, v.byteOffset, v.byteLength))];
6851
+ }
6852
+ else if (v instanceof RegExp) out = ["R", v.source, v.flags];
6853
+ else if (Array.isArray(v)) {
6854
+ out = ["A"];
6855
+ for (let i = 0; i < v.length; i++) out.push(__sbDOPack(v[i], seen));
6856
+ } else if (v instanceof Error) out = ["E", String(v.name), String(v.message)];
6857
+ else {
6858
+ out = ["O"];
6859
+ const keys = Object.keys(v);
6860
+ for (let i = 0; i < keys.length; i++) out.push(keys[i], __sbDOPack(v[keys[i]], seen));
6861
+ }
6862
+ seen.pop();
6863
+ return out;
6864
+ }
6865
+ /* oxlint-enable anti-slop/no-runtime-typeof */
6866
+
6867
+ function __sbDOUnpack(p) {
6868
+ if (!Array.isArray(p)) return p;
6869
+ const tag = p[0];
6870
+ if (tag === "U") return undefined;
6871
+ if (tag === "N") return p[1] === "-0" ? -0 : Number(p[1]);
6872
+ if (tag === "B") return BigInt(p[1]);
6873
+ if (tag === "D") return new Date(p[1]);
6874
+ if (tag === "AB") return __sbDOUnhex(p[1]).buffer;
6875
+ if (tag === "V") return __sbDOView(p[1], __sbDOUnhex(p[2]));
6876
+ if (tag === "R") return new RegExp(p[1], p[2]);
6877
+ if (tag === "E") {
6878
+ const e = new Error(p[2]);
6879
+ e.name = p[1];
6880
+ return e;
6881
+ }
6882
+ if (tag === "M") {
6883
+ const m = new Map();
6884
+ for (let i = 1; i + 1 < p.length; i += 2) m.set(__sbDOUnpack(p[i]), __sbDOUnpack(p[i + 1]));
6885
+ return m;
6886
+ }
6887
+ if (tag === "S") {
6888
+ const s = new Set();
6889
+ for (let i = 1; i < p.length; i++) s.add(__sbDOUnpack(p[i]));
6890
+ return s;
6891
+ }
6892
+ if (tag === "A") {
6893
+ const a = [];
6894
+ for (let i = 1; i < p.length; i++) a.push(__sbDOUnpack(p[i]));
6895
+ return a;
6896
+ }
6897
+ const o = {};
6898
+ for (let i = 1; i + 1 < p.length; i += 2) o[p[i]] = __sbDOUnpack(p[i + 1]);
6899
+ return o;
6900
+ }
6901
+
6902
+ function __sbDOEncode(value) {
6903
+ if (value === undefined) throw new TypeError("put() called with undefined value.");
6904
+ return __sbDOTag + JSON.stringify(__sbDOPack(value, []));
6905
+ }
6906
+
6907
+ // Reads both formats at every date: tagged (2026-10-28 writes) and plain JSON (earlier ones).
6908
+ function __sbDODecode(text) {
6909
+ if (__sbIsStr(text) && text.indexOf(__sbDOTag) === 0) return __sbDOUnpack(JSON.parse(text.slice(__sbDOTag.length)));
6910
+ return JSON.parse(text);
6911
+ }
6912
+
6913
+ function __sbDOListLimit(options) {
6914
+ const o = options || {};
6915
+ if (o.limit != null && !(Number(o.limit) > 0)) throw new TypeError("List limit must be positive.");
6916
+ return o;
6917
+ }
6918
+
6919
+ function __sbDOInRange(key, o) {
6920
+ if (o.prefix != null && key.indexOf(String(o.prefix)) !== 0) return false;
6921
+ if (o.start != null && key < String(o.start)) return false;
6922
+ if (o.startAfter != null && key <= String(o.startAfter)) return false;
6923
+ if (o.end != null && key >= String(o.end)) return false;
6924
+ return true;
6925
+ }
6926
+
6927
+ // The synchronous core of the 2026-10-28 storage: structured values, list() ranges.
6928
+ function __sbDOStrictStorage(cls, id) {
6929
+ const get = (key) => {
6930
+ const r = __sbRpc("do.storage.get", { cls, id, key: String(key) });
6931
+ return r.found ? __sbDODecode(r.value) : undefined;
6932
+ };
6933
+ const put = (key, value) => {
6934
+ __sbRpc("do.storage.put", { cls, id, key: String(key), value: __sbDOEncode(value) });
6935
+ };
6936
+ const del = (key) => !!__sbRpc("do.storage.delete", { cls, id, key: String(key) }).deleted;
6937
+ return {
6938
+ get(key) {
6939
+ if (!Array.isArray(key)) return get(key);
6940
+ const out = new Map();
6941
+ for (let i = 0; i < key.length; i++) {
6942
+ const v = get(key[i]);
6943
+ if (v !== undefined) out.set(key[i], v);
6944
+ }
6945
+ return out;
6946
+ },
6947
+ put(key, value) {
6948
+ if (key != null && __sbIsObj(key)) {
6949
+ // Encode every value before writing any, so one bad value writes nothing.
6950
+ const keys = Object.keys(key);
6951
+ const encoded = [];
6952
+ for (let i = 0; i < keys.length; i++) encoded.push(__sbDOEncode(key[keys[i]]));
6953
+ for (let i = 0; i < keys.length; i++) __sbRpc("do.storage.put", { cls, id, key: keys[i], value: encoded[i] });
6954
+ return;
6955
+ }
6956
+ put(key, value);
6957
+ },
6958
+ delete(key) {
6959
+ if (!Array.isArray(key)) return del(key);
6960
+ let n = 0;
6961
+ for (let i = 0; i < key.length; i++) n += del(key[i]) ? 1 : 0;
6962
+ return n;
6963
+ },
6964
+ deleteAll() {
6965
+ // workerd's deleteAll() also drops the alarm.
6966
+ __sbRpc("do.storage.delete_all", { cls, id });
6967
+ __sbRpc("do.alarm.delete", { cls, id });
6968
+ },
6969
+ setAlarm(when) {
6970
+ const at = when instanceof Date ? when.getTime() : when;
6971
+ if (!Number.isFinite(at))
6972
+ throw new TypeError("Failed to execute 'setAlarm' on 'DurableObjectStorage': parameter 1 is not of type 'date'.");
6973
+ __sbRpc("do.alarm.set", { cls, id, at: Number(at) });
6974
+ },
6975
+ getAlarm() {
6976
+ const r = __sbRpc("do.alarm.get", { cls, id });
6977
+ return r.at == null ? null : r.at;
6978
+ },
6979
+ deleteAlarm() {
6980
+ __sbRpc("do.alarm.delete", { cls, id });
6981
+ },
6982
+ list(options) {
6983
+ const o = __sbDOListLimit(options);
6984
+ const r = __sbRpc("do.storage.list", {
6985
+ cls,
6986
+ id,
6987
+ prefix: o.prefix == null ? "" : String(o.prefix),
6988
+ start: o.start == null ? null : String(o.start),
6989
+ startAfter: o.startAfter == null ? null : String(o.startAfter),
6990
+ end: o.end == null ? null : String(o.end),
6991
+ reverse: !!o.reverse,
6992
+ limit: o.limit == null ? 10000 : Number(o.limit),
6993
+ });
6994
+ const out = new Map();
6995
+ const entries = r.entries || [];
6996
+ // A transport that predates the range fields returns a superset: filter it here.
6997
+ for (let i = 0; i < entries.length; i++)
6998
+ if (__sbDOInRange(entries[i][0], o)) out.set(entries[i][0], __sbDODecode(entries[i][1]));
6999
+ return out;
7000
+ },
7001
+ };
7002
+ }
7003
+
7004
+ // The 2026-10-28 storage: every method returns a Promise; reads shut the input gate.
7005
+ function __sbDOAsyncStorage(cls, id, gate) {
7006
+ const s = __sbDOStrictStorage(cls, id);
7007
+ const settle = (f) => {
7008
+ try {
7009
+ return Promise.resolve(f());
7010
+ } catch (error) {
7011
+ return Promise.reject(error);
7012
+ }
7013
+ };
7014
+ const read = (f) => {
7015
+ __sbDOHold(gate);
7016
+ return settle(f);
7017
+ };
7018
+ return {
7019
+ get: (key) => read(() => s.get(key)),
7020
+ list: (options) => read(() => s.list(options)),
7021
+ getAlarm: () => read(() => s.getAlarm()),
7022
+ put: (key, value) => settle(() => s.put(key, value)),
7023
+ delete: (key) => settle(() => s.delete(key)),
7024
+ deleteAll: () => settle(() => s.deleteAll()),
7025
+ setAlarm: (when) => settle(() => s.setAlarm(when)),
7026
+ deleteAlarm: () => settle(() => s.deleteAlarm()),
7027
+ sync: () => Promise.resolve(),
7028
+ transaction: (fn) => __sbDOTransaction(cls, id, s, gate, fn),
7029
+ };
7030
+ }
7031
+
7032
+ // storage.transaction(fn): fn gets a txn with the storage methods; its writes stay in
7033
+ // memory (read back by its own get and list) and are written when fn resolves, unless it
7034
+ // threw or called txn.rollback(). The input gate stays shut meanwhile, so no other event
7035
+ // sees a half-written transaction.
7036
+ function __sbDOTransaction(cls, id, s, gate, fn) {
7037
+ const writes = new Map(); // key -> encoded value, or null for a delete
7038
+ let rolledBack = false;
7039
+ const getOne = (key) => {
7040
+ const k = String(key);
7041
+ if (!writes.has(k)) return s.get(k);
7042
+ const v = writes.get(k);
7043
+ return v === null ? undefined : __sbDODecode(v);
7044
+ };
7045
+ const putOne = (key, value) => {
7046
+ writes.set(String(key), __sbDOEncode(value));
7047
+ };
7048
+ const delOne = (key) => {
7049
+ const existed = getOne(key) !== undefined;
7050
+ writes.set(String(key), null);
7051
+ return existed;
7052
+ };
7053
+ const txn = {
7054
+ get(key) {
7055
+ if (!Array.isArray(key)) return Promise.resolve(getOne(key));
7056
+ const out = new Map();
7057
+ for (let i = 0; i < key.length; i++) {
7058
+ const v = getOne(key[i]);
7059
+ if (v !== undefined) out.set(key[i], v);
7060
+ }
7061
+ return Promise.resolve(out);
7062
+ },
7063
+ put(key, value) {
7064
+ try {
7065
+ if (key != null && __sbIsObj(key)) {
7066
+ const keys = Object.keys(key);
7067
+ for (let i = 0; i < keys.length; i++) putOne(keys[i], key[keys[i]]);
7068
+ } else putOne(key, value);
7069
+ return Promise.resolve();
7070
+ } catch (error) {
7071
+ return Promise.reject(error);
7072
+ }
7073
+ },
7074
+ delete(key) {
7075
+ if (!Array.isArray(key)) return Promise.resolve(delOne(key));
7076
+ let n = 0;
7077
+ for (let i = 0; i < key.length; i++) n += delOne(key[i]) ? 1 : 0;
7078
+ return Promise.resolve(n);
7079
+ },
7080
+ list(options) {
7081
+ try {
7082
+ const o = __sbDOListLimit(options);
7083
+ const merged = s.list({ prefix: o.prefix, start: o.start, startAfter: o.startAfter, end: o.end });
7084
+ writes.forEach((v, k) => {
7085
+ if (!__sbDOInRange(k, o)) return;
7086
+ if (v === null) merged.delete(k);
7087
+ else merged.set(k, __sbDODecode(v));
7088
+ });
7089
+ const keys = [];
7090
+ merged.forEach((v, k) => keys.push(k));
7091
+ keys.sort();
7092
+ if (o.reverse) keys.reverse();
7093
+ const out = new Map();
7094
+ const limit = o.limit == null ? keys.length : Number(o.limit);
7095
+ for (let i = 0; i < keys.length && i < limit; i++) out.set(keys[i], merged.get(keys[i]));
7096
+ return Promise.resolve(out);
7097
+ } catch (error) {
7098
+ return Promise.reject(error);
7099
+ }
7100
+ },
7101
+ rollback() {
7102
+ rolledBack = true;
7103
+ },
7104
+ getAlarm: () => Promise.resolve(s.getAlarm()),
7105
+ setAlarm: (when) => Promise.resolve(s.setAlarm(when)),
7106
+ deleteAlarm: () => Promise.resolve(s.deleteAlarm()),
7107
+ };
7108
+ gate.locks++;
7109
+ const done = () => {
7110
+ gate.locks--;
7111
+ __sbDOPump(gate);
7112
+ };
7113
+ let r;
7114
+ try {
7115
+ r = fn(txn);
7116
+ } catch (error) {
7117
+ done();
7118
+ return Promise.reject(error);
7119
+ }
7120
+ return Promise.resolve(r).then(
7121
+ (v) => {
7122
+ try {
7123
+ if (!rolledBack)
7124
+ writes.forEach((value, key) => {
7125
+ if (value === null) __sbRpc("do.storage.delete", { cls, id, key });
7126
+ else __sbRpc("do.storage.put", { cls, id, key, value });
7127
+ });
7128
+ } finally {
7129
+ done();
7130
+ }
7131
+ return v;
7132
+ },
7133
+ (error) => {
7134
+ done();
7135
+ throw error;
7136
+ },
7137
+ );
7138
+ }
7139
+
6681
7140
  // ---------------------------------------------------------------------------
6682
7141
  // Trigger dispatch. The compiled server only ever calls `fetch(request)`; this
6683
7142
  // routes the internal `x-sb-trigger` requests (sent by the broker, authenticated
@@ -7347,7 +7806,7 @@ globalThis.__sbEntry = function (handlers, request) {
7347
7806
  if (!__sbIsFn(inst.alarm)) return new Response("no alarm handler", { status: 404 });
7348
7807
  let __ares;
7349
7808
  try {
7350
- __ares = inst.alarm();
7809
+ __ares = __sbDOGated() ? __sbDOEnter(inst.__sbGate, () => inst.alarm()) : inst.alarm();
7351
7810
  } catch (error) {
7352
7811
  // #179 — same rule as fetch: don't let a throw here crash the process.
7353
7812
  __sbLogUncaught("alarm", error);
@@ -7372,6 +7831,7 @@ globalThis.__sbEntry = function (handlers, request) {
7372
7831
  async function __sbRunQueueBatch(handlers, body, ctx) {
7373
7832
  const acked = [];
7374
7833
  const retried = [];
7834
+ let threw = false;
7375
7835
  const raw = body.messages || [];
7376
7836
  const messages = [];
7377
7837
  for (let i = 0; i < raw.length; i++) {
@@ -7408,15 +7868,18 @@ async function __sbRunQueueBatch(handlers, body, ctx) {
7408
7868
  // the response go out) before its own `await`s did, so any ack()/retry()
7409
7869
  // past the first one never counted.
7410
7870
  if (__qres && __sbIsFn(__qres.then)) await __qres;
7411
- } catch {
7871
+ } catch (error) {
7412
7872
  // #179 — a throw here must not crash the process. Whatever ack()/retry()
7413
- // calls the handler made before throwing still count; anything it never
7414
- // touched falls through to the default-ack pass below, same as a handler
7415
- // that returns without calling ack()/retry() on every message.
7873
+ // calls the handler made before throwing still count.
7874
+ threw = true;
7875
+ __sbLogUncaught("queue", error);
7416
7876
  }
7417
- // default: any message neither acked nor retried is treated as acked
7877
+ // A message neither acked nor retried is acked when the handler returns, and retried
7878
+ // when it throws, as on Workers. A throw used to ack the whole batch on the embedded
7879
+ // transport (standalone, sproutd), so a failing consumer lost its messages.
7418
7880
  for (let i = 0; i < messages.length; i++) {
7419
- if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
7881
+ if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1)
7882
+ (threw ? retried : acked).push(messages[i].id);
7420
7883
  }
7421
7884
  return { ack: acked, retry: retried };
7422
7885
  }
@@ -4194,12 +4194,17 @@ function __sbEmbeddedDispatch(msg) {
4194
4194
  }
4195
4195
 
4196
4196
  if (op === "queue.send" || op === "queue.send_batch") {
4197
- const items = op === "queue.send" ? [msg.body] : msg.messages || [];
4197
+ // send_batch's items are { body, delaySeconds } objects, like the broker's. They were
4198
+ // stored with String(item), so every sendBatch() body arrived as "[object Object]"
4199
+ // (data loss), and delaySeconds was ignored on both calls.
4200
+ const items = op === "queue.send" ? [{ body: msg.body, delaySeconds: msg.delaySeconds }] : msg.messages || [];
4198
4201
  for (let i = 0; i < items.length; i++) {
4202
+ const item = items[i] || {};
4203
+ const delay = Math.max(0, Number(item.delaySeconds) || 0) * 1000;
4199
4204
  __sbSql(
4200
4205
  store,
4201
4206
  "INSERT INTO mq (queue, id, body, visible_at, attempts, dead) VALUES (?1,?2,?3,?4,0,0)",
4202
- [msg.queue, __sbHex(12), String(items[i]), Date.now()],
4207
+ [msg.queue, __sbHex(12), String(item.body), Date.now() + delay],
4203
4208
  );
4204
4209
  }
4205
4210
  return { ok: true };
@@ -4240,8 +4245,21 @@ function __sbEmbeddedDispatch(msg) {
4240
4245
  store,
4241
4246
  // Same clamp as the broker: an unbounded list builds the whole result in
4242
4247
  // memory before the caller sees any of it.
4243
- "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
4244
- [msg.cls, msg.id, msg.prefix || "", Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000)],
4248
+ // start, startAfter, end and reverse come from 2026-10-28 storage (null when unset).
4249
+ // The prefix match is exact: LIKE also matched _ and % as wildcards, case-insensitively.
4250
+ "SELECT key, value FROM do_storage WHERE cls = ?1 AND id = ?2 AND substr(key, 1, length(?3)) = ?3" +
4251
+ " AND (?5 IS NULL OR key >= ?5) AND (?6 IS NULL OR key > ?6) AND (?7 IS NULL OR key < ?7)" +
4252
+ (msg.reverse ? " ORDER BY key DESC" : " ORDER BY key") +
4253
+ " LIMIT ?4",
4254
+ [
4255
+ msg.cls,
4256
+ msg.id,
4257
+ msg.prefix || "",
4258
+ Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000),
4259
+ msg.start == null ? null : String(msg.start),
4260
+ msg.startAfter == null ? null : String(msg.startAfter),
4261
+ msg.end == null ? null : String(msg.end),
4262
+ ],
4245
4263
  );
4246
4264
  const entries = [];
4247
4265
  for (let i = 0; i < r.rows.length; i++) entries.push([r.rows[i][0], r.rows[i][1]]);
@@ -4799,7 +4817,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
4799
4817
  try {
4800
4818
  const instance = __sbGetDOInstance(cls, id);
4801
4819
  if (__sbIsFn(instance.alarm)) {
4802
- const res = instance.alarm();
4820
+ const res = __sbDOGated() ? __sbDOEnter(instance.__sbGate, () => instance.alarm()) : instance.alarm();
4803
4821
  if (res && __sbIsFn(res.then)) await res;
4804
4822
  if (instance.__sbTasks.length) await __sbDrainWaitUntil(instance.__sbTasks);
4805
4823
  }