@sproutboat/runtime 0.5.0 → 0.6.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,87 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 806c21e: Fix (baronunread/sproutboat#176): #172's bytestring UTF-8 encoding fix broke
8
+ the assets binding, which reads a file's bytes off disk into a `bytestring`
9
+ specifically so they pass through the wire untouched — a `bytestring`
10
+ carrying real text a handler built and one carrying opaque file bytes are the
11
+ same type with no way to tell them apart, so #172's blanket encoding also
12
+ re-encoded already-finished bytes, corrupting any binary asset (and any
13
+ proxied `fetch()`/service-binding response body).
14
+
15
+ Adds a second, dedicated `porf_native_fetch_read_raw_bytes` (zero-copy,
16
+ bytestring-only, no encoding, ever) alongside the untouched original —
17
+ `porf_native_fetch_read_value` is called from ~30 places across sproutboat's
18
+ own inline C, all genuine text, so its signature and behavior stay exactly as
19
+ #172 left them. `write_response_value` picks between the two based on a new
20
+ reserved `x-sb-raw-body` response header (same pattern as #163's
21
+ `x-sb-remote-addr`), which the assets binding, outbound `fetch()`, and
22
+ service-binding `fetch()` now set — the three places sproutboat's own
23
+ prelude constructs a Response from wire bytes rather than a string a handler
24
+ built. Verified end-to-end: a 256-byte all-values fixture now round-trips
25
+ byte-for-byte through a real standalone build's assets binding, and #172's
26
+ original UTF-8 fix still holds for genuine text.
27
+
28
+ Not fixed here: `env.<R2>.get()`'s `.body` is exposed directly to handler
29
+ code, which then constructs its own `new Response(obj.body)` — no
30
+ sproutboat-owned call site to attach the marker to, so R2 binary objects
31
+ served this way carry the same corruption. Needs either a real binary body
32
+ type upstream in Porffor or a different API shape; tracked as a known gap on
33
+ #176, not resolved by this fix.
34
+
35
+ ## 0.6.0
36
+
37
+ ### Minor Changes
38
+
39
+ - ae8c7a8: `ctx.waitUntil` (baronunread/sproutboat#57): `fetch(request, ctx)` now takes a
40
+ second argument with `waitUntil(promise)`. Registered promises are drained,
41
+ in-process and sequentially, after the handler returns and before the turn
42
+ completes, capped at 25s for the whole batch — a task still running past that
43
+ keeps running, but stops holding up the response. A rejected task is
44
+ swallowed rather than failing the response.
45
+
46
+ `DurableObjectState.waitUntil` was a silent no-op stub; it now actually queues
47
+ and drains the same way, scoped to the instance. `alarm()` also used to be
48
+ fire-and-forget (its promise and any `state.waitUntil()` it queued were
49
+ dropped the moment the 204 went out) — both are now awaited.
50
+
51
+ Additive: a handler that ignores the second `fetch` argument is unaffected.
52
+ - ae8c7a8: `ctx.waitUntil` for `scheduled` and `queue` (baronunread/sproutboat#171):
53
+ both now take the same second `ctx` argument as `fetch`, drained the same way.
54
+
55
+ Also fixes a real bug found while wiring it up: `scheduled` and `queue` were
56
+ fire-and-forget regardless of `ctx` — an async handler's own promise was
57
+ dropped the instant the reply went out. For `queue` specifically, the default
58
+ "unhandled messages are acked" pass ran synchronously right after the
59
+ (discarded) call to the handler, so `ack()`/`retry()` calls made after the
60
+ handler's first `await` never reached the response. Both are now properly
61
+ awaited, on every delivery path: broker-dispatched, and the embedded
62
+ standalone binary's own local cron/queue/DO-alarm timers (the latter had the
63
+ same gap for `DurableObjectState.waitUntil` from #57 — that local path
64
+ bypassed the fix there entirely).
65
+
66
+ Additive: a handler that ignores the second argument is unaffected.
67
+
68
+ ### Patch Changes
69
+
70
+ - 5b76f6b: Fix (baronunread/sproutboat#132): the banned-API capability check matched a bare
71
+ identifier (`\bprocess\b`), so a locally-declared `function process()` — zod v4
72
+ declares exactly that — failed the check as readily as a real read of the Node
73
+ global. Now requires a member access (`process.env`, no whitespace around the
74
+ `.`, to avoid matching a sentence like "...unique to this process. The...") or
75
+ `new Buffer(...)`; `node:` now only matches as the start of a quoted string
76
+ (specifier-shaped), not as a substring anywhere. A handler that imports zod (or
77
+ anything built on it, e.g. better-auth) and only uses APIs the compiler
78
+ otherwise supports now passes the capability check and builds.
79
+
80
+ Not fixed here: zod still throws an uncaught `TypeError` at runtime on
81
+ `.safeParse()` even for the simplest schema (`z.string()`) — a separate,
82
+ deeper Porffor compatibility gap this change does not touch. This fix removes
83
+ an incorrect early rejection; it does not make zod usable end to end.
84
+
3
85
  ## 0.5.0
4
86
 
5
87
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1140,6 +1140,11 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1140
1140
  const headers = {};
1141
1141
  if (r.type) headers["content-type"] = r.type;
1142
1142
  if (r.found) headers["etag"] = '"' + r.hash + '"';
1143
+ // #176: these bytes came off disk already-finished (UTF-8 text or
1144
+ // binary, doesn't matter which) -- the reserved x-sb-raw-body header
1145
+ // tells the C write path to pass them through untouched instead of
1146
+ // re-encoding as if this were a JS string a handler built.
1147
+ if (r.body != null) headers["x-sb-raw-body"] = "1";
1143
1148
  return new Response(r.body == null ? "" : r.body, { status: r.status || (r.found ? 200 : 404), headers });
1144
1149
  },
1145
1150
  };
@@ -1168,6 +1173,10 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1168
1173
  });
1169
1174
  const respHeaders = new Headers();
1170
1175
  for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
1176
+ // #176: wire bytes from another deployment, not a string this handler
1177
+ // built -- must not be re-encoded if the handler proxies it straight
1178
+ // through (see the assets binding for the same reasoning).
1179
+ if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
1171
1180
  return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
1172
1181
  },
1173
1182
  };
@@ -1190,6 +1199,10 @@ globalThis.__sbInstallBindings = function (target, bindings) {
1190
1199
  });
1191
1200
  const respHeaders = new Headers();
1192
1201
  for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
1202
+ // #176: bytes from an outbound HTTP response, not a string this handler
1203
+ // built -- must not be re-encoded if the handler proxies it straight
1204
+ // through (see the assets binding for the same reasoning).
1205
+ if (r.body != null) respHeaders.set("x-sb-raw-body", "1");
1193
1206
  return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
1194
1207
  };
1195
1208
  }
@@ -1248,7 +1261,10 @@ function __sbMakeDONamespace(binding, className) {
1248
1261
  req = new Request(url, { method: opts.method || "GET", headers });
1249
1262
  if (opts.body != null) req.body = String(opts.body);
1250
1263
  }
1251
- return __sbGetDOInstance(className, idStr).fetch(req);
1264
+ const inst = __sbGetDOInstance(className, idStr);
1265
+ const res = inst.fetch(req);
1266
+ if (inst.__sbTasks.length || (res && __sbIsFn(res.then))) return __sbFetchWithDrain(res, inst.__sbTasks);
1267
+ return res;
1252
1268
  },
1253
1269
  };
1254
1270
  },
@@ -1267,6 +1283,11 @@ function __sbGetDOInstance(cls, id) {
1267
1283
  const cacheKey = cls + " " + id;
1268
1284
  let inst = __sbDOInstances[cacheKey];
1269
1285
  if (!inst) {
1286
+ // #57 — was a no-op: a DO calling `state.waitUntil(p)` had `p` vanish with
1287
+ // no error. The instance outlives any one call (cached in
1288
+ // `__sbDOInstances`), so the queue lives on `state` too and is drained by
1289
+ // whichever call site invoked `fetch`/`alarm` this turn.
1290
+ const __sbTasks = [];
1270
1291
  const state = {
1271
1292
  id: {
1272
1293
  toString() {
@@ -1277,9 +1298,12 @@ function __sbGetDOInstance(cls, id) {
1277
1298
  blockConcurrencyWhile(fn) {
1278
1299
  return fn();
1279
1300
  },
1280
- waitUntil() {},
1301
+ waitUntil(p) {
1302
+ if (p && __sbIsFn(p.then)) __sbTasks.push(p);
1303
+ },
1281
1304
  };
1282
1305
  inst = new Ctor(state, globalThis.env);
1306
+ inst.__sbTasks = __sbTasks;
1283
1307
  __sbDOInstances[cacheKey] = inst;
1284
1308
  }
1285
1309
  return inst;
@@ -1488,6 +1512,71 @@ function __sbClientIp(request) {
1488
1512
  return peer;
1489
1513
  }
1490
1514
 
1515
+ // #57 — ctx.waitUntil: work that must run before the turn completes but must
1516
+ // not block the response the handler already built. `waitUntil(p)` just
1517
+ // records `p`; nothing awaits it until the handler itself has returned.
1518
+ //
1519
+ // The wall-clock cap #25 was meant to gate this on turned out not to apply:
1520
+ // #25 was per-tenant abuse control (rate limits, cgroup isolation against a
1521
+ // hostile *other* caller) and was closed as not-planned — this deployment
1522
+ // model is single-admin, nobody else's handler to isolate from. A drain that
1523
+ // never lets go of the worker slot is a local bug either way, so it gets its
1524
+ // own timeout rather than waiting on that. Kept under the edge's own
1525
+ // `SPROUTBOAT_REQUEST_TIMEOUT_MS` (30s default, services/edge/src/main.ts) so
1526
+ // the sprout still gets to answer before the edge gives up on it.
1527
+ //
1528
+ // ponytail: drained sequentially, one timeout for the whole batch (not one
1529
+ // per task — N tasks should not cost N timeouts). A task still running past
1530
+ // the cap keeps running; nothing here can cancel a promise it didn't create.
1531
+ // No Promise.all (unproven in this runtime, sequential is fine at this
1532
+ // volume). A rejected task is swallowed rather than failing the response:
1533
+ // dropping a background task's error beats dropping the response it doesn't
1534
+ // own.
1535
+ const __SB_WAITUNTIL_TIMEOUT_MS = 25000;
1536
+
1537
+ function __sbTimeout(ms) {
1538
+ return new Promise((resolve) => setTimeout(resolve, ms));
1539
+ }
1540
+
1541
+ async function __sbDrainWaitUntil(tasks) {
1542
+ if (!tasks.length) return;
1543
+ const pending = tasks.splice(0, tasks.length);
1544
+ await Promise.race([__sbDrainAll(pending), __sbTimeout(__SB_WAITUNTIL_TIMEOUT_MS)]);
1545
+ }
1546
+
1547
+ async function __sbDrainAll(pending) {
1548
+ for (let i = 0; i < pending.length; i++) {
1549
+ try {
1550
+ await pending[i];
1551
+ } catch {
1552
+ /* a background task's rejection must not fail the response */
1553
+ }
1554
+ }
1555
+ }
1556
+
1557
+ // Tail-called from `__sbEntry`/DO dispatch so the promise this returns is the
1558
+ // one *this* async function creates, not a `.then()` derived from someone
1559
+ // else's — see the note below on why that distinction matters here.
1560
+ async function __sbFetchWithDrain(res, tasks) {
1561
+ const r = res && __sbIsFn(res.then) ? await res : res;
1562
+ await __sbDrainWaitUntil(tasks);
1563
+ return r;
1564
+ }
1565
+
1566
+ // Shared by alarm and scheduled: both reply with an empty 204 once their
1567
+ // handler (and anything it queued via `waitUntil`) has settled.
1568
+ async function __sb204WithDrain(res, tasks) {
1569
+ if (res && __sbIsFn(res.then)) await res;
1570
+ await __sbDrainWaitUntil(tasks);
1571
+ return new Response("", { status: 204 });
1572
+ }
1573
+
1574
+ async function __sbQueueWithDrain(result, tasks) {
1575
+ const r = result && __sbIsFn(result.then) ? await result : result;
1576
+ await __sbDrainWaitUntil(tasks);
1577
+ return new Response(JSON.stringify(r), { headers: { "content-type": "application/json" } });
1578
+ }
1579
+
1491
1580
  globalThis.__sbEntry = function (handlers, request) {
1492
1581
  const trigger = request.headers.get("x-sb-trigger");
1493
1582
  if (!trigger) {
@@ -1506,8 +1595,16 @@ globalThis.__sbEntry = function (handlers, request) {
1506
1595
  // tag on hangs the request forever. cpuMs is documented as absent for
1507
1596
  // async handlers (see LogEvent in services/edge) — that is this.
1508
1597
  const __t0 = __sbCpuMs();
1509
- const __res = handlers.fetch(request);
1510
- if (__res && __sbIsFn(__res.then)) return __res;
1598
+ const __sbTasks = [];
1599
+ const ctx = {
1600
+ waitUntil(p) {
1601
+ if (p && __sbIsFn(p.then)) __sbTasks.push(p);
1602
+ },
1603
+ };
1604
+ const __res = handlers.fetch(request, ctx);
1605
+ // Same promise-identity rule as above applies to `__sbFetchWithDrain`'s
1606
+ // own return, which is why it's tail-called rather than chained on here.
1607
+ if (__sbTasks.length || (__res && __sbIsFn(__res.then))) return __sbFetchWithDrain(__res, __sbTasks);
1511
1608
  return __sbTagCpu(__res, __t0);
1512
1609
  }
1513
1610
  if (!__sbTriggerAuthed(request)) return new Response("forbidden", { status: 403 });
@@ -1515,21 +1612,50 @@ globalThis.__sbEntry = function (handlers, request) {
1515
1612
  if (trigger === "scheduled") {
1516
1613
  if (!__sbIsFn(handlers.scheduled)) return new Response("no scheduled handler", { status: 404 });
1517
1614
  const body = __sbReadJson(request);
1518
- handlers.scheduled({ cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} });
1615
+ // #171 — ctx.waitUntil, same shape as fetch's. Was also fire-and-forget
1616
+ // before this: an async `scheduled()`'s own promise was dropped the
1617
+ // moment 204 went out, same bug alarm() had.
1618
+ const __sbTasks = [];
1619
+ const ctx = {
1620
+ waitUntil(p) {
1621
+ if (p && __sbIsFn(p.then)) __sbTasks.push(p);
1622
+ },
1623
+ };
1624
+ const __sres = handlers.scheduled(
1625
+ { cron: body.cron || "", scheduledTime: body.scheduledTime || Date.now(), noRetry() {} },
1626
+ ctx,
1627
+ );
1628
+ if (__sbTasks.length || (__sres && __sbIsFn(__sres.then))) return __sb204WithDrain(__sres, __sbTasks);
1519
1629
  return new Response("", { status: 204 });
1520
1630
  }
1521
1631
 
1522
1632
  if (trigger === "queue") {
1523
1633
  if (!__sbIsFn(handlers.queue)) return new Response("no queue handler", { status: 404 });
1524
- const result = __sbRunQueueBatch(handlers, __sbReadJson(request));
1525
- return new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
1634
+ // #171 same ctx.waitUntil shape. `__sbRunQueueBatch` was also
1635
+ // fire-and-forget for an async `queue()`: it computed the default
1636
+ // ack/retry and answered the broker before the handler's own awaits ran,
1637
+ // so any ack()/retry() past the first `await` never made it into the
1638
+ // response. It now awaits the handler itself when it returns a promise.
1639
+ const __sbTasks = [];
1640
+ const ctx = {
1641
+ waitUntil(p) {
1642
+ if (p && __sbIsFn(p.then)) __sbTasks.push(p);
1643
+ },
1644
+ };
1645
+ // Always a promise now (`__sbRunQueueBatch` is async, to await the
1646
+ // handler above), so always tail-call the drain wrapper — same
1647
+ // promise-identity reasoning as `__sbFetchWithDrain`.
1648
+ return __sbQueueWithDrain(__sbRunQueueBatch(handlers, __sbReadJson(request), ctx), __sbTasks);
1526
1649
  }
1527
1650
 
1528
1651
  if (trigger === "alarm") {
1529
1652
  const body = __sbReadJson(request);
1530
1653
  const inst = __sbGetDOInstance(String(body.cls || ""), String(body.id || ""));
1531
1654
  if (!__sbIsFn(inst.alarm)) return new Response("no alarm handler", { status: 404 });
1532
- inst.alarm();
1655
+ const __ares = inst.alarm();
1656
+ // Was fire-and-forget before: an async `alarm()` and any `state.waitUntil()`
1657
+ // it queued were both dropped the moment 204 went out. Await both.
1658
+ if (inst.__sbTasks.length || (__ares && __sbIsFn(__ares.then))) return __sb204WithDrain(__ares, inst.__sbTasks);
1533
1659
  return new Response("", { status: 204 });
1534
1660
  }
1535
1661
 
@@ -1543,7 +1669,7 @@ globalThis.__sbEntry = function (handlers, request) {
1543
1669
  * (deployed, and the phase-0 standalone launcher), or straight from the local
1544
1670
  * timer in an embedded binary that has no broker to be delivered from.
1545
1671
  */
1546
- function __sbRunQueueBatch(handlers, body) {
1672
+ async function __sbRunQueueBatch(handlers, body, ctx) {
1547
1673
  const acked = [];
1548
1674
  const retried = [];
1549
1675
  const raw = body.messages || [];
@@ -1574,7 +1700,11 @@ function __sbRunQueueBatch(handlers, body) {
1574
1700
  for (let i = 0; i < messages.length; i++) messages[i].retry();
1575
1701
  },
1576
1702
  };
1577
- handlers.queue(batch);
1703
+ const __qres = handlers.queue(batch, ctx);
1704
+ // Was fire-and-forget: an async `queue()` had this default-ack pass run (and
1705
+ // the response go out) before its own `await`s did, so any ack()/retry()
1706
+ // past the first one never counted.
1707
+ if (__qres && __sbIsFn(__qres.then)) await __qres;
1578
1708
  // default: any message neither acked nor retried is treated as acked
1579
1709
  for (let i = 0; i < messages.length; i++) {
1580
1710
  if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
package/src/source.ts CHANGED
@@ -11,7 +11,20 @@ const alwaysForbidden: Array<[RegExp, string]> = [
11
11
  [/\bimport\s*\(/, "dynamic import() is not supported: nothing can resolve it at build time"],
12
12
  [/\brequire\s*\(/, "CommonJS require is not supported"],
13
13
  [/\b(WebSocket|XMLHttpRequest)\s*\(/, "WebSocket / XMLHttpRequest are not supported"],
14
- [/\b(process|Bun|Deno|Buffer|node:)\b/, "Node, Bun, and Deno APIs are not supported"],
14
+ // baronunread/sproutboat#132 a bare identifier match flags a *local*
15
+ // `function process()` as readily as the global: zod v4 declares exactly
16
+ // that (its internal `process(schema, ctx)`), so importing zod alone used
17
+ // to fail this check pointing at code the handler author never wrote. A
18
+ // Node/Bun/Deno API is always reached through a member access or `new`;
19
+ // requiring that shape lets a local binding of the same name through.
20
+ // No whitespace is allowed around the `.`: real member access never has
21
+ // any (`process.env`, always contiguous), while prose mentioning the word
22
+ // does (a bundled comment ending "...unique to this process. The id...").
23
+ [/\b(process|Bun|Deno|Buffer)\.[a-zA-Z_$]|\bnew\s+Buffer\s*\(/, "Node, Bun, and Deno APIs are not supported"],
24
+ // `node:` only means something as an import specifier; a bare substring
25
+ // match would also flag it appearing in an ordinary string a dependency
26
+ // happens to construct (a doc link, a log message).
27
+ [/['"`]node:/, "Node, Bun, and Deno APIs are not supported"],
15
28
  // Porffor alpha-4 compiles `new Proxy(...)` and then ignores the handler: a
16
29
  // trapped property reads back as `undefined`, with no throw. Rejecting it
17
30
  // here is the difference between a build error and a 502 nobody can explain.
@@ -1668,7 +1668,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
1668
1668
  const store = __sbStore();
1669
1669
 
1670
1670
  if (crons.length > 0 && __sbIsFn(handlers.scheduled)) {
1671
- setInterval(function () {
1671
+ setInterval(async function () {
1672
1672
  const now = new Date();
1673
1673
  const stamp =
1674
1674
  now.getUTCFullYear() +
@@ -1684,14 +1684,24 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
1684
1684
  __sbLastCronTick = stamp;
1685
1685
  for (let i = 0; i < crons.length; i++) {
1686
1686
  if (__sbCronMatches(crons[i], now)) {
1687
- handlers.scheduled({ cron: crons[i], scheduledTime: now.getTime(), noRetry() {} });
1687
+ // #171 ctx.waitUntil, and stop dropping the handler's own promise
1688
+ // (was fire-and-forget, same bug the broker-dispatched path had).
1689
+ const __sbTasks = [];
1690
+ const ctx = {
1691
+ waitUntil(p) {
1692
+ if (p && __sbIsFn(p.then)) __sbTasks.push(p);
1693
+ },
1694
+ };
1695
+ const res = handlers.scheduled({ cron: crons[i], scheduledTime: now.getTime(), noRetry() {} }, ctx);
1696
+ if (res && __sbIsFn(res.then)) await res;
1697
+ if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
1688
1698
  }
1689
1699
  }
1690
1700
  }, 15000);
1691
1701
  }
1692
1702
 
1693
1703
  if (queues.length > 0 && __sbIsFn(handlers.queue)) {
1694
- setInterval(function () {
1704
+ setInterval(async function () {
1695
1705
  __sbEnsureSchema();
1696
1706
  const now = Date.now();
1697
1707
  for (let q = 0; q < queues.length; q++) {
@@ -1709,7 +1719,19 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
1709
1719
  __sbSql(store, "UPDATE mq SET visible_at = ? WHERE id = ?", [now + 30000, row[0]]);
1710
1720
  messages.push({ id: row[0], body: row[1], timestamp: now, attempts: Number(row[2]) + 1 });
1711
1721
  }
1712
- const result = __sbRunQueueBatch(handlers, { queue: name, messages });
1722
+ // #171 same ctx.waitUntil as the broker-delivered path, and the same
1723
+ // fix for the handler's own promise: `__sbRunQueueBatch` used to be
1724
+ // called fire-and-forget here too, so an async `queue()`'s ack/retry
1725
+ // calls made after its first `await` never reached these SQL writes.
1726
+ const __sbTasks = [];
1727
+ const ctx = {
1728
+ waitUntil(p) {
1729
+ if (p && __sbIsFn(p.then)) __sbTasks.push(p);
1730
+ },
1731
+ };
1732
+ let result = __sbRunQueueBatch(handlers, { queue: name, messages }, ctx);
1733
+ if (result && __sbIsFn(result.then)) result = await result;
1734
+ if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
1713
1735
  for (let i = 0; i < result.ack.length; i++) {
1714
1736
  __sbSql(store, "DELETE FROM mq WHERE id = ?", [result.ack[i]]);
1715
1737
  }
@@ -1725,7 +1747,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
1725
1747
  }
1726
1748
 
1727
1749
  if (dos.length > 0) {
1728
- setInterval(function () {
1750
+ setInterval(async function () {
1729
1751
  __sbEnsureSchema();
1730
1752
  const now = Date.now();
1731
1753
  const due = __sbSql(store, "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10", [
@@ -1738,7 +1760,14 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
1738
1760
  // afterwards would erase it. Same rule as the broker (#125).
1739
1761
  __sbSql(store, "DELETE FROM do_alarm WHERE cls = ? AND id = ?", [cls, id]);
1740
1762
  const instance = __sbGetDOInstance(cls, id);
1741
- if (__sbIsFn(instance.alarm)) instance.alarm();
1763
+ if (__sbIsFn(instance.alarm)) {
1764
+ // #57 gap: this local path bypasses `__sbEntry`'s alarm dispatch
1765
+ // entirely, so it kept the old fire-and-forget behaviour after that
1766
+ // fix landed. Same treatment: await alarm() and drain its waitUntil.
1767
+ const res = instance.alarm();
1768
+ if (res && __sbIsFn(res.then)) await res;
1769
+ if (instance.__sbTasks.length) await __sbDrainWaitUntil(instance.__sbTasks);
1770
+ }
1742
1771
  }
1743
1772
  }, 500);
1744
1773
  }