@gscdump/engine-duckdb-wasm 0.23.4 → 0.24.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/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Assume, DrizzleConfig, entityKind } from "drizzle-orm";
2
- import { PgDatabase, PgDialect, PgPreparedQuery, PgQueryResultHKT, PgSession } from "drizzle-orm/pg-core";
2
+ import { PgAsyncDatabase, PgAsyncPreparedQuery, PgAsyncSession, PgDialect, PgQueryResultHKT } from "drizzle-orm/pg-core";
3
+ import { AnyRelations, EmptyRelations, ExtractTablesWithRelations, Schema as Schema$1 } from "drizzle-orm/relations";
3
4
  import { DrizzleSchema as Schema, countries, dates, drizzleSchema as schema, hourly_pages, page_queries, pages, queries } from "@gscdump/engine/schema";
4
5
  import { ScopedRunnerOptions, TableScope } from "@gscdump/engine/scope";
5
6
  import { AnalyzerRegistry } from "@gscdump/engine/analyzer";
@@ -31,13 +32,14 @@ type Row = Record<string, unknown>;
31
32
  interface DuckDBWasmQueryResultHKT extends PgQueryResultHKT {
32
33
  type: Assume<this['row'], Row>[];
33
34
  }
34
- declare class DuckDBWasmDatabase<TSchema extends Record<string, unknown> = Record<string, never>> extends PgDatabase<DuckDBWasmQueryResultHKT, TSchema> {
35
+ type SchemaRelations<TSchema extends Schema$1> = ExtractTablesWithRelations<Record<string, never>, TSchema>;
36
+ declare class DuckDBWasmDatabase<TRelations extends AnyRelations = EmptyRelations> extends PgAsyncDatabase<DuckDBWasmQueryResultHKT, TRelations> {
35
37
  static readonly [entityKind]: string;
36
38
  }
37
- interface DuckDBWasmDrizzleDatabase<TSchema extends Record<string, unknown> = Record<string, never>> extends DuckDBWasmDatabase<TSchema> {
39
+ interface DuckDBWasmDrizzleDatabase<TSchema extends Schema$1 = Record<string, never>, TRelations extends AnyRelations = SchemaRelations<TSchema>> extends DuckDBWasmDatabase<TRelations> {
38
40
  $client: Promise<DuckDBWasmClient>;
39
41
  }
40
- declare function drizzle<TSchema extends Record<string, unknown> = Record<string, never>>(client: Promise<DuckDBWasmClient> | DuckDBWasmClient, config?: DrizzleConfig<TSchema>): DuckDBWasmDrizzleDatabase<TSchema>;
42
+ declare function drizzle<TSchema extends Schema$1 = Record<string, never>, TRelations extends AnyRelations = SchemaRelations<TSchema>>(client: Promise<DuckDBWasmClient> | DuckDBWasmClient, config?: DrizzleConfig<TSchema, TRelations>): DuckDBWasmDrizzleDatabase<TSchema, TRelations>;
41
43
  interface InsightRunnerOptions {
42
44
  db: AsyncDuckDB;
43
45
  conn: AsyncDuckDBConnection;
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { arrowToRows } from "@gscdump/engine/arrow";
2
- import { DefaultLogger, NoopLogger, createTableRelationsHelpers, entityKind, extractTablesRelationalConfig, fillPlaceholders, sql } from "drizzle-orm";
3
- import { PgDatabase, PgDialect, PgPreparedQuery, PgSession } from "drizzle-orm/pg-core";
2
+ import { DefaultLogger, NoopLogger, entityKind, sql } from "drizzle-orm";
3
+ import { PgAsyncDatabase, PgAsyncPreparedQuery, PgAsyncSession, PgDialect } from "drizzle-orm/pg-core";
4
+ import { buildRelations } from "drizzle-orm/relations";
4
5
  import { toIsoDate } from "gscdump";
5
6
  import { countries, dates, drizzleSchema as schema, hourly_pages, page_queries, pages, queries } from "@gscdump/engine/schema";
6
7
  import { createScopedHelpers } from "@gscdump/engine/scope";
@@ -263,69 +264,44 @@ async function createClient(db, conn) {
263
264
  }
264
265
  };
265
266
  }
266
- var DuckDBWasmPreparedQuery = class extends PgPreparedQuery {
267
- client;
268
- logger;
269
- static [entityKind] = "DuckDBWasmPreparedQuery";
270
- constructor(client, query, logger, queryMetadata, cache) {
271
- super(query, cache, queryMetadata);
272
- this.client = client;
273
- this.logger = logger;
274
- }
275
- async execute(placeholderValues = {}) {
276
- const params = fillPlaceholders(this.query.params, placeholderValues);
277
- this.logger.logQuery(this.query.sql, params);
278
- return await (await this.client).query(this.query.sql, params);
279
- }
280
- async all(placeholderValues = {}) {
281
- const params = fillPlaceholders(this.query.params, placeholderValues);
282
- this.logger.logQuery(this.query.sql, params);
283
- return await (await this.client).query(this.query.sql, params);
284
- }
285
- };
286
- var DuckDBWasmSession = class extends PgSession {
267
+ var DuckDBWasmSession = class extends PgAsyncSession {
287
268
  client;
288
269
  options;
289
270
  static [entityKind] = "DuckDBWasmSession";
290
271
  logger;
291
272
  cache;
292
- constructor(client, dialect, _schema, options = {}) {
273
+ constructor(client, dialect, options = {}) {
293
274
  super(dialect);
294
275
  this.client = client;
295
276
  this.options = options;
296
277
  this.logger = options.logger ?? new NoopLogger();
297
278
  this.cache = options.cache;
298
279
  }
299
- prepareQuery(query, _fields, _name, _isResponseInArrayMode, _customResultMapper, queryMetadata) {
300
- return new DuckDBWasmPreparedQuery(this.client, query, this.logger, queryMetadata, this.cache);
301
- }
302
- async query(sql, params) {
303
- this.logger.logQuery(sql, params);
304
- return (await this.client).query(sql, params);
280
+ prepareQuery(query, mode, _name, mapper, queryMetadata, cacheConfig) {
281
+ const client = this.client;
282
+ const executor = async (params = []) => {
283
+ const rows = await (await client).query(query.sql, params);
284
+ return mode === "arrays" ? rows.map((row) => Object.values(row)) : rows;
285
+ };
286
+ return new PgAsyncPreparedQuery(executor, query, mapper, mode, this.logger, this.cache, queryMetadata, cacheConfig);
305
287
  }
306
- transaction(_transaction, _config) {
307
- throw new Error("Transactions are not supported by the DuckDB-WASM drizzle adapter. The analytics workload is read-only; if transactions become necessary, implement PgSession.transaction() in @gscdump/engine-duckdb-wasm.");
288
+ transaction() {
289
+ throw new Error("Transactions are not supported by the DuckDB-WASM drizzle adapter. The analytics workload is read-only; if transactions become necessary, implement PgAsyncSession.transaction() in @gscdump/engine-duckdb-wasm.");
308
290
  }
309
291
  };
310
- var DuckDBWasmDatabase = class extends PgDatabase {
292
+ var DuckDBWasmDatabase = class extends PgAsyncDatabase {
311
293
  static [entityKind] = "DuckDBWasmDatabase";
312
294
  };
313
295
  function drizzle(client, config = {}) {
314
- const dialect = new PgDialect({ casing: config.casing });
296
+ const dialect = new PgDialect();
315
297
  const clientPromise = Promise.resolve(client);
316
298
  let logger;
317
299
  if (config.logger === true) logger = new DefaultLogger();
318
300
  else if (config.logger !== false) logger = config.logger;
319
- let schema;
320
- if (config.schema) {
321
- const tablesConfig = extractTablesRelationalConfig(config.schema, createTableRelationsHelpers);
322
- schema = {
323
- fullSchema: config.schema,
324
- schema: tablesConfig.tables,
325
- tableNamesMap: tablesConfig.tableNamesMap
326
- };
327
- }
328
- const db = new DuckDBWasmDatabase(dialect, new DuckDBWasmSession(clientPromise, dialect, schema, { logger }), schema);
301
+ const db = new DuckDBWasmDatabase(dialect, new DuckDBWasmSession(clientPromise, dialect, {
302
+ logger,
303
+ cache: config.cache
304
+ }), config.relations ?? (config.schema ? buildRelations(config.schema, {}) : {}));
329
305
  db.$client = clientPromise;
330
306
  return db;
331
307
  }
@@ -390,6 +366,73 @@ async function strikingMomentum(runner, opts = {}) {
390
366
  `;
391
367
  return await runner.db.execute(windowExpr);
392
368
  }
369
+ function createOpfsHandleRegistry(backend) {
370
+ const entries = /* @__PURE__ */ new Map();
371
+ const pending = /* @__PURE__ */ new Map();
372
+ async function acquire(name, openHandle) {
373
+ const existing = entries.get(name);
374
+ if (existing) {
375
+ existing.refs++;
376
+ return;
377
+ }
378
+ const inflight = pending.get(name);
379
+ if (inflight) {
380
+ await inflight.catch(() => {});
381
+ const settled = entries.get(name);
382
+ if (settled) {
383
+ settled.refs++;
384
+ return;
385
+ }
386
+ return acquire(name, openHandle);
387
+ }
388
+ const registration = (async () => {
389
+ const handle = await openHandle();
390
+ await backend.register(name, handle);
391
+ entries.set(name, {
392
+ handle,
393
+ refs: 1
394
+ });
395
+ })();
396
+ pending.set(name, registration);
397
+ try {
398
+ await registration;
399
+ } finally {
400
+ pending.delete(name);
401
+ }
402
+ }
403
+ async function release(names) {
404
+ for (const name of names) {
405
+ const entry = entries.get(name);
406
+ if (!entry) continue;
407
+ entry.refs--;
408
+ if (entry.refs > 0) continue;
409
+ entries.delete(name);
410
+ await backend.drop(name).catch(() => {});
411
+ }
412
+ }
413
+ const viewRefs = /* @__PURE__ */ new Map();
414
+ function acquireView(key) {
415
+ viewRefs.set(key, (viewRefs.get(key) ?? 0) + 1);
416
+ }
417
+ async function releaseView(key, drop) {
418
+ const next = (viewRefs.get(key) ?? 0) - 1;
419
+ if (next > 0) {
420
+ viewRefs.set(key, next);
421
+ return;
422
+ }
423
+ viewRefs.delete(key);
424
+ await drop().catch(() => {});
425
+ }
426
+ return {
427
+ acquire,
428
+ release,
429
+ size: () => entries.size,
430
+ refs: (name) => entries.get(name)?.refs ?? 0,
431
+ acquireView,
432
+ releaseView,
433
+ viewRefs: (key) => viewRefs.get(key) ?? 0
434
+ };
435
+ }
393
436
  var OpfsQuotaExceededError = class extends Error {
394
437
  name = "OpfsQuotaExceededError";
395
438
  degradedTables;
@@ -400,17 +443,19 @@ var OpfsQuotaExceededError = class extends Error {
400
443
  };
401
444
  const DEFAULT_CONCURRENCY = 2;
402
445
  const OPFS_PREFIX = "gscdump-snapshot__";
403
- const dbFileRegistrations = /* @__PURE__ */ new WeakMap();
404
- function isAlreadyRegistered(db, name) {
405
- return dbFileRegistrations.get(db)?.has(name) === true;
406
- }
407
- function markRegistered(db, name) {
408
- let set = dbFileRegistrations.get(db);
409
- if (!set) {
410
- set = /* @__PURE__ */ new Set();
411
- dbFileRegistrations.set(db, set);
446
+ const dbRegistries = /* @__PURE__ */ new WeakMap();
447
+ function getOpfsRegistry(db, protocol) {
448
+ let registry = dbRegistries.get(db);
449
+ if (!registry) {
450
+ registry = createOpfsHandleRegistry({
451
+ register: (name, handle) => db.registerFileHandle(name, handle, protocol, true),
452
+ drop: async (name) => {
453
+ await db.dropFile(name);
454
+ }
455
+ });
456
+ dbRegistries.set(db, registry);
412
457
  }
413
- set.add(name);
458
+ return registry;
414
459
  }
415
460
  function isQuotaError(err) {
416
461
  if (typeof err !== "object" || err === null) return false;
@@ -419,6 +464,10 @@ function isQuotaError(err) {
419
464
  function isAbortError$1(err) {
420
465
  return typeof err === "object" && err !== null && err.name === "AbortError";
421
466
  }
467
+ function isOpfsAccessHandleConflict(err) {
468
+ const msg = err instanceof Error ? err.message : String(err);
469
+ return /createSyncAccessHandle|Access Handle/i.test(msg);
470
+ }
422
471
  function opfsFileName(table, index, hashSlug) {
423
472
  const base = `${OPFS_PREFIX}${table}_${index}`;
424
473
  return hashSlug ? `${base}_${hashSlug}.parquet` : `${base}.parquet`;
@@ -522,10 +571,17 @@ async function attachOpfsParquetTables(options) {
522
571
  });
523
572
  const total = flat.length;
524
573
  const { DuckDBDataProtocol } = await import("@duckdb/duckdb-wasm");
574
+ const registry = getOpfsRegistry(db, DuckDBDataProtocol.BROWSER_FSACCESS);
525
575
  const tableFiles = /* @__PURE__ */ new Map();
526
576
  const degraded = /* @__PURE__ */ new Set();
577
+ const acquiredNames = /* @__PURE__ */ new Set();
527
578
  let bytesAttached = 0;
528
- await runWithConcurrency$1(flat, Math.max(1, fetchConcurrency), async (item, index) => {
579
+ const releaseTable = async (table) => {
580
+ const names = (tableFiles.get(table) ?? []).map((f) => f.name);
581
+ for (const n of names) acquiredNames.delete(n);
582
+ await registry.release(names);
583
+ };
584
+ const runDownloads = () => runWithConcurrency$1(flat, Math.max(1, fetchConcurrency), async (item, index) => {
529
585
  if (degraded.has(item.table)) return;
530
586
  signal?.throwIfAborted();
531
587
  const hashSlug = item.file.contentHash ? await contentHashSlug(item.file.contentHash) : void 0;
@@ -542,9 +598,16 @@ async function attachOpfsParquetTables(options) {
542
598
  }
543
599
  throw err;
544
600
  }
545
- if (!isAlreadyRegistered(db, name)) {
546
- await db.registerFileHandle(name, result.handle, DuckDBDataProtocol.BROWSER_FSACCESS, true);
547
- markRegistered(db, name);
601
+ try {
602
+ await registry.acquire(name, () => result.handle);
603
+ acquiredNames.add(name);
604
+ } catch (err) {
605
+ if (isAbortError$1(err)) throw err;
606
+ if (isOpfsAccessHandleConflict(err)) {
607
+ degraded.add(item.table);
608
+ return;
609
+ }
610
+ throw err;
548
611
  }
549
612
  const list = tableFiles.get(item.table) ?? [];
550
613
  list.push({
@@ -562,24 +625,45 @@ async function attachOpfsParquetTables(options) {
562
625
  outcome: result.outcome
563
626
  });
564
627
  });
628
+ try {
629
+ await runDownloads();
630
+ } catch (err) {
631
+ await registry.release([...acquiredNames]).catch(() => {});
632
+ throw err;
633
+ }
565
634
  const attached = [];
566
635
  const registeredNames = [];
567
- try {
568
- for (const t of tables) {
569
- if (degraded.has(t.table)) continue;
570
- const files = tableFiles.get(t.table) ?? [];
571
- if (files.length !== t.files.length) {
636
+ for (const t of tables) {
637
+ if (degraded.has(t.table)) {
638
+ await releaseTable(t.table);
639
+ continue;
640
+ }
641
+ const files = tableFiles.get(t.table) ?? [];
642
+ if (files.length !== t.files.length) {
643
+ degraded.add(t.table);
644
+ await releaseTable(t.table);
645
+ continue;
646
+ }
647
+ try {
648
+ signal?.throwIfAborted();
649
+ await conn.query(readParquetViewSql$1(schema, t.table, files.map((f) => f.name)));
650
+ } catch (err) {
651
+ if (isAbortError$1(err)) {
652
+ await detachOpfs(registry, conn, schema, attached, [...acquiredNames]).catch(() => {});
653
+ throw err;
654
+ }
655
+ if (isOpfsAccessHandleConflict(err)) {
656
+ if (registry.viewRefs(`${schema}.${t.table}`) === 0) await conn.query(`DROP VIEW IF EXISTS ${schema}.${t.table}`).catch(() => {});
572
657
  degraded.add(t.table);
658
+ await releaseTable(t.table);
573
659
  continue;
574
660
  }
575
- signal?.throwIfAborted();
576
- await conn.query(readParquetViewSql$1(schema, t.table, files.map((f) => f.name)));
577
- attached.push(t.table);
578
- for (const f of files) registeredNames.push(f.name);
661
+ await detachOpfs(registry, conn, schema, attached, [...acquiredNames]).catch(() => {});
662
+ throw err;
579
663
  }
580
- } catch (err) {
581
- await detachOpfs(db, conn, schema, attached, registeredNames).catch(() => {});
582
- throw err;
664
+ attached.push(t.table);
665
+ registry.acquireView(`${schema}.${t.table}`);
666
+ for (const f of files) registeredNames.push(f.name);
583
667
  }
584
668
  let detached = false;
585
669
  return {
@@ -591,12 +675,13 @@ async function attachOpfsParquetTables(options) {
591
675
  async detach() {
592
676
  if (detached) return;
593
677
  detached = true;
594
- await detachOpfs(db, conn, schema, attached, registeredNames);
678
+ await detachOpfs(registry, conn, schema, attached, registeredNames);
595
679
  }
596
680
  };
597
681
  }
598
- async function detachOpfs(db, conn, schema, tables, _files) {
599
- for (const table of tables) await conn.query(`DROP VIEW IF EXISTS ${schema}.${table}`).catch(() => {});
682
+ async function detachOpfs(registry, conn, schema, tables, files) {
683
+ for (const table of tables) await registry.releaseView(`${schema}.${table}`, () => conn.query(`DROP VIEW IF EXISTS ${schema}.${table}`).then(() => {}));
684
+ await registry.release(files);
600
685
  }
601
686
  async function clearOpfsSnapshotCache() {
602
687
  const root = await getOpfsRoot().catch(() => null);
@@ -761,13 +846,19 @@ async function bootDuckDBWasm(options = {}) {
761
846
  const workerUrl = URL.createObjectURL(new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" }));
762
847
  const worker = new Worker(workerUrl);
763
848
  const db = new AsyncDuckDB(options.logger ?? new ConsoleLogger(), worker);
764
- await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
765
- await db.open(rangeOnlyConfig(options.config));
766
- URL.revokeObjectURL(workerUrl);
767
- return {
768
- db,
769
- conn: await db.connect()
770
- };
849
+ try {
850
+ await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
851
+ await db.open(rangeOnlyConfig(options.config));
852
+ return {
853
+ db,
854
+ conn: await db.connect()
855
+ };
856
+ } catch (err) {
857
+ worker.terminate();
858
+ throw err;
859
+ } finally {
860
+ URL.revokeObjectURL(workerUrl);
861
+ }
771
862
  }
772
863
  async function attachParquetTables(options) {
773
864
  const { db, conn, tables, schema = "main" } = options;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/engine-duckdb-wasm",
3
3
  "type": "module",
4
- "version": "0.23.4",
4
+ "version": "0.24.0",
5
5
  "description": "DuckDB-WASM engine adapter for @gscdump/analysis — typed browser analytics against parquet via R2.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -44,19 +44,23 @@
44
44
  }
45
45
  },
46
46
  "dependencies": {
47
- "drizzle-orm": "^0.45.2",
48
- "@gscdump/engine": "0.23.4",
49
- "@gscdump/sdk": "0.23.4",
50
- "gscdump": "0.23.4"
47
+ "drizzle-orm": "1.0.0-rc.3",
48
+ "@gscdump/sdk": "0.24.0",
49
+ "gscdump": "0.24.0",
50
+ "@gscdump/engine": "0.24.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@duckdb/duckdb-wasm": "^1.32.0",
54
+ "@vitest/browser": "^4.1.7",
55
+ "@vitest/browser-playwright": "^4.1.7",
56
+ "playwright": "^1.60.0",
54
57
  "vitest": "^4.1.7"
55
58
  },
56
59
  "scripts": {
57
60
  "build": "obuild",
58
61
  "dev": "obuild --stub",
59
62
  "typecheck": "tsc --noEmit",
60
- "test": "vitest"
63
+ "test": "vitest",
64
+ "test:browser": "GSCDUMP_E2E=1 vitest --run --config vitest.browser.config.ts"
61
65
  }
62
66
  }