@malloydata/malloyyo 0.2.20 → 0.2.22

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.
@@ -46,24 +46,30 @@ class StaticWasmConnection extends DuckDBWASMConnection {
46
46
  }
47
47
  }
48
48
 
49
- // Whole-file fetch, not ranged reads. For a columnar file whose dominant column
50
- // every query touches, ranged reads trade one bulk download for dozens of WAN
51
- // round trips and lose badly.
49
+ // Whole-file fetch, not ranged reads.
52
50
  //
53
- // This CANNOT go through registerRemoteTableCallback: db-duckdb's findTables
54
- // skips anything matching ^https?:// ("handled by duckdb-wasm") so the callback
55
- // never fires for a remote URL. Instead we pre-register the bytes under the
56
- // exact URL string before any query runs duckdb-wasm resolves its registered
57
- // files by name first, so the table reference finds the buffer and never issues
58
- // an HTTP request. The URL list is extracted from the model at build time.
59
- const REMOTE_TABLES: string[] = (window as any).__REMOTE_TABLES__ || [];
60
-
61
- async function preloadRemoteTables(db: any) {
51
+ // __TABLE_FILES__ maps each table reference the model makes to the URL this
52
+ // page fetches for it. The KEY is what the model wrote and what DuckDB will
53
+ // look up, so registering the bytes under that exact name means the model needs
54
+ // no rewriting between `dashboard dev` (reads the file off disk) and here.
55
+ //
56
+ // Project-relative refs like `data/x.parquet` resolve against the page, so they
57
+ // are SAME-ORIGIN no CORS involved at all. Absolute URLs are fetched
58
+ // cross-origin and do need CORS on that host.
59
+ //
60
+ // This cannot go through db-duckdb's registerRemoteTableCallback: findTables
61
+ // skips anything matching ^https?:// ("handled by duckdb-wasm"), so the callback
62
+ // never fires for a remote URL. Pre-registering is also strictly better — one
63
+ // bulk GET instead of dozens of ranged round trips.
64
+ const TABLE_FILES: Record<string, string> = (window as any).__TABLE_FILES__ || {};
65
+
66
+ async function preloadTables(db: any) {
62
67
  await Promise.all(
63
- REMOTE_TABLES.map(async (url) => {
68
+ Object.entries(TABLE_FILES).map(async ([name, href]) => {
69
+ const url = new URL(href, document.baseURI).href;
64
70
  const r = await fetch(url);
65
71
  if (!r.ok) throw new Error(`fetch ${url} failed: ${r.status} ${r.statusText}`);
66
- await db.registerFileBuffer(url, new Uint8Array(await r.arrayBuffer()));
72
+ await db.registerFileBuffer(name, new Uint8Array(await r.arrayBuffer()));
67
73
  }),
68
74
  );
69
75
  }
@@ -89,7 +95,7 @@ function getRuntime() {
89
95
  runtimeP = (async () => {
90
96
  const connection = new StaticWasmConnection({ name: "duckdb" });
91
97
  await connection.connecting;
92
- await preloadRemoteTables((connection as any).database);
98
+ await preloadTables((connection as any).database);
93
99
  return new SingleConnectionRuntime({ connection, urlReader });
94
100
  })();
95
101
  }
package/dist/index.js CHANGED
@@ -3202,13 +3202,54 @@ function inlineModelFiles(root) {
3202
3202
  walk(root);
3203
3203
  return files;
3204
3204
  }
3205
- function findRemoteTables(modelFiles) {
3206
- const urls = /* @__PURE__ */ new Set();
3207
- const re = /\btable\(\s*(['"])(https?:\/\/[^'"]+)\1/g;
3205
+ function reachableModelFiles(modelFiles, entries) {
3206
+ const IMPORT = /\bimport\s+(?:\{[^}]*\}\s+from\s+)?['"]([^'"]+)['"]/g;
3207
+ const out = {};
3208
+ const queue = entries.map((e) => `file:///${e.replace(/^\.?\//, "")}`);
3209
+ while (queue.length) {
3210
+ const key = queue.shift();
3211
+ if (key in out) continue;
3212
+ const src = modelFiles[key];
3213
+ if (src == null) continue;
3214
+ out[key] = src;
3215
+ const dir = key.slice(0, key.lastIndexOf("/"));
3216
+ for (const m of src.matchAll(IMPORT)) {
3217
+ queue.push(new URL(m[1], dir + "/").href);
3218
+ }
3219
+ }
3220
+ return out;
3221
+ }
3222
+ function findTableRefs(modelFiles) {
3223
+ const refs = /* @__PURE__ */ new Set();
3224
+ const re = /\btable\(\s*(['"])([^'"]+)\1/g;
3208
3225
  for (const src of Object.values(modelFiles)) {
3209
- for (const m of src.matchAll(re)) urls.add(m[2]);
3226
+ for (const m of src.matchAll(re)) refs.add(m[2]);
3227
+ }
3228
+ return [...refs].sort();
3229
+ }
3230
+ function isDataFile(ref) {
3231
+ if (/^https?:\/\//i.test(ref)) return true;
3232
+ return /\.(parquet|csv|tsv|json|ndjson)$/i.test(ref);
3233
+ }
3234
+ function tableFilePlan(modelFiles, outRel) {
3235
+ const map = {};
3236
+ const copies = [];
3237
+ const out = outRel.replace(/^\.?\//, "").replace(/\/$/, "");
3238
+ for (const ref of findTableRefs(modelFiles)) {
3239
+ if (!isDataFile(ref)) continue;
3240
+ if (/^https?:\/\//i.test(ref)) {
3241
+ map[ref] = ref;
3242
+ continue;
3243
+ }
3244
+ const rel = ref.replace(/^\.?\//, "");
3245
+ if (out && (rel === out || rel.startsWith(out + "/"))) {
3246
+ map[ref] = `./${rel.slice(out.length + 1)}`;
3247
+ } else {
3248
+ map[ref] = `./${rel}`;
3249
+ copies.push(rel);
3250
+ }
3210
3251
  }
3211
- return [...urls].sort();
3252
+ return { map, copies };
3212
3253
  }
3213
3254
  function copyDuckDBAssets(outDir) {
3214
3255
  const names = [
@@ -3234,7 +3275,14 @@ function navFor(dash, all, cleanUrls) {
3234
3275
  (n) => cleanUrls ? `./${encodeURIComponent(n)}` : `./${encodeURIComponent(n)}.html`
3235
3276
  );
3236
3277
  }
3237
- function page(dash, all, title, givenSpecs, tileSpecs, cleanUrls) {
3278
+ function analyticsSnippet(id) {
3279
+ if (!id) return "";
3280
+ const j = JSON.stringify(id);
3281
+ return `<script async src="https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}"></script>
3282
+ <script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}
3283
+ gtag('js',new Date());gtag('config',${j});</script>`;
3284
+ }
3285
+ function page(dash, all, title, givenSpecs, tileSpecs, cleanUrls, analytics) {
3238
3286
  const info = {
3239
3287
  name: dash.name,
3240
3288
  query: dash.query,
@@ -3254,6 +3302,7 @@ function page(dash, all, title, givenSpecs, tileSpecs, cleanUrls) {
3254
3302
  <meta name="viewport" content="width=device-width, initial-scale=1">
3255
3303
  <title>${esc3(dash.title || dash.name)}${title ? ` \xB7 ${esc3(title)}` : ""}</title>
3256
3304
  <link rel="stylesheet" href="./assets/site.css">
3305
+ ${analyticsSnippet(analytics)}
3257
3306
  </head>
3258
3307
  <body>
3259
3308
  ${navFor(dash, all, cleanUrls)}
@@ -3276,7 +3325,7 @@ window.__GIVENS__ = ${JSON.stringify(givenSpecs)};
3276
3325
  </html>
3277
3326
  `;
3278
3327
  }
3279
- function indexPage(dashboards, title, custom, cleanUrls) {
3328
+ function indexPage(dashboards, title, custom, cleanUrls, analytics) {
3280
3329
  const link = (n) => cleanUrls ? `./${encodeURIComponent(n)}` : `./${encodeURIComponent(n)}.html`;
3281
3330
  const body = custom ? `<div id="root"></div>
3282
3331
  <script>window.__DASHBOARDS__ = ${JSON.stringify(
@@ -3292,6 +3341,7 @@ function indexPage(dashboards, title, custom, cleanUrls) {
3292
3341
  <meta name="viewport" content="width=device-width, initial-scale=1">
3293
3342
  <title>${esc3(title)}</title>
3294
3343
  <link rel="stylesheet" href="./assets/site.css">
3344
+ ${analyticsSnippet(analytics)}
3295
3345
  </head>
3296
3346
  <body>
3297
3347
  ${navHtml("", dashboards, link)}
@@ -3321,6 +3371,12 @@ async function bundleDashboards(opts = {}) {
3321
3371
  const runner = await makeRunner(root);
3322
3372
  const dashboards = await discoverDashboards(root, runner);
3323
3373
  if (dashboards.length === 0) throw new Error(`no dashboards found in ${path7.join(root, "dashboards")}`);
3374
+ const manifestPath = path7.join(outDir, ".bundle-manifest.json");
3375
+ let priorData = [];
3376
+ try {
3377
+ priorData = JSON.parse(fs6.readFileSync(manifestPath, "utf8")).dataFiles ?? [];
3378
+ } catch {
3379
+ }
3324
3380
  for (const sub of ["assets", "duckdb"]) {
3325
3381
  fs6.rmSync(path7.join(outDir, sub), { recursive: true, force: true });
3326
3382
  }
@@ -3357,11 +3413,39 @@ async function bundleDashboards(opts = {}) {
3357
3413
  );
3358
3414
  }
3359
3415
  const modelFiles = inlineModelFiles(root);
3360
- const remoteTables = findRemoteTables(modelFiles);
3416
+ const outRel = path7.relative(root, outDir).split(path7.sep).join("/");
3417
+ const usedFiles = reachableModelFiles(
3418
+ modelFiles,
3419
+ dashboards.map((d) => d.entryFile).filter((f) => !!f)
3420
+ );
3421
+ const { map: tableFiles, copies } = tableFilePlan(usedFiles, outRel);
3422
+ for (const rel of copies) {
3423
+ const from = path7.join(root, rel);
3424
+ if (!fs6.existsSync(from)) {
3425
+ throw new Error(
3426
+ `model reads '${rel}' but ${from} does not exist.
3427
+ Data files are referenced by a path relative to the project root.`
3428
+ );
3429
+ }
3430
+ const to = path7.join(outDir, rel);
3431
+ fs6.mkdirSync(path7.dirname(to), { recursive: true });
3432
+ fs6.copyFileSync(from, to);
3433
+ }
3434
+ const copiedData = copies;
3435
+ for (const stale of priorData) {
3436
+ if (copies.includes(stale)) continue;
3437
+ fs6.rmSync(path7.join(outDir, stale), { force: true });
3438
+ try {
3439
+ fs6.rmdirSync(path7.dirname(path7.join(outDir, stale)));
3440
+ } catch {
3441
+ }
3442
+ console.log(` removed stale ${stale}`);
3443
+ }
3444
+ fs6.writeFileSync(manifestPath, JSON.stringify({ dataFiles: copies }, null, 2) + "\n");
3361
3445
  fs6.writeFileSync(
3362
3446
  path7.join(outDir, "assets", "model-files.js"),
3363
3447
  `window.__MODEL_FILES__ = ${JSON.stringify(modelFiles)};
3364
- window.__REMOTE_TABLES__ = ${JSON.stringify(remoteTables)};
3448
+ window.__TABLE_FILES__ = ${JSON.stringify(tableFiles)};
3365
3449
  ` + (selfHostDuckdb ? `window.__DUCKDB_BASE__ = "./duckdb/";
3366
3450
  ` : "")
3367
3451
  );
@@ -3418,9 +3502,9 @@ boot(Dashboard);
3418
3502
  if (!got.ok) throw new Error(`dashboard ${d.name}: ${got.error}`);
3419
3503
  specs = got.givens;
3420
3504
  }
3421
- fs6.writeFileSync(path7.join(outDir, `${d.name}.html`), page(d, dashboards, title, specs, tileSpecs, cleanUrls));
3505
+ fs6.writeFileSync(path7.join(outDir, `${d.name}.html`), page(d, dashboards, title, specs, tileSpecs, cleanUrls, opts.analytics));
3422
3506
  }
3423
- fs6.writeFileSync(path7.join(outDir, "index.html"), indexPage(dashboards, title, !!landing, cleanUrls));
3507
+ fs6.writeFileSync(path7.join(outDir, "index.html"), indexPage(dashboards, title, !!landing, cleanUrls, opts.analytics));
3424
3508
  if (landing) {
3425
3509
  await esbuild3.build({
3426
3510
  stdin: {
@@ -3458,6 +3542,10 @@ bundled ${dashboards.length} dashboard(s) \u2192 ${path7.relative(process.cwd(),
3458
3542
  selfHostDuckdb ? ` duckdb ${duck.length} assets (self-hosted)` : ` duckdb jsDelivr CDN (nothing copied)`
3459
3543
  );
3460
3544
  console.log(` model ${Object.keys(modelFiles).length} .malloy files inlined`);
3545
+ if (copiedData.length) {
3546
+ const mb = copiedData.reduce((a, r) => a + fs6.statSync(path7.join(root, r)).size, 0) / 1048576;
3547
+ console.log(` data ${copiedData.length} file(s) copied, ${mb.toFixed(1)} MB (same-origin)`);
3548
+ }
3461
3549
  console.log(
3462
3550
  target === "pages" ? `
3463
3551
  Publish: commit ${path7.basename(outDir)}/ and point GitHub Pages at it.` : `
@@ -3589,7 +3677,7 @@ async function launchCmd(mode, opts) {
3589
3677
  }
3590
3678
 
3591
3679
  // package.json
3592
- var version = "0.2.20";
3680
+ var version = "0.2.22";
3593
3681
 
3594
3682
  // src/index.ts
3595
3683
  function shortSha(sha) {
@@ -3696,7 +3784,7 @@ program.command("author").option("-C, --root <dir>", "project root (default: cur
3696
3784
  program.command("test").option("-C, --root <dir>", "project root (default: current directory)").description("launch Claude wired ONLY to the explore surface \u2014 the claude.ai web preview").action(async (opts) => {
3697
3785
  await launchCmd("test", opts);
3698
3786
  });
3699
- program.command("dashboard").argument("<action>", "action to run (dev | bundle)").option("-C, --root <dir>", "project root (default: current directory)").option("-p, --port <port>", "port to serve on (dev)", "4173").option("-o, --out <dir>", "output directory (bundle)", "docs").option("--title <title>", "site title (bundle; default: project directory name)").option("--target <target>", "deploy target: pages | vercel (bundle)", "pages").option("--duckdb <source>", "DuckDB binaries: cdn | bundled (bundle)", "cdn").option("--no-serve", "bundle only; don't serve the result (bundle)").description("preview dashboards locally (dev), or build a static site from them (bundle)").action(
3787
+ program.command("dashboard").argument("<action>", "action to run (dev | bundle)").option("-C, --root <dir>", "project root (default: current directory)").option("-p, --port <port>", "port to serve on (dev)", "4173").option("-o, --out <dir>", "output directory (bundle)", "docs").option("--title <title>", "site title (bundle; default: project directory name)").option("--target <target>", "deploy target: pages | vercel (bundle)", "pages").option("--duckdb <source>", "DuckDB binaries: cdn | bundled (bundle)", "cdn").option("--analytics <id>", "Google Analytics 4 Measurement ID, e.g. G-XXXXXXXXXX (bundle)").option("--no-serve", "bundle only; don't serve the result (bundle)").description("preview dashboards locally (dev), or build a static site from them (bundle)").action(
3700
3788
  async (action, opts) => {
3701
3789
  if (action === "dev") {
3702
3790
  await serveDashboard({ root: opts.root, port: Number(opts.port) });
@@ -3706,6 +3794,11 @@ program.command("dashboard").argument("<action>", "action to run (dev | bundle)"
3706
3794
  if (opts.target !== "pages" && opts.target !== "vercel") {
3707
3795
  throw new Error(`unknown --target '${opts.target}' (expected: pages | vercel)`);
3708
3796
  }
3797
+ if (opts.analytics && !/^G-[A-Z0-9]+$/i.test(opts.analytics)) {
3798
+ throw new Error(
3799
+ `--analytics expects a GA4 Measurement ID like G-XXXXXXXXXX, got '${opts.analytics}'`
3800
+ );
3801
+ }
3709
3802
  if (opts.duckdb !== "cdn" && opts.duckdb !== "bundled") {
3710
3803
  throw new Error(`unknown --duckdb '${opts.duckdb}' (expected: cdn | bundled)`);
3711
3804
  }
@@ -3716,6 +3809,7 @@ program.command("dashboard").argument("<action>", "action to run (dev | bundle)"
3716
3809
  serve: opts.serve,
3717
3810
  target: opts.target,
3718
3811
  duckdb: opts.duckdb,
3812
+ analytics: opts.analytics,
3719
3813
  // `dashboard dev` owns 4173/4174; default the bundle preview clear of
3720
3814
  // both so you can run the two side by side.
3721
3815
  port: opts.port === "4173" ? 4180 : Number(opts.port)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@malloydata/malloyyo",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Publish Malloy models to a Malloyyo instance",
5
5
  "license": "MIT",
6
6
  "repository": {