window.App = window.App || {}; App.sqlite = App.sqlite || {}; // A read-only SQLite file reader, just big enough to walk a table and hand back // its rows. It exists so a Hot Tub backup can be opened in the browser with no // dependency and no upload: the file stays on the device, and a 12MB database // costs one ArrayBuffer. // // What it covers: the file header, the schema table, table b-trees (interior and // leaf), record decoding, and payloads that spill onto overflow pages -- which // real rows here do. What it deliberately doesn't: writes, indexes, WITHOUT // ROWID tables, encryption, and a WAL sidecar (an exported backup is a single // checkpointed file; see readTable's error for the case that isn't true). // // Reference: https://www.sqlite.org/fileformat2.html (function() { const HEADER_MAGIC = 'SQLite format 3'; const PAGE_INTERIOR_INDEX = 0x02; const PAGE_INTERIOR_TABLE = 0x05; const PAGE_LEAF_INDEX = 0x0a; const PAGE_LEAF_TABLE = 0x0d; // Serial types 1-6 are big-endian two's complement integers of these widths. const INT_WIDTHS = { 1: 1, 2: 2, 3: 3, 4: 4, 5: 6, 6: 8 }; const utf8 = new TextDecoder('utf-8'); const utf16le = new TextDecoder('utf-16le'); const utf16be = new TextDecoder('utf-16be'); // Variable-length integer: up to nine bytes, seven bits each, most // significant first; a set high bit means "another byte follows". The ninth // byte, if reached, contributes all eight of its bits. function readVarint(bytes, offset) { let value = 0; for (let i = 0; i < 8; i++) { const byte = bytes[offset + i]; if (byte === undefined) throw new Error('sqlite: truncated varint'); if (i === 7) { // Bits can exceed 2^53 here in principle; no field this reader // looks at (payload sizes, rowids, serial types) comes close. value = value * 128 + byte; return [value, 8]; } value = value * 128 + (byte & 0x7f); if (!(byte & 0x80)) return [value, i + 1]; } const last = bytes[offset + 8]; return [value * 256 + last, 9]; } // Big-endian two's complement, `width` bytes wide. Eight-byte values go // through BigInt so the sign is applied exactly, then come back as a Number // when they fit in one (they always do for the fields read here). function readBigEndianInt(bytes, offset, width) { if (width === 8) { let big = 0n; for (let i = 0; i < 8; i++) big = (big << 8n) | BigInt(bytes[offset + i]); if (big >= 0x8000000000000000n) big -= 0x10000000000000000n; return (big >= BigInt(Number.MIN_SAFE_INTEGER) && big <= BigInt(Number.MAX_SAFE_INTEGER)) ? Number(big) : big; } let value = 0; for (let i = 0; i < width; i++) value = value * 256 + bytes[offset + i]; if (bytes[offset] & 0x80) value -= Math.pow(2, width * 8); return value; } function Database(buffer) { const bytes = new Uint8Array(buffer); if (bytes.length < 100) throw new Error('sqlite: file is too small to be a database'); const magic = utf8.decode(bytes.subarray(0, 16)); if (magic !== HEADER_MAGIC) throw new Error('sqlite: not a SQLite database'); const view = new DataView(buffer); let pageSize = view.getUint16(16); if (pageSize === 1) pageSize = 65536; const reserved = bytes[20]; // Bytes at the end of every page that the database reserves for itself // (encryption extensions use these); everything below counts in // "usable" space rather than page size. const usable = pageSize - reserved; const encoding = view.getUint32(56) || 1; this.bytes = bytes; this.view = view; this.pageSize = pageSize; this.usable = usable; this.encoding = encoding; this.pageCount = view.getUint32(28) || Math.floor(bytes.length / pageSize); } Database.prototype.decodeText = function(slice) { if (this.encoding === 2) return utf16le.decode(slice); if (this.encoding === 3) return utf16be.decode(slice); return utf8.decode(slice); }; Database.prototype.pageOffset = function(pageNumber) { const offset = (pageNumber - 1) * this.pageSize; if (offset < 0 || offset + this.pageSize > this.bytes.length) { throw new Error(`sqlite: page ${pageNumber} is outside the file`); } return offset; }; // Reassembles one cell's payload, following the overflow chain when the row // is too big to sit in a single page. The split point isn't "whatever fits" // -- SQLite picks it so that overflow pages stay well filled; the formulas // below are from the file-format spec and must match exactly, or every byte // after the split is misread. Database.prototype.readPayload = function(pageStart, localOffset, payloadSize) { const usable = this.usable; const maxLocal = usable - 35; let localSize = payloadSize; if (payloadSize > maxLocal) { const minLocal = Math.floor(((usable - 12) * 32) / 255) - 23; const surplus = minLocal + ((payloadSize - minLocal) % (usable - 4)); localSize = surplus <= maxLocal ? surplus : minLocal; } const payload = new Uint8Array(payloadSize); payload.set(this.bytes.subarray(pageStart + localOffset, pageStart + localOffset + localSize)); if (localSize === payloadSize) return payload; let written = localSize; let nextPage = this.view.getUint32(pageStart + localOffset + localSize); const seen = new Set(); while (nextPage && written < payloadSize) { if (seen.has(nextPage)) throw new Error('sqlite: overflow chain loops'); seen.add(nextPage); const offset = this.pageOffset(nextPage); const take = Math.min(usable - 4, payloadSize - written); payload.set(this.bytes.subarray(offset + 4, offset + 4 + take), written); written += take; nextPage = this.view.getUint32(offset); } if (written < payloadSize) throw new Error('sqlite: overflow chain ended early'); return payload; }; // Splits a record into values. `wanted` (a Set of column indexes) keeps this // from decoding columns nobody asked for -- a row here carries a couple of // kilobytes of format JSON that an import has no use for. Database.prototype.decodeRecord = function(payload, wanted) { const [headerSize, headerVarintLength] = readVarint(payload, 0); const serialTypes = []; let cursor = headerVarintLength; while (cursor < headerSize) { const [serialType, length] = readVarint(payload, cursor); serialTypes.push(serialType); cursor += length; } const values = new Array(serialTypes.length).fill(null); let bodyOffset = headerSize; for (let i = 0; i < serialTypes.length; i++) { const serialType = serialTypes[i]; let size = 0; if (serialType >= 12) size = Math.floor((serialType - (serialType % 2 === 0 ? 12 : 13)) / 2); else if (INT_WIDTHS[serialType]) size = INT_WIDTHS[serialType]; else if (serialType === 7) size = 8; if (!wanted || wanted.has(i)) { if (serialType === 0) values[i] = null; else if (INT_WIDTHS[serialType]) values[i] = readBigEndianInt(payload, bodyOffset, INT_WIDTHS[serialType]); else if (serialType === 7) values[i] = new DataView(payload.buffer, payload.byteOffset + bodyOffset, 8).getFloat64(0); else if (serialType === 8) values[i] = 0; else if (serialType === 9) values[i] = 1; else if (serialType >= 12 && serialType % 2 === 0) values[i] = payload.slice(bodyOffset, bodyOffset + size); else if (serialType >= 13) values[i] = this.decodeText(payload.subarray(bodyOffset, bodyOffset + size)); } bodyOffset += size; } return values; }; // Depth-first walk of a table b-tree, calling onRow(values, rowid) per row. Database.prototype.walkTable = function(rootPage, wanted, onRow) { const stack = [rootPage]; const seen = new Set(); while (stack.length) { const pageNumber = stack.pop(); if (!pageNumber || seen.has(pageNumber)) continue; seen.add(pageNumber); const pageStart = this.pageOffset(pageNumber); // Page 1 carries the 100-byte file header before its b-tree header. const headerStart = pageNumber === 1 ? pageStart + 100 : pageStart; const pageType = this.bytes[headerStart]; if (pageType === PAGE_INTERIOR_INDEX || pageType === PAGE_LEAF_INDEX) { throw new Error('sqlite: WITHOUT ROWID tables are not supported'); } if (pageType !== PAGE_INTERIOR_TABLE && pageType !== PAGE_LEAF_TABLE) { throw new Error(`sqlite: unexpected page type ${pageType} on page ${pageNumber}`); } const cellCount = this.view.getUint16(headerStart + 3); const isInterior = pageType === PAGE_INTERIOR_TABLE; const cellPointers = headerStart + (isInterior ? 12 : 8); if (isInterior) { stack.push(this.view.getUint32(headerStart + 8)); // right-most child } for (let i = 0; i < cellCount; i++) { const cellOffset = pageStart + this.view.getUint16(cellPointers + i * 2); if (isInterior) { stack.push(this.view.getUint32(cellOffset)); continue; } const [payloadSize, sizeLength] = readVarint(this.bytes, cellOffset); const [rowid, rowidLength] = readVarint(this.bytes, cellOffset + sizeLength); const payload = this.readPayload(pageStart, cellOffset - pageStart + sizeLength + rowidLength, payloadSize); onRow(this.decodeRecord(payload, wanted), rowid); } } }; // The schema lives in a table b-tree rooted at page 1: one row per table, // index, view and trigger, with the columns below. const MASTER_COLUMNS = ['type', 'name', 'tbl_name', 'rootpage', 'sql']; Database.prototype.schema = function() { if (this._schema) return this._schema; const entries = []; this.walkTable(1, null, (values) => { const entry = {}; MASTER_COLUMNS.forEach((name, i) => { entry[name] = values[i]; }); entries.push(entry); }); this._schema = entries; return entries; }; Database.prototype.tableNames = function() { return this.schema().filter((entry) => entry.type === 'table').map((entry) => entry.name); }; // Column names in declaration order, read out of the CREATE TABLE text -- // the file format itself doesn't store them anywhere else. Table-level // constraints look like columns to a naive split, so they're skipped by // keyword; the same walk notes an INTEGER PRIMARY KEY, whose value lives in // the rowid rather than in the record. const TABLE_CONSTRAINT_RE = /^(primary|unique|check|foreign|constraint)\b/i; function parseColumns(sql) { const open = sql.indexOf('('); const close = sql.lastIndexOf(')'); if (open < 0 || close < open) return { names: [], rowidAlias: -1 }; const body = sql.slice(open + 1, close); const parts = []; let depth = 0; let current = ''; let quote = ''; for (const char of body) { if (quote) { current += char; if (char === quote) quote = ''; continue; } if (char === '"' || char === "'" || char === '`') { quote = char; current += char; continue; } if (char === '(') depth++; if (char === ')') depth--; if (char === ',' && depth === 0) { parts.push(current); current = ''; continue; } current += char; } parts.push(current); const names = []; let rowidAlias = -1; parts.forEach((part) => { const definition = part.trim(); if (!definition || TABLE_CONSTRAINT_RE.test(definition)) return; const match = definition.match(/^(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][\w$]*))/); if (!match) return; const name = match[1] || match[2] || match[3] || match[4]; if (/\binteger\s+primary\s+key\b/i.test(definition)) rowidAlias = names.length; names.push(name); }); return { names, rowidAlias }; } // Reads every row of `tableName` as an object. `columns`, when given, limits // both what is decoded and what each object carries. Database.prototype.readTable = function(tableName, options) { const wantedColumns = options && options.columns; const entry = this.schema().find((row) => row.type === 'table' && row.name === tableName); if (!entry) throw new Error(`sqlite: no table named ${tableName}`); if (!entry.rootpage) throw new Error(`sqlite: table ${tableName} has no data pages`); const { names, rowidAlias } = parseColumns(entry.sql || ''); if (!names.length) throw new Error(`sqlite: could not read the columns of ${tableName}`); let indexes = names.map((_, i) => i); if (Array.isArray(wantedColumns) && wantedColumns.length) { indexes = wantedColumns.map((name) => { const index = names.indexOf(name); if (index < 0) throw new Error(`sqlite: ${tableName} has no column ${name}`); return index; }); } const wanted = new Set(indexes); const rows = []; this.walkTable(entry.rootpage, wanted, (values, rowid) => { const row = {}; indexes.forEach((index) => { row[names[index]] = (index === rowidAlias && values[index] === null) ? rowid : values[index]; }); rows.push(row); }); return rows; }; App.sqlite.open = function(buffer) { return new Database(buffer); }; })();