@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.
- package/README.md +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- 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
|
+
}
|