@malloy-publisher/server 0.0.223 → 0.0.225
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/api-doc.yaml +75 -0
- package/dist/app/assets/{EnvironmentPage-3wCdljov.js → EnvironmentPage-DYTeXDll.js} +1 -1
- package/dist/app/assets/{HomePage-K0GHwqq2.js → HomePage-pDK2BPJY.js} +1 -1
- package/dist/app/assets/{LightMode-CwU3kN4I.js → LightMode-C2bwGPY1.js} +1 -1
- package/dist/app/assets/{MainPage-CTncHE5T.js → MainPage-WtBulMH_.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-DziAOD6-.js → MaterializationsPage-hMgOtflG.js} +1 -1
- package/dist/app/assets/{ModelPage-XrS2jXEc.js → ModelPage-B2N5kYII.js} +1 -1
- package/dist/app/assets/{PackagePage-BkwgFxG8.js → PackagePage-CEN90nQG.js} +1 -1
- package/dist/app/assets/{RouteError-9cIQga6p.js → RouteError-BG2c5Zf0.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-DWiCRfEe.js → ThemeEditorPage-DNfeUwEZ.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Ce7FM_Po.js → WorkbookPage-NKI1BhFS.js} +1 -1
- package/dist/app/assets/{core-D9Hl0IDY.es-CrO01m4X.js → core-C6anj5c0.es-DDLHqpzt.js} +1 -1
- package/dist/app/assets/{index-D2sm5RBA.js → index-C6gZ6sSY.js} +5 -5
- package/dist/app/assets/{index-CtQm7kvp.js → index-CzNqKMVl.js} +1 -1
- package/dist/app/assets/{index-wJU_kzl2.js → index-DMQtnaf4.js} +2 -2
- package/dist/app/assets/{index-Dz8hgkmy.js → index-JXgvyZna.js} +1 -1
- package/dist/app/assets/{index-8E2uLeV9.js → index-OEjKNSYb.js} +2 -2
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +411 -26
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +12 -12
- package/src/controller/materialization.controller.spec.ts +72 -0
- package/src/controller/materialization.controller.ts +75 -11
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/service/build_plan.spec.ts +119 -0
- package/src/service/build_plan.ts +143 -0
- package/src/service/environment.ts +3 -3
- package/src/service/freshness.spec.ts +183 -0
- package/src/service/freshness.ts +112 -0
- package/src/service/manifest_loader.spec.ts +33 -0
- package/src/service/manifest_loader.ts +17 -8
- package/src/service/materialization_cron_gate.spec.ts +61 -17
- package/src/service/materialization_service.spec.ts +42 -0
- package/src/service/materialization_service.ts +46 -3
- package/src/service/materialization_test_fixtures.ts +47 -14
- package/src/service/model.ts +54 -4
- package/src/service/package.ts +173 -35
- package/src/storage/DatabaseInterface.ts +24 -0
- package/tests/fixtures/persist-multi-level/data/orders.csv +5 -0
- package/tests/fixtures/persist-multi-level/multi_level.malloy +18 -0
- package/tests/fixtures/persist-multi-level/publisher.json +5 -0
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +292 -0
- package/tests/integration/materialization/reference_manifest.integration.spec.ts +251 -0
package/dist/server.mjs
CHANGED
|
@@ -158407,6 +158407,11 @@ var require_utils74 = __commonJS((exports, module) => {
|
|
|
158407
158407
|
};
|
|
158408
158408
|
});
|
|
158409
158409
|
|
|
158410
|
+
// ../../node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
|
|
158411
|
+
var require_sshcrypto = __commonJS((exports, module) => {
|
|
158412
|
+
module.exports = __require("./sshcrypto-8m50vnmb.node");
|
|
158413
|
+
});
|
|
158414
|
+
|
|
158410
158415
|
// ../../node_modules/ssh2/lib/protocol/crypto/poly1305.js
|
|
158411
158416
|
var require_poly1305 = __commonJS((exports, module) => {
|
|
158412
158417
|
var __dirname = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto", __filename = "/home/runner/work/publisher/publisher/node_modules/ssh2/lib/protocol/crypto/poly1305.js";
|
|
@@ -158893,7 +158898,7 @@ var require_crypto = __commonJS((exports, module) => {
|
|
|
158893
158898
|
var ChaChaPolyDecipher;
|
|
158894
158899
|
var GenericDecipher;
|
|
158895
158900
|
try {
|
|
158896
|
-
binding = (
|
|
158901
|
+
binding = require_sshcrypto();
|
|
158897
158902
|
({
|
|
158898
158903
|
AESGCMCipher,
|
|
158899
158904
|
ChaChaPolyCipher,
|
|
@@ -256953,7 +256958,12 @@ async function fetchManifestEntries(uri) {
|
|
|
256953
256958
|
});
|
|
256954
256959
|
continue;
|
|
256955
256960
|
}
|
|
256956
|
-
entries[sourceEntityId] = {
|
|
256961
|
+
entries[sourceEntityId] = {
|
|
256962
|
+
tableName: physicalTableName,
|
|
256963
|
+
dataAsOf: entry.dataAsOf,
|
|
256964
|
+
freshnessWindowSeconds: entry.freshnessWindowSeconds,
|
|
256965
|
+
freshnessFallback: entry.freshnessFallback
|
|
256966
|
+
};
|
|
256957
256967
|
}
|
|
256958
256968
|
return entries;
|
|
256959
256969
|
}
|
|
@@ -257537,6 +257547,7 @@ class Model {
|
|
|
257537
257547
|
fileLevelAuthorize = [];
|
|
257538
257548
|
discoveryCurationEnabled = false;
|
|
257539
257549
|
queryBoundary = { mode: "all", exploresDeclared: false, isQueryEntryPoint: true };
|
|
257550
|
+
freshnessResolver;
|
|
257540
257551
|
meter = publisherMeter();
|
|
257541
257552
|
queryExecutionHistogram = this.meter.createHistogram("malloy_model_query_duration", {
|
|
257542
257553
|
description: "How long it takes to execute a Malloy model query",
|
|
@@ -257749,6 +257760,13 @@ class Model {
|
|
|
257749
257760
|
setDiscoveryCuration(enabled) {
|
|
257750
257761
|
this.discoveryCurationEnabled = enabled;
|
|
257751
257762
|
}
|
|
257763
|
+
setFreshnessResolver(resolver) {
|
|
257764
|
+
this.freshnessResolver = resolver;
|
|
257765
|
+
}
|
|
257766
|
+
resolveFreshBuildManifest() {
|
|
257767
|
+
const entries = this.freshnessResolver?.();
|
|
257768
|
+
return entries ? { entries, strict: false } : undefined;
|
|
257769
|
+
}
|
|
257752
257770
|
getSources() {
|
|
257753
257771
|
return this.curateForDiscovery(this.sources);
|
|
257754
257772
|
}
|
|
@@ -257993,12 +258011,18 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
257993
258011
|
}
|
|
257994
258012
|
const maxRows = getMaxQueryRows();
|
|
257995
258013
|
const maxBytes = getMaxResponseBytes();
|
|
257996
|
-
const
|
|
258014
|
+
const buildManifest = this.resolveFreshBuildManifest();
|
|
258015
|
+
const rowLimit = resolveModelQueryRowLimit((await runnable.getPreparedResult({ givens, buildManifest })).resultExplore.limit, { defaultLimit: getDefaultQueryRowLimit(), maxRows });
|
|
257997
258016
|
const endTime = performance.now();
|
|
257998
258017
|
const executionTime = endTime - startTime;
|
|
257999
258018
|
let queryResults;
|
|
258000
258019
|
try {
|
|
258001
|
-
queryResults = await runnable.run({
|
|
258020
|
+
queryResults = await runnable.run({
|
|
258021
|
+
rowLimit,
|
|
258022
|
+
givens,
|
|
258023
|
+
abortSignal,
|
|
258024
|
+
buildManifest
|
|
258025
|
+
});
|
|
258002
258026
|
} catch (error) {
|
|
258003
258027
|
const errorEndTime = performance.now();
|
|
258004
258028
|
const errorExecutionTime = errorEndTime - startTime;
|
|
@@ -258125,14 +258149,19 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
258125
258149
|
}
|
|
258126
258150
|
const cellMaxRows = getMaxQueryRows();
|
|
258127
258151
|
const cellMaxBytes = getMaxResponseBytes();
|
|
258128
|
-
const
|
|
258152
|
+
const buildManifest = this.resolveFreshBuildManifest();
|
|
258153
|
+
const rowLimit = resolveModelQueryRowLimit((await runnableToExecute.getPreparedResult({
|
|
258154
|
+
givens,
|
|
258155
|
+
buildManifest
|
|
258156
|
+
})).resultExplore.limit, {
|
|
258129
258157
|
defaultLimit: getDefaultQueryRowLimit(),
|
|
258130
258158
|
maxRows: cellMaxRows
|
|
258131
258159
|
});
|
|
258132
258160
|
const result = await runnableToExecute.run({
|
|
258133
258161
|
rowLimit,
|
|
258134
258162
|
givens,
|
|
258135
|
-
abortSignal
|
|
258163
|
+
abortSignal,
|
|
258164
|
+
buildManifest
|
|
258136
258165
|
});
|
|
258137
258166
|
const query = (await runnableToExecute.getPreparedQuery())._query;
|
|
258138
258167
|
queryName = query.as || query.name;
|
|
@@ -258397,6 +258426,7 @@ function hydrateMarkdownOnlyCells(notebookCells) {
|
|
|
258397
258426
|
}
|
|
258398
258427
|
|
|
258399
258428
|
// src/service/build_plan.ts
|
|
258429
|
+
var FRESHNESS_FALLBACKS = ["live", "stale_ok", "fail"];
|
|
258400
258430
|
function deriveColumns(persistSource) {
|
|
258401
258431
|
try {
|
|
258402
258432
|
return persistSource._explore.intrinsicFields.filter((f) => f.isAtomicField()).map((f) => ({
|
|
@@ -258423,6 +258453,65 @@ function deriveAnnotationFields(persistSource) {
|
|
|
258423
258453
|
} catch {}
|
|
258424
258454
|
return out;
|
|
258425
258455
|
}
|
|
258456
|
+
function tagFreshnessScheduleLayer(tag) {
|
|
258457
|
+
if (!tag || typeof tag.text !== "function")
|
|
258458
|
+
return {};
|
|
258459
|
+
const layer = {};
|
|
258460
|
+
const window2 = tag.text("freshness", "window");
|
|
258461
|
+
if (typeof window2 === "string")
|
|
258462
|
+
layer.window = window2;
|
|
258463
|
+
const fallback = tag.text("freshness", "fallback");
|
|
258464
|
+
if (typeof fallback === "string" && FRESHNESS_FALLBACKS.includes(fallback)) {
|
|
258465
|
+
layer.fallback = fallback;
|
|
258466
|
+
}
|
|
258467
|
+
const schedule = tag.text("schedule");
|
|
258468
|
+
if (typeof schedule === "string")
|
|
258469
|
+
layer.schedule = schedule;
|
|
258470
|
+
return layer;
|
|
258471
|
+
}
|
|
258472
|
+
function packageFreshnessLayer(cfg) {
|
|
258473
|
+
if (!cfg)
|
|
258474
|
+
return {};
|
|
258475
|
+
const layer = {};
|
|
258476
|
+
if (cfg.freshness?.window)
|
|
258477
|
+
layer.window = cfg.freshness.window;
|
|
258478
|
+
const fallback = cfg.freshness?.fallback;
|
|
258479
|
+
if (typeof fallback === "string" && FRESHNESS_FALLBACKS.includes(fallback)) {
|
|
258480
|
+
layer.fallback = fallback;
|
|
258481
|
+
}
|
|
258482
|
+
return layer;
|
|
258483
|
+
}
|
|
258484
|
+
function safeSourceTag(source) {
|
|
258485
|
+
try {
|
|
258486
|
+
return source.annotations.parseAsTag("@").tag;
|
|
258487
|
+
} catch {
|
|
258488
|
+
return;
|
|
258489
|
+
}
|
|
258490
|
+
}
|
|
258491
|
+
function safeModelTag(source) {
|
|
258492
|
+
try {
|
|
258493
|
+
return source.modelAnnotations.parseAsTag().tag;
|
|
258494
|
+
} catch {
|
|
258495
|
+
return;
|
|
258496
|
+
}
|
|
258497
|
+
}
|
|
258498
|
+
function resolveFreshnessSchedule(source, packageMaterialization) {
|
|
258499
|
+
const sourceLayer = tagFreshnessScheduleLayer(safeSourceTag(source));
|
|
258500
|
+
const modelLayer = tagFreshnessScheduleLayer(safeModelTag(source));
|
|
258501
|
+
const pkgLayer = packageFreshnessLayer(packageMaterialization);
|
|
258502
|
+
const window2 = sourceLayer.window ?? modelLayer.window ?? pkgLayer.window;
|
|
258503
|
+
const fallback = sourceLayer.fallback ?? modelLayer.fallback ?? pkgLayer.fallback;
|
|
258504
|
+
const schedule = sourceLayer.schedule ?? modelLayer.schedule ?? null;
|
|
258505
|
+
let freshness = null;
|
|
258506
|
+
if (window2 !== undefined || fallback !== undefined) {
|
|
258507
|
+
freshness = {};
|
|
258508
|
+
if (window2 !== undefined)
|
|
258509
|
+
freshness.window = window2;
|
|
258510
|
+
if (fallback !== undefined)
|
|
258511
|
+
freshness.fallback = fallback;
|
|
258512
|
+
}
|
|
258513
|
+
return { freshness, schedule };
|
|
258514
|
+
}
|
|
258426
258515
|
function flattenDependsOn(node) {
|
|
258427
258516
|
return node.dependsOn.map((d) => d.sourceID);
|
|
258428
258517
|
}
|
|
@@ -258515,7 +258604,7 @@ async function compilePackageBuildPlan(pkg, signal) {
|
|
|
258515
258604
|
sourceModelPaths
|
|
258516
258605
|
};
|
|
258517
258606
|
}
|
|
258518
|
-
function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, sourceModelPaths) {
|
|
258607
|
+
function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, sourceModelPaths, packageMaterialization) {
|
|
258519
258608
|
const include = sourceNames ? new Set(sourceNames) : null;
|
|
258520
258609
|
const wireGraphs = graphs.map((graph) => ({
|
|
258521
258610
|
connectionName: graph.connectionName,
|
|
@@ -258529,6 +258618,7 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
|
|
|
258529
258618
|
if (include && !include.has(source.name))
|
|
258530
258619
|
continue;
|
|
258531
258620
|
const annotationFields = deriveAnnotationFields(source);
|
|
258621
|
+
const { freshness, schedule } = resolveFreshnessSchedule(source, packageMaterialization);
|
|
258532
258622
|
wireSources[sourceID] = {
|
|
258533
258623
|
name: source.name,
|
|
258534
258624
|
sourceID: source.sourceID,
|
|
@@ -258538,6 +258628,8 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
|
|
|
258538
258628
|
sql: source.getSQL(),
|
|
258539
258629
|
sharing: annotationFields.sharing ?? null,
|
|
258540
258630
|
refresh: annotationFields.refresh ?? null,
|
|
258631
|
+
freshness,
|
|
258632
|
+
schedule,
|
|
258541
258633
|
columns: deriveColumns(source),
|
|
258542
258634
|
annotationFields,
|
|
258543
258635
|
modelPath: sourceModelPaths?.[sourceID]
|
|
@@ -258550,7 +258642,47 @@ async function computePackageBuildPlan(pkg, signal) {
|
|
|
258550
258642
|
if (compiled.graphs.length === 0) {
|
|
258551
258643
|
return null;
|
|
258552
258644
|
}
|
|
258553
|
-
return deriveBuildPlan(compiled.graphs, compiled.sources, compiled.connectionDigests, undefined, compiled.sourceModelPaths);
|
|
258645
|
+
return deriveBuildPlan(compiled.graphs, compiled.sources, compiled.connectionDigests, undefined, compiled.sourceModelPaths, pkg.getMaterializationConfig?.() ?? null);
|
|
258646
|
+
}
|
|
258647
|
+
|
|
258648
|
+
// src/service/freshness.ts
|
|
258649
|
+
function evaluateManifestFreshness(entry, now) {
|
|
258650
|
+
if (entry.freshnessWindowSeconds == null)
|
|
258651
|
+
return "serve_table";
|
|
258652
|
+
if (entry.dataAsOf == null)
|
|
258653
|
+
return "serve_table";
|
|
258654
|
+
const dataAsOfMs = Date.parse(entry.dataAsOf);
|
|
258655
|
+
if (Number.isNaN(dataAsOfMs))
|
|
258656
|
+
return "serve_table";
|
|
258657
|
+
const ageSeconds = (now.getTime() - dataAsOfMs) / 1000;
|
|
258658
|
+
const stale = ageSeconds > entry.freshnessWindowSeconds;
|
|
258659
|
+
if (!stale)
|
|
258660
|
+
return "serve_table";
|
|
258661
|
+
return entry.freshnessFallback === "stale_ok" ? "serve_table" : "serve_live";
|
|
258662
|
+
}
|
|
258663
|
+
function staleSinceMs(entry) {
|
|
258664
|
+
if (entry.freshnessWindowSeconds == null || entry.dataAsOf == null) {
|
|
258665
|
+
return null;
|
|
258666
|
+
}
|
|
258667
|
+
const dataAsOfMs = Date.parse(entry.dataAsOf);
|
|
258668
|
+
if (Number.isNaN(dataAsOfMs))
|
|
258669
|
+
return null;
|
|
258670
|
+
return dataAsOfMs + entry.freshnessWindowSeconds * 1000;
|
|
258671
|
+
}
|
|
258672
|
+
function filterFreshManifest(entries, now) {
|
|
258673
|
+
const manifest = {};
|
|
258674
|
+
let nextStaleSince = null;
|
|
258675
|
+
const nowMs = now.getTime();
|
|
258676
|
+
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
258677
|
+
if (evaluateManifestFreshness(entry, now) === "serve_live")
|
|
258678
|
+
continue;
|
|
258679
|
+
manifest[sourceEntityId] = { tableName: entry.tableName };
|
|
258680
|
+
const flipAt = staleSinceMs(entry);
|
|
258681
|
+
if (flipAt != null && flipAt > nowMs) {
|
|
258682
|
+
nextStaleSince = nextStaleSince == null ? flipAt : Math.min(nextStaleSince, flipAt);
|
|
258683
|
+
}
|
|
258684
|
+
}
|
|
258685
|
+
return { manifest, nextStaleSince };
|
|
258554
258686
|
}
|
|
258555
258687
|
|
|
258556
258688
|
// src/service/persist_annotation_validation.ts
|
|
@@ -258575,6 +258707,14 @@ function assertPersistNamesQuoted(modelSource, modelPath) {
|
|
|
258575
258707
|
}
|
|
258576
258708
|
|
|
258577
258709
|
// src/service/package.ts
|
|
258710
|
+
function toTableNameManifest(entries) {
|
|
258711
|
+
const out = {};
|
|
258712
|
+
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
258713
|
+
out[sourceEntityId] = { tableName: entry.tableName };
|
|
258714
|
+
}
|
|
258715
|
+
return out;
|
|
258716
|
+
}
|
|
258717
|
+
|
|
258578
258718
|
class Package {
|
|
258579
258719
|
environmentName;
|
|
258580
258720
|
packageName;
|
|
@@ -258584,6 +258724,8 @@ class Package {
|
|
|
258584
258724
|
packagePath;
|
|
258585
258725
|
malloyConfig;
|
|
258586
258726
|
buildManifestEntries;
|
|
258727
|
+
freshnessEntries;
|
|
258728
|
+
freshManifestCache;
|
|
258587
258729
|
manifestBindingStatus = "unbound";
|
|
258588
258730
|
manifestEntryCount = 0;
|
|
258589
258731
|
boundManifestUri = null;
|
|
@@ -258743,6 +258885,7 @@ class Package {
|
|
|
258743
258885
|
});
|
|
258744
258886
|
const pkg = new Package(environmentName, packageName, packagePath, packageConfig, databases, models, malloyConfig);
|
|
258745
258887
|
pkg.renderTagWarnings = renderTagWarnings;
|
|
258888
|
+
pkg.wireFreshnessResolvers();
|
|
258746
258889
|
try {
|
|
258747
258890
|
const buildPlanStart = Date.now();
|
|
258748
258891
|
pkg.buildPlan = await computePackageBuildPlan(pkg);
|
|
@@ -258776,6 +258919,9 @@ class Package {
|
|
|
258776
258919
|
getBuildPlan() {
|
|
258777
258920
|
return this.buildPlan;
|
|
258778
258921
|
}
|
|
258922
|
+
getMaterializationConfig() {
|
|
258923
|
+
return this.packageMetadata.materialization ?? null;
|
|
258924
|
+
}
|
|
258779
258925
|
getPackageMetadata() {
|
|
258780
258926
|
const metadata = {
|
|
258781
258927
|
...this.packageMetadata,
|
|
@@ -258797,17 +258943,37 @@ class Package {
|
|
|
258797
258943
|
getBuildManifestEntries() {
|
|
258798
258944
|
return this.buildManifestEntries;
|
|
258799
258945
|
}
|
|
258946
|
+
getFreshBuildManifest(now = Date.now()) {
|
|
258947
|
+
if (!this.freshnessEntries)
|
|
258948
|
+
return;
|
|
258949
|
+
if (this.freshManifestCache && now < this.freshManifestCache.validUntil) {
|
|
258950
|
+
return this.freshManifestCache.manifest;
|
|
258951
|
+
}
|
|
258952
|
+
const { manifest, nextStaleSince } = filterFreshManifest(this.freshnessEntries, new Date(now));
|
|
258953
|
+
this.freshManifestCache = {
|
|
258954
|
+
manifest,
|
|
258955
|
+
validUntil: nextStaleSince ?? Infinity
|
|
258956
|
+
};
|
|
258957
|
+
return manifest;
|
|
258958
|
+
}
|
|
258959
|
+
wireFreshnessResolvers() {
|
|
258960
|
+
for (const model of this.models.values()) {
|
|
258961
|
+
model.setFreshnessResolver(() => this.getFreshBuildManifest());
|
|
258962
|
+
}
|
|
258963
|
+
}
|
|
258800
258964
|
setBoundManifestUri(uri) {
|
|
258801
258965
|
this.boundManifestUri = uri;
|
|
258802
258966
|
}
|
|
258803
258967
|
markManifestBindFailed() {
|
|
258804
258968
|
this.manifestBindingStatus = "live_fallback";
|
|
258805
258969
|
}
|
|
258806
|
-
recordManifestBinding(
|
|
258807
|
-
const count = Object.keys(
|
|
258808
|
-
this.
|
|
258970
|
+
recordManifestBinding(entries) {
|
|
258971
|
+
const count = Object.keys(entries).length;
|
|
258972
|
+
this.freshnessEntries = count > 0 ? entries : undefined;
|
|
258973
|
+
this.buildManifestEntries = count > 0 ? toTableNameManifest(entries) : undefined;
|
|
258809
258974
|
this.manifestEntryCount = count;
|
|
258810
258975
|
this.manifestBindingStatus = count > 0 ? "bound" : "unbound";
|
|
258976
|
+
this.freshManifestCache = undefined;
|
|
258811
258977
|
if (count === 0) {
|
|
258812
258978
|
this.boundManifestUri = null;
|
|
258813
258979
|
}
|
|
@@ -258841,11 +259007,22 @@ class Package {
|
|
|
258841
259007
|
`);
|
|
258842
259008
|
}
|
|
258843
259009
|
scheduleWarnings() {
|
|
258844
|
-
const
|
|
258845
|
-
|
|
258846
|
-
|
|
258847
|
-
|
|
258848
|
-
|
|
259010
|
+
const warnings = [];
|
|
259011
|
+
const sources = this.buildPlan?.sources ? Object.values(this.buildPlan.sources) : [];
|
|
259012
|
+
for (const source of sources) {
|
|
259013
|
+
if (source.schedule && source.sharing !== "private") {
|
|
259014
|
+
warnings.push(`#@ persist source "${source.name}" declares a schedule (cron) but ` + `resolves to sharing="${source.sharing ?? "shared (unset default)"}": a per-source cron is valid only for sharing="private". ` + `Declare 'freshness.window' instead (the control plane schedules ` + `refreshes from it).`);
|
|
259015
|
+
}
|
|
259016
|
+
}
|
|
259017
|
+
const packageSchedule = this.packageMetadata.materialization?.schedule;
|
|
259018
|
+
if (packageSchedule) {
|
|
259019
|
+
const nonPrivate = sources.filter((s) => s.sharing !== "private");
|
|
259020
|
+
if (sources.length === 0 || nonPrivate.length > 0) {
|
|
259021
|
+
const detail = sources.length === 0 ? "the package declares no persist source for it to govern" : `sources [${nonPrivate.map((s) => s.name).join(", ")}] resolve to shared/unset`;
|
|
259022
|
+
warnings.push(`materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} is valid ` + `only when every persist source resolves to sharing="private": ` + `${detail}. Declare 'materialization.freshness.window' instead ` + `(the control plane schedules refreshes from it).`);
|
|
259023
|
+
}
|
|
259024
|
+
}
|
|
259025
|
+
return warnings;
|
|
258849
259026
|
}
|
|
258850
259027
|
formatInvalidSchedule() {
|
|
258851
259028
|
return this.scheduleWarnings().join(`
|
|
@@ -258891,7 +259068,8 @@ class Package {
|
|
|
258891
259068
|
getModelPaths() {
|
|
258892
259069
|
return Array.from(this.models.keys());
|
|
258893
259070
|
}
|
|
258894
|
-
async reloadAllModels(
|
|
259071
|
+
async reloadAllModels(entries) {
|
|
259072
|
+
const buildManifest = toTableNameManifest(entries);
|
|
258895
259073
|
const modelPaths = Array.from(this.models.keys());
|
|
258896
259074
|
logger.info("Reloading all models with build manifest", {
|
|
258897
259075
|
packageName: this.packageName,
|
|
@@ -258951,7 +259129,8 @@ class Package {
|
|
|
258951
259129
|
this.packageMetadata.manifestLocation = outcome.packageMetadata.manifestLocation ?? null;
|
|
258952
259130
|
this.applyDiscoveryPolicyToModels();
|
|
258953
259131
|
this.applyQueryBoundaryToModels();
|
|
258954
|
-
this.recordManifestBinding(
|
|
259132
|
+
this.recordManifestBinding(entries);
|
|
259133
|
+
this.wireFreshnessResolvers();
|
|
258955
259134
|
const invalidMsg = this.formatInvalidExplores();
|
|
258956
259135
|
if (invalidMsg) {
|
|
258957
259136
|
logger.warn(`Package ${this.packageName} has invalid explores`, {
|
|
@@ -261163,7 +261342,14 @@ class MaterializationController {
|
|
|
261163
261342
|
validateCreateBody(body) {
|
|
261164
261343
|
const result = {};
|
|
261165
261344
|
if (body.buildInstructions !== undefined && body.buildInstructions !== null) {
|
|
261166
|
-
|
|
261345
|
+
const parsed = this.validateBuildInstructions(body.buildInstructions);
|
|
261346
|
+
result.buildInstructions = parsed.sources;
|
|
261347
|
+
if (parsed.referenceManifest !== undefined) {
|
|
261348
|
+
result.referenceManifest = parsed.referenceManifest;
|
|
261349
|
+
}
|
|
261350
|
+
if (parsed.strictUpstreams !== undefined) {
|
|
261351
|
+
result.strictUpstreams = parsed.strictUpstreams;
|
|
261352
|
+
}
|
|
261167
261353
|
}
|
|
261168
261354
|
if (body.forceRefresh !== undefined) {
|
|
261169
261355
|
if (typeof body.forceRefresh !== "boolean") {
|
|
@@ -261183,11 +261369,42 @@ class MaterializationController {
|
|
|
261183
261369
|
if (typeof raw !== "object" || raw === null) {
|
|
261184
261370
|
throw new BadRequestError("buildInstructions must be an object");
|
|
261185
261371
|
}
|
|
261186
|
-
const
|
|
261372
|
+
const obj = raw;
|
|
261373
|
+
const sources = obj.sources;
|
|
261187
261374
|
if (!Array.isArray(sources) || sources.length === 0) {
|
|
261188
261375
|
throw new BadRequestError("buildInstructions requires a non-empty 'sources' array of BuildInstruction");
|
|
261189
261376
|
}
|
|
261190
|
-
|
|
261377
|
+
const result = {
|
|
261378
|
+
sources: sources.map((instruction) => this.validateInstruction(instruction))
|
|
261379
|
+
};
|
|
261380
|
+
if (obj.referenceManifest !== undefined && obj.referenceManifest !== null) {
|
|
261381
|
+
if (!Array.isArray(obj.referenceManifest)) {
|
|
261382
|
+
throw new BadRequestError("buildInstructions.referenceManifest must be an array of ManifestReference");
|
|
261383
|
+
}
|
|
261384
|
+
result.referenceManifest = obj.referenceManifest.map((ref) => this.validateManifestReference(ref));
|
|
261385
|
+
}
|
|
261386
|
+
if (obj.strictUpstreams !== undefined) {
|
|
261387
|
+
if (typeof obj.strictUpstreams !== "boolean") {
|
|
261388
|
+
throw new BadRequestError("buildInstructions.strictUpstreams must be a boolean");
|
|
261389
|
+
}
|
|
261390
|
+
result.strictUpstreams = obj.strictUpstreams;
|
|
261391
|
+
}
|
|
261392
|
+
return result;
|
|
261393
|
+
}
|
|
261394
|
+
validateManifestReference(raw) {
|
|
261395
|
+
if (typeof raw !== "object" || raw === null) {
|
|
261396
|
+
throw new BadRequestError("Each manifest reference must be an object");
|
|
261397
|
+
}
|
|
261398
|
+
const ref = raw;
|
|
261399
|
+
for (const field of ["sourceEntityId", "physicalTableName"]) {
|
|
261400
|
+
if (typeof ref[field] !== "string") {
|
|
261401
|
+
throw new BadRequestError(`Manifest reference is missing required string field '${field}'`);
|
|
261402
|
+
}
|
|
261403
|
+
}
|
|
261404
|
+
return {
|
|
261405
|
+
sourceEntityId: ref.sourceEntityId,
|
|
261406
|
+
physicalTableName: ref.physicalTableName
|
|
261407
|
+
};
|
|
261191
261408
|
}
|
|
261192
261409
|
validateInstruction(raw) {
|
|
261193
261410
|
if (typeof raw !== "object" || raw === null) {
|
|
@@ -265777,7 +265994,162 @@ view: rev_delta is {
|
|
|
265777
265994
|
}
|
|
265778
265995
|
\`\`\`
|
|
265779
265996
|
|
|
265780
|
-
Use \`down_is_good=true\` for metrics where decrease is positive (churn, defects).` }, { name: "lookml-review", description: "Analyze LookML files as prior art for Malloy modeling. Used during Step 1 (DISCOVER) when .lkml files are present. Coordinates reference files that extract business logic, relationships, and curation decisions. Works with or without a database connection.", body: `# LookML Review
|
|
265997
|
+
Use \`down_is_good=true\` for metrics where decrease is positive (churn, defects).` }, { name: "html-data-app-embedding", description: "Embed an in-package HTML data app into a host page or another application, including auto-sizing and auth. Read when embedding a Publisher page via Publisher.embed.", body: '# Embedding an HTML Data App\n\n> `Publisher.embed(selector, { src })` drops a package page into a host page as a sandboxed, auto-resizing iframe. Same-origin embeds authenticate with the browser\'s cookies; cross-origin embeds need a signed token.\n\n## The host-page pattern\n\n```html\n<script src="https://your-publisher/sdk/publisher.js"></script>\n<div id="dashboard"></div>\n<script>\n const handle = Publisher.embed("#dashboard", {\n src: "https://your-publisher/environments/demo/packages/sales/index.html",\n });\n // handle.destroy() removes the iframe and detaches its listeners.\n</script>\n```\n\n`embed(selector, options)` returns `{ iframe, destroy() }`. Options: `src` (required), `token` (a signed token for cross-origin auth, appended as `embed_token`), `height` (omit to auto-size; a number is treated as pixels), and `allow` (the iframe permissions policy).\n\n## Sizing and the resize contract\n\nOmit `height` and the frame auto-sizes. The embedded page measures its real content height and posts a `publisher:resize` message to the host, which resizes the iframe and accepts that message only from the iframe it created. You write none of this; it ships in `/sdk/publisher.js`, so the embedded page only has to load that script.\n\nDo not rely on `body { min-height: 100vh }` to drive the frame height. The runtime deliberately measures the content\'s bottom edge, not the viewport, to avoid a grow-forever loop.\n\n## Auth\n\n- Same-origin or same-tenant: pass no token. The browser\'s cookies authenticate the iframe.\n- Cross-origin: mint a short-lived signed token server-side and pass it as `options.token`. The runtime appends it to the iframe URL as `embed_token`; the embedded page must read it (from `location.search`) and call `Publisher.setToken(token)`. Because it rides in the URL, it can land in browser history, Referer headers, and server logs, so keep it short-lived and scoped to that one embed, and never put a long-lived or admin token in client HTML.\n\n## Guardrails (v1)\n\n- The iframe is sandboxed (`allow-scripts allow-same-origin allow-forms`). Design for that: no top-level navigation, no popups.\n- Embedded author JavaScript runs with the viewing user\'s data authority, so treat everything under `public/` as strictly first-party code: do not load untrusted third-party scripts, and do not move query results off to other hosts. Tighter per-embed isolation is planned.' }, { name: "html-data-app-runtime", description: "Write the JavaScript that drives an in-package HTML data app, calling Publisher.query, building queries from filter state, and handling results and errors. Read before writing the page's data code.", body: '# HTML Data App Runtime\n\n> `Publisher.query(modelPath, malloy)` returns an array of plain row objects. Build the Malloy string, let the model do the work, and render the rows with whatever front-end code you like.\n\nThe runtime loads from the root-relative `<script src="/sdk/publisher.js">` and adds one global, `window.Publisher`.\n\n## The query contract\n\n| Call | Returns | Use for |\n|---|---|---|\n| `Publisher.query(modelPath, malloy, opts?)` | `Promise<Array>` of rows | driving your own charts and tables |\n| `Publisher.queryFull(modelPath, malloy, opts?)` | `Promise<MalloyResult>` | handing to `<malloy-render>` |\n\n- `modelPath` is the model FILE path within the package, with `/` separators (`"carriers.malloy"`, `"models/events.malloy"`). It is not the source name.\n- `malloy` is any query string, written in standard Malloy. This skill covers only the JavaScript glue, not Malloy syntax.\n- `opts` (all optional): `sourceName`, `queryName`, `filterParams` (values for the model\'s legacy `#(filter)` source filters; `Publisher.query` does not forward Malloy `given:` values), `bypassFilters`, and `environment` / `package` (only if the page is served from outside `/environments/<env>/packages/<pkg>/`).\n\n## Structure the app as modules, not one inline script\n\nPast a single tile, an inline `<script>` becomes unmaintainable and untestable. Split the work, and load it without a build step: put your shared libraries first as plain globals, then one ES-module entry point that `import`s your own files.\n\n```html\n<!-- Globals first: the runtime, then any vendored chart library. -->\n<script src="/sdk/publisher.js"></script>\n<script src="./vendor/chart.umd.js"></script>\n<!-- One module entry; it imports the rest. ES modules resolve with no bundler. -->\n<script type="module" src="./app.js"></script>\n```\n\nA separation that keeps each piece testable and changeable on its own:\n\n- **`format.js`**. Pure functions only: number/date formatting, a series-align-by-month helper, status thresholds. No DOM, no globals. This is the file `node --test` can cover directly.\n- **`charts.js`**. Turns a prepared data object into a drawn chart; the only file that touches the chart library.\n- **`tiles.js`**. Your tiles as *data*: for each, its model/source/view (and target source, if any), plus a pure `build(rows)` that shapes query rows for the chart. This is the single source of truth for what each tile queries.\n- **`app.js`**. The thin entry point: reads `tiles.js`, runs the queries, wires results to the DOM. Adding a tile means adding a `tiles.js` entry, not editing `app.js`.\n\nDeclare each tile\'s source and view names once, in `tiles.js`, and have everything else (render code, any agent prompt, tests) read from there. A second copy of those names in another file is the classic drift bug, and a *derived* name (`okr_4_4_2_targets` invented from a tile code) is simply wrong: a target source may have an irregular name or not exist at all. Read the model; don\'t compute names.\n\n## Patterns that work\n\nThese run against the example `carriers` package (source `carriers`; views `by_letter`, `by_size_bucket`, `kpis`). Swap in your own model and view names.\n\nRun a named view:\n\n```js\nconst rows = await Publisher.query("carriers.malloy", "run: carriers -> by_letter");\n```\n\nRefine a view from UI state by appending a `where:`. Restrict the values to ones you control (for example a dropdown populated from the model\'s own distinct values) and escape each interpolated value with a backslash before quotes and backslashes (Malloy rejects the SQL-style `\'\'` doubling). An unescaped apostrophe in a value breaks out of the literal:\n\n```js\nfunction whereClause(state) {\n const q = (s) => s.replace(/\\\\/g, "\\\\\\\\").replace(/\'/g, "\\\\\'"); // backslash-escape for Malloy\n const parts = [];\n if (state.letter) parts.push(`letter = \'${q(state.letter)}\'`);\n if (state.bucket) parts.push(`size_bucket = \'${q(state.bucket)}\'`);\n return parts.length ? `where: ${parts.join(", ")}` : "";\n}\nconst rows = await Publisher.query(\n "carriers.malloy",\n `run: carriers -> by_letter + { ${whereClause(state)} }`,\n);\n```\n\nDo not interpolate free-text or otherwise untrusted input. A data-app page has no clean server-side parameterization for arbitrary input today: `Publisher.query` forwards `opts.filterParams` (the deprecated `#(filter)` source-filter API), not Malloy `given:` values. So constrain the input to a known set and escape it, or keep the filtering in model-defined views.\n\nKPI or single-row view. Destructure element zero:\n\n```js\nconst [kpis] = await Publisher.query("carriers.malloy", "run: carriers -> kpis");\nel.textContent = kpis.total; // the result is an array; kpis.total, not rows.total\n```\n\nRefresh a dashboard. Fire the tiles together:\n\n```js\nconst [byLetter, byBucket, kpisRows] = await Promise.all([\n Publisher.query("carriers.malloy", "run: carriers -> by_letter"),\n Publisher.query("carriers.malloy", "run: carriers -> by_size_bucket"),\n Publisher.query("carriers.malloy", "run: carriers -> kpis"),\n]);\n```\n\nPrefer defining the views in the model (one per tile, pre-aggregated and sorted) over building long query strings in JS.\n\nGet the numbers right. The fastest way to ship a wrong-but-convincing dashboard is to paper over missing data:\n\n- **Missing is not zero.** When you join two series (actuals to a separately-keyed target) and a key is absent, leave it `null` so the chart skips it. Do not `|| 0`, which plots a real-looking zero and reads as "we hit nothing that month." Align on a normalized key (`"YYYY-MM"`), and let the renderer omit null points:\n\n ```js\n // monthKey/monthLabel are your own format.js helpers: monthKey normalizes a\n // date to a "YYYY-MM" string; monthLabel formats it for display.\n // target may not cover every actual month; an absent month stays null, never 0.\n const target = new Map(planRows.map((r) => [monthKey(r.plan_month), Number(r.target_revenue)]));\n const data = actualRows.map((r) => ({\n label: monthLabel(r.order_month),\n actual: Number(r.revenue),\n target: target.has(monthKey(r.order_month)) ? target.get(monthKey(r.order_month)) : null,\n }));\n ```\n\n- **"Current" means latest non-null.** For a KPI scorecard, scan back to the last month that actually has data rather than reading the final row, which may be an incomplete current month.\n- **Guard division in Malloy, not after.** `avg(paid / nullif(active, 0))`. A `nullif` in the query beats catching `Infinity`/`NaN` in JS.\n- **Convert units explicitly.** If the model stores a 0 to 1 fraction and you show a percent, multiply once in `build()` and comment it. Mismatched units are a silent off-by-100.\n\nLoading, empty, and error states. Handle all three; a bare `.then()` that assumes rows leaves the page blank when the query is slow or fails:\n\n```js\nconst el = document.getElementById("out");\nel.textContent = "Loading...";\nPublisher.query("carriers.malloy", "run: carriers -> by_letter")\n .then((rows) => {\n if (!rows.length) { el.textContent = "No data."; return; }\n render(rows);\n })\n .catch((err) => {\n el.textContent = `Query failed (${err.status ?? ""}): ${err.response?.message ?? err.message}`;\n });\n```\n\nRender through `<malloy-render>`. `queryFull` returns the full Malloy result envelope (the JSON form of the server\'s result, not a live result object) to hand to the component:\n\n```js\nconst el = document.querySelector("malloy-render");\nel.result = await Publisher.queryFull("carriers.malloy", "run: carriers -> by_letter");\n```\n\nPublisher does not serve or bundle `<malloy-render>`; you must obtain a built component bundle matched to your model\'s Malloy version and vendor it into `public/` yourself, then confirm it accepts the envelope as-is. The shipped example renders rows with a plain chart library instead, so this path is not exercised there. A view tagged in the model (for example `# bar_chart`) drives how it draws.\n\nValidate every query before wiring it into render code, using whatever query tool your environment provides, or by POSTing the query to a running Publisher at `/api/v0/environments/<env>/packages/<pkg>/models/<modelPath>/query` with body `{"compactJson":true,"query":"..."}`, or by running `Publisher.query` once and logging the rows. Malloy names result columns after the `group_by` / `aggregate` field names (`group_by: letter` gives a `letter` column; `aggregate: n is count()` gives an `n` column), so confirm those names against real output before you read them.\n\nIf you validate the rendered page in a headless browser (Playwright or Puppeteer), do not wait for network idle: `publisher.js` holds the live-reload SSE stream open, so the page never reaches it. Wait on `domcontentloaded` or `load` plus a content selector instead.\n\n## Context, auth, live reload (all automatic)\n\n- Context. A page served under `/environments/<env>/packages/<pkg>/...` infers its environment and package, so `query` needs no env or package args. Serving from elsewhere? Pass `opts.environment` and `opts.package`.\n- Auth. By default the runtime sends cookies (`credentials: include`), so a signed-in user is authenticated with no code. For a bearer token, call `Publisher.setToken(token)` first; `Publisher.setToken(null)` reverts to cookies.\n- Live reload. Under `--watch-env`, the page reloads on package changes by itself. Nothing to wire.\n\n## When the app fails\n\n| Symptom | Likely cause and fix |\n|---|---|\n| 404 or "model not found" | `modelPath` wrong. It is the file path (`"carriers.malloy"`), with `/` separators, not the source name. |\n| "source/view not defined" | View or source name guessed. Read the model (your environment\'s context tool, or open the `.malloy` file) and use the real names. |\n| Promise rejects, message starts `Publisher.query:` | Read `error.status` and `error.response` for the server\'s reason (compile error, missing required parameter, permission). |\n| Empty array when you expect rows | Filter value mismatch (case, spelling, type, or a non-ASCII character like `≤` or an en-dash in the literal). Copy the literal verbatim from the model, do not retype the user\'s paraphrase, and confirm it with a distinct-values query (`run: src -> { group_by: the_dimension }`). Quote strings, use `@` for dates. |\n| 400, required parameter | The model marks a runtime parameter (given) as required. Supply it via `opts.filterParams`, or pass `bypassFilters: true` only if you are a trusted caller. |\n| KPI shows `undefined` | The result is an array. Read `rows[0].field` (or destructure `const [k] = ...`), not `rows.field`. |\n| Page loads in dev but is not listed or not served | The file is not under the package\'s `public/` directory. Publisher serves only `public/`; a page written anywhere else (for example `/tmp`) is never reachable at `/environments/<env>/packages/<pkg>/<file>`. |\n| Queries fail only when embedded cross-origin | Cookies are not sent cross-site. Serve same-origin, or pass a bearer token. |\n| No live reload | Watch mode is off. Start with `--watch-env <env>`; without it the events stream reports `mode: disabled` and never reloads. |' }, { name: "html-data-apps", description: "Build or modify an in-package HTML data app for a Malloy Publisher package (a public/ directory the package serves). Use when the user wants a hand-authored HTML dashboard or web page backed by a package's Malloy models, with no build step.", body: `# In-Package HTML Data Apps
|
|
265998
|
+
|
|
265999
|
+
> A package becomes a web app by adding a \`public/\` directory. Publisher serves those files and gives the page \`Publisher.query(...)\` to run Malloy against the package's models. No build step, no npm, no framework.
|
|
266000
|
+
|
|
266001
|
+
## When this is the right tool
|
|
266002
|
+
|
|
266003
|
+
| The user wants | Use |
|
|
266004
|
+
|---|---|
|
|
266005
|
+
| A hand-authored HTML/JS dashboard, no toolchain | this skill (an HTML data app) |
|
|
266006
|
+
| A React app with managed components | the Publisher React SDK (out of scope here) |
|
|
266007
|
+
| An analyst notebook with charts | a Malloy notebook (\`.malloynb\`) |
|
|
266008
|
+
| Point-and-click exploration, no code | the Publisher Explorer |
|
|
266009
|
+
|
|
266010
|
+
Pick an HTML data app when the user wants full control of the markup and only plain web files.
|
|
266011
|
+
|
|
266012
|
+
## Package anatomy
|
|
266013
|
+
|
|
266014
|
+
\`\`\`
|
|
266015
|
+
my-package/
|
|
266016
|
+
publisher.json # name, version, description
|
|
266017
|
+
carriers.malloy # the model(s), stays private
|
|
266018
|
+
carriers.parquet # data, stays private
|
|
266019
|
+
public/ # ONLY this directory is web-served
|
|
266020
|
+
index.html
|
|
266021
|
+
app.js
|
|
266022
|
+
\`\`\`
|
|
266023
|
+
|
|
266024
|
+
Only \`public/\` is reachable over the web, at \`/environments/<env>/packages/<pkg>/<file>\`. Models, data, and \`publisher.json\` are private and reached only through the query API, which still applies the model's filters, access modifiers, and authorize rules. There is no flag to set: a \`public/\` directory is what makes a package an app.
|
|
266025
|
+
|
|
266026
|
+
## Build sequence
|
|
266027
|
+
|
|
266028
|
+
The agent orchestrates these. Each query and chart step hands off to a focused skill.
|
|
266029
|
+
|
|
266030
|
+
1. READ THE MODEL FIRST. Get the model's real source and view names, through your environment's context tool if it has one, or by opening the \`.malloy\` file directly. Never guess field or view names.
|
|
266031
|
+
2. SCAFFOLD the package (template below).
|
|
266032
|
+
3. WRITE THE QUERIES with \`skill:html-data-app-runtime\`. Validate each before pasting it into the page, using whatever query tool your environment provides or a running Publisher (see \`skill:html-data-app-runtime\`). Malloy syntax questions go to \`skill:malloy-queries\`.
|
|
266033
|
+
4. CHOOSE CHARTS with \`skill:malloy-charts\` when rendering through \`<malloy-render>\`; otherwise it is your own chart library drawing the returned rows. Vendor any chart library into \`public/\` and load it locally, not from a CDN, because embedded author JavaScript runs with the viewing user's data authority. (The shipped example loads its chart library from a CDN for brevity; a published or embedded app should vendor it.)
|
|
266034
|
+
5. EMBED (optional) with \`skill:html-data-app-embedding\`.
|
|
266035
|
+
6. PREVIEW with the local authoring loop (below).
|
|
266036
|
+
7. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
|
|
266037
|
+
|
|
266038
|
+
The scaffold in step 2 only proves the wiring. It is the start, not the deliverable. What you ship is a production app that meets the recipe below.
|
|
266039
|
+
|
|
266040
|
+
## What "done" means (production recipe)
|
|
266041
|
+
|
|
266042
|
+
A data app you can defend has all of these. Build to this list, not to the scaffold.
|
|
266043
|
+
|
|
266044
|
+
- **Real names, never guessed.** Every source, view, and field name comes from the model you read in step 1. A name you derived or assumed is a bug waiting to surface as an empty tile.
|
|
266045
|
+
- **Modular, not one inline blob.** Split the page into modules per \`skill:html-data-app-runtime\` (pure formatting helpers, a chart layer, your tile/query definitions as data, a thin entry point). One source of truth for each tile's model/source/view, no parallel maps that drift.
|
|
266046
|
+
- **Every tile handles loading, empty, and error on its own.** One failing query must not blank the page. (\`skill:html-data-app-runtime\`.)
|
|
266047
|
+
- **Defensible numbers.** Missing ≠ zero (omit the point, don't plot a fake 0); show the latest non-null value for "current"; guard division with \`nullif\`; convert units explicitly. (\`skill:html-data-app-runtime\`.)
|
|
266048
|
+
- **Visible assumptions.** When you assume something (two sources joined by month, an in-month proxy that differs from a certified definition) or a metric is incomplete, say so *in the app*: a caption, a footnote, a placeholder card with the reason. The non-technical user cannot see your reasoning; bury a caveat and you have misled them. Don't silently drop a metric you couldn't model. Show a placeholder that names what's missing and why.
|
|
266049
|
+
- **Looks decent.** Give it real layout, type, and color: a styled card grid with a clear hierarchy, not raw unstyled tables.
|
|
266050
|
+
- **Vendored libraries.** Chart and helper libraries live in \`public/\`, loaded locally (step 4).
|
|
266051
|
+
|
|
266052
|
+
### Verify before you call it done
|
|
266053
|
+
|
|
266054
|
+
You are building for someone who cannot tell a correct dashboard from a broken one. Verification is your job, not theirs.
|
|
266055
|
+
|
|
266056
|
+
- **Validate every query against the model before wiring it in** (step 3): confirm it compiles and that the column names match what your render code reads.
|
|
266057
|
+
- **Load the finished page and confirm every tile shows real numbers**: not stuck on "Loading…", not an error, not an empty state you didn't intend. In a headless browser, wait on \`load\` plus a content selector, not network idle (\`publisher.js\` holds an SSE stream open; see \`skill:html-data-app-runtime\`).
|
|
266058
|
+
- **Unit-test any non-trivial pure logic** (a month-join, a de-cumulation, a unit conversion). Keep that logic in DOM-free helpers so \`node --test\` can cover it, and run it.
|
|
266059
|
+
|
|
266060
|
+
## Minimal scaffold
|
|
266061
|
+
|
|
266062
|
+
\`publisher.json\` at the package root:
|
|
266063
|
+
|
|
266064
|
+
\`\`\`json
|
|
266065
|
+
{ "name": "my-package", "version": "0.0.1", "description": "..." }
|
|
266066
|
+
\`\`\`
|
|
266067
|
+
|
|
266068
|
+
\`public/index.html\` is a NEW file you create (make the \`public/\` directory if it does not exist). Load the runtime root-relative, then query. The examples below use the shipped \`carriers\` package; swap in your own model and a view it defines.
|
|
266069
|
+
|
|
266070
|
+
Start with the smallest page that proves the wiring, dumping the rows:
|
|
266071
|
+
|
|
266072
|
+
\`\`\`html
|
|
266073
|
+
<!doctype html>
|
|
266074
|
+
<title>My dashboard</title>
|
|
266075
|
+
<pre id="out"></pre>
|
|
266076
|
+
<script src="/sdk/publisher.js"></script>
|
|
266077
|
+
<script>
|
|
266078
|
+
Publisher.query("carriers.malloy", "run: carriers -> by_letter").then((rows) => {
|
|
266079
|
+
document.getElementById("out").textContent = JSON.stringify(rows, null, 2);
|
|
266080
|
+
});
|
|
266081
|
+
</script>
|
|
266082
|
+
\`\`\`
|
|
266083
|
+
|
|
266084
|
+
Then render the rows. This page builds a table from whatever columns the view returns, so it does not depend on the exact field names:
|
|
266085
|
+
|
|
266086
|
+
\`\`\`html
|
|
266087
|
+
<!doctype html>
|
|
266088
|
+
<title>Carriers by letter</title>
|
|
266089
|
+
<table id="t"><thead></thead><tbody></tbody></table>
|
|
266090
|
+
<script src="/sdk/publisher.js"></script>
|
|
266091
|
+
<script>
|
|
266092
|
+
Publisher.query("carriers.malloy", "run: carriers -> by_letter").then((rows) => {
|
|
266093
|
+
const t = document.getElementById("t");
|
|
266094
|
+
if (!rows.length) { t.textContent = "No rows."; return; }
|
|
266095
|
+
const cols = Object.keys(rows[0]);
|
|
266096
|
+
const headRow = t.tHead.insertRow();
|
|
266097
|
+
for (const c of cols) {
|
|
266098
|
+
const th = document.createElement("th");
|
|
266099
|
+
th.textContent = c;
|
|
266100
|
+
headRow.appendChild(th);
|
|
266101
|
+
}
|
|
266102
|
+
for (const r of rows) {
|
|
266103
|
+
const tr = t.tBodies[0].insertRow();
|
|
266104
|
+
for (const c of cols) tr.insertCell().textContent = r[c];
|
|
266105
|
+
}
|
|
266106
|
+
});
|
|
266107
|
+
</script>
|
|
266108
|
+
\`\`\`
|
|
266109
|
+
|
|
266110
|
+
Build row content with \`textContent\`, not \`innerHTML\` with model values: an \`innerHTML\` table renders any markup a value contains. This is the HTML-output side of the don't-trust-interpolated-values rule that \`skill:html-data-app-runtime\` applies to Malloy query strings.
|
|
266111
|
+
|
|
266112
|
+
Two invariants break a page most often:
|
|
266113
|
+
|
|
266114
|
+
- **The file must live under \`public/\`.** Publisher serves only \`public/\`, so a page written anywhere else (for example \`/tmp\`) is never reachable at \`/environments/<env>/packages/<pkg>/<file>\`.
|
|
266115
|
+
- **The script src must be the root-relative \`/sdk/publisher.js\`**, not a relative path.
|
|
266116
|
+
|
|
266117
|
+
A third gotcha: the first argument to \`Publisher.query\` is the model FILE path (\`"carriers.malloy"\`), not the source name.
|
|
266118
|
+
|
|
266119
|
+
## Authoring loop and publishing
|
|
266120
|
+
|
|
266121
|
+
Authoring happens locally, then you publish. These are two stages.
|
|
266122
|
+
|
|
266123
|
+
### Author locally (with live reload)
|
|
266124
|
+
|
|
266125
|
+
Run a local Publisher from the directory that holds your \`publisher.config.json\` and package folder(s):
|
|
266126
|
+
|
|
266127
|
+
\`\`\`sh
|
|
266128
|
+
npx @malloy-publisher/server --server_root . --port 4000 --watch-env <env>
|
|
266129
|
+
\`\`\`
|
|
266130
|
+
|
|
266131
|
+
\`--watch-env <env>\` (or \`PUBLISHER_WATCH=<env>\`) mounts that environment's local-dir packages in place (a symlink, not a copy) and watches them: editing a \`.malloy\` recompiles the package, and editing a \`public/\` file live-reloads any open page over an SSE stream. Nothing to wire in the page. The app is served at \`http://localhost:4000/environments/<env>/packages/<pkg>/index.html\`.
|
|
266132
|
+
|
|
266133
|
+
\`publisher.config.json\` (at \`--server_root\`) declares the environment, its packages, and its connections:
|
|
266134
|
+
|
|
266135
|
+
\`\`\`json
|
|
266136
|
+
{
|
|
266137
|
+
"frozenConfig": false,
|
|
266138
|
+
"environments": [
|
|
266139
|
+
{
|
|
266140
|
+
"name": "<env>",
|
|
266141
|
+
"packages": [{ "name": "<pkg>", "location": "./<pkg>" }],
|
|
266142
|
+
"connections": []
|
|
266143
|
+
}
|
|
266144
|
+
]
|
|
266145
|
+
}
|
|
266146
|
+
\`\`\`
|
|
266147
|
+
|
|
266148
|
+
A local package uses a filesystem \`location\` (\`"./<pkg>"\`, relative to \`--server_root\`); a remote one uses a GitHub \`tree\` URL. If one model in the package fails to compile, the **whole package** fails to load, so a stray notebook/model error blanks every tile. (Common one: a \`.malloynb\` whose cells each \`import "x.malloy"\`, the notebook compiles as one batch, so the repeated import errors \`Cannot redefine 'x'\`. Import once in the first cell.)
|
|
266149
|
+
|
|
266150
|
+
### Publishing
|
|
266151
|
+
|
|
266152
|
+
Publishing an app is publishing its package: get the package into publishable shape and hand it to your Publisher host's deploy path (see \`skill:malloy-publish\`). A deployed package serves its \`public/\` app the same way a local one does, at \`/environments/<env>/packages/<pkg>/<file>\`. A deployed environment has no \`--watch-env\` live reload, so the loop there is author, publish, then view.` }, { name: "lookml-review", description: "Analyze LookML files as prior art for Malloy modeling. Used during Step 1 (DISCOVER) when .lkml files are present. Coordinates reference files that extract business logic, relationships, and curation decisions. Works with or without a database connection.", body: `# LookML Review
|
|
265781
266153
|
|
|
265782
266154
|
> **Purpose:** Evaluate a LookML project as prior art for building a Malloy semantic model. This skill coordinates the LookML adapter. The implementation lives in reference files under \`reference/\`.
|
|
265783
266155
|
|
|
@@ -267921,7 +268293,9 @@ class MaterializationService {
|
|
|
267921
268293
|
this.runInBackground(created.id, (signal) => this.runBuild(created.id, environmentName, packageName, {
|
|
267922
268294
|
sourceNames: options.sourceNames,
|
|
267923
268295
|
forceRefresh,
|
|
267924
|
-
buildInstructions
|
|
268296
|
+
buildInstructions,
|
|
268297
|
+
referenceManifest: options.referenceManifest,
|
|
268298
|
+
strictUpstreams: options.strictUpstreams
|
|
267925
268299
|
}, signal));
|
|
267926
268300
|
return created;
|
|
267927
268301
|
}
|
|
@@ -267946,12 +268320,12 @@ class MaterializationService {
|
|
|
267946
268320
|
let carried;
|
|
267947
268321
|
if (orchestrated) {
|
|
267948
268322
|
instructions = opts.buildInstructions;
|
|
267949
|
-
carried =
|
|
268323
|
+
carried = this.referenceManifestToEntries(opts.referenceManifest);
|
|
267950
268324
|
} else {
|
|
267951
268325
|
const priorEntries = opts.forceRefresh ? {} : await this.getMostRecentManifestEntries(environmentId, packageName, id);
|
|
267952
268326
|
({ instructions, carried } = this.deriveSelfInstructions(compiled, opts.sourceNames, priorEntries));
|
|
267953
268327
|
}
|
|
267954
|
-
const entries = await this.executeInstructedBuild(compiled, instructions, carried, signal);
|
|
268328
|
+
const entries = await this.executeInstructedBuild(compiled, instructions, carried, signal, opts.strictUpstreams ?? false);
|
|
267955
268329
|
const sourcesBuilt = instructions.length;
|
|
267956
268330
|
const sourcesReused = Object.keys(carried).length;
|
|
267957
268331
|
const durationMs = Date.now() - startedAt;
|
|
@@ -268010,6 +268384,16 @@ class MaterializationService {
|
|
|
268010
268384
|
}
|
|
268011
268385
|
return { instructions, carried };
|
|
268012
268386
|
}
|
|
268387
|
+
referenceManifestToEntries(referenceManifest) {
|
|
268388
|
+
const entries = {};
|
|
268389
|
+
for (const ref of referenceManifest ?? []) {
|
|
268390
|
+
entries[ref.sourceEntityId] = {
|
|
268391
|
+
sourceEntityId: ref.sourceEntityId,
|
|
268392
|
+
physicalTableName: ref.physicalTableName
|
|
268393
|
+
};
|
|
268394
|
+
}
|
|
268395
|
+
return entries;
|
|
268396
|
+
}
|
|
268013
268397
|
async getMostRecentManifestEntries(environmentId, packageName, excludeId) {
|
|
268014
268398
|
const list = await this.repository.listMaterializations(environmentId, packageName);
|
|
268015
268399
|
for (const m of list) {
|
|
@@ -268062,7 +268446,7 @@ class MaterializationService {
|
|
|
268062
268446
|
}
|
|
268063
268447
|
}
|
|
268064
268448
|
}
|
|
268065
|
-
async executeInstructedBuild(compiled, instructions, seedEntries, signal) {
|
|
268449
|
+
async executeInstructedBuild(compiled, instructions, seedEntries, signal, strict = false) {
|
|
268066
268450
|
const { graphs, sources, connectionDigests, connections } = compiled;
|
|
268067
268451
|
const bySourceID = new Map;
|
|
268068
268452
|
const bySourceEntityId = new Map;
|
|
@@ -268073,6 +268457,7 @@ class MaterializationService {
|
|
|
268073
268457
|
bySourceEntityId.set(instruction.sourceEntityId, instruction);
|
|
268074
268458
|
}
|
|
268075
268459
|
const manifest = new Manifest;
|
|
268460
|
+
manifest.strict = strict;
|
|
268076
268461
|
const entries = {};
|
|
268077
268462
|
for (const [sourceEntityId, entry] of Object.entries(seedEntries)) {
|
|
268078
268463
|
if (entry.physicalTableName) {
|