@sproutboat/runtime 0.21.0 → 0.22.0

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,39 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3f95234: Add experimental bounded local LTX replication to the embedded SQLite transport.
8
+ Opt in with compatibility date 2026-10-22 and SB_LTX_ENABLE=1. A single writer
9
+ owns the database, commits are captured durably before checkpoint, and the WAL
10
+ has an enforced byte ceiling. Default behavior and the broker are unchanged.
11
+ Verified clean-checkpoint restarts reuse their generation without rewriting a base.
12
+
13
+ Developed with AI assistance from OpenAI Codex (GPT-6.1).
14
+
15
+ ## 0.21.1
16
+
17
+ ### Patch Changes
18
+
19
+ - 98800ca: The asynchronous `fetch()` and service-binding behaviour from 0.20.0 now starts at compatibility date `2026-10-22`. Before, it applied at every date, which broke apps that use the result without `await`, such as the CLI's kitchen-sink: `const r = fetch(url); r.body`.
20
+
21
+ From `2026-10-22`, in a deployed sprout:
22
+
23
+ - `fetch()` and `env.SERVICE.fetch()` return promises and run off the event loop;
24
+ - responses stream;
25
+ - redirects are followed;
26
+ - a callee's error is thrown in the caller;
27
+ - a callee sees its caller's URL.
28
+
29
+ With an earlier `compatibility_date`, a deployed sprout's `fetch()` and binding calls answer at once with a `Response`, as they did before 0.20.0. To get the new behaviour, move `compatibility_date` to `2026-10-22` and rebuild.
30
+
31
+ These stay ungated:
32
+
33
+ - sproutd answered with promises at every date, and still does;
34
+ - RPC calls are always asynchronous;
35
+ - abort signals, `Set-Cookie` and `Content-Type` work at every date.
36
+
3
37
  ## 0.21.0
4
38
 
5
39
  ### Minor Changes
@@ -0,0 +1,124 @@
1
+ # Experimental local LTX replication
2
+
3
+ AI assistance: OpenAI Codex (GPT-6.1).
4
+
5
+ This feature is for an embedded D1 database owned by one native executable.
6
+ Enable it with compatibility date `2026-10-22` or later, `SB_LTX_ENABLE=1`,
7
+ and `SB_LTX_DATABASE=DB` (the exact D1 binding name). Earlier compatibility dates,
8
+ unset flags, other bindings and the broker keep their existing behavior.
9
+ No Node or Bun runtime is needed by the executable.
10
+
11
+ ## Capture and bounds
12
+
13
+ SQLite uses WAL, synchronous FULL, no auto-checkpoint, and exclusive locking.
14
+ An advisory lock also rejects another copy of the executable using the same
15
+ path. Other SQLite connections, including readers, are excluded while it runs.
16
+ Do not use this feature with the SO_REUSEPORT multi-process deployment recipe.
17
+ The raw page-read descriptor stays open for the SQLite connection's lifetime:
18
+ closing any descriptor for the database inode would release process-level POSIX
19
+ locks, even if SQLite's own descriptor remained open.
20
+
21
+ The commit hook validates the WAL header, salts and rolling checksum through the
22
+ commit frame. It sorts a bounded array of `(pgno, frame)` entries and emits the
23
+ newest version of each page in ascending order. The prior database checksum is
24
+ updated using the old page version from the main file or the captured WAL prefix.
25
+ Truncation removes the checksums of discarded pages. No full database buffer or
26
+ in-memory page-index map is retained.
27
+
28
+ Each commit creates one LTX v3 file (magic `LTX1`), with a single logical TXID,
29
+ pre-apply checksum, committed database size, salts, and WAL frame range. TXIDs
30
+ start at one inside each generation; they are backup sequence numbers, not an
31
+ application transaction ID. The base contains exact checkpointed live pages
32
+ under a read transaction. It does not use VACUUM INTO. Page compression currently
33
+ uses interoperable literal-only LZ4 blocks; these are fast and have bounded memory,
34
+ but do not reduce file size. The prototype's JS word-based LZ4 compressor is a
35
+ separate path, not a claim that this C writer compresses data.
36
+
37
+ `SB_LTX_MAX_WAL_BYTES` defaults to 16 MiB, with an allowed range of 128 KiB to
38
+ 1 GiB. A VFS wrapper rejects a WAL write or truncate that would cross the ceiling
39
+ with SQLITE_FULL, including inside a single huge transaction. That transaction
40
+ rolls back. Reduce transaction size to fit the ceiling; this implementation does
41
+ not split a transaction. Frame metadata is bounded by that same ceiling, about
42
+ 8 bytes per WAL frame. SQLite's normal page cache remains capped at 2 MiB.
43
+
44
+ After each statement resets, checkpoint TRUNCATE runs. Multi-statement `exec`
45
+ steps and finalizes each statement separately under this opt-in, so every commit
46
+ gets a checkpoint opportunity. Inside an explicit open transaction, BUSY/LOCKED
47
+ can defer checkpointing; the VFS ceiling still applies. SQL that changes the WAL,
48
+ locking, synchronization or page-size invariants, or attaches another database,
49
+ is rejected by an authorizer. `DB.backup()` uses VACUUM INTO and is not supported
50
+ for this opted-in binding. Use the base LTX file for replication.
51
+
52
+ ## Files and durability
53
+
54
+ Files live under `<database-path>.ltx/epoch-XXXXXX/`. Completed names are
55
+ `<minTXID as 16 hex digits>-<maxTXID as 16 hex digits>.ltx`.
56
+ The encoder streams one page at a time and spools index records with `tmpfile()`.
57
+
58
+ For every LTX file:
59
+
60
+ 1. Write a `.tmp` file, flush it and fsync it.
61
+ 2. Publish the completed filename with an atomic, no-replace hard link, unlink
62
+ the temporary name, and fsync the generation directory. A finished file is
63
+ never replaced. This has rename-style publication with no overwrite race.
64
+ 3. Write a versioned, checksummed position record to `position.tmp`, fsync it,
65
+ rename it to `position`, and fsync the root directory. The record includes
66
+ generation, TXID, salts, next WAL byte offset, database checksum, WAL checksum
67
+ state, page size and committed page count.
68
+ 4. Only then checkpoint the source database. After checkpoint TRUNCATE succeeds,
69
+ atomically persist a second position record marking the database checkpointed.
70
+ The same file/directory fsync sequence applies to that record. A crash between
71
+ checkpoint and publication of the marker retains the conservative fallback.
72
+
73
+ Capture, fsync and publication failures latch the database closed to subsequent
74
+ operations until restart. The SQL commit has already happened when a hook error
75
+ is returned. A failed request can therefore represent a committed transaction;
76
+ applications must use idempotent transaction identifiers for retries.
77
+
78
+ ## Qualification status
79
+
80
+ Capture is tested against the exact inline C through `bun run test:native-ltx`,
81
+ including independent BigInt CRC and index verification, a 102-file chain,
82
+ byte-exact restore, integrity checking, oversized transaction rollback, database truncation, explicit rollback and late position-write failure latching.
83
+ The workspace prototype additionally tests the compiled Porffor executable,
84
+ including external writer exclusion and invariant PRAGMA rejection.
85
+ Restart validation now checks the position CRC, completed LTX checksum, WAL
86
+ header/salts, commit frame and cumulative frame checksum. A matching prefix
87
+ resumes in the same generation; any later complete commits are captured before
88
+ serving. Incomplete/torn trailing WAL frames are excluded. An orphan finished
89
+ file ahead of position forces a new generation, preserving the old files.
90
+ Missing, truncated, reset or mismatched WAL takes a fresh page-exact base when
91
+ it contradicts the recorded state. Position format LTXPOS2 distinguishes W
92
+ (captured WAL prefix) from C (checkpoint complete, expected absent/empty WAL).
93
+ A C record also requires a valid completed LTX, unchanged file size and matching
94
+ full database page checksum before the generation is reused. Clean restarts do
95
+ not create a base or rewrite the position. Any unexpected nonempty WAL, offline
96
+ database modification or record mismatch retains the fresh-base fallback.
97
+ Older LTXPOS1 records are conservatively replaced by a new base on first start.
98
+ All state and file descriptors are still created lazily on first DB open.
99
+
100
+ Verification is a bounded streaming read, but it is O(database size), not constant
101
+ time. It also checks the last LTX file. Five native restarts each at 4.2 MB and
102
+ 51.1 MB retained the same generation and unchanged base inode/mtime and position.
103
+ The 51.1 MB verification took 399 to 446 ms on macOS arm64 (plus about 13 ms to
104
+ health readiness); this is not a 9 ms cold-start claim for large databases.
105
+
106
+ The regression SIGKILLs at ten durability boundaries, including both sides of
107
+ clean-marker publication, and verifies matching-prefix resume and fallback cases.
108
+ It also recovers a committed tail beyond the durable cursor. The full Porffor executable passed 200 seeded random SIGKILL iterations on macOS
109
+ arm64: 1,675 acknowledged writes retained, 88 same-generation resumes and 112
110
+ fresh-base recoveries. Every finished chain passed file/database CRC verification,
111
+ integrity_check, deterministic source comparison at the last durable TXID, and
112
+ comparison after restart. The test also recovered commits whose HTTP response
113
+ was interrupted. Evidence and the runnable harness are in the linked prototype
114
+ workspace; the self-contained durability-boundary regression runs in this repo's
115
+ native CI. These are process-crash tests, not power-cut or storage-failure tests.
116
+ Linux CI exercises the same C implementation; a randomized full executable Linux
117
+ campaign and a power-loss campaign are not yet qualified.
118
+
119
+ No uploader is included. Finished local files are retained without a retention
120
+ policy; disk usage of the backup chain is not bounded by the WAL ceiling.
121
+
122
+ Format and API references: [SQLite WAL](https://www.sqlite.org/fileformat2.html#walformat),
123
+ [commit hook](https://www.sqlite.org/c3ref/wal_hook.html),
124
+ [checkpoint](https://www.sqlite.org/c3ref/wal_checkpoint_v2.html).
package/README.md CHANGED
@@ -78,3 +78,10 @@ generation with its matching size and etag.
78
78
  Unfinished uploads expire after seven days. The broker removes expired parts
79
79
  when another multipart upload starts; a standalone binary removes them when it
80
80
  initializes its embedded store.
81
+
82
+ ## Experimental SQLite replication
83
+
84
+ The embedded transport can capture bounded, durable local LTX files for one D1
85
+ binding. It requires explicit opt-in and compatibility date 2026-10-22 or later.
86
+ See [LTX replication](LTX_REPLICATION.md) for bounds, recovery, file durability,
87
+ qualification evidence and current limitations. No remote uploader is included.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "license": "MIT",
5
- "type": "module",
6
5
  "repository": {
7
6
  "type": "git",
8
7
  "url": "git+https://github.com/baronunread/sproutboat-packages.git",
9
8
  "directory": "packages/runtime"
10
9
  },
10
+ "type": "module",
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "scripts": {
@@ -5218,24 +5218,19 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5218
5218
 
5219
5219
  // #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
5220
5220
  // resolves the target itself and forwards it internally, so this is not
5221
- // egress and is not subject to the private-address block. Always a promise, as
5222
- // in Workers; on an async host the caller keeps serving while the call runs,
5223
- // which is also what lets a deployment call itself without deadlocking.
5221
+ // egress and is not subject to the private-address block. From compatibility date
5222
+ // 2026-10-22 a promise, as in Workers: on an async host the caller keeps serving
5223
+ // while the call runs, which is also what lets a deployment call itself without
5224
+ // deadlocking. Before it, the Response comes back at once, as it always did, so a
5225
+ // handler that uses it without awaiting keeps working.
5224
5226
  for (let i = 0; i < (bindings.services || []).length; i++) {
5225
5227
  const binding = bindings.services[i].binding;
5226
5228
  const fetcher = {
5227
- async fetch(input, init) {
5229
+ fetch(input, init) {
5230
+ if (globalThis.__sbCompat >= "2026-10-22") return __sbServiceFetch(binding, input, init);
5228
5231
  const parts = __sbFetchParts(input, init);
5229
- if (parts.signal) parts.signal.throwIfAborted();
5230
- // A stream body is read to the end first: the wire carries a whole body.
5231
- let body = __sbUnwrapBody(parts.body);
5232
- if (body instanceof ReadableStream) body = await __sbCollectBody(body);
5233
- const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(body) };
5234
- const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
5235
- // The callee threw: as in workerd, its error is thrown here.
5236
- const uncaught = response.headers.get("x-sb-uncaught");
5237
- if (uncaught != null) throw __sbRemoteError(uncaught);
5238
- return response;
5232
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(parts.body) };
5233
+ return __sbUpstreamResponse(globalThis.__sbFetchUpstream("service.fetch", msg));
5239
5234
  },
5240
5235
  };
5241
5236
  // Any other name is an RPC method of the callee's WorkerEntrypoint (see __sbRpcCall).
@@ -5248,9 +5243,24 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5248
5243
  },
5249
5244
  });
5250
5245
  }
5251
-
5252
5246
  };
5253
5247
 
5248
+ // A service-binding fetch from compatibility date 2026-10-22: a promise, off the event
5249
+ // loop on an async host, following redirects, and throwing the callee's error.
5250
+ async function __sbServiceFetch(binding, input, init) {
5251
+ const parts = __sbFetchParts(input, init);
5252
+ if (parts.signal) parts.signal.throwIfAborted();
5253
+ // A stream body is read to the end first: the wire carries a whole body.
5254
+ let body = __sbUnwrapBody(parts.body);
5255
+ if (body instanceof ReadableStream) body = await __sbCollectBody(body);
5256
+ const msg = { binding, url: parts.url, method: parts.method, headers: parts.headers, ...__sbRequestBody(body) };
5257
+ const response = await __sbFetchFollowing("service.fetch", msg, "sproutboat service " + binding, parts.redirect, parts.signal);
5258
+ // The callee threw: as in workerd, its error is thrown here.
5259
+ const uncaught = response.headers.get("x-sb-uncaught");
5260
+ if (uncaught != null) throw __sbRemoteError(uncaught);
5261
+ return response;
5262
+ }
5263
+
5254
5264
  // The error a service-binding callee threw, from its x-sb-uncaught marker: the same
5255
5265
  // standard class where there is one, else an Error with the original name.
5256
5266
  function __sbRemoteError(marker) {
@@ -5381,7 +5391,7 @@ async function __sbRpcCall(binding, method, args) {
5381
5391
  ],
5382
5392
  body,
5383
5393
  };
5384
- const response = await __sbUpstream("service.fetch", msg, label, null);
5394
+ const response = await __sbUpstream("service.fetch", msg, label, null, true);
5385
5395
  const uncaught = response.headers.get("x-sb-uncaught");
5386
5396
  if (uncaught != null) throw __sbRemoteError(uncaught);
5387
5397
  if (response.headers.get("x-sb-rpc-result") !== "1") {
@@ -5462,8 +5472,8 @@ function __sbUpstreamResponse(r) {
5462
5472
  return response;
5463
5473
  }
5464
5474
 
5465
- // A streamed upstream response (from compatibility date 2026-10-21, where body is a
5466
- // stream anyway): the call resolves as soon as the head arrives, and the body is a
5475
+ // A streamed upstream response (from compatibility date 2026-10-22, with the other
5476
+ // asynchronous fetch() behaviour; body is a stream from 2026-10-21 anyway): the call resolves as soon as the head arrives, and the body is a
5467
5477
  // ReadableStream fed chunk by chunk as the broker passes them on. Each frame fires the
5468
5478
  // same callback, so it is an interval that the last frame clears. A reply that comes
5469
5479
  // whole (a broker from before streaming, or sproutd's embedded transport) is handled
@@ -5587,10 +5597,15 @@ function __sbFollowHop(op, msg, label, mode, signal, hops) {
5587
5597
  // callback alive until then. An aborted `signal` rejects with its reason and cancels
5588
5598
  // the exchange, which closes its broker connection: the broker stops the upstream
5589
5599
  // request, and the edge in turn stops the callee's.
5590
- function __sbUpstream(op, msg, label, signal) {
5600
+ // Promises from compatibility date 2026-10-22. Before it, a transport that always
5601
+ // answered at once (the broker's) still does, so handlers that use fetch()'s result
5602
+ // without awaiting keep working; sproutd's answered with promises at any date
5603
+ // (__sbAsyncAtAnyDate). `always` is for RPC, which has no older behaviour to keep.
5604
+ function __sbUpstream(op, msg, label, signal, always) {
5591
5605
  if (signal) signal.throwIfAborted();
5592
- if (!__sbHttpAsyncEnabled()) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
5593
- if (globalThis.__sbCompat >= "2026-10-21") return __sbUpstreamStreamed(op, msg, label, signal);
5606
+ const promised = always || globalThis.__sbCompat >= "2026-10-22" || __sbAsyncAtAnyDate();
5607
+ if (!__sbHttpAsyncEnabled() || !promised) return __sbUpstreamResponse(globalThis.__sbFetchUpstream(op, msg));
5608
+ if (globalThis.__sbCompat >= "2026-10-22") return __sbUpstreamStreamed(op, msg, label, signal);
5594
5609
  return new Promise((resolve, reject) => {
5595
5610
  let onAbort = null;
5596
5611
  const settled = () => {
@@ -6164,7 +6179,8 @@ function __sbBindingCallIngress(request) {
6164
6179
  const vouched = !!want && headers.get("x-sb-binding-call") === "1" && __sbEqCt(headers.get("x-sb-edge-token") || "", want);
6165
6180
  if (vouched) {
6166
6181
  const url = headers.get("x-sb-url");
6167
- if (url && URL.canParse(url)) request.url = url;
6182
+ // The callee sees its caller's URL from compatibility date 2026-10-22, as in workerd.
6183
+ if (url && URL.canParse(url) && globalThis.__sbCompat >= "2026-10-22") request.url = url;
6168
6184
  headers.delete("x-sb-remote-addr");
6169
6185
  }
6170
6186
  for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
@@ -895,6 +895,11 @@ function __sbCancelAsync(asyncId) {
895
895
  `;
896
896
  }
897
897
 
898
+ // Before compatibility date 2026-10-22, fetch() through the broker answered at once.
899
+ function __sbAsyncAtAnyDate() {
900
+ return false;
901
+ }
902
+
898
903
  // Whether the host runs exchanges off its loop (it installed a waker).
899
904
  function __sbHttpAsyncEnabled() {
900
905
  let res = false;