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/src/feeds.js ADDED
@@ -0,0 +1,135 @@
1
+ // swarmlite feeds (JS): resolve a Swarm feed (owner, topic) to the
2
+ // immutable root it currently points at, via the gateway's /feeds
3
+ // endpoint. The gateway does the SOC lookup server-side; the reference
4
+ // travels in the response's Etag header (verified live against Bee
5
+ // 2.8.1, `Swarm-Feed-Resolved-Version: v1` — the body streams the
6
+ // resolved content, which we cancel unread). For gateways that return
7
+ // the raw update payload as the body instead, parsing that is the
8
+ // fallback.
9
+ //
10
+ // Resolution happens once, BEFORE open(): a feed can move mid-session,
11
+ // a database must not (see SwarmVFS.js). Typical use:
12
+ //
13
+ // const { reference } = await resolveFeed(api, owner, topic);
14
+ // const db = await open(`${api}/bzz/${reference}/site.db`);
15
+ //
16
+ // Client-side SOC signature verification (so an untrusted gateway
17
+ // cannot forge an update) is a documented follow-up, together with
18
+ // chunk/BMT verification — both need only keccak256, already vendored.
19
+
20
+ import { keccak256 } from '../vendor/js-sha3/index.js';
21
+ import {
22
+ VerificationError, feedIdentifier, fromHex, socAddress, toHex, verifySoc,
23
+ } from './verify.js';
24
+
25
+ const HEX64 = /^[0-9a-fA-F]{64}$/;
26
+
27
+ /** 32-byte feed topic as hex: a raw 64-hex string passes through,
28
+ * anything else is keccak256 of the utf-8 string — mirrors swarmfs
29
+ * topic_bytes(), so topics printed by `swarmlite publish` resolve. */
30
+ export function topicHex(topic) {
31
+ return HEX64.test(topic) ? topic.toLowerCase() : keccak256(topic);
32
+ }
33
+
34
+ /** Resolve bzzf://<owner>/<topic> to { reference, index } using a Bee
35
+ * API/gateway URL. Throws with an actionable message on any failure —
36
+ * never returns a guess.
37
+ *
38
+ * With `verify: true` the update's single-owner chunk is fetched and
39
+ * checked client-side (address derivation + owner-signature recovery),
40
+ * so an untrusted gateway cannot forge a feed update. What cannot be
41
+ * proven client-side is *latestness* — a malicious gateway could serve
42
+ * an older (but genuine) update; that limitation is shared with every
43
+ * feed reader, including swarmfs. */
44
+ export async function resolveFeed(apiUrl, owner, topic, { fetchFn, verify = false } = {}) {
45
+ const doFetch = fetchFn ?? ((...args) => globalThis.fetch(...args));
46
+ owner = owner.toLowerCase().replace(/^0x/, '');
47
+ if (!/^[0-9a-f]{40}$/.test(owner)) {
48
+ throw new Error(`feed owner must be a 20-byte hex address, got "${owner}"`);
49
+ }
50
+ const api = apiUrl.replace(/\/+$/, '');
51
+ const url = `${api}/feeds/${owner}/${topicHex(topic)}?type=sequence`;
52
+ if (verify) {
53
+ return resolveVerified(doFetch, api, url, owner, topicHex(topic));
54
+ }
55
+ const resp = await doFetch(url);
56
+ if (!resp.ok) {
57
+ throw new Error(
58
+ `feed lookup failed: HTTP ${resp.status} for ${url}` +
59
+ (resp.status === 404 ? ' — feed has no updates yet, or still syncing' : ''),
60
+ );
61
+ }
62
+ const index = parseInt(resp.headers.get('swarm-feed-index') ?? '', 16);
63
+ const done = (reference) =>
64
+ ({ reference, index: Number.isNaN(index) ? null : index });
65
+
66
+ // Bee resolves the update server-side; Etag carries the reference and
67
+ // the body streams the content it points at — skip the download.
68
+ const etag = (resp.headers.get('etag') ?? '').replaceAll('"', '');
69
+ if (HEX64.test(etag)) {
70
+ await resp.body?.cancel?.();
71
+ return done(etag.toLowerCase());
72
+ }
73
+
74
+ // fallback: the body is the raw update payload (bee-js format)
75
+ const payload = new Uint8Array(await resp.arrayBuffer());
76
+ if (payload.byteLength === 40 || payload.byteLength === 72) {
77
+ return done(toHex(payload.subarray(8))); // timestamp(8) + ref(32|64)
78
+ }
79
+ if (payload.byteLength === 32 || payload.byteLength === 64) {
80
+ return done(toHex(payload)); // bare reference, no timestamp
81
+ }
82
+ throw new Error(
83
+ `unexpected /feeds response from ${url}: no reference Etag, and the ` +
84
+ `body (${payload.byteLength} bytes) is not a bee-js feed update ` +
85
+ '(timestamp + reference) — was the feed published with swarmlite/swarmfs?',
86
+ );
87
+ }
88
+
89
+ /** Verified resolution: take only the update INDEX from the gateway's
90
+ * headers (server-side sequence lookup), then fetch the single-owner
91
+ * chunk at the address we derive ourselves and verify it fully —
92
+ * mirrors swarmfs FeedOps.latest(verify=True). */
93
+ async function resolveVerified(doFetch, api, url, owner, topicHexStr) {
94
+ const head = await doFetch(url, {
95
+ headers: { 'Swarm-Only-Root-Chunk': 'true' }, // headers only, skip content
96
+ });
97
+ if (!head.ok) {
98
+ throw new Error(
99
+ `feed lookup failed: HTTP ${head.status} for ${url}` +
100
+ (head.status === 404 ? ' — feed has no updates yet, or still syncing' : ''),
101
+ );
102
+ }
103
+ await head.body?.cancel?.();
104
+ const indexHex = head.headers.get('swarm-feed-index');
105
+ if (!/^[0-9a-fA-F]{16}$/.test(indexHex ?? '')) {
106
+ throw new VerificationError(
107
+ `cannot verify: no Swarm-Feed-Index header on ${url} (got ${indexHex})`,
108
+ );
109
+ }
110
+ const index = parseInt(indexHex, 16);
111
+ const ownerBytes = fromHex(owner);
112
+ const addr = socAddress(feedIdentifier(fromHex(topicHexStr), index), ownerBytes);
113
+ const resp = await doFetch(`${api}/chunks/${toHex(addr)}`);
114
+ if (!resp.ok) {
115
+ throw new Error(
116
+ `feed update chunk ${toHex(addr).slice(0, 16)}… not retrievable ` +
117
+ `(HTTP ${resp.status}) — the gateway reported index ${index} but ` +
118
+ 'does not serve the corresponding single-owner chunk',
119
+ );
120
+ }
121
+ const soc = new Uint8Array(await resp.arrayBuffer());
122
+ const payload = verifySoc(soc, ownerBytes, addr); // throws VerificationError on forgery
123
+ let ref;
124
+ if (payload.byteLength === 40 || payload.byteLength === 72) {
125
+ ref = payload.subarray(8); // timestamp(8) + reference(32|64)
126
+ } else if (payload.byteLength === 32 || payload.byteLength === 64) {
127
+ ref = payload;
128
+ } else {
129
+ throw new VerificationError(
130
+ `verified feed update carries an unexpected ${payload.byteLength}-byte ` +
131
+ 'payload — not a bee-js update (timestamp + reference)',
132
+ );
133
+ }
134
+ return { reference: toHex(ref), index };
135
+ }
package/src/index.js ADDED
@@ -0,0 +1,97 @@
1
+ // swarmlite/js: open a SQLite database published on Swarm, lazily.
2
+ //
3
+ // import { open } from './src/index.js';
4
+ // const db = await open('http://localhost:1633/bzz/<root>/site.db');
5
+ // const rows = await db.query('SELECT title FROM posts LIMIT 5');
6
+ // console.log(db.stats()); // pages/bytes fetched vs. file size
7
+ //
8
+ // Fully self-contained: wa-sqlite is vendored, everything loads from
9
+ // relative URLs, so the whole reader can itself be published on Swarm.
10
+
11
+ import SQLiteAsyncESMFactory from '../vendor/wa-sqlite/dist/wa-sqlite-async.mjs';
12
+ import * as SQLite from '../vendor/wa-sqlite/src/sqlite-api.js';
13
+ import { SwarmVFS } from './SwarmVFS.js';
14
+ import { NodeStore, lookupPath } from './mantaray.js';
15
+ import { VerifiedChunkStore, verifiedRead, verifiedSize } from './verify.js';
16
+
17
+ export { SwarmVFS, PAGE_SIZE } from './SwarmVFS.js';
18
+ export { resolveFeed, topicHex } from './feeds.js';
19
+ export { VerificationError } from './verify.js';
20
+
21
+ let modulePromise = null;
22
+ let aliasCounter = 0;
23
+
24
+ /** Open a read-only lazy connection to a database URL (gateway URL with
25
+ * Range support). Returns { query, stats, close, sqlite3, db, vfs }.
26
+ *
27
+ * With `verify: true` every byte is checked against the 32-byte root in
28
+ * the URL: the manifest is resolved client-side and pages are read
29
+ * chunk-by-chunk via /chunks, each BMT-verified — safe against a
30
+ * tampering gateway, at the cost of one request per 4 KB chunk (a local
31
+ * light node verifies natively, so the default fast path is fine
32
+ * there). Requires a '<api>/bzz/<64-hex root>/<path>' URL. */
33
+ export async function open(url, { cachePages, moduleOptions, verify = false, fetchFn } = {}) {
34
+ modulePromise ??= SQLiteAsyncESMFactory(moduleOptions);
35
+ const module = await modulePromise;
36
+ const sqlite3 = SQLite.Factory(module);
37
+
38
+ const vfs = new SwarmVFS({ cachePages, fetchFn });
39
+ // register a unique VFS name per open (each VFS instance carries its
40
+ // own cache/stats), and open via a short alias — SQLite VFS filename
41
+ // buffers can be as small as 64 bytes, too short for gateway URLs
42
+ vfs.name = `swarmlite-${++aliasCounter}`;
43
+ const alias = `db-${aliasCounter}`;
44
+ let chunkStore = null;
45
+ if (verify) {
46
+ const m = /^(.+?)\/bzz\/([0-9a-fA-F]{64})\/(.+)$/.exec(url);
47
+ if (!m) {
48
+ throw new Error(
49
+ `verify: true needs a '<api>/bzz/<64-hex root>/<path>' URL to ` +
50
+ `know what to verify against, got ${url}`,
51
+ );
52
+ }
53
+ const [, api, root, path] = m;
54
+ chunkStore = new VerifiedChunkStore(api, { fetchFn });
55
+ const nodes = new NodeStore((ref) => verifiedRead(chunkStore, ref));
56
+ const entry = await lookupPath(nodes, root.toLowerCase(), path);
57
+ if (!entry) {
58
+ throw new Error(`verify: '${path}' is not a file in manifest ${root}`);
59
+ }
60
+ const size = await verifiedSize(chunkStore, entry.reference);
61
+ vfs.registerSource(alias, {
62
+ size,
63
+ read: (from, toInclusive) =>
64
+ verifiedRead(chunkStore, entry.reference, from, toInclusive + 1),
65
+ });
66
+ } else {
67
+ vfs.registerUrl(alias, url);
68
+ }
69
+ sqlite3.vfs_register(vfs, false);
70
+ const db = await sqlite3.open_v2(alias, SQLite.SQLITE_OPEN_READONLY, vfs.name);
71
+
72
+ /** Run SQL; returns [{col: value, ...}, ...]. */
73
+ async function query(sql, bindings) {
74
+ const rows = [];
75
+ for await (const stmt of sqlite3.statements(db, sql)) {
76
+ if (bindings) sqlite3.bind_collection(stmt, bindings);
77
+ const cols = sqlite3.column_names(stmt);
78
+ while ((await sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
79
+ const row = {};
80
+ cols.forEach((c, i) => { row[c] = sqlite3.column(stmt, i); });
81
+ rows.push(row);
82
+ }
83
+ }
84
+ return rows;
85
+ }
86
+
87
+ return {
88
+ query,
89
+ // verified mode adds chunk-level counters (network = chunk bytes,
90
+ // including intermediate tree/manifest chunks)
91
+ stats: () => ({ ...vfs.statsFor(alias), ...(chunkStore?.stats ?? {}) }),
92
+ close: () => sqlite3.close(db),
93
+ sqlite3,
94
+ db,
95
+ vfs,
96
+ };
97
+ }
@@ -0,0 +1,157 @@
1
+ // Mantaray manifest lookup: resolve a path inside a manifest trie to
2
+ // the file's data reference, fetching nodes on demand. A read-only port
3
+ // of swarmfs's mantaray/node.py (unmarshal) and mantaray/walk.py
4
+ // (NodeStore/locate) — see there for the wire-format documentation.
5
+ // Only lookup is ported; listing and building stay publisher-side.
6
+ //
7
+ // Transport-agnostic: the store's `load` function (ref hex -> node
8
+ // bytes) is the only I/O dependency. Load through verifiedRead and the
9
+ // whole manifest resolution is trustless.
10
+
11
+ export class MantarayFormatError extends Error {
12
+ name = 'MantarayFormatError';
13
+ }
14
+
15
+ const OBFUSCATION_KEY_SIZE = 32;
16
+ const VERSION_HASH_SIZE = 31;
17
+ const HEADER_SIZE = OBFUSCATION_KEY_SIZE + VERSION_HASH_SIZE + 1; // 64
18
+ const FORK_HEADER_SIZE = 2;
19
+ const FORK_PRE_REFERENCE_SIZE = 32;
20
+ const PREFIX_MAX_SIZE = FORK_PRE_REFERENCE_SIZE - FORK_HEADER_SIZE; // 30
21
+ const METADATA_SIZE_SIZE = 2;
22
+
23
+ // keccak256("mantaray:0.1") / keccak256("mantaray:0.2") truncated to 31
24
+ // bytes, as pre-computed in bee's pkg/manifest/mantaray/marshal.go
25
+ const VERSION_01_HASH = '025184789d63635766d78c41900196b57d7400875ebe4d9b5d1e76bd9652a9';
26
+ const VERSION_02_HASH = '5768b3b6a7db56d21d1abff40d41cebfc83448fed8d7e9b06ec0d3b073f28f';
27
+
28
+ const NT_VALUE = 0x02;
29
+ const NT_EDGE = 0x04;
30
+
31
+ const hex = (bytes) =>
32
+ Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
33
+
34
+ /** Decode one serialized Mantaray node. Returns
35
+ * { entry: hex|null, forks: Map<byte, fork> } with fork =
36
+ * { nodeType, prefix: Uint8Array, ref: hex, metadata: object|null }. */
37
+ export function unmarshal(data) {
38
+ if (data.length < HEADER_SIZE) {
39
+ throw new MantarayFormatError(`serialized node too short: ${data.length} bytes`);
40
+ }
41
+ const key = data.subarray(0, OBFUSCATION_KEY_SIZE);
42
+ const body = data.slice(OBFUSCATION_KEY_SIZE); // copy — XORed in place
43
+ if (key.some((b) => b !== 0)) {
44
+ for (let i = 0; i < body.length; i++) body[i] ^= key[i % OBFUSCATION_KEY_SIZE];
45
+ }
46
+
47
+ const versionHash = hex(body.subarray(0, VERSION_HASH_SIZE));
48
+ let version;
49
+ if (versionHash === VERSION_01_HASH) version = 1;
50
+ else if (versionHash === VERSION_02_HASH) version = 2;
51
+ else throw new MantarayFormatError(`unknown version hash: ${versionHash}`);
52
+
53
+ const rbs = body[VERSION_HASH_SIZE];
54
+ let off = VERSION_HASH_SIZE + 1;
55
+ if (body.length < off + rbs + 32) {
56
+ throw new MantarayFormatError('serialized node too short for entry + fork bitmap');
57
+ }
58
+ const entryBytes = body.subarray(off, off + rbs);
59
+ off += rbs;
60
+ const bitmap = body.subarray(off, off + 32);
61
+ off += 32;
62
+
63
+ const node = {
64
+ entry: entryBytes.some((b) => b !== 0) ? hex(entryBytes) : null,
65
+ forks: new Map(),
66
+ };
67
+
68
+ for (let b = 0; b < 256; b++) {
69
+ if (!((bitmap[b >> 3] >> (b & 7)) & 1)) continue;
70
+ if (version === 2 && rbs === 0) continue; // bee skips fork parsing here
71
+ const base = FORK_PRE_REFERENCE_SIZE + rbs;
72
+ if (body.length < off + base) {
73
+ throw new MantarayFormatError(`truncated fork record for byte ${b}`);
74
+ }
75
+ const nodeType = body[off];
76
+ const prefixLen = body[off + 1];
77
+ if (prefixLen === 0 || prefixLen > PREFIX_MAX_SIZE) {
78
+ throw new MantarayFormatError(`invalid fork prefix length: ${prefixLen}`);
79
+ }
80
+ const prefix = body.subarray(off + FORK_HEADER_SIZE, off + FORK_HEADER_SIZE + prefixLen);
81
+ const ref = hex(body.subarray(off + FORK_PRE_REFERENCE_SIZE, off + base));
82
+
83
+ let metadata = null;
84
+ let size = base;
85
+ if (version === 2 && nodeType & 0x10 /* NT_WITH_METADATA */) {
86
+ const msize = (body[off + base] << 8) | body[off + base + 1];
87
+ size = base + METADATA_SIZE_SIZE + msize;
88
+ if (body.length < off + size) {
89
+ throw new MantarayFormatError(`truncated fork metadata for byte ${b}`);
90
+ }
91
+ const raw = body.subarray(off + base + METADATA_SIZE_SIZE, off + size);
92
+ try {
93
+ metadata = JSON.parse(new TextDecoder().decode(raw)); // '\n' padding is valid JSON whitespace
94
+ } catch (e) {
95
+ throw new MantarayFormatError(`bad fork metadata JSON: ${e}`);
96
+ }
97
+ }
98
+ node.forks.set(b, { nodeType, prefix, ref, metadata });
99
+ off += size;
100
+ }
101
+ return node;
102
+ }
103
+
104
+ /** Fetch-and-parse cache for manifest nodes, keyed by reference (hex).
105
+ * Content-addressed, so cached nodes never go stale. */
106
+ export class NodeStore {
107
+ #load;
108
+ #cache = new Map();
109
+ #cacheSize;
110
+
111
+ constructor(load, { cacheSize = 4096 } = {}) {
112
+ this.#load = load;
113
+ this.#cacheSize = cacheSize;
114
+ }
115
+
116
+ async get(refHex) {
117
+ const hit = this.#cache.get(refHex);
118
+ if (hit !== undefined) {
119
+ this.#cache.delete(refHex);
120
+ this.#cache.set(refHex, hit);
121
+ return hit;
122
+ }
123
+ const node = unmarshal(await this.#load(refHex));
124
+ this.#cache.set(refHex, node);
125
+ while (this.#cache.size > this.#cacheSize) {
126
+ this.#cache.delete(this.#cache.keys().next().value);
127
+ }
128
+ return node;
129
+ }
130
+ }
131
+
132
+ const startsWith = (bytes, prefix) =>
133
+ prefix.length <= bytes.length && prefix.every((b, i) => bytes[i] === b);
134
+
135
+ /** Resolve `path` inside the manifest at `rootRefHex` to the file's
136
+ * data reference: { reference: hex, metadata } — or null if the path
137
+ * does not name a file in the manifest. */
138
+ export async function lookupPath(store, rootRefHex, path) {
139
+ let needle = new TextEncoder().encode(path);
140
+ let node = await store.get(rootRefHex);
141
+ while (needle.length) {
142
+ const fork = node.forks.get(needle[0]);
143
+ if (fork === undefined || !startsWith(needle, fork.prefix)) return null;
144
+ needle = needle.subarray(fork.prefix.length);
145
+ if (!needle.length) {
146
+ // path consumed exactly at this fork: a file iff it's a value
147
+ // fork whose child carries an entry
148
+ if (!(fork.nodeType & NT_VALUE)) return null;
149
+ const child = await store.get(fork.ref);
150
+ return child.entry ? { reference: child.entry, metadata: fork.metadata } : null;
151
+ }
152
+ if (!(fork.nodeType & NT_EDGE)) return null;
153
+ node = await store.get(fork.ref);
154
+ }
155
+ // empty path = the manifest root itself: a directory, not a file
156
+ return null;
157
+ }
package/src/memvfs.js ADDED
@@ -0,0 +1,112 @@
1
+ // A tiny writable in-memory VFS for wa-sqlite, used by the publisher's
2
+ // prepare step (js/src/prepare.js): the database file and its rollback
3
+ // journal live in RAM, the checklist runs (VACUUM etc.), and the
4
+ // finished bytes are read back out. Fully synchronous — no Asyncify
5
+ // round trips — and never touches disk or network.
6
+
7
+ import * as VFS from '../vendor/wa-sqlite/src/VFS.js';
8
+
9
+ class MemFile {
10
+ data = new Uint8Array(0);
11
+ size = 0;
12
+
13
+ ensure(capacity) {
14
+ if (capacity <= this.data.length) return;
15
+ const grown = new Uint8Array(Math.max(capacity, this.data.length * 2, 4096));
16
+ grown.set(this.data);
17
+ this.data = grown;
18
+ }
19
+ }
20
+
21
+ export class MemVFS extends VFS.Base {
22
+ name = 'swarmlite-mem';
23
+ mxPathName = 512;
24
+
25
+ /** @type {Map<string, MemFile>} filename -> contents */
26
+ files = new Map();
27
+ /** @type {Map<number, {file: MemFile, name: string, deleteOnClose: boolean}>} */
28
+ #open = new Map();
29
+
30
+ /** Preload a file (e.g. the database to prepare). */
31
+ put(name, bytes) {
32
+ const f = new MemFile();
33
+ f.data = new Uint8Array(bytes); // private copy
34
+ f.size = bytes.byteLength;
35
+ this.files.set(name, f);
36
+ }
37
+
38
+ /** Read a file's current contents (e.g. the prepared database). */
39
+ get(name) {
40
+ const f = this.files.get(name);
41
+ return f ? f.data.slice(0, f.size) : null;
42
+ }
43
+
44
+ xOpen(name, fileId, flags, pOutFlags) {
45
+ name = name ?? `temp-${fileId}`; // anonymous temp files
46
+ let file = this.files.get(name);
47
+ if (!file) {
48
+ if (!(flags & VFS.SQLITE_OPEN_CREATE)) return VFS.SQLITE_CANTOPEN;
49
+ file = new MemFile();
50
+ this.files.set(name, file);
51
+ }
52
+ this.#open.set(fileId, {
53
+ file, name,
54
+ deleteOnClose: Boolean(flags & VFS.SQLITE_OPEN_DELETEONCLOSE),
55
+ });
56
+ pOutFlags.setInt32(0, flags, true);
57
+ return VFS.SQLITE_OK;
58
+ }
59
+
60
+ xClose(fileId) {
61
+ const entry = this.#open.get(fileId);
62
+ this.#open.delete(fileId);
63
+ if (entry?.deleteOnClose) this.files.delete(entry.name);
64
+ return VFS.SQLITE_OK;
65
+ }
66
+
67
+ xRead(fileId, pData, iOffset) {
68
+ const { file } = this.#open.get(fileId);
69
+ const available = Math.max(file.size - iOffset, 0);
70
+ const n = Math.min(pData.byteLength, available);
71
+ if (n > 0) pData.set(file.data.subarray(iOffset, iOffset + n), 0);
72
+ if (n < pData.byteLength) {
73
+ pData.fill(0, n);
74
+ return VFS.SQLITE_IOERR_SHORT_READ;
75
+ }
76
+ return VFS.SQLITE_OK;
77
+ }
78
+
79
+ xWrite(fileId, pData, iOffset) {
80
+ const { file } = this.#open.get(fileId);
81
+ file.ensure(iOffset + pData.byteLength);
82
+ file.data.set(pData, iOffset);
83
+ file.size = Math.max(file.size, iOffset + pData.byteLength);
84
+ return VFS.SQLITE_OK;
85
+ }
86
+
87
+ xTruncate(fileId, iSize) {
88
+ const { file } = this.#open.get(fileId);
89
+ file.size = Math.min(file.size, iSize);
90
+ return VFS.SQLITE_OK;
91
+ }
92
+
93
+ xFileSize(fileId, pSize64) {
94
+ const { file } = this.#open.get(fileId);
95
+ pSize64.setBigInt64(0, BigInt(file.size), true);
96
+ return VFS.SQLITE_OK;
97
+ }
98
+
99
+ xSync() {
100
+ return VFS.SQLITE_OK; // RAM is always "synced"
101
+ }
102
+
103
+ xDelete(name) {
104
+ this.files.delete(name);
105
+ return VFS.SQLITE_OK;
106
+ }
107
+
108
+ xAccess(name, flags, pResOut) {
109
+ pResOut.setInt32(0, this.files.has(name) ? 1 : 0, true);
110
+ return VFS.SQLITE_OK;
111
+ }
112
+ }
package/src/prepare.js ADDED
@@ -0,0 +1,104 @@
1
+ // The publisher's checklist, in JS: mirror of swarmlite/publish.py
2
+ // prepare() — PRAGMA page_size=4096, ANALYZE, VACUUM, integrity_check,
3
+ // and the same missing-index warnings (PRAGMA table_list: shadow tables
4
+ // skipped, WITHOUT ROWID counts as indexed). Runs entirely in memory on
5
+ // wa-sqlite via MemVFS; the input bytes are never modified.
6
+ //
7
+ // One divergence from the Python publisher: WAL-mode inputs are
8
+ // REJECTED rather than converted. Switching out of WAL requires
9
+ // opening the database, and WAL needs shared-memory VFS support that
10
+ // the in-memory VFS (deliberately) does not have. The fix is a
11
+ // one-liner for the caller, stated in the error.
12
+
13
+ import SQLiteAsyncESMFactory from '../vendor/wa-sqlite/dist/wa-sqlite-async.mjs';
14
+ import * as SQLite from '../vendor/wa-sqlite/src/sqlite-api.js';
15
+ import { MemVFS } from './memvfs.js';
16
+
17
+ export const PAGE_SIZE = 4096; // one SQLite page = one Swarm chunk
18
+ const LARGE_TABLE_ROWS = 5000;
19
+
20
+ export class PrepareError extends Error {
21
+ name = 'PrepareError';
22
+ }
23
+
24
+ let modulePromise = null;
25
+ let vfsCounter = 0;
26
+
27
+ /** Produce a publish-ready copy of a SQLite database.
28
+ * @param {Uint8Array} bytes - the database file's contents
29
+ * @returns {Promise<{bytes: Uint8Array, warnings: string[]}>} */
30
+ export async function prepare(bytes, { moduleOptions } = {}) {
31
+ if (bytes.byteLength < 100 ||
32
+ new TextDecoder().decode(bytes.subarray(0, 15)) !== 'SQLite format 3') {
33
+ throw new PrepareError('not a SQLite database (bad header magic)');
34
+ }
35
+ if (bytes[18] === 2 || bytes[19] === 2) {
36
+ throw new PrepareError(
37
+ 'database is in WAL mode, which cannot ship to Swarm (WAL needs ' +
38
+ 'sidecar files). Switch it locally first — ' +
39
+ `sqlite3 your.db "PRAGMA journal_mode=DELETE" — and re-run`,
40
+ );
41
+ }
42
+
43
+ modulePromise ??= SQLiteAsyncESMFactory(moduleOptions);
44
+ const sqlite3 = SQLite.Factory(await modulePromise);
45
+ const vfs = new MemVFS();
46
+ vfs.name = `swarmlite-mem-${++vfsCounter}`;
47
+ vfs.put('work.db', bytes);
48
+ sqlite3.vfs_register(vfs, false);
49
+ const db = await sqlite3.open_v2(
50
+ 'work.db', SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_CREATE, vfs.name);
51
+
52
+ async function rows(sql) {
53
+ const out = [];
54
+ for await (const stmt of sqlite3.statements(db, sql)) {
55
+ while ((await sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
56
+ out.push(sqlite3.row(stmt));
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ const warnings = [];
63
+ try {
64
+ const [[pageSize]] = await rows('PRAGMA page_size');
65
+ if (pageSize !== PAGE_SIZE) {
66
+ await rows(`PRAGMA page_size=${PAGE_SIZE}`);
67
+ warnings.push(
68
+ `page_size was ${pageSize}; rewriting to ${PAGE_SIZE} ` +
69
+ '(one page = one Swarm chunk)',
70
+ );
71
+ }
72
+
73
+ // ordinary tables only: 'shadow' skips virtual-table internals, and
74
+ // wr=1 (WITHOUT ROWID) means the PRIMARY KEY is the table's B-tree
75
+ for (const [schema, table, type, _ncol, wr] of await rows('PRAGMA table_list')) {
76
+ if (schema !== 'main' || type !== 'table' || wr) continue;
77
+ if (table.startsWith('sqlite_')) continue;
78
+ const [[n]] = await rows(`SELECT count(*) FROM "${table}"`);
79
+ const [[idx]] = await rows(
80
+ `SELECT count(*) FROM sqlite_master WHERE type='index' ` +
81
+ `AND tbl_name='${table.replaceAll("'", "''")}'`);
82
+ if (n >= LARGE_TABLE_ROWS && idx === 0) {
83
+ warnings.push(
84
+ `table '${table}' has ${n} rows and no index — remote queries ` +
85
+ 'filtering on non-rowid columns will scan the whole file ' +
86
+ '(INTEGER PRIMARY KEY lookups are still fine)',
87
+ );
88
+ }
89
+ }
90
+
91
+ await rows('ANALYZE');
92
+ await rows('VACUUM'); // applies the page size, defragments
93
+
94
+ const check = (await rows('PRAGMA integrity_check')).map((r) => r[0]);
95
+ if (check.length !== 1 || check[0] !== 'ok') {
96
+ throw new PrepareError(
97
+ `integrity_check failed: ${check.slice(0, 5).join('; ')}`);
98
+ }
99
+ } finally {
100
+ await sqlite3.close(db);
101
+ }
102
+
103
+ return { bytes: vfs.get('work.db'), warnings };
104
+ }