@malloy-publisher/server 0.0.223 → 0.0.224
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 +17 -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 +266 -13
- package/package.json +12 -12
- package/src/mcp/skills/skills_bundle.json +1 -1
- 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 +23 -18
- package/src/service/materialization_service.ts +6 -1
- package/src/service/model.ts +54 -4
- package/src/service/package.ts +126 -33
- package/src/storage/DatabaseInterface.ts +23 -0
- package/tests/integration/materialization/freshness_gate.integration.spec.ts +292 -0
package/dist/app/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
|
12
12
|
/>
|
|
13
13
|
<title>Malloy Publisher</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-C6gZ6sSY.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-5eLCcNmP.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
package/dist/server.mjs
CHANGED
|
@@ -256953,7 +256953,12 @@ async function fetchManifestEntries(uri) {
|
|
|
256953
256953
|
});
|
|
256954
256954
|
continue;
|
|
256955
256955
|
}
|
|
256956
|
-
entries[sourceEntityId] = {
|
|
256956
|
+
entries[sourceEntityId] = {
|
|
256957
|
+
tableName: physicalTableName,
|
|
256958
|
+
dataAsOf: entry.dataAsOf,
|
|
256959
|
+
freshnessWindowSeconds: entry.freshnessWindowSeconds,
|
|
256960
|
+
freshnessFallback: entry.freshnessFallback
|
|
256961
|
+
};
|
|
256957
256962
|
}
|
|
256958
256963
|
return entries;
|
|
256959
256964
|
}
|
|
@@ -257537,6 +257542,7 @@ class Model {
|
|
|
257537
257542
|
fileLevelAuthorize = [];
|
|
257538
257543
|
discoveryCurationEnabled = false;
|
|
257539
257544
|
queryBoundary = { mode: "all", exploresDeclared: false, isQueryEntryPoint: true };
|
|
257545
|
+
freshnessResolver;
|
|
257540
257546
|
meter = publisherMeter();
|
|
257541
257547
|
queryExecutionHistogram = this.meter.createHistogram("malloy_model_query_duration", {
|
|
257542
257548
|
description: "How long it takes to execute a Malloy model query",
|
|
@@ -257749,6 +257755,13 @@ class Model {
|
|
|
257749
257755
|
setDiscoveryCuration(enabled) {
|
|
257750
257756
|
this.discoveryCurationEnabled = enabled;
|
|
257751
257757
|
}
|
|
257758
|
+
setFreshnessResolver(resolver) {
|
|
257759
|
+
this.freshnessResolver = resolver;
|
|
257760
|
+
}
|
|
257761
|
+
resolveFreshBuildManifest() {
|
|
257762
|
+
const entries = this.freshnessResolver?.();
|
|
257763
|
+
return entries ? { entries, strict: false } : undefined;
|
|
257764
|
+
}
|
|
257752
257765
|
getSources() {
|
|
257753
257766
|
return this.curateForDiscovery(this.sources);
|
|
257754
257767
|
}
|
|
@@ -257993,12 +258006,18 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
257993
258006
|
}
|
|
257994
258007
|
const maxRows = getMaxQueryRows();
|
|
257995
258008
|
const maxBytes = getMaxResponseBytes();
|
|
257996
|
-
const
|
|
258009
|
+
const buildManifest = this.resolveFreshBuildManifest();
|
|
258010
|
+
const rowLimit = resolveModelQueryRowLimit((await runnable.getPreparedResult({ givens, buildManifest })).resultExplore.limit, { defaultLimit: getDefaultQueryRowLimit(), maxRows });
|
|
257997
258011
|
const endTime = performance.now();
|
|
257998
258012
|
const executionTime = endTime - startTime;
|
|
257999
258013
|
let queryResults;
|
|
258000
258014
|
try {
|
|
258001
|
-
queryResults = await runnable.run({
|
|
258015
|
+
queryResults = await runnable.run({
|
|
258016
|
+
rowLimit,
|
|
258017
|
+
givens,
|
|
258018
|
+
abortSignal,
|
|
258019
|
+
buildManifest
|
|
258020
|
+
});
|
|
258002
258021
|
} catch (error) {
|
|
258003
258022
|
const errorEndTime = performance.now();
|
|
258004
258023
|
const errorExecutionTime = errorEndTime - startTime;
|
|
@@ -258125,14 +258144,19 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
258125
258144
|
}
|
|
258126
258145
|
const cellMaxRows = getMaxQueryRows();
|
|
258127
258146
|
const cellMaxBytes = getMaxResponseBytes();
|
|
258128
|
-
const
|
|
258147
|
+
const buildManifest = this.resolveFreshBuildManifest();
|
|
258148
|
+
const rowLimit = resolveModelQueryRowLimit((await runnableToExecute.getPreparedResult({
|
|
258149
|
+
givens,
|
|
258150
|
+
buildManifest
|
|
258151
|
+
})).resultExplore.limit, {
|
|
258129
258152
|
defaultLimit: getDefaultQueryRowLimit(),
|
|
258130
258153
|
maxRows: cellMaxRows
|
|
258131
258154
|
});
|
|
258132
258155
|
const result = await runnableToExecute.run({
|
|
258133
258156
|
rowLimit,
|
|
258134
258157
|
givens,
|
|
258135
|
-
abortSignal
|
|
258158
|
+
abortSignal,
|
|
258159
|
+
buildManifest
|
|
258136
258160
|
});
|
|
258137
258161
|
const query = (await runnableToExecute.getPreparedQuery())._query;
|
|
258138
258162
|
queryName = query.as || query.name;
|
|
@@ -258553,6 +258577,46 @@ async function computePackageBuildPlan(pkg, signal) {
|
|
|
258553
258577
|
return deriveBuildPlan(compiled.graphs, compiled.sources, compiled.connectionDigests, undefined, compiled.sourceModelPaths);
|
|
258554
258578
|
}
|
|
258555
258579
|
|
|
258580
|
+
// src/service/freshness.ts
|
|
258581
|
+
function evaluateManifestFreshness(entry, now) {
|
|
258582
|
+
if (entry.freshnessWindowSeconds == null)
|
|
258583
|
+
return "serve_table";
|
|
258584
|
+
if (entry.dataAsOf == null)
|
|
258585
|
+
return "serve_table";
|
|
258586
|
+
const dataAsOfMs = Date.parse(entry.dataAsOf);
|
|
258587
|
+
if (Number.isNaN(dataAsOfMs))
|
|
258588
|
+
return "serve_table";
|
|
258589
|
+
const ageSeconds = (now.getTime() - dataAsOfMs) / 1000;
|
|
258590
|
+
const stale = ageSeconds > entry.freshnessWindowSeconds;
|
|
258591
|
+
if (!stale)
|
|
258592
|
+
return "serve_table";
|
|
258593
|
+
return entry.freshnessFallback === "stale_ok" ? "serve_table" : "serve_live";
|
|
258594
|
+
}
|
|
258595
|
+
function staleSinceMs(entry) {
|
|
258596
|
+
if (entry.freshnessWindowSeconds == null || entry.dataAsOf == null) {
|
|
258597
|
+
return null;
|
|
258598
|
+
}
|
|
258599
|
+
const dataAsOfMs = Date.parse(entry.dataAsOf);
|
|
258600
|
+
if (Number.isNaN(dataAsOfMs))
|
|
258601
|
+
return null;
|
|
258602
|
+
return dataAsOfMs + entry.freshnessWindowSeconds * 1000;
|
|
258603
|
+
}
|
|
258604
|
+
function filterFreshManifest(entries, now) {
|
|
258605
|
+
const manifest = {};
|
|
258606
|
+
let nextStaleSince = null;
|
|
258607
|
+
const nowMs = now.getTime();
|
|
258608
|
+
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
258609
|
+
if (evaluateManifestFreshness(entry, now) === "serve_live")
|
|
258610
|
+
continue;
|
|
258611
|
+
manifest[sourceEntityId] = { tableName: entry.tableName };
|
|
258612
|
+
const flipAt = staleSinceMs(entry);
|
|
258613
|
+
if (flipAt != null && flipAt > nowMs) {
|
|
258614
|
+
nextStaleSince = nextStaleSince == null ? flipAt : Math.min(nextStaleSince, flipAt);
|
|
258615
|
+
}
|
|
258616
|
+
}
|
|
258617
|
+
return { manifest, nextStaleSince };
|
|
258618
|
+
}
|
|
258619
|
+
|
|
258556
258620
|
// src/service/persist_annotation_validation.ts
|
|
258557
258621
|
init_errors();
|
|
258558
258622
|
var PERSIST_LINE_PATTERN = /^\s*#@\s+persist\b/;
|
|
@@ -258575,6 +258639,14 @@ function assertPersistNamesQuoted(modelSource, modelPath) {
|
|
|
258575
258639
|
}
|
|
258576
258640
|
|
|
258577
258641
|
// src/service/package.ts
|
|
258642
|
+
function toTableNameManifest(entries) {
|
|
258643
|
+
const out = {};
|
|
258644
|
+
for (const [sourceEntityId, entry] of Object.entries(entries)) {
|
|
258645
|
+
out[sourceEntityId] = { tableName: entry.tableName };
|
|
258646
|
+
}
|
|
258647
|
+
return out;
|
|
258648
|
+
}
|
|
258649
|
+
|
|
258578
258650
|
class Package {
|
|
258579
258651
|
environmentName;
|
|
258580
258652
|
packageName;
|
|
@@ -258584,6 +258656,8 @@ class Package {
|
|
|
258584
258656
|
packagePath;
|
|
258585
258657
|
malloyConfig;
|
|
258586
258658
|
buildManifestEntries;
|
|
258659
|
+
freshnessEntries;
|
|
258660
|
+
freshManifestCache;
|
|
258587
258661
|
manifestBindingStatus = "unbound";
|
|
258588
258662
|
manifestEntryCount = 0;
|
|
258589
258663
|
boundManifestUri = null;
|
|
@@ -258743,6 +258817,7 @@ class Package {
|
|
|
258743
258817
|
});
|
|
258744
258818
|
const pkg = new Package(environmentName, packageName, packagePath, packageConfig, databases, models, malloyConfig);
|
|
258745
258819
|
pkg.renderTagWarnings = renderTagWarnings;
|
|
258820
|
+
pkg.wireFreshnessResolvers();
|
|
258746
258821
|
try {
|
|
258747
258822
|
const buildPlanStart = Date.now();
|
|
258748
258823
|
pkg.buildPlan = await computePackageBuildPlan(pkg);
|
|
@@ -258797,17 +258872,37 @@ class Package {
|
|
|
258797
258872
|
getBuildManifestEntries() {
|
|
258798
258873
|
return this.buildManifestEntries;
|
|
258799
258874
|
}
|
|
258875
|
+
getFreshBuildManifest(now = Date.now()) {
|
|
258876
|
+
if (!this.freshnessEntries)
|
|
258877
|
+
return;
|
|
258878
|
+
if (this.freshManifestCache && now < this.freshManifestCache.validUntil) {
|
|
258879
|
+
return this.freshManifestCache.manifest;
|
|
258880
|
+
}
|
|
258881
|
+
const { manifest, nextStaleSince } = filterFreshManifest(this.freshnessEntries, new Date(now));
|
|
258882
|
+
this.freshManifestCache = {
|
|
258883
|
+
manifest,
|
|
258884
|
+
validUntil: nextStaleSince ?? Infinity
|
|
258885
|
+
};
|
|
258886
|
+
return manifest;
|
|
258887
|
+
}
|
|
258888
|
+
wireFreshnessResolvers() {
|
|
258889
|
+
for (const model of this.models.values()) {
|
|
258890
|
+
model.setFreshnessResolver(() => this.getFreshBuildManifest());
|
|
258891
|
+
}
|
|
258892
|
+
}
|
|
258800
258893
|
setBoundManifestUri(uri) {
|
|
258801
258894
|
this.boundManifestUri = uri;
|
|
258802
258895
|
}
|
|
258803
258896
|
markManifestBindFailed() {
|
|
258804
258897
|
this.manifestBindingStatus = "live_fallback";
|
|
258805
258898
|
}
|
|
258806
|
-
recordManifestBinding(
|
|
258807
|
-
const count = Object.keys(
|
|
258808
|
-
this.
|
|
258899
|
+
recordManifestBinding(entries) {
|
|
258900
|
+
const count = Object.keys(entries).length;
|
|
258901
|
+
this.freshnessEntries = count > 0 ? entries : undefined;
|
|
258902
|
+
this.buildManifestEntries = count > 0 ? toTableNameManifest(entries) : undefined;
|
|
258809
258903
|
this.manifestEntryCount = count;
|
|
258810
258904
|
this.manifestBindingStatus = count > 0 ? "bound" : "unbound";
|
|
258905
|
+
this.freshManifestCache = undefined;
|
|
258811
258906
|
if (count === 0) {
|
|
258812
258907
|
this.boundManifestUri = null;
|
|
258813
258908
|
}
|
|
@@ -258844,8 +258939,9 @@ class Package {
|
|
|
258844
258939
|
const schedule = this.packageMetadata.materialization?.schedule;
|
|
258845
258940
|
if (!schedule)
|
|
258846
258941
|
return [];
|
|
258847
|
-
|
|
258848
|
-
|
|
258942
|
+
return [
|
|
258943
|
+
`materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} is not accepted: ` + `package-level crons are rejected in Phase B. Declare ` + `'materialization.freshness.window' instead (the control plane schedules ` + `refreshes from it).`
|
|
258944
|
+
];
|
|
258849
258945
|
}
|
|
258850
258946
|
formatInvalidSchedule() {
|
|
258851
258947
|
return this.scheduleWarnings().join(`
|
|
@@ -258891,7 +258987,8 @@ class Package {
|
|
|
258891
258987
|
getModelPaths() {
|
|
258892
258988
|
return Array.from(this.models.keys());
|
|
258893
258989
|
}
|
|
258894
|
-
async reloadAllModels(
|
|
258990
|
+
async reloadAllModels(entries) {
|
|
258991
|
+
const buildManifest = toTableNameManifest(entries);
|
|
258895
258992
|
const modelPaths = Array.from(this.models.keys());
|
|
258896
258993
|
logger.info("Reloading all models with build manifest", {
|
|
258897
258994
|
packageName: this.packageName,
|
|
@@ -258951,7 +259048,8 @@ class Package {
|
|
|
258951
259048
|
this.packageMetadata.manifestLocation = outcome.packageMetadata.manifestLocation ?? null;
|
|
258952
259049
|
this.applyDiscoveryPolicyToModels();
|
|
258953
259050
|
this.applyQueryBoundaryToModels();
|
|
258954
|
-
this.recordManifestBinding(
|
|
259051
|
+
this.recordManifestBinding(entries);
|
|
259052
|
+
this.wireFreshnessResolvers();
|
|
258955
259053
|
const invalidMsg = this.formatInvalidExplores();
|
|
258956
259054
|
if (invalidMsg) {
|
|
258957
259055
|
logger.warn(`Package ${this.packageName} has invalid explores`, {
|
|
@@ -265777,7 +265875,162 @@ view: rev_delta is {
|
|
|
265777
265875
|
}
|
|
265778
265876
|
\`\`\`
|
|
265779
265877
|
|
|
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
|
|
265878
|
+
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
|
|
265879
|
+
|
|
265880
|
+
> 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.
|
|
265881
|
+
|
|
265882
|
+
## When this is the right tool
|
|
265883
|
+
|
|
265884
|
+
| The user wants | Use |
|
|
265885
|
+
|---|---|
|
|
265886
|
+
| A hand-authored HTML/JS dashboard, no toolchain | this skill (an HTML data app) |
|
|
265887
|
+
| A React app with managed components | the Publisher React SDK (out of scope here) |
|
|
265888
|
+
| An analyst notebook with charts | a Malloy notebook (\`.malloynb\`) |
|
|
265889
|
+
| Point-and-click exploration, no code | the Publisher Explorer |
|
|
265890
|
+
|
|
265891
|
+
Pick an HTML data app when the user wants full control of the markup and only plain web files.
|
|
265892
|
+
|
|
265893
|
+
## Package anatomy
|
|
265894
|
+
|
|
265895
|
+
\`\`\`
|
|
265896
|
+
my-package/
|
|
265897
|
+
publisher.json # name, version, description
|
|
265898
|
+
carriers.malloy # the model(s), stays private
|
|
265899
|
+
carriers.parquet # data, stays private
|
|
265900
|
+
public/ # ONLY this directory is web-served
|
|
265901
|
+
index.html
|
|
265902
|
+
app.js
|
|
265903
|
+
\`\`\`
|
|
265904
|
+
|
|
265905
|
+
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.
|
|
265906
|
+
|
|
265907
|
+
## Build sequence
|
|
265908
|
+
|
|
265909
|
+
The agent orchestrates these. Each query and chart step hands off to a focused skill.
|
|
265910
|
+
|
|
265911
|
+
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.
|
|
265912
|
+
2. SCAFFOLD the package (template below).
|
|
265913
|
+
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\`.
|
|
265914
|
+
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.)
|
|
265915
|
+
5. EMBED (optional) with \`skill:html-data-app-embedding\`.
|
|
265916
|
+
6. PREVIEW with the local authoring loop (below).
|
|
265917
|
+
7. VERIFY before you call it done (see "What 'done' means" below). This step is not optional.
|
|
265918
|
+
|
|
265919
|
+
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.
|
|
265920
|
+
|
|
265921
|
+
## What "done" means (production recipe)
|
|
265922
|
+
|
|
265923
|
+
A data app you can defend has all of these. Build to this list, not to the scaffold.
|
|
265924
|
+
|
|
265925
|
+
- **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.
|
|
265926
|
+
- **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.
|
|
265927
|
+
- **Every tile handles loading, empty, and error on its own.** One failing query must not blank the page. (\`skill:html-data-app-runtime\`.)
|
|
265928
|
+
- **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\`.)
|
|
265929
|
+
- **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.
|
|
265930
|
+
- **Looks decent.** Give it real layout, type, and color: a styled card grid with a clear hierarchy, not raw unstyled tables.
|
|
265931
|
+
- **Vendored libraries.** Chart and helper libraries live in \`public/\`, loaded locally (step 4).
|
|
265932
|
+
|
|
265933
|
+
### Verify before you call it done
|
|
265934
|
+
|
|
265935
|
+
You are building for someone who cannot tell a correct dashboard from a broken one. Verification is your job, not theirs.
|
|
265936
|
+
|
|
265937
|
+
- **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.
|
|
265938
|
+
- **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\`).
|
|
265939
|
+
- **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.
|
|
265940
|
+
|
|
265941
|
+
## Minimal scaffold
|
|
265942
|
+
|
|
265943
|
+
\`publisher.json\` at the package root:
|
|
265944
|
+
|
|
265945
|
+
\`\`\`json
|
|
265946
|
+
{ "name": "my-package", "version": "0.0.1", "description": "..." }
|
|
265947
|
+
\`\`\`
|
|
265948
|
+
|
|
265949
|
+
\`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.
|
|
265950
|
+
|
|
265951
|
+
Start with the smallest page that proves the wiring, dumping the rows:
|
|
265952
|
+
|
|
265953
|
+
\`\`\`html
|
|
265954
|
+
<!doctype html>
|
|
265955
|
+
<title>My dashboard</title>
|
|
265956
|
+
<pre id="out"></pre>
|
|
265957
|
+
<script src="/sdk/publisher.js"></script>
|
|
265958
|
+
<script>
|
|
265959
|
+
Publisher.query("carriers.malloy", "run: carriers -> by_letter").then((rows) => {
|
|
265960
|
+
document.getElementById("out").textContent = JSON.stringify(rows, null, 2);
|
|
265961
|
+
});
|
|
265962
|
+
</script>
|
|
265963
|
+
\`\`\`
|
|
265964
|
+
|
|
265965
|
+
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:
|
|
265966
|
+
|
|
265967
|
+
\`\`\`html
|
|
265968
|
+
<!doctype html>
|
|
265969
|
+
<title>Carriers by letter</title>
|
|
265970
|
+
<table id="t"><thead></thead><tbody></tbody></table>
|
|
265971
|
+
<script src="/sdk/publisher.js"></script>
|
|
265972
|
+
<script>
|
|
265973
|
+
Publisher.query("carriers.malloy", "run: carriers -> by_letter").then((rows) => {
|
|
265974
|
+
const t = document.getElementById("t");
|
|
265975
|
+
if (!rows.length) { t.textContent = "No rows."; return; }
|
|
265976
|
+
const cols = Object.keys(rows[0]);
|
|
265977
|
+
const headRow = t.tHead.insertRow();
|
|
265978
|
+
for (const c of cols) {
|
|
265979
|
+
const th = document.createElement("th");
|
|
265980
|
+
th.textContent = c;
|
|
265981
|
+
headRow.appendChild(th);
|
|
265982
|
+
}
|
|
265983
|
+
for (const r of rows) {
|
|
265984
|
+
const tr = t.tBodies[0].insertRow();
|
|
265985
|
+
for (const c of cols) tr.insertCell().textContent = r[c];
|
|
265986
|
+
}
|
|
265987
|
+
});
|
|
265988
|
+
</script>
|
|
265989
|
+
\`\`\`
|
|
265990
|
+
|
|
265991
|
+
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.
|
|
265992
|
+
|
|
265993
|
+
Two invariants break a page most often:
|
|
265994
|
+
|
|
265995
|
+
- **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>\`.
|
|
265996
|
+
- **The script src must be the root-relative \`/sdk/publisher.js\`**, not a relative path.
|
|
265997
|
+
|
|
265998
|
+
A third gotcha: the first argument to \`Publisher.query\` is the model FILE path (\`"carriers.malloy"\`), not the source name.
|
|
265999
|
+
|
|
266000
|
+
## Authoring loop and publishing
|
|
266001
|
+
|
|
266002
|
+
Authoring happens locally, then you publish. These are two stages.
|
|
266003
|
+
|
|
266004
|
+
### Author locally (with live reload)
|
|
266005
|
+
|
|
266006
|
+
Run a local Publisher from the directory that holds your \`publisher.config.json\` and package folder(s):
|
|
266007
|
+
|
|
266008
|
+
\`\`\`sh
|
|
266009
|
+
npx @malloy-publisher/server --server_root . --port 4000 --watch-env <env>
|
|
266010
|
+
\`\`\`
|
|
266011
|
+
|
|
266012
|
+
\`--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\`.
|
|
266013
|
+
|
|
266014
|
+
\`publisher.config.json\` (at \`--server_root\`) declares the environment, its packages, and its connections:
|
|
266015
|
+
|
|
266016
|
+
\`\`\`json
|
|
266017
|
+
{
|
|
266018
|
+
"frozenConfig": false,
|
|
266019
|
+
"environments": [
|
|
266020
|
+
{
|
|
266021
|
+
"name": "<env>",
|
|
266022
|
+
"packages": [{ "name": "<pkg>", "location": "./<pkg>" }],
|
|
266023
|
+
"connections": []
|
|
266024
|
+
}
|
|
266025
|
+
]
|
|
266026
|
+
}
|
|
266027
|
+
\`\`\`
|
|
266028
|
+
|
|
266029
|
+
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.)
|
|
266030
|
+
|
|
266031
|
+
### Publishing
|
|
266032
|
+
|
|
266033
|
+
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
266034
|
|
|
265782
266035
|
> **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
266036
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloy-publisher/server",
|
|
3
3
|
"description": "Malloy Publisher Server",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.224",
|
|
5
5
|
"main": "dist/server.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"malloy-publisher": "dist/server.mjs"
|
|
@@ -36,17 +36,17 @@
|
|
|
36
36
|
"@azure/storage-blob": "^12.26.0",
|
|
37
37
|
"@duckdb/node-api": "1.5.3-r.2",
|
|
38
38
|
"@google-cloud/storage": "^7.16.0",
|
|
39
|
-
"@malloydata/db-bigquery": "^0.0.
|
|
40
|
-
"@malloydata/db-databricks": "^0.0.
|
|
41
|
-
"@malloydata/db-duckdb": "^0.0.
|
|
42
|
-
"@malloydata/db-mysql": "^0.0.
|
|
43
|
-
"@malloydata/db-postgres": "^0.0.
|
|
44
|
-
"@malloydata/db-publisher": "^0.0.
|
|
45
|
-
"@malloydata/db-snowflake": "^0.0.
|
|
46
|
-
"@malloydata/db-trino": "^0.0.
|
|
47
|
-
"@malloydata/malloy": "^0.0.
|
|
48
|
-
"@malloydata/malloy-sql": "^0.0.
|
|
49
|
-
"@malloydata/render-validator": "^0.0.
|
|
39
|
+
"@malloydata/db-bigquery": "^0.0.422",
|
|
40
|
+
"@malloydata/db-databricks": "^0.0.422",
|
|
41
|
+
"@malloydata/db-duckdb": "^0.0.422",
|
|
42
|
+
"@malloydata/db-mysql": "^0.0.422",
|
|
43
|
+
"@malloydata/db-postgres": "^0.0.422",
|
|
44
|
+
"@malloydata/db-publisher": "^0.0.422",
|
|
45
|
+
"@malloydata/db-snowflake": "^0.0.422",
|
|
46
|
+
"@malloydata/db-trino": "^0.0.422",
|
|
47
|
+
"@malloydata/malloy": "^0.0.422",
|
|
48
|
+
"@malloydata/malloy-sql": "^0.0.422",
|
|
49
|
+
"@malloydata/render-validator": "^0.0.422",
|
|
50
50
|
"@modelcontextprotocol/sdk": "^1.13.2",
|
|
51
51
|
"@opentelemetry/api": "^1.9.0",
|
|
52
52
|
"@opentelemetry/auto-instrumentations-node": "^0.57.0",
|