@malloy-publisher/server 0.0.204 → 0.0.206

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 (100) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +494 -397
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  4. package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
  7. package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/instrumentation.mjs +18 -8
  18. package/dist/package_load_worker.mjs +19 -2
  19. package/dist/runtime/publisher.js +318 -0
  20. package/dist/server.mjs +1703 -1443
  21. package/package.json +5 -4
  22. package/scripts/bake-duckdb-extensions.js +104 -0
  23. package/src/constants.ts +12 -0
  24. package/src/controller/materialization.controller.ts +79 -35
  25. package/src/controller/package.controller.spec.ts +179 -0
  26. package/src/controller/package.controller.ts +60 -73
  27. package/src/controller/watch-mode.controller.ts +176 -46
  28. package/src/dto/package.dto.ts +16 -1
  29. package/src/errors.spec.ts +33 -0
  30. package/src/errors.ts +18 -0
  31. package/src/health.spec.ts +34 -1
  32. package/src/instrumentation.ts +33 -17
  33. package/src/materialization_metrics.ts +66 -0
  34. package/src/mcp/error_messages.spec.ts +35 -0
  35. package/src/mcp/error_messages.ts +14 -1
  36. package/src/mcp/handler_utils.ts +12 -0
  37. package/src/package_load/package_load_pool.ts +7 -1
  38. package/src/package_load/package_load_worker.ts +44 -4
  39. package/src/package_load/protocol.ts +7 -1
  40. package/src/runtime/publisher.js +318 -0
  41. package/src/server-old.ts +7 -149
  42. package/src/server.ts +488 -190
  43. package/src/service/authorize_integration.spec.ts +163 -2
  44. package/src/service/compile_authorize.spec.ts +85 -0
  45. package/src/service/environment.ts +270 -12
  46. package/src/service/environment_store.spec.ts +0 -81
  47. package/src/service/environment_store.ts +142 -34
  48. package/src/service/explore_visibility.spec.ts +434 -0
  49. package/src/service/exports_probe.spec.ts +107 -0
  50. package/src/service/manifest_loader.spec.ts +99 -0
  51. package/src/service/manifest_loader.ts +95 -0
  52. package/src/service/materialization_service.spec.ts +324 -512
  53. package/src/service/materialization_service.ts +816 -656
  54. package/src/service/model.ts +444 -13
  55. package/src/service/package.spec.ts +14 -2
  56. package/src/service/package.ts +271 -20
  57. package/src/service/package_rollback.spec.ts +190 -0
  58. package/src/service/package_worker_path.spec.ts +223 -0
  59. package/src/service/query_boundary.spec.ts +470 -0
  60. package/src/storage/DatabaseInterface.ts +35 -57
  61. package/src/storage/StorageManager.mock.ts +0 -9
  62. package/src/storage/StorageManager.ts +7 -290
  63. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  64. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  65. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  66. package/src/storage/duckdb/schema.ts +4 -20
  67. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  68. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  69. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  70. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/data.csv +3 -0
  72. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  73. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  74. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  75. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  76. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  77. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  78. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  79. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  80. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  81. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  82. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  83. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  84. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  85. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  86. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  87. package/tests/unit/duckdb/repositories.test.ts +208 -0
  88. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  89. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  90. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
  91. package/src/controller/manifest.controller.ts +0 -38
  92. package/src/service/manifest_service.spec.ts +0 -206
  93. package/src/service/manifest_service.ts +0 -117
  94. package/src/service/materialized_table_gc.spec.ts +0 -384
  95. package/src/service/materialized_table_gc.ts +0 -231
  96. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  97. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  98. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  99. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  100. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Centralized telemetry for the two-round materialization protocol.
3
+ *
4
+ * Operators need to answer "are builds completing, how long do the two
5
+ * rounds take, and where are they failing?" without grepping logs. The
6
+ * counter carries `round` (`round1`|`round2`) and `outcome`
7
+ * (`success`|`failed`|`cancelled`); the histogram carries `round` so a
8
+ * dashboard can render Round 1 (compile+plan) vs Round 2 (build) latency
9
+ * side by side.
10
+ *
11
+ * Lazy init for the same reason as {@link ./query_cap_metrics}: instruments
12
+ * created before `setGlobalMeterProvider` bind to a NoOp meter
13
+ * (https://github.com/open-telemetry/opentelemetry-js/issues/3505).
14
+ */
15
+
16
+ import { metrics, type Counter, type Histogram } from "@opentelemetry/api";
17
+
18
+ export type MaterializationRound = "round1" | "round2" | "auto";
19
+ export type MaterializationOutcome = "success" | "failed" | "cancelled";
20
+
21
+ let roundCounter: Counter | null = null;
22
+ let roundDuration: Histogram | null = null;
23
+
24
+ function ensureTelemetry(): { counter: Counter; duration: Histogram } {
25
+ if (roundCounter && roundDuration) {
26
+ return { counter: roundCounter, duration: roundDuration };
27
+ }
28
+ const meter = metrics.getMeter("publisher");
29
+ if (!roundCounter) {
30
+ roundCounter = meter.createCounter(
31
+ "publisher_materialization_rounds_total",
32
+ {
33
+ description:
34
+ "Materialization rounds completed. Labels: round ('round1'|'round2'|'auto'), outcome ('success'|'failed'|'cancelled').",
35
+ },
36
+ );
37
+ }
38
+ if (!roundDuration) {
39
+ roundDuration = meter.createHistogram(
40
+ "publisher_materialization_round_duration_ms",
41
+ {
42
+ description:
43
+ "Wall-clock duration of a materialization round. Label: round ('round1'|'round2'|'auto').",
44
+ unit: "ms",
45
+ },
46
+ );
47
+ }
48
+ return { counter: roundCounter, duration: roundDuration };
49
+ }
50
+
51
+ /** Record the outcome and duration of a materialization round. */
52
+ export function recordMaterializationRound(
53
+ round: MaterializationRound,
54
+ outcome: MaterializationOutcome,
55
+ durationMs: number,
56
+ ): void {
57
+ const { counter, duration } = ensureTelemetry();
58
+ counter.add(1, { round, outcome });
59
+ duration.record(durationMs, { round });
60
+ }
61
+
62
+ /** Visible for tests. Drops cached instruments so a fresh MeterProvider can capture emissions. */
63
+ export function resetMaterializationTelemetryForTesting(): void {
64
+ roundCounter = null;
65
+ roundDuration = null;
66
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { AccessDeniedError } from "../errors";
3
+ import { getMalloyErrorDetails } from "./error_messages";
4
+
5
+ describe("getMalloyErrorDetails — access-denied branch", () => {
6
+ it("recognizes an authorize denial and gives access-relevant (not syntax) advice", () => {
7
+ const details = getMalloyErrorDetails(
8
+ "executeQuery",
9
+ "env/pkg/model.malloy",
10
+ new AccessDeniedError('Access denied for source "gated".'),
11
+ );
12
+
13
+ // Message carries the source name, never the gate expression.
14
+ expect(details.message).toContain('Access denied for source "gated".');
15
+
16
+ // The suggestion is about satisfying access (givens/role), and the
17
+ // generic Malloy-syntax suggestions are replaced, not appended.
18
+ expect(details.suggestions).toHaveLength(1);
19
+ expect(details.suggestions[0]).toMatch(/given|authorize|restricted/i);
20
+ // Not the generic "check the database connection / consult the language
21
+ // docs" advice that an unrecognized error would yield.
22
+ expect(details.suggestions.join(" ")).not.toMatch(
23
+ /database connection configuration|language documentation/i,
24
+ );
25
+ });
26
+
27
+ it("still falls back to generic suggestions for an unrecognized error", () => {
28
+ const details = getMalloyErrorDetails(
29
+ "executeQuery",
30
+ "env/pkg/model.malloy",
31
+ new Error("something unexpected"),
32
+ );
33
+ expect(details.suggestions.length).toBeGreaterThan(1);
34
+ });
35
+ });
@@ -126,8 +126,21 @@ export function getMalloyErrorDetails(
126
126
  const invalidRequestMatch = error.message.match(
127
127
  /Invalid query request\\. Query OR queryName must be defined/i,
128
128
  );
129
+ // `#(authorize)` gate denial. The message names only the source (gate
130
+ // logic is never leaked), so the suggestion is about satisfying access,
131
+ // not fixing syntax — otherwise this falls through to generic Malloy
132
+ // syntax advice that misleads the caller.
133
+ const accessDeniedMatch = error.message.match(
134
+ /Access denied for source "([^"]+)"/i,
135
+ );
129
136
 
130
- if (viewNotFoundMatch) {
137
+ if (accessDeniedMatch) {
138
+ refined = true;
139
+ const [, sourceName] = accessDeniedMatch;
140
+ suggestions = [
141
+ `Suggestion: Access to source '${sourceName}' is restricted by an #(authorize) gate. Supply the givens its authorize expression requires (e.g. a role/region given) and retry. This is an authorization denial, not a syntax error.`,
142
+ ];
143
+ } else if (viewNotFoundMatch) {
131
144
  refined = true;
132
145
  const [, viewName, sourceName] = viewNotFoundMatch;
133
146
  suggestions.unshift(
@@ -3,6 +3,7 @@ import type { ResourceMetadata } from "@modelcontextprotocol/sdk/server/mcp";
3
3
  import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { EnvironmentStore } from "../service/environment_store";
5
5
  import {
6
+ AccessDeniedError,
6
7
  PackageNotFoundError,
7
8
  ModelNotFoundError,
8
9
  ModelCompilationError,
@@ -167,6 +168,17 @@ export async function getModelForQuery(
167
168
  `${environmentName}/${packageName}/${modelPath}`,
168
169
  error,
169
170
  );
171
+ } else if (error instanceof AccessDeniedError) {
172
+ // An #(authorize) denial during model setup. Funnel through
173
+ // getMalloyErrorDetails (which recognizes the access-denied message and
174
+ // gives supply-the-givens guidance) so a 403 never surfaces as an
175
+ // opaque internal error. Defensive: the gate fires during query
176
+ // execution today, not setup, but this keeps every error class homed.
177
+ errorDetails = getMalloyErrorDetails(
178
+ "executeQuery (load model)",
179
+ `${environmentName}/${packageName}/${modelPath}`,
180
+ error,
181
+ );
170
182
  } else if (error instanceof ServiceUnavailableError) {
171
183
  // Back-pressure: don't dress this up as a 404/500. Surface the
172
184
  // server's own message so the MCP caller knows to retry.
@@ -228,7 +228,13 @@ export interface LoadPackageJob {
228
228
  * don't allocate a Map for models the caller never looks at.
229
229
  */
230
230
  export interface LoadPackageOutcome {
231
- packageMetadata: { name?: string; description?: string };
231
+ packageMetadata: {
232
+ name?: string;
233
+ description?: string;
234
+ explores?: string[];
235
+ queryableSources?: "declared" | "all";
236
+ manifestLocation?: string | null;
237
+ };
232
238
  models: Array<
233
239
  Omit<SerializedModel, "modelDef" | "sourceInfos"> & {
234
240
  modelDef?: ModelDef;
@@ -75,6 +75,7 @@ import { fileURLToPath, pathToFileURL } from "url";
75
75
 
76
76
  import {
77
77
  MODEL_FILE_SUFFIX,
78
+ normalizeModelPath,
78
79
  NOTEBOOK_FILE_SUFFIX,
79
80
  PACKAGE_MANIFEST_NAME,
80
81
  } from "../constants";
@@ -375,16 +376,38 @@ function buildWorkerMalloyConfig(job: LoadPackageRequest): MalloyConfig {
375
376
  // stays decoupled from the main-thread service module graph)
376
377
  // ──────────────────────────────────────────────────────────────────────
377
378
 
378
- async function readPackageMetadata(
379
- packagePath: string,
380
- ): Promise<{ name?: string; description?: string }> {
379
+ async function readPackageMetadata(packagePath: string): Promise<{
380
+ name?: string;
381
+ description?: string;
382
+ explores?: string[];
383
+ queryableSources?: "declared" | "all";
384
+ manifestLocation?: string | null;
385
+ }> {
381
386
  const manifestPath = path.join(packagePath, PACKAGE_MANIFEST_NAME);
382
387
  const contents = await fs.promises.readFile(manifestPath, "utf8");
383
388
  const parsed = JSON.parse(contents) as {
384
389
  name?: string;
385
390
  description?: string;
391
+ explores?: string[];
392
+ queryableSources?: unknown;
393
+ manifestLocation?: unknown;
394
+ };
395
+ return {
396
+ name: parsed.name,
397
+ description: parsed.description,
398
+ explores: Array.isArray(parsed.explores)
399
+ ? parsed.explores.map(normalizeModelPath)
400
+ : undefined,
401
+ // Default + invalid fall back to "declared" (fail-safe: queryable ==
402
+ // discoverable). Only an explicit "all" opts out of the query boundary.
403
+ queryableSources: parsed.queryableSources === "all" ? "all" : "declared",
404
+ // URI (gs://, s3://, file://, or local path) of the control-plane-computed
405
+ // build manifest. The main thread fetches + binds it after load.
406
+ manifestLocation:
407
+ typeof parsed.manifestLocation === "string"
408
+ ? parsed.manifestLocation
409
+ : null,
386
410
  };
387
- return { name: parsed.name, description: parsed.description };
388
411
  }
389
412
 
390
413
  async function listPackageFiles(packagePath: string): Promise<string[]> {
@@ -400,6 +423,18 @@ function filterModelPaths(allRelative: string[]): string[] {
400
423
  );
401
424
  }
402
425
 
426
+ // `normalizeModelPath` (shared, from ../constants) runs here at parse time so
427
+ // the keys stored in Package.models are already normalized, and the API
428
+ // publish/update path normalizes its `explores` input through the same helper —
429
+ // so on-disk and API-written manifests share one representation.
430
+
431
+ // explores validation is intentionally NOT done here. The worker is the
432
+ // shared load path (startup, reload, AND publish), but the policy differs by
433
+ // context — strict-reject at publish, warn-and-fail-safe at load — so it lives
434
+ // on the main thread (see Package.getInvalidExplores / loadViaWorker and the
435
+ // publish path in package.controller). An unmatched entry simply lists nothing
436
+ // (listModels filters by set membership), which is the fail-safe outcome.
437
+
403
438
  // ──────────────────────────────────────────────────────────────────────
404
439
  // Model compile (mirrors service/model.ts but produces SerializedModel)
405
440
  // ──────────────────────────────────────────────────────────────────────
@@ -558,6 +593,10 @@ async function compileMalloyModel(
558
593
  modelDef,
559
594
  modelInfo: modelDefToModelInfo(modelDef),
560
595
  sourceInfos,
596
+ // `sources`/`queries` ship complete (authorize + filter enforcement and
597
+ // join resolution read the full set); the Model's discovery accessors
598
+ // filter them down to the export closure (`modelDef.exports`) to match
599
+ // `modelInfo`/`sourceInfos`.
561
600
  sources,
562
601
  queries,
563
602
  filterMap: Array.from(filterMap.entries()),
@@ -800,6 +839,7 @@ async function loadPackage(
800
839
 
801
840
  const allFiles = await listPackageFiles(job.packagePath);
802
841
  const modelPaths = filterModelPaths(allFiles);
842
+
803
843
  const models = await Promise.all(
804
844
  modelPaths.map((modelPath) =>
805
845
  compileOneModel(job, malloyConfig, modelPath),
@@ -173,7 +173,13 @@ export interface SerializedNotebookCell {
173
173
  export interface LoadPackageResult {
174
174
  type: "load-package-result";
175
175
  requestId: string;
176
- packageMetadata: { name?: string; description?: string };
176
+ packageMetadata: {
177
+ name?: string;
178
+ description?: string;
179
+ explores?: string[];
180
+ queryableSources?: "declared" | "all";
181
+ manifestLocation?: string | null;
182
+ };
177
183
  models: SerializedModel[];
178
184
  /** Wall-clock ms inside the worker for the full package load. */
179
185
  loadDurationMs: number;
@@ -0,0 +1,318 @@
1
+ // Publisher runtime helper for in-package HTML dashboards.
2
+ // Served by the Publisher server at /sdk/publisher.js. Hand-authored vanilla
3
+ // JS — no bundler. Loaded via <script src="/sdk/publisher.js">.
4
+ //
5
+ // Exposes window.Publisher with:
6
+ // - Publisher.query(model, malloy, opts?) → Promise<rows[]>
7
+ // - Publisher.queryFull(model, malloy, opts?) → Promise<MalloyResult> (envelope for <malloy-render>)
8
+ // - Publisher.embed(selector, { src, height?, token? })
9
+ // - Publisher.context ({ environment, package } inferred from URL)
10
+ // - Publisher.setToken(token) (override Bearer token; default uses cookies)
11
+ //
12
+ // When loaded inside an iframe served from /environments/<env>/packages/<pkg>/...,
13
+ // the runtime auto-subscribes to a Server-Sent Events live-reload stream
14
+ // (GET .../events) and reloads the page on file changes. It also posts size
15
+ // updates to the parent window so Publisher.embed() in the host can resize
16
+ // the iframe.
17
+ //
18
+ // The "publisher:resize" postMessage protocol below is the SAME contract the
19
+ // SPA host consumes. Its canonical definition lives in
20
+ // packages/sdk/src/utils/pageEmbed.ts (PUBLISHER_RESIZE_MESSAGE_TYPE /
21
+ // PublisherResizeMessage). This file is build-step-free vanilla JS and can't
22
+ // import it, so keep the message type/shape here in sync with that module.
23
+
24
+ (function () {
25
+ "use strict";
26
+
27
+ // --- Context inference -------------------------------------------------
28
+ // URL shape: /environments/<env>/packages/<pkg>/<file>
29
+ //
30
+ // location.pathname is URL-encoded, so we MUST decode the captured
31
+ // segments here. Without this step, a name with a space (e.g.
32
+ // "demo env") would arrive as "demo%20env" — and the encodeURIComponent
33
+ // we apply when building API URLs (below) would produce "demo%2520env",
34
+ // which Publisher then 404s on.
35
+ var pathMatch = location.pathname.match(
36
+ /^\/environments\/([^/]+)\/packages\/([^/]+)\//,
37
+ );
38
+ function safeDecode(s) {
39
+ try {
40
+ return decodeURIComponent(s);
41
+ } catch (_e) {
42
+ return s;
43
+ }
44
+ }
45
+ var ctx = pathMatch
46
+ ? {
47
+ environment: safeDecode(pathMatch[1]),
48
+ package: safeDecode(pathMatch[2]),
49
+ }
50
+ : {};
51
+
52
+ var apiBase = location.origin + "/api/v0";
53
+ var bearerToken = null;
54
+
55
+ function authHeaders() {
56
+ return bearerToken ? { Authorization: "Bearer " + bearerToken } : {};
57
+ }
58
+
59
+ // --- Query helpers -----------------------------------------------------
60
+ function resolveTarget(opts) {
61
+ var env = (opts && opts.environment) || ctx.environment;
62
+ var pkg = (opts && opts.package) || ctx.package;
63
+ if (!env || !pkg) {
64
+ throw new Error(
65
+ "Publisher: no environment/package; either serve the page from " +
66
+ "/environments/<env>/packages/<pkg>/... or pass { environment, package } in opts.",
67
+ );
68
+ }
69
+ return { env: env, pkg: pkg };
70
+ }
71
+
72
+ async function rawQuery(modelPath, malloyQuery, opts, compactJson) {
73
+ opts = opts || {};
74
+ var target = resolveTarget(opts);
75
+ var url =
76
+ apiBase +
77
+ "/environments/" +
78
+ encodeURIComponent(target.env) +
79
+ "/packages/" +
80
+ encodeURIComponent(target.pkg) +
81
+ "/models/" +
82
+ modelPath.split("/").map(encodeURIComponent).join("/") +
83
+ "/query";
84
+ var body = { compactJson: compactJson };
85
+ if (malloyQuery) body.query = malloyQuery;
86
+ if (opts.sourceName) body.sourceName = opts.sourceName;
87
+ if (opts.queryName) body.queryName = opts.queryName;
88
+ if (opts.filterParams) body.filterParams = opts.filterParams;
89
+ if (opts.bypassFilters) body.bypassFilters = true;
90
+
91
+ var headers = Object.assign(
92
+ { "content-type": "application/json" },
93
+ authHeaders(),
94
+ );
95
+ var res = await fetch(url, {
96
+ method: "POST",
97
+ credentials: "include",
98
+ headers: headers,
99
+ body: JSON.stringify(body),
100
+ });
101
+ var json;
102
+ try {
103
+ json = await res.json();
104
+ } catch (_e) {
105
+ throw new Error(
106
+ "Publisher: server returned non-JSON response (" + res.status + ")",
107
+ );
108
+ }
109
+ if (!res.ok) {
110
+ var msg = (json && json.message) || res.statusText || "Query failed";
111
+ var err = new Error("Publisher.query: " + msg);
112
+ err.response = json;
113
+ err.status = res.status;
114
+ throw err;
115
+ }
116
+ // The server's QueryResult always has `result` as a JSON-encoded string.
117
+ // Parse it before handing it back so callers see real JS values.
118
+ return JSON.parse(json.result);
119
+ }
120
+
121
+ function query(modelPath, malloyQuery, opts) {
122
+ return rawQuery(modelPath, malloyQuery, opts, true);
123
+ }
124
+ function queryFull(modelPath, malloyQuery, opts) {
125
+ return rawQuery(modelPath, malloyQuery, opts, false);
126
+ }
127
+
128
+ // --- Embed helper (host page) -----------------------------------------
129
+ function embed(selector, options) {
130
+ options = options || {};
131
+ var host =
132
+ typeof selector === "string"
133
+ ? document.querySelector(selector)
134
+ : selector;
135
+ if (!host) {
136
+ throw new Error("Publisher.embed: selector did not match an element");
137
+ }
138
+ if (!options.src) {
139
+ throw new Error("Publisher.embed: opts.src is required");
140
+ }
141
+ var iframe = document.createElement("iframe");
142
+ iframe.src = options.token
143
+ ? options.src +
144
+ (options.src.indexOf("?") === -1 ? "?" : "&") +
145
+ "embed_token=" +
146
+ encodeURIComponent(options.token)
147
+ : options.src;
148
+ iframe.style.border = "0";
149
+ iframe.style.width = "100%";
150
+ iframe.style.display = "block";
151
+ if (options.height) {
152
+ iframe.style.height =
153
+ typeof options.height === "number"
154
+ ? options.height + "px"
155
+ : options.height;
156
+ } else {
157
+ iframe.style.height = "0px"; // will be sized via postMessage
158
+ }
159
+ if (options.allow) iframe.allow = options.allow;
160
+ iframe.setAttribute(
161
+ "sandbox",
162
+ "allow-scripts allow-same-origin allow-forms",
163
+ );
164
+
165
+ // Resize listener
166
+ function onMessage(e) {
167
+ if (!e.data || e.data.type !== "publisher:resize") return;
168
+ if (e.source !== iframe.contentWindow) return;
169
+ if (typeof e.data.height === "number") {
170
+ iframe.style.height = Math.max(0, e.data.height) + "px";
171
+ }
172
+ }
173
+ window.addEventListener("message", onMessage);
174
+ // Best-effort cleanup if the host removes the iframe
175
+ var observer = new MutationObserver(function () {
176
+ if (!host.contains(iframe)) {
177
+ window.removeEventListener("message", onMessage);
178
+ observer.disconnect();
179
+ }
180
+ });
181
+ observer.observe(host, { childList: true, subtree: false });
182
+
183
+ host.appendChild(iframe);
184
+ return {
185
+ iframe: iframe,
186
+ destroy: function () {
187
+ window.removeEventListener("message", onMessage);
188
+ observer.disconnect();
189
+ if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
190
+ },
191
+ };
192
+ }
193
+
194
+ // --- When this runtime is itself inside an iframe ---------------------
195
+ // Post size updates upstream + listen for live-reload SSE events.
196
+ function setUpEmbeddedSelfBehaviors() {
197
+ var inIframe = (function () {
198
+ try {
199
+ return window.self !== window.top;
200
+ } catch (_e) {
201
+ return true; // cross-origin parent — assume embedded
202
+ }
203
+ })();
204
+
205
+ if (inIframe) {
206
+ var lastHeight = -1;
207
+ function measureContentHeight() {
208
+ // We want the "ink height" — where the last piece of visible
209
+ // content ends. NOT document.body.scrollHeight: any rule like
210
+ // `body { min-height: 100vh }` (extremely common in dashboards
211
+ // that look nice standalone) inflates scrollHeight to match
212
+ // whatever the iframe's current viewport is, creating a
213
+ // feedback loop where the iframe ratchets up but never shrinks.
214
+ //
215
+ // Sum the lowest bottom edge across body's children, in
216
+ // document coordinates. This ignores body padding, min-height,
217
+ // and CSS that just fills the viewport.
218
+ var body = document.body;
219
+ if (!body) return document.documentElement.scrollHeight;
220
+ var maxBottom = 0;
221
+ var kids = body.children;
222
+ for (var i = 0; i < kids.length; i++) {
223
+ var rect = kids[i].getBoundingClientRect();
224
+ if (rect.bottom > maxBottom) maxBottom = rect.bottom;
225
+ }
226
+ if (maxBottom <= 0) {
227
+ // Fallback for empty body / hidden children
228
+ return document.documentElement.scrollHeight;
229
+ }
230
+ var scrollTop =
231
+ window.scrollY ||
232
+ document.documentElement.scrollTop ||
233
+ document.body.scrollTop ||
234
+ 0;
235
+ // Add body bottom padding (rect.bottom is content-box bottom,
236
+ // body padding isn't part of any child's rect).
237
+ var bodyStyle = window.getComputedStyle(body);
238
+ var pad = parseFloat(bodyStyle.paddingBottom) || 0;
239
+ return Math.ceil(maxBottom + scrollTop + pad);
240
+ }
241
+ function postSize() {
242
+ var h = measureContentHeight();
243
+ if (h !== lastHeight) {
244
+ lastHeight = h;
245
+ try {
246
+ window.parent.postMessage(
247
+ { type: "publisher:resize", height: h },
248
+ "*",
249
+ );
250
+ } catch (_e) {
251
+ /* ignore */
252
+ }
253
+ }
254
+ }
255
+ // Initial + observe content changes
256
+ if (document.readyState === "loading") {
257
+ document.addEventListener("DOMContentLoaded", postSize);
258
+ } else {
259
+ postSize();
260
+ }
261
+ window.addEventListener("load", postSize);
262
+ if (typeof ResizeObserver !== "undefined") {
263
+ var ro = new ResizeObserver(postSize);
264
+ // Observe documentElement so we catch any layout change
265
+ ro.observe(document.documentElement);
266
+ } else {
267
+ // Fallback: poll once a second
268
+ setInterval(postSize, 1000);
269
+ }
270
+ }
271
+ }
272
+
273
+ // --- SSE live reload --------------------------------------------------
274
+ function setUpLiveReload() {
275
+ if (!ctx.environment || !ctx.package) return;
276
+ if (typeof EventSource === "undefined") return;
277
+ var url =
278
+ apiBase +
279
+ "/environments/" +
280
+ encodeURIComponent(ctx.environment) +
281
+ "/packages/" +
282
+ encodeURIComponent(ctx.package) +
283
+ "/events";
284
+ try {
285
+ var es = new EventSource(url, { withCredentials: true });
286
+ var pending = false;
287
+ es.addEventListener("changed", function () {
288
+ if (pending) return;
289
+ pending = true;
290
+ // Tiny debounce to coalesce a flurry of saves
291
+ setTimeout(function () {
292
+ location.reload();
293
+ }, 100);
294
+ });
295
+ es.onerror = function () {
296
+ // Browser will auto-reconnect; nothing to do.
297
+ };
298
+ } catch (_e) {
299
+ // SSE may be blocked (e.g. corp proxy) — non-fatal.
300
+ }
301
+ }
302
+
303
+ // --- Public API --------------------------------------------------------
304
+ window.Publisher = {
305
+ query: query,
306
+ queryFull: queryFull,
307
+ embed: embed,
308
+ context: ctx,
309
+ setToken: function (token) {
310
+ bearerToken = token || null;
311
+ },
312
+ };
313
+
314
+ // Auto-init the in-iframe behaviors and live-reload subscription.
315
+ // Both are no-ops if not applicable.
316
+ setUpEmbeddedSelfBehaviors();
317
+ setUpLiveReload();
318
+ })();