@malloy-publisher/server 0.0.215 → 0.0.217

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.
Files changed (31) hide show
  1. package/dist/app/api-doc.yaml +30 -4
  2. package/dist/app/assets/{EnvironmentPage-CuO6sty5.js → EnvironmentPage-BX71Wsun.js} +1 -1
  3. package/dist/app/assets/{HomePage-u3vxROIF.js → HomePage-Bnp4Puv4.js} +1 -1
  4. package/dist/app/assets/{MainPage-Bt2sYLbr.js → MainPage-chkqUlI0.js} +1 -1
  5. package/dist/app/assets/{MaterializationsPage-B1rj61zs.js → MaterializationsPage-DG4e_wRd.js} +1 -1
  6. package/dist/app/assets/{ModelPage-BpvONvSR.js → ModelPage-DbLU-ABs.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DHNcwboW.js → PackagePage-EvWf3VZ4.js} +1 -1
  8. package/dist/app/assets/{RouteError-D1vhLJvr.js → RouteError-POj8kImc.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-CZmud-yI.js → WorkbookPage-Bkq3C1MS.js} +1 -1
  10. package/dist/app/assets/{core-XtBSnW2U.es-DE88sVsZ.js → core-DiLT6QvL.es-Jn6sdy15.js} +1 -1
  11. package/dist/app/assets/{index-BQdOB6m9.js → index-C_AT6ZZw.js} +1 -1
  12. package/dist/app/assets/{index-CYf2akGH.js → index-bAdd7U9-.js} +1 -1
  13. package/dist/app/assets/{index-BlnQtDZj.js → index-gjr27uMq.js} +73 -73
  14. package/dist/app/assets/{index.umd-BdQ0R4hx.js → index.umd-BxzPw_1E.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/server.mjs +33 -7
  17. package/package.json +1 -1
  18. package/src/server.ts +55 -1
  19. package/src/service/build_plan.spec.ts +21 -0
  20. package/src/service/build_plan.ts +14 -0
  21. package/src/service/environment.ts +7 -0
  22. package/src/service/materialization_schedule_surface.spec.ts +103 -0
  23. package/src/service/package.ts +10 -1
  24. package/tests/fixtures/html-pages-test/public/barehtml.html +4 -0
  25. package/tests/fixtures/html-pages-test/public/bodymeta.html +8 -0
  26. package/tests/fixtures/html-pages-test/public/fitcomment.html +11 -0
  27. package/tests/fixtures/html-pages-test/public/nohead.html +8 -0
  28. package/tests/fixtures/html-pages-test/public/notfit.html +13 -0
  29. package/tests/fixtures/html-pages-test/public/slides.html +12 -0
  30. package/tests/fixtures/html-pages-test/public/unterminated.html +10 -0
  31. package/tests/integration/html_pages/html_pages.integration.spec.ts +63 -1
package/src/server.ts CHANGED
@@ -491,7 +491,32 @@ type PageItem = {
491
491
  packageName: string;
492
492
  path: string;
493
493
  title: string;
494
+ fit?: "viewport";
494
495
  };
496
+
497
+ // The spots in an HTML head where a "<meta ...>" literal would NOT be a live
498
+ // tag: HTML comments (terminated or unterminated) and raw-text/RCDATA elements
499
+ // (script/style/title/textarea). One alternation so a single .replace covers
500
+ // them all (and so the fixpoint loop below applies one self-referential
501
+ // replace, which is the complete-sanitization shape CodeQL recognizes).
502
+ const NON_TAG_TEXT_PATTERN =
503
+ /<!--[\s\S]*?-->|<!--[\s\S]*$|<(script|style|title|textarea)\b[\s\S]*?<\/\1\s*>/gi;
504
+
505
+ // Remove those matches until the string stops changing. A single pass is
506
+ // incomplete because removing one match can splice the surrounding text into a
507
+ // new one (CWE-116), so re-apply the same pattern to its own output until a
508
+ // fixpoint. Each pass only deletes, so the string strictly shrinks and the loop
509
+ // terminates, bounded by the input length (callers pass at most the first 4KB).
510
+ function stripNonTagText(input: string): string {
511
+ let current = input;
512
+ let previous: string;
513
+ do {
514
+ previous = current;
515
+ current = current.replace(NON_TAG_TEXT_PATTERN, "");
516
+ } while (current !== previous);
517
+ return current;
518
+ }
519
+
495
520
  async function listPackagePages(
496
521
  environmentName: string,
497
522
  packageName: string,
@@ -539,8 +564,9 @@ async function listPackagePages(
539
564
  (entry.name.endsWith(".html") || entry.name.endsWith(".htm"))
540
565
  ) {
541
566
  const rel = path.relative(publicRoot, full).replace(/\\/g, "/");
542
- // Cheap title extraction: read first 4KB and grep for <title>.
567
+ // Cheap metadata extraction: read first 4KB and grep the <head>.
543
568
  let title = rel;
569
+ let fit: "viewport" | undefined;
544
570
  try {
545
571
  const fh = await fs.open(full, "r");
546
572
  try {
@@ -549,6 +575,33 @@ async function listPackagePages(
549
575
  const head = buf.slice(0, bytesRead).toString("utf8");
550
576
  const m = head.match(/<title[^>]*>([^<]+)<\/title>/i);
551
577
  if (m) title = m[1].trim();
578
+ // Full-screen apps (e.g. slide decks) opt into a viewport-fill
579
+ // embed with <meta name="publisher:fit" content="viewport">.
580
+ // FIRST strip the spots where the literal string is NOT a live
581
+ // tag (comments, script/style/title/textarea), THEN look at the
582
+ // <head> region only (up to </head> or <body>). Order matters:
583
+ // stripping before locating the boundary keeps a literal
584
+ // "<body>"/"</head>" inside a comment or <script> from
585
+ // truncating the scan and hiding a real tag. What's left and
586
+ // matches is a genuine <meta> the browser would honor too, so a
587
+ // documented/commented example or a string in a code block
588
+ // can't opt the page in. Match by name (attribute order/quoting
589
+ // vary), then confirm content="viewport"; the [\s"'] before
590
+ // `name` keeps `data-name="publisher:fit"` out. Like the title,
591
+ // the tag must sit within the first 4KB.
592
+ const cleaned = stripNonTagText(head);
593
+ const headEnd = cleaned.search(/<\/head\s*>|<body[\s>]/i);
594
+ const headTags =
595
+ headEnd === -1 ? cleaned : cleaned.slice(0, headEnd);
596
+ const fitMeta = headTags.match(
597
+ /<meta\b[^>]*[\s"']name\s*=\s*["']publisher:fit["'][^>]*>/i,
598
+ );
599
+ if (
600
+ fitMeta &&
601
+ /\bcontent\s*=\s*["']\s*viewport\s*["']/i.test(fitMeta[0])
602
+ ) {
603
+ fit = "viewport";
604
+ }
552
605
  } finally {
553
606
  await fh.close();
554
607
  }
@@ -560,6 +613,7 @@ async function listPackagePages(
560
613
  packageName,
561
614
  path: rel,
562
615
  title,
616
+ fit,
563
617
  });
564
618
  }
565
619
  }
@@ -229,6 +229,27 @@ describe("deriveBuildPlan", () => {
229
229
 
230
230
  expect(Object.keys(plan.sources)).toEqual(["a@m"]);
231
231
  });
232
+
233
+ it("carries the per-source package-relative modelPath", () => {
234
+ const a = fakeSource({ name: "a", buildId: "bid-a" });
235
+ const b = fakeSource({ name: "b", buildId: "bid-b" });
236
+ const plan = deriveBuildPlan(
237
+ [
238
+ {
239
+ connectionName: "duckdb",
240
+ nodes: [[{ sourceID: "a@m", dependsOn: [] }]],
241
+ },
242
+ ] as unknown as Parameters<typeof deriveBuildPlan>[0],
243
+ { "a@m": a, "b@m": b },
244
+ { duckdb: "dig" },
245
+ undefined,
246
+ { "a@m": "rollup.malloy" },
247
+ );
248
+
249
+ // Mapped source gets its model path; an unmapped source stays undefined.
250
+ expect(plan.sources["a@m"].modelPath).toBe("rollup.malloy");
251
+ expect(plan.sources["b@m"].modelPath).toBeUndefined();
252
+ });
232
253
  });
233
254
 
234
255
  describe("compilePackageBuildPlan", () => {
@@ -42,6 +42,13 @@ export interface CompiledBuildPlan {
42
42
  sources: Record<string, PersistSource>;
43
43
  connectionDigests: Record<string, string>;
44
44
  connections: Map<string, MalloyConnection>;
45
+ /**
46
+ * sourceID -> the package-relative path of the `.malloy` model that declares
47
+ * the source (the only place that mapping is known, since a sourceID's
48
+ * embedded modelURL is an absolute `file://` path with no package boundary).
49
+ * Optional so existing fixtures/callers that don't track it still typecheck.
50
+ */
51
+ sourceModelPaths?: Record<string, string>;
45
52
  }
46
53
 
47
54
  /** Output columns of a persist source, degrading to [] if unavailable. */
@@ -192,6 +199,7 @@ export async function compilePackageBuildPlan(
192
199
  ): Promise<CompiledBuildPlan> {
193
200
  const allGraphs: MalloyBuildGraph[] = [];
194
201
  const allSources: Record<string, PersistSource> = {};
202
+ const sourceModelPaths: Record<string, string> = {};
195
203
 
196
204
  for (const modelPath of pkg.getModelPaths()) {
197
205
  // Only `.malloy` models declare persist sources. Skip `.malloynb`
@@ -225,6 +233,7 @@ export async function compilePackageBuildPlan(
225
233
  allGraphs.push(...buildPlan.graphs);
226
234
  for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
227
235
  allSources[sourceID] = source;
236
+ sourceModelPaths[sourceID] = modelPath;
228
237
  }
229
238
  }
230
239
 
@@ -256,6 +265,7 @@ export async function compilePackageBuildPlan(
256
265
  sources: allSources,
257
266
  connectionDigests,
258
267
  connections,
268
+ sourceModelPaths,
259
269
  };
260
270
  }
261
271
 
@@ -265,6 +275,7 @@ export function deriveBuildPlan(
265
275
  sources: Record<string, PersistSource>,
266
276
  connectionDigests: Record<string, string>,
267
277
  sourceNames?: string[],
278
+ sourceModelPaths?: Record<string, string>,
268
279
  ): BuildPlan {
269
280
  const include = sourceNames ? new Set(sourceNames) : null;
270
281
 
@@ -290,6 +301,7 @@ export function deriveBuildPlan(
290
301
  sql: source.getSQL(),
291
302
  columns: deriveColumns(source),
292
303
  annotationFields: deriveAnnotationFields(source),
304
+ modelPath: sourceModelPaths?.[sourceID],
293
305
  };
294
306
  }
295
307
 
@@ -313,5 +325,7 @@ export async function computePackageBuildPlan(
313
325
  compiled.graphs,
314
326
  compiled.sources,
315
327
  compiled.connectionDigests,
328
+ undefined,
329
+ compiled.sourceModelPaths,
316
330
  );
317
331
  }
@@ -1340,6 +1340,13 @@ export class Environment {
1340
1340
  explores,
1341
1341
  queryableSources,
1342
1342
  manifestLocation,
1343
+ // Carry the manifest-derived materialization policy through the
1344
+ // PATCH. `setPackageMetadata` replaces the whole object, so omitting
1345
+ // this wipes the in-memory `materialization` until the next reload —
1346
+ // which makes a later getPackage report no schedule and lets the
1347
+ // control plane misread the gap as a schedule removal. It is not a
1348
+ // PATCH-editable field, so always preserve the existing value.
1349
+ materialization: existing.materialization,
1343
1350
  });
1344
1351
 
1345
1352
  // Strict-reject, symmetric with the publish path
@@ -0,0 +1,103 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "fs/promises";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+
6
+ import { Environment } from "./environment";
7
+
8
+ /**
9
+ * The publisher must surface a non-null `materialization` object on a loaded
10
+ * package's metadata so the control plane can treat object-present as the
11
+ * authoritative manifest policy ("this is what the manifest says") and
12
+ * object-absent as "metadata not loaded this request" — never as a schedule
13
+ * removal. A metadata PATCH must not drop that policy from the in-memory
14
+ * metadata. Regression coverage for the re-materialization schedule self-wipe
15
+ * (docs/bugs/materialization-schedule-self-wipe.md).
16
+ *
17
+ * Runs against a real `Environment` + real `Package.create` over temp dirs.
18
+ */
19
+ describe("materialization schedule surfacing", () => {
20
+ const MODEL = `source: ones is duckdb.sql("SELECT 1 as x")\n`;
21
+ let rootDir: string;
22
+ let envPath: string;
23
+
24
+ async function writePackageDir(
25
+ manifest: Record<string, unknown>,
26
+ ): Promise<void> {
27
+ const dir = path.join(envPath, "pkg");
28
+ await fs.mkdir(dir, { recursive: true });
29
+ await fs.writeFile(
30
+ path.join(dir, "publisher.json"),
31
+ JSON.stringify({ name: "pkg", description: "fixture", ...manifest }),
32
+ );
33
+ await fs.writeFile(path.join(dir, "model.malloy"), MODEL);
34
+ }
35
+
36
+ beforeEach(async () => {
37
+ rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-mat-"));
38
+ envPath = path.join(rootDir, "env");
39
+ await fs.mkdir(envPath, { recursive: true });
40
+ });
41
+
42
+ afterEach(async () => {
43
+ await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
44
+ });
45
+
46
+ it(
47
+ "surfaces the manifest's materialization.schedule on load",
48
+ async () => {
49
+ const env = await Environment.create("testEnv", envPath, []);
50
+ await writePackageDir({ materialization: { schedule: "0 6 * * *" } });
51
+ await env.addPackage("pkg");
52
+
53
+ const pkg = await env.getPackage("pkg", false);
54
+ expect(pkg.getPackageMetadata().materialization).toEqual({
55
+ schedule: "0 6 * * *",
56
+ });
57
+ },
58
+ { timeout: 20000 },
59
+ );
60
+
61
+ it(
62
+ "surfaces a non-null materialization object even when the manifest declares none",
63
+ async () => {
64
+ const env = await Environment.create("testEnv", envPath, []);
65
+ await writePackageDir({});
66
+ await env.addPackage("pkg");
67
+
68
+ // Object present with a null schedule — NOT a null object — so the
69
+ // control plane reads it as an authoritative "no schedule declared",
70
+ // not as "metadata unavailable".
71
+ const pkg = await env.getPackage("pkg", false);
72
+ expect(pkg.getPackageMetadata().materialization).toEqual({
73
+ schedule: null,
74
+ });
75
+ },
76
+ { timeout: 20000 },
77
+ );
78
+
79
+ it(
80
+ "preserves the materialization policy across a metadata PATCH",
81
+ async () => {
82
+ const env = await Environment.create("testEnv", envPath, []);
83
+ await writePackageDir({ materialization: { schedule: "0 6 * * *" } });
84
+ await env.addPackage("pkg");
85
+
86
+ // A description-only PATCH must not wipe the schedule from the
87
+ // in-memory metadata. The bug: setPackageMetadata replaces the whole
88
+ // object, so a later getPackage reported no schedule and the control
89
+ // plane misread the gap as a removal and cleared the cadence.
90
+ const updated = await env.updatePackage("pkg", {
91
+ name: "pkg",
92
+ description: "updated",
93
+ });
94
+ expect(updated.materialization).toEqual({ schedule: "0 6 * * *" });
95
+
96
+ const pkg = await env.getPackage("pkg", false);
97
+ expect(pkg.getPackageMetadata().materialization).toEqual({
98
+ schedule: "0 6 * * *",
99
+ });
100
+ },
101
+ { timeout: 20000 },
102
+ );
103
+ });
@@ -331,7 +331,16 @@ export class Package {
331
331
  explores: outcome.packageMetadata.explores,
332
332
  queryableSources: outcome.packageMetadata.queryableSources,
333
333
  manifestLocation: outcome.packageMetadata.manifestLocation ?? null,
334
- materialization: outcome.packageMetadata.materialization ?? null,
334
+ // Always surface a non-null `materialization` object once the package
335
+ // has loaded (schedule null when the manifest declares no policy). The
336
+ // control plane treats object-present as the authoritative "this is
337
+ // what the manifest says" signal and object-absent as "metadata not
338
+ // available this request" — so it must never be dropped to null on a
339
+ // successfully loaded package, or the CP can misread a transient
340
+ // absence as a schedule removal. See `parsePackageMaterialization`.
341
+ materialization: outcome.packageMetadata.materialization ?? {
342
+ schedule: null,
343
+ },
335
344
  };
336
345
 
337
346
  // Build live `Model`s from worker output. Any per-model compile
@@ -0,0 +1,4 @@
1
+ <!doctype html>
2
+ <title>Bare HTML</title>
3
+ <!-- example only, must NOT opt in: <meta name="publisher:fit" content="viewport"> -->
4
+ <h1>No head or body tags; the tag appears only in a comment.</h1>
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <html>
3
+ <title>Body Meta</title>
4
+ <body>
5
+ <meta name="publisher:fit" content="viewport" />
6
+ <h1>A real fit meta in &lt;body&gt;; the head-only scan means no opt-in.</h1>
7
+ </body>
8
+ </html>
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <!-- this deck does <body> styling tricks; the literal must not truncate the scan -->
5
+ <meta name="publisher:fit" content="viewport" />
6
+ <title>Fit After Comment</title>
7
+ </head>
8
+ <body style="height: 100vh; margin: 0; overflow: hidden">
9
+ <section style="height: 100%">A full-screen slide</section>
10
+ </body>
11
+ </html>
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <html>
3
+ <title>No Head Close Tag</title>
4
+ <body>
5
+ <h1>Omits the optional &lt;/head&gt; end tag</h1>
6
+ <!-- shown in body, must NOT opt in: <meta name="publisher:fit" content="viewport"> -->
7
+ </body>
8
+ </html>
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta data-name="publisher:fit" content="viewport" />
7
+ <title>Tall Dashboard</title>
8
+ </head>
9
+ <body>
10
+ <h1>A normal dashboard that hugs its content</h1>
11
+ <!-- shown in body, must NOT opt in: <meta name="publisher:fit" content="viewport"> -->
12
+ </body>
13
+ </html>
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="publisher:fit" content="viewport" />
7
+ <title>FY27 Slide Deck</title>
8
+ </head>
9
+ <body style="height: 100vh; margin: 0; overflow: hidden">
10
+ <section style="height: 100%">A full-screen slide</section>
11
+ </body>
12
+ </html>
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Unterminated Comment</title>
5
+ <!-- never closed, must NOT opt in: <meta name="publisher:fit" content="viewport">
6
+ </head>
7
+ <body>
8
+ <h1>An unterminated comment swallows the tag.</h1>
9
+ </body>
10
+ </html>
@@ -66,6 +66,7 @@ interface PageItem {
66
66
  packageName?: string;
67
67
  path?: string;
68
68
  title?: string;
69
+ fit?: "viewport";
69
70
  }
70
71
 
71
72
  // Creating a symlink that escapes the package needs privileges the Windows CI
@@ -322,7 +323,17 @@ describe("In-package HTML data apps (E2E)", () => {
322
323
  const paths = pages.map((p) => p.path).sort();
323
324
  // Only HTML files under public/ are listed; the toEqual pins the exact
324
325
  // set, so non-public files (manifest, models, data) can't appear.
325
- expect(paths).toEqual(["index.html", "sub/page2.html"]);
326
+ expect(paths).toEqual([
327
+ "barehtml.html",
328
+ "bodymeta.html",
329
+ "fitcomment.html",
330
+ "index.html",
331
+ "nohead.html",
332
+ "notfit.html",
333
+ "slides.html",
334
+ "sub/page2.html",
335
+ "unterminated.html",
336
+ ]);
326
337
 
327
338
  const index = pages.find((p) => p.path === "index.html");
328
339
  expect(index?.title).toBe("Carrier Dashboard");
@@ -332,6 +343,57 @@ describe("In-package HTML data apps (E2E)", () => {
332
343
  );
333
344
  });
334
345
 
346
+ it("surfaces fit=viewport only for pages that opt in via <meta name=publisher:fit>", async () => {
347
+ const res = await fetch(apiUrl("/pages"));
348
+ expect(res.status).toBe(200);
349
+ const pages = (await res.json()) as PageItem[];
350
+
351
+ // slides.html opts in with <meta name="publisher:fit" content="viewport">
352
+ // (alongside a standard charset + viewport meta) → fill the embedded viewer.
353
+ expect(pages.find((p) => p.path === "slides.html")?.fit).toBe("viewport");
354
+
355
+ // fitcomment.html has a real <meta publisher:fit> AFTER a comment that
356
+ // contains the literal "<body>"; stripping comments before locating the
357
+ // </head>/<body> boundary must keep that literal from truncating the scan
358
+ // and hiding the real tag (it must still opt in).
359
+ expect(pages.find((p) => p.path === "fitcomment.html")?.fit).toBe(
360
+ "viewport",
361
+ );
362
+
363
+ // notfit.html carries the STANDARD <meta name="viewport"> (device-width),
364
+ // a decoy <meta data-name="publisher:fit"> in <head>, and the real tag as
365
+ // text in a <body> comment. None is a genuine <head> name=publisher:fit,
366
+ // so it must NOT be mistaken for an opt-in.
367
+ expect(pages.find((p) => p.path === "notfit.html")?.fit).toBeUndefined();
368
+
369
+ // A page with no relevant meta keeps content-height auto-sizing (the
370
+ // field is omitted, never `null`/`"content"`).
371
+ expect(pages.find((p) => p.path === "index.html")?.fit).toBeUndefined();
372
+
373
+ // nohead.html omits the optional </head> end tag and shows the tag in a
374
+ // <body> comment; the scan stops at <body>, so it must NOT opt in.
375
+ expect(pages.find((p) => p.path === "nohead.html")?.fit).toBeUndefined();
376
+
377
+ // barehtml.html omits BOTH </head> and <body> and shows the tag only in
378
+ // an HTML comment; comment-stripping must keep it from opting in.
379
+ expect(
380
+ pages.find((p) => p.path === "barehtml.html")?.fit,
381
+ ).toBeUndefined();
382
+
383
+ // bodymeta.html has a REAL (uncommented) <meta publisher:fit> but in
384
+ // <body> and omits </head>, so the head-only scan must stop at <body>
385
+ // and not opt in (locks the <body> boundary).
386
+ expect(
387
+ pages.find((p) => p.path === "bodymeta.html")?.fit,
388
+ ).toBeUndefined();
389
+
390
+ // unterminated.html wraps the tag in a comment with no closing -->; the
391
+ // unterminated-comment strip must still keep it from opting in.
392
+ expect(
393
+ pages.find((p) => p.path === "unterminated.html")?.fit,
394
+ ).toBeUndefined();
395
+ });
396
+
335
397
  it("400s a malformed environment/package name on /pages", async () => {
336
398
  // getEnvironment runs assertSafePackageName, so a name outside
337
399
  // IdentifierPattern is a 400 (now documented on list-pages in api-doc).