@blulotus/trellis-core 0.7.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/LICENSE +235 -0
- package/dist/channel/api/assert.d.ts +3 -0
- package/dist/channel/api/assert.d.ts.map +1 -0
- package/dist/channel/api/assert.js +11 -0
- package/dist/channel/api/assert.js.map +1 -0
- package/dist/channel/api/context.d.ts +21 -0
- package/dist/channel/api/context.d.ts.map +1 -0
- package/dist/channel/api/context.js +99 -0
- package/dist/channel/api/context.js.map +1 -0
- package/dist/channel/api/create.d.ts +9 -0
- package/dist/channel/api/create.d.ts.map +1 -0
- package/dist/channel/api/create.js +104 -0
- package/dist/channel/api/create.js.map +1 -0
- package/dist/channel/api/inbox.d.ts +51 -0
- package/dist/channel/api/inbox.d.ts.map +1 -0
- package/dist/channel/api/inbox.js +176 -0
- package/dist/channel/api/inbox.js.map +1 -0
- package/dist/channel/api/interrupt.d.ts +31 -0
- package/dist/channel/api/interrupt.d.ts.map +1 -0
- package/dist/channel/api/interrupt.js +102 -0
- package/dist/channel/api/interrupt.js.map +1 -0
- package/dist/channel/api/post-thread.d.ts +14 -0
- package/dist/channel/api/post-thread.d.ts.map +1 -0
- package/dist/channel/api/post-thread.js +109 -0
- package/dist/channel/api/post-thread.js.map +1 -0
- package/dist/channel/api/read.d.ts +17 -0
- package/dist/channel/api/read.d.ts.map +1 -0
- package/dist/channel/api/read.js +44 -0
- package/dist/channel/api/read.js.map +1 -0
- package/dist/channel/api/resolve.d.ts +21 -0
- package/dist/channel/api/resolve.d.ts.map +1 -0
- package/dist/channel/api/resolve.js +28 -0
- package/dist/channel/api/resolve.js.map +1 -0
- package/dist/channel/api/runtime.d.ts +70 -0
- package/dist/channel/api/runtime.d.ts.map +1 -0
- package/dist/channel/api/runtime.js +11 -0
- package/dist/channel/api/runtime.js.map +1 -0
- package/dist/channel/api/send.d.ts +4 -0
- package/dist/channel/api/send.d.ts.map +1 -0
- package/dist/channel/api/send.js +52 -0
- package/dist/channel/api/send.js.map +1 -0
- package/dist/channel/api/spawn.d.ts +13 -0
- package/dist/channel/api/spawn.d.ts.map +1 -0
- package/dist/channel/api/spawn.js +54 -0
- package/dist/channel/api/spawn.js.map +1 -0
- package/dist/channel/api/title.d.ts +5 -0
- package/dist/channel/api/title.d.ts.map +1 -0
- package/dist/channel/api/title.js +40 -0
- package/dist/channel/api/title.js.map +1 -0
- package/dist/channel/api/types.d.ts +65 -0
- package/dist/channel/api/types.d.ts.map +1 -0
- package/dist/channel/api/types.js +2 -0
- package/dist/channel/api/types.js.map +1 -0
- package/dist/channel/api/watch-channels.d.ts +40 -0
- package/dist/channel/api/watch-channels.d.ts.map +1 -0
- package/dist/channel/api/watch-channels.js +143 -0
- package/dist/channel/api/watch-channels.js.map +1 -0
- package/dist/channel/api/watch.d.ts +11 -0
- package/dist/channel/api/watch.d.ts.map +1 -0
- package/dist/channel/api/watch.js +17 -0
- package/dist/channel/api/watch.js.map +1 -0
- package/dist/channel/api/workers.d.ts +69 -0
- package/dist/channel/api/workers.d.ts.map +1 -0
- package/dist/channel/api/workers.js +145 -0
- package/dist/channel/api/workers.js.map +1 -0
- package/dist/channel/index.d.ts +38 -0
- package/dist/channel/index.d.ts.map +1 -0
- package/dist/channel/index.js +23 -0
- package/dist/channel/index.js.map +1 -0
- package/dist/channel/internal/store/channel-metadata.d.ts +23 -0
- package/dist/channel/internal/store/channel-metadata.d.ts.map +1 -0
- package/dist/channel/internal/store/channel-metadata.js +94 -0
- package/dist/channel/internal/store/channel-metadata.js.map +1 -0
- package/dist/channel/internal/store/delivery.d.ts +27 -0
- package/dist/channel/internal/store/delivery.d.ts.map +1 -0
- package/dist/channel/internal/store/delivery.js +37 -0
- package/dist/channel/internal/store/delivery.js.map +1 -0
- package/dist/channel/internal/store/events.d.ts +215 -0
- package/dist/channel/internal/store/events.d.ts.map +1 -0
- package/dist/channel/internal/store/events.js +206 -0
- package/dist/channel/internal/store/events.js.map +1 -0
- package/dist/channel/internal/store/filter.d.ts +21 -0
- package/dist/channel/internal/store/filter.d.ts.map +1 -0
- package/dist/channel/internal/store/filter.js +75 -0
- package/dist/channel/internal/store/filter.js.map +1 -0
- package/dist/channel/internal/store/inbox.d.ts +17 -0
- package/dist/channel/internal/store/inbox.d.ts.map +1 -0
- package/dist/channel/internal/store/inbox.js +30 -0
- package/dist/channel/internal/store/inbox.js.map +1 -0
- package/dist/channel/internal/store/lock.d.ts +17 -0
- package/dist/channel/internal/store/lock.d.ts.map +1 -0
- package/dist/channel/internal/store/lock.js +88 -0
- package/dist/channel/internal/store/lock.js.map +1 -0
- package/dist/channel/internal/store/paths.d.ts +57 -0
- package/dist/channel/internal/store/paths.d.ts.map +1 -0
- package/dist/channel/internal/store/paths.js +264 -0
- package/dist/channel/internal/store/paths.js.map +1 -0
- package/dist/channel/internal/store/schema.d.ts +77 -0
- package/dist/channel/internal/store/schema.d.ts.map +1 -0
- package/dist/channel/internal/store/schema.js +127 -0
- package/dist/channel/internal/store/schema.js.map +1 -0
- package/dist/channel/internal/store/seq.d.ts +12 -0
- package/dist/channel/internal/store/seq.d.ts.map +1 -0
- package/dist/channel/internal/store/seq.js +133 -0
- package/dist/channel/internal/store/seq.js.map +1 -0
- package/dist/channel/internal/store/thread-state.d.ts +37 -0
- package/dist/channel/internal/store/thread-state.d.ts.map +1 -0
- package/dist/channel/internal/store/thread-state.js +206 -0
- package/dist/channel/internal/store/thread-state.js.map +1 -0
- package/dist/channel/internal/store/watch.d.ts +10 -0
- package/dist/channel/internal/store/watch.d.ts.map +1 -0
- package/dist/channel/internal/store/watch.js +122 -0
- package/dist/channel/internal/store/watch.js.map +1 -0
- package/dist/channel/internal/store/worker-state.d.ts +56 -0
- package/dist/channel/internal/store/worker-state.d.ts.map +1 -0
- package/dist/channel/internal/store/worker-state.js +220 -0
- package/dist/channel/internal/store/worker-state.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mem/adapters/claude.d.ts +22 -0
- package/dist/mem/adapters/claude.d.ts.map +1 -0
- package/dist/mem/adapters/claude.js +209 -0
- package/dist/mem/adapters/claude.js.map +1 -0
- package/dist/mem/adapters/codex.d.ts +55 -0
- package/dist/mem/adapters/codex.d.ts.map +1 -0
- package/dist/mem/adapters/codex.js +344 -0
- package/dist/mem/adapters/codex.js.map +1 -0
- package/dist/mem/adapters/grok.d.ts +41 -0
- package/dist/mem/adapters/grok.d.ts.map +1 -0
- package/dist/mem/adapters/grok.js +202 -0
- package/dist/mem/adapters/grok.js.map +1 -0
- package/dist/mem/adapters/opencode.d.ts +19 -0
- package/dist/mem/adapters/opencode.d.ts.map +1 -0
- package/dist/mem/adapters/opencode.js +25 -0
- package/dist/mem/adapters/opencode.js.map +1 -0
- package/dist/mem/adapters/pi.d.ts +24 -0
- package/dist/mem/adapters/pi.d.ts.map +1 -0
- package/dist/mem/adapters/pi.js +290 -0
- package/dist/mem/adapters/pi.js.map +1 -0
- package/dist/mem/adapters/zcode.d.ts +76 -0
- package/dist/mem/adapters/zcode.d.ts.map +1 -0
- package/dist/mem/adapters/zcode.js +434 -0
- package/dist/mem/adapters/zcode.js.map +1 -0
- package/dist/mem/context.d.ts +23 -0
- package/dist/mem/context.d.ts.map +1 -0
- package/dist/mem/context.js +120 -0
- package/dist/mem/context.js.map +1 -0
- package/dist/mem/dialogue.d.ts +32 -0
- package/dist/mem/dialogue.d.ts.map +1 -0
- package/dist/mem/dialogue.js +0 -0
- package/dist/mem/dialogue.js.map +1 -0
- package/dist/mem/filter.d.ts +31 -0
- package/dist/mem/filter.d.ts.map +1 -0
- package/dist/mem/filter.js +66 -0
- package/dist/mem/filter.js.map +1 -0
- package/dist/mem/index.d.ts +18 -0
- package/dist/mem/index.d.ts.map +1 -0
- package/dist/mem/index.js +17 -0
- package/dist/mem/index.js.map +1 -0
- package/dist/mem/internal/jsonl.d.ts +35 -0
- package/dist/mem/internal/jsonl.d.ts.map +1 -0
- package/dist/mem/internal/jsonl.js +125 -0
- package/dist/mem/internal/jsonl.js.map +1 -0
- package/dist/mem/internal/paths.d.ts +38 -0
- package/dist/mem/internal/paths.d.ts.map +1 -0
- package/dist/mem/internal/paths.js +126 -0
- package/dist/mem/internal/paths.js.map +1 -0
- package/dist/mem/internal/sqlite-readonly.d.ts +77 -0
- package/dist/mem/internal/sqlite-readonly.d.ts.map +1 -0
- package/dist/mem/internal/sqlite-readonly.js +797 -0
- package/dist/mem/internal/sqlite-readonly.js.map +1 -0
- package/dist/mem/phase.d.ts +45 -0
- package/dist/mem/phase.d.ts.map +1 -0
- package/dist/mem/phase.js +220 -0
- package/dist/mem/phase.js.map +1 -0
- package/dist/mem/projects.d.ts +13 -0
- package/dist/mem/projects.d.ts.map +1 -0
- package/dist/mem/projects.js +47 -0
- package/dist/mem/projects.js.map +1 -0
- package/dist/mem/search.d.ts +39 -0
- package/dist/mem/search.d.ts.map +1 -0
- package/dist/mem/search.js +133 -0
- package/dist/mem/search.js.map +1 -0
- package/dist/mem/sessions.d.ts +38 -0
- package/dist/mem/sessions.d.ts.map +1 -0
- package/dist/mem/sessions.js +313 -0
- package/dist/mem/sessions.js.map +1 -0
- package/dist/mem/types.d.ts +188 -0
- package/dist/mem/types.d.ts.map +1 -0
- package/dist/mem/types.js +10 -0
- package/dist/mem/types.js.map +1 -0
- package/dist/task/index.d.ts +9 -0
- package/dist/task/index.d.ts.map +1 -0
- package/dist/task/index.js +9 -0
- package/dist/task/index.js.map +1 -0
- package/dist/task/paths.d.ts +37 -0
- package/dist/task/paths.d.ts.map +1 -0
- package/dist/task/paths.js +49 -0
- package/dist/task/paths.js.map +1 -0
- package/dist/task/phase.d.ts +27 -0
- package/dist/task/phase.d.ts.map +1 -0
- package/dist/task/phase.js +24 -0
- package/dist/task/phase.js.map +1 -0
- package/dist/task/records.d.ts +39 -0
- package/dist/task/records.d.ts.map +1 -0
- package/dist/task/records.js +89 -0
- package/dist/task/records.js.map +1 -0
- package/dist/task/schema.d.ts +77 -0
- package/dist/task/schema.d.ts.map +1 -0
- package/dist/task/schema.js +220 -0
- package/dist/task/schema.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency read-only SQLite file parser.
|
|
3
|
+
*
|
|
4
|
+
* `@blulotus/trellis-core` is intentionally free of runtime dependencies —
|
|
5
|
+
* `better-sqlite3` was tried for the OpenCode adapter and reverted because its
|
|
6
|
+
* native build chain broke `npm install` on Windows + restricted networks. This
|
|
7
|
+
* parser is the minimum viable reader needed by the ZCode `mem` adapter: list
|
|
8
|
+
* tables, full-scan one table, return rows as `{ [columnName]: value }`. It is
|
|
9
|
+
* NOT a SQL engine: no WHERE pushdown, no index lookup, no writes.
|
|
10
|
+
*
|
|
11
|
+
* Scope:
|
|
12
|
+
* - Database header (page size, db size, text encoding, WAL flag)
|
|
13
|
+
* - Table b-tree leaf (0x0d) + interior (0x05) traversal
|
|
14
|
+
* - Record decode for TEXT / INTEGER / NULL / float64 / BLOB(serial only)
|
|
15
|
+
* - Overflow page chains (long TEXT rows like ZCode `message.data`)
|
|
16
|
+
* - WAL replay: stable main/WAL snapshot, WAL-index `mxFrame` end mark, and
|
|
17
|
+
* cumulative header/frame checksum verification.
|
|
18
|
+
*
|
|
19
|
+
* Out of scope:
|
|
20
|
+
* - WITHOUT ROWID tables (return [] — their rows live in index b-trees,
|
|
21
|
+
* which this parser doesn't walk). ZCode's tables are normal rowid tables.
|
|
22
|
+
*
|
|
23
|
+
* Format references:
|
|
24
|
+
* - https://www.sqlite.org/fileformat2.html
|
|
25
|
+
* - https://www.sqlite.org/walformat.html
|
|
26
|
+
*
|
|
27
|
+
* All multi-byte integers are big-endian (SQLite's native byte order).
|
|
28
|
+
*/
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
// ---------- errors ----------
|
|
31
|
+
/** Thrown when the SQLite file is structurally invalid. Adapters are expected
|
|
32
|
+
* to catch this and degrade to an empty result rather than crash the CLI. */
|
|
33
|
+
export class SqliteParseError extends Error {
|
|
34
|
+
cause;
|
|
35
|
+
constructor(message, cause) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.cause = cause;
|
|
38
|
+
this.name = "SqliteParseError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Thrown when the database files do not remain stable across all snapshot
|
|
43
|
+
* capture attempts. This is deliberately distinct from a malformed database:
|
|
44
|
+
* callers must not parse a snapshot whose consistency they could not verify.
|
|
45
|
+
*/
|
|
46
|
+
export class SqliteSnapshotUnstableError extends SqliteParseError {
|
|
47
|
+
constructor(mainPath) {
|
|
48
|
+
super(`SQLite main/WAL files changed while capturing a read snapshot: ${mainPath}`);
|
|
49
|
+
this.name = "SqliteSnapshotUnstableError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ---------- low-level byte readers (big-endian) ----------
|
|
53
|
+
/** Read one byte at `off`, treating out-of-range reads as 0. Avoids the
|
|
54
|
+
* `no-non-null-assertion` lint rule while keeping byte access concise. */
|
|
55
|
+
function byteAt(buf, off) {
|
|
56
|
+
return off >= 0 && off < buf.length ? buf[off] : 0;
|
|
57
|
+
}
|
|
58
|
+
function readUint32BE(buf, off) {
|
|
59
|
+
return ((byteAt(buf, off) * 0x1000000 +
|
|
60
|
+
((byteAt(buf, off + 1) << 16) |
|
|
61
|
+
(byteAt(buf, off + 2) << 8) |
|
|
62
|
+
byteAt(buf, off + 3))) >>>
|
|
63
|
+
0);
|
|
64
|
+
}
|
|
65
|
+
function readUint16BE(buf, off) {
|
|
66
|
+
return ((byteAt(buf, off) << 8) | byteAt(buf, off + 1)) >>> 0;
|
|
67
|
+
}
|
|
68
|
+
function readUint8(buf, off) {
|
|
69
|
+
return byteAt(buf, off);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decode a SQLite varint (1-9 bytes, big-endian, high bit = continuation).
|
|
73
|
+
* Returns the integer value and the offset past the varint. The 9th byte
|
|
74
|
+
* contributes all 8 bits.
|
|
75
|
+
*
|
|
76
|
+
* Uses multiplication (`* 128` / `* 256`) rather than left-shift (`<< 7`):
|
|
77
|
+
* `<<` coerces to int32 and wraps past 2³¹, which would corrupt large
|
|
78
|
+
* varints (rowids > 2³¹, or TEXT/BLOB serial types > ~2⁴⁰). Multiplication
|
|
79
|
+
* preserves full precision up to 2⁵³ (Number.MAX_SAFE_INTEGER), which covers
|
|
80
|
+
* every value SQLite can store.
|
|
81
|
+
*/
|
|
82
|
+
function readVarint(buf, off) {
|
|
83
|
+
let result = 0;
|
|
84
|
+
for (let i = 0; i < 8; i++) {
|
|
85
|
+
const byte = byteAt(buf, off + i);
|
|
86
|
+
if (byte < 0x80) {
|
|
87
|
+
result = result * 128 + byte;
|
|
88
|
+
return { value: result, next: off + i + 1 };
|
|
89
|
+
}
|
|
90
|
+
result = result * 128 + (byte & 0x7f);
|
|
91
|
+
}
|
|
92
|
+
// 9th byte: all 8 bits
|
|
93
|
+
const ninth = byteAt(buf, off + 8);
|
|
94
|
+
result = result * 256 + ninth;
|
|
95
|
+
return { value: result, next: off + 9 };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Decode a big-endian signed integer of `n` bytes (1,2,3,4,6,8) into a
|
|
99
|
+
* JS number. Used for INTEGER column serial types.
|
|
100
|
+
*
|
|
101
|
+
* Like `readVarint`, this accumulates via multiplication (`* 256`) rather than
|
|
102
|
+
* `<<`, which would wrap to int32 past 2³¹. For n=8 (serial type 6, the full
|
|
103
|
+
* 64-bit INTEGER) values beyond 2⁵³ still lose precision — this is inherent to
|
|
104
|
+
* JS numbers and acceptable here (ZCode never stores such large integers).
|
|
105
|
+
*/
|
|
106
|
+
function readSignedBE(buf, off, n) {
|
|
107
|
+
let val = 0;
|
|
108
|
+
for (let i = 0; i < n; i++)
|
|
109
|
+
val = val * 256 + byteAt(buf, off + i);
|
|
110
|
+
// Sign-extend if the high bit is set.
|
|
111
|
+
const bits = n * 8;
|
|
112
|
+
if (val >= 2 ** (bits - 1))
|
|
113
|
+
val -= 2 ** bits;
|
|
114
|
+
return val;
|
|
115
|
+
}
|
|
116
|
+
const DB_HEADER_SIZE = 100;
|
|
117
|
+
const WAL_HEADER_SIZE = 32;
|
|
118
|
+
const WAL_FRAME_HEADER_SIZE = 24;
|
|
119
|
+
const WAL_INDEX_HEADER_SIZE = 96;
|
|
120
|
+
const SNAPSHOT_ATTEMPTS = 3;
|
|
121
|
+
function parseDbHeader(buf) {
|
|
122
|
+
// Magic "SQLite format 3\0" (note: mixed-case, lowercase 'i').
|
|
123
|
+
if (buf[0] !== 0x53 || // S
|
|
124
|
+
buf[1] !== 0x51 || // Q
|
|
125
|
+
buf[2] !== 0x4c || // l
|
|
126
|
+
buf[3] !== 0x69 || // i
|
|
127
|
+
buf[4] !== 0x74 || // t
|
|
128
|
+
buf[5] !== 0x65 // e
|
|
129
|
+
) {
|
|
130
|
+
throw new SqliteParseError("not a SQLite database (bad magic)");
|
|
131
|
+
}
|
|
132
|
+
const ps = readUint16BE(buf, 16);
|
|
133
|
+
const pageSize = ps === 1 ? 65536 : ps;
|
|
134
|
+
if (pageSize < 512 || (pageSize & (pageSize - 1)) !== 0) {
|
|
135
|
+
throw new SqliteParseError(`invalid page size ${pageSize}`);
|
|
136
|
+
}
|
|
137
|
+
const textEncoding = readUint32BE(buf, 56);
|
|
138
|
+
const reservedBytes = readUint8(buf, 20);
|
|
139
|
+
const dbSizePages = readUint32BE(buf, 28);
|
|
140
|
+
return {
|
|
141
|
+
pageSize,
|
|
142
|
+
dbSizePages,
|
|
143
|
+
textEncoding: textEncoding || 1,
|
|
144
|
+
reservedBytes,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const HOST_IS_LITTLE_ENDIAN = (() => {
|
|
148
|
+
const bytes = new Uint8Array(4);
|
|
149
|
+
new Uint32Array(bytes.buffer)[0] = 1;
|
|
150
|
+
return bytes[0] === 1;
|
|
151
|
+
})();
|
|
152
|
+
function readUint32LE(buf, off) {
|
|
153
|
+
return ((byteAt(buf, off) |
|
|
154
|
+
(byteAt(buf, off + 1) << 8) |
|
|
155
|
+
(byteAt(buf, off + 2) << 16) |
|
|
156
|
+
(byteAt(buf, off + 3) * 0x1000000)) >>>
|
|
157
|
+
0);
|
|
158
|
+
}
|
|
159
|
+
function readUint32Native(buf, off) {
|
|
160
|
+
return HOST_IS_LITTLE_ENDIAN
|
|
161
|
+
? readUint32LE(buf, off)
|
|
162
|
+
: readUint32BE(buf, off);
|
|
163
|
+
}
|
|
164
|
+
function readUint16Native(buf, off) {
|
|
165
|
+
return HOST_IS_LITTLE_ENDIAN
|
|
166
|
+
? (byteAt(buf, off) | (byteAt(buf, off + 1) << 8)) >>> 0
|
|
167
|
+
: readUint16BE(buf, off);
|
|
168
|
+
}
|
|
169
|
+
function equalBytes(a, b) {
|
|
170
|
+
if (a === null || b === null)
|
|
171
|
+
return a === b;
|
|
172
|
+
if (a.length !== b.length)
|
|
173
|
+
return false;
|
|
174
|
+
for (let i = 0; i < a.length; i++) {
|
|
175
|
+
if (a[i] !== b[i])
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
function fileStamp(path) {
|
|
181
|
+
try {
|
|
182
|
+
const stat = fs.statSync(path);
|
|
183
|
+
return {
|
|
184
|
+
size: stat.size,
|
|
185
|
+
mtimeMs: stat.mtimeMs,
|
|
186
|
+
ctimeMs: stat.ctimeMs,
|
|
187
|
+
dev: stat.dev,
|
|
188
|
+
ino: stat.ino,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
const code = error.code;
|
|
193
|
+
if (code === "ENOENT")
|
|
194
|
+
return null;
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function sameStamp(a, b) {
|
|
199
|
+
if (a === null || b === null)
|
|
200
|
+
return a === b;
|
|
201
|
+
return (a.size === b.size &&
|
|
202
|
+
a.mtimeMs === b.mtimeMs &&
|
|
203
|
+
a.ctimeMs === b.ctimeMs &&
|
|
204
|
+
a.dev === b.dev &&
|
|
205
|
+
a.ino === b.ino);
|
|
206
|
+
}
|
|
207
|
+
function readOptionalFile(path) {
|
|
208
|
+
try {
|
|
209
|
+
return fs.readFileSync(path);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
const code = error.code;
|
|
213
|
+
if (code === "ENOENT")
|
|
214
|
+
return null;
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function readWalIndexPrefix(path) {
|
|
219
|
+
const bytes = readOptionalFile(path);
|
|
220
|
+
return bytes === null
|
|
221
|
+
? null
|
|
222
|
+
: bytes.subarray(0, Math.min(bytes.length, WAL_INDEX_HEADER_SIZE));
|
|
223
|
+
}
|
|
224
|
+
function walChecksum(bytes, off, length, bigEndian, seed0, seed1) {
|
|
225
|
+
if (length % 8 !== 0) {
|
|
226
|
+
throw new SqliteParseError("WAL checksum input is not 8-byte aligned");
|
|
227
|
+
}
|
|
228
|
+
const readWord = bigEndian ? readUint32BE : readUint32LE;
|
|
229
|
+
let s0 = seed0 >>> 0;
|
|
230
|
+
let s1 = seed1 >>> 0;
|
|
231
|
+
for (let i = off; i < off + length; i += 8) {
|
|
232
|
+
s0 = (s0 + readWord(bytes, i) + s1) >>> 0;
|
|
233
|
+
s1 = (s1 + readWord(bytes, i + 4) + s0) >>> 0;
|
|
234
|
+
}
|
|
235
|
+
return [s0, s1];
|
|
236
|
+
}
|
|
237
|
+
function parseWalIndexHeader(bytes) {
|
|
238
|
+
if (bytes === null)
|
|
239
|
+
return null;
|
|
240
|
+
if (bytes.length < WAL_INDEX_HEADER_SIZE) {
|
|
241
|
+
throw new SqliteParseError("WAL-index header is truncated");
|
|
242
|
+
}
|
|
243
|
+
const first = bytes.subarray(0, 48);
|
|
244
|
+
const second = bytes.subarray(48, 96);
|
|
245
|
+
if (!equalBytes(first, second)) {
|
|
246
|
+
throw new SqliteParseError("WAL-index header copies disagree");
|
|
247
|
+
}
|
|
248
|
+
if (byteAt(first, 12) !== 1) {
|
|
249
|
+
throw new SqliteParseError("WAL-index is not initialized");
|
|
250
|
+
}
|
|
251
|
+
const [checksum1, checksum2] = walChecksum(first, 0, 40, !HOST_IS_LITTLE_ENDIAN, 0, 0);
|
|
252
|
+
if (checksum1 !== readUint32Native(first, 40) ||
|
|
253
|
+
checksum2 !== readUint32Native(first, 44)) {
|
|
254
|
+
throw new SqliteParseError("WAL-index header checksum mismatch");
|
|
255
|
+
}
|
|
256
|
+
const encodedPageSize = readUint16Native(first, 14);
|
|
257
|
+
return {
|
|
258
|
+
pageSize: encodedPageSize === 1 ? 65536 : encodedPageSize,
|
|
259
|
+
mxFrame: readUint32Native(first, 16),
|
|
260
|
+
nPage: readUint32Native(first, 20),
|
|
261
|
+
frameChecksum1: readUint32Native(first, 24),
|
|
262
|
+
frameChecksum2: readUint32Native(first, 28),
|
|
263
|
+
salt1: readUint32BE(first, 32),
|
|
264
|
+
salt2: readUint32BE(first, 36),
|
|
265
|
+
bigEndianChecksum: byteAt(first, 13) !== 0,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
function captureSnapshot(mainPath) {
|
|
269
|
+
const walPath = mainPath + "-wal";
|
|
270
|
+
const shmPath = mainPath + "-shm";
|
|
271
|
+
let lastError;
|
|
272
|
+
for (let attempt = 0; attempt < SNAPSHOT_ATTEMPTS; attempt++) {
|
|
273
|
+
try {
|
|
274
|
+
const shmBefore = readWalIndexPrefix(shmPath);
|
|
275
|
+
const mainBefore = fileStamp(mainPath);
|
|
276
|
+
const walBefore = fileStamp(walPath);
|
|
277
|
+
if (mainBefore === null) {
|
|
278
|
+
throw new SqliteParseError(`cannot read db file: ${mainPath}`);
|
|
279
|
+
}
|
|
280
|
+
const mainBytes = fs.readFileSync(mainPath);
|
|
281
|
+
const walBytes = readOptionalFile(walPath);
|
|
282
|
+
const mainAfter = fileStamp(mainPath);
|
|
283
|
+
const walAfter = fileStamp(walPath);
|
|
284
|
+
const shmAfter = readWalIndexPrefix(shmPath);
|
|
285
|
+
if (!sameStamp(mainBefore, mainAfter) ||
|
|
286
|
+
!sameStamp(walBefore, walAfter) ||
|
|
287
|
+
!equalBytes(shmBefore, shmAfter)) {
|
|
288
|
+
lastError = new SqliteSnapshotUnstableError(mainPath);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const walIndex = walBytes === null ? null : parseWalIndexHeader(shmAfter);
|
|
292
|
+
return { mainBytes, walBytes, walIndex };
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
lastError = error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
throw lastError instanceof SqliteParseError
|
|
299
|
+
? lastError
|
|
300
|
+
: new SqliteParseError(`cannot capture SQLite snapshot: ${mainPath}`, lastError);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Validate and replay captured WAL bytes. Returns a page-overrides map or
|
|
304
|
+
* `null` when the WAL is absent.
|
|
305
|
+
*
|
|
306
|
+
* Algorithm (per https://www.sqlite.org/walformat.html):
|
|
307
|
+
* 1. Validate the 32-byte header checksum, salts, and page size.
|
|
308
|
+
* 2. Use WAL-index `mxFrame` as the committed end mark when available;
|
|
309
|
+
* otherwise stop recovery at the first invalid/stale frame.
|
|
310
|
+
* 3. Validate every frame's cumulative checksum seeded by the header.
|
|
311
|
+
* 4. Replay pages through the committed end mark, with later frames winning.
|
|
312
|
+
*/
|
|
313
|
+
function loadWal(walBytes, expectedPageSize, walIndex) {
|
|
314
|
+
if (walBytes === null)
|
|
315
|
+
return null;
|
|
316
|
+
if (walBytes.length < WAL_HEADER_SIZE)
|
|
317
|
+
return null;
|
|
318
|
+
const magic = readUint32BE(walBytes, 0);
|
|
319
|
+
if (magic !== 0x377f0682 && magic !== 0x377f0683) {
|
|
320
|
+
throw new SqliteParseError("invalid WAL magic");
|
|
321
|
+
}
|
|
322
|
+
const bigEndianChecksum = magic === 0x377f0683;
|
|
323
|
+
const walPageSize = readUint32BE(walBytes, 8);
|
|
324
|
+
if (walPageSize !== expectedPageSize) {
|
|
325
|
+
throw new SqliteParseError("WAL page size does not match database");
|
|
326
|
+
}
|
|
327
|
+
const salt1 = readUint32BE(walBytes, 16);
|
|
328
|
+
const salt2 = readUint32BE(walBytes, 20);
|
|
329
|
+
let [checksum1, checksum2] = walChecksum(walBytes, 0, 24, bigEndianChecksum, 0, 0);
|
|
330
|
+
if (checksum1 !== readUint32BE(walBytes, 24) ||
|
|
331
|
+
checksum2 !== readUint32BE(walBytes, 28)) {
|
|
332
|
+
throw new SqliteParseError("WAL header checksum mismatch");
|
|
333
|
+
}
|
|
334
|
+
if (walIndex) {
|
|
335
|
+
if (walIndex.pageSize !== expectedPageSize ||
|
|
336
|
+
walIndex.salt1 !== salt1 ||
|
|
337
|
+
walIndex.salt2 !== salt2 ||
|
|
338
|
+
walIndex.bigEndianChecksum !== bigEndianChecksum) {
|
|
339
|
+
throw new SqliteParseError("WAL-index does not match WAL header");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const frameSize = WAL_FRAME_HEADER_SIZE + expectedPageSize;
|
|
343
|
+
const frameCount = Math.floor((walBytes.length - WAL_HEADER_SIZE) / frameSize);
|
|
344
|
+
const frameLimit = walIndex?.mxFrame ?? frameCount;
|
|
345
|
+
if (frameLimit > frameCount) {
|
|
346
|
+
throw new SqliteParseError("WAL-index end mark exceeds WAL frame count");
|
|
347
|
+
}
|
|
348
|
+
let lastCommitFrame = -1;
|
|
349
|
+
let validatedFrameCount = 0;
|
|
350
|
+
for (let i = 0; i < frameLimit; i++) {
|
|
351
|
+
const base = WAL_HEADER_SIZE + i * frameSize;
|
|
352
|
+
const fSalt1 = readUint32BE(walBytes, base + 8);
|
|
353
|
+
const fSalt2 = readUint32BE(walBytes, base + 12);
|
|
354
|
+
if (fSalt1 !== salt1 || fSalt2 !== salt2) {
|
|
355
|
+
if (walIndex) {
|
|
356
|
+
throw new SqliteParseError(`WAL frame ${i + 1} salt mismatch`);
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
[checksum1, checksum2] = walChecksum(walBytes, base, 8, bigEndianChecksum, checksum1, checksum2);
|
|
361
|
+
[checksum1, checksum2] = walChecksum(walBytes, base + WAL_FRAME_HEADER_SIZE, expectedPageSize, bigEndianChecksum, checksum1, checksum2);
|
|
362
|
+
if (checksum1 !== readUint32BE(walBytes, base + 16) ||
|
|
363
|
+
checksum2 !== readUint32BE(walBytes, base + 20)) {
|
|
364
|
+
if (walIndex) {
|
|
365
|
+
throw new SqliteParseError(`WAL frame ${i + 1} checksum mismatch`);
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
validatedFrameCount = i + 1;
|
|
370
|
+
const dbSizeAfterCommit = readUint32BE(walBytes, base + 4);
|
|
371
|
+
if (dbSizeAfterCommit !== 0)
|
|
372
|
+
lastCommitFrame = i;
|
|
373
|
+
}
|
|
374
|
+
if (walIndex) {
|
|
375
|
+
if (checksum1 !== walIndex.frameChecksum1 ||
|
|
376
|
+
checksum2 !== walIndex.frameChecksum2) {
|
|
377
|
+
throw new SqliteParseError("WAL end-mark checksum disagrees with WAL-index");
|
|
378
|
+
}
|
|
379
|
+
if (frameLimit > 0 && lastCommitFrame !== frameLimit - 1) {
|
|
380
|
+
throw new SqliteParseError("WAL-index end mark is not a commit frame");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (lastCommitFrame < 0)
|
|
384
|
+
return { pageMap: new Map() };
|
|
385
|
+
const pageMap = new Map();
|
|
386
|
+
const replayFrameCount = walIndex
|
|
387
|
+
? frameLimit
|
|
388
|
+
: Math.min(validatedFrameCount, lastCommitFrame + 1);
|
|
389
|
+
for (let i = 0; i < replayFrameCount; i++) {
|
|
390
|
+
const base = WAL_HEADER_SIZE + i * frameSize;
|
|
391
|
+
const pgno = readUint32BE(walBytes, base);
|
|
392
|
+
const pageStart = base + WAL_FRAME_HEADER_SIZE;
|
|
393
|
+
pageMap.set(pgno, walBytes.subarray(pageStart, pageStart + expectedPageSize));
|
|
394
|
+
}
|
|
395
|
+
return { pageMap };
|
|
396
|
+
}
|
|
397
|
+
function makePageSource(mainBytes, header, wal) {
|
|
398
|
+
return {
|
|
399
|
+
pageSize: header.pageSize,
|
|
400
|
+
getPage(pgno) {
|
|
401
|
+
// WAL override first
|
|
402
|
+
if (wal?.pageMap.has(pgno)) {
|
|
403
|
+
const walPage = wal.pageMap.get(pgno);
|
|
404
|
+
if (walPage)
|
|
405
|
+
return walPage;
|
|
406
|
+
}
|
|
407
|
+
const start = (pgno - 1) * header.pageSize;
|
|
408
|
+
const end = start + header.pageSize;
|
|
409
|
+
if (end > mainBytes.length) {
|
|
410
|
+
// Page lives beyond the main file and has no WAL copy — treat as empty.
|
|
411
|
+
return new Uint8Array(header.pageSize);
|
|
412
|
+
}
|
|
413
|
+
return mainBytes.subarray(start, end);
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** Decode a record body (the payload of a table-leaf cell) into an array of
|
|
418
|
+
* JS values. Serial type reference: https://www.sqlite.org/fileformat2.html
|
|
419
|
+
* section 2.1. Only TEXT / INTEGER / NULL / float / BLOB are handled. The
|
|
420
|
+
* caller supplies a reused `TextDecoder` (hoisted per scan) so we don't
|
|
421
|
+
* allocate one per cell. */
|
|
422
|
+
function decodeRecord(payload, td) {
|
|
423
|
+
const headerLenInfo = readVarint(payload, 0);
|
|
424
|
+
const headerLen = headerLenInfo.value;
|
|
425
|
+
const headerEnd = headerLenInfo.next;
|
|
426
|
+
if (headerLen > payload.length) {
|
|
427
|
+
throw new SqliteParseError("record header length exceeds payload");
|
|
428
|
+
}
|
|
429
|
+
// Collect serial types.
|
|
430
|
+
const serialTypes = [];
|
|
431
|
+
let p = headerEnd;
|
|
432
|
+
while (p < headerLen) {
|
|
433
|
+
const st = readVarint(payload, p);
|
|
434
|
+
serialTypes.push(st.value);
|
|
435
|
+
p = st.next;
|
|
436
|
+
}
|
|
437
|
+
// Decode body values.
|
|
438
|
+
const values = [];
|
|
439
|
+
let bodyOff = headerLen;
|
|
440
|
+
for (const st of serialTypes) {
|
|
441
|
+
if (st === 0) {
|
|
442
|
+
values.push(null);
|
|
443
|
+
}
|
|
444
|
+
else if (st <= 4) {
|
|
445
|
+
values.push(readSignedBE(payload, bodyOff, st));
|
|
446
|
+
bodyOff += st;
|
|
447
|
+
}
|
|
448
|
+
else if (st === 5) {
|
|
449
|
+
values.push(readSignedBE(payload, bodyOff, 6));
|
|
450
|
+
bodyOff += 6;
|
|
451
|
+
}
|
|
452
|
+
else if (st === 6) {
|
|
453
|
+
values.push(readSignedBE(payload, bodyOff, 8));
|
|
454
|
+
bodyOff += 8;
|
|
455
|
+
}
|
|
456
|
+
else if (st === 7) {
|
|
457
|
+
// float64 big-endian
|
|
458
|
+
const view = new DataView(payload.buffer, payload.byteOffset + bodyOff, 8);
|
|
459
|
+
values.push(view.getFloat64(0));
|
|
460
|
+
bodyOff += 8;
|
|
461
|
+
}
|
|
462
|
+
else if (st === 8) {
|
|
463
|
+
values.push(0);
|
|
464
|
+
}
|
|
465
|
+
else if (st === 9) {
|
|
466
|
+
values.push(1);
|
|
467
|
+
}
|
|
468
|
+
else if (st >= 12 && st % 2 === 0) {
|
|
469
|
+
// BLOB
|
|
470
|
+
const len = (st - 12) / 2;
|
|
471
|
+
values.push(payload.subarray(bodyOff, bodyOff + len));
|
|
472
|
+
bodyOff += len;
|
|
473
|
+
}
|
|
474
|
+
else if (st >= 13 && st % 2 === 1) {
|
|
475
|
+
// TEXT
|
|
476
|
+
const len = (st - 13) / 2;
|
|
477
|
+
values.push(td.decode(payload.subarray(bodyOff, bodyOff + len)));
|
|
478
|
+
bodyOff += len;
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
// st === 10 or 11 are reserved for internal use; skip defensively.
|
|
482
|
+
values.push(null);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return { values };
|
|
486
|
+
}
|
|
487
|
+
function decodeTextDecoderEncoding(enc) {
|
|
488
|
+
if (enc === 2)
|
|
489
|
+
return "utf-16le";
|
|
490
|
+
if (enc === 3)
|
|
491
|
+
return "utf-16be";
|
|
492
|
+
return "utf-8";
|
|
493
|
+
}
|
|
494
|
+
// ---------- b-tree traversal ----------
|
|
495
|
+
const PAGE_LEAF_TABLE = 0x0d;
|
|
496
|
+
const PAGE_INTERIOR_TABLE = 0x05;
|
|
497
|
+
/**
|
|
498
|
+
* Visit every row from the table b-tree rooted at `rootPgno`, in b-tree key
|
|
499
|
+
* (rowid) order. The visitor form lets callers filter rows without retaining
|
|
500
|
+
* a decoded copy of the entire table first.
|
|
501
|
+
*/
|
|
502
|
+
function visitTableBtree(src, rootPgno, textEncoding, header, visit) {
|
|
503
|
+
// Hoist one decoder per scan instead of allocating one per cell.
|
|
504
|
+
const td = new TextDecoder(decodeTextDecoderEncoding(textEncoding));
|
|
505
|
+
// Visited-set guards against corrupted interior pages whose child pointer
|
|
506
|
+
// forms a cycle (ancestor → self), which would otherwise recurse until the
|
|
507
|
+
// JS stack overflows. Valid SQLite b-trees are acyclic, so this never trims
|
|
508
|
+
// real data.
|
|
509
|
+
const visited = new Set();
|
|
510
|
+
walk(rootPgno);
|
|
511
|
+
function walk(pgno) {
|
|
512
|
+
if (pgno <= 0 || visited.has(pgno))
|
|
513
|
+
return;
|
|
514
|
+
visited.add(pgno);
|
|
515
|
+
const page = src.getPage(pgno);
|
|
516
|
+
// Page 1 has a 100-byte db header before its b-tree header.
|
|
517
|
+
const hdrOff = pgno === 1 ? DB_HEADER_SIZE : 0;
|
|
518
|
+
const pageType = byteAt(page, hdrOff);
|
|
519
|
+
if (pageType === PAGE_INTERIOR_TABLE) {
|
|
520
|
+
walkInterior(page, hdrOff);
|
|
521
|
+
}
|
|
522
|
+
else if (pageType === PAGE_LEAF_TABLE) {
|
|
523
|
+
walkLeaf(page, hdrOff);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
// Not a table b-tree page (could be index/freelist). Silently skip — a
|
|
527
|
+
// corrupted root page will just yield no rows rather than crash.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function walkInterior(page, hdrOff) {
|
|
531
|
+
const ncells = readUint16BE(page, hdrOff + 3);
|
|
532
|
+
const cellPtrStart = hdrOff + 12; // interior header is 12 bytes
|
|
533
|
+
for (let i = 0; i < ncells; i++) {
|
|
534
|
+
const cellOff = readUint16BE(page, cellPtrStart + i * 2);
|
|
535
|
+
const childPgno = readUint32BE(page, cellOff);
|
|
536
|
+
walk(childPgno);
|
|
537
|
+
}
|
|
538
|
+
// Right-most pointer (header bytes 8-11).
|
|
539
|
+
const rightMost = readUint32BE(page, hdrOff + 8);
|
|
540
|
+
if (rightMost !== 0)
|
|
541
|
+
walk(rightMost);
|
|
542
|
+
}
|
|
543
|
+
function walkLeaf(page, hdrOff) {
|
|
544
|
+
const ncells = readUint16BE(page, hdrOff + 3);
|
|
545
|
+
const cellPtrStart = hdrOff + 8; // leaf header is 8 bytes
|
|
546
|
+
for (let i = 0; i < ncells; i++) {
|
|
547
|
+
const cellOff = readUint16BE(page, cellPtrStart + i * 2);
|
|
548
|
+
const { rowid, values } = decodeLeafCell(page, cellOff, src, header, td);
|
|
549
|
+
visit({ rowid, values });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** Compute the overflow thresholds and reassemble a cell payload, following
|
|
554
|
+
* overflow page chains when the payload spills past the leaf page. */
|
|
555
|
+
function decodeLeafCell(page, cellOff, src, header, td) {
|
|
556
|
+
// leaf cell: payloadLen (varint) | rowid (varint) | payload [| overflow pgno]
|
|
557
|
+
let cur = cellOff;
|
|
558
|
+
const payloadLenInfo = readVarint(page, cur);
|
|
559
|
+
cur = payloadLenInfo.next;
|
|
560
|
+
const rowidInfo = readVarint(page, cur);
|
|
561
|
+
cur = rowidInfo.next;
|
|
562
|
+
const payloadLen = payloadLenInfo.value;
|
|
563
|
+
// Overflow math (https://www.sqlite.org/fileformat2.html §1.6).
|
|
564
|
+
const usableSize = header.pageSize - header.reservedBytes;
|
|
565
|
+
const maxLocal = usableSize - 35;
|
|
566
|
+
const minLocal = Math.floor(((usableSize - 12) * 32) / 255) - 23;
|
|
567
|
+
let localBytes;
|
|
568
|
+
let overflowPgno = null;
|
|
569
|
+
if (payloadLen <= maxLocal) {
|
|
570
|
+
localBytes = payloadLen;
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
const k = minLocal + ((payloadLen - minLocal) % (usableSize - 4));
|
|
574
|
+
localBytes = k <= maxLocal ? k : minLocal;
|
|
575
|
+
overflowPgno = readUint32BE(page, cur + localBytes);
|
|
576
|
+
}
|
|
577
|
+
// Build the full payload buffer.
|
|
578
|
+
let payload;
|
|
579
|
+
if (overflowPgno === null) {
|
|
580
|
+
payload = page.subarray(cur, cur + localBytes);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
const full = new Uint8Array(payloadLen);
|
|
584
|
+
full.set(page.subarray(cur, cur + localBytes), 0);
|
|
585
|
+
let written = localBytes;
|
|
586
|
+
let nextPgno = overflowPgno;
|
|
587
|
+
// Guard against pathological chains.
|
|
588
|
+
let guard = 0;
|
|
589
|
+
while (nextPgno !== 0 && written < payloadLen && guard < 100000) {
|
|
590
|
+
guard++;
|
|
591
|
+
const ovPage = src.getPage(nextPgno);
|
|
592
|
+
const nextPage = readUint32BE(ovPage, 0);
|
|
593
|
+
const chunkLen = Math.min(payloadLen - written, usableSize - 4);
|
|
594
|
+
full.set(ovPage.subarray(4, 4 + chunkLen), written);
|
|
595
|
+
written += chunkLen;
|
|
596
|
+
nextPgno = nextPage;
|
|
597
|
+
}
|
|
598
|
+
payload = full;
|
|
599
|
+
}
|
|
600
|
+
const { values } = decodeRecord(payload, td);
|
|
601
|
+
return { rowid: rowidInfo.value, values };
|
|
602
|
+
}
|
|
603
|
+
// ---------- column-name recovery from CREATE TABLE sql ----------
|
|
604
|
+
/**
|
|
605
|
+
* Best-effort column-name extractor for `CREATE TABLE name (col1 TYPE, col2, ...)`.
|
|
606
|
+
* Returns the list of column names in order, or `null` when parsing fails (the
|
|
607
|
+
* caller then falls back to `col0`, `col1`, ...).
|
|
608
|
+
*
|
|
609
|
+
* Strategy: split the parenthesised body on top-level commas, then take the
|
|
610
|
+
* first identifier of each segment as the column name. Segments whose first
|
|
611
|
+
* identifier is a table-constraint keyword (PRIMARY KEY / UNIQUE / CHECK /
|
|
612
|
+
* FOREIGN KEY / CONSTRAINT) are dropped — they are table-level constraints,
|
|
613
|
+
* not columns.
|
|
614
|
+
*/
|
|
615
|
+
function parseColumnNames(sql) {
|
|
616
|
+
const open = sql.indexOf("(");
|
|
617
|
+
const close = sql.lastIndexOf(")");
|
|
618
|
+
if (open < 0 || close < 0 || close <= open)
|
|
619
|
+
return null;
|
|
620
|
+
const body = sql.slice(open + 1, close);
|
|
621
|
+
const segments = splitTopLevelCommas(body);
|
|
622
|
+
const cols = [];
|
|
623
|
+
for (const seg of segments) {
|
|
624
|
+
const trimmed = seg.trim();
|
|
625
|
+
if (!trimmed)
|
|
626
|
+
continue;
|
|
627
|
+
const ident = firstIdentifier(trimmed);
|
|
628
|
+
if (!ident)
|
|
629
|
+
continue;
|
|
630
|
+
if (isConstraintKeyword(ident))
|
|
631
|
+
continue;
|
|
632
|
+
cols.push(ident);
|
|
633
|
+
}
|
|
634
|
+
return cols.length ? cols : null;
|
|
635
|
+
}
|
|
636
|
+
/** Split on commas that are not inside any parenthesised group. */
|
|
637
|
+
function splitTopLevelCommas(body) {
|
|
638
|
+
const out = [];
|
|
639
|
+
let depth = 0;
|
|
640
|
+
let start = 0;
|
|
641
|
+
for (let i = 0; i <= body.length; i++) {
|
|
642
|
+
const ch = body[i];
|
|
643
|
+
if (ch === "(")
|
|
644
|
+
depth++;
|
|
645
|
+
else if (ch === ")")
|
|
646
|
+
depth = Math.max(0, depth - 1);
|
|
647
|
+
else if ((ch === "," && depth === 0) || i === body.length) {
|
|
648
|
+
out.push(body.slice(start, i));
|
|
649
|
+
start = i + 1;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
/** First identifier of a column-definition piece, handling quoted forms. */
|
|
655
|
+
function firstIdentifier(piece) {
|
|
656
|
+
const m = piece.match(/^\s*(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([A-Za-z_][A-Za-z0-9_$]*))/);
|
|
657
|
+
if (!m)
|
|
658
|
+
return null;
|
|
659
|
+
return m[1] ?? m[2] ?? m[3] ?? m[4] ?? null;
|
|
660
|
+
}
|
|
661
|
+
function isConstraintKeyword(id) {
|
|
662
|
+
return /^(primary|unique|check|foreign|constraint)$/i.test(id);
|
|
663
|
+
}
|
|
664
|
+
// ---------- public entry ----------
|
|
665
|
+
/**
|
|
666
|
+
* Open a SQLite database file for read-only access. Automatically loads the
|
|
667
|
+
* sibling `<db>-wal` file when present so active WAL-mode databases (e.g.
|
|
668
|
+
* ZCode's running store) return their latest committed data.
|
|
669
|
+
*
|
|
670
|
+
* Throws `SqliteParseError` if `mainPath` is not a SQLite file or is too
|
|
671
|
+
* corrupted to read the header. Adapters should catch and degrade.
|
|
672
|
+
*/
|
|
673
|
+
export function openSqliteReadOnly(mainPath) {
|
|
674
|
+
const snapshot = captureSnapshot(mainPath);
|
|
675
|
+
const { mainBytes } = snapshot;
|
|
676
|
+
if (mainBytes.length < DB_HEADER_SIZE) {
|
|
677
|
+
throw new SqliteParseError(`db file too small: ${mainPath}`);
|
|
678
|
+
}
|
|
679
|
+
const header = parseDbHeader(mainBytes);
|
|
680
|
+
const wal = loadWal(snapshot.walBytes, header.pageSize, snapshot.walIndex);
|
|
681
|
+
const src = makePageSource(mainBytes, header, wal);
|
|
682
|
+
// Column-name cache: tableName → column list (or null).
|
|
683
|
+
const columnCache = new Map();
|
|
684
|
+
function readSqliteMaster() {
|
|
685
|
+
const tables = [];
|
|
686
|
+
visitTableBtree(src, 1, header.textEncoding, header, ({ values }) => {
|
|
687
|
+
// sqlite_master columns: type, name, tbl_name, rootpage, sql
|
|
688
|
+
const [type, name, _tbl, rootpage, sql] = values;
|
|
689
|
+
if (type === "table" &&
|
|
690
|
+
typeof name === "string" &&
|
|
691
|
+
typeof rootpage === "number") {
|
|
692
|
+
tables.push({ name, rootPgno: rootpage, sql: sql ?? "" });
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
return tables;
|
|
696
|
+
}
|
|
697
|
+
function columnsFor(table) {
|
|
698
|
+
const cached = columnCache.get(table.name);
|
|
699
|
+
if (cached !== undefined)
|
|
700
|
+
return cached;
|
|
701
|
+
const parsed = table.sql ? parseColumnNames(table.sql) : null;
|
|
702
|
+
columnCache.set(table.name, parsed);
|
|
703
|
+
return parsed;
|
|
704
|
+
}
|
|
705
|
+
/** Find the column index that is `INTEGER PRIMARY KEY` (the rowid alias), or
|
|
706
|
+
* -1 if none. SQLite stores NULL in that column's record slot and the real
|
|
707
|
+
* value in the cell rowid; we splice it back so callers see correct values. */
|
|
708
|
+
function rowidAliasIndex(table, columns) {
|
|
709
|
+
if (!table.sql || !columns)
|
|
710
|
+
return -1;
|
|
711
|
+
const open = table.sql.indexOf("(");
|
|
712
|
+
const close = table.sql.lastIndexOf(")");
|
|
713
|
+
if (open < 0 || close < 0)
|
|
714
|
+
return -1;
|
|
715
|
+
const body = table.sql.slice(open + 1, close);
|
|
716
|
+
const segments = splitTopLevelCommas(body);
|
|
717
|
+
let idx = 0;
|
|
718
|
+
for (const seg of segments) {
|
|
719
|
+
const trimmed = seg.trim();
|
|
720
|
+
const ident = firstIdentifier(trimmed);
|
|
721
|
+
if (!ident || isConstraintKeyword(ident))
|
|
722
|
+
continue;
|
|
723
|
+
// Match "INTEGER PRIMARY KEY" anywhere in this column definition (case-
|
|
724
|
+
// insensitive). The trailing \b guards against a false positive on
|
|
725
|
+
// something like `PRIMARY KEYDESC`. This is a heuristic; WITHOUT ROWID
|
|
726
|
+
// and other edge cases are out of scope (ZCode tables don't use them).
|
|
727
|
+
if (/integer\s+primary\s+key\b/i.test(trimmed)) {
|
|
728
|
+
return idx;
|
|
729
|
+
}
|
|
730
|
+
idx++;
|
|
731
|
+
}
|
|
732
|
+
return -1;
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
dbPath: mainPath,
|
|
736
|
+
listTables() {
|
|
737
|
+
return readSqliteMaster();
|
|
738
|
+
},
|
|
739
|
+
scanTable(tableName, predicate) {
|
|
740
|
+
let table;
|
|
741
|
+
try {
|
|
742
|
+
table = readSqliteMaster().find((t) => t.name === tableName);
|
|
743
|
+
if (!table || table.rootPgno <= 0)
|
|
744
|
+
return [];
|
|
745
|
+
const columns = columnsFor(table);
|
|
746
|
+
const aliasIdx = rowidAliasIndex(table, columns);
|
|
747
|
+
const rows = [];
|
|
748
|
+
visitTableBtree(src, table.rootPgno, header.textEncoding, header, ({ rowid, values }) => {
|
|
749
|
+
const row = {};
|
|
750
|
+
for (let i = 0; i < values.length; i++) {
|
|
751
|
+
const key = columns?.[i] ?? `col${i}`;
|
|
752
|
+
// INTEGER PRIMARY KEY columns store NULL in the record; the real
|
|
753
|
+
// value is the cell rowid. Splice it in so callers see the integer.
|
|
754
|
+
if (i === aliasIdx && values[i] === null) {
|
|
755
|
+
row[key] = rowid;
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
row[key] = values[i];
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (!predicate || predicate(row))
|
|
762
|
+
rows.push(row);
|
|
763
|
+
});
|
|
764
|
+
return rows;
|
|
765
|
+
}
|
|
766
|
+
catch (e) {
|
|
767
|
+
// Wrap any non-SqliteParseError (e.g. RangeError from a truncated
|
|
768
|
+
// float64, TypeError from a bad cell pointer) so the adapter's single
|
|
769
|
+
// catch on SqliteParseError degrades cleanly instead of crashing CLI.
|
|
770
|
+
throw e instanceof SqliteParseError
|
|
771
|
+
? e
|
|
772
|
+
: new SqliteParseError(`failed reading table "${tableName}"`, e);
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
close() {
|
|
776
|
+
// Nothing to release — we read the whole file into memory at open time.
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/** Convenience: open + scan + close in one call. Returns [] on any parse error
|
|
781
|
+
* or missing table, never throws. Useful for adapters that already decided to
|
|
782
|
+
* degrade silently. */
|
|
783
|
+
export function scanTableSafe(mainPath, tableName) {
|
|
784
|
+
try {
|
|
785
|
+
const db = openSqliteReadOnly(mainPath);
|
|
786
|
+
try {
|
|
787
|
+
return db.scanTable(tableName);
|
|
788
|
+
}
|
|
789
|
+
finally {
|
|
790
|
+
db.close();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
return [];
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
//# sourceMappingURL=sqlite-readonly.js.map
|