@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.
- package/build.ts +10 -1
- package/dist/app/api-doc.yaml +494 -397
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
- package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +1703 -1443
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +33 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +66 -0
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/runtime/publisher.js +318 -0
- package/src/server-old.ts +7 -149
- package/src/server.ts +488 -190
- package/src/service/authorize_integration.spec.ts +163 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +142 -34
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +444 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +271 -20
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
- package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
- package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- 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 (
|
|
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(
|
package/src/mcp/handler_utils.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
380
|
-
|
|
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: {
|
|
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
|
+
})();
|