@malloy-publisher/server 0.0.205 → 0.0.207

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 (69) hide show
  1. package/dist/app/api-doc.yaml +394 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
  12. package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
  13. package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +18 -8
  17. package/dist/package_load_worker.mjs +19 -2
  18. package/dist/server.mjs +1272 -1299
  19. package/package.json +1 -1
  20. package/src/constants.ts +12 -0
  21. package/src/controller/materialization.controller.ts +79 -35
  22. package/src/controller/package.controller.spec.ts +179 -0
  23. package/src/controller/package.controller.ts +60 -73
  24. package/src/dto/package.dto.ts +16 -1
  25. package/src/errors.spec.ts +12 -0
  26. package/src/errors.ts +18 -0
  27. package/src/health.spec.ts +34 -1
  28. package/src/instrumentation.ts +33 -17
  29. package/src/materialization_metrics.ts +121 -0
  30. package/src/package_load/package_load_pool.ts +7 -1
  31. package/src/package_load/package_load_worker.ts +44 -4
  32. package/src/package_load/protocol.ts +7 -1
  33. package/src/server-old.ts +7 -149
  34. package/src/server.ts +9 -188
  35. package/src/service/authorize_integration.spec.ts +67 -0
  36. package/src/service/environment.ts +270 -12
  37. package/src/service/environment_store.spec.ts +0 -81
  38. package/src/service/environment_store.ts +0 -23
  39. package/src/service/explore_visibility.spec.ts +434 -0
  40. package/src/service/exports_probe.spec.ts +107 -0
  41. package/src/service/manifest_loader.spec.ts +99 -0
  42. package/src/service/manifest_loader.ts +95 -0
  43. package/src/service/materialization_service.spec.ts +584 -500
  44. package/src/service/materialization_service.ts +839 -657
  45. package/src/service/model.ts +419 -15
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +339 -29
  48. package/src/service/package_rollback.spec.ts +190 -0
  49. package/src/service/package_worker_path.spec.ts +223 -0
  50. package/src/service/query_boundary.spec.ts +470 -0
  51. package/src/storage/DatabaseInterface.ts +35 -57
  52. package/src/storage/StorageManager.mock.ts +0 -9
  53. package/src/storage/StorageManager.ts +7 -290
  54. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  55. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  56. package/src/storage/duckdb/schema.ts +4 -20
  57. package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
  59. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  60. package/src/controller/manifest.controller.ts +0 -38
  61. package/src/service/manifest_service.spec.ts +0 -206
  62. package/src/service/manifest_service.ts +0 -117
  63. package/src/service/materialized_table_gc.spec.ts +0 -384
  64. package/src/service/materialized_table_gc.ts +0 -231
  65. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  66. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  67. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  68. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  69. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -42,6 +42,7 @@ import {
42
42
  BadRequestError,
43
43
  ModelCompilationError,
44
44
  ModelNotFoundError,
45
+ NotQueryableError,
45
46
  PayloadTooLargeError,
46
47
  } from "../errors";
47
48
  import { logger } from "../logger";
@@ -101,6 +102,18 @@ interface RunnableNotebookCell {
101
102
  queryInfo?: Malloy.QueryInfo;
102
103
  }
103
104
 
105
+ /**
106
+ * Backtick-quote a Malloy identifier for safe interpolation into a `run:`
107
+ * query string. Escapes backslashes and backticks (in that order) so a name
108
+ * that needs Malloy quoting (hyphen, space, reserved word, leading digit) or
109
+ * contains an embedded backtick cannot break out of the quotes. Mirrors
110
+ * malloy's internal `identifierCode` / `escapeIdentifier` (to_stable.ts), which
111
+ * is not exported.
112
+ */
113
+ function quoteMalloyIdentifier(name: string | undefined): string {
114
+ return "`" + (name ?? "").replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
115
+ }
116
+
104
117
  export class Model {
105
118
  private packageName: string;
106
119
  private modelPath: string;
@@ -123,6 +136,21 @@ export class Model {
123
136
  /** Model-wide `##(authorize)` expressions; apply to every query in the
124
137
  * model, including ad-hoc inline sources not declared in the model. */
125
138
  private fileLevelAuthorize: string[] = [];
139
+ /** Whether discovery accessors curate to the `export {}` closure. Pushed
140
+ * down by the owning Package (see Package.applyDiscoveryPolicyToModels):
141
+ * true only when the package declares `explores` in publisher.json.
142
+ * Defaults to false (legacy listings) so a Model created outside a
143
+ * Package matches pre-opt-in behavior. */
144
+ private discoveryCurationEnabled = false;
145
+ /** Per-package query-boundary policy, pushed down by the owning Package
146
+ * (see `Package.applyQueryBoundaryToModels`). Defaults are inert (mode
147
+ * "all" / not declared) so a Model created outside a Package — or before
148
+ * the policy is applied — never spuriously denies. */
149
+ private queryBoundary: {
150
+ mode: "declared" | "all";
151
+ exploresDeclared: boolean;
152
+ isQueryEntryPoint: boolean;
153
+ } = { mode: "all", exploresDeclared: false, isQueryEntryPoint: true };
126
154
  private meter = metrics.getMeter("publisher");
127
155
  private queryExecutionHistogram = this.meter.createHistogram(
128
156
  "malloy_model_query_duration",
@@ -389,14 +417,7 @@ export class Model {
389
417
  const target = this.extractSourceName(query);
390
418
  if (!target || !query) return undefined;
391
419
 
392
- // Map each ad-hoc source name to the base it derives from
393
- // (`source: NAME is BASE ...` → NAME → BASE).
394
- const aliasOf = new Map<string, string>();
395
- const declRe = /source\s*:\s*(\w+)\s+is\s+(\w+)/g;
396
- let match: RegExpExecArray | null;
397
- while ((match = declRe.exec(query)) !== null) {
398
- aliasOf.set(match[1], match[2]);
399
- }
420
+ const aliasOf = Model.buildAliasMap(query);
400
421
 
401
422
  // Walk the derivation chain until we hit a protected source or run out.
402
423
  let current: string | undefined = target;
@@ -585,6 +606,7 @@ export class Model {
585
606
  _packagePath: string,
586
607
  malloyConfig: ModelConnectionInput,
587
608
  data: SerializedModel,
609
+ options?: { buildManifest?: BuildManifest["entries"] },
588
610
  ): Model {
589
611
  const modelDef = data.modelDef as ModelDef | undefined;
590
612
  const modelInfo = data.modelInfo as Malloy.ModelInfo | undefined;
@@ -624,7 +646,10 @@ export class Model {
624
646
  );
625
647
  }
626
648
 
627
- const runtime = makeHydrationRuntime(malloyConfig);
649
+ const runtime = makeHydrationRuntime(
650
+ malloyConfig,
651
+ options?.buildManifest,
652
+ );
628
653
  const modelMaterializer = runtime._loadModelFromModelDef(modelDef);
629
654
  const runnableNotebookCells =
630
655
  data.modelType === "notebook"
@@ -688,16 +713,353 @@ export class Model {
688
713
  return this.modelType;
689
714
  }
690
715
 
716
+ /**
717
+ * Restrict a list of named objects to the model's re-export closure
718
+ * (`modelDef.exports`) for discovery. This mirrors what Malloy's stable
719
+ * `modelDefToModelInfo` already does for `modelInfo`/`sourceInfos` (and what
720
+ * the app renders), so the publisher-extracted `sources`/`queries` stay
721
+ * consistent with `modelInfo` within a single response. A model with no
722
+ * `export { … }` has `exports` = all top-level names, so this is a no-op
723
+ * there. Only active when the package declares `explores` (see
724
+ * `discoveryCurationEnabled`). `this.sources`/`this.queries` stay complete so
725
+ * #(authorize)/filter enforcement and join/extend resolution are unaffected.
726
+ * Whether a non-exported source is also non-*queryable* depends on the
727
+ * package's `queryableSources` policy — see {@link assertQueryBoundaryEarly}.
728
+ */
729
+ private curateForDiscovery<T extends { name?: string }>(
730
+ items: T[] | undefined,
731
+ ): T[] | undefined {
732
+ if (!items) return items;
733
+ if (!this.discoveryCurationEnabled) return items;
734
+ const exports = this.modelDef?.exports;
735
+ if (!Array.isArray(exports)) return items;
736
+ const exported = new Set(exports);
737
+ return items.filter(
738
+ (item) => item.name !== undefined && exported.has(item.name),
739
+ );
740
+ }
741
+
742
+ /** Set by the owning Package; see {@link curateForDiscovery}. */
743
+ public setDiscoveryCuration(enabled: boolean): void {
744
+ this.discoveryCurationEnabled = enabled;
745
+ }
746
+
691
747
  public getSources(): ApiSource[] | undefined {
692
- return this.sources;
748
+ return this.curateForDiscovery(this.sources);
693
749
  }
694
750
 
695
751
  public getSourceInfos(): Malloy.SourceInfo[] | undefined {
696
- return this.sourceInfos;
752
+ return this.curateForDiscovery(this.sourceInfos);
697
753
  }
698
754
 
699
755
  public getQueries(): ApiQuery[] | undefined {
700
- return this.queries;
756
+ return this.curateForDiscovery(this.queries);
757
+ }
758
+
759
+ /**
760
+ * True when this is an import-only model: it imports other files but
761
+ * declares and re-exports nothing of its own, so `modelDef.exports` is
762
+ * empty and its discovery surface lists no sources or queries. Legitimate
763
+ * as plumbing, but confusing when the model is *listed* — the page renders
764
+ * blank. Used by the load-time warning (Package.emptyDiscoveryWarnings);
765
+ * the fix is to re-export what should be visible (`export { name }`).
766
+ */
767
+ public hasEmptyDiscoverySurface(): boolean {
768
+ // No curation (no `explores`) ⇒ legacy listings include imported sources.
769
+ if (!this.discoveryCurationEnabled) return false;
770
+ if (this.modelType !== "model" || !this.modelDef) return false;
771
+ const exports = this.modelDef.exports;
772
+ if (!Array.isArray(exports) || exports.length > 0) return false;
773
+ return (this.modelDef.imports?.length ?? 0) > 0;
774
+ }
775
+
776
+ /** Set by the owning Package; see {@link assertQueryBoundaryEarly}. */
777
+ public setQueryBoundary(policy: {
778
+ mode: "declared" | "all";
779
+ exploresDeclared: boolean;
780
+ isQueryEntryPoint: boolean;
781
+ }): void {
782
+ this.queryBoundary = policy;
783
+ }
784
+
785
+ /**
786
+ * Query boundary, step 1 of 2 — the PRE-compilation gate. Enforces the rule
787
+ * "queryable == discoverable": a source is a valid top-level query target
788
+ * only if it is in the package's discovery surface (`explores` files +
789
+ * their `export {}` closure). This is the *what* axis (identity-free);
790
+ * `#(authorize)` is the orthogonal *who* axis, and both must pass. Denials
791
+ * are a generic 404 ({@link NotQueryableError}) so a hidden target is
792
+ * indistinguishable from a non-existent one. Inert unless `explores` is
793
+ * declared AND mode is "declared" (the default). Notebooks are exempt
794
+ * (always public) and never call this.
795
+ *
796
+ * This step runs before compilation so a denied target can't be probed via
797
+ * compile errors (schema oracle), and it only POSITIVELY denies — explicit
798
+ * names it can check, and ad-hoc text whose surface-resolved target is a
799
+ * model-declared non-curated source. Anything it can't pin returns
800
+ * "deferred" for {@link assertQueryBoundaryCompiled} to settle against the
801
+ * compiled run target. "cleared" admissions (explicit curated names) skip
802
+ * the backstop: an exported named query may read hidden sources internally —
803
+ * that is the author's deliberate exposure, and re-deriving its source from
804
+ * the compiled query must not re-deny it.
805
+ */
806
+ public assertQueryBoundaryEarly(
807
+ sourceName?: string,
808
+ queryName?: string,
809
+ query?: string,
810
+ ): "cleared" | "deferred" {
811
+ // Notebooks are always public — they can't be explores and are never
812
+ // gated by the boundary, even when reached via the /compile path.
813
+ if (this.modelPath.endsWith(NOTEBOOK_FILE_SUFFIX)) return "cleared";
814
+ const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
815
+ // No opt-in surface (no explores) or explicitly decoupled ("all") ⇒ the
816
+ // boundary is discovery-only; everything compiled stays queryable.
817
+ if (mode === "all" || !exploresDeclared) return "cleared";
818
+
819
+ // File-level: a non-`explores` model file is not a query entry point.
820
+ // This is the robust line — the file is named by the request URL, so
821
+ // there is nothing to resolve and nothing to evade.
822
+ if (!isQueryEntryPoint) {
823
+ throw new NotQueryableError(`No queryable model "${this.modelPath}".`);
824
+ }
825
+
826
+ const curatedSources = this.curatedSourceNames();
827
+ const curatedQueries = new Set(
828
+ (this.getQueries() ?? []).map((q) => q.name).filter(Boolean),
829
+ );
830
+
831
+ // A named query/view is an author-exported entry point (the author chose
832
+ // to expose it, even if it reads hidden sources internally) — admit it on
833
+ // its own name, or on the explicitly-named curated source it runs against.
834
+ if (queryName && !query) {
835
+ // The exported-query fast-path admits a *pure* named query (`run: q`)
836
+ // on its own name. It must NOT fire when a source is also named: a
837
+ // request is `run: <sourceName>-><queryName>`, so a hidden source
838
+ // could otherwise be reached via a view whose name happens to collide
839
+ // with an exported top-level query (and clearing skips the compiled
840
+ // backstop). With a source prefix, only the named source's curation
841
+ // gates the request.
842
+ if (!sourceName && curatedQueries.has(queryName)) return "cleared";
843
+ if (sourceName && curatedSources.has(sourceName)) return "cleared";
844
+ throw new NotQueryableError(`No queryable query "${queryName}".`);
845
+ }
846
+
847
+ // An explicitly-named source: admit iff curated.
848
+ if (sourceName) {
849
+ if (curatedSources.has(sourceName)) return "cleared";
850
+ throw new NotQueryableError(`No queryable source "${sourceName}".`);
851
+ }
852
+
853
+ // Ad-hoc text: positively deny only a surface-resolved target that is a
854
+ // model-declared source outside the curated surface (and doesn't derive
855
+ // from a curated one) — pre-compile, so its compile errors can't leak
856
+ // schema. Everything else (inline derivations, multi-statement, forms
857
+ // the regex can't read) defers to the compiled backstop.
858
+ if (query) {
859
+ const target = this.extractSourceName(query);
860
+ if (
861
+ target &&
862
+ !curatedSources.has(target) &&
863
+ !this.derivesFromCurated(target, query) &&
864
+ this.sources?.some((s) => s.name === target)
865
+ ) {
866
+ throw new NotQueryableError(`No queryable source "${target}".`);
867
+ }
868
+ }
869
+ return "deferred";
870
+ }
871
+
872
+ /**
873
+ * Query boundary, step 2 of 2 — the POST-compilation backstop for
874
+ * "deferred" admissions. `compiledSource` is the run target read off the
875
+ * compiled query's `structRef` (see
876
+ * {@link resolveAuthorizeSourceFromRunnable}) — the source Malloy actually
877
+ * executes, surviving named-query indirection, multi-statement forms (the
878
+ * LAST `run:` wins), and derivation. This inspects compiler *output* only;
879
+ * compilation itself is never altered or restricted.
880
+ *
881
+ * Admit iff the compiled target is curated, or is an ad-hoc alias that
882
+ * derives from a curated source (`source: x is customers extend { … }` →
883
+ * `run: x` — composing over a queryable source is itself queryable). FAIL
884
+ * CLOSED otherwise, including when the target can't be resolved at all.
885
+ * (Raw inline `conn.sql(...)` never reaches here on the query path —
886
+ * restricted-mode compilation rejects it first.)
887
+ */
888
+ public assertQueryBoundaryCompiled(
889
+ compiledSource: string | undefined,
890
+ query?: string,
891
+ ): void {
892
+ // Notebooks are always public (mirrors assertQueryBoundaryEarly).
893
+ if (this.modelPath.endsWith(NOTEBOOK_FILE_SUFFIX)) return;
894
+ const { mode, exploresDeclared, isQueryEntryPoint } = this.queryBoundary;
895
+ if (mode === "all" || !exploresDeclared) return;
896
+ if (!isQueryEntryPoint) {
897
+ throw new NotQueryableError(`No queryable model "${this.modelPath}".`);
898
+ }
899
+ if (compiledSource) {
900
+ if (this.curatedSourceNames().has(compiledSource)) return;
901
+ if (query && this.derivesFromCurated(compiledSource, query)) return;
902
+ }
903
+ throw new NotQueryableError("Query target is not queryable.");
904
+ }
905
+
906
+ /**
907
+ * The /compile-path wrapper: resolve the compiled run target from the
908
+ * materializer and apply the boundary backstop. No-ops when the boundary is
909
+ * inert, so the resolution work is skipped for unaffected packages.
910
+ */
911
+ public async assertQueryBoundaryForRunnable(
912
+ runnable: { getPreparedQuery(): Promise<unknown> },
913
+ query?: string,
914
+ ): Promise<void> {
915
+ const { mode, exploresDeclared } = this.queryBoundary;
916
+ if (mode === "all" || !exploresDeclared) return;
917
+ this.assertQueryBoundaryCompiled(
918
+ await this.resolveAuthorizeSourceFromRunnable(runnable),
919
+ query,
920
+ );
921
+ }
922
+
923
+ /** Source names in the export-curated discovery surface (= the directly
924
+ * queryable set under the "declared" boundary). */
925
+ private curatedSourceNames(): Set<string> {
926
+ return new Set(
927
+ (this.getSources() ?? [])
928
+ .map((s) => s.name)
929
+ .filter((n): n is string => n !== undefined),
930
+ );
931
+ }
932
+
933
+ /** True if `name` reaches a curated source by walking the ad-hoc text's
934
+ * `source: NAME is BASE` derivation declarations — composition over a
935
+ * queryable source is itself queryable. */
936
+ private derivesFromCurated(name: string, query: string): boolean {
937
+ const curated = this.curatedSourceNames();
938
+ const aliasOf = Model.buildAliasMap(query);
939
+ let current: string | undefined = name;
940
+ const seen = new Set<string>();
941
+ while (current && !seen.has(current)) {
942
+ if (curated.has(current)) return true;
943
+ seen.add(current);
944
+ current = aliasOf.get(current);
945
+ }
946
+ return false;
947
+ }
948
+
949
+ /** Map each ad-hoc source alias to the base it derives from
950
+ * (`source: NAME is BASE …` → NAME → BASE). Shared by filter inheritance
951
+ * ({@link resolveFilterSource}) and the query boundary, which both walk
952
+ * derivation chains in caller-authored query text. */
953
+ private static buildAliasMap(query: string): Map<string, string> {
954
+ const aliasOf = new Map<string, string>();
955
+ // Match a bare `\w+` or a backtick-quoted Malloy identifier on BOTH sides
956
+ // (a hyphenated source like `customer-orders` needs quoting), same pattern
957
+ // as `extractSourceName`. The quoted capture returns the inner name (no
958
+ // backticks), keeping aliases keyed the way `curatedSourceNames()` and the
959
+ // compiled run target are — otherwise a derivation over a quoted source
960
+ // wouldn't walk back to the curated set and would be falsely denied.
961
+ const declRe =
962
+ /source\s*:\s*(?:`([^`]+)`|(\w+))\s+is\s+(?:`([^`]+)`|(\w+))/g;
963
+ let match: RegExpExecArray | null;
964
+ while ((match = declRe.exec(query)) !== null) {
965
+ aliasOf.set(match[1] ?? match[2], match[3] ?? match[4]);
966
+ }
967
+ return aliasOf;
968
+ }
969
+
970
+ /**
971
+ * Compile-time renderer-tag validation, run on the main thread.
972
+ *
973
+ * The renderer (`@malloydata/render`) is a large solid-js bundle that mutates
974
+ * DOM globals at import; loading it inside the package-load worker isolate
975
+ * destabilizes that thread (it is deliberately kept pure-CPU). So validation
976
+ * runs here, after the worker has hydrated this Model, where the renderer is
977
+ * already used at query time (see query.controller.ts / execute_query_tool.ts).
978
+ *
979
+ * Prepares each annotated top-level named query (`run: <name>`) and each
980
+ * annotated source view (`run: <source> -> <view>`) compile-only -- no
981
+ * execution -- to get a stable result schema, then runs the renderer's
982
+ * headless `validateRenderTags`. Targets with no annotations carry no render
983
+ * tags, so they are skipped without compiling. Any
984
+ * error-severity finding throws a `ModelCompilationError` (HTTP 424) so a
985
+ * misconfigured tag (e.g. a child-only `# big_value { sparkline=... }` placed
986
+ * on a view with no activating big_value) fails the package load with a clear
987
+ * message instead of rendering as "[object Object]" at query time. Warnings
988
+ * (e.g. unread tags) are left for the query-time `renderLogs` surface so a
989
+ * benign render lint never blocks a load.
990
+ */
991
+ public async validateRenderTags(): Promise<void> {
992
+ const mm = this.modelMaterializer;
993
+ if (!mm) {
994
+ return;
995
+ }
996
+ // Dynamic import (like execute_query_tool.ts): the renderer is heavy and
997
+ // mutates DOM globals on load, so only pull it in when there's a model to
998
+ // validate.
999
+ const { validateRenderTags } = await import(
1000
+ "@malloydata/render-validator"
1001
+ );
1002
+
1003
+ // Renderable targets: top-level named queries and every view declared on a
1004
+ // source. Source views are where render tags like `# big_value` usually
1005
+ // live, and they are NOT in `this.queries`.
1006
+ const targets: { label: string; queryString: string }[] = [];
1007
+ for (const query of this.queries ?? []) {
1008
+ // Only an annotated, named query can carry a render tag to validate;
1009
+ // skip the rest rather than compiling every query in the package.
1010
+ if (!query.name || !query.annotations?.length) {
1011
+ continue;
1012
+ }
1013
+ // Quote the identifier (see quoteMalloyIdentifier) so a name needing
1014
+ // Malloy quoting still lexes and cannot break out of the quotes.
1015
+ targets.push({
1016
+ label: query.name,
1017
+ queryString: `run: ${quoteMalloyIdentifier(query.name)}`,
1018
+ });
1019
+ }
1020
+ for (const source of this.sources ?? []) {
1021
+ for (const view of source.views ?? []) {
1022
+ // Render tags live on the view's own or inherited annotations, not
1023
+ // via source-to-view inheritance, so an unannotated view has
1024
+ // nothing to validate and need not be compiled. (A model-level
1025
+ // `##` tag is the one case this gate doesn't reach, but those are
1026
+ // theme/config, not the child-only chart tags this guards against.)
1027
+ if (!view.annotations?.length) {
1028
+ continue;
1029
+ }
1030
+ // Quote both identifiers (see quoteMalloyIdentifier): an unquoted
1031
+ // name like `gated-source` fails to lex, and the catch below would
1032
+ // then silently skip the very view this is meant to validate.
1033
+ targets.push({
1034
+ label: `${source.name} -> ${view.name}`,
1035
+ queryString: `run: ${quoteMalloyIdentifier(source.name)} -> ${quoteMalloyIdentifier(view.name)}`,
1036
+ });
1037
+ }
1038
+ }
1039
+
1040
+ for (const target of targets) {
1041
+ let result: Malloy.Result;
1042
+ try {
1043
+ const prepared = await mm
1044
+ .loadQuery(target.queryString)
1045
+ .getPreparedResult();
1046
+ result = prepared.toStableResult();
1047
+ } catch {
1048
+ // A view/query that fails to prepare is reported by the normal
1049
+ // compile path; don't mask that with a render-tag error.
1050
+ continue;
1051
+ }
1052
+ const errors = validateRenderTags(result).filter(
1053
+ (log) => log.severity === "error",
1054
+ );
1055
+ if (errors.length > 0) {
1056
+ throw new ModelCompilationError({
1057
+ message: `Invalid renderer configuration on '${target.label}': ${errors
1058
+ .map((e) => e.message)
1059
+ .join("; ")}`,
1060
+ });
1061
+ }
1062
+ }
701
1063
  }
702
1064
 
703
1065
  public async getModel(): Promise<ApiCompiledModel> {
@@ -770,6 +1132,18 @@ export class Model {
770
1132
  if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
771
1133
  throw new BadRequestError("Model has no queryable entities.");
772
1134
 
1135
+ // Query boundary FIRST (the *what* axis): reject a target that isn't in
1136
+ // the package's queryable surface with a generic 404, before authorize
1137
+ // (the *who* axis) and before compilation — so a non-queryable source is
1138
+ // indistinguishable from a non-existent one and can't be probed.
1139
+ // "deferred" means the early gate couldn't pin the target; the compiled
1140
+ // backstop below settles it against the source the query actually runs.
1141
+ const boundary = this.assertQueryBoundaryEarly(
1142
+ sourceName,
1143
+ queryName,
1144
+ query,
1145
+ );
1146
+
773
1147
  // Early fast-path authorize gate (before loadQuery). Resolve the source
774
1148
  // from surface syntax; gate if it names one. This runs BEFORE compilation
775
1149
  // so the gate can't be used as a schema oracle — without it, a denied
@@ -886,6 +1260,16 @@ export class Model {
886
1260
  // AccessDeniedError stays a 403; independent of bypassFilters.
887
1261
  const compiledSource =
888
1262
  await this.resolveAuthorizeSourceFromRunnable(runnable);
1263
+
1264
+ // Boundary backstop (the *what* axis, 404) BEFORE the authorize gate
1265
+ // (the *who* axis, 403): settle "deferred" early-gate admissions against
1266
+ // the compiled run target. Skipped for "cleared" admissions — an
1267
+ // explicitly-named exported query may read hidden sources internally
1268
+ // (the author's deliberate exposure), and must not be re-denied here.
1269
+ if (boundary === "deferred") {
1270
+ this.assertQueryBoundaryCompiled(compiledSource, query);
1271
+ }
1272
+
889
1273
  // Run unless it's the redundant re-probe of the exact named source the
890
1274
  // early gate already cleared. When compiledSource is unknown/unresolved,
891
1275
  // this still runs and assertAuthorized applies the model-wide file-level
@@ -994,8 +1378,11 @@ export class Model {
994
1378
  sourceInfos: this.getSourceInfos()?.map((sourceInfo) =>
995
1379
  JSON.stringify(sourceInfo),
996
1380
  ),
997
- sources: this.sources,
998
- queries: this.queries,
1381
+ // Discovery surface: an explore lists only its export closure
1382
+ // (getSources/getQueries curate); `this.sources` stays complete for
1383
+ // enforcement and resolution.
1384
+ sources: this.getSources(),
1385
+ queries: this.getQueries(),
999
1386
  givens: this.givens,
1000
1387
  } as ApiCompiledModel;
1001
1388
  }
@@ -1051,6 +1438,10 @@ export class Model {
1051
1438
  modelPath: this.modelPath,
1052
1439
  malloyVersion: MALLOY_VERSION,
1053
1440
  modelInfo: JSON.stringify(this.modelInfo ?? {}),
1441
+ // Raw-notebook view is uncurated (complete `this.sources`/`this.queries`,
1442
+ // not the export-filtered `getSources`/`getQueries`): notebooks can't be
1443
+ // imported, so their in-file sources have no internal/import-only role to
1444
+ // hide — they're always public. Model files curate; notebooks don't.
1054
1445
  sources: this.modelDef && this.sources,
1055
1446
  queries: this.modelDef && this.queries,
1056
1447
  annotations: allAnnotations,
@@ -1534,6 +1925,7 @@ type HydrationMaterializer = ModelMaterializer & {
1534
1925
 
1535
1926
  function makeHydrationRuntime(
1536
1927
  malloyConfig: ModelConnectionInput,
1928
+ buildManifest?: BuildManifest["entries"],
1537
1929
  ): HydrationRuntime {
1538
1930
  const urlReader = new HackyDataStylesAccumulator(URL_READER);
1539
1931
  const config =
@@ -1546,7 +1938,19 @@ function makeHydrationRuntime(
1546
1938
  );
1547
1939
  return c;
1548
1940
  })();
1549
- return new Runtime({ urlReader, config }) as HydrationRuntime;
1941
+ // Thread the package's bound build manifest into the *serve* runtime. Malloy
1942
+ // substitutes a persisted source for its materialized table at query
1943
+ // (getSQL) time, gated on `prepareResultOptions.buildManifest`; without this
1944
+ // the hydrated model always recomputes from the base tables even though the
1945
+ // manifest was bound at load. `strict: false` keeps serving live for any
1946
+ // source whose buildId is absent from the manifest.
1947
+ return new Runtime({
1948
+ urlReader,
1949
+ config,
1950
+ buildManifest: buildManifest
1951
+ ? { entries: buildManifest, strict: false }
1952
+ : undefined,
1953
+ }) as HydrationRuntime;
1550
1954
  }
1551
1955
 
1552
1956
  /**
@@ -75,11 +75,19 @@ describe("service/package", () => {
75
75
  new Map([
76
76
  [
77
77
  "model1.malloy",
78
- { getPath: () => "model1.malloy" } as unknown as Model,
78
+ {
79
+ getPath: () => "model1.malloy",
80
+ setDiscoveryCuration: () => {},
81
+ setQueryBoundary: () => {},
82
+ } as unknown as Model,
79
83
  ],
80
84
  [
81
85
  "model2.malloynb",
82
- { getPath: () => "model2.malloynb" } as unknown as Model,
86
+ {
87
+ getPath: () => "model2.malloynb",
88
+ setDiscoveryCuration: () => {},
89
+ setQueryBoundary: () => {},
90
+ } as unknown as Model,
83
91
  ],
84
92
  ]),
85
93
  );
@@ -294,17 +302,21 @@ describe("service/package", () => {
294
302
  {
295
303
  getPath: () => "model1.malloy",
296
304
  getModel: () => "foo",
305
+ setDiscoveryCuration: () => {},
306
+ setQueryBoundary: () => {},
297
307
  } as unknown as Model,
298
308
  ],
299
309
  [
300
310
  "model2.malloynb",
301
311
  {
302
312
  getPath: () => "model2.malloynb",
313
+ setDiscoveryCuration: () => {},
303
314
  getNotebookError: () => {
304
315
  return {
305
316
  message: "This is the error",
306
317
  };
307
318
  },
319
+ setQueryBoundary: () => {},
308
320
  } as unknown as Model,
309
321
  ],
310
322
  ]),