@gscdump/engine-duckdb-wasm 0.20.3 → 0.21.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,11 +1,25 @@
1
1
  import { Assume, DrizzleConfig, entityKind } from "drizzle-orm";
2
2
  import { PgDatabase, PgDialect, PgPreparedQuery, PgQueryResultHKT, PgSession } from "drizzle-orm/pg-core";
3
- import { DrizzleSchema as Schema, countries, devices, drizzleSchema as schema, hourly_pages, keywords, page_keywords, pages } from "@gscdump/engine/schema";
3
+ import { DrizzleSchema as Schema, countries, dates, drizzleSchema as schema, hourly_pages, page_queries, pages, queries } from "@gscdump/engine/schema";
4
4
  import { ScopedRunnerOptions, TableScope } from "@gscdump/engine/scope";
5
5
  import { AnalyzerRegistry } from "@gscdump/engine/analyzer";
6
6
  import { ComparisonMode, ResolveWindowOptions, ResolvedWindow, WindowPreset, resolveWindow } from "@gscdump/engine/period";
7
+ import { ArchetypeQuery } from "@gscdump/sdk";
7
8
  import { AsyncDuckDB, AsyncDuckDBConnection, DuckDBBundles, DuckDBConfig } from "@duckdb/duckdb-wasm";
8
9
  import { AnalysisParams } from "@gscdump/engine/analysis-types";
10
+ /** A compiled, parameterised statement. */
11
+ interface CompiledArchetypeSql {
12
+ sql: string;
13
+ params: unknown[];
14
+ /** The fact-table view the query reads. */
15
+ table: string;
16
+ }
17
+ /**
18
+ * Compile one archetype query to DuckDB SQL. Throws for `aux-cloud-only`.
19
+ */
20
+ declare function compileArchetypeSql(query: ArchetypeQuery): CompiledArchetypeSql;
21
+ /** The fact-table view an archetype reads — drives per-table browser routing. */
22
+ declare function tableForArchetype(query: ArchetypeQuery): string | null;
9
23
  interface DuckDBWasmClient {
10
24
  db: AsyncDuckDB;
11
25
  conn: AsyncDuckDBConnection;
@@ -44,7 +58,7 @@ declare function createInsightRunner(opts: InsightRunnerOptions): Promise<Insigh
44
58
  * so consumers can add the predicate without an interface change when
45
59
  * multi-site snapshots land.
46
60
  */
47
- declare const scopeFor: (table: "pages" | "keywords" | "countries" | "devices" | "page_keywords" | "search_appearance" | "hourly_pages", opts: ScopedRunnerOptions) => TableScope, mergeScope: typeof import("@gscdump/engine/scope").mergeScope;
61
+ declare const scopeFor: (table: "pages" | "queries" | "countries" | "page_queries" | "dates" | "search_appearance" | "hourly_pages", opts: ScopedRunnerOptions) => TableScope, mergeScope: typeof import("@gscdump/engine/scope").mergeScope;
48
62
  interface StrikingMomentumOptions {
49
63
  /** Anchor date (YYYY-MM-DD). Defaults to today. */
50
64
  anchor?: string;
@@ -67,6 +81,113 @@ interface StrikingMomentumRow {
67
81
  momentum_score: number;
68
82
  }
69
83
  declare function strikingMomentum(runner: InsightRunner, opts?: StrikingMomentumOptions): Promise<StrikingMomentumRow[]>;
84
+ /** A parquet data file to materialise into OPFS. */
85
+ interface OpfsParquetFile {
86
+ /** Same-origin URL carrying a signed size hint + short-lived access token. */
87
+ url: string;
88
+ /** Expected byte size — drives progress + a cheap pre-verify shortcut. */
89
+ bytes: number;
90
+ /**
91
+ * Lowercase hex SHA-256 of the file's bytes (the Iceberg data-file digest).
92
+ * The OPFS-cached copy is verified against this before it is trusted.
93
+ * When omitted, only the byte size is checked (degraded trust).
94
+ */
95
+ contentHash?: string;
96
+ /** Row count — diagnostics only. */
97
+ rowCount?: number;
98
+ }
99
+ /** One logical table and the parquet files that compose it. */
100
+ interface OpfsParquetTable {
101
+ /** Iceberg table name — becomes the DuckDB view name. */
102
+ table: string;
103
+ files: OpfsParquetFile[];
104
+ }
105
+ interface AttachOpfsTablesOptions {
106
+ db: AsyncDuckDB;
107
+ conn: AsyncDuckDBConnection;
108
+ tables: OpfsParquetTable[];
109
+ /** DuckDB schema the views are created in. Default `main`. */
110
+ schema?: string;
111
+ /** `fetch` override (tests). Default `globalThis.fetch`. */
112
+ fetch?: typeof fetch;
113
+ /** Request init for the parquet downloads (auth headers, credentials mode). */
114
+ fetchInit?: RequestInit;
115
+ /** Caps simultaneous downloads. Default 2. */
116
+ fetchConcurrency?: number;
117
+ /** Abort signal threaded through downloads + registration. */
118
+ signal?: AbortSignal;
119
+ /** Snapshot version associated with this file set — echoed on the handle. */
120
+ version?: string;
121
+ /** Ticks once per file as it lands in OPFS + registers. UI progress. */
122
+ onFileProgress?: (info: OpfsFileProgress) => void;
123
+ }
124
+ interface OpfsFileProgress {
125
+ table: string;
126
+ /** OPFS file name. */
127
+ file: string;
128
+ /** Index within the flat file list. */
129
+ index: number;
130
+ /** Total files across all tables. */
131
+ total: number;
132
+ /** Bytes of this file (cumulative caller-side). */
133
+ bytes: number;
134
+ /** `'cache-hit'` — already in OPFS, verified; `'downloaded'` — fetched. */
135
+ outcome: 'cache-hit' | 'downloaded';
136
+ }
137
+ /** Handle returned from {@link attachOpfsParquetTables}. */
138
+ interface OpfsAttachedHandle {
139
+ version: string | undefined;
140
+ /** Tables that successfully attached (a per-table failure drops only that table). */
141
+ tables: string[];
142
+ schema: string;
143
+ /** Total bytes materialised into OPFS for this attach. */
144
+ bytesAttached: number;
145
+ /**
146
+ * Tables that could NOT be attached because OPFS ran out of quota. The
147
+ * caller routes these to the server tail. Empty on a clean attach.
148
+ */
149
+ degradedTables: string[];
150
+ /** Detach the created views + release the registered OPFS file handles. */
151
+ detach: () => Promise<void>;
152
+ }
153
+ /**
154
+ * Raised when OPFS cannot hold the file set. Carries the partial state so the
155
+ * caller can degrade — attach what fit, route the rest server-side.
156
+ */
157
+ declare class OpfsQuotaExceededError extends Error {
158
+ name: string;
159
+ /** Tables that did not fit. */
160
+ readonly degradedTables: string[];
161
+ constructor(message: string, degradedTables: string[]);
162
+ }
163
+ /**
164
+ * Request persistent storage so the browser is less likely to evict the OPFS
165
+ * cache under pressure. Returns the granted state — `false` is normal for an
166
+ * un-engaged origin and is NOT an error; it just means eviction is possible.
167
+ */
168
+ declare function requestPersistentStorage(): Promise<boolean>;
169
+ /** Best-effort `{ usageBytes, quotaBytes }` from the Storage API. */
170
+ declare function estimateOpfsStorage(): Promise<{
171
+ usageBytes?: number;
172
+ quotaBytes?: number;
173
+ }>;
174
+ /**
175
+ * Download every parquet file in `tables` into OPFS, content-hash verify, and
176
+ * attach them as DuckDB-WASM views. Attach-once: call this once per
177
+ * `(site, table)` span; re-query without re-attaching for filter / range
178
+ * changes inside the span.
179
+ *
180
+ * Quota handling: a `QuotaExceededError` while writing a table's files does
181
+ * NOT throw — that table is recorded in `degradedTables` and skipped; the
182
+ * caller routes it to the server tail. The remaining tables still attach.
183
+ */
184
+ declare function attachOpfsParquetTables(options: AttachOpfsTablesOptions): Promise<OpfsAttachedHandle>;
185
+ /**
186
+ * Delete every OPFS entry this module created. Used to reclaim space after a
187
+ * quota error, or to force a clean re-download. Best-effort — missing entries
188
+ * are ignored.
189
+ */
190
+ declare function clearOpfsSnapshotCache(): Promise<void>;
70
191
  interface QueryResult {
71
192
  rows: Record<string, unknown>[];
72
193
  queryMs: number;
@@ -188,8 +309,8 @@ interface BrowserAnalysisRuntime {
188
309
  /**
189
310
  * Update the list of attached table names. Lets callers fast-fail in
190
311
  * `analyze()` when a SQL plan references a table that wasn't in the manifest
191
- * for this site (e.g. site has only `keywords` parquet, analyzer wants
192
- * `page_keywords`) — surface a clean `AttachedTableMissingError` so the
312
+ * for this site (e.g. site has only `queries` parquet, analyzer wants
313
+ * `page_queries`) — surface a clean `AttachedTableMissingError` so the
193
314
  * caller can route to cloud fallback without paying the SQL execution cost.
194
315
  */
195
316
  setAttachedTables: (tables: readonly string[]) => void;
@@ -206,4 +327,4 @@ declare function createBrowserAnalysisRuntime(boot: DuckDBWasmBootResult, option
206
327
  version?: number | string;
207
328
  attachedTables?: readonly string[];
208
329
  }): BrowserAnalysisRuntime;
209
- export { type AnalyzeResult, type AttachParquetTablesOptions, type AttachParquetUrlTablesOptions, type AttachedTablesHandle, type BootDuckDBWasmOptions, type BrowserAnalysisRuntime, BrowserAttachBudgetExceededError, type BrowserParquetFile, type BrowserParquetTable, type BrowserParquetUrlTable, type ComparisonMode, type DuckDBWasmBootResult, type DuckDBWasmClient, DuckDBWasmDatabase, type DuckDBWasmDrizzleDatabase, type InsightRunner, type InsightRunnerOptions, type QueryResult, type ResolveWindowOptions, type ResolvedWindow, type Schema, type ScopedRunnerOptions, type StrikingMomentumOptions, type StrikingMomentumRow, type TableScope, type WindowPreset, attachParquetTables, attachParquetUrlTables, bootDuckDBWasm, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, devices, drizzle, hourly_pages, keywords, mergeScope, page_keywords, pages, resolveWindow, schema, scopeFor, strikingMomentum };
330
+ export { type AnalyzeResult, type AttachOpfsTablesOptions, type AttachParquetTablesOptions, type AttachParquetUrlTablesOptions, type AttachedTablesHandle, type BootDuckDBWasmOptions, type BrowserAnalysisRuntime, BrowserAttachBudgetExceededError, type BrowserParquetFile, type BrowserParquetTable, type BrowserParquetUrlTable, type ComparisonMode, type CompiledArchetypeSql, type DuckDBWasmBootResult, type DuckDBWasmClient, DuckDBWasmDatabase, type DuckDBWasmDrizzleDatabase, type InsightRunner, type InsightRunnerOptions, type OpfsAttachedHandle, type OpfsFileProgress, type OpfsParquetFile, type OpfsParquetTable, OpfsQuotaExceededError, type QueryResult, type ResolveWindowOptions, type ResolvedWindow, type Schema, type ScopedRunnerOptions, type StrikingMomentumOptions, type StrikingMomentumRow, type TableScope, type WindowPreset, attachOpfsParquetTables, attachParquetTables, attachParquetUrlTables, bootDuckDBWasm, clearOpfsSnapshotCache, compileArchetypeSql, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, dates, drizzle, estimateOpfsStorage, hourly_pages, mergeScope, page_queries, pages, queries, requestPersistentStorage, resolveWindow, schema, scopeFor, strikingMomentum, tableForArchetype };
package/dist/index.mjs CHANGED
@@ -2,13 +2,247 @@ import { arrowToRows } from "@gscdump/engine/arrow";
2
2
  import { DefaultLogger, NoopLogger, createTableRelationsHelpers, entityKind, extractTablesRelationalConfig, fillPlaceholders, sql } from "drizzle-orm";
3
3
  import { PgDatabase, PgDialect, PgPreparedQuery, PgSession } from "drizzle-orm/pg-core";
4
4
  import { toIsoDate } from "gscdump";
5
- import { countries, devices, drizzleSchema as schema, hourly_pages, keywords, page_keywords, pages } from "@gscdump/engine/schema";
5
+ import { countries, dates, drizzleSchema as schema, hourly_pages, page_queries, pages, queries } from "@gscdump/engine/schema";
6
6
  import { createScopedHelpers } from "@gscdump/engine/scope";
7
7
  import { runAnalyzerFromSource } from "@gscdump/engine/analyzer";
8
8
  import { pgResolverAdapter } from "@gscdump/engine/resolver";
9
9
  import { createAttachedTableSource } from "@gscdump/engine/source";
10
10
  import { sqlEscape } from "@gscdump/engine/sql";
11
11
  import { resolveWindow } from "@gscdump/engine/period";
12
+ const METRIC_SQL = {
13
+ clicks: "SUM(clicks)",
14
+ impressions: "SUM(impressions)",
15
+ ctr: "CASE WHEN SUM(impressions) = 0 THEN 0 ELSE SUM(clicks) * 1.0 / SUM(impressions) END",
16
+ position: "CASE WHEN SUM(impressions) = 0 THEN NULL ELSE SUM(sum_position) * 1.0 / SUM(impressions) END"
17
+ };
18
+ const DIM_COLUMN = {
19
+ page: "url",
20
+ query: "query",
21
+ country: "country",
22
+ date: "date",
23
+ searchAppearance: "search_appearance"
24
+ };
25
+ function tableForDimensions(dims) {
26
+ const set = new Set(dims.filter((d) => d !== "date"));
27
+ if (set.has("page") && set.has("query")) return "page_queries";
28
+ if (set.has("query")) return "queries";
29
+ if (set.has("country")) return "countries";
30
+ if (set.has("device")) return "dates";
31
+ return "pages";
32
+ }
33
+ function metricExpr(metric) {
34
+ const expr = METRIC_SQL[metric];
35
+ if (!expr) throw new Error(`[archetype-sql] unknown metric: ${metric}`);
36
+ return expr;
37
+ }
38
+ function metricSelectList(metrics) {
39
+ return metrics.map((m) => `${metricExpr(m)} AS ${m}`).join(", ");
40
+ }
41
+ const DEVICE_VALUES = [
42
+ "DESKTOP",
43
+ "MOBILE",
44
+ "TABLET"
45
+ ];
46
+ const DEVICE_SUFFIX = {
47
+ DESKTOP: "desktop",
48
+ MOBILE: "mobile",
49
+ TABLET: "tablet"
50
+ };
51
+ function deviceMetricExpr(metric, suffix) {
52
+ switch (metric) {
53
+ case "clicks": return `SUM(clicks_${suffix})`;
54
+ case "impressions": return `SUM(impressions_${suffix})`;
55
+ case "ctr": return `CASE WHEN SUM(impressions_${suffix}) = 0 THEN 0 ELSE SUM(clicks_${suffix}) * 1.0 / SUM(impressions_${suffix}) END`;
56
+ case "position": return `CASE WHEN SUM(impressions_${suffix}) = 0 THEN NULL ELSE SUM(sum_position_${suffix}) * 1.0 / SUM(impressions_${suffix}) END`;
57
+ default: throw new Error(`[archetype-sql] unknown metric: ${metric}`);
58
+ }
59
+ }
60
+ function deviceUnpivotSql(metrics, whereSql, byDate) {
61
+ const dateCol = byDate ? "date, " : "";
62
+ const dateGroup = byDate ? " GROUP BY date" : "";
63
+ return DEVICE_VALUES.map((dv) => {
64
+ const suffix = DEVICE_SUFFIX[dv];
65
+ return `SELECT ${dateCol}'${dv}' AS device, ${metrics.map((m) => `${deviceMetricExpr(m, suffix)} AS ${m}`).join(", ")} FROM dates WHERE ${whereSql}${dateGroup}`;
66
+ }).join(" UNION ALL ");
67
+ }
68
+ function rangePredicate(q) {
69
+ return {
70
+ sql: "date BETWEEN ? AND ? AND search_type = ?",
71
+ params: [
72
+ q.range.start,
73
+ q.range.end,
74
+ q.searchType
75
+ ]
76
+ };
77
+ }
78
+ function compileArchetypeSql(query) {
79
+ switch (query.archetype) {
80
+ case "site-daily-timeseries": {
81
+ const table = "dates";
82
+ const where = rangePredicate(query);
83
+ return {
84
+ table,
85
+ sql: `SELECT date, ${query.metrics.map((m) => `${metricExpr(m)} AS ${m}`).join(", ")} FROM ${table} WHERE ${where.sql} GROUP BY date ORDER BY date`,
86
+ params: where.params
87
+ };
88
+ }
89
+ case "entity-daily-timeseries": {
90
+ const table = tableForDimensions([query.entity.dimension]);
91
+ const col = DIM_COLUMN[query.entity.dimension];
92
+ const where = rangePredicate(query);
93
+ return {
94
+ table,
95
+ sql: `SELECT date, ${metricSelectList(query.metrics)} FROM ${table} WHERE ${where.sql} AND ${col} = ? GROUP BY date ORDER BY date`,
96
+ params: [...where.params, query.entity.value]
97
+ };
98
+ }
99
+ case "entity-daily-sparkline": {
100
+ const table = tableForDimensions([query.dimension]);
101
+ const col = DIM_COLUMN[query.dimension];
102
+ const where = rangePredicate(query);
103
+ if (query.entities.length === 0) throw new Error("[archetype-sql] entity-daily-sparkline requires resolved entities");
104
+ const placeholders = query.entities.map(() => "?").join(", ");
105
+ return {
106
+ table,
107
+ sql: `SELECT ${col} AS entity, date, ${metricExpr(query.metric)} AS ${query.metric} FROM ${table} WHERE ${where.sql} AND ${col} IN (${placeholders}) GROUP BY ${col}, date ORDER BY ${col}, date`,
108
+ params: [...where.params, ...query.entities]
109
+ };
110
+ }
111
+ case "top-n-breakdown": {
112
+ const table = tableForDimensions([query.dimension]);
113
+ const where = rangePredicate(query);
114
+ const dir = query.orderBy.dir === "asc" ? "ASC" : "DESC";
115
+ if (query.dimension === "device") {
116
+ let sql = `SELECT device, ${query.metrics.join(", ")} FROM (${deviceUnpivotSql(query.metrics, where.sql, false)}) ORDER BY ${query.orderBy.metric} ${dir} LIMIT ?`;
117
+ const params = [
118
+ ...where.params,
119
+ ...where.params,
120
+ ...where.params,
121
+ query.limit
122
+ ];
123
+ if (query.offset && query.offset > 0) {
124
+ sql += " OFFSET ?";
125
+ params.push(query.offset);
126
+ }
127
+ return {
128
+ table,
129
+ sql,
130
+ params
131
+ };
132
+ }
133
+ const col = DIM_COLUMN[query.dimension];
134
+ let sql = `SELECT ${col} AS ${query.dimension}, ${metricSelectList(query.metrics)} FROM ${table} WHERE ${where.sql} GROUP BY ${col} ORDER BY ${query.orderBy.metric} ${dir} LIMIT ?`;
135
+ const params = [...where.params, query.limit];
136
+ if (query.offset && query.offset > 0) {
137
+ sql += " OFFSET ?";
138
+ params.push(query.offset);
139
+ }
140
+ return {
141
+ table,
142
+ sql,
143
+ params
144
+ };
145
+ }
146
+ case "single-row-lookup": {
147
+ const table = tableForDimensions(Object.keys(query.match));
148
+ const where = rangePredicate(query);
149
+ const matchParts = [];
150
+ const matchParams = [];
151
+ for (const [dim, value] of Object.entries(query.match)) {
152
+ const col = DIM_COLUMN[dim];
153
+ if (!col) throw new Error(`[archetype-sql] single-row-lookup: unknown dimension ${dim}`);
154
+ matchParts.push(`${col} = ?`);
155
+ matchParams.push(value);
156
+ }
157
+ const matchSql = matchParts.length ? ` AND ${matchParts.join(" AND ")}` : "";
158
+ return {
159
+ table,
160
+ sql: `SELECT ${metricSelectList(query.metrics)} FROM ${table} WHERE ${where.sql}${matchSql}`,
161
+ params: [...where.params, ...matchParams]
162
+ };
163
+ }
164
+ case "multi-series-stacked-daily": {
165
+ const table = tableForDimensions([query.seriesDimension]);
166
+ const where = rangePredicate(query);
167
+ if (query.seriesDimension === "device") return {
168
+ table,
169
+ sql: `SELECT date, device, ${query.metric} FROM (${deviceUnpivotSql([query.metric], where.sql, true)}) ORDER BY date, device`,
170
+ params: [
171
+ ...where.params,
172
+ ...where.params,
173
+ ...where.params
174
+ ]
175
+ };
176
+ const col = DIM_COLUMN[query.seriesDimension];
177
+ return {
178
+ table,
179
+ sql: `SELECT date, ${col} AS ${query.seriesDimension}, ${metricExpr(query.metric)} AS ${query.metric} FROM ${table} WHERE ${where.sql} GROUP BY date, ${col} ORDER BY date, ${col}`,
180
+ params: where.params
181
+ };
182
+ }
183
+ case "preset-analyzer": {
184
+ const table = "queries";
185
+ const where = rangePredicate(query);
186
+ const params = query.params ?? {};
187
+ const minPos = Number(params.minPosition ?? 4);
188
+ const maxPos = Number(params.maxPosition ?? 20);
189
+ const minImpr = Number(params.minImpressions ?? 10);
190
+ const limit = Number(params.limit ?? 1e3);
191
+ return {
192
+ table,
193
+ sql: `SELECT query, ${metricExpr("clicks")} AS clicks, ${metricExpr("impressions")} AS impressions, ${metricExpr("position")} AS position FROM ${table} WHERE ${where.sql} GROUP BY query HAVING position BETWEEN ? AND ? AND impressions > ? ORDER BY impressions DESC LIMIT ?`,
194
+ params: [
195
+ ...where.params,
196
+ minPos,
197
+ maxPos,
198
+ minImpr,
199
+ limit
200
+ ]
201
+ };
202
+ }
203
+ case "two-dimension-detail": {
204
+ const table = "page_queries";
205
+ const where = rangePredicate(query);
206
+ const filterParts = [];
207
+ const filterParams = [];
208
+ if (query.filter?.page) {
209
+ filterParts.push("url = ?");
210
+ filterParams.push(query.filter.page);
211
+ }
212
+ if (query.filter?.query) {
213
+ filterParts.push("query = ?");
214
+ filterParams.push(query.filter.query);
215
+ }
216
+ const filterSql = filterParts.length ? ` AND ${filterParts.join(" AND ")}` : "";
217
+ let sql = `SELECT url AS page, query, ${metricSelectList(query.metrics)} FROM ${table} WHERE ${where.sql}${filterSql} GROUP BY url, query`;
218
+ const params = [...where.params, ...filterParams];
219
+ if (query.orderBy) {
220
+ const dir = query.orderBy.dir === "asc" ? "ASC" : "DESC";
221
+ sql += ` ORDER BY ${query.orderBy.metric} ${dir}`;
222
+ }
223
+ if (typeof query.limit === "number") {
224
+ sql += " LIMIT ?";
225
+ params.push(query.limit);
226
+ }
227
+ return {
228
+ table,
229
+ sql,
230
+ params
231
+ };
232
+ }
233
+ case "arbitrary-sql": return {
234
+ table: "page_queries",
235
+ sql: query.sql,
236
+ params: [...query.params ?? []]
237
+ };
238
+ case "aux-cloud-only": throw new Error("[archetype-sql] aux-cloud-only is not an Iceberg query — route to the cloud endpoint");
239
+ default: throw new Error(`[archetype-sql] unhandled archetype: ${query.archetype}`);
240
+ }
241
+ }
242
+ function tableForArchetype(query) {
243
+ if (query.archetype === "aux-cloud-only") return null;
244
+ return compileArchetypeSql(query).table;
245
+ }
12
246
  async function createClient(db, conn) {
13
247
  return {
14
248
  db,
@@ -104,16 +338,16 @@ async function strikingMomentum(runner, opts = {}) {
104
338
  SELECT
105
339
  query,
106
340
  url,
107
- ${page_keywords.date} AS date,
108
- ${page_keywords.impressions} AS impressions,
109
- ${page_keywords.sum_position} AS sum_position,
341
+ ${page_queries.date} AS date,
342
+ ${page_queries.impressions} AS impressions,
343
+ ${page_queries.sum_position} AS sum_position,
110
344
  CASE
111
- WHEN ${page_keywords.date} >= (DATE ${sql.raw(`'${anchor}'`)} - INTERVAL ${sql.raw(`${windowDays}`)} DAY)
345
+ WHEN ${page_queries.date} >= (DATE ${sql.raw(`'${anchor}'`)} - INTERVAL ${sql.raw(`${windowDays}`)} DAY)
112
346
  THEN 'recent' ELSE 'prior'
113
347
  END AS period
114
- FROM ${page_keywords}
115
- WHERE ${page_keywords.date} >= (DATE ${sql.raw(`'${anchor}'`)} - INTERVAL ${sql.raw(`${windowDays * 2}`)} DAY)
116
- AND ${page_keywords.date} < (DATE ${sql.raw(`'${anchor}'`)} + INTERVAL 1 DAY)
348
+ FROM ${page_queries}
349
+ WHERE ${page_queries.date} >= (DATE ${sql.raw(`'${anchor}'`)} - INTERVAL ${sql.raw(`${windowDays * 2}`)} DAY)
350
+ AND ${page_queries.date} < (DATE ${sql.raw(`'${anchor}'`)} + INTERVAL 1 DAY)
117
351
  ),
118
352
  agg AS (
119
353
  SELECT
@@ -154,6 +388,206 @@ async function strikingMomentum(runner, opts = {}) {
154
388
  `;
155
389
  return await runner.db.execute(windowExpr);
156
390
  }
391
+ var OpfsQuotaExceededError = class extends Error {
392
+ name = "OpfsQuotaExceededError";
393
+ degradedTables;
394
+ constructor(message, degradedTables) {
395
+ super(message);
396
+ this.degradedTables = degradedTables;
397
+ }
398
+ };
399
+ const DEFAULT_CONCURRENCY = 2;
400
+ const OPFS_PREFIX = "gscdump-snapshot__";
401
+ function isQuotaError(err) {
402
+ if (typeof err !== "object" || err === null) return false;
403
+ return err.name === "QuotaExceededError" || err.code === 22;
404
+ }
405
+ function isAbortError$1(err) {
406
+ return typeof err === "object" && err !== null && err.name === "AbortError";
407
+ }
408
+ function opfsFileName(table, index) {
409
+ return `${OPFS_PREFIX}${table}_${index}.parquet`;
410
+ }
411
+ async function requestPersistentStorage() {
412
+ const storage = globalThis.navigator?.storage;
413
+ if (!storage?.persist) return false;
414
+ if (await storage.persisted?.().catch(() => false)) return true;
415
+ return storage.persist().catch(() => false);
416
+ }
417
+ async function estimateOpfsStorage() {
418
+ const estimate = globalThis.navigator?.storage?.estimate;
419
+ if (!estimate) return {};
420
+ const est = await estimate.call(globalThis.navigator.storage).catch(() => null);
421
+ return est ? {
422
+ usageBytes: est.usage,
423
+ quotaBytes: est.quota
424
+ } : {};
425
+ }
426
+ async function sha256Hex(bytes) {
427
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
428
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
429
+ }
430
+ async function getOpfsRoot() {
431
+ const dir = globalThis.navigator?.storage?.getDirectory;
432
+ if (!dir) throw new Error("[engine-duckdb-wasm/opfs] OPFS unavailable: navigator.storage.getDirectory missing");
433
+ return dir.call(globalThis.navigator.storage);
434
+ }
435
+ async function materialiseFile(root, name, file, fetchImpl, fetchInit, signal) {
436
+ signal?.throwIfAborted();
437
+ let handle;
438
+ try {
439
+ handle = await root.getFileHandle(name);
440
+ const cached = await handle.getFile();
441
+ if (cached.size === file.bytes) {
442
+ if (!file.contentHash) return {
443
+ handle,
444
+ outcome: "cache-hit"
445
+ };
446
+ if (await sha256Hex(await cached.arrayBuffer()) === file.contentHash.toLowerCase()) return {
447
+ handle,
448
+ outcome: "cache-hit"
449
+ };
450
+ }
451
+ } catch {}
452
+ signal?.throwIfAborted();
453
+ const resp = await fetchImpl(file.url, {
454
+ ...fetchInit,
455
+ signal
456
+ });
457
+ if (!resp.ok) throw new Error(`[engine-duckdb-wasm/opfs] download ${file.url} failed: ${resp.status}`);
458
+ const buf = await resp.arrayBuffer();
459
+ if (file.contentHash) {
460
+ const hash = await sha256Hex(buf);
461
+ if (hash !== file.contentHash.toLowerCase()) throw new Error(`[engine-duckdb-wasm/opfs] content-hash mismatch for ${file.url}: expected ${file.contentHash}, got ${hash}`);
462
+ }
463
+ handle = await root.getFileHandle(name, { create: true });
464
+ const writable = await handle.createWritable();
465
+ try {
466
+ await writable.write(buf);
467
+ await writable.close();
468
+ } catch (err) {
469
+ await writable.abort?.().catch(() => {});
470
+ await root.removeEntry(name).catch(() => {});
471
+ throw err;
472
+ }
473
+ return {
474
+ handle,
475
+ outcome: "downloaded"
476
+ };
477
+ }
478
+ function readParquetViewSql$1(schema, table, files) {
479
+ return `CREATE OR REPLACE VIEW ${schema}.${table} AS SELECT * REPLACE (CAST(date AS DATE) AS date) FROM read_parquet([${files.map((f) => `'${f.replace(/'/g, "''")}'`).join(", ")}], union_by_name = true)`;
480
+ }
481
+ async function runWithConcurrency$1(items, concurrency, fn) {
482
+ let next = 0;
483
+ let failed;
484
+ async function worker() {
485
+ while (failed === void 0 && next < items.length) {
486
+ const index = next++;
487
+ try {
488
+ await fn(items[index], index);
489
+ } catch (err) {
490
+ failed = err;
491
+ throw err;
492
+ }
493
+ }
494
+ }
495
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
496
+ }
497
+ async function attachOpfsParquetTables(options) {
498
+ const { db, conn, tables, schema = "main", fetch: fetchImpl = globalThis.fetch.bind(globalThis), fetchInit, fetchConcurrency = DEFAULT_CONCURRENCY, signal, version, onFileProgress } = options;
499
+ await requestPersistentStorage();
500
+ const root = await getOpfsRoot();
501
+ const flat = [];
502
+ for (const t of tables) for (let i = 0; i < t.files.length; i++) flat.push({
503
+ table: t.table,
504
+ file: t.files[i],
505
+ fileIndex: i
506
+ });
507
+ const total = flat.length;
508
+ const { DuckDBDataProtocol } = await import("@duckdb/duckdb-wasm");
509
+ const tableFiles = /* @__PURE__ */ new Map();
510
+ const degraded = /* @__PURE__ */ new Set();
511
+ let bytesAttached = 0;
512
+ await runWithConcurrency$1(flat, Math.max(1, fetchConcurrency), async (item, index) => {
513
+ if (degraded.has(item.table)) return;
514
+ signal?.throwIfAborted();
515
+ const name = opfsFileName(item.table, item.fileIndex);
516
+ let result;
517
+ try {
518
+ result = await materialiseFile(root, name, item.file, fetchImpl, fetchInit, signal);
519
+ } catch (err) {
520
+ if (isAbortError$1(err)) throw err;
521
+ if (isQuotaError(err)) {
522
+ degraded.add(item.table);
523
+ return;
524
+ }
525
+ throw err;
526
+ }
527
+ await db.registerFileHandle(name, result.handle, DuckDBDataProtocol.BROWSER_FSACCESS, true);
528
+ const list = tableFiles.get(item.table) ?? [];
529
+ list.push({
530
+ name,
531
+ handle: result.handle
532
+ });
533
+ tableFiles.set(item.table, list);
534
+ bytesAttached += item.file.bytes;
535
+ onFileProgress?.({
536
+ table: item.table,
537
+ file: name,
538
+ index,
539
+ total,
540
+ bytes: item.file.bytes,
541
+ outcome: result.outcome
542
+ });
543
+ });
544
+ const attached = [];
545
+ const registeredNames = [];
546
+ try {
547
+ for (const t of tables) {
548
+ if (degraded.has(t.table)) continue;
549
+ const files = tableFiles.get(t.table) ?? [];
550
+ if (files.length !== t.files.length) {
551
+ degraded.add(t.table);
552
+ continue;
553
+ }
554
+ signal?.throwIfAborted();
555
+ await conn.query(readParquetViewSql$1(schema, t.table, files.map((f) => f.name)));
556
+ attached.push(t.table);
557
+ for (const f of files) registeredNames.push(f.name);
558
+ }
559
+ } catch (err) {
560
+ await detachOpfs(db, conn, schema, attached, registeredNames).catch(() => {});
561
+ throw err;
562
+ }
563
+ let detached = false;
564
+ return {
565
+ version,
566
+ tables: attached,
567
+ schema,
568
+ bytesAttached,
569
+ degradedTables: [...degraded],
570
+ async detach() {
571
+ if (detached) return;
572
+ detached = true;
573
+ await detachOpfs(db, conn, schema, attached, registeredNames);
574
+ }
575
+ };
576
+ }
577
+ async function detachOpfs(db, conn, schema, tables, files) {
578
+ for (const table of tables) await conn.query(`DROP VIEW IF EXISTS ${schema}.${table}`).catch(() => {});
579
+ if (files.length > 0) await db.dropFiles([...files]).catch(() => {});
580
+ }
581
+ async function clearOpfsSnapshotCache() {
582
+ const root = await getOpfsRoot().catch(() => null);
583
+ if (!root) return;
584
+ const removable = [];
585
+ const dir = root;
586
+ if (dir.keys) {
587
+ for await (const name of dir.keys()) if (name.startsWith(OPFS_PREFIX)) removable.push(name);
588
+ }
589
+ for (const name of removable) await root.removeEntry(name).catch(() => {});
590
+ }
157
591
  async function createInsightRunner(opts) {
158
592
  const client = await createClient(opts.db, opts.conn);
159
593
  const clientPromise = Promise.resolve(client);
@@ -521,4 +955,4 @@ function createBrowserAnalysisRuntime(boot, options = {}) {
521
955
  }
522
956
  };
523
957
  }
524
- export { BrowserAttachBudgetExceededError, DuckDBWasmDatabase, attachParquetTables, attachParquetUrlTables, bootDuckDBWasm, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, devices, drizzle, hourly_pages, keywords, mergeScope, page_keywords, pages, resolveWindow, schema, scopeFor, strikingMomentum };
958
+ export { BrowserAttachBudgetExceededError, DuckDBWasmDatabase, OpfsQuotaExceededError, attachOpfsParquetTables, attachParquetTables, attachParquetUrlTables, bootDuckDBWasm, clearOpfsSnapshotCache, compileArchetypeSql, countries, createBrowserAnalysisRuntime, createClient, createInsightRunner, dates, drizzle, estimateOpfsStorage, hourly_pages, mergeScope, page_queries, pages, queries, requestPersistentStorage, resolveWindow, schema, scopeFor, strikingMomentum, tableForArchetype };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/engine-duckdb-wasm",
3
3
  "type": "module",
4
- "version": "0.20.3",
4
+ "version": "0.21.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",
@@ -45,8 +45,9 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "drizzle-orm": "^0.45.2",
48
- "@gscdump/engine": "0.20.3",
49
- "gscdump": "0.20.3"
48
+ "@gscdump/sdk": "0.21.0",
49
+ "@gscdump/engine": "0.21.0",
50
+ "gscdump": "0.21.0"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@duckdb/duckdb-wasm": "^1.32.0",