@sproutboat/runtime 0.22.0 → 0.23.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 +44 -0
- package/package.json +1 -1
- package/src/d1-shim.test.js +114 -0
- package/src/native-fetch-prelude.js +154 -21
- package/src/transport-embedded.js +182 -32
- package/test/native-d1.ts +283 -0
- package/test/native-fetch-request.ts +305 -0
- package/test/native-ltx.ts +59 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { createBroker, listen } from "../../wire/src/broker";
|
|
7
|
+
import { EMPTY_BINDINGS, preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
8
|
+
|
|
9
|
+
// fetch(Request), fetch(Request, init) and new Request(request, init) on a compiled sprout,
|
|
10
|
+
// through the broker transport, against what workerd does (the platform's
|
|
11
|
+
// tests/service-bindings suite holds workerd's answers). The same handler is compiled at
|
|
12
|
+
// compatibility date 2026-10-23, where Request follows Fetch as workerd does, and at
|
|
13
|
+
// 2026-10-22, where an app keeps the older behaviour: a copied Request shares its body,
|
|
14
|
+
// headers stay mutable and any method name passes.
|
|
15
|
+
const handler = `
|
|
16
|
+
const textStream = (...parts) =>
|
|
17
|
+
new ReadableStream({
|
|
18
|
+
start(c) {
|
|
19
|
+
for (const p of parts) c.enqueue(new TextEncoder().encode(p));
|
|
20
|
+
c.close();
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
const attempt = async (step) => {
|
|
24
|
+
try {
|
|
25
|
+
return await step();
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return e.name + ": " + e.message;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
async fetch(request, env) {
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
const up = url.searchParams.get("up");
|
|
35
|
+
const echo = async (res) => res.json();
|
|
36
|
+
if (url.pathname === "/ping") return new Response("pong");
|
|
37
|
+
if (url.pathname === "/fetch") {
|
|
38
|
+
const viaRequest = await echo(await fetch(new Request(up + "/echo", { method: "PUT", headers: { "x-from": "request" }, body: "req body" })));
|
|
39
|
+
const plusInit = await echo(
|
|
40
|
+
await fetch(new Request(up + "/echo", { method: "PUT", headers: { "x-from": "request" }, body: "req body" }), { method: "PATCH", headers: { "x-from": "init" } }),
|
|
41
|
+
);
|
|
42
|
+
const initBody = await echo(await fetch(new Request(up + "/echo", { method: "PUT", body: "req body" }), { body: "init body" }));
|
|
43
|
+
const stream = await echo(await fetch(new Request(up + "/echo", { method: "POST", body: textStream("in ", "a request"), duplex: "half" })));
|
|
44
|
+
const initStream = await echo(await fetch(up + "/echo", { method: "POST", body: textStream("in ", "init"), duplex: "half" }));
|
|
45
|
+
const initRequest = new Request("https://other/", { method: "POST", headers: { "x-from": "init-request" }, body: "init req" });
|
|
46
|
+
const asInit = { ...(await echo(await fetch(up + "/echo", initRequest))), initUsed: initRequest.bodyUsed };
|
|
47
|
+
const manual = (await fetch(new Request(up + "/redirect", { redirect: "manual" }))).status;
|
|
48
|
+
const aborted = await attempt(() => fetch(new Request(up + "/echo", { signal: AbortSignal.abort() })).then((r) => r.status));
|
|
49
|
+
const once = new Request(up + "/echo", { method: "POST", body: "once" });
|
|
50
|
+
const first = (await echo(await fetch(once))).body;
|
|
51
|
+
const usedUp = once.bodyUsed;
|
|
52
|
+
const again = await attempt(() => fetch(once).then(echo).then((e) => e.body));
|
|
53
|
+
const getBody = await attempt(() => fetch(up + "/echo", { body: "x" }).then((r) => r.status));
|
|
54
|
+
return Response.json({ viaRequest, plusInit, initBody, stream, initStream, asInit, manual, aborted, first, usedUp, again, getBody });
|
|
55
|
+
}
|
|
56
|
+
if (url.pathname === "/forward") {
|
|
57
|
+
// The incoming request's raw bytes reach the upstream intact.
|
|
58
|
+
const copied = await echo(await fetch(new Request(up + "/echo", request)));
|
|
59
|
+
return Response.json({ copied, incomingUsed: request.bodyUsed });
|
|
60
|
+
}
|
|
61
|
+
if (url.pathname === "/request") {
|
|
62
|
+
const a = new Request("https://peer/a", { method: "POST", headers: { "x-a": "1" }, body: "abc" });
|
|
63
|
+
const b = new Request(a, { headers: { "x-b": "2" } });
|
|
64
|
+
const copy = { aUsed: a.bodyUsed, aRead: await attempt(() => a.text()), b: await b.text(), headers: [...b.headers] };
|
|
65
|
+
const s = new Request("https://peer/s", { method: "POST", body: textStream("tee ", "me"), duplex: "half" });
|
|
66
|
+
const t = s.clone();
|
|
67
|
+
const tee = [await t.text(), await s.text()];
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const signalled = new Request(new Request("https://peer/", { signal: controller.signal }));
|
|
70
|
+
controller.abort();
|
|
71
|
+
const res = await fetch(url.searchParams.get("up") + "/echo");
|
|
72
|
+
await res.text();
|
|
73
|
+
return Response.json({
|
|
74
|
+
copy,
|
|
75
|
+
tee,
|
|
76
|
+
signal: [signalled.signal.aborted, new Request("https://peer/").signal.aborted],
|
|
77
|
+
getBody: await attempt(() => new Request("https://peer/", { body: "" }).method),
|
|
78
|
+
method: await attempt(() => new Request("https://peer/", { method: "custom" }).method),
|
|
79
|
+
redirect: await attempt(() => new Request("https://peer/", { redirect: "error" }).redirect),
|
|
80
|
+
incoming: await attempt(() => (request.headers.set("x-t", "1"), "mutable")),
|
|
81
|
+
fetched: await attempt(() => (res.headers.set("x-t", "1"), "mutable")),
|
|
82
|
+
copiedResponse: await attempt(() => (new Response(null, res).headers.set("x-t", "1"), "mutable")),
|
|
83
|
+
incomingBody: request.body === null ? "none" : typeof request.body,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return new Response("no route", { status: 404 });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
function freePort(): number {
|
|
92
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
93
|
+
const port = listener.port;
|
|
94
|
+
listener.stop(true);
|
|
95
|
+
return port;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const upstream = Bun.serve({
|
|
99
|
+
hostname: "127.0.0.1",
|
|
100
|
+
port: 0,
|
|
101
|
+
async fetch(request) {
|
|
102
|
+
const url = new URL(request.url);
|
|
103
|
+
if (url.pathname === "/redirect") return new Response(null, { status: 302, headers: { location: "/echo?hop=1" } });
|
|
104
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
105
|
+
let binaryIntact = bytes.length === 256;
|
|
106
|
+
for (let i = 0; binaryIntact && i < 256; i++) binaryIntact = bytes[i] === i;
|
|
107
|
+
return Response.json({
|
|
108
|
+
method: request.method,
|
|
109
|
+
from: request.headers.get("x-from"),
|
|
110
|
+
type: request.headers.get("content-type"),
|
|
111
|
+
body: binaryIntact ? "bytes 0-255" : new TextDecoder().decode(bytes),
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const up = `http://127.0.0.1:${upstream.port}`;
|
|
116
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-fetch-request-"));
|
|
117
|
+
const broker = createBroker({
|
|
118
|
+
db: ":memory:",
|
|
119
|
+
token: "native-fetch-request",
|
|
120
|
+
bindings: { ...EMPTY_BINDINGS, resources: {} },
|
|
121
|
+
egressAllow: ["127.0.0.1"],
|
|
122
|
+
});
|
|
123
|
+
const brokerServer = listen(broker, "127.0.0.1", 0);
|
|
124
|
+
const servers: ReturnType<typeof Bun.spawn>[] = [];
|
|
125
|
+
|
|
126
|
+
// Compiles the handler at `date` and starts it; resolves to a GET helper for it.
|
|
127
|
+
async function start(porffor: string, node: string, source: string, date: string) {
|
|
128
|
+
const port = freePort();
|
|
129
|
+
const generated = join(workdir, `fetch-request-${date}.generated.js`);
|
|
130
|
+
const binary = join(workdir, `fetch-request-${date}`);
|
|
131
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, source, {}, EMPTY_BINDINGS, undefined, date));
|
|
132
|
+
const compile = Bun.spawn(
|
|
133
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
134
|
+
{
|
|
135
|
+
cwd: workdir,
|
|
136
|
+
stdout: "pipe",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
const [exitCode, stderr] = await Promise.all([compile.exited, new Response(compile.stderr).text()]);
|
|
141
|
+
assert.equal(exitCode, 0, `Porffor native compile at ${date} failed:\n${stderr}`);
|
|
142
|
+
const server = Bun.spawn([binary], {
|
|
143
|
+
cwd: workdir,
|
|
144
|
+
env: {
|
|
145
|
+
...process.env,
|
|
146
|
+
PORT: String(port),
|
|
147
|
+
SB_BROKER_PORT: String(brokerServer.port),
|
|
148
|
+
SB_BROKER_TOKEN: "native-fetch-request",
|
|
149
|
+
},
|
|
150
|
+
stdout: "ignore",
|
|
151
|
+
stderr: "inherit",
|
|
152
|
+
});
|
|
153
|
+
servers.push(server);
|
|
154
|
+
const call = async (path: string, init?: RequestInit) => {
|
|
155
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
156
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}${sep}up=${encodeURIComponent(up)}`, {
|
|
157
|
+
...init,
|
|
158
|
+
signal: AbortSignal.timeout(20_000),
|
|
159
|
+
});
|
|
160
|
+
const text = await res.text();
|
|
161
|
+
assert.equal(res.status, 200, `${date} ${path}: ${text}`);
|
|
162
|
+
return JSON.parse(text);
|
|
163
|
+
};
|
|
164
|
+
for (let i = 0; i < 50; i++) {
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`http://127.0.0.1:${port}/ping`);
|
|
167
|
+
if ((await res.text()) === "pong") return call;
|
|
168
|
+
} catch {
|
|
169
|
+
if (server.exitCode !== null) break;
|
|
170
|
+
await Bun.sleep(100);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw new Error(`compiled handler at ${date} did not start`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const porffor = await ensurePorffor({ cacheRoot: workdir });
|
|
178
|
+
const node = Bun.which("node");
|
|
179
|
+
if (!node) throw new Error("native fetch-request test needs node on PATH");
|
|
180
|
+
const [core, transport] = await Promise.all([
|
|
181
|
+
readFile(preludePath, "utf8"),
|
|
182
|
+
readFile(transportPath("broker"), "utf8"),
|
|
183
|
+
]);
|
|
184
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
185
|
+
const source = core.replace(TRANSPORT_MARKER, transport);
|
|
186
|
+
const [strict, legacy] = await Promise.all([
|
|
187
|
+
start(porffor, node, source, "2026-10-23"),
|
|
188
|
+
start(porffor, node, source, "2026-10-22"),
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
const echo = (method: string, from: string | null, type: string | null, body: string) => ({
|
|
192
|
+
method,
|
|
193
|
+
from,
|
|
194
|
+
type,
|
|
195
|
+
body,
|
|
196
|
+
});
|
|
197
|
+
const plain = "text/plain;charset=UTF-8";
|
|
198
|
+
const bytes = new Uint8Array(256).map((_, i) => i);
|
|
199
|
+
|
|
200
|
+
// From 2026-10-23: fetch() is fetch(new Request(input, init)), as in workerd.
|
|
201
|
+
assert.deepEqual(await strict("/fetch"), {
|
|
202
|
+
viaRequest: echo("PUT", "request", plain, "req body"),
|
|
203
|
+
plusInit: echo("PATCH", "init", null, "req body"),
|
|
204
|
+
initBody: echo("PUT", null, plain, "init body"),
|
|
205
|
+
stream: echo("POST", null, null, "in a request"),
|
|
206
|
+
initStream: echo("POST", null, null, "in init"),
|
|
207
|
+
asInit: { ...echo("POST", "init-request", plain, "init req"), initUsed: true },
|
|
208
|
+
manual: 302,
|
|
209
|
+
aborted: "AbortError: The operation was aborted",
|
|
210
|
+
first: "once",
|
|
211
|
+
usedUp: true,
|
|
212
|
+
again: "TypeError: Cannot reconstruct a Request with a used body.",
|
|
213
|
+
getBody: "TypeError: Request with a GET or HEAD method cannot have a body.",
|
|
214
|
+
});
|
|
215
|
+
assert.deepEqual(await strict("/forward", { method: "POST", body: bytes, headers: { "x-from": "client" } }), {
|
|
216
|
+
copied: echo("POST", "client", null, "bytes 0-255"),
|
|
217
|
+
incomingUsed: true,
|
|
218
|
+
});
|
|
219
|
+
assert.deepEqual(await strict("/request"), {
|
|
220
|
+
copy: {
|
|
221
|
+
aUsed: true,
|
|
222
|
+
aRead:
|
|
223
|
+
"TypeError: Body has already been used. It can only be used once. Use tee() first if you need to read it twice.",
|
|
224
|
+
b: "abc",
|
|
225
|
+
headers: [["x-b", "2"]],
|
|
226
|
+
},
|
|
227
|
+
tee: ["tee me", "tee me"],
|
|
228
|
+
signal: [true, false],
|
|
229
|
+
getBody: "TypeError: Request with a GET or HEAD method cannot have a body.",
|
|
230
|
+
method: "TypeError: Invalid HTTP method string: custom",
|
|
231
|
+
redirect:
|
|
232
|
+
'TypeError: Invalid redirect value, must be one of "follow" or "manual" ("error" won\'t be implemented since it does not make sense at the edge; use "manual" and check the response status code).',
|
|
233
|
+
incoming: "TypeError: Can't modify immutable headers.",
|
|
234
|
+
fetched: "TypeError: Can't modify immutable headers.",
|
|
235
|
+
copiedResponse: "mutable",
|
|
236
|
+
incomingBody: "none",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Before it: the Request's method, headers and body still reach the upstream (fixed at
|
|
240
|
+
// every date, as are stream bodies and raw bytes), but a Request can be sent twice, a
|
|
241
|
+
// copy leaves the source readable, and headers, methods and redirect modes are not checked.
|
|
242
|
+
const old = await legacy("/fetch");
|
|
243
|
+
assert.deepEqual(
|
|
244
|
+
{
|
|
245
|
+
viaRequest: old.viaRequest,
|
|
246
|
+
plusInit: old.plusInit,
|
|
247
|
+
stream: old.stream,
|
|
248
|
+
initStream: old.initStream,
|
|
249
|
+
manual: old.manual,
|
|
250
|
+
again: old.again,
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
viaRequest: echo("PUT", "request", plain, "req body"),
|
|
254
|
+
plusInit: echo("PATCH", "init", null, "req body"),
|
|
255
|
+
stream: echo("POST", null, null, "in a request"),
|
|
256
|
+
initStream: echo("POST", null, null, "in init"),
|
|
257
|
+
manual: 302,
|
|
258
|
+
again: "once",
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
assert.deepEqual(await legacy("/forward", { method: "POST", body: bytes, headers: { "x-from": "client" } }), {
|
|
262
|
+
copied: echo("POST", "client", null, "bytes 0-255"),
|
|
263
|
+
incomingUsed: false,
|
|
264
|
+
});
|
|
265
|
+
const oldRequest = await legacy("/request");
|
|
266
|
+
assert.deepEqual(
|
|
267
|
+
{
|
|
268
|
+
aUsed: oldRequest.copy.aUsed,
|
|
269
|
+
aRead: oldRequest.copy.aRead,
|
|
270
|
+
tee: oldRequest.tee,
|
|
271
|
+
signal: oldRequest.signal,
|
|
272
|
+
method: oldRequest.method,
|
|
273
|
+
redirect: oldRequest.redirect,
|
|
274
|
+
incoming: oldRequest.incoming,
|
|
275
|
+
fetched: oldRequest.fetched,
|
|
276
|
+
incomingBody: oldRequest.incomingBody,
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
aUsed: false,
|
|
280
|
+
aRead: "abc",
|
|
281
|
+
tee: ["tee me", "tee me"],
|
|
282
|
+
signal: [true, false],
|
|
283
|
+
method: "CUSTOM",
|
|
284
|
+
redirect: "error",
|
|
285
|
+
incoming: "mutable",
|
|
286
|
+
fetched: "mutable",
|
|
287
|
+
incomingBody: "object",
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
console.log(
|
|
292
|
+
"fetch(Request) and new Request(request, init) follow workerd from 2026-10-23 (body used up, init merging, " +
|
|
293
|
+
"GET/HEAD bodies, methods, redirect modes, signals, immutable headers); stream bodies, clone() tees and raw bytes at every date; " +
|
|
294
|
+
"2026-10-22 keeps the older Request semantics",
|
|
295
|
+
);
|
|
296
|
+
} finally {
|
|
297
|
+
for (const server of servers) {
|
|
298
|
+
server.kill(9);
|
|
299
|
+
await server.exited;
|
|
300
|
+
}
|
|
301
|
+
brokerServer.stop();
|
|
302
|
+
broker.close();
|
|
303
|
+
upstream.stop(true);
|
|
304
|
+
await rm(workdir, { recursive: true, force: true });
|
|
305
|
+
}
|
package/test/native-ltx.ts
CHANGED
|
@@ -247,6 +247,65 @@ try {
|
|
|
247
247
|
assert.deepEqual(await readdir(join(root, epoch)), finishedBefore, "idle restart must not rewrite a base");
|
|
248
248
|
assert.equal((await readdir(root)).filter((n) => n.startsWith("epoch-")).length, 1);
|
|
249
249
|
}
|
|
250
|
+
// A D1 batch() (__sbD1Batch) is a SAVEPOINT around its statements, each its own call,
|
|
251
|
+
// then RELEASE: one transaction, so one LTX file with the next TXID. A rolled-back batch
|
|
252
|
+
// (ROLLBACK TO, then RELEASE) commits the pages it touched back unchanged: at most one
|
|
253
|
+
// more LTX file, and the restored database has none of its rows.
|
|
254
|
+
{
|
|
255
|
+
const batchPath = join(work, "batch.sqlite");
|
|
256
|
+
await run(batchPath, "CREATE TABLE b(id INTEGER PRIMARY KEY,value TEXT);\n");
|
|
257
|
+
const batchRoot = batchPath + ".ltx";
|
|
258
|
+
const batchEpoch = (await readdir(batchRoot)).find((n) => n.startsWith("epoch-"));
|
|
259
|
+
assert(batchEpoch);
|
|
260
|
+
const ltxFiles = async () => (await readdir(join(batchRoot, batchEpoch))).filter((n) => n.endsWith(".ltx")).sort();
|
|
261
|
+
const before = await ltxFiles();
|
|
262
|
+
const lastTxid = BigInt("0x" + before[before.length - 1].split("-")[0]);
|
|
263
|
+
const replies = await run(
|
|
264
|
+
batchPath,
|
|
265
|
+
"SAVEPOINT sb_d1_batch;\nINSERT INTO b VALUES(1,'one');\nINSERT INTO b VALUES(2,'two');\nRELEASE sb_d1_batch;\n",
|
|
266
|
+
);
|
|
267
|
+
assert(
|
|
268
|
+
replies.every((n) => n.startsWith("0 ")),
|
|
269
|
+
replies.join("\n"),
|
|
270
|
+
);
|
|
271
|
+
const after = await ltxFiles();
|
|
272
|
+
const added = after.filter((n) => !before.includes(n));
|
|
273
|
+
assert.equal(added.length, 1, "a batch is one LTX file: " + added.join(", "));
|
|
274
|
+
const [minTxid, maxTxid] = added[0]
|
|
275
|
+
.replace(".ltx", "")
|
|
276
|
+
.split("-")
|
|
277
|
+
.map((h) => BigInt("0x" + h));
|
|
278
|
+
assert.equal(minTxid, lastTxid + 1n);
|
|
279
|
+
assert.equal(maxTxid, lastTxid + 1n);
|
|
280
|
+
const rolledBack = await run(
|
|
281
|
+
batchPath,
|
|
282
|
+
"SAVEPOINT sb_d1_batch;\nINSERT INTO b VALUES(3,'three');\nROLLBACK TO sb_d1_batch;\nRELEASE sb_d1_batch;\n",
|
|
283
|
+
);
|
|
284
|
+
assert(
|
|
285
|
+
rolledBack.every((n) => n.startsWith("0 ")),
|
|
286
|
+
rolledBack.join("\n"),
|
|
287
|
+
);
|
|
288
|
+
const afterRollback = await ltxFiles();
|
|
289
|
+
assert(afterRollback.length <= after.length + 1, "a rolled-back batch writes at most one LTX file");
|
|
290
|
+
// If it writes one, it holds page 1 only (SQLite bumps the header of the page it had
|
|
291
|
+
// dirtied: the file change counter and version-valid-for number, bytes 24-27 and 92-95,
|
|
292
|
+
// so the pre- and post-apply checksums differ); the rows are checked below.
|
|
293
|
+
for (const name of afterRollback.filter((n) => !after.includes(n))) {
|
|
294
|
+
const b = new Uint8Array(await readFile(join(batchRoot, batchEpoch, name)));
|
|
295
|
+
const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
296
|
+
assert.equal(v.getUint32(100), 1, "first page of the rolled-back batch's LTX file");
|
|
297
|
+
assert.equal(v.getUint32(106 + 4 + v.getUint32(106)), 0, "the rolled-back batch's LTX file holds one page");
|
|
298
|
+
}
|
|
299
|
+
const batchRestore = join(work, "batch-restored.sqlite");
|
|
300
|
+
await restore(join(batchRoot, batchEpoch), batchRestore);
|
|
301
|
+
assert.deepEqual(await readFile(batchRestore), await readFile(batchPath));
|
|
302
|
+
const check = new Database(batchRestore);
|
|
303
|
+
assert.deepEqual(check.query("SELECT id, value FROM b ORDER BY id").all(), [
|
|
304
|
+
{ id: 1, value: "one" },
|
|
305
|
+
{ id: 2, value: "two" },
|
|
306
|
+
]);
|
|
307
|
+
check.close();
|
|
308
|
+
}
|
|
250
309
|
// Size alone is not enough: an offline committed change must invalidate a clean marker.
|
|
251
310
|
const offline = new Database(path);
|
|
252
311
|
offline.exec("UPDATE t SET value='changed-offline' WHERE id=1");
|