diff --git a/demo/file/index.html b/demo/file/index.html
new file mode 100644
index 00000000..817a8ad6
--- /dev/null
+++ b/demo/file/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+ Local file transfer
+
+
+
+
+ Sample database importer
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/file/index.js b/demo/file/index.js
new file mode 100644
index 00000000..779f6ebf
--- /dev/null
+++ b/demo/file/index.js
@@ -0,0 +1,169 @@
+import * as VFS from "../../src/VFS";
+import { IDBBatchAtomicVFS } from "../../src/examples/IDBBatchAtomicVFS";
+
+const SEARCH_PARAMS = new URLSearchParams(location.search);
+const IDB_NAME = SEARCH_PARAMS.get('idb') ?? 'sqlite-vfs';
+const DB_NAME = SEARCH_PARAMS.get('db') ?? 'sqlite.db';
+
+const DBFILE_MAGIC = 'SQLite format 3\x00';
+
+document.getElementById('file-import').addEventListener('change', async event => {
+ let vfs;
+ try {
+ log(`Importing to IndexedDB ${IDB_NAME}, path ${DB_NAME}`);
+ vfs = new IDBBatchAtomicVFS(IDB_NAME);
+ // @ts-ignore
+ await importDatabase(vfs, DB_NAME, event.target.files[0].stream());
+ log('Import complete');
+
+ log('Verifying database integrity');
+ const url = new URL('./verifier.js', location.href);
+ url.searchParams.set('idb', IDB_NAME);
+ url.searchParams.set('db', DB_NAME);
+ const worker = new Worker(url, { type: 'module' });
+ await new Promise(resolve => {
+ worker.addEventListener('message', ({data}) => {
+ resolve();
+ for (const row of data) {
+ log(`integrity result: ${row}`);
+ }
+ worker.terminate();
+ });
+ });
+ log('Verification complete');
+ } catch (e) {
+ log(e.toString());
+ throw e;
+ } finally {
+ vfs?.close();
+ }
+});
+
+/**
+ * @param {VFS.Base} vfs
+ * @param {string} path
+ * @param {ReadableStream} stream
+ */
+async function importDatabase(vfs, path, stream) {
+ async function* pagify() {
+ /** @type {Uint8Array[]} */ const chunks = [];
+ const reader = stream.getReader();
+
+ // Read at least the file header fields we need.
+ log('Reading file header...');
+ while (chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) < 32) {
+ const { done, value } = await reader.read();
+ if (done) throw new Error('Unexpected end of file');
+ chunks.push(value);
+ }
+
+ // Assemble the file header.
+ let copyOffset = 0;
+ const header = new DataView(new ArrayBuffer(32));
+ for (const chunk of chunks) {
+ const dst = new Uint8Array(header.buffer, copyOffset);
+ dst.set(chunk.subarray(0, header.byteLength - copyOffset));
+ }
+
+ if (new TextDecoder().decode(header.buffer.slice(0, 16)) !== DBFILE_MAGIC) {
+ throw new Error('Not a SQLite database file');
+ }
+
+ // Extract page parameters.
+ const pageSize = (field => field === 1 ? 65536 : field)(header.getUint16(16));
+ const pageCount = header.getUint32(28);
+ log(`${pageCount} pages, ${pageSize} bytes each, ${pageCount * pageSize} bytes total`);
+
+ log('Copying pages...');
+ for (let i = 0; i < pageCount; ++i) {
+ // Read enough chunks to produce the next page.
+ while (chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) < pageSize) {
+ const { done, value } = await reader.read();
+ if (done) throw new Error('Unexpected end of file');
+ chunks.push(value);
+ }
+
+ // Assemble the page.
+ // TODO: Optimize case where first chunk has >= pageSize bytes.
+ let copyOffset = 0;
+ const page = new Uint8Array(pageSize);
+ while (copyOffset < pageSize) {
+ // Copy bytes into the page.
+ const src = chunks[0].subarray(0, pageSize - copyOffset);
+ const dst = new Uint8Array(page.buffer, copyOffset);
+ dst.set(src);
+
+ copyOffset += src.byteLength;
+ if (src.byteLength === chunks[0].byteLength) {
+ // All the bytes in the chunk were consumed.
+ chunks.shift();
+ } else {
+ chunks[0] = chunks[0].subarray(src.byteLength);
+ }
+ }
+
+ yield page;
+ }
+
+ const { done } = await reader.read();
+ if (!done) throw new Error('Unexpected data after last page');
+ };
+
+ const onFinally = [];
+ try {
+ log(`Deleting ${path}...`);
+ await vfs.xDelete(path, 1);
+
+ // Create the file.
+ log(`Creating ${path}...`);
+ const fileId = 1234;
+ const flags = VFS.SQLITE_OPEN_MAIN_DB | VFS.SQLITE_OPEN_CREATE | VFS.SQLITE_OPEN_READWRITE;
+ await check(vfs.xOpen(path, fileId, flags, new DataView(new ArrayBuffer(4))));
+ onFinally.push(() => vfs.xClose(fileId));
+
+ // Open a "transaction".
+ await check(vfs.xLock(fileId, VFS.SQLITE_LOCK_SHARED));
+ onFinally.push(() => vfs.xUnlock(fileId, VFS.SQLITE_LOCK_NONE));
+ await check(vfs.xLock(fileId, VFS.SQLITE_LOCK_RESERVED));
+ onFinally.push(() => vfs.xUnlock(fileId, VFS.SQLITE_LOCK_SHARED));
+ await check(vfs.xLock(fileId, VFS.SQLITE_LOCK_EXCLUSIVE));
+
+ const empty = new DataView(new ArrayBuffer(4));
+ await vfs.xFileControl(fileId, VFS.SQLITE_FCNTL_BEGIN_ATOMIC_WRITE, empty);
+
+ // Write pages.
+ let iOffset = 0;
+ for await (const page of pagify()) {
+ await check(vfs.xWrite(fileId, page, iOffset));
+ iOffset += page.byteLength;
+ }
+
+ await vfs.xFileControl(fileId, VFS.SQLITE_FCNTL_COMMIT_ATOMIC_WRITE, empty);
+ await vfs.xFileControl(fileId, VFS.SQLITE_FCNTL_SYNC, empty);
+ await vfs.xSync(fileId, VFS.SQLITE_SYNC_NORMAL);
+ } finally {
+ while (onFinally.length) {
+ await onFinally.pop()();
+ }
+ }
+}
+
+function log(...args) {
+ const timestamp = new Date().toLocaleTimeString(undefined, {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ fractionalSecondDigits: 3
+ });
+
+ const element = document.createElement('pre');
+ element.textContent = `${timestamp} ${args.join(' ')}`;
+ document.body.append(element);
+}
+
+async function check(code) {
+ if (await code !== VFS.SQLITE_OK) {
+ throw new Error(`Error code: ${code}`);
+ }
+}
\ No newline at end of file
diff --git a/demo/file/verifier.js b/demo/file/verifier.js
new file mode 100644
index 00000000..1a53e864
--- /dev/null
+++ b/demo/file/verifier.js
@@ -0,0 +1,25 @@
+import SQLiteESMFactory from '../../dist/wa-sqlite-async.mjs';
+import * as SQLite from '../../src/sqlite-api.js';
+import { IDBBatchAtomicVFS } from '../../src/examples/IDBBatchAtomicVFS.js';
+
+const SEARCH_PARAMS = new URLSearchParams(location.search);
+const IDB_NAME = SEARCH_PARAMS.get('idb') ?? 'sqlite-vfs';
+const DB_NAME = SEARCH_PARAMS.get('db') ?? 'sqlite.db';
+
+(async function() {
+ const module = await SQLiteESMFactory();
+ const sqlite3 = SQLite.Factory(module);
+
+ const vfs = new IDBBatchAtomicVFS(IDB_NAME);
+ sqlite3.vfs_register(vfs, true);
+
+ const db = await sqlite3.open_v2(DB_NAME, SQLite.SQLITE_OPEN_READWRITE, IDB_NAME);
+
+ const results = []
+ await sqlite3.exec(db, 'PRAGMA integrity_check;', (row, columns) => {
+ results.push(row[0]);
+ });
+ await sqlite3.close(db);
+
+ postMessage(results);
+})();
\ No newline at end of file