@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 +126 -5
- package/dist/index.mjs +443 -9
- package/package.json +4 -3
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,
|
|
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" | "
|
|
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 `
|
|
192
|
-
* `
|
|
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,
|
|
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,
|
|
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
|
-
${
|
|
108
|
-
${
|
|
109
|
-
${
|
|
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 ${
|
|
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 ${
|
|
115
|
-
WHERE ${
|
|
116
|
-
AND ${
|
|
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,
|
|
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.
|
|
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/
|
|
49
|
-
"gscdump": "0.
|
|
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",
|