@ixo/oracle-runtime-workers 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.
Files changed (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,1485 @@
1
+ /* eslint-disable no-console -- console is the logger on workerd (no @ixo/logger); VFS failures must be logged, they cannot throw */
2
+ /**
3
+ * A synchronous SQLite VFS that stores file bytes as fixed-size pages in a
4
+ * Durable Object's SQLite storage (`ctx.storage.sql`).
5
+ *
6
+ * Layout in DO storage (v2 — chunked):
7
+ * vfs2_files(file TEXT PRIMARY KEY, size INTEGER) -- logical byte length
8
+ * vfs2_chunks(file TEXT, chunkno INTEGER, data BLOB, -- 64 KiB chunks
9
+ * PRIMARY KEY(file, chunkno)) -- = 16 × 4 KiB pages
10
+ *
11
+ * Chunking exists for BILLING: Durable Object SQLite bills per row
12
+ * read/written regardless of value size, so packing 16 SQLite pages into one
13
+ * 64 KiB row divides row-write and row-read charges by up to 16× versus the
14
+ * v1 one-page-per-row layout. SQLite still sees 4 KiB pages; chunks are
15
+ * purely the storage grain. The v1 tables (`vfs_files`/`vfs_pages`) were
16
+ * never deployed — `ensureSchema` drops them if a dev environment still has
17
+ * them, and any working copy they held simply re-imports from the user's
18
+ * owner-store file on next boot.
19
+ *
20
+ * Only files opened with `SQLITE_OPEN_MAIN_DB` are persisted. Everything else
21
+ * SQLite might open (rollback/statement journals, temp databases, super
22
+ * journals) lives in an in-memory file, MemoryVFS-style. The database wrapper
23
+ * sets `journal_mode=MEMORY`, `synchronous=OFF`, `temp_store=MEMORY`, so in
24
+ * practice only the main database ever touches storage.
25
+ *
26
+ * Durability / atomicity model (read this before changing lock handling):
27
+ * - SQLite writes main-db pages only while it holds the EXCLUSIVE lock on that
28
+ * file (commit phase). We buffer those page writes in memory (`dirty`) and
29
+ * flush them in ONE `storage.transactionSync` when SQLite either syncs the
30
+ * file (`xSync`, only with synchronous>OFF) or releases the exclusive lock
31
+ * (`xUnlock`, always). A crash mid-commit therefore loses the whole
32
+ * transaction rather than tearing the file; a crash after the flush loses
33
+ * nothing. This is what makes `journal_mode=MEMORY` + `synchronous=OFF`
34
+ * safe here even though it is not on a real filesystem: the "journal" that
35
+ * protects against process death is the DO storage transaction.
36
+ * - The trade-off: if the flush itself fails (storage error) after SQLite has
37
+ * already declared the transaction committed (xUnlock path), the pages stay
38
+ * dirty in memory and are retried on the next flush; `lastFlushError`
39
+ * records the failure so the wrapper can surface it. Run write transactions
40
+ * with `synchronous=NORMAL` if you need the failure reported to the
41
+ * committing statement (xSync then flushes before SQLite finalizes).
42
+ * - Locks are otherwise no-ops: a Durable Object is single-threaded and each
43
+ * object owns its storage exclusively.
44
+ *
45
+ * Memory footprint: an LRU cache of clean chunks (default ≈ 8 MiB, see
46
+ * `cachePages`) plus whatever is dirty in the current transaction. The file
47
+ * itself is never materialized in memory except by `exportFile`; the owner
48
+ * store reads it through `openSnapshot()` (a consistent, windowed stream)
49
+ * and writes it through `importFromStream()`.
50
+ *
51
+ * Snapshots: a snapshot pins the file's content at open time while SQLite
52
+ * keeps committing. `flush()` captures the PRE-IMAGE of every chunk it is
53
+ * about to overwrite (or truncate away) while a snapshot of that file is
54
+ * open — usually straight from the clean cache, so it costs no storage read
55
+ * — and snapshot readers consult those pre-images first. Memory is bounded
56
+ * by the bytes written during the snapshot's lifetime, not by the file size.
57
+ *
58
+ * Write generation: `vfs2_files.gen` counts flushes that changed the file.
59
+ * The owner-store flush compares it with the generation it last uploaded and
60
+ * skips the whole export when nothing was written — no hashing, no reads.
61
+ *
62
+ * Spill files: names ending in `.vacuum` are rebuilt from scratch (`VACUUM
63
+ * INTO`) and need no crash atomicity, so their dirty chunks are flushed to
64
+ * storage every `SPILL_CHUNKS` instead of being held until commit. That is
65
+ * what keeps a full VACUUM of a multi-hundred-MB file at constant memory.
66
+ */
67
+ import { createHash } from 'node:crypto';
68
+ import {
69
+ SQLITE_CANTOPEN,
70
+ SQLITE_IOCAP_SAFE_APPEND,
71
+ SQLITE_IOCAP_SEQUENTIAL,
72
+ SQLITE_IOERR,
73
+ SQLITE_IOERR_ACCESS,
74
+ SQLITE_IOERR_CLOSE,
75
+ SQLITE_IOERR_DELETE,
76
+ SQLITE_IOERR_FSYNC,
77
+ SQLITE_IOERR_READ,
78
+ SQLITE_IOERR_SHORT_READ,
79
+ SQLITE_IOERR_TRUNCATE,
80
+ SQLITE_IOERR_UNLOCK,
81
+ SQLITE_IOERR_WRITE,
82
+ SQLITE_LOCK_EXCLUSIVE,
83
+ SQLITE_LOCK_NONE,
84
+ SQLITE_NOTFOUND,
85
+ SQLITE_OK,
86
+ SQLITE_OPEN_CREATE,
87
+ SQLITE_OPEN_DELETEONCLOSE,
88
+ SQLITE_OPEN_MAIN_DB,
89
+ } from 'wa-sqlite';
90
+
91
+ /** Bytes per SQLite page. Matches `PRAGMA page_size=4096`. */
92
+ export const VFS_PAGE_SIZE = 4096;
93
+
94
+ /** SQLite pages packed into one stored row (billing grain). */
95
+ export const PAGES_PER_CHUNK = 16;
96
+
97
+ /** Bytes per stored chunk row (64 KiB — well under the 2 MB DO value cap). */
98
+ export const CHUNK_SIZE = VFS_PAGE_SIZE * PAGES_PER_CHUNK;
99
+
100
+ /**
101
+ * Default number of clean pages worth of chunk cache kept in memory (4 MiB).
102
+ * Measured on devnet against 8 MiB: a turn's working set (~20 chunks) fits
103
+ * either, latency is indistinguishable, and the smaller budget leaves more
104
+ * isolate memory for turns running concurrently on the same server.
105
+ */
106
+ export const DEFAULT_CACHE_PAGES = 1024;
107
+
108
+ /**
109
+ * Chunk rows per multi-row INSERT during flush/import: 3 bindings per row
110
+ * (well under DO SQLite's 100-bound-parameter cap) and ~1 MiB of blob payload
111
+ * per statement.
112
+ */
113
+ const FLUSH_BATCH_ROWS = 16;
114
+
115
+ /** Chunks a snapshot stream hands out per pull (1 MiB). */
116
+ const SNAPSHOT_WINDOW_CHUNKS = 16;
117
+
118
+ /** Dirty chunks a spill file (`.vacuum`) may hold before they go to storage (2 MiB). */
119
+ export const SPILL_CHUNKS = 32;
120
+
121
+ /** Suffix of files rebuilt by `VACUUM INTO` — spilled early, never snapshotted. */
122
+ export const VACUUM_FILE_SUFFIX = '.vacuum';
123
+
124
+ /** Suffix of the staging file a streamed import writes before the atomic rename. */
125
+ export const IMPORT_FILE_SUFFIX = '.importing';
126
+
127
+ const SQLITE_HEADER_MAGIC = 'SQLite format 3\0';
128
+
129
+ export interface DoVfsOptions {
130
+ /**
131
+ * Max clean pages held in the LRU cache (dirty pages are never evicted).
132
+ * Applied on every `open()`: an isolate that already holds this object's
133
+ * VFS is resized to the new budget (excess clean chunks are dropped).
134
+ */
135
+ cachePages?: number;
136
+ }
137
+
138
+ export interface DoVfsStats {
139
+ cachedPages: number;
140
+ dirtyPages: number;
141
+ openFiles: number;
142
+ flushes: number;
143
+ storageReads: number;
144
+ storageWrites: number;
145
+ /** Billing proxy: chunk rows written to / read from DO storage. */
146
+ rowsWritten: number;
147
+ rowsRead: number;
148
+ /** Chunk lookups served from the clean cache vs. read from storage. */
149
+ cacheHits: number;
150
+ cacheMisses: number;
151
+ /** Configured cache budget. */
152
+ cacheCapacityChunks: number;
153
+ cacheBudgetBytes: number;
154
+ }
155
+
156
+ /**
157
+ * A consistent view of one persistent file as of `openSnapshot()`. `open()`
158
+ * may be called any number of times (the owner store measures the gzipped
159
+ * length in one pass and uploads in a second); every stream reads the same
160
+ * bytes even while SQLite keeps committing. `close()` releases the captured
161
+ * pre-images — always call it.
162
+ */
163
+ export interface VfsSnapshot {
164
+ readonly name: string;
165
+ readonly size: number;
166
+ /** Write generation the snapshot was taken at (see `writeGeneration`). */
167
+ readonly generation: number;
168
+ open(): ReadableStream<Uint8Array>;
169
+ close(): void;
170
+ }
171
+
172
+ interface SnapshotState {
173
+ name: string;
174
+ size: number;
175
+ totalChunks: number;
176
+ /** Chunk pre-images captured because a later flush overwrote/deleted them. */
177
+ shadow: Map<number, Uint8Array>;
178
+ open: boolean;
179
+ }
180
+
181
+ interface PersistentFile {
182
+ kind: 'persistent';
183
+ name: string;
184
+ flags: number;
185
+ size: number;
186
+ /** Flushes that changed the file (mirrors `vfs2_files.gen`). */
187
+ gen: number;
188
+ lock: number;
189
+ /** Chunks written since the last flush, keyed by chunk number (full 64 KiB buffers). */
190
+ dirty: Map<number, Uint8Array>;
191
+ /** Lowest page count the file was truncated to since the last flush (chunks past it are deleted on flush). */
192
+ truncateToPages: number | null;
193
+ sizeDirty: boolean;
194
+ /** Flush dirty chunks early (no crash atomicity needed — see file header). */
195
+ spill: boolean;
196
+ }
197
+
198
+ interface MemoryFile {
199
+ kind: 'memory';
200
+ name: string;
201
+ flags: number;
202
+ size: number;
203
+ data: Uint8Array;
204
+ }
205
+
206
+ type OpenFile = PersistentFile | MemoryFile;
207
+
208
+ interface ChunkRow extends Record<string, SqlStorageValue> {
209
+ chunkno: number;
210
+ data: ArrayBuffer;
211
+ }
212
+
213
+ interface FileMetaRow extends Record<string, SqlStorageValue> {
214
+ size: number;
215
+ gen: number;
216
+ }
217
+
218
+ /** Simple LRU of clean chunks keyed by `${file}\u0000${chunkno}`; Map iteration order = recency. */
219
+ class ChunkCache {
220
+ private readonly map = new Map<string, Uint8Array>();
221
+
222
+ constructor(private capacity: number) {}
223
+
224
+ get size(): number {
225
+ return this.map.size;
226
+ }
227
+
228
+ get capacityChunks(): number {
229
+ return this.capacity;
230
+ }
231
+
232
+ /** Resize the budget; drops the least recently used chunks past it. */
233
+ setCapacity(capacity: number): void {
234
+ this.capacity = Math.max(1, capacity);
235
+ this.evict();
236
+ }
237
+
238
+ get(file: string, chunkno: number): Uint8Array | undefined {
239
+ const key = ChunkCache.key(file, chunkno);
240
+ const chunk = this.map.get(key);
241
+ if (chunk !== undefined) {
242
+ this.map.delete(key);
243
+ this.map.set(key, chunk);
244
+ }
245
+ return chunk;
246
+ }
247
+
248
+ set(file: string, chunkno: number, chunk: Uint8Array): void {
249
+ const key = ChunkCache.key(file, chunkno);
250
+ this.map.delete(key);
251
+ this.map.set(key, chunk);
252
+ this.evict();
253
+ }
254
+
255
+ private evict(): void {
256
+ while (this.map.size > this.capacity) {
257
+ const oldest = this.map.keys().next();
258
+ if (oldest.done) break;
259
+ this.map.delete(oldest.value);
260
+ }
261
+ }
262
+
263
+ /** Drop every cached chunk of `file` with chunkno >= `fromChunk`. */
264
+ dropFrom(file: string, fromChunk: number): void {
265
+ const prefix = `${file}\u0000`;
266
+ for (const key of this.map.keys()) {
267
+ if (!key.startsWith(prefix)) continue;
268
+ const chunkno = Number(key.slice(prefix.length));
269
+ if (chunkno >= fromChunk) this.map.delete(key);
270
+ }
271
+ }
272
+
273
+ dropFile(file: string): void {
274
+ this.dropFrom(file, 0);
275
+ }
276
+
277
+ clear(): void {
278
+ this.map.clear();
279
+ }
280
+
281
+ private static key(file: string, chunkno: number): string {
282
+ return `${file}\u0000${chunkno}`;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Errors raised by VFS helpers (`exportFile`, `importFile`, ...). VFS methods
288
+ * called by SQLite never throw; they return SQLite error codes instead.
289
+ */
290
+ export class DoVfsError extends Error {
291
+ constructor(message: string) {
292
+ super(message);
293
+ this.name = 'DoVfsError';
294
+ }
295
+ }
296
+
297
+ export class DoVfs implements SQLiteVFS {
298
+ /** Maximum path length SQLite may hand us (the glue caps at 64 unless overridden). */
299
+ readonly mxPathName = 64;
300
+
301
+ private storage: DurableObjectStorage;
302
+ private schemaReady = false;
303
+ private readonly cache: ChunkCache;
304
+ private readonly openFiles = new Map<number, OpenFile>();
305
+ private readonly memoryFiles = new Map<string, MemoryFile>();
306
+ /** Persistent files currently open, by name (a file may be open through several fileIds). */
307
+ private readonly persistentByName = new Map<string, PersistentFile>();
308
+ /** Open snapshots by file name (at most one per file). */
309
+ private readonly snapshots = new Map<string, SnapshotState>();
310
+ private readonly stats: DoVfsStats = {
311
+ cachedPages: 0,
312
+ dirtyPages: 0,
313
+ openFiles: 0,
314
+ flushes: 0,
315
+ storageReads: 0,
316
+ storageWrites: 0,
317
+ rowsWritten: 0,
318
+ rowsRead: 0,
319
+ cacheHits: 0,
320
+ cacheMisses: 0,
321
+ cacheCapacityChunks: 0,
322
+ cacheBudgetBytes: 0,
323
+ };
324
+
325
+ /** Last error thrown by a flush that SQLite could not be told about (see file header). */
326
+ lastFlushError: unknown = undefined;
327
+
328
+ constructor(
329
+ readonly name: string,
330
+ storage: DurableObjectStorage,
331
+ options: DoVfsOptions = {},
332
+ ) {
333
+ this.storage = storage;
334
+ this.cache = new ChunkCache(DoVfs.chunksForPages(options.cachePages));
335
+ }
336
+
337
+ private static chunksForPages(cachePages: number | undefined): number {
338
+ return Math.max(
339
+ 1,
340
+ Math.ceil((cachePages ?? DEFAULT_CACHE_PAGES) / PAGES_PER_CHUNK),
341
+ );
342
+ }
343
+
344
+ /** Apply a (new) clean-chunk budget; see `DoVfsOptions.cachePages`. */
345
+ configureCache(cachePages: number | undefined): void {
346
+ this.cache.setCapacity(DoVfs.chunksForPages(cachePages));
347
+ }
348
+
349
+ isAttachedTo(storage: DurableObjectStorage): boolean {
350
+ return this.storage === storage;
351
+ }
352
+
353
+ /**
354
+ * Re-bind to a (new) DurableObjectState's storage. Used when the same DO id
355
+ * is re-instantiated in an isolate that still holds this VFS registered
356
+ * (the previous instance was evicted without closing its connections). All
357
+ * in-memory state — including files the dead instance left open — is
358
+ * discarded; the caller closes the stale SQLite connections first via
359
+ * `takeConnections()`.
360
+ */
361
+ attach(storage: DurableObjectStorage): void {
362
+ this.storage = storage;
363
+ this.schemaReady = false;
364
+ this.cache.clear();
365
+ this.memoryFiles.clear();
366
+ this.persistentByName.clear();
367
+ this.openFiles.clear();
368
+ this.snapshots.clear();
369
+ this.lastFlushError = undefined;
370
+ }
371
+
372
+ /** SQLite connection handles opened through this VFS (tracked by `DoSqliteDatabase`). */
373
+ private readonly connections = new Set<number>();
374
+
375
+ trackConnection(db: number): void {
376
+ this.connections.add(db);
377
+ }
378
+
379
+ untrackConnection(db: number): void {
380
+ this.connections.delete(db);
381
+ }
382
+
383
+ /**
384
+ * Hand over every tracked connection and forget all pending page state so
385
+ * closing them touches neither the old storage nor the new one. Only used
386
+ * when re-attaching after an eviction.
387
+ */
388
+ takeConnections(): number[] {
389
+ const handles = Array.from(this.connections);
390
+ this.connections.clear();
391
+ for (const file of this.persistentByName.values()) {
392
+ file.dirty.clear();
393
+ file.truncateToPages = null;
394
+ file.sizeDirty = false;
395
+ file.flags &= ~SQLITE_OPEN_DELETEONCLOSE;
396
+ }
397
+ return handles;
398
+ }
399
+
400
+ getStats(): DoVfsStats {
401
+ let dirty = 0;
402
+ for (const file of this.persistentByName.values()) dirty += file.dirty.size;
403
+ return {
404
+ ...this.stats,
405
+ cachedPages: this.cache.size * PAGES_PER_CHUNK,
406
+ dirtyPages: dirty * PAGES_PER_CHUNK,
407
+ openFiles: this.openFiles.size,
408
+ cacheCapacityChunks: this.cache.capacityChunks,
409
+ cacheBudgetBytes: this.cache.capacityChunks * CHUNK_SIZE,
410
+ };
411
+ }
412
+
413
+ /**
414
+ * VFS methods must not throw across the wasm boundary; record the cause (so
415
+ * `DoSqliteDatabase` can attach it to the SQLite error it surfaces), log it,
416
+ * and answer SQLite with `code`.
417
+ */
418
+ private fail(codeName: string, error: unknown, code: number): number {
419
+ this.lastFlushError = error;
420
+ console.error(`[do-vfs ${this.name}] ${codeName}:`, error);
421
+ return code;
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // sqlite3_vfs methods
426
+ // ---------------------------------------------------------------------------
427
+
428
+ xOpen(
429
+ name: string | null,
430
+ fileId: number,
431
+ flags: number,
432
+ pOutFlags: DataView,
433
+ ): number {
434
+ try {
435
+ if ((flags & SQLITE_OPEN_MAIN_DB) !== 0) {
436
+ if (name === null) return SQLITE_CANTOPEN;
437
+ let file = this.persistentByName.get(name);
438
+ if (file === undefined) {
439
+ this.ensureSchema();
440
+ const meta = this.readStoredMeta(name);
441
+ if (meta === undefined) {
442
+ if ((flags & SQLITE_OPEN_CREATE) === 0) return SQLITE_CANTOPEN;
443
+ this.storage.sql.exec(
444
+ 'INSERT INTO vfs2_files (file, size, gen) VALUES (?, 0, 0)',
445
+ name,
446
+ );
447
+ this.stats.storageWrites++;
448
+ this.stats.rowsWritten++;
449
+ }
450
+ file = {
451
+ kind: 'persistent',
452
+ name,
453
+ flags,
454
+ size: meta?.size ?? 0,
455
+ gen: meta?.gen ?? 0,
456
+ lock: SQLITE_LOCK_NONE,
457
+ dirty: new Map(),
458
+ truncateToPages: null,
459
+ sizeDirty: false,
460
+ spill: name.endsWith(VACUUM_FILE_SUFFIX),
461
+ };
462
+ this.persistentByName.set(name, file);
463
+ }
464
+ this.openFiles.set(fileId, file);
465
+ pOutFlags.setInt32(0, flags, true);
466
+ return SQLITE_OK;
467
+ }
468
+
469
+ // Journals, temp databases, sub-journals: memory only.
470
+ const memName =
471
+ name ??
472
+ `temp-${Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36)}`;
473
+ let mem = this.memoryFiles.get(memName);
474
+ if (mem === undefined) {
475
+ if ((flags & SQLITE_OPEN_CREATE) === 0) return SQLITE_CANTOPEN;
476
+ mem = {
477
+ kind: 'memory',
478
+ name: memName,
479
+ flags,
480
+ size: 0,
481
+ data: new Uint8Array(0),
482
+ };
483
+ this.memoryFiles.set(memName, mem);
484
+ }
485
+ this.openFiles.set(fileId, mem);
486
+ pOutFlags.setInt32(0, flags, true);
487
+ return SQLITE_OK;
488
+ } catch (error) {
489
+ return this.fail('SQLITE_CANTOPEN', error, SQLITE_CANTOPEN);
490
+ }
491
+ }
492
+
493
+ xClose(fileId: number): number {
494
+ const file = this.openFiles.get(fileId);
495
+ if (file === undefined) return SQLITE_IOERR_CLOSE;
496
+ this.openFiles.delete(fileId);
497
+ try {
498
+ if (file.kind === 'memory') {
499
+ if ((file.flags & SQLITE_OPEN_DELETEONCLOSE) !== 0)
500
+ this.memoryFiles.delete(file.name);
501
+ return SQLITE_OK;
502
+ }
503
+ this.flush(file);
504
+ if (!this.isOpen(file)) {
505
+ this.persistentByName.delete(file.name);
506
+ if ((file.flags & SQLITE_OPEN_DELETEONCLOSE) !== 0)
507
+ this.deletePersistent(file.name);
508
+ }
509
+ return SQLITE_OK;
510
+ } catch (error) {
511
+ return this.fail('SQLITE_IOERR_CLOSE', error, SQLITE_IOERR_CLOSE);
512
+ }
513
+ }
514
+
515
+ xRead(fileId: number, pData: Uint8Array, iOffset: number): number {
516
+ const file = this.openFiles.get(fileId);
517
+ if (file === undefined) return SQLITE_IOERR_READ;
518
+ try {
519
+ if (file.kind === 'memory') return readMemory(file, pData, iOffset);
520
+ return this.readPersistent(file, pData, iOffset);
521
+ } catch (error) {
522
+ return this.fail('SQLITE_IOERR_READ', error, SQLITE_IOERR_READ);
523
+ }
524
+ }
525
+
526
+ xWrite(fileId: number, pData: Uint8Array, iOffset: number): number {
527
+ const file = this.openFiles.get(fileId);
528
+ if (file === undefined) return SQLITE_IOERR_WRITE;
529
+ try {
530
+ if (file.kind === 'memory') return writeMemory(file, pData, iOffset);
531
+ this.writePersistent(file, pData, iOffset);
532
+ return SQLITE_OK;
533
+ } catch (error) {
534
+ return this.fail('SQLITE_IOERR_WRITE', error, SQLITE_IOERR_WRITE);
535
+ }
536
+ }
537
+
538
+ xTruncate(fileId: number, iSize: number): number {
539
+ const file = this.openFiles.get(fileId);
540
+ if (file === undefined) return SQLITE_IOERR_TRUNCATE;
541
+ try {
542
+ if (file.kind === 'memory') {
543
+ file.size = Math.min(file.size, iSize);
544
+ return SQLITE_OK;
545
+ }
546
+ this.truncatePersistent(file, iSize);
547
+ return SQLITE_OK;
548
+ } catch (error) {
549
+ return this.fail('SQLITE_IOERR_TRUNCATE', error, SQLITE_IOERR_TRUNCATE);
550
+ }
551
+ }
552
+
553
+ xSync(fileId: number, _flags: number): number {
554
+ const file = this.openFiles.get(fileId);
555
+ if (file === undefined) return SQLITE_IOERR_FSYNC;
556
+ if (file.kind === 'memory') return SQLITE_OK;
557
+ try {
558
+ this.flush(file);
559
+ return SQLITE_OK;
560
+ } catch (error) {
561
+ return this.fail('SQLITE_IOERR_FSYNC', error, SQLITE_IOERR_FSYNC);
562
+ }
563
+ }
564
+
565
+ xFileSize(fileId: number, pSize64: DataView): number {
566
+ const file = this.openFiles.get(fileId);
567
+ if (file === undefined) return SQLITE_IOERR;
568
+ pSize64.setBigInt64(0, BigInt(file.size), true);
569
+ return SQLITE_OK;
570
+ }
571
+
572
+ xLock(fileId: number, flags: number): number {
573
+ const file = this.openFiles.get(fileId);
574
+ if (file === undefined) return SQLITE_IOERR;
575
+ if (file.kind === 'persistent') file.lock = flags;
576
+ return SQLITE_OK;
577
+ }
578
+
579
+ xUnlock(fileId: number, flags: number): number {
580
+ const file = this.openFiles.get(fileId);
581
+ if (file === undefined) return SQLITE_IOERR;
582
+ if (file.kind === 'memory') return SQLITE_OK;
583
+ const hadExclusive = file.lock >= SQLITE_LOCK_EXCLUSIVE;
584
+ file.lock = flags;
585
+ if (hadExclusive && flags < SQLITE_LOCK_EXCLUSIVE) {
586
+ try {
587
+ this.flush(file);
588
+ } catch (error) {
589
+ // SQLite already considers the transaction committed here; keep the
590
+ // pages dirty so the next flush retries, and surface the failure.
591
+ return this.fail('SQLITE_IOERR_UNLOCK', error, SQLITE_IOERR_UNLOCK);
592
+ }
593
+ }
594
+ return SQLITE_OK;
595
+ }
596
+
597
+ xCheckReservedLock(_fileId: number, pResOut: DataView): number {
598
+ pResOut.setInt32(0, 0, true);
599
+ return SQLITE_OK;
600
+ }
601
+
602
+ xFileControl(_fileId: number, _op: number, _pArg: DataView): number {
603
+ return SQLITE_NOTFOUND;
604
+ }
605
+
606
+ xSectorSize(_fileId: number): number {
607
+ return VFS_PAGE_SIZE;
608
+ }
609
+
610
+ xDeviceCharacteristics(_fileId: number): number {
611
+ return SQLITE_IOCAP_SAFE_APPEND | SQLITE_IOCAP_SEQUENTIAL;
612
+ }
613
+
614
+ xDelete(name: string, _syncDir: number): number {
615
+ try {
616
+ if (this.memoryFiles.delete(name)) return SQLITE_OK;
617
+ if (this.persistentByName.has(name)) {
618
+ // SQLite never deletes an open main database; refuse rather than corrupt.
619
+ return SQLITE_IOERR_DELETE;
620
+ }
621
+ this.ensureSchema();
622
+ this.deletePersistent(name);
623
+ return SQLITE_OK;
624
+ } catch (error) {
625
+ return this.fail('SQLITE_IOERR_DELETE', error, SQLITE_IOERR_DELETE);
626
+ }
627
+ }
628
+
629
+ /** `_flags` (EXISTS / READWRITE / READ) all get the same answer: storage is always writable. */
630
+ xAccess(name: string, _flags: number, pResOut: DataView): number {
631
+ try {
632
+ let exists: boolean;
633
+ if (this.memoryFiles.has(name) || this.persistentByName.has(name)) {
634
+ exists = true;
635
+ } else {
636
+ this.ensureSchema();
637
+ exists = this.readStoredMeta(name) !== undefined;
638
+ }
639
+ pResOut.setInt32(0, exists ? 1 : 0, true);
640
+ return SQLITE_OK;
641
+ } catch (error) {
642
+ return this.fail('SQLITE_IOERR_ACCESS', error, SQLITE_IOERR_ACCESS);
643
+ }
644
+ }
645
+
646
+ // ---------------------------------------------------------------------------
647
+ // Helpers exposed to the runtime (not called by SQLite)
648
+ // ---------------------------------------------------------------------------
649
+
650
+ /** Whether `name` exists in storage (or is an open persistent file). */
651
+ fileExists(name: string): boolean {
652
+ if (this.persistentByName.has(name)) return true;
653
+ this.ensureSchema();
654
+ return this.readStoredMeta(name) !== undefined;
655
+ }
656
+
657
+ /** Logical size in bytes of a persistent file, or undefined when absent. */
658
+ fileSize(name: string): number | undefined {
659
+ const open = this.persistentByName.get(name);
660
+ if (open !== undefined) return open.size;
661
+ this.ensureSchema();
662
+ return this.readStoredMeta(name)?.size;
663
+ }
664
+
665
+ /**
666
+ * Number of flushes that changed `name` (0 for a file that was never
667
+ * written). Bumped by every flush that wrote chunks, truncated or resized,
668
+ * by imports and by renames — never by reads. Two equal generations mean
669
+ * byte-identical content; the converse does not hold (a rewrite of the
670
+ * same bytes still bumps it), so callers that care hash as a second gate.
671
+ */
672
+ writeGeneration(name: string): number {
673
+ const open = this.persistentByName.get(name);
674
+ if (open !== undefined) return open.gen;
675
+ this.ensureSchema();
676
+ return this.readStoredMeta(name)?.gen ?? 0;
677
+ }
678
+
679
+ /**
680
+ * Concatenate the stored pages of `name` in order: the result is the SQLite
681
+ * file exactly as a filesystem VFS would hold it. Pending (unflushed) pages
682
+ * are flushed first, so this must not be called from inside a SQLite
683
+ * transaction on that file. Materializes the whole file in memory.
684
+ */
685
+ exportFile(name: string): Uint8Array {
686
+ const open = this.persistentByName.get(name);
687
+ if (open !== undefined) {
688
+ if (open.lock >= SQLITE_LOCK_EXCLUSIVE) {
689
+ throw new DoVfsError(
690
+ `cannot export '${name}' while a write transaction is open`,
691
+ );
692
+ }
693
+ this.flush(open);
694
+ }
695
+ this.ensureSchema();
696
+ const size = open?.size ?? this.readStoredMeta(name)?.size;
697
+ if (size === undefined)
698
+ throw new DoVfsError(`file '${name}' does not exist`);
699
+ const out = new Uint8Array(size);
700
+ const totalChunks = Math.ceil(size / CHUNK_SIZE);
701
+ // Read in windows so a large file doesn't need a single giant result set
702
+ // (64 chunks = 4 MiB per query).
703
+ const window = 64;
704
+ for (let start = 0; start < totalChunks; start += window) {
705
+ const rows = this.storage.sql
706
+ .exec<ChunkRow>(
707
+ 'SELECT chunkno, data FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno < ? ORDER BY chunkno',
708
+ name,
709
+ start,
710
+ start + window,
711
+ )
712
+ .toArray();
713
+ this.stats.storageReads++;
714
+ this.stats.rowsRead += rows.length;
715
+ for (const row of rows) {
716
+ const offset = row.chunkno * CHUNK_SIZE;
717
+ const bytes = new Uint8Array(row.data);
718
+ const n = Math.min(bytes.byteLength, size - offset);
719
+ if (n > 0) out.set(bytes.subarray(0, n), offset);
720
+ }
721
+ }
722
+ return out;
723
+ }
724
+
725
+ /**
726
+ * Replace the contents of `name` with `bytes` (split into pages) inside one
727
+ * storage transaction. The file must not be open. If the header says the
728
+ * database was left in WAL mode (which this VFS cannot serve — it has no
729
+ * shared memory), the header is rewritten to rollback-journal mode; a file
730
+ * that was closed cleanly by its previous owner carries no pending WAL.
731
+ */
732
+ importFile(name: string, bytes: Uint8Array): void {
733
+ if (this.persistentByName.has(name)) {
734
+ throw new DoVfsError(`cannot import into '${name}' while it is open`);
735
+ }
736
+ this.assertNoSnapshot(name, 'import into');
737
+ if (bytes.byteLength >= 100 && !startsWith(bytes, SQLITE_HEADER_MAGIC)) {
738
+ throw new DoVfsError(
739
+ 'import rejected: not a SQLite 3 database (bad header magic)',
740
+ );
741
+ }
742
+ const normalized = normalizeJournalModeHeader(bytes);
743
+ this.ensureSchema();
744
+ this.cache.dropFile(name);
745
+ this.storage.transactionSync(() => {
746
+ this.storage.sql.exec('DELETE FROM vfs2_chunks WHERE file = ?', name);
747
+ this.storage.sql.exec(
748
+ 'INSERT INTO vfs2_files (file, size, gen) VALUES (?, ?, 1) ON CONFLICT(file) DO UPDATE SET size = excluded.size, gen = vfs2_files.gen + 1',
749
+ name,
750
+ normalized.byteLength,
751
+ );
752
+ this.stats.rowsWritten += 1;
753
+ const totalChunks = Math.ceil(normalized.byteLength / CHUNK_SIZE);
754
+ const rows: Array<[number, Uint8Array]> = [];
755
+ for (let chunkno = 0; chunkno < totalChunks; chunkno++) {
756
+ const chunk = new Uint8Array(CHUNK_SIZE);
757
+ const start = chunkno * CHUNK_SIZE;
758
+ chunk.set(
759
+ normalized.subarray(
760
+ start,
761
+ Math.min(start + CHUNK_SIZE, normalized.byteLength),
762
+ ),
763
+ );
764
+ rows.push([chunkno, chunk]);
765
+ if (rows.length === FLUSH_BATCH_ROWS) {
766
+ this.upsertChunks(name, rows);
767
+ rows.length = 0;
768
+ }
769
+ }
770
+ if (rows.length > 0) this.upsertChunks(name, rows);
771
+ });
772
+ this.stats.storageWrites++;
773
+ }
774
+
775
+ /** Remove a persistent file and all of its pages. The file must not be open. */
776
+ deleteFile(name: string): void {
777
+ if (this.persistentByName.has(name)) {
778
+ throw new DoVfsError(`cannot delete '${name}' while it is open`);
779
+ }
780
+ this.assertNoSnapshot(name, 'delete');
781
+ this.ensureSchema();
782
+ this.deletePersistent(name);
783
+ }
784
+
785
+ /**
786
+ * Hex SHA-256 of the file as `exportFile` would return it, hashed window
787
+ * by window (never materialized). Synchronous storage reads make it a
788
+ * consistent view even without a snapshot.
789
+ */
790
+ async fileChecksum(name: string): Promise<string> {
791
+ const open = this.persistentByName.get(name);
792
+ if (open !== undefined) {
793
+ if (open.lock >= SQLITE_LOCK_EXCLUSIVE) {
794
+ throw new DoVfsError(
795
+ `cannot checksum '${name}' while a write transaction is open`,
796
+ );
797
+ }
798
+ this.flush(open);
799
+ }
800
+ this.ensureSchema();
801
+ const size = open?.size ?? this.readStoredMeta(name)?.size;
802
+ if (size === undefined)
803
+ throw new DoVfsError(`file '${name}' does not exist`);
804
+ const hash = createHash('sha256');
805
+ const totalChunks = Math.ceil(size / CHUNK_SIZE);
806
+ for (let start = 0; start < totalChunks; start += SNAPSHOT_WINDOW_CHUNKS) {
807
+ const end = Math.min(totalChunks, start + SNAPSHOT_WINDOW_CHUNKS);
808
+ hash.update(this.readWindow(name, size, start, end, undefined));
809
+ }
810
+ return hash.digest('hex');
811
+ }
812
+
813
+ /**
814
+ * Pin the current content of `name` (see `VfsSnapshot`). Pending pages are
815
+ * flushed first, so this must not be called while a write transaction is
816
+ * open on the file; one snapshot per file at a time.
817
+ */
818
+ openSnapshot(name: string): VfsSnapshot {
819
+ if (this.snapshots.has(name))
820
+ throw new DoVfsError(`a snapshot of '${name}' is already open`);
821
+ const open = this.persistentByName.get(name);
822
+ if (open !== undefined) {
823
+ if (open.lock >= SQLITE_LOCK_EXCLUSIVE) {
824
+ throw new DoVfsError(
825
+ `cannot snapshot '${name}' while a write transaction is open`,
826
+ );
827
+ }
828
+ this.flush(open);
829
+ }
830
+ this.ensureSchema();
831
+ const meta = open
832
+ ? { size: open.size, gen: open.gen }
833
+ : this.readStoredMeta(name);
834
+ if (meta === undefined)
835
+ throw new DoVfsError(`file '${name}' does not exist`);
836
+ const state: SnapshotState = {
837
+ name,
838
+ size: meta.size,
839
+ totalChunks: Math.ceil(meta.size / CHUNK_SIZE),
840
+ shadow: new Map(),
841
+ open: true,
842
+ };
843
+ this.snapshots.set(name, state);
844
+ return {
845
+ name,
846
+ size: meta.size,
847
+ generation: meta.gen,
848
+ open: () => this.snapshotStream(state),
849
+ close: () => {
850
+ if (!state.open) return;
851
+ state.open = false;
852
+ state.shadow.clear();
853
+ if (this.snapshots.get(name) === state) this.snapshots.delete(name);
854
+ },
855
+ };
856
+ }
857
+
858
+ private snapshotStream(state: SnapshotState): ReadableStream<Uint8Array> {
859
+ let cursor = 0;
860
+ return new ReadableStream<Uint8Array>(
861
+ {
862
+ pull: (controller) => {
863
+ if (!state.open) {
864
+ controller.error(
865
+ new DoVfsError(`snapshot of '${state.name}' was closed`),
866
+ );
867
+ return;
868
+ }
869
+ if (cursor >= state.totalChunks) {
870
+ controller.close();
871
+ return;
872
+ }
873
+ const end = Math.min(
874
+ state.totalChunks,
875
+ cursor + SNAPSHOT_WINDOW_CHUNKS,
876
+ );
877
+ controller.enqueue(
878
+ this.readWindow(state.name, state.size, cursor, end, state.shadow),
879
+ );
880
+ cursor = end;
881
+ },
882
+ },
883
+ // One window in flight: the consumer (gzip, tus part buffer) pulls the
884
+ // next 1 MiB only after it drained the previous one.
885
+ { highWaterMark: 1 },
886
+ );
887
+ }
888
+
889
+ /**
890
+ * Bytes of chunks `[start, end)` of `name` exactly as stored, capped at
891
+ * `size`. Pre-images in `shadow` win over storage. Windows bypass the clean
892
+ * cache so a whole-file read never evicts the working set.
893
+ */
894
+ private readWindow(
895
+ name: string,
896
+ size: number,
897
+ start: number,
898
+ end: number,
899
+ shadow: Map<number, Uint8Array> | undefined,
900
+ ): Uint8Array {
901
+ const from = start * CHUNK_SIZE;
902
+ const to = Math.min(size, end * CHUNK_SIZE);
903
+ const out = new Uint8Array(Math.max(0, to - from));
904
+ const rows = this.storage.sql
905
+ .exec<ChunkRow>(
906
+ 'SELECT chunkno, data FROM vfs2_chunks WHERE file = ? AND chunkno >= ? AND chunkno < ? ORDER BY chunkno',
907
+ name,
908
+ start,
909
+ end,
910
+ )
911
+ .toArray();
912
+ this.stats.storageReads++;
913
+ this.stats.rowsRead += rows.length;
914
+ const place = (chunkno: number, bytes: Uint8Array): void => {
915
+ const offset = chunkno * CHUNK_SIZE - from;
916
+ const n = Math.min(bytes.byteLength, out.byteLength - offset);
917
+ if (n > 0) out.set(bytes.subarray(0, n), offset);
918
+ };
919
+ for (const row of rows) {
920
+ if (shadow?.has(row.chunkno)) continue;
921
+ place(row.chunkno, new Uint8Array(row.data));
922
+ }
923
+ if (shadow) {
924
+ for (let chunkno = start; chunkno < end; chunkno++) {
925
+ const pre = shadow.get(chunkno);
926
+ if (pre !== undefined) place(chunkno, pre);
927
+ }
928
+ }
929
+ return out;
930
+ }
931
+
932
+ /**
933
+ * Replace `name` with the bytes of `stream` (a decompressed SQLite file of
934
+ * unknown length), written in 64 KiB chunks to a staging file as they
935
+ * arrive and swapped into place in ONE storage transaction at the end. A
936
+ * failure mid-stream leaves `name` untouched. Never holds more than one
937
+ * batch (~1 MiB) in memory. Returns the byte length written.
938
+ */
939
+ async importFromStream(
940
+ name: string,
941
+ stream: ReadableStream<Uint8Array>,
942
+ ): Promise<number> {
943
+ if (this.persistentByName.has(name)) {
944
+ throw new DoVfsError(`cannot import into '${name}' while it is open`);
945
+ }
946
+ this.assertNoSnapshot(name, 'import into');
947
+ this.ensureSchema();
948
+ const staging = `${name}${IMPORT_FILE_SUFFIX}`;
949
+ this.deletePersistent(staging);
950
+ const reader = stream.getReader();
951
+ let pending = new Uint8Array(CHUNK_SIZE);
952
+ let fill = 0;
953
+ let chunkno = 0;
954
+ let total = 0;
955
+ let headerChecked = false;
956
+ let rows: Array<[number, Uint8Array]> = [];
957
+ const flushRows = (): void => {
958
+ if (rows.length === 0) return;
959
+ const batch = rows;
960
+ rows = [];
961
+ this.storage.transactionSync(() => this.upsertChunks(staging, batch));
962
+ this.stats.storageWrites++;
963
+ };
964
+ const checkHeader = (chunk: Uint8Array, length: number): void => {
965
+ if (headerChecked) return;
966
+ headerChecked = true;
967
+ if (length >= 100 && !startsWith(chunk, SQLITE_HEADER_MAGIC)) {
968
+ throw new DoVfsError(
969
+ 'import rejected: not a SQLite 3 database (bad header magic)',
970
+ );
971
+ }
972
+ // WAL-flagged header → rollback-journal mode (this VFS has no shared memory).
973
+ if (length >= 100 && (chunk[18] === 2 || chunk[19] === 2)) {
974
+ chunk[18] = 1;
975
+ chunk[19] = 1;
976
+ }
977
+ };
978
+ try {
979
+ for (;;) {
980
+ const { done, value } = await reader.read();
981
+ if (done) break;
982
+ let offset = 0;
983
+ while (offset < value.byteLength) {
984
+ const n = Math.min(CHUNK_SIZE - fill, value.byteLength - offset);
985
+ pending.set(value.subarray(offset, offset + n), fill);
986
+ fill += n;
987
+ offset += n;
988
+ total += n;
989
+ if (fill === CHUNK_SIZE) {
990
+ if (chunkno === 0) checkHeader(pending, fill);
991
+ rows.push([chunkno++, pending]);
992
+ pending = new Uint8Array(CHUNK_SIZE);
993
+ fill = 0;
994
+ if (rows.length === FLUSH_BATCH_ROWS) flushRows();
995
+ }
996
+ }
997
+ }
998
+ if (fill > 0) {
999
+ if (chunkno === 0) checkHeader(pending, fill);
1000
+ rows.push([chunkno++, pending]);
1001
+ }
1002
+ flushRows();
1003
+ this.cache.dropFile(name);
1004
+ this.storage.transactionSync(() => {
1005
+ this.storage.sql.exec('DELETE FROM vfs2_chunks WHERE file = ?', name);
1006
+ this.storage.sql.exec(
1007
+ 'UPDATE vfs2_chunks SET file = ? WHERE file = ?',
1008
+ name,
1009
+ staging,
1010
+ );
1011
+ this.storage.sql.exec(
1012
+ 'INSERT INTO vfs2_files (file, size, gen) VALUES (?, ?, 1) ON CONFLICT(file) DO UPDATE SET size = excluded.size, gen = vfs2_files.gen + 1',
1013
+ name,
1014
+ total,
1015
+ );
1016
+ this.storage.sql.exec('DELETE FROM vfs2_files WHERE file = ?', staging);
1017
+ this.stats.rowsWritten += chunkno + 1;
1018
+ });
1019
+ this.stats.storageWrites++;
1020
+ return total;
1021
+ } catch (error) {
1022
+ try {
1023
+ this.deletePersistent(staging);
1024
+ } catch {
1025
+ // The import error is the one worth reporting.
1026
+ }
1027
+ throw error;
1028
+ } finally {
1029
+ reader.releaseLock();
1030
+ }
1031
+ }
1032
+
1033
+ /**
1034
+ * Atomically replace `to` with the content of `from` (both must be closed
1035
+ * and `to` unsnapshotted). Used to swap a `VACUUM INTO` output into place.
1036
+ */
1037
+ renameFile(from: string, to: string): void {
1038
+ if (this.persistentByName.has(from) || this.persistentByName.has(to)) {
1039
+ throw new DoVfsError(`cannot rename '${from}' → '${to}' while open`);
1040
+ }
1041
+ this.assertNoSnapshot(to, 'replace');
1042
+ this.ensureSchema();
1043
+ const meta = this.readStoredMeta(from);
1044
+ if (meta === undefined)
1045
+ throw new DoVfsError(`file '${from}' does not exist`);
1046
+ this.cache.dropFile(from);
1047
+ this.cache.dropFile(to);
1048
+ this.storage.transactionSync(() => {
1049
+ this.storage.sql.exec('DELETE FROM vfs2_chunks WHERE file = ?', to);
1050
+ this.storage.sql.exec(
1051
+ 'UPDATE vfs2_chunks SET file = ? WHERE file = ?',
1052
+ to,
1053
+ from,
1054
+ );
1055
+ this.storage.sql.exec(
1056
+ 'INSERT INTO vfs2_files (file, size, gen) VALUES (?, ?, 1) ON CONFLICT(file) DO UPDATE SET size = excluded.size, gen = vfs2_files.gen + 1',
1057
+ to,
1058
+ meta.size,
1059
+ );
1060
+ this.storage.sql.exec('DELETE FROM vfs2_files WHERE file = ?', from);
1061
+ this.stats.rowsWritten += Math.ceil(meta.size / CHUNK_SIZE) + 1;
1062
+ });
1063
+ this.stats.storageWrites++;
1064
+ }
1065
+
1066
+ private assertNoSnapshot(name: string, verb: string): void {
1067
+ if (this.snapshots.has(name)) {
1068
+ throw new DoVfsError(`cannot ${verb} '${name}' while a snapshot is open`);
1069
+ }
1070
+ }
1071
+
1072
+ /** Force any buffered pages of every open file into storage. */
1073
+ flushAll(): void {
1074
+ for (const file of this.persistentByName.values()) this.flush(file);
1075
+ }
1076
+
1077
+ // ---------------------------------------------------------------------------
1078
+ // Persistent file internals
1079
+ // ---------------------------------------------------------------------------
1080
+
1081
+ private isOpen(file: PersistentFile): boolean {
1082
+ for (const open of this.openFiles.values()) {
1083
+ if (open === file) return true;
1084
+ }
1085
+ return false;
1086
+ }
1087
+
1088
+ private ensureSchema(): void {
1089
+ if (this.schemaReady) return;
1090
+ this.storage.sql.exec(
1091
+ 'CREATE TABLE IF NOT EXISTS vfs2_files (file TEXT PRIMARY KEY, size INTEGER NOT NULL)',
1092
+ );
1093
+ this.storage.sql.exec(
1094
+ 'CREATE TABLE IF NOT EXISTS vfs2_chunks (file TEXT NOT NULL, chunkno INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (file, chunkno))',
1095
+ );
1096
+ // Write generation (added after the first deployments): older working
1097
+ // copies get the column with gen = 0.
1098
+ const columns = this.storage.sql
1099
+ .exec<{ name: string }>('PRAGMA table_info(vfs2_files)')
1100
+ .toArray()
1101
+ .map((row) => row.name);
1102
+ if (!columns.includes('gen')) {
1103
+ this.storage.sql.exec(
1104
+ 'ALTER TABLE vfs2_files ADD COLUMN gen INTEGER NOT NULL DEFAULT 0',
1105
+ );
1106
+ }
1107
+ // v1 (one page per row) never shipped; drop dev-environment leftovers so
1108
+ // a stale un-chunked working copy can't shadow the re-import path.
1109
+ this.storage.sql.exec('DROP TABLE IF EXISTS vfs_pages');
1110
+ this.storage.sql.exec('DROP TABLE IF EXISTS vfs_files');
1111
+ // Staging files of an import or VACUUM that died mid-way are garbage.
1112
+ for (const suffix of [IMPORT_FILE_SUFFIX, VACUUM_FILE_SUFFIX]) {
1113
+ const leftovers = this.storage.sql
1114
+ .exec<{
1115
+ file: string;
1116
+ }>(
1117
+ 'SELECT file FROM vfs2_files WHERE substr(file, -?) = ?',
1118
+ suffix.length,
1119
+ suffix,
1120
+ )
1121
+ .toArray();
1122
+ for (const row of leftovers) this.deletePersistent(row.file);
1123
+ }
1124
+ this.schemaReady = true;
1125
+ }
1126
+
1127
+ private readStoredMeta(
1128
+ name: string,
1129
+ ): { size: number; gen: number } | undefined {
1130
+ const rows = this.storage.sql
1131
+ .exec<FileMetaRow>(
1132
+ 'SELECT size, gen FROM vfs2_files WHERE file = ?',
1133
+ name,
1134
+ )
1135
+ .toArray();
1136
+ this.stats.storageReads++;
1137
+ this.stats.rowsRead += rows.length;
1138
+ const row = rows[0];
1139
+ return row === undefined ? undefined : { size: row.size, gen: row.gen };
1140
+ }
1141
+
1142
+ /**
1143
+ * The chunk holding `chunkno`, from (in order) the dirty set, the clean
1144
+ * cache, then storage. Returns a buffer that must be treated as read-only
1145
+ * unless it came from `dirty` — writers copy before mutating.
1146
+ */
1147
+ private loadChunk(
1148
+ file: PersistentFile,
1149
+ chunkno: number,
1150
+ ): Uint8Array | undefined {
1151
+ const dirty = file.dirty.get(chunkno);
1152
+ if (dirty !== undefined) return dirty;
1153
+ const cached = this.cache.get(file.name, chunkno);
1154
+ if (cached !== undefined) {
1155
+ this.stats.cacheHits++;
1156
+ return cached;
1157
+ }
1158
+ if (
1159
+ file.truncateToPages !== null &&
1160
+ chunkno * PAGES_PER_CHUNK >= file.truncateToPages
1161
+ )
1162
+ return undefined;
1163
+ this.stats.cacheMisses++;
1164
+ const rows = this.storage.sql
1165
+ .exec<ChunkRow>(
1166
+ 'SELECT chunkno, data FROM vfs2_chunks WHERE file = ? AND chunkno = ?',
1167
+ file.name,
1168
+ chunkno,
1169
+ )
1170
+ .toArray();
1171
+ this.stats.storageReads++;
1172
+ this.stats.rowsRead += rows.length;
1173
+ const row = rows[0];
1174
+ if (row === undefined) return undefined;
1175
+ let chunk = new Uint8Array(row.data);
1176
+ if (chunk.byteLength !== CHUNK_SIZE) {
1177
+ const full = new Uint8Array(CHUNK_SIZE);
1178
+ full.set(chunk.subarray(0, Math.min(chunk.byteLength, CHUNK_SIZE)));
1179
+ chunk = full;
1180
+ }
1181
+ this.cache.set(file.name, chunkno, chunk);
1182
+ return chunk;
1183
+ }
1184
+
1185
+ /** A dirty (mutable) buffer for `chunkno`, copied from clean state if needed. */
1186
+ private dirtyChunk(file: PersistentFile, chunkno: number): Uint8Array {
1187
+ let chunk = file.dirty.get(chunkno);
1188
+ if (chunk !== undefined) return chunk;
1189
+ const clean = this.loadChunk(file, chunkno);
1190
+ chunk = clean !== undefined ? clean.slice() : new Uint8Array(CHUNK_SIZE);
1191
+ file.dirty.set(chunkno, chunk);
1192
+ return chunk;
1193
+ }
1194
+
1195
+ private readPersistent(
1196
+ file: PersistentFile,
1197
+ pData: Uint8Array,
1198
+ iOffset: number,
1199
+ ): number {
1200
+ const want = pData.byteLength;
1201
+ const available = Math.max(0, Math.min(want, file.size - iOffset));
1202
+ let done = 0;
1203
+ while (done < available) {
1204
+ const pos = iOffset + done;
1205
+ const chunkno = Math.floor(pos / CHUNK_SIZE);
1206
+ const inChunk = pos - chunkno * CHUNK_SIZE;
1207
+ const n = Math.min(CHUNK_SIZE - inChunk, available - done);
1208
+ const chunk = this.loadChunk(file, chunkno);
1209
+ if (chunk === undefined) {
1210
+ pData.fill(0, done, done + n); // sparse region
1211
+ } else {
1212
+ pData.set(chunk.subarray(inChunk, inChunk + n), done);
1213
+ }
1214
+ done += n;
1215
+ }
1216
+ if (available < want) {
1217
+ pData.fill(0, available);
1218
+ return SQLITE_IOERR_SHORT_READ;
1219
+ }
1220
+ return SQLITE_OK;
1221
+ }
1222
+
1223
+ private writePersistent(
1224
+ file: PersistentFile,
1225
+ pData: Uint8Array,
1226
+ iOffset: number,
1227
+ ): void {
1228
+ let done = 0;
1229
+ while (done < pData.byteLength) {
1230
+ const pos = iOffset + done;
1231
+ const chunkno = Math.floor(pos / CHUNK_SIZE);
1232
+ const inChunk = pos - chunkno * CHUNK_SIZE;
1233
+ const n = Math.min(CHUNK_SIZE - inChunk, pData.byteLength - done);
1234
+ // Always merge into the full chunk buffer (a page write touches 1/16th
1235
+ // of its chunk). `dirtyChunk` copies clean state exactly once; pData is
1236
+ // copied implicitly by `set` (it aliases the wasm heap).
1237
+ const chunk = this.dirtyChunk(file, chunkno);
1238
+ chunk.set(pData.subarray(done, done + n), inChunk);
1239
+ done += n;
1240
+ }
1241
+ const end = iOffset + pData.byteLength;
1242
+ if (end > file.size) {
1243
+ file.size = end;
1244
+ file.sizeDirty = true;
1245
+ }
1246
+ if (file.spill && file.dirty.size >= SPILL_CHUNKS) this.flush(file);
1247
+ }
1248
+
1249
+ private truncatePersistent(file: PersistentFile, iSize: number): void {
1250
+ if (iSize >= file.size) return;
1251
+ const keepPages = Math.ceil(iSize / VFS_PAGE_SIZE);
1252
+ const keepChunks = Math.ceil(keepPages / PAGES_PER_CHUNK);
1253
+ for (const chunkno of Array.from(file.dirty.keys())) {
1254
+ if (chunkno >= keepChunks) file.dirty.delete(chunkno);
1255
+ }
1256
+ this.cache.dropFrom(file.name, keepChunks);
1257
+ file.truncateToPages =
1258
+ file.truncateToPages === null
1259
+ ? keepPages
1260
+ : Math.min(file.truncateToPages, keepPages);
1261
+ file.size = iSize;
1262
+ file.sizeDirty = true;
1263
+ // Zero the bytes past the new end inside the last surviving chunk, so a
1264
+ // later grow-then-read never resurrects stale bytes.
1265
+ if (keepChunks > 0) {
1266
+ const tail = iSize - (keepChunks - 1) * CHUNK_SIZE;
1267
+ if (tail < CHUNK_SIZE) {
1268
+ const last = this.loadChunk(file, keepChunks - 1);
1269
+ if (last !== undefined) {
1270
+ const copy =
1271
+ file.dirty.get(keepChunks - 1) === last ? last : last.slice();
1272
+ copy.fill(0, tail);
1273
+ file.dirty.set(keepChunks - 1, copy);
1274
+ }
1275
+ }
1276
+ }
1277
+ }
1278
+
1279
+ /** First chunk fully past the page-count boundary `keepPages`. */
1280
+ private static chunkFloor(keepPages: number): number {
1281
+ return Math.ceil(keepPages / PAGES_PER_CHUNK);
1282
+ }
1283
+
1284
+ /** Write dirty chunks, truncation and size of `file` to storage in one transaction. */
1285
+ private flush(file: PersistentFile): void {
1286
+ if (
1287
+ file.dirty.size === 0 &&
1288
+ file.truncateToPages === null &&
1289
+ !file.sizeDirty
1290
+ )
1291
+ return;
1292
+ const truncateTo = file.truncateToPages;
1293
+ const dirty = Array.from(file.dirty.entries()).sort((a, b) => a[0] - b[0]);
1294
+ const size = file.size;
1295
+ const snapshot = this.snapshots.get(file.name);
1296
+ if (snapshot !== undefined) {
1297
+ // Pin the pre-image of everything this flush overwrites or deletes.
1298
+ const touched = dirty.map(([chunkno]) => chunkno);
1299
+ if (truncateTo !== null) {
1300
+ for (
1301
+ let chunkno = DoVfs.chunkFloor(truncateTo);
1302
+ chunkno < snapshot.totalChunks;
1303
+ chunkno++
1304
+ )
1305
+ touched.push(chunkno);
1306
+ }
1307
+ this.shadowPreImages(file, snapshot, touched);
1308
+ }
1309
+ this.storage.transactionSync(() => {
1310
+ if (truncateTo !== null) {
1311
+ this.storage.sql.exec(
1312
+ 'DELETE FROM vfs2_chunks WHERE file = ? AND chunkno >= ?',
1313
+ file.name,
1314
+ DoVfs.chunkFloor(truncateTo),
1315
+ );
1316
+ }
1317
+ for (let i = 0; i < dirty.length; i += FLUSH_BATCH_ROWS) {
1318
+ this.upsertChunks(file.name, dirty.slice(i, i + FLUSH_BATCH_ROWS));
1319
+ }
1320
+ this.storage.sql.exec(
1321
+ 'UPDATE vfs2_files SET size = ?, gen = gen + 1 WHERE file = ?',
1322
+ size,
1323
+ file.name,
1324
+ );
1325
+ this.stats.rowsWritten += 1;
1326
+ });
1327
+ file.gen += 1;
1328
+ this.stats.storageWrites++;
1329
+ this.stats.flushes++;
1330
+ for (const [chunkno, chunk] of dirty)
1331
+ this.cache.set(file.name, chunkno, chunk);
1332
+ file.dirty.clear();
1333
+ file.truncateToPages = null;
1334
+ file.sizeDirty = false;
1335
+ }
1336
+
1337
+ /**
1338
+ * Capture, for an open snapshot of `file`, the stored content of every
1339
+ * chunk in `chunknos` that lies inside the snapshot and is not shadowed
1340
+ * yet. The clean cache usually still holds the pre-image (a write copies
1341
+ * the clean chunk through it); otherwise one storage read per chunk.
1342
+ */
1343
+ private shadowPreImages(
1344
+ file: PersistentFile,
1345
+ snapshot: SnapshotState,
1346
+ chunknos: number[],
1347
+ ): void {
1348
+ const missing: number[] = [];
1349
+ for (const chunkno of chunknos) {
1350
+ if (chunkno >= snapshot.totalChunks || snapshot.shadow.has(chunkno))
1351
+ continue;
1352
+ const cached = this.cache.get(file.name, chunkno);
1353
+ if (cached !== undefined) snapshot.shadow.set(chunkno, cached);
1354
+ else missing.push(chunkno);
1355
+ }
1356
+ for (let i = 0; i < missing.length; i += SNAPSHOT_WINDOW_CHUNKS) {
1357
+ const batch = missing.slice(i, i + SNAPSHOT_WINDOW_CHUNKS);
1358
+ const rows = this.storage.sql
1359
+ .exec<ChunkRow>(
1360
+ `SELECT chunkno, data FROM vfs2_chunks WHERE file = ? AND chunkno IN (${batch.map(() => '?').join(', ')})`,
1361
+ file.name,
1362
+ ...batch,
1363
+ )
1364
+ .toArray();
1365
+ this.stats.storageReads++;
1366
+ this.stats.rowsRead += rows.length;
1367
+ for (const row of rows)
1368
+ snapshot.shadow.set(row.chunkno, new Uint8Array(row.data));
1369
+ // A chunk with no stored row was sparse at snapshot time: zeros.
1370
+ for (const chunkno of batch) {
1371
+ if (!snapshot.shadow.has(chunkno))
1372
+ snapshot.shadow.set(chunkno, new Uint8Array(CHUNK_SIZE));
1373
+ }
1374
+ }
1375
+ }
1376
+
1377
+ private upsertChunks(
1378
+ name: string,
1379
+ rows: ReadonlyArray<readonly [number, Uint8Array]>,
1380
+ ): void {
1381
+ if (rows.length === 0) return;
1382
+ const placeholders = rows.map(() => '(?, ?, ?)').join(', ');
1383
+ const bindings: Array<string | number | ArrayBuffer> = [];
1384
+ for (const [chunkno, chunk] of rows) {
1385
+ bindings.push(name, chunkno, toArrayBuffer(chunk));
1386
+ }
1387
+ this.storage.sql.exec(
1388
+ `INSERT OR REPLACE INTO vfs2_chunks (file, chunkno, data) VALUES ${placeholders}`,
1389
+ ...bindings,
1390
+ );
1391
+ this.stats.rowsWritten += rows.length;
1392
+ }
1393
+
1394
+ private deletePersistent(name: string): void {
1395
+ this.assertNoSnapshot(name, 'delete');
1396
+ this.cache.dropFile(name);
1397
+ this.storage.transactionSync(() => {
1398
+ this.storage.sql.exec('DELETE FROM vfs2_chunks WHERE file = ?', name);
1399
+ this.storage.sql.exec('DELETE FROM vfs2_files WHERE file = ?', name);
1400
+ });
1401
+ this.stats.storageWrites++;
1402
+ }
1403
+ }
1404
+
1405
+ export function isDoVfs(vfs: SQLiteVFS): vfs is DoVfs {
1406
+ return vfs instanceof DoVfs;
1407
+ }
1408
+
1409
+ // -----------------------------------------------------------------------------
1410
+ // Memory file helpers (journals / temp files)
1411
+ // -----------------------------------------------------------------------------
1412
+
1413
+ function readMemory(
1414
+ file: MemoryFile,
1415
+ pData: Uint8Array,
1416
+ iOffset: number,
1417
+ ): number {
1418
+ const begin = Math.min(iOffset, file.size);
1419
+ const end = Math.min(iOffset + pData.byteLength, file.size);
1420
+ const n = end - begin;
1421
+ if (n > 0) pData.set(file.data.subarray(begin, end));
1422
+ if (n < pData.byteLength) {
1423
+ pData.fill(0, n);
1424
+ return SQLITE_IOERR_SHORT_READ;
1425
+ }
1426
+ return SQLITE_OK;
1427
+ }
1428
+
1429
+ function writeMemory(
1430
+ file: MemoryFile,
1431
+ pData: Uint8Array,
1432
+ iOffset: number,
1433
+ ): number {
1434
+ const end = iOffset + pData.byteLength;
1435
+ if (end > file.data.byteLength) {
1436
+ const grown = new Uint8Array(Math.max(end, file.data.byteLength * 2, 8192));
1437
+ grown.set(file.data.subarray(0, file.size));
1438
+ file.data = grown;
1439
+ }
1440
+ file.data.set(pData, iOffset);
1441
+ file.size = Math.max(file.size, end);
1442
+ return SQLITE_OK;
1443
+ }
1444
+
1445
+ // -----------------------------------------------------------------------------
1446
+ // Byte helpers
1447
+ // -----------------------------------------------------------------------------
1448
+
1449
+ function toArrayBuffer(page: Uint8Array): ArrayBuffer {
1450
+ const { buffer } = page;
1451
+ if (
1452
+ page.byteOffset === 0 &&
1453
+ page.byteLength === buffer.byteLength &&
1454
+ buffer instanceof ArrayBuffer
1455
+ ) {
1456
+ return buffer;
1457
+ }
1458
+ const copy = new Uint8Array(page.byteLength);
1459
+ copy.set(page);
1460
+ return copy.buffer;
1461
+ }
1462
+
1463
+ function startsWith(bytes: Uint8Array, ascii: string): boolean {
1464
+ if (bytes.byteLength < ascii.length) return false;
1465
+ for (let i = 0; i < ascii.length; i++) {
1466
+ if (bytes[i] !== ascii.charCodeAt(i)) return false;
1467
+ }
1468
+ return true;
1469
+ }
1470
+
1471
+ /**
1472
+ * Header bytes 18 (write version) and 19 (read version) are 2 for WAL-mode
1473
+ * databases and 1 for rollback-journal databases. Returns the input unchanged
1474
+ * unless it is a WAL-flagged SQLite file, in which case a copy with both bytes
1475
+ * set to 1 is returned.
1476
+ */
1477
+ export function normalizeJournalModeHeader(bytes: Uint8Array): Uint8Array {
1478
+ if (bytes.byteLength < 100 || !startsWith(bytes, SQLITE_HEADER_MAGIC))
1479
+ return bytes;
1480
+ if (bytes[18] !== 2 && bytes[19] !== 2) return bytes;
1481
+ const copy = bytes.slice();
1482
+ copy[18] = 1;
1483
+ copy[19] = 1;
1484
+ return copy;
1485
+ }