@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.
package/dist/opfs.mjs ADDED
@@ -0,0 +1,584 @@
1
+ import { createOpfsHandleRegistry } from "./opfs-registry.mjs";
2
+ import { overlayViewBody } from "./overlay-view.mjs";
3
+ var OpfsQuotaExceededError = class extends Error {
4
+ name = "OpfsQuotaExceededError";
5
+ degradedTables;
6
+ constructor(message, degradedTables) {
7
+ super(message);
8
+ this.degradedTables = degradedTables;
9
+ }
10
+ };
11
+ const DEFAULT_CONCURRENCY = 2;
12
+ const OPFS_PREFIX = "gscdump-snapshot__";
13
+ const RECOVER_BUFFER_PREFIX = "gscdump-recover__";
14
+ const MAX_CONTENT_HASH_SLUG_CACHE = 4096;
15
+ const contentHashSlugCache = /* @__PURE__ */ new Map();
16
+ const IDENT_RE = /^[A-Z_][\w$]*$/i;
17
+ function nowMs() {
18
+ return globalThis.performance?.now?.() ?? Date.now();
19
+ }
20
+ function isNotFoundError(error) {
21
+ return typeof error === "object" && error !== null && error.name === "NotFoundError";
22
+ }
23
+ function reportBestEffortFailure(operation, error) {
24
+ console.warn(`[gscdump/engine-duckdb-wasm] ${operation} failed`, error);
25
+ }
26
+ async function attemptCleanup(operation, cleanup) {
27
+ try {
28
+ await cleanup();
29
+ } catch (error) {
30
+ reportBestEffortFailure(operation, error);
31
+ }
32
+ }
33
+ function emitTiming(onTiming, info) {
34
+ try {
35
+ onTiming?.(info);
36
+ } catch (error) {
37
+ reportBestEffortFailure("timing callback", error);
38
+ }
39
+ }
40
+ async function timed(onTiming, stage, meta, fn) {
41
+ const started = nowMs();
42
+ try {
43
+ return await fn();
44
+ } finally {
45
+ emitTiming(onTiming, {
46
+ ...meta,
47
+ stage,
48
+ durationMs: nowMs() - started
49
+ });
50
+ }
51
+ }
52
+ const dbRegistries = /* @__PURE__ */ new WeakMap();
53
+ function getOpfsRegistry(db, protocol) {
54
+ let registry = dbRegistries.get(db);
55
+ if (!registry) {
56
+ registry = createOpfsHandleRegistry({
57
+ register: (name, handle) => db.registerFileHandle(name, handle, protocol, true),
58
+ drop: async (name) => {
59
+ await db.dropFile(name);
60
+ }
61
+ });
62
+ dbRegistries.set(db, registry);
63
+ }
64
+ return registry;
65
+ }
66
+ const dbBufferRegistries = /* @__PURE__ */ new WeakMap();
67
+ function getBufferRegistry(db) {
68
+ let registry = dbBufferRegistries.get(db);
69
+ if (!registry) {
70
+ registry = createOpfsHandleRegistry({
71
+ register: (name, handle) => db.registerFileBuffer(name, handle),
72
+ drop: async (name) => {
73
+ await db.dropFile(name);
74
+ }
75
+ });
76
+ dbBufferRegistries.set(db, registry);
77
+ }
78
+ return registry;
79
+ }
80
+ function assertSqlIdentifier(kind, value) {
81
+ if (!IDENT_RE.test(value)) throw new TypeError(`[engine-duckdb-wasm/opfs] invalid ${kind} identifier ${JSON.stringify(value)}`);
82
+ }
83
+ const DOWNLOAD_DEADLINE_MS = 12e4;
84
+ function isQuotaError(err) {
85
+ if (typeof err !== "object" || err === null) return false;
86
+ return err.name === "QuotaExceededError" || err.code === 22;
87
+ }
88
+ function isAbortError(err) {
89
+ return typeof err === "object" && err !== null && err.name === "AbortError";
90
+ }
91
+ function isOpfsAccessHandleConflict(err) {
92
+ const msg = err instanceof Error ? err.message : String(err);
93
+ return /createSyncAccessHandle|Access Handle/i.test(msg);
94
+ }
95
+ function isOpfsWriteConflict(err) {
96
+ if (typeof err !== "object" || err === null) return false;
97
+ const name = err.name;
98
+ const msg = err instanceof Error ? err.message : String(err);
99
+ return name === "NoModificationAllowedError" || err.code === 7 || /createWritable|modifications are not allowed/i.test(msg);
100
+ }
101
+ function opfsFileName(table, hashSlug, index) {
102
+ return hashSlug ? `${OPFS_PREFIX}${table}_${hashSlug}.parquet` : `${OPFS_PREFIX}${table}_${index}.parquet`;
103
+ }
104
+ function escapeRegExp(s) {
105
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
106
+ }
107
+ function tableEntryMatcher(table) {
108
+ return new RegExp(`^${escapeRegExp(`${OPFS_PREFIX}${table}_`)}(?:[0-9a-f]{16}|\\d+(?:_[0-9a-f]{16})?)\\.parquet$`);
109
+ }
110
+ async function sweepStaleEntries(root, registry, expectedByTable) {
111
+ const dir = root;
112
+ if (!dir.keys) return;
113
+ const matchers = [...expectedByTable].map(([table, expected]) => ({
114
+ expected,
115
+ re: tableEntryMatcher(table)
116
+ }));
117
+ for await (const name of dir.keys()) {
118
+ const m = matchers.find((m) => m.re.test(name));
119
+ if (!m || m.expected.has(name) || registry.refs(name) > 0) continue;
120
+ try {
121
+ await root.removeEntry(name);
122
+ } catch (error) {
123
+ if (!isNotFoundError(error)) reportBestEffortFailure(`removing stale OPFS entry ${name}`, error);
124
+ }
125
+ }
126
+ }
127
+ async function requestPersistentStorage() {
128
+ const storage = globalThis.navigator?.storage;
129
+ if (!storage?.persist) return false;
130
+ if (await storage.persisted?.().catch(() => false)) return true;
131
+ return storage.persist().catch(() => false);
132
+ }
133
+ async function estimateOpfsStorage() {
134
+ const estimate = globalThis.navigator?.storage?.estimate;
135
+ if (!estimate) return {};
136
+ const est = await estimate.call(globalThis.navigator.storage).catch(() => null);
137
+ return est ? {
138
+ usageBytes: est.usage,
139
+ quotaBytes: est.quota
140
+ } : {};
141
+ }
142
+ async function contentHashSlug(contentHash) {
143
+ const cached = contentHashSlugCache.get(contentHash);
144
+ if (cached) return cached;
145
+ if (contentHashSlugCache.size >= MAX_CONTENT_HASH_SLUG_CACHE) {
146
+ const oldest = contentHashSlugCache.keys().next().value;
147
+ if (oldest !== void 0) contentHashSlugCache.delete(oldest);
148
+ }
149
+ const promise = deriveContentHashSlug(contentHash).catch((err) => {
150
+ contentHashSlugCache.delete(contentHash);
151
+ throw err;
152
+ });
153
+ contentHashSlugCache.set(contentHash, promise);
154
+ return promise;
155
+ }
156
+ async function deriveContentHashSlug(contentHash) {
157
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(contentHash));
158
+ const bytes = new Uint8Array(digest);
159
+ let hex = "";
160
+ for (let i = 0; i < 8; i++) hex += bytes[i].toString(16).padStart(2, "0");
161
+ return hex;
162
+ }
163
+ async function getOpfsRoot() {
164
+ const dir = globalThis.navigator?.storage?.getDirectory;
165
+ if (!dir) throw new Error("[engine-duckdb-wasm/opfs] OPFS unavailable: navigator.storage.getDirectory missing");
166
+ return dir.call(globalThis.navigator.storage);
167
+ }
168
+ async function readOpfsSnapshotFile(table, contentHash, index, expectedBytes) {
169
+ try {
170
+ const root = await getOpfsRoot();
171
+ const name = opfsFileName(table, contentHash ? await contentHashSlug(contentHash) : void 0, index);
172
+ const file = await (await root.getFileHandle(name)).getFile();
173
+ if (file.size !== expectedBytes) return null;
174
+ return new Uint8Array(await file.arrayBuffer());
175
+ } catch (error) {
176
+ if (!isNotFoundError(error)) reportBestEffortFailure(`reading OPFS snapshot ${table}`, error);
177
+ return null;
178
+ }
179
+ }
180
+ async function materialiseFile(root, name, file, fetchImpl, fetchInit, signal) {
181
+ signal?.throwIfAborted();
182
+ let handle;
183
+ try {
184
+ handle = await root.getFileHandle(name);
185
+ if ((await handle.getFile()).size === file.bytes) return {
186
+ handle,
187
+ outcome: "cache-hit"
188
+ };
189
+ } catch (error) {
190
+ if (!isNotFoundError(error)) throw error;
191
+ }
192
+ signal?.throwIfAborted();
193
+ const deadline = AbortSignal.timeout(DOWNLOAD_DEADLINE_MS);
194
+ const fetchSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
195
+ const resp = await fetchImpl(file.url, {
196
+ ...fetchInit,
197
+ signal: fetchSignal
198
+ });
199
+ if (!resp.ok) throw new Error(`[engine-duckdb-wasm/opfs] download ${file.url} failed: ${resp.status}`);
200
+ handle = await root.getFileHandle(name, { create: true });
201
+ let writable;
202
+ try {
203
+ writable = await handle.createWritable();
204
+ } catch (err) {
205
+ await resp.body?.cancel().catch(() => void 0);
206
+ throw err;
207
+ }
208
+ let reader;
209
+ let bytesWritten = 0;
210
+ try {
211
+ if (resp.body) {
212
+ reader = resp.body.getReader();
213
+ while (true) {
214
+ const { done, value } = await reader.read();
215
+ if (done) break;
216
+ bytesWritten += value.byteLength;
217
+ if (bytesWritten > file.bytes) throw new Error(`[engine-duckdb-wasm/opfs] download ${file.url} byte length mismatch: expected ${file.bytes}, got more than ${file.bytes}`);
218
+ await writable.write(value);
219
+ }
220
+ } else {
221
+ const buf = await resp.arrayBuffer();
222
+ bytesWritten = buf.byteLength;
223
+ await writable.write(buf);
224
+ }
225
+ if (bytesWritten !== file.bytes) throw new Error(`[engine-duckdb-wasm/opfs] download ${file.url} byte length mismatch: expected ${file.bytes}, got ${bytesWritten}`);
226
+ await writable.close();
227
+ } catch (err) {
228
+ await reader?.cancel().catch(() => void 0);
229
+ if (writable.abort) await attemptCleanup(`aborting partial OPFS write ${name}`, () => writable.abort());
230
+ await attemptCleanup(`removing partial OPFS file ${name}`, () => root.removeEntry(name));
231
+ throw err;
232
+ } finally {
233
+ reader?.releaseLock();
234
+ }
235
+ return {
236
+ handle,
237
+ outcome: "downloaded"
238
+ };
239
+ }
240
+ function quoteList(files) {
241
+ return files.map((f) => `'${f.replace(/'/g, "''")}'`).join(", ");
242
+ }
243
+ function lakeSelect(files) {
244
+ return `SELECT * REPLACE (CAST(date AS DATE) AS date) FROM read_parquet([${quoteList(files)}], union_by_name = true)`;
245
+ }
246
+ function readParquetViewSql(schema, table, files) {
247
+ return `CREATE OR REPLACE VIEW ${schema}.${table} AS ${lakeSelect(files)}`;
248
+ }
249
+ function readParquetViewWithOverlaySql(schema, table, lakeFiles, overlayFile) {
250
+ const overlay = `SELECT * REPLACE (CAST(date AS DATE) AS date) FROM read_parquet(['${overlayFile.replace(/'/g, "''")}'], union_by_name = true)`;
251
+ return `CREATE OR REPLACE VIEW ${schema}.${table} AS ${overlayViewBody({
252
+ lakeSelect: lakeFiles.length === 0 ? null : lakeSelect(lakeFiles),
253
+ overlaySelect: overlay,
254
+ materializeLake: false
255
+ })}`;
256
+ }
257
+ function viewKey(schema, table) {
258
+ return `${schema}.${table}`;
259
+ }
260
+ function viewSignature(lakeFiles, overlayFile) {
261
+ return JSON.stringify({
262
+ lake: [...lakeFiles].sort(),
263
+ overlay: overlayFile ?? null
264
+ });
265
+ }
266
+ async function ensureView(registry, conn, schema, table, signature, sql) {
267
+ const key = viewKey(schema, table);
268
+ if (registry.viewSignature(key) === signature) {
269
+ registry.acquireView(key, signature);
270
+ return;
271
+ }
272
+ if (registry.viewRefs(key) > 0) throw new Error(`[engine-duckdb-wasm/opfs] view ${key} is already attached with a different fileset`);
273
+ await conn.query(sql);
274
+ registry.acquireView(key, signature);
275
+ }
276
+ async function recoveredBufferName(table, file, index) {
277
+ const slug = file.contentHash ? await contentHashSlug(file.contentHash) : String(index);
278
+ return `${RECOVER_BUFFER_PREFIX}${table}_${slug}.parquet`;
279
+ }
280
+ async function runWithConcurrency(items, concurrency, fn) {
281
+ let next = 0;
282
+ let failed;
283
+ async function worker() {
284
+ while (failed === void 0 && next < items.length) {
285
+ const index = next++;
286
+ try {
287
+ await fn(items[index], index);
288
+ } catch (err) {
289
+ failed = err;
290
+ throw err;
291
+ }
292
+ }
293
+ }
294
+ const rejected = (await Promise.allSettled(Array.from({ length: Math.min(concurrency, items.length) }, worker))).find((r) => r.status === "rejected");
295
+ if (rejected) throw rejected.reason;
296
+ }
297
+ async function attachOpfsParquetTables(options) {
298
+ const { db, conn, tables, schema = "main", fetch: fetchImpl = globalThis.fetch.bind(globalThis), fetchInit, fetchConcurrency = DEFAULT_CONCURRENCY, signal, version, onFileProgress, onTiming, withDb = (fn) => fn(), recoverContention = true } = options;
299
+ assertSqlIdentifier("schema", schema);
300
+ for (const table of tables) assertSqlIdentifier("table", table.table);
301
+ const totalStarted = nowMs();
302
+ await timed(onTiming, "persist", {}, requestPersistentStorage);
303
+ const root = await timed(onTiming, "root", {}, getOpfsRoot);
304
+ const flat = [];
305
+ const expectedByTable = /* @__PURE__ */ new Map();
306
+ await timed(onTiming, "plan", { tables: tables.length }, async () => {
307
+ for (const t of tables) {
308
+ const expected = /* @__PURE__ */ new Set();
309
+ for (let i = 0; i < t.files.length; i++) {
310
+ const file = t.files[i];
311
+ const slug = file.contentHash ? await contentHashSlug(file.contentHash) : void 0;
312
+ const name = opfsFileName(t.table, slug, i);
313
+ flat.push({
314
+ table: t.table,
315
+ file,
316
+ name
317
+ });
318
+ expected.add(name);
319
+ }
320
+ if (t.overlay) {
321
+ const slug = t.overlay.contentHash ? await contentHashSlug(t.overlay.contentHash) : void 0;
322
+ const name = opfsFileName(t.table, slug, t.files.length);
323
+ flat.push({
324
+ table: t.table,
325
+ file: t.overlay,
326
+ name,
327
+ overlay: true
328
+ });
329
+ expected.add(name);
330
+ }
331
+ expectedByTable.set(t.table, expected);
332
+ }
333
+ });
334
+ const total = flat.length;
335
+ const overlayNames = /* @__PURE__ */ new Map();
336
+ const expectedCount = new Map(tables.map((t) => [t.table, t.files.length + (t.overlay ? 1 : 0)]));
337
+ const { DuckDBDataProtocol } = await timed(onTiming, "duckdb-import", {}, () => import("@duckdb/duckdb-wasm"));
338
+ const registry = getOpfsRegistry(db, DuckDBDataProtocol.BROWSER_FSACCESS);
339
+ const bufferRegistry = getBufferRegistry(db);
340
+ await timed(onTiming, "sweep", {
341
+ files: total,
342
+ tables: tables.length
343
+ }, () => sweepStaleEntries(root, registry, expectedByTable)).catch((error) => {
344
+ reportBestEffortFailure("stale OPFS cache sweep", error);
345
+ });
346
+ const tableFiles = /* @__PURE__ */ new Map();
347
+ const degraded = /* @__PURE__ */ new Set();
348
+ const degradeReason = /* @__PURE__ */ new Map();
349
+ const acquiredNames = /* @__PURE__ */ new Set();
350
+ const bufferFiles = [];
351
+ const bufferViews = [];
352
+ let bytesAttached = 0;
353
+ const releaseTable = async (table) => {
354
+ const names = (tableFiles.get(table) ?? []).map((f) => f.name);
355
+ for (const n of names) acquiredNames.delete(n);
356
+ await withDb(() => registry.release(names));
357
+ };
358
+ const runDownloads = () => runWithConcurrency(flat, Math.max(1, fetchConcurrency), async (item, index) => {
359
+ if (degraded.has(item.table)) return;
360
+ signal?.throwIfAborted();
361
+ const fileStarted = nowMs();
362
+ const name = item.name;
363
+ let result;
364
+ let materialiseMs = 0;
365
+ let registerMs = 0;
366
+ try {
367
+ const materialiseStarted = nowMs();
368
+ result = await materialiseFile(root, name, item.file, fetchImpl, fetchInit, signal);
369
+ materialiseMs = nowMs() - materialiseStarted;
370
+ emitTiming(onTiming, {
371
+ stage: "materialise",
372
+ durationMs: materialiseMs,
373
+ table: item.table,
374
+ file: name,
375
+ bytes: item.file.bytes,
376
+ outcome: result.outcome
377
+ });
378
+ } catch (err) {
379
+ if (isAbortError(err)) throw err;
380
+ if (isQuotaError(err)) {
381
+ degraded.add(item.table);
382
+ degradeReason.set(item.table, "quota");
383
+ return;
384
+ }
385
+ if (isOpfsWriteConflict(err)) {
386
+ degraded.add(item.table);
387
+ degradeReason.set(item.table, "contention");
388
+ return;
389
+ }
390
+ throw err;
391
+ }
392
+ try {
393
+ const registerStarted = nowMs();
394
+ await withDb(() => registry.acquire(name, () => result.handle));
395
+ registerMs = nowMs() - registerStarted;
396
+ emitTiming(onTiming, {
397
+ stage: "register",
398
+ durationMs: registerMs,
399
+ table: item.table,
400
+ file: name,
401
+ bytes: item.file.bytes,
402
+ outcome: result.outcome
403
+ });
404
+ acquiredNames.add(name);
405
+ } catch (err) {
406
+ if (isAbortError(err)) throw err;
407
+ if (isOpfsAccessHandleConflict(err)) {
408
+ degraded.add(item.table);
409
+ degradeReason.set(item.table, "contention");
410
+ return;
411
+ }
412
+ throw err;
413
+ }
414
+ const list = tableFiles.get(item.table) ?? [];
415
+ list.push({
416
+ name,
417
+ handle: result.handle
418
+ });
419
+ tableFiles.set(item.table, list);
420
+ if (item.overlay) overlayNames.set(item.table, name);
421
+ bytesAttached += item.file.bytes;
422
+ onFileProgress?.({
423
+ table: item.table,
424
+ file: name,
425
+ index,
426
+ total,
427
+ bytes: item.file.bytes,
428
+ outcome: result.outcome,
429
+ materialiseMs,
430
+ registerMs,
431
+ totalMs: nowMs() - fileStarted
432
+ });
433
+ });
434
+ try {
435
+ await timed(onTiming, "downloads", {
436
+ files: total,
437
+ tables: tables.length
438
+ }, runDownloads);
439
+ } catch (err) {
440
+ await attemptCleanup("releasing handles after download failure", () => withDb(() => registry.release([...acquiredNames])));
441
+ throw err;
442
+ }
443
+ const attached = [];
444
+ const registeredNames = [];
445
+ for (const t of tables) {
446
+ if (degraded.has(t.table)) {
447
+ await releaseTable(t.table);
448
+ continue;
449
+ }
450
+ const files = tableFiles.get(t.table) ?? [];
451
+ if (files.length !== (expectedCount.get(t.table) ?? t.files.length)) {
452
+ degraded.add(t.table);
453
+ degradeReason.set(t.table, degradeReason.get(t.table) ?? "incomplete");
454
+ await releaseTable(t.table);
455
+ continue;
456
+ }
457
+ const overlayName = overlayNames.get(t.table);
458
+ const lakeNames = (overlayName ? files.map((f) => f.name).filter((n) => n !== overlayName) : files.map((f) => f.name)).sort();
459
+ const signature = viewSignature(lakeNames, overlayName);
460
+ try {
461
+ signal?.throwIfAborted();
462
+ await timed(onTiming, "view", {
463
+ table: t.table,
464
+ files: files.length
465
+ }, () => withDb(() => ensureView(registry, conn, schema, t.table, signature, overlayName ? readParquetViewWithOverlaySql(schema, t.table, lakeNames, overlayName) : readParquetViewSql(schema, t.table, lakeNames))));
466
+ } catch (err) {
467
+ if (isAbortError(err)) {
468
+ await attemptCleanup("detaching OPFS resources after abort", () => withDb(() => detachOpfs(registry, conn, schema, attached, [...acquiredNames])));
469
+ throw err;
470
+ }
471
+ if (isOpfsAccessHandleConflict(err)) {
472
+ if (registry.viewRefs(viewKey(schema, t.table)) === 0) await attemptCleanup(`dropping incomplete view ${schema}.${t.table}`, () => withDb(() => conn.query(`DROP VIEW IF EXISTS ${schema}.${t.table}`)));
473
+ degraded.add(t.table);
474
+ degradeReason.set(t.table, "contention");
475
+ await releaseTable(t.table);
476
+ continue;
477
+ }
478
+ await attemptCleanup("detaching OPFS resources after view failure", () => withDb(() => detachOpfs(registry, conn, schema, attached, [...acquiredNames])));
479
+ throw err;
480
+ }
481
+ attached.push(t.table);
482
+ for (const f of files) registeredNames.push(f.name);
483
+ }
484
+ const recoverTableToBuffers = async (t) => {
485
+ return timed(onTiming, "recovery", {
486
+ table: t.table,
487
+ files: t.files.length + (t.overlay ? 1 : 0)
488
+ }, async () => {
489
+ const readOne = async (file, index) => {
490
+ const cached = await readOpfsSnapshotFile(t.table, file.contentHash, index, file.bytes);
491
+ if (cached) return cached;
492
+ signal?.throwIfAborted();
493
+ const resp = await fetchImpl(file.url, {
494
+ ...fetchInit,
495
+ signal
496
+ });
497
+ if (!resp.ok) throw new Error(`[engine-duckdb-wasm/opfs] recover ${file.url} failed: ${resp.status}`);
498
+ return new Uint8Array(await resp.arrayBuffer());
499
+ };
500
+ const lakeNames = [];
501
+ for (let i = 0; i < t.files.length; i++) {
502
+ const file = t.files[i];
503
+ const name = await recoveredBufferName(t.table, file, i);
504
+ const buf = await readOne(file, i);
505
+ await withDb(() => bufferRegistry.acquire(name, () => buf));
506
+ bufferFiles.push(name);
507
+ lakeNames.push(name);
508
+ bytesAttached += file.bytes;
509
+ }
510
+ let overlayName;
511
+ if (t.overlay) {
512
+ overlayName = await recoveredBufferName(t.table, t.overlay, t.files.length);
513
+ const buf = await readOne(t.overlay, t.files.length);
514
+ await withDb(() => bufferRegistry.acquire(overlayName, () => buf));
515
+ bufferFiles.push(overlayName);
516
+ bytesAttached += t.overlay.bytes;
517
+ }
518
+ lakeNames.sort();
519
+ const body = overlayViewBody({
520
+ lakeSelect: lakeNames.length ? lakeSelect(lakeNames) : null,
521
+ overlaySelect: overlayName ? `SELECT * REPLACE (CAST(date AS DATE) AS date) FROM read_parquet(['${overlayName.replace(/'/g, "''")}'], union_by_name = true)` : null,
522
+ materializeLake: false
523
+ });
524
+ if (!body) return false;
525
+ await withDb(() => ensureView(registry, conn, schema, t.table, viewSignature(lakeNames, overlayName), `CREATE OR REPLACE VIEW ${schema}.${t.table} AS ${body}`));
526
+ bufferViews.push(t.table);
527
+ return true;
528
+ });
529
+ };
530
+ if (recoverContention && !signal?.aborted) for (const t of tables) {
531
+ if (!degraded.has(t.table) || degradeReason.get(t.table) !== "contention") continue;
532
+ const before = bufferFiles.length;
533
+ if (await recoverTableToBuffers(t).catch(() => false)) degraded.delete(t.table);
534
+ else {
535
+ await attemptCleanup(`releasing recovery buffers for ${t.table}`, () => withDb(() => bufferRegistry.release(bufferFiles.slice(before))));
536
+ bufferFiles.length = before;
537
+ }
538
+ }
539
+ emitTiming(onTiming, {
540
+ stage: "total",
541
+ durationMs: nowMs() - totalStarted,
542
+ files: total,
543
+ tables: tables.length,
544
+ bytes: bytesAttached
545
+ });
546
+ let detached = false;
547
+ return {
548
+ version,
549
+ tables: [...attached, ...bufferViews],
550
+ schema,
551
+ bytesAttached,
552
+ degradedTables: [...degraded],
553
+ degradeReasons: Object.fromEntries(degradeReason),
554
+ async detach() {
555
+ if (detached) return;
556
+ detached = true;
557
+ await detachOpfs(registry, conn, schema, attached, registeredNames);
558
+ for (const v of bufferViews) await registry.releaseView(viewKey(schema, v), () => conn.query(`DROP VIEW IF EXISTS ${schema}.${v}`).then(() => {}));
559
+ await bufferRegistry.release(bufferFiles);
560
+ }
561
+ };
562
+ }
563
+ async function detachOpfs(registry, conn, schema, tables, files) {
564
+ for (const table of tables) await registry.releaseView(`${schema}.${table}`, () => conn.query(`DROP VIEW IF EXISTS ${schema}.${table}`).then(() => {}));
565
+ await registry.release(files);
566
+ }
567
+ async function clearOpfsSnapshotCache() {
568
+ const root = await getOpfsRoot().catch((error) => {
569
+ reportBestEffortFailure("opening OPFS cache for clearing", error);
570
+ return null;
571
+ });
572
+ if (!root) return;
573
+ const removable = [];
574
+ const dir = root;
575
+ if (dir.keys) {
576
+ for await (const name of dir.keys()) if (name.startsWith(OPFS_PREFIX)) removable.push(name);
577
+ }
578
+ for (const name of removable) try {
579
+ await root.removeEntry(name);
580
+ } catch (error) {
581
+ if (!isNotFoundError(error)) reportBestEffortFailure(`clearing OPFS cache entry ${name}`, error);
582
+ }
583
+ }
584
+ export { OpfsQuotaExceededError, attachOpfsParquetTables, clearOpfsSnapshotCache, estimateOpfsStorage, readOpfsSnapshotFile, requestPersistentStorage };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Browser-side recent-overlay merge-on-read SQL — shared by the OPFS attach path
3
+ * (`opfs.ts`) and the in-memory buffer attach path (consumers'
4
+ * `registerParquetView`).
5
+ *
6
+ * The lake serves every day it has; the overlay serves ONLY days the lake lacks
7
+ * (`date NOT IN (SELECT DISTINCT date FROM lake)`), unioned with
8
+ * `UNION ALL BY NAME`. This is the browser sibling of the server's
9
+ * `overlayMergeEnvelope` (in gscdump.com); kept SEPARATE because the browser path
10
+ * can read the lake once via a `MATERIALIZED` CTE (CPU-bound DuckDB-WASM, bounded
11
+ * per-site buffers) where the server's many-s3-file reads prefer projection
12
+ * pushdown over a re-scan.
13
+ *
14
+ * Both `lakeSelect` / `overlaySelect` must already expose a `date` column cast to
15
+ * DATE (the overlay stores ISO strings; `SELECT * REPLACE (CAST(date AS DATE) AS
16
+ * date)`), so `UNION ALL BY NAME` merges them type-uniformly.
17
+ */
18
+ /**
19
+ * The view BODY (no `CREATE VIEW` wrapper) merging a base lake relation and a
20
+ * recent overlay. Returns null when neither side exists; the lake verbatim with
21
+ * no overlay; the overlay verbatim with no lake (every recent row is wanted).
22
+ *
23
+ * `materializeLake` (default true): wrap the lake in a `MATERIALIZED` CTE so it is
24
+ * scanned ONCE and reused for both the served rows and the anti-join date set — a
25
+ * plain CTE inlines and re-scans the parquet, the dominant cost in DuckDB-WASM.
26
+ * Set false to keep the streaming re-scan shape (lower peak memory, two scans).
27
+ */
28
+ declare function overlayViewBody(args: {
29
+ lakeSelect: string | null;
30
+ overlaySelect: string | null;
31
+ materializeLake?: boolean;
32
+ }): string | null;
33
+ export { overlayViewBody };
@@ -0,0 +1,10 @@
1
+ function overlayViewBody(args) {
2
+ const { lakeSelect, overlaySelect } = args;
3
+ const materialize = args.materializeLake ?? true;
4
+ if (!lakeSelect && !overlaySelect) return null;
5
+ if (!overlaySelect) return lakeSelect;
6
+ if (!lakeSelect) return overlaySelect;
7
+ if (materialize) return `WITH lake AS MATERIALIZED (${lakeSelect}), lake_dates AS (SELECT DISTINCT date FROM lake) SELECT * FROM lake UNION ALL BY NAME SELECT * FROM (${overlaySelect}) AS overlay WHERE overlay.date NOT IN (SELECT date FROM lake_dates)`;
8
+ return `${lakeSelect} UNION ALL BY NAME SELECT * FROM (${overlaySelect}) AS overlay WHERE overlay.date NOT IN (SELECT DISTINCT date FROM (${lakeSelect}))`;
9
+ }
10
+ export { overlayViewBody };
@@ -0,0 +1,28 @@
1
+ import { DuckDBWasmClient } from "./drizzle-adapter/client.mjs";
2
+ import { DuckDBWasmDrizzleDatabase } from "./drizzle-adapter/driver.mjs";
3
+ import "./drizzle-adapter/index.mjs";
4
+ import { Schema } from "./schema.mjs";
5
+ import { ScopedRunnerOptions, TableScope } from "@gscdump/engine/scope";
6
+ import { AsyncDuckDB, AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
7
+ interface InsightRunnerOptions {
8
+ db: AsyncDuckDB;
9
+ conn: AsyncDuckDBConnection;
10
+ logger?: boolean;
11
+ }
12
+ interface InsightRunner {
13
+ db: DuckDBWasmDrizzleDatabase<Schema>;
14
+ client: Promise<DuckDBWasmClient>;
15
+ close: () => Promise<void>;
16
+ }
17
+ declare function createInsightRunner(opts: InsightRunnerOptions): Promise<InsightRunner>;
18
+ /**
19
+ * Build a per-table predicate set from {siteId, window}. The returned
20
+ * `wherePredicates` composes with user-level filters via `mergeScope`.
21
+ *
22
+ * Note: the current SCHEMAS don't include `site_id` on any table (snapshots
23
+ * are already per-site), so `siteId` is a no-op for now — kept in the API
24
+ * so consumers can add the predicate without an interface change when
25
+ * multi-site snapshots land.
26
+ */
27
+ declare const scopeFor: (table: "countries" | "dates" | "hourly_pages" | "page_queries" | "pages" | "queries" | "search_appearance" | "search_appearance_page_queries" | "search_appearance_pages" | "search_appearance_queries", opts: ScopedRunnerOptions) => TableScope, mergeScope: typeof import("@gscdump/engine/scope").mergeScope;
28
+ export { InsightRunner, InsightRunnerOptions, type ScopedRunnerOptions, type TableScope, createInsightRunner, mergeScope, scopeFor };
@@ -0,0 +1,19 @@
1
+ import { createClient } from "./drizzle-adapter/client.mjs";
2
+ import { drizzle } from "./drizzle-adapter/driver.mjs";
3
+ import "./drizzle-adapter/index.mjs";
4
+ import { schema } from "./schema.mjs";
5
+ import { createScopedHelpers } from "@gscdump/engine/scope";
6
+ async function createInsightRunner(opts) {
7
+ const client = await createClient(opts.db, opts.conn);
8
+ const clientPromise = Promise.resolve(client);
9
+ return {
10
+ db: drizzle(clientPromise, {
11
+ schema,
12
+ logger: opts.logger
13
+ }),
14
+ client: clientPromise,
15
+ close: () => client.close()
16
+ };
17
+ }
18
+ const { scopeFor, mergeScope } = createScopedHelpers(schema);
19
+ export { createInsightRunner, mergeScope, scopeFor };