swarmlite 0.2.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/README.md +75 -0
- package/bin/swarmlite.mjs +167 -0
- package/package.json +49 -0
- package/src/SwarmVFS.js +274 -0
- package/src/feeds.js +135 -0
- package/src/index.js +97 -0
- package/src/mantaray.js +157 -0
- package/src/memvfs.js +112 -0
- package/src/prepare.js +104 -0
- package/src/publisher.js +240 -0
- package/src/verify.js +267 -0
- package/vendor/js-sha3/LICENSE +20 -0
- package/vendor/js-sha3/PROVENANCE.md +19 -0
- package/vendor/js-sha3/index.js +12 -0
- package/vendor/js-sha3/sha3.js +662 -0
- package/vendor/noble-secp256k1/LICENSE +21 -0
- package/vendor/noble-secp256k1/PROVENANCE.md +17 -0
- package/vendor/noble-secp256k1/index.js +790 -0
- package/vendor/wa-sqlite/LICENSE +21 -0
- package/vendor/wa-sqlite/PROVENANCE.md +18 -0
- package/vendor/wa-sqlite/dist/wa-sqlite-async.mjs +115 -0
- package/vendor/wa-sqlite/dist/wa-sqlite-async.wasm +0 -0
- package/vendor/wa-sqlite/src/VFS.js +172 -0
- package/vendor/wa-sqlite/src/sqlite-api.js +955 -0
- package/vendor/wa-sqlite/src/sqlite-constants.js +271 -0
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# swarmlite
|
|
2
|
+
|
|
3
|
+
**Verifiable serverless SQLite on [Ethereum Swarm](https://www.ethswarm.org/).**
|
|
4
|
+
|
|
5
|
+
Publish an ordinary SQLite file to Swarm and run `SELECT` against it —
|
|
6
|
+
from a browser or Node — fetching only the 4 KB B-tree pages the query
|
|
7
|
+
plan touches. Measured live: a cold point lookup on a published
|
|
8
|
+
41.9 MB database fetched **5 pages / 20 KB**; a whole 4-query session
|
|
9
|
+
23 pages. There is no database server anywhere, and with
|
|
10
|
+
`verify: true` every byte is checked client-side against the 32-byte
|
|
11
|
+
content address — a tampering gateway is caught on the first bad chunk.
|
|
12
|
+
|
|
13
|
+
Everything is vendored (wa-sqlite, js-sha3, noble-secp256k1): zero
|
|
14
|
+
runtime dependencies, and the whole reader can itself be published on
|
|
15
|
+
Swarm next to your data — one immutable root serves the page, the
|
|
16
|
+
engine, and the database.
|
|
17
|
+
|
|
18
|
+
## Read (browser or Node)
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import { open, resolveFeed } from 'swarmlite';
|
|
22
|
+
|
|
23
|
+
const db = await open('http://localhost:1633/bzz/<root>/site.db');
|
|
24
|
+
const rows = await db.query('SELECT title FROM posts WHERE id = ?', [12345]);
|
|
25
|
+
console.log(db.stats()); // { readCount, pagesFetched, bytesFetched, fileSize }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Any URL served with HTTP Range support works. Follow a feed for
|
|
29
|
+
always-latest (resolve once, at load — a database must not move
|
|
30
|
+
mid-session), and pass `{ verify: true }` on untrusted gateways:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
const { reference } = await resolveFeed(api, owner, 'mysite', { verify: true });
|
|
34
|
+
const db = await open(`${api}/bzz/${reference}/site.db`, { verify: true });
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
In Node, pass the wasm explicitly:
|
|
38
|
+
`open(url, { moduleOptions: { wasmBinary: await readFile(...) } })`.
|
|
39
|
+
|
|
40
|
+
## Publish (Node)
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { publish } from 'swarmlite/publisher';
|
|
44
|
+
|
|
45
|
+
const { root } = await publish(await readFile('site.db'), {
|
|
46
|
+
feed: 'mysite', // optional: stable URL + version history
|
|
47
|
+
signer: process.env.SWARMLITE_SIGNER,
|
|
48
|
+
log: console.error,
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
or from the shell (no Python required):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx swarmlite publish site.db --feed mysite --signer <key hex>
|
|
56
|
+
npx swarmlite query "bzz://<root>/site.db" "SELECT count(*) FROM posts" --stats
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The publisher runs the same checklist as the Python CLI — page size
|
|
60
|
+
4096 (one page = one Swarm chunk), ANALYZE, VACUUM, integrity check,
|
|
61
|
+
missing-index warnings — entirely in memory via wa-sqlite, then uploads
|
|
62
|
+
and (optionally) signs a feed update. Feed signatures are byte-for-byte
|
|
63
|
+
identical to the Python implementation's (checked in CI against
|
|
64
|
+
fixtures the Python side generates). `--buy` prices and purchases a
|
|
65
|
+
postage batch from the node's wallet, with the cost shown first.
|
|
66
|
+
|
|
67
|
+
Writes are the publisher's job by design: work on a local SQLite file
|
|
68
|
+
with full SQL, then ship an immutable snapshot. Reader connections are
|
|
69
|
+
strictly read-only — the URL *is* the content hash.
|
|
70
|
+
|
|
71
|
+
## More
|
|
72
|
+
|
|
73
|
+
The [project repository](https://github.com/petfold/swarmlite) has the
|
|
74
|
+
full User Guide (stamps, feeds, snapshots, verification, patterns), the
|
|
75
|
+
Python reader/publisher, and a self-contained demo site. BSD-3-Clause.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// swarmlite CLI (pure JS — no Python needed):
|
|
3
|
+
//
|
|
4
|
+
// swarmlite publish <db> [--name F] [--feed TOPIC] [--signer HEX]
|
|
5
|
+
// [--stamp ID] [--buy] [--ttl 1d] [--yes]
|
|
6
|
+
// [--api-url URL] [--quiet]
|
|
7
|
+
// swarmlite query <url> <sql> [--verify] [--api-url URL]
|
|
8
|
+
//
|
|
9
|
+
// Same behavior as the Python CLI: bare root on stdout, human lines on
|
|
10
|
+
// stderr, one-line errors, exit 1 on failure. SWARMLITE_DEBUG=1 shows
|
|
11
|
+
// tracebacks; the signer key falls back to $SWARMLITE_SIGNER.
|
|
12
|
+
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { basename } from 'node:path';
|
|
15
|
+
import { parseArgs } from 'node:util';
|
|
16
|
+
import { createInterface } from 'node:readline/promises';
|
|
17
|
+
|
|
18
|
+
const wasmBinary = await readFile(
|
|
19
|
+
new URL('../vendor/wa-sqlite/dist/wa-sqlite-async.wasm', import.meta.url));
|
|
20
|
+
const moduleOptions = {
|
|
21
|
+
wasmBinary,
|
|
22
|
+
// Emscripten's random-device probe is noisy but harmless in Node
|
|
23
|
+
printErr: (t) => { if (!/initRandomDevice/.test(String(t))) console.error(t); },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 7 * 86400 };
|
|
27
|
+
|
|
28
|
+
function parseTtl(text) {
|
|
29
|
+
const unit = UNITS[text.slice(-1)];
|
|
30
|
+
const value = Number(unit ? text.slice(0, -1) : text);
|
|
31
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
32
|
+
throw new Error(`cannot parse TTL '${text}' — use e.g. 36h, 7d, 4w, or seconds`);
|
|
33
|
+
}
|
|
34
|
+
return value * (unit ?? 1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rejectPlaceholders(values) {
|
|
38
|
+
for (const [name, value] of Object.entries(values)) {
|
|
39
|
+
const m = value && /<[^<> ]+>/.exec(value);
|
|
40
|
+
if (m) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`${name} contains the placeholder ${m[0]} — replace it with the ` +
|
|
43
|
+
'real value (the root/reference is printed by publish)');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function cmdPublish(args) {
|
|
49
|
+
const { values, positionals } = parseArgs({
|
|
50
|
+
args,
|
|
51
|
+
allowPositionals: true,
|
|
52
|
+
options: {
|
|
53
|
+
name: { type: 'string' },
|
|
54
|
+
feed: { type: 'string' },
|
|
55
|
+
signer: { type: 'string' },
|
|
56
|
+
stamp: { type: 'string', default: 'auto' },
|
|
57
|
+
buy: { type: 'boolean', default: false },
|
|
58
|
+
ttl: { type: 'string', default: '1d' },
|
|
59
|
+
yes: { type: 'boolean', default: false },
|
|
60
|
+
'api-url': { type: 'string' },
|
|
61
|
+
quiet: { type: 'boolean', default: false },
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const [dbPath] = positionals;
|
|
65
|
+
if (!dbPath) throw new Error('usage: swarmlite publish <db> [options]');
|
|
66
|
+
rejectPlaceholders({
|
|
67
|
+
db_path: dbPath, name: values.name, feed: values.feed,
|
|
68
|
+
stamp: values.stamp, 'api-url': values['api-url'],
|
|
69
|
+
});
|
|
70
|
+
const apiUrl = values['api-url'] ?? process.env.BEE_API_URL ?? 'http://localhost:1633';
|
|
71
|
+
const log = values.quiet ? () => {} : (l) => console.error(l);
|
|
72
|
+
|
|
73
|
+
const bytes = new Uint8Array(await readFile(dbPath).catch(() => {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`no such database file: '${dbPath}' — publish uploads an existing ` +
|
|
76
|
+
'local SQLite file');
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
let stamp = values.stamp;
|
|
80
|
+
if (values.buy) {
|
|
81
|
+
if (stamp !== 'auto') throw new Error('--buy and --stamp are mutually exclusive');
|
|
82
|
+
const { planBatch, buyBatch } = await import('../src/publisher.js');
|
|
83
|
+
const plan = await planBatch(apiUrl, bytes.byteLength, parseTtl(values.ttl));
|
|
84
|
+
console.error(
|
|
85
|
+
`batch for ${(bytes.byteLength / 2 ** 20).toFixed(1)} MB: depth ${plan.depth}, ` +
|
|
86
|
+
`amount ${plan.amount}, lasting ~${Math.round(plan.ttlSecs / 3600)} h ` +
|
|
87
|
+
`-> ${plan.costBzz.toFixed(4)} xBZZ from the node's wallet`);
|
|
88
|
+
if (!values.yes) {
|
|
89
|
+
if (!process.stdin.isTTY) {
|
|
90
|
+
throw new Error('--buy needs a terminal to confirm; pass --yes to skip');
|
|
91
|
+
}
|
|
92
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
93
|
+
const answer = (await rl.question('buy it? [y/N] ')).trim().toLowerCase();
|
|
94
|
+
rl.close();
|
|
95
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
96
|
+
throw new Error('purchase declined; nothing was bought');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
console.error('buying (waits for on-chain confirmation) ...');
|
|
100
|
+
stamp = await buyBatch(apiUrl, plan.amount, plan.depth);
|
|
101
|
+
console.error(`bought batch ${stamp}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { publish } = await import('../src/publisher.js');
|
|
105
|
+
const { root } = await publish(bytes, {
|
|
106
|
+
apiUrl,
|
|
107
|
+
name: values.name ?? basename(dbPath),
|
|
108
|
+
stamp,
|
|
109
|
+
feed: values.feed ?? null,
|
|
110
|
+
signer: values.signer ?? process.env.SWARMLITE_SIGNER ?? null,
|
|
111
|
+
moduleOptions,
|
|
112
|
+
log,
|
|
113
|
+
});
|
|
114
|
+
console.log(root);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function cmdQuery(args) {
|
|
118
|
+
const { values, positionals } = parseArgs({
|
|
119
|
+
args,
|
|
120
|
+
allowPositionals: true,
|
|
121
|
+
options: {
|
|
122
|
+
verify: { type: 'boolean', default: false },
|
|
123
|
+
stats: { type: 'boolean', default: false },
|
|
124
|
+
'api-url': { type: 'string' },
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const [url, sql] = positionals;
|
|
128
|
+
if (!url || !sql) throw new Error('usage: swarmlite query <url> <sql> [--verify]');
|
|
129
|
+
rejectPlaceholders({ url });
|
|
130
|
+
// accept bzz://<root>/<path> as shorthand for a gateway URL
|
|
131
|
+
const apiUrl = values['api-url'] ?? process.env.BEE_API_URL ?? 'http://localhost:1633';
|
|
132
|
+
const resolved = url.startsWith('bzz://')
|
|
133
|
+
? `${apiUrl}/bzz/${url.slice('bzz://'.length)}`
|
|
134
|
+
: url;
|
|
135
|
+
|
|
136
|
+
const { open } = await import('../src/index.js');
|
|
137
|
+
const db = await open(resolved, { verify: values.verify, moduleOptions });
|
|
138
|
+
try {
|
|
139
|
+
for (const row of await db.query(sql)) {
|
|
140
|
+
console.log(Object.values(row).join('\t'));
|
|
141
|
+
}
|
|
142
|
+
if (values.stats) {
|
|
143
|
+
const s = db.stats();
|
|
144
|
+
console.error(
|
|
145
|
+
`fetched ${s.pagesFetched} pages (${(s.bytesFetched / 1024).toFixed(0)} KB) ` +
|
|
146
|
+
`in ${s.readCount} reads, of a ${(s.fileSize / 2 ** 20).toFixed(1)} MB file` +
|
|
147
|
+
(s.chunksFetched ? ` — ${s.chunksFetched} verified chunks` : ''));
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
await db.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
155
|
+
try {
|
|
156
|
+
if (command === 'publish') await cmdPublish(rest);
|
|
157
|
+
else if (command === 'query') await cmdQuery(rest);
|
|
158
|
+
else {
|
|
159
|
+
console.error(
|
|
160
|
+
'usage: swarmlite publish <db> [options] | swarmlite query <url> <sql> [--verify]');
|
|
161
|
+
process.exit(2);
|
|
162
|
+
}
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (process.env.SWARMLITE_DEBUG) throw e;
|
|
165
|
+
console.error(`swarmlite: ${e.message}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "swarmlite",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Verifiable serverless SQLite on Ethereum Swarm: lazy SELECT over gateway range reads (browser and Node), client-side verification, and a pure-JS publisher",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./publisher": "./src/publisher.js",
|
|
10
|
+
"./prepare": "./src/prepare.js",
|
|
11
|
+
"./verify": "./src/verify.js",
|
|
12
|
+
"./feeds": "./src/feeds.js"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"swarmlite": "bin/swarmlite.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"vendor",
|
|
20
|
+
"bin"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/petfold/swarmlite.git",
|
|
28
|
+
"directory": "js"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/petfold/swarmlite#readme",
|
|
31
|
+
"keywords": [
|
|
32
|
+
"swarm",
|
|
33
|
+
"ethswarm",
|
|
34
|
+
"sqlite",
|
|
35
|
+
"wasm",
|
|
36
|
+
"serverless",
|
|
37
|
+
"decentralized",
|
|
38
|
+
"web3",
|
|
39
|
+
"vfs",
|
|
40
|
+
"content-addressed"
|
|
41
|
+
],
|
|
42
|
+
"license": "BSD-3-Clause",
|
|
43
|
+
"author": "Peter Foldiak",
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node test/feed.mjs && node test/verify.mjs && node test/publish.mjs",
|
|
46
|
+
"smoke": "node test/smoke.mjs",
|
|
47
|
+
"serve": "node test/serve.mjs"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/SwarmVFS.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// swarmlite browser VFS: lazy, read-only SQLite pages over Swarm gateway
|
|
2
|
+
// range reads. The JS twin of swarmlite/vfs.py — same design, same
|
|
3
|
+
// counters (docs/DESIGN.md section 4).
|
|
4
|
+
//
|
|
5
|
+
// Works with wa-sqlite's Asyncify build: VFS methods may return
|
|
6
|
+
// handleAsync(async () => ...), so page fetches are ordinary async
|
|
7
|
+
// fetch() calls — no SharedArrayBuffer, no COOP/COEP headers.
|
|
8
|
+
//
|
|
9
|
+
// The "filename" SQLite opens is a URL: anything a gateway serves with
|
|
10
|
+
// HTTP Range support, typically
|
|
11
|
+
// http://localhost:1633/bzz/<root>/site.db (pin, immutable)
|
|
12
|
+
// https://gateway.example/bzz/<root>/site.db
|
|
13
|
+
// Feed URLs must be resolved to a root by the caller before opening
|
|
14
|
+
// (a feed can move mid-session; a database must not).
|
|
15
|
+
//
|
|
16
|
+
// Read-only by design: journal/WAL are reported absent, writes fail.
|
|
17
|
+
// Chunk-level BMT verification is a documented follow-up (roadmap):
|
|
18
|
+
// against a local light node, retrieved chunks are verified by the node.
|
|
19
|
+
|
|
20
|
+
import * as VFS from '../vendor/wa-sqlite/src/VFS.js';
|
|
21
|
+
|
|
22
|
+
export const PAGE_SIZE = 4096; // SQLite default page == Swarm chunk
|
|
23
|
+
const DEFAULT_CACHE_PAGES = 1024; // ~4 MiB
|
|
24
|
+
const MAX_RUN_PAGES = 16; // cap one Range request at 64 KiB — big ranges
|
|
25
|
+
// are the ones gateways abort on fresh content
|
|
26
|
+
const FETCH_TIMEOUT_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
export class SwarmVFS extends VFS.Base {
|
|
29
|
+
name = 'swarmlite';
|
|
30
|
+
mxPathName = 1024; // filenames are URLs
|
|
31
|
+
|
|
32
|
+
/** @type {Map<number, object>} sqlite3_file id -> open file state */
|
|
33
|
+
#files = new Map();
|
|
34
|
+
/** @type {Map<string, string>} short alias -> URL */
|
|
35
|
+
#urls = new Map();
|
|
36
|
+
/** @type {Map<string, object>} short alias -> {size, read} source */
|
|
37
|
+
#sources = new Map();
|
|
38
|
+
#cachePages;
|
|
39
|
+
#fetch;
|
|
40
|
+
|
|
41
|
+
constructor({ cachePages = DEFAULT_CACHE_PAGES, fetchFn } = {}) {
|
|
42
|
+
super();
|
|
43
|
+
this.#cachePages = Math.max(1, cachePages);
|
|
44
|
+
this.#fetch = fetchFn ?? ((...args) => globalThis.fetch(...args));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Map a short alias to a database URL. SQLite opens the alias; the
|
|
48
|
+
* VFS fetches the URL. Necessary because VFS filename length is
|
|
49
|
+
* limited (mxPathName can be as small as 64 in some builds), and it
|
|
50
|
+
* keeps URLs out of SQLite's filename handling entirely. */
|
|
51
|
+
registerUrl(alias, url) {
|
|
52
|
+
this.#urls.set(alias, url);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Map an alias to a custom page source instead of a URL:
|
|
56
|
+
* `{ size, read(from, toInclusive) -> Promise<Uint8Array> }`. Used
|
|
57
|
+
* for verified reads, where pages come from a chunk-tree walk rather
|
|
58
|
+
* than gateway Range requests. */
|
|
59
|
+
registerSource(alias, source) {
|
|
60
|
+
this.#sources.set(alias, source);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
xOpen(name, fileId, flags, pOutFlags) {
|
|
64
|
+
return this.handleAsync(async () => {
|
|
65
|
+
if (!(flags & VFS.SQLITE_OPEN_MAIN_DB)) {
|
|
66
|
+
return VFS.SQLITE_CANTOPEN; // no journals, no temp files
|
|
67
|
+
}
|
|
68
|
+
const source = this.#sources.get(name);
|
|
69
|
+
const url = this.#urls.get(name) ?? name;
|
|
70
|
+
try {
|
|
71
|
+
let size;
|
|
72
|
+
if (source) {
|
|
73
|
+
size = source.size;
|
|
74
|
+
} else {
|
|
75
|
+
// one 1-byte range read: existence check + total size
|
|
76
|
+
const resp = await this.#fetch(url, {
|
|
77
|
+
headers: { Range: 'bytes=0-0' },
|
|
78
|
+
});
|
|
79
|
+
size = contentRangeTotal(resp);
|
|
80
|
+
if (size === null) {
|
|
81
|
+
console.error(
|
|
82
|
+
`swarmlite: ${url} -> HTTP ${resp.status}, ` +
|
|
83
|
+
`Content-Range ${resp.headers.get('content-range')} — ` +
|
|
84
|
+
`need a gateway URL with Range support`,
|
|
85
|
+
);
|
|
86
|
+
return VFS.SQLITE_CANTOPEN;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
this.#files.set(fileId, {
|
|
90
|
+
alias: name,
|
|
91
|
+
url,
|
|
92
|
+
source,
|
|
93
|
+
size,
|
|
94
|
+
cache: new Map(), // page index -> Uint8Array (Map keeps LRU order)
|
|
95
|
+
stats: { readCount: 0, pagesFetched: 0, bytesFetched: 0, fileSize: size },
|
|
96
|
+
});
|
|
97
|
+
pOutFlags.setInt32(0, VFS.SQLITE_OPEN_READONLY, true);
|
|
98
|
+
return VFS.SQLITE_OK;
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error(`swarmlite: open ${url} failed:`, e);
|
|
101
|
+
return VFS.SQLITE_CANTOPEN;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
xClose(fileId) {
|
|
107
|
+
this.#files.delete(fileId);
|
|
108
|
+
return VFS.SQLITE_OK;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
xRead(fileId, pData, iOffset) {
|
|
112
|
+
return this.handleAsync(async () => {
|
|
113
|
+
const file = this.#files.get(fileId);
|
|
114
|
+
if (!file) return VFS.SQLITE_IOERR;
|
|
115
|
+
try {
|
|
116
|
+
const wanted = Math.min(iOffset + pData.byteLength, file.size) - iOffset;
|
|
117
|
+
if (wanted > 0) {
|
|
118
|
+
const data = await this.#readRange(file, iOffset, wanted);
|
|
119
|
+
pData.set(data, 0);
|
|
120
|
+
}
|
|
121
|
+
if (wanted < pData.byteLength) {
|
|
122
|
+
// short read past EOF: zero-fill, per SQLite contract
|
|
123
|
+
pData.fill(0, Math.max(wanted, 0));
|
|
124
|
+
return VFS.SQLITE_IOERR_SHORT_READ;
|
|
125
|
+
}
|
|
126
|
+
return VFS.SQLITE_OK;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error(`swarmlite: read @${iOffset} failed:`, e);
|
|
129
|
+
return VFS.SQLITE_IOERR;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
xFileSize(fileId, pSize64) {
|
|
135
|
+
const file = this.#files.get(fileId);
|
|
136
|
+
if (!file) return VFS.SQLITE_IOERR;
|
|
137
|
+
pSize64.setBigInt64(0, BigInt(file.size), true);
|
|
138
|
+
return VFS.SQLITE_OK;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
xAccess(name, flags, pResOut) {
|
|
142
|
+
pResOut.setInt32(0, 0, true); // journals/WAL never exist
|
|
143
|
+
return VFS.SQLITE_OK;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
xSectorSize(fileId) {
|
|
147
|
+
return PAGE_SIZE;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
xDeviceCharacteristics(fileId) {
|
|
151
|
+
return VFS.SQLITE_IOCAP_IMMUTABLE; // content-addressed: never changes
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
xWrite() {
|
|
155
|
+
return VFS.SQLITE_READONLY;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
xTruncate() {
|
|
159
|
+
return VFS.SQLITE_READONLY;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
xDelete() {
|
|
163
|
+
return VFS.SQLITE_READONLY;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Read counters for an open database URL or alias (last opened wins). */
|
|
167
|
+
statsFor(urlOrAlias) {
|
|
168
|
+
for (const file of this.#files.values()) {
|
|
169
|
+
if (file.url === urlOrAlias || file.alias === urlOrAlias) {
|
|
170
|
+
return { ...file.stats };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- transport ----------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** Ranged fetch with retry: gateways occasionally close a range read
|
|
179
|
+
* mid-stream on freshly uploaded content (seen live against Bee 2.8.1);
|
|
180
|
+
* a short backoff and retry recovers. */
|
|
181
|
+
async #fetchRange(url, from, to, attempts = 5) {
|
|
182
|
+
let lastErr;
|
|
183
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
const resp = await this.#fetch(url, {
|
|
186
|
+
headers: { Range: `bytes=${from}-${to}` },
|
|
187
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
188
|
+
});
|
|
189
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status} for bytes=${from}-${to}`);
|
|
190
|
+
const blob = new Uint8Array(await resp.arrayBuffer());
|
|
191
|
+
if (blob.byteLength !== to - from + 1) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`truncated range read: got ${blob.byteLength} of ${to - from + 1}`);
|
|
194
|
+
}
|
|
195
|
+
return blob;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
lastErr = e;
|
|
198
|
+
if (attempt < attempts) {
|
|
199
|
+
// seen live: freshly uploaded content can be unretrievable for
|
|
200
|
+
// seconds while the node syncs — back off up to ~7.5 s total
|
|
201
|
+
console.warn(
|
|
202
|
+
`swarmlite: retry ${attempt} for bytes=${from}-${to} (${e})`);
|
|
203
|
+
await new Promise((r) => setTimeout(r, 500 * 2 ** (attempt - 1)));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw lastErr;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- paging ------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** Serve [offset, offset+length) from the page cache, fetching each
|
|
213
|
+
* contiguous run of missing pages with a single Range request. */
|
|
214
|
+
async #readRange(file, offset, length) {
|
|
215
|
+
const first = Math.floor(offset / PAGE_SIZE);
|
|
216
|
+
const last = Math.floor((offset + length - 1) / PAGE_SIZE);
|
|
217
|
+
|
|
218
|
+
const pages = new Map();
|
|
219
|
+
for (let i = first; i <= last; i++) {
|
|
220
|
+
const hit = file.cache.get(i);
|
|
221
|
+
if (hit !== undefined) {
|
|
222
|
+
file.cache.delete(i); // refresh LRU position
|
|
223
|
+
file.cache.set(i, hit);
|
|
224
|
+
pages.set(i, hit);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let i = first;
|
|
229
|
+
while (i <= last) {
|
|
230
|
+
if (pages.has(i)) { i++; continue; }
|
|
231
|
+
let j = i;
|
|
232
|
+
while (j <= last && !pages.has(j) && j - i < MAX_RUN_PAGES) j++;
|
|
233
|
+
const from = i * PAGE_SIZE;
|
|
234
|
+
const to = Math.min(j * PAGE_SIZE, file.size) - 1;
|
|
235
|
+
const blob = file.source
|
|
236
|
+
? await file.source.read(from, to)
|
|
237
|
+
: await this.#fetchRange(file.url, from, to);
|
|
238
|
+
file.stats.readCount += 1;
|
|
239
|
+
file.stats.bytesFetched += blob.byteLength;
|
|
240
|
+
for (let k = i; k < j; k++) {
|
|
241
|
+
const page = blob.subarray((k - i) * PAGE_SIZE, (k - i + 1) * PAGE_SIZE);
|
|
242
|
+
pages.set(k, page);
|
|
243
|
+
file.cache.set(k, page);
|
|
244
|
+
file.stats.pagesFetched += 1;
|
|
245
|
+
}
|
|
246
|
+
i = j;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
while (file.cache.size > this.#cachePages) {
|
|
250
|
+
file.cache.delete(file.cache.keys().next().value); // evict LRU
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const out = new Uint8Array(length);
|
|
254
|
+
let outPos = 0;
|
|
255
|
+
let cur = offset;
|
|
256
|
+
while (outPos < length) {
|
|
257
|
+
const k = Math.floor(cur / PAGE_SIZE);
|
|
258
|
+
const page = pages.get(k);
|
|
259
|
+
const pageStart = cur - k * PAGE_SIZE;
|
|
260
|
+
const n = Math.min(PAGE_SIZE - pageStart, length - outPos);
|
|
261
|
+
out.set(page.subarray(pageStart, pageStart + n), outPos);
|
|
262
|
+
outPos += n;
|
|
263
|
+
cur += n;
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Parse the total size out of a 206 response's Content-Range header. */
|
|
270
|
+
function contentRangeTotal(resp) {
|
|
271
|
+
if (resp.status !== 206) return null;
|
|
272
|
+
const m = /\/(\d+)\s*$/.exec(resp.headers.get('content-range') ?? '');
|
|
273
|
+
return m ? Number(m[1]) : null;
|
|
274
|
+
}
|