@gscdump/engine-duckdb-wasm 1.3.2 → 1.4.1

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.
@@ -0,0 +1,89 @@
1
+ function createOpfsHandleRegistry(backend) {
2
+ const entries = /* @__PURE__ */ new Map();
3
+ const pending = /* @__PURE__ */ new Map();
4
+ function reportCleanupFailure(operation, error) {
5
+ console.warn(`[gscdump/engine-duckdb-wasm] ${operation} failed`, error);
6
+ }
7
+ async function acquire(name, openHandle) {
8
+ const existing = entries.get(name);
9
+ if (existing) {
10
+ existing.refs++;
11
+ return;
12
+ }
13
+ const inflight = pending.get(name);
14
+ if (inflight) {
15
+ await inflight.then(() => void 0, () => void 0);
16
+ const settled = entries.get(name);
17
+ if (settled) {
18
+ settled.refs++;
19
+ return;
20
+ }
21
+ return acquire(name, openHandle);
22
+ }
23
+ const registration = (async () => {
24
+ const handle = await openHandle();
25
+ await backend.register(name, handle);
26
+ entries.set(name, {
27
+ handle,
28
+ refs: 1
29
+ });
30
+ })();
31
+ pending.set(name, registration);
32
+ try {
33
+ await registration;
34
+ } finally {
35
+ pending.delete(name);
36
+ }
37
+ }
38
+ async function release(names) {
39
+ for (const name of names) {
40
+ const entry = entries.get(name);
41
+ if (!entry) continue;
42
+ entry.refs--;
43
+ if (entry.refs > 0) continue;
44
+ entries.delete(name);
45
+ await backend.drop(name).catch((error) => {
46
+ reportCleanupFailure(`dropping OPFS handle ${name}`, error);
47
+ });
48
+ }
49
+ }
50
+ const viewRefs = /* @__PURE__ */ new Map();
51
+ function acquireView(key, signature) {
52
+ const existing = viewRefs.get(key);
53
+ if (existing) {
54
+ if (existing.signature !== signature) throw new Error(`view ${key} is already attached with a different signature`);
55
+ existing.refs++;
56
+ return;
57
+ }
58
+ viewRefs.set(key, {
59
+ refs: 1,
60
+ signature
61
+ });
62
+ }
63
+ async function releaseView(key, drop) {
64
+ const existing = viewRefs.get(key);
65
+ const next = (existing?.refs ?? 0) - 1;
66
+ if (next > 0) {
67
+ viewRefs.set(key, {
68
+ refs: next,
69
+ signature: existing?.signature
70
+ });
71
+ return;
72
+ }
73
+ viewRefs.delete(key);
74
+ await drop().catch((error) => {
75
+ reportCleanupFailure(`dropping view ${key}`, error);
76
+ });
77
+ }
78
+ return {
79
+ acquire,
80
+ release,
81
+ size: () => entries.size,
82
+ refs: (name) => entries.get(name)?.refs ?? 0,
83
+ acquireView,
84
+ releaseView,
85
+ viewRefs: (key) => viewRefs.get(key)?.refs ?? 0,
86
+ viewSignature: (key) => viewRefs.get(key)?.signature
87
+ };
88
+ }
89
+ export { createOpfsHandleRegistry };
@@ -0,0 +1,195 @@
1
+ import { AsyncDuckDB, AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
2
+ /** A parquet data file to materialise into OPFS. */
3
+ interface OpfsParquetFile {
4
+ /** Same-origin URL carrying a signed size hint + short-lived access token. */
5
+ url: string;
6
+ /** Expected byte size — drives progress + a cheap pre-verify shortcut. */
7
+ bytes: number;
8
+ /**
9
+ * Opaque content-stable identifier for this file (e.g. the Iceberg data-
10
+ * file object key). Encoded into the OPFS filename so the same hash is the
11
+ * same cache entry. NOT required to be a SHA-256. When omitted, the cache
12
+ * key falls back to `(table, index)` and only the byte size is verified
13
+ * (degraded — stale entries can survive a content change).
14
+ */
15
+ contentHash?: string;
16
+ /** Row count — diagnostics only. */
17
+ rowCount?: number;
18
+ }
19
+ /** One logical table and the parquet files that compose it. */
20
+ interface OpfsParquetTable {
21
+ /** Iceberg table name — becomes the DuckDB view name. */
22
+ table: string;
23
+ files: OpfsParquetFile[];
24
+ /**
25
+ * Recent-window overlay parquet (the non-stable tail the lake excludes). When
26
+ * present it is materialised into OPFS alongside `files` and the view unions
27
+ * it with an anti-join dedup: the lake (`files`) serves every day it has, the
28
+ * overlay serves ONLY days the lake lacks. So a stale overlay whose days have
29
+ * since landed in the lake can't double-count, and a day that stabilised but
30
+ * isn't yet in the lake still serves from the overlay. Its `contentHash` must
31
+ * change when the overlay bytes change (it is overwritten in place) so the
32
+ * cache re-downloads.
33
+ */
34
+ overlay?: OpfsParquetFile;
35
+ }
36
+ interface AttachOpfsTablesOptions {
37
+ db: AsyncDuckDB;
38
+ conn: AsyncDuckDBConnection;
39
+ tables: OpfsParquetTable[];
40
+ /** DuckDB schema the views are created in. Default `main`. */
41
+ schema?: string;
42
+ /** `fetch` override (tests). Default `globalThis.fetch`. */
43
+ fetch?: typeof fetch;
44
+ /** Request init for the parquet downloads (auth headers, credentials mode). */
45
+ fetchInit?: RequestInit;
46
+ /** Caps simultaneous downloads. Default 2. */
47
+ fetchConcurrency?: number;
48
+ /** Abort signal threaded through downloads + registration. */
49
+ signal?: AbortSignal;
50
+ /** Snapshot version associated with this file set — echoed on the handle. */
51
+ version?: string;
52
+ /** Ticks once per file as it lands in OPFS + registers. UI progress. */
53
+ onFileProgress?: (info: OpfsFileProgress) => void;
54
+ /**
55
+ * Low-volume diagnostics for the OPFS attach waterfall. This is intentionally
56
+ * phase-level rather than a logger so hosts can join it with their own route
57
+ * timings without parsing console text.
58
+ */
59
+ onTiming?: (info: OpfsAttachTiming) => void;
60
+ /**
61
+ * Serializer for the operations that mutate the DuckDB-WASM virtual
62
+ * filesystem / catalog (`registerFileHandle`, `dropFile`, `CREATE`/`DROP
63
+ * VIEW`) — those are not concurrency-safe across consumers sharing one
64
+ * `AsyncDuckDB`. Callers with a shared DB pass their global attach mutex
65
+ * here so ONLY these mutations serialize; the parquet downloads (network +
66
+ * OPFS writes, no DB interaction) run outside the lock and overlap across
67
+ * concurrent attaches. Wrapping the whole `attachOpfsParquetTables` call in
68
+ * the mutex instead serializes every table's downloads end-to-end — the
69
+ * dominant cold-load cost.
70
+ *
71
+ * The returned handle's `detach()` is NOT routed through this — callers
72
+ * that pass a lock typically already hold it around teardown, and the lock
73
+ * is not reentrant.
74
+ *
75
+ * Default: run inline (caller owns the DB exclusively).
76
+ */
77
+ withDb?: <T>(fn: () => Promise<T>) => Promise<T>;
78
+ /**
79
+ * Recover a table degraded by an OPFS sync-access-handle / write conflict
80
+ * (the multi-tab case — another tab holds the file's exclusive handle) by
81
+ * reading the already-cached bytes via the lock-free File API (or HTTP on a
82
+ * genuine cache miss) and registering them as in-memory buffers, so the table
83
+ * attaches from the shared cache instead of being pushed back to the caller.
84
+ * Quota / incomplete degradations are NEVER recovered this way — buffering a
85
+ * quota-exceeding set risks OOM, and the caller routes those to its tail.
86
+ * Default true.
87
+ */
88
+ recoverContention?: boolean;
89
+ }
90
+ interface OpfsFileProgress {
91
+ table: string;
92
+ /** OPFS file name. */
93
+ file: string;
94
+ /** Index within the flat file list. */
95
+ index: number;
96
+ /** Total files across all tables. */
97
+ total: number;
98
+ /** Bytes of this file (cumulative caller-side). */
99
+ bytes: number;
100
+ /** `'cache-hit'` — already in OPFS, verified; `'downloaded'` — fetched. */
101
+ outcome: 'cache-hit' | 'downloaded';
102
+ /** Time spent probing/downloading/writing this file before DuckDB registration. */
103
+ materialiseMs?: number;
104
+ /** Time spent inside DuckDB's file-registration mutation lock for this file. */
105
+ registerMs?: number;
106
+ /** End-to-end file phase time, materialise + register + callback overhead. */
107
+ totalMs?: number;
108
+ }
109
+ type OpfsAttachTimingStage = 'persist' | 'root' | 'plan' | 'duckdb-import' | 'sweep' | 'materialise' | 'register' | 'downloads' | 'view' | 'recovery' | 'total';
110
+ interface OpfsAttachTiming {
111
+ stage: OpfsAttachTimingStage;
112
+ durationMs: number;
113
+ table?: string;
114
+ file?: string;
115
+ files?: number;
116
+ tables?: number;
117
+ bytes?: number;
118
+ outcome?: 'cache-hit' | 'downloaded';
119
+ }
120
+ /** Handle returned from {@link attachOpfsParquetTables}. */
121
+ interface OpfsAttachedHandle {
122
+ version: string | undefined;
123
+ /** Tables that successfully attached (a per-table failure drops only that table). */
124
+ tables: string[];
125
+ schema: string;
126
+ /** Total bytes materialised into OPFS for this attach. */
127
+ bytesAttached: number;
128
+ /**
129
+ * Tables that could NOT be attached because OPFS ran out of quota. The
130
+ * caller routes these to the server tail. Empty on a clean attach.
131
+ */
132
+ degradedTables: string[];
133
+ /**
134
+ * Why each degraded table degraded. 'quota' is actionable (the caller can
135
+ * reclaim space via `clearOpfsSnapshotCache` and retry once); 'contention'
136
+ * and 'incomplete' are transient multi-tab races the buffer fallback
137
+ * already absorbs.
138
+ */
139
+ degradeReasons: Partial<Record<string, 'quota' | 'contention' | 'incomplete'>>;
140
+ /** Detach the created views + release the registered OPFS file handles. */
141
+ detach: () => Promise<void>;
142
+ }
143
+ /**
144
+ * Raised when OPFS cannot hold the file set. Carries the partial state so the
145
+ * caller can degrade — attach what fit, route the rest server-side.
146
+ */
147
+ declare class OpfsQuotaExceededError extends Error {
148
+ name: string;
149
+ /** Tables that did not fit. */
150
+ readonly degradedTables: string[];
151
+ constructor(message: string, degradedTables: string[]);
152
+ }
153
+ /**
154
+ * Request persistent storage so the browser is less likely to evict the OPFS
155
+ * cache under pressure. Returns the granted state — `false` is normal for an
156
+ * un-engaged origin and is NOT an error; it just means eviction is possible.
157
+ */
158
+ declare function requestPersistentStorage(): Promise<boolean>;
159
+ /** Best-effort `{ usageBytes, quotaBytes }` from the Storage API. */
160
+ declare function estimateOpfsStorage(): Promise<{
161
+ usageBytes?: number;
162
+ quotaBytes?: number;
163
+ }>;
164
+ /**
165
+ * Read a content-addressed OPFS snapshot file via the async File API and return
166
+ * its bytes, or null when absent / size-mismatched (partial / stale write).
167
+ *
168
+ * `getFile()` takes NO lock — unlike DuckDB's `BROWSER_FSACCESS` sync access
169
+ * handle — so it reads cleanly while ANOTHER tab holds the same file open. That
170
+ * is the basis for cross-tab cache sharing: a tab that loses the exclusive-handle
171
+ * race reads the shared cached bytes here instead of re-downloading. The
172
+ * filename derivation is the SAME `opfsFileName` + `contentHashSlug` the attach
173
+ * path writes, so consumers reuse the engine's naming with no replicated slug
174
+ * logic to drift out of lock-step. `index` is only the disambiguator for the
175
+ * degraded no-`contentHash` fallback (mirrors `attachOpfsParquetTables`).
176
+ */
177
+ declare function readOpfsSnapshotFile(table: string, contentHash: string | undefined, index: number, expectedBytes: number): Promise<Uint8Array | null>;
178
+ /**
179
+ * Download every parquet file in `tables` into OPFS, content-hash verify, and
180
+ * attach them as DuckDB-WASM views. Attach-once: call this once per
181
+ * `(site, table)` span; re-query without re-attaching for filter / range
182
+ * changes inside the span.
183
+ *
184
+ * Quota handling: a `QuotaExceededError` while writing a table's files does
185
+ * NOT throw — that table is recorded in `degradedTables` and skipped; the
186
+ * caller routes it to the server tail. The remaining tables still attach.
187
+ */
188
+ declare function attachOpfsParquetTables(options: AttachOpfsTablesOptions): Promise<OpfsAttachedHandle>;
189
+ /**
190
+ * Delete every OPFS entry this module created. Used to reclaim space after a
191
+ * quota error, or to force a clean re-download. Best-effort — missing entries
192
+ * are ignored.
193
+ */
194
+ declare function clearOpfsSnapshotCache(): Promise<void>;
195
+ export { AttachOpfsTablesOptions, OpfsAttachTiming, OpfsAttachTimingStage, OpfsAttachedHandle, OpfsFileProgress, OpfsParquetFile, OpfsParquetTable, OpfsQuotaExceededError, attachOpfsParquetTables, clearOpfsSnapshotCache, estimateOpfsStorage, readOpfsSnapshotFile, requestPersistentStorage };