@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.
- package/dist/app/api-doc.yaml +394 -395
- package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
- package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
- package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
- package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
- package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
- package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
- package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
- package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
- package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
- package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
- package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/server.mjs +1272 -1299
- package/package.json +1 -1
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +12 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +121 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/server-old.ts +7 -149
- package/src/server.ts +9 -188
- package/src/service/authorize_integration.spec.ts +67 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +0 -23
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +584 -500
- package/src/service/materialization_service.ts +839 -657
- package/src/service/model.ts +419 -15
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +339 -29
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
package/src/service/model.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
998
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
78
|
+
{
|
|
79
|
+
getPath: () => "model1.malloy",
|
|
80
|
+
setDiscoveryCuration: () => {},
|
|
81
|
+
setQueryBoundary: () => {},
|
|
82
|
+
} as unknown as Model,
|
|
79
83
|
],
|
|
80
84
|
[
|
|
81
85
|
"model2.malloynb",
|
|
82
|
-
{
|
|
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
|
]),
|