@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.
Files changed (43) hide show
  1. package/dist/app/api-doc.yaml +75 -0
  2. package/dist/app/assets/{EnvironmentPage-3wCdljov.js → EnvironmentPage-DYTeXDll.js} +1 -1
  3. package/dist/app/assets/{HomePage-K0GHwqq2.js → HomePage-pDK2BPJY.js} +1 -1
  4. package/dist/app/assets/{LightMode-CwU3kN4I.js → LightMode-C2bwGPY1.js} +1 -1
  5. package/dist/app/assets/{MainPage-CTncHE5T.js → MainPage-WtBulMH_.js} +2 -2
  6. package/dist/app/assets/{MaterializationsPage-DziAOD6-.js → MaterializationsPage-hMgOtflG.js} +1 -1
  7. package/dist/app/assets/{ModelPage-XrS2jXEc.js → ModelPage-B2N5kYII.js} +1 -1
  8. package/dist/app/assets/{PackagePage-BkwgFxG8.js → PackagePage-CEN90nQG.js} +1 -1
  9. package/dist/app/assets/{RouteError-9cIQga6p.js → RouteError-BG2c5Zf0.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-DWiCRfEe.js → ThemeEditorPage-DNfeUwEZ.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-Ce7FM_Po.js → WorkbookPage-NKI1BhFS.js} +1 -1
  12. package/dist/app/assets/{core-D9Hl0IDY.es-CrO01m4X.js → core-C6anj5c0.es-DDLHqpzt.js} +1 -1
  13. package/dist/app/assets/{index-D2sm5RBA.js → index-C6gZ6sSY.js} +5 -5
  14. package/dist/app/assets/{index-CtQm7kvp.js → index-CzNqKMVl.js} +1 -1
  15. package/dist/app/assets/{index-wJU_kzl2.js → index-DMQtnaf4.js} +2 -2
  16. package/dist/app/assets/{index-Dz8hgkmy.js → index-JXgvyZna.js} +1 -1
  17. package/dist/app/assets/{index-8E2uLeV9.js → index-OEjKNSYb.js} +2 -2
  18. package/dist/app/index.html +1 -1
  19. package/dist/server.mjs +411 -26
  20. package/dist/sshcrypto-8m50vnmb.node +0 -0
  21. package/package.json +12 -12
  22. package/src/controller/materialization.controller.spec.ts +72 -0
  23. package/src/controller/materialization.controller.ts +75 -11
  24. package/src/mcp/skills/skills_bundle.json +1 -1
  25. package/src/service/build_plan.spec.ts +119 -0
  26. package/src/service/build_plan.ts +143 -0
  27. package/src/service/environment.ts +3 -3
  28. package/src/service/freshness.spec.ts +183 -0
  29. package/src/service/freshness.ts +112 -0
  30. package/src/service/manifest_loader.spec.ts +33 -0
  31. package/src/service/manifest_loader.ts +17 -8
  32. package/src/service/materialization_cron_gate.spec.ts +61 -17
  33. package/src/service/materialization_service.spec.ts +42 -0
  34. package/src/service/materialization_service.ts +46 -3
  35. package/src/service/materialization_test_fixtures.ts +47 -14
  36. package/src/service/model.ts +54 -4
  37. package/src/service/package.ts +173 -35
  38. package/src/storage/DatabaseInterface.ts +24 -0
  39. package/tests/fixtures/persist-multi-level/data/orders.csv +5 -0
  40. package/tests/fixtures/persist-multi-level/multi_level.malloy +18 -0
  41. package/tests/fixtures/persist-multi-level/publisher.json +5 -0
  42. package/tests/integration/materialization/freshness_gate.integration.spec.ts +292 -0
  43. 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 = (()=>{throw new Error("Cannot require module "+"./crypto/build/Release/sshcrypto.node");})();
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] = { tableName: physicalTableName };
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 rowLimit = resolveModelQueryRowLimit((await runnable.getPreparedResult({ givens })).resultExplore.limit, { defaultLimit: getDefaultQueryRowLimit(), maxRows });
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({ rowLimit, givens, abortSignal });
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 rowLimit = resolveModelQueryRowLimit((await runnableToExecute.getPreparedResult({ givens })).resultExplore.limit, {
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(buildManifest) {
258807
- const count = Object.keys(buildManifest).length;
258808
- this.buildManifestEntries = count > 0 ? buildManifest : undefined;
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 schedule = this.packageMetadata.materialization?.schedule;
258845
- if (!schedule)
258846
- return [];
258847
- const sources = Object.values(this.buildPlan?.sources ?? {});
258848
- return sources.filter((source) => source.sharing !== "private").map((source) => `materialization.schedule (cron) in ${PACKAGE_MANIFEST_NAME} requires every ` + `persist source to declare '#@ persist ... sharing=private'; source ` + `'${source.name}' resolves to ${source.sharing ? `'${source.sharing}'` : "unset"}. Declare 'materialization.freshness.window' instead (the control plane ` + `schedules refreshes from it), or mark every persist source sharing=private.`);
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(buildManifest) {
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(buildManifest);
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
- result.buildInstructions = this.validateBuildInstructions(body.buildInstructions);
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 sources = raw.sources;
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
- return sources.map((instruction) => this.validateInstruction(instruction));
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) {