@malloy-publisher/server 0.10.2 → 0.10.4
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 +21 -10
- package/dist/app/assets/{EnvironmentPage-qOdFSQL1.js → EnvironmentPage-B9Q9ZYYM.js} +1 -1
- package/dist/app/assets/{HomePage-qPrsLgkV.js → HomePage-CVbXvH7c.js} +1 -1
- package/dist/app/assets/{MainPage-BfS_-PBR.js → MainPage-D1Fj4Wy1.js} +1 -1
- package/dist/app/assets/{ModelPage-Rgt_50mg.js → ModelPage-DmYocpWT.js} +2 -2
- package/dist/app/assets/{PackagePage-D6bXCeY6.js → PackagePage-4TwX_ajC.js} +1 -1
- package/dist/app/assets/RenderedResult-B_sW7neX.es-CFeRQyYD.js +179 -0
- package/dist/app/assets/{RouteError-D3qvottt.js → RouteError-BYb6Nj48.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-CYe_2MfI.js +1 -0
- package/dist/app/assets/{ToggleButtonGroup-DnK5WHKM.js → ToggleButtonGroup-b325mGmL.js} +1 -1
- package/dist/app/assets/documentRoutes-D_C8snHV.js +1 -0
- package/dist/app/assets/{editPageMounted-po3c6RP3.js → editPageMounted-1X99LP7v.js} +2 -2
- package/dist/app/assets/{index-CNbuoYFM.js → index-3fArIYYK.js} +1 -1
- package/dist/app/assets/index-CGKV9Dxi.js +483 -0
- package/dist/app/assets/{index-4-yQ2uF2.js → index-DEj7hnfZ.js} +1 -1
- package/dist/app/assets/{index-a9NQPL4B.js → index-DWhQwvJR.js} +1 -1
- package/dist/app/assets/{index.es-C2AyMc9a.js → index.es-CMsNTB-h.js} +21 -21
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +81 -48
- package/dist/server.mjs +382 -127
- package/package.json +1 -1
- package/dist/app/assets/RenderedResult-BCJC0ZVj.es-ThIM8SXj.js +0 -171
- package/dist/app/assets/ThemeEditorPage-UAf70V3Q.js +0 -1
- package/dist/app/assets/documentRoutes-CnLoYf4D.js +0 -1
- package/dist/app/assets/index-iyvwQg3j.js +0 -481
package/dist/server.mjs
CHANGED
|
@@ -164832,7 +164832,7 @@ var init_telemetry = __esm(() => {
|
|
|
164832
164832
|
});
|
|
164833
164833
|
|
|
164834
164834
|
// package.json
|
|
164835
|
-
var version = "0.10.
|
|
164835
|
+
var version = "0.10.4";
|
|
164836
164836
|
var init_package = () => {};
|
|
164837
164837
|
|
|
164838
164838
|
// src/version.ts
|
|
@@ -296720,6 +296720,47 @@ var init_connection_public_view = __esm(() => {
|
|
|
296720
296720
|
];
|
|
296721
296721
|
});
|
|
296722
296722
|
|
|
296723
|
+
// src/service/single_select.ts
|
|
296724
|
+
import { DuckDBInstance } from "@duckdb/node-api";
|
|
296725
|
+
async function parserInstance() {
|
|
296726
|
+
if (!parser) {
|
|
296727
|
+
parser = DuckDBInstance.create(":memory:", {
|
|
296728
|
+
threads: "1",
|
|
296729
|
+
enable_external_access: "false"
|
|
296730
|
+
}).catch((error) => {
|
|
296731
|
+
parser = undefined;
|
|
296732
|
+
throw error;
|
|
296733
|
+
});
|
|
296734
|
+
}
|
|
296735
|
+
return parser;
|
|
296736
|
+
}
|
|
296737
|
+
async function classifySelect(sql) {
|
|
296738
|
+
const connection = await (await parserInstance()).connect();
|
|
296739
|
+
try {
|
|
296740
|
+
const prepared = await connection.prepare("SELECT json_serialize_sql($1::VARCHAR) AS j");
|
|
296741
|
+
prepared.bindVarchar(1, sql);
|
|
296742
|
+
const rows = await (await prepared.run()).getRowObjectsJson();
|
|
296743
|
+
const parsed = JSON.parse(String(rows[0]?.j));
|
|
296744
|
+
if (parsed.error) {
|
|
296745
|
+
return { refused: parsed.error_message ?? "it could not be parsed" };
|
|
296746
|
+
}
|
|
296747
|
+
return { selects: parsed.statements?.length ?? 0 };
|
|
296748
|
+
} finally {
|
|
296749
|
+
connection.closeSync();
|
|
296750
|
+
}
|
|
296751
|
+
}
|
|
296752
|
+
async function assertSingleSelect(connectionName, sql) {
|
|
296753
|
+
const verdict = await classifySelect(sql);
|
|
296754
|
+
if ("selects" in verdict && verdict.selects === 1)
|
|
296755
|
+
return;
|
|
296756
|
+
const why = "refused" in verdict ? "it is not a SELECT" : verdict.selects === 0 ? "it holds no statement" : `it holds ${verdict.selects} statements`;
|
|
296757
|
+
throw new BadRequestError(`'${connectionName}' is a managed database: raw SQL on it runs only as a single SELECT, and ${why}.`);
|
|
296758
|
+
}
|
|
296759
|
+
var parser;
|
|
296760
|
+
var init_single_select = __esm(() => {
|
|
296761
|
+
init_errors();
|
|
296762
|
+
});
|
|
296763
|
+
|
|
296723
296764
|
// src/service/connection_service.ts
|
|
296724
296765
|
async function runEnvironmentConnectionUpdate(environment, fn) {
|
|
296725
296766
|
if (environment.runConnectionUpdateExclusive) {
|
|
@@ -305614,6 +305655,15 @@ class ConnectionController {
|
|
|
305614
305655
|
}
|
|
305615
305656
|
return environment.getApiConnection(connectionName);
|
|
305616
305657
|
}
|
|
305658
|
+
isManagedConnection(environment, connectionName) {
|
|
305659
|
+
try {
|
|
305660
|
+
return this.getApiConnectionForLookup(environment, connectionName).managed === true;
|
|
305661
|
+
} catch (error) {
|
|
305662
|
+
if (error instanceof ConnectionNotFoundError)
|
|
305663
|
+
return false;
|
|
305664
|
+
throw error;
|
|
305665
|
+
}
|
|
305666
|
+
}
|
|
305617
305667
|
async connectionQueryMetadata(environmentName, connectionName) {
|
|
305618
305668
|
try {
|
|
305619
305669
|
const environment = await this.environmentStore.getEnvironment(environmentName, false);
|
|
@@ -305736,6 +305786,10 @@ class ConnectionController {
|
|
|
305736
305786
|
return withDatabaseAccessClassified(() => listTablesForSchema(connection, schemaName, malloyConnection, tableNames));
|
|
305737
305787
|
}
|
|
305738
305788
|
async getConnectionSqlSource(environmentName, connectionName, sqlStatement, packageName) {
|
|
305789
|
+
const environment = await this.environmentStore.getEnvironment(environmentName, false);
|
|
305790
|
+
if (this.isManagedConnection(environment, connectionName)) {
|
|
305791
|
+
await assertSingleSelect(connectionName, sqlStatement);
|
|
305792
|
+
}
|
|
305739
305793
|
const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
|
|
305740
305794
|
try {
|
|
305741
305795
|
const schema = await malloyConnection.fetchSelectSchema({
|
|
@@ -305796,6 +305850,9 @@ class ConnectionController {
|
|
|
305796
305850
|
}
|
|
305797
305851
|
const environmentForAdmission = await this.environmentStore.getEnvironment(environmentName, false);
|
|
305798
305852
|
environmentForAdmission.assertCanAdmitQuery();
|
|
305853
|
+
if (this.isManagedConnection(environmentForAdmission, connectionName)) {
|
|
305854
|
+
await assertSingleSelect(connectionName, sqlStatement);
|
|
305855
|
+
}
|
|
305799
305856
|
const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
|
|
305800
305857
|
let runSQLOptions = {};
|
|
305801
305858
|
if (options) {
|
|
@@ -305881,6 +305938,9 @@ class ConnectionController {
|
|
|
305881
305938
|
}
|
|
305882
305939
|
const environmentForAdmission = await this.environmentStore.getEnvironment(environmentName, false);
|
|
305883
305940
|
environmentForAdmission.assertCanAdmitQuery();
|
|
305941
|
+
if (this.isManagedConnection(environmentForAdmission, connectionName)) {
|
|
305942
|
+
throw new BadRequestError(`'${connectionName}' is a managed database: raw SQL on it runs only as a single SELECT, so it cannot create a temporary table.`);
|
|
305943
|
+
}
|
|
305884
305944
|
const malloyConnection = await this.getMalloyConnection(environmentName, connectionName, packageName);
|
|
305885
305945
|
return await runWithQueryTimeout(async (signal) => {
|
|
305886
305946
|
try {
|
|
@@ -305993,6 +306053,7 @@ var init_connection_controller = __esm(() => {
|
|
|
305993
306053
|
init_connection();
|
|
305994
306054
|
init_connection_public_view();
|
|
305995
306055
|
init_connection_config();
|
|
306056
|
+
init_single_select();
|
|
305996
306057
|
init_connection_service();
|
|
305997
306058
|
init_db_utils();
|
|
305998
306059
|
init_query_metadata();
|
|
@@ -314991,8 +315052,8 @@ var require_lunr = __commonJS((exports, module) => {
|
|
|
314991
315052
|
};
|
|
314992
315053
|
lunr.Index.prototype.search = function(queryString) {
|
|
314993
315054
|
return this.query(function(query) {
|
|
314994
|
-
var
|
|
314995
|
-
|
|
315055
|
+
var parser2 = new lunr.QueryParser(queryString, query);
|
|
315056
|
+
parser2.parse();
|
|
314996
315057
|
});
|
|
314997
315058
|
};
|
|
314998
315059
|
lunr.Index.prototype.query = function(fn) {
|
|
@@ -315583,8 +315644,8 @@ var require_lunr = __commonJS((exports, module) => {
|
|
|
315583
315644
|
this.query.clause(completedClause);
|
|
315584
315645
|
this.currentClause = {};
|
|
315585
315646
|
};
|
|
315586
|
-
lunr.QueryParser.parseClause = function(
|
|
315587
|
-
var lexeme =
|
|
315647
|
+
lunr.QueryParser.parseClause = function(parser2) {
|
|
315648
|
+
var lexeme = parser2.peekLexeme();
|
|
315588
315649
|
if (lexeme == undefined) {
|
|
315589
315650
|
return;
|
|
315590
315651
|
}
|
|
@@ -315603,23 +315664,23 @@ var require_lunr = __commonJS((exports, module) => {
|
|
|
315603
315664
|
throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
|
|
315604
315665
|
}
|
|
315605
315666
|
};
|
|
315606
|
-
lunr.QueryParser.parsePresence = function(
|
|
315607
|
-
var lexeme =
|
|
315667
|
+
lunr.QueryParser.parsePresence = function(parser2) {
|
|
315668
|
+
var lexeme = parser2.consumeLexeme();
|
|
315608
315669
|
if (lexeme == undefined) {
|
|
315609
315670
|
return;
|
|
315610
315671
|
}
|
|
315611
315672
|
switch (lexeme.str) {
|
|
315612
315673
|
case "-":
|
|
315613
|
-
|
|
315674
|
+
parser2.currentClause.presence = lunr.Query.presence.PROHIBITED;
|
|
315614
315675
|
break;
|
|
315615
315676
|
case "+":
|
|
315616
|
-
|
|
315677
|
+
parser2.currentClause.presence = lunr.Query.presence.REQUIRED;
|
|
315617
315678
|
break;
|
|
315618
315679
|
default:
|
|
315619
315680
|
var errorMessage = "unrecognised presence operator'" + lexeme.str + "'";
|
|
315620
315681
|
throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
|
|
315621
315682
|
}
|
|
315622
|
-
var nextLexeme =
|
|
315683
|
+
var nextLexeme = parser2.peekLexeme();
|
|
315623
315684
|
if (nextLexeme == undefined) {
|
|
315624
315685
|
var errorMessage = "expecting term or field, found nothing";
|
|
315625
315686
|
throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
|
|
@@ -315634,19 +315695,19 @@ var require_lunr = __commonJS((exports, module) => {
|
|
|
315634
315695
|
throw new lunr.QueryParseError(errorMessage, nextLexeme.start, nextLexeme.end);
|
|
315635
315696
|
}
|
|
315636
315697
|
};
|
|
315637
|
-
lunr.QueryParser.parseField = function(
|
|
315638
|
-
var lexeme =
|
|
315698
|
+
lunr.QueryParser.parseField = function(parser2) {
|
|
315699
|
+
var lexeme = parser2.consumeLexeme();
|
|
315639
315700
|
if (lexeme == undefined) {
|
|
315640
315701
|
return;
|
|
315641
315702
|
}
|
|
315642
|
-
if (
|
|
315643
|
-
var possibleFields =
|
|
315703
|
+
if (parser2.query.allFields.indexOf(lexeme.str) == -1) {
|
|
315704
|
+
var possibleFields = parser2.query.allFields.map(function(f3) {
|
|
315644
315705
|
return "'" + f3 + "'";
|
|
315645
315706
|
}).join(", "), errorMessage = "unrecognised field '" + lexeme.str + "', possible fields: " + possibleFields;
|
|
315646
315707
|
throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
|
|
315647
315708
|
}
|
|
315648
|
-
|
|
315649
|
-
var nextLexeme =
|
|
315709
|
+
parser2.currentClause.fields = [lexeme.str];
|
|
315710
|
+
var nextLexeme = parser2.peekLexeme();
|
|
315650
315711
|
if (nextLexeme == undefined) {
|
|
315651
315712
|
var errorMessage = "expecting term, found nothing";
|
|
315652
315713
|
throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
|
|
@@ -315659,41 +315720,41 @@ var require_lunr = __commonJS((exports, module) => {
|
|
|
315659
315720
|
throw new lunr.QueryParseError(errorMessage, nextLexeme.start, nextLexeme.end);
|
|
315660
315721
|
}
|
|
315661
315722
|
};
|
|
315662
|
-
lunr.QueryParser.parseTerm = function(
|
|
315663
|
-
var lexeme =
|
|
315723
|
+
lunr.QueryParser.parseTerm = function(parser2) {
|
|
315724
|
+
var lexeme = parser2.consumeLexeme();
|
|
315664
315725
|
if (lexeme == undefined) {
|
|
315665
315726
|
return;
|
|
315666
315727
|
}
|
|
315667
|
-
|
|
315728
|
+
parser2.currentClause.term = lexeme.str.toLowerCase();
|
|
315668
315729
|
if (lexeme.str.indexOf("*") != -1) {
|
|
315669
|
-
|
|
315730
|
+
parser2.currentClause.usePipeline = false;
|
|
315670
315731
|
}
|
|
315671
|
-
var nextLexeme =
|
|
315732
|
+
var nextLexeme = parser2.peekLexeme();
|
|
315672
315733
|
if (nextLexeme == undefined) {
|
|
315673
|
-
|
|
315734
|
+
parser2.nextClause();
|
|
315674
315735
|
return;
|
|
315675
315736
|
}
|
|
315676
315737
|
switch (nextLexeme.type) {
|
|
315677
315738
|
case lunr.QueryLexer.TERM:
|
|
315678
|
-
|
|
315739
|
+
parser2.nextClause();
|
|
315679
315740
|
return lunr.QueryParser.parseTerm;
|
|
315680
315741
|
case lunr.QueryLexer.FIELD:
|
|
315681
|
-
|
|
315742
|
+
parser2.nextClause();
|
|
315682
315743
|
return lunr.QueryParser.parseField;
|
|
315683
315744
|
case lunr.QueryLexer.EDIT_DISTANCE:
|
|
315684
315745
|
return lunr.QueryParser.parseEditDistance;
|
|
315685
315746
|
case lunr.QueryLexer.BOOST:
|
|
315686
315747
|
return lunr.QueryParser.parseBoost;
|
|
315687
315748
|
case lunr.QueryLexer.PRESENCE:
|
|
315688
|
-
|
|
315749
|
+
parser2.nextClause();
|
|
315689
315750
|
return lunr.QueryParser.parsePresence;
|
|
315690
315751
|
default:
|
|
315691
315752
|
var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'";
|
|
315692
315753
|
throw new lunr.QueryParseError(errorMessage, nextLexeme.start, nextLexeme.end);
|
|
315693
315754
|
}
|
|
315694
315755
|
};
|
|
315695
|
-
lunr.QueryParser.parseEditDistance = function(
|
|
315696
|
-
var lexeme =
|
|
315756
|
+
lunr.QueryParser.parseEditDistance = function(parser2) {
|
|
315757
|
+
var lexeme = parser2.consumeLexeme();
|
|
315697
315758
|
if (lexeme == undefined) {
|
|
315698
315759
|
return;
|
|
315699
315760
|
}
|
|
@@ -315702,33 +315763,33 @@ var require_lunr = __commonJS((exports, module) => {
|
|
|
315702
315763
|
var errorMessage = "edit distance must be numeric";
|
|
315703
315764
|
throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
|
|
315704
315765
|
}
|
|
315705
|
-
|
|
315706
|
-
var nextLexeme =
|
|
315766
|
+
parser2.currentClause.editDistance = editDistance;
|
|
315767
|
+
var nextLexeme = parser2.peekLexeme();
|
|
315707
315768
|
if (nextLexeme == undefined) {
|
|
315708
|
-
|
|
315769
|
+
parser2.nextClause();
|
|
315709
315770
|
return;
|
|
315710
315771
|
}
|
|
315711
315772
|
switch (nextLexeme.type) {
|
|
315712
315773
|
case lunr.QueryLexer.TERM:
|
|
315713
|
-
|
|
315774
|
+
parser2.nextClause();
|
|
315714
315775
|
return lunr.QueryParser.parseTerm;
|
|
315715
315776
|
case lunr.QueryLexer.FIELD:
|
|
315716
|
-
|
|
315777
|
+
parser2.nextClause();
|
|
315717
315778
|
return lunr.QueryParser.parseField;
|
|
315718
315779
|
case lunr.QueryLexer.EDIT_DISTANCE:
|
|
315719
315780
|
return lunr.QueryParser.parseEditDistance;
|
|
315720
315781
|
case lunr.QueryLexer.BOOST:
|
|
315721
315782
|
return lunr.QueryParser.parseBoost;
|
|
315722
315783
|
case lunr.QueryLexer.PRESENCE:
|
|
315723
|
-
|
|
315784
|
+
parser2.nextClause();
|
|
315724
315785
|
return lunr.QueryParser.parsePresence;
|
|
315725
315786
|
default:
|
|
315726
315787
|
var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'";
|
|
315727
315788
|
throw new lunr.QueryParseError(errorMessage, nextLexeme.start, nextLexeme.end);
|
|
315728
315789
|
}
|
|
315729
315790
|
};
|
|
315730
|
-
lunr.QueryParser.parseBoost = function(
|
|
315731
|
-
var lexeme =
|
|
315791
|
+
lunr.QueryParser.parseBoost = function(parser2) {
|
|
315792
|
+
var lexeme = parser2.consumeLexeme();
|
|
315732
315793
|
if (lexeme == undefined) {
|
|
315733
315794
|
return;
|
|
315734
315795
|
}
|
|
@@ -315737,25 +315798,25 @@ var require_lunr = __commonJS((exports, module) => {
|
|
|
315737
315798
|
var errorMessage = "boost must be numeric";
|
|
315738
315799
|
throw new lunr.QueryParseError(errorMessage, lexeme.start, lexeme.end);
|
|
315739
315800
|
}
|
|
315740
|
-
|
|
315741
|
-
var nextLexeme =
|
|
315801
|
+
parser2.currentClause.boost = boost;
|
|
315802
|
+
var nextLexeme = parser2.peekLexeme();
|
|
315742
315803
|
if (nextLexeme == undefined) {
|
|
315743
|
-
|
|
315804
|
+
parser2.nextClause();
|
|
315744
315805
|
return;
|
|
315745
315806
|
}
|
|
315746
315807
|
switch (nextLexeme.type) {
|
|
315747
315808
|
case lunr.QueryLexer.TERM:
|
|
315748
|
-
|
|
315809
|
+
parser2.nextClause();
|
|
315749
315810
|
return lunr.QueryParser.parseTerm;
|
|
315750
315811
|
case lunr.QueryLexer.FIELD:
|
|
315751
|
-
|
|
315812
|
+
parser2.nextClause();
|
|
315752
315813
|
return lunr.QueryParser.parseField;
|
|
315753
315814
|
case lunr.QueryLexer.EDIT_DISTANCE:
|
|
315754
315815
|
return lunr.QueryParser.parseEditDistance;
|
|
315755
315816
|
case lunr.QueryLexer.BOOST:
|
|
315756
315817
|
return lunr.QueryParser.parseBoost;
|
|
315757
315818
|
case lunr.QueryLexer.PRESENCE:
|
|
315758
|
-
|
|
315819
|
+
parser2.nextClause();
|
|
315759
315820
|
return lunr.QueryParser.parsePresence;
|
|
315760
315821
|
default:
|
|
315761
315822
|
var errorMessage = "Unexpected lexeme type '" + nextLexeme.type + "'";
|
|
@@ -315902,11 +315963,11 @@ function lexProseRanges(text) {
|
|
|
315902
315963
|
});
|
|
315903
315964
|
const lexerErrors = counter2();
|
|
315904
315965
|
const parserErrors = counter2();
|
|
315905
|
-
const { tokenStream, parser } = factory2(text, {
|
|
315966
|
+
const { tokenStream, parser: parser2 } = factory2(text, {
|
|
315906
315967
|
lexerErrorListener: lexerErrors,
|
|
315907
315968
|
parserErrorListener: parserErrors
|
|
315908
315969
|
});
|
|
315909
|
-
|
|
315970
|
+
parser2.malloyDocument();
|
|
315910
315971
|
const vocabulary = tokenStream.tokenSource?.vocabulary;
|
|
315911
315972
|
if (lexerErrors.errors > 0 || parserErrors.errors > 0 || typeof vocabulary?.getSymbolicName !== "function")
|
|
315912
315973
|
return;
|
|
@@ -338178,7 +338239,7 @@ For anything not covered here, call \`search_malloy_docs\` with the topic (for e
|
|
|
338178
338239
|
{
|
|
338179
338240
|
name: "malloy-reports",
|
|
338180
338241
|
description: "Build or modify a Malloy Publisher report: a .malloy file with an ## artifact tag, written at the package root unless asked otherwise, laid out as a grid (a dashboard, scanned at a glance) or linear (a notebook or data story, read top to bottom), with filter controls rendered from givens and # drill click-through. Use when the user asks for a dashboard, a notebook, a report, a data story, a filterable view, or drill-through between views, and no code is wanted. Legacy .malloynb files are read, not authored.",
|
|
338181
|
-
body: "# Publisher Reports\n\n> A `.malloy` file with an `## artifact { layout=… tiles=[…] }` tag **is** a report. It imports the model, names the views to show, binds the filtering on those views, and says how to lay them out. Publisher discovers it at package load, renders the filter controls from the givens its tiles reference, offers `# drill` click-through, and serves it at `/<env>/<pkg>/<path>`, its package path without `.malloy`. No code, no build step.\n\nWhat other tools call a dashboard and a notebook are both reports here. The **layout** is the only difference:\n\n- **`layout=grid`**: tiles across a grid behind one filter row, scanned at a glance (a dashboard). Read `reference/grid.md` before you lay one out.\n- **`layout=linear`**: tiles and prose top to bottom, read in order (a notebook, a data story). Read `reference/linear.md` before you write one.\n\nEverything else (filters, text tiles, drill, the builder, the lint, the write path) is the same for both, and switching layout later is a one-property edit.\n\n## Where the file goes\n\n- **Write a new report at the package root**, as `<slug>.malloy`, unless the author asks for another location. No directory is required, and none is a convention: a report can live anywhere in the package except `public/`, and the root `index.malloy` is never a report. Do not create a `dashboards/` or `notebooks/` directory for it.\n- **Always write `layout=grid` or `layout=linear`** in the tag. A tag without one is read by a fallback rule, and a linear report that lists tiles without `layout=linear` is read as a grid. `kind=` is no longer read: a tag that sets it makes no report (`report-kind-removed`), so replace it with `layout=`.\n- **The package path is the report's identity.** `sales/q3.malloy` is the report `sales/q3`: its URL, its `GET …/reports/{path}` address, and its `# drill` target. Two reports may share a basename in different directories.\n- **Imports are relative to the report's own location.** At the root: `import { order_items } from \"storefront.malloy\"`. In a subdirectory: `\"../storefront.malloy\"`.\n- **An untagged `.malloy` file is an ordinary model**, wherever it lives. Shared definitions several reports import go in one.\n\n## When this is the right tool\n\n| The user wants | Use |\n| ---------------------------------------------------- | ------------------------------------------------------------ |\n| A recurring, at-a-glance view behind shared filters | this skill, `layout=grid` |\n| A narrative, with prose between the numbers | this skill, `layout=linear` |\n| A quick write-up of queries you already ran | `skill:malloy-analysis-report`, which writes a linear report |\n| Custom design, branding, or interactions beyond tags | an HTML data app |\n| The model itself: sources, measures, joins | `malloy-model` |\n\nInteractivity is not the axis: both layouts get filter controls, URL-addressable state, Apply batching, and `# drill`. Pick on the shape of the document: scanned at a glance is grid, read top to bottom is linear. When in doubt, linear.\n\n## Build sequence\n\nTool names below are bare (`get_context`); match each against the tools you actually have, since a host may prefix or rename them. A host names its compile and reload tools its own way. Where you have none, use the REST endpoint named beside the step.\n\n1. **READ THE MODEL FIRST.** Get the real source, view, dimension, and given names from the package: `get_context` if you have it, otherwise the REST model endpoint for `index.malloy` (it answers 404 for a file off the surface) or the `.malloy` files. Never guess a name. A guessed field in a query fails the whole package load, not just that one report; a guessed tile or suggest source is quieter, and only shows up in the package warnings. If the package root holds an `index.malloy` (every scaffolded package does), a tile reads only the sources that file exports.\n2. **PICK THE LAYOUT AND THE TILES.** Which views, in what order, and on a grid how wide each sits. A query tile is a `view:` you declare in the report, named as `\"source -> view\"`.\n3. **DECLARE THE GIVENS** the report will filter by, in the report file itself, with their control tags (see \"Filter controls\"). A report holds its own filters: the builder edits the report file and nothing else, so a filter it can add, change or remove is a declaration in that file. A given the model already declares and reads is imported by name, never declared again; if it is only a reader's filter, move it into the report instead (see \"Givens that stay in the model\").\n4. **COMPOSE THE FILE** at its path (the package root unless asked otherwise), following the template below, but do not save it yet. Name the sources you need in the import. Each tile binds its controls with a refinement, `view: t is v + { where: category ~ $CATEGORY }`, one clause per given it answers to; a `filter<...>` binds with `~`, a plain `date` or `number` given is a value and binds with `>=`, `<=` or `=`. Only the givens some tile references become controls. Then import every source or query any referenced given names in a `suggest`; **a `suggest` naming a `query=` needs that query's own source imported as well**, because an import is not transitive.\n5. **COMPILE IT** with your compile tool (`POST …/models/<path>/compile` over REST) at the path the file will have, with **`\"scope\": \"file\"`**: a report opens with an `import`, which the default `append` scope refuses, and an existing file compiled at `append` collides with itself. **`\"scope\": \"file\"` does not check tiles, drills or suggest queries.** To see those before you save, compile the same text at `\"scope\": \"package\"` with `modelPath` set to the report's path: it runs the lint step 6 reads and returns its findings with code `dashboard-lint` (or `render-tag`). Editing an untagged model several reports import also wants `\"scope\": \"package\"`, which recompiles every file as saved.\n6. **SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS.** Save by writing the file, or with `PUT …/models/<path>` over REST (omit `expectedHash` to create; it creates at any new path and replaces only a file the package already serves as a report). Then your reload tool, or `GET …/packages/<pkg>?reload=true`. A 424 means the package did not load and your edit is not live. **The `warnings` key is absent when there are none.** Then read `GET …/packages/<pkg>/reports/<path>`: its `givens` are exactly the controls that will render, and its `layout` is the layout Publisher read. See \"Read the lint\".\n7. **OPEN IT AND LOOK.** Not optional; see \"What done means\".\n\n## The form\n\n```malloy\n##! experimental.givens\n##\" One line saying what this report is for.\n##| artifact { layout=grid title=\"Storefront overview\"\n tiles=[\n intro { kind=text colspan=12 },\n \"overview -> kpis\",\n \"overview -> revenue_trend\",\n \"overview -> revenue_by_state\"\n ]\n} dashboard { columns=12 }\n|##\nimport { order_items, products } from \"storefront.malloy\"\n\n# label=\"Category\" control=select suggest { source=products dimension=category }\ngiven: CATEGORY :: filter<string> is f''\n# label=\"Ordered since\"\ngiven: SINCE :: date is @2023-01-01\n\n##|(markdown) intro\nRevenue and margin for the selected category.\n|##\n\nsource: overview is order_items extend {\n # colspan=12\n # label=\"Key figures\"\n # big_value\n view: kpis is key_figures + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n\n # colspan=8\n # break\n # label=\"Revenue by month\"\n view: revenue_trend is sales_by_month + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n\n # colspan=4\n # label=\"Revenue by state\"\n view: revenue_by_state is sales_by_state + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n}\n```\n\nThe same file with `layout=linear` and no `dashboard { … }` or `colspan` is a linear report.\n\n- **The tag is model-level**, one line (`## artifact { … }`) or a `##|` … `|##` block with the same text inside, the form to use once the list is long: one tile per line, `}` on its own line, `|##` closing at the opener's column. A one-line `##` tag must stay on one line: wrapping it is a compile error, or worse, compiles clean and quietly stops being a report.\n- **The header** above the tag holds only `##!` flags, `//` comments and the unnamed `\"` notes that are the report's description. Keep statements and `#` tags below `## artifact`: nothing refuses one above it, but the header is where readers and the builder look.\n- **Quote every query entry.** `\"overview -> kpis\"` is a string; unquoted, the tag does not parse (`notebook-artifact-unparsed`). A bare quoted name (`\"top_products\"`) names a model-level `query:` in the file.\n- **A query tile is a thin `view:` on an extension the report declares** (`source: overview is order_items extend { … }`): the modelled view keeps its chart tag, the wrapper carries the tile's `# label` and its binding. On a grid its width, row break and height can go on its entry instead, `\"overview -> kpis\" { colspan=12 rowspan=2 }`, which wins over the view's tags; `rowspan=N` is rows of 80px, and `row=N col=N` place the tile at a grid cell (a report with any `row` is placed: fixed rows, tiles where they say, empty cells allowed).\n- **A text tile is a `##|(markdown) name` block** listed as `name { kind=text }`. The name is one bare word on the opener line, the body starts on the next line (a heading goes inside the block), and `|##` closes it at the opener's column. A bare `intro` without `{ kind=text }` is the query-tile form for a `query:`, so the block is not shown. On a grid the entry also takes `colspan`, `break` and `rowspan`, and nothing else.\n- **File order:** header, imports and givens, the prose blocks in tile order, then the extension that defines the tile views. That is the order the builder writes.\n- **A `run:` in a report that lists `tiles` is never shown** (`notebook-layout-run`). Define a view, list it, delete the `run:`.\n- **A tile expression is a string, so the compiler never checks it.** Rename a view and the file still compiles; the tile fails at package load, where the lint names it.\n- **Chart tags go on the view** (`# line_chart`, `# bar_chart`, `# big_value`, `# label=\"…\"`). `skill:malloy-charts` covers choosing them.\n\n## Filter controls\n\nControls come from the `given:` declarations the tiles reference. The tags on the declaration are the control contract:\n\n```malloy\n##! experimental.givens\n\n# label=\"Category\" control=select suggest { source=products dimension=category }\ngiven: CATEGORY :: filter<string> is f''\n\n# label=\"Brand\" control=multiselect suggest { query=brand_suggest dimension=brand }\ngiven: BRAND :: filter<string> is f''\n\n# label=\"Minimum line total\" range_min=0 range_max=250\ngiven: MIN_SALE :: filter<number> is f''\n\n# label=\"Ordered since\"\ngiven: SINCE :: date is @2023-01-01\n```\n\n`control=select`/`multiselect` with a `suggest` renders a picker filled from the data; `range_min`/`range_max` on a `filter<number>` renders a two-handled range slider; a `filter<date>` or `filter<timestamp>` renders a time-range control with preset windows and a custom day range; a bare `date` or `timestamp` renders a date picker; a `filter<string>` with no tag renders a text box taking filter syntax. Which controls appear is per report, decided by which givens its tiles reference.\n\n- `#(description=\"…\")` renders as helper text under the control and draws a cosmetic \"not a well-formed route\" compile diagnostic for a multi-word value; `# description=\"…\"` publishes to the API but the UI does not render it.\n- `dimension=` can name a field of a directly joined source, quoted: `suggest { source=sales dimension=\"products.department\" }`. On the `query=` form, `dimension` names a column of that query's output; match it to the query's `group_by`.\n- **A suggest's source or query has to resolve in the report file**, so import it there. Getting it wrong does not error: the control still looks like a picker and says \"Could not load the options for this control\"; the package warnings name it.\n- Two options on the artifact tag, either layout: `autorun=false` batches control changes behind an Apply button; `givens { CATEGORY=f'Outerwear' }` sets starting values (a URL parameter wins). A file-level `## autorun=false` does nothing on a `.malloy` report.\n\n### Binding, and givens that stay in the model\n\n**Binding is per declaration, not per name.** A report declaring its own `CATEGORY` over a source whose model-level `where:` reads the model's `CATEGORY` compiles, shows the control, and filters nothing. So bind on the tiles, with `+ { where: … }`. The other design, model-level scoping, still works: put a scoped source in an untagged model that imports the givens it reads, and import that model and those givens into the report. Do not mix the two on one given.\n\nA given stays in the model, declared once there, when a model source, view or measure, an `#(authorize)` or `#(access_filter)` gate, or an HTML data app reads it. The report imports it by name (`import { CATEGORY } from \"givens.malloy\"`) and never re-declares it:\n\n- **Import a name and also declare it: a compile error.** Drop the local declaration.\n- **Declare a name the model already reads, without importing it: no error at all.** The control renders and moves, and the model's own `where:` or gate never sees the value.\n\n**A model that already scopes a source by an optional filter given** (`where: Manufacturer ~ $manufacturer_filter` in the model) needs a decision:\n\n- **It is a reader's filter and you may change the model: move it.** Delete the given and its `where:` clause from the model, declare it in the report with its control tags, and bind it on each tile that should answer to it. A reader's filter is anything a person picks on the page; a given stays in the model only for the reasons `malloy-model` lists (a gate, a value every query must supply, a parameter a field is defined with, scoping meant for every consumer).\n- **The model can't change, or the given is semantic or governance: import it** as above. Say so plainly: the filter shows in the reader and in the builder, and can be set in both, but the builder cannot remove or reconfigure it. It is the model's.\n\nBefore, in the model:\n\n```malloy\n##! experimental.givens\ngiven:\n manufacturer_filter :: filter<string> is f''\n subject_filter :: filter<string> is f''\n major_recall_filter :: filter<boolean> is f''\n recall_date_filter :: filter<date> is f''\n\nsource: recalls is duckdb.table('data/auto_recalls.csv') extend {\n where:\n Manufacturer ~ $manufacturer_filter,\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n dimension: `Major Recall` is ifnull(`Potentially Affected`, 0) > 100000\n}\n```\n\nAfter: the model declares no givens and `recalls` has no `where:`; the report declares the four and binds each tile:\n\n```malloy\n##! experimental.givens\n##| artifact { layout=grid title=\"Recalls\"\n tiles=[\"recalls_tiles -> recalls_by_year\", \"recalls_tiles -> recalls_by_manufacturer\"]\n} dashboard { columns=12 }\n|##\nimport { recalls } from \"auto_recalls.malloy\"\n\n# label=\"Manufacturer\" control=multiselect suggest { source=recalls dimension=Manufacturer }\ngiven: manufacturer_filter :: filter<string> is f''\n# label=\"Subject\"\ngiven: subject_filter :: filter<string> is f''\n# label=\"Major recalls only\"\ngiven: major_recall_filter :: filter<boolean> is f''\n# label=\"Filed\"\ngiven: recall_date_filter :: filter<date> is f''\n\nsource: recalls_tiles is recalls extend {\n # colspan=6\n view: recalls_by_year is by_year + {\n where:\n Manufacturer ~ $manufacturer_filter,\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n }\n # colspan=6\n view: recalls_by_manufacturer is by_manufacturer + {\n where:\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n }\n}\n```\n\nA tile view takes a name of its own: `view: by_year is by_year + { … }` is \"Cannot redefine 'by_year'\". Each tile binds only the givens it should answer to: `recalls_by_manufacturer` leaves the manufacturer filter off, since it is the breakdown by manufacturer. Every given here is a `filter<…>`, so each binds with `~`; a plain `date` or `number` given binds with `>=`, `<=` or `=` (step 4).\n\nA given has to be in the report file's own scope to be bindable: declared there or imported. Never add a `#(filter)` annotation: it is deprecated, a report renders no control for it, and every use has a `given:` form (`malloy-model` covers the migration). `malloy-model` and `docs/givens.md` cover givens themselves.\n\n## Drill\n\n`# drill` goes on a model **dimension**, never on a report:\n\n```malloy\n# drill { to=[\"category\", \"self\"] given=CATEGORY }\ndimension: category is products.category\n\n# drill { to=\"regions/west\" given=REGION }\ndimension: region is regions.region\n```\n\n- **`to=<path>`** navigates to a report, of either layout, by its package path without `.malloy`, read from the package root: `to=category` is `category.malloy` at the root, `to=\"regions/west\"` is `regions/west.malloy`. Quote a path with a `/`. **Always write the path from the package root**: a bare name is a root file, never a report of that name elsewhere.\n- **`to=self`** filters in place; two or more destinations pop a menu.\n- **Always write `given=`.** Without it the given is the dimension name verbatim, so `dimension: category` seeds `category`, not `CATEGORY`. `to=self` survives that (it folds case); `to=<path>` does not, and lands on an unfiltered page with nothing erroring.\n- **The destination needs a control for the given**, declared as `filter<T>`. No lint checks that half, so click it and look.\n- **Only table cells are marked drillable.** A report meant to be drilled wants at least one untagged (table) tile, grouped by the tagged dimension. A column whose rendered header another, non-drillable tile also shows loses its marking: give the two different `# label=`s.\n\n## Read the lint\n\nPackage warnings after a reload are the report's test suite. Fix all of them, and read the text rather than the severity or the count. **\"Dashboard lint stopped early, so this list is incomplete\"** means the rest of the list is partial.\n\n| Finding | Fix |\n| ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\n| `# drill … targets \"x\", which is not a report in this package` | A dead click. Write the target's path |\n| `to=self, but no model in this package declares a given \"X\"` | The clicked value has nowhere to go. Declare the given, or fix `given=` |\n| `given \"X\" suggests options from source \"y\", which this file does not define` | Import `y` into the report |\n| `filters by given \"X\", which this file does not import` | No control is shown for it. Import the given |\n| A tile that does not resolve to a real view | Fix the view name, or declare the view |\n| `notebook-artifact-unparsed` | The tag does not parse, usually an unquoted `source -> view` entry |\n| `notebook-layout-run` | A `run:` beside `tiles`. Make it a view, list it, delete the `run:` |\n| `notebook-tile-layout-ignored` | `colspan` or `break` on a linear report. Remove them, or switch to `layout=grid` |\n| `report-kind-removed` | The tag sets `kind=`, which is no longer read, so the file is no report. Write the `layout=` the finding gives |\n| `report-cells-removed` | A linear tag with no `tiles`, the retired cell format, so the file is no report. List its tiles (`reference/linear.md`) |\n| `report-on-surface` | A model `index.malloy` imports (or `explores` lists) is tagged, so its sources left the surface. Untag it, or move the report out |\n| `index-is-not-a-report` | The root `index.malloy` carries a tag. Remove it; put the report in its own file |\n| `report-name-ambiguous` | Two grid reports share a file name, so the deprecated by-name dashboards route answers 409 for it. Rename one of the files |\n| `Tile … reads <source>, which index.malloy doesn't export` | Add the source to the `export { ... }` in `index.malloy`. Never add an `explores` to `publisher.json` |\n| A grid's `columns`, `colspan`, or `dashboard_columns=` findings | `reference/grid.md` |\n| Prose the reader cannot place (`notebook-markdown-attached-nowhere`, `notebook-markdown-block-unnamed`) | `reference/linear.md` |\n\n**Read the status the reload itself returns, not the listing.** One report that fails to compile fails the whole package load (424), and a package already serving keeps its previous version, so the listing still looks healthy. `GET /api/v0/status` still knows: a package serving an older model than its files appears in `loadErrors` with `stale: true`. If the reload is 200 and your report is not listed, discovery skipped it: usually a missing or misspelled artifact tag, or a file under `public/`.\n\n**A clean reload is not proof the tags are right.** The parse warning is syntax only and carries no position. That is why the last step is opening the page.\n\n## Template\n\n```malloy\n##! experimental.givens\n##\" [One-line description of the report.]\n##| artifact { layout=linear title=\"[Title]\"\n tiles=[\n intro { kind=text },\n \"main_source_tiles -> broad_tile\",\n \"main_source_tiles -> drill_tile\"\n ]\n}\n|##\nimport { main_source } from \"model.malloy\"\n\n##|(markdown) intro\n[Framing question: what are we trying to understand?]\n|##\n\nsource: main_source_tiles is main_source extend {\n view: broad_tile is [broad view]\n view: drill_tile is [drill view]\n}\n```\n\nFor a grid, write `layout=grid`, add `dashboard { columns=12 }` after the tag's closing `}`, and give every tile view a `# colspan` (`reference/grid.md`).\n\n## Editing in the Console\n\nA person can open any report in the Console's builder (**Edit** on the report page; **+ Report** on the package page writes `<slug>.malloy` at the package root). The builder splices the file rather than regenerating it, so comments and anything it does not model survive; it never rewrites `layout=`, so the layout is set in the file. A file written as `run:` cells, the retired linear form, is no report and does not open (`reference/linear.md`). A save whose text declares a real `#(authorize)` or `#(access_filter)` gate outside prose is refused with a 400: gates live in a model file the report imports.\n\n## What \"done\" means\n\n- The file is at the package root (or where the author asked), and its tag says `layout=`.\n- Every source, view, and field name came from the model you read in step 1.\n- The reload returned **200**, not 424, with **zero** report warnings, and your report is in `GET …/reports`.\n- You opened the page and every tile shows real numbers: not stuck loading, not an error, not an empty state you did not intend.\n- Each control renders as the widget you intended, and changing one changes the numbers.\n- If you added a `# drill`, its `to=` is a path, and you clicked it and landed where you meant to, with the given seeded.\n- On a grid: every tile carries a colspan, each row sums to `columns`, rows end flush, nothing is clipped, and no legend or card label ends in `…`.\n\n## Reference\n\n- `reference/grid.md`: the grid recipe, its traps, and one query as the whole page.\n- `reference/linear.md`: linear reports, rewriting a retired `run:`-cell file, legacy `.malloynb`, style, and common mistakes.\n- `docs/reports.md`: the full guide this skill condenses.\n- `docs/givens.md`: the givens the controls are generated from.\n\n## Reference files over MCP\n\nThis skill's `reference/` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read `reference/<name>.md`, get the prompt named `malloy-reports/<name>` instead.\n\nAvailable: grid, linear."
|
|
338242
|
+
body: "# Publisher Reports\n\n> A `.malloy` file with an `## artifact { layout=… tiles=[…] }` tag **is** a report. It imports the model, names the views to show, binds the filtering on those views, and says how to lay them out. Publisher discovers it at package load, renders the filter controls from the givens its tiles reference, offers `# drill` click-through, and serves it at `/<env>/<pkg>/<path>`, its package path without `.malloy`. No code, no build step.\n\nWhat other tools call a dashboard and a notebook are both reports here. The **layout** is the only difference:\n\n- **`layout=grid`**: tiles across a grid behind one filter row, scanned at a glance (a dashboard). Read `reference/grid.md` before you lay one out.\n- **`layout=linear`**: tiles and prose top to bottom, read in order (a notebook, a data story). Read `reference/linear.md` before you write one.\n\nEverything else (filters, text tiles, drill, the builder, the lint, the write path) is the same for both, and switching layout later is a one-property edit.\n\n## Where the file goes\n\n- **Write a new report at the package root**, as `<slug>.malloy`, unless the author asks for another location. No directory is required, and none is a convention: a report can live anywhere in the package except `public/`, and the root `index.malloy` is never a report. Do not create a `dashboards/` or `notebooks/` directory for it.\n- **Always write `layout=grid` or `layout=linear`** in the tag. A tag without one is read by a fallback rule, and a linear report that lists tiles without `layout=linear` is read as a grid. `kind=` is no longer read: a tag that sets it makes no report (`report-kind-removed`), so replace it with `layout=`.\n- **The package path is the report's identity.** `sales/q3.malloy` is the report `sales/q3`: its URL, its `GET …/reports/{path}` address, and its `# drill` target. Two reports may share a basename in different directories.\n- **Imports are relative to the report's own location.** At the root: `import { order_items } from \"storefront.malloy\"`. In a subdirectory: `\"../storefront.malloy\"`.\n- **An untagged `.malloy` file is an ordinary model**, wherever it lives. Shared definitions several reports import go in one.\n\n## When this is the right tool\n\n| The user wants | Use |\n| ---------------------------------------------------- | ------------------------------------------------------------ |\n| A recurring, at-a-glance view behind shared filters | this skill, `layout=grid` |\n| A narrative, with prose between the numbers | this skill, `layout=linear` |\n| A quick write-up of queries you already ran | `skill:malloy-analysis-report`, which writes a linear report |\n| Custom design, branding, or interactions beyond tags | an HTML data app |\n| The model itself: sources, measures, joins | `malloy-model` |\n\nInteractivity is not the axis: both layouts get filter controls, URL-addressable state, Apply batching, and `# drill`. Pick on the shape of the document: scanned at a glance is grid, read top to bottom is linear. When in doubt, linear.\n\n## Build sequence\n\nTool names below are bare (`get_context`); match each against the tools you actually have, since a host may prefix or rename them. A host names its compile and reload tools its own way. Where you have none, use the REST endpoint named beside the step.\n\n1. **READ THE MODEL FIRST.** Get the real source, view, dimension, and given names from the package: `get_context` if you have it, otherwise the REST model endpoint for `index.malloy` (it answers 404 for a file off the surface) or the `.malloy` files. Never guess a name. A guessed field in a query fails the whole package load, not just that one report; a guessed tile or suggest source is quieter, and only shows up in the package warnings. If the package root holds an `index.malloy` (every scaffolded package does), a tile reads only the sources that file exports.\n2. **PICK THE LAYOUT AND THE TILES.** Which views, in what order, and on a grid how wide each sits. A query tile is a `view:` you declare in the report, named as `\"source -> view\"`.\n3. **DECLARE THE GIVENS** the report will filter by, in the report file itself, with their control tags (see \"Filter controls\"). A report holds its own filters: the builder edits the report file and nothing else, so a filter it can add, change or remove is a declaration in that file. A given the model already declares and reads is imported by name, never declared again; if it is only a reader's filter, move it into the report instead (see \"Givens that stay in the model\").\n4. **COMPOSE THE FILE** at its path (the package root unless asked otherwise), following the template below, but do not save it yet. Name the sources you need in the import. Each tile binds its controls with a refinement, `view: t is v + { where: category ~ $CATEGORY }`, one clause per given it answers to; a `filter<...>` binds with `~`, a plain `date` or `number` given is a value and binds with `>=`, `<=` or `=`. Only the givens some tile references become controls. Then import every source or query any referenced given names in a `suggest`; **a `suggest` naming a `query=` needs that query's own source imported as well**, because an import is not transitive.\n5. **COMPILE IT** with your compile tool (`POST …/models/<path>/compile` over REST) at the path the file will have, with **`\"scope\": \"file\"`**: a report opens with an `import`, which the default `append` scope refuses, and an existing file compiled at `append` collides with itself. **`\"scope\": \"file\"` does not check tiles, drills or suggest queries.** To see those before you save, compile the same text at `\"scope\": \"package\"` with `modelPath` set to the report's path: it runs the lint step 6 reads and returns its findings with code `dashboard-lint` (or `render-tag`). Editing an untagged model several reports import also wants `\"scope\": \"package\"`, which recompiles every file as saved.\n6. **SAVE IT, RELOAD, AND READ THE MANIFEST AND THE WARNINGS.** Save by writing the file, or with `PUT …/models/<path>` over REST (omit `expectedHash` to create; it creates at any new path and replaces only a file the package already serves as a report). Then your reload tool, or `GET …/packages/<pkg>?reload=true`. A 424 means the package did not load and your edit is not live. **The `warnings` key is absent when there are none.** Then read `GET …/packages/<pkg>/reports/<path>`: its `givens` are exactly the controls that will render, and its `layout` is the layout Publisher read. See \"Read the lint\".\n7. **OPEN IT AND LOOK.** Not optional; see \"What done means\".\n\n## The form\n\n```malloy\n##! experimental.givens\n##\" One line saying what this report is for.\n##| artifact { layout=grid title=\"Storefront overview\"\n tiles=[\n intro { kind=text colspan=12 },\n \"overview -> kpis\",\n \"overview -> revenue_trend\",\n \"overview -> revenue_by_state\"\n ]\n} dashboard { columns=12 }\n|##\nimport { order_items, products } from \"storefront.malloy\"\n\n# label=\"Category\" control=select suggest { source=products dimension=category }\ngiven: CATEGORY :: filter<string> is f''\n# label=\"Ordered since\"\ngiven: SINCE :: date is @2023-01-01\n\n##|(markdown) intro\nRevenue and margin for the selected category.\n|##\n\nsource: overview is order_items extend {\n # colspan=12\n # label=\"Key figures\"\n # big_value\n view: kpis is key_figures + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n\n # colspan=8\n # break\n # label=\"Revenue by month\"\n view: revenue_trend is sales_by_month + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n\n # colspan=4\n # label=\"Revenue by state\"\n view: revenue_by_state is sales_by_state + { where: category ~ $CATEGORY, where: created_at >= $SINCE }\n}\n```\n\nThe same file with `layout=linear` and no `dashboard { … }` or `colspan` is a linear report.\n\n- **The tag is model-level**, one line (`## artifact { … }`) or a `##|` … `|##` block with the same text inside, the form to use once the list is long: one tile per line, `}` on its own line, `|##` closing at the opener's column. A one-line `##` tag must stay on one line: wrapping it is a compile error, or worse, compiles clean and quietly stops being a report.\n- **The header** above the tag holds only `##!` flags, `//` comments and the unnamed `\"` notes that are the report's description. Keep statements and `#` tags below `## artifact`: nothing refuses one above it, but the header is where readers and the builder look.\n- **Quote every query entry.** `\"overview -> kpis\"` is a string; unquoted, the tag does not parse (`notebook-artifact-unparsed`). A bare quoted name (`\"top_products\"`) names a model-level `query:` in the file.\n- **A query tile is a thin `view:` on an extension the report declares** (`source: overview is order_items extend { … }`): the modelled view keeps its chart tag, the wrapper carries the tile's `# label` and its binding. On a grid its width, row break and height can go on its entry instead, `\"overview -> kpis\" { colspan=12 rowspan=2 }`, which wins over the view's tags; `rowspan=N` is rows of 80px, and `row=N col=N` place the tile at a grid cell (a report with any `row` is placed: fixed rows, tiles where they say, empty cells allowed).\n- **A text tile is a `##|(markdown) name` block** listed as `name { kind=text }`. The name is one bare word on the opener line, the body starts on the next line (a heading goes inside the block), and `|##` closes it at the opener's column. A bare `intro` without `{ kind=text }` is the query-tile form for a `query:`, so the block is not shown. On a grid the entry also takes `colspan`, `break` and `rowspan`, and nothing else.\n- **File order:** header, imports and givens, the prose blocks in tile order, then the extension that defines the tile views. That is the order the builder writes.\n- **A `run:` in a report that lists `tiles` is never shown** (`notebook-layout-run`). Define a view, list it, delete the `run:`.\n- **A tile expression is a string, so the compiler never checks it.** Rename a view and the file still compiles; the tile fails at package load, where the lint names it.\n- **Chart tags go on the view** (`# line_chart`, `# bar_chart`, `# big_value`, `# label=\"…\"`). `skill:malloy-charts` covers choosing them.\n\n## Filter controls\n\nControls come from the `given:` declarations the tiles reference. The tags on the declaration are the control contract:\n\n```malloy\n##! experimental.givens\n\n# label=\"Category\" control=select suggest { source=products dimension=category }\ngiven: CATEGORY :: filter<string> is f''\n\n# label=\"Brand\" control=multiselect suggest { query=brand_suggest dimension=brand }\ngiven: BRAND :: filter<string> is f''\n\n# label=\"Minimum line total\" range_min=0 range_max=250\ngiven: MIN_SALE :: filter<number> is f''\n\n# label=\"Ordered since\"\ngiven: SINCE :: date is @2023-01-01\n```\n\n`control=select`/`multiselect` with a `suggest` renders a picker filled from the data; `range_min`/`range_max` on a `filter<number>` renders a two-handled range slider; a `filter<date>` or `filter<timestamp>` renders a time-range control with preset windows and a custom day range; a bare `date` or `timestamp` renders a date picker; a `filter<string>` with no tag renders a text box taking filter syntax. Which controls appear is per report, decided by which givens its tiles reference.\n\n- `#(description=\"…\")` renders as helper text under the control and draws a cosmetic \"not a well-formed route\" compile diagnostic for a multi-word value; `# description=\"…\"` publishes to the API but the UI does not render it.\n- `dimension=` can name a field of a directly joined source, quoted: `suggest { source=sales dimension=\"products.department\" }`. On the `query=` form, `dimension` names a column of that query's output; match it to the query's `group_by`.\n- **A suggest's source or query has to resolve in the report file**, so import it there. Getting it wrong does not error: the control still looks like a picker and says \"Could not load the options for this control\"; the package warnings name it.\n- Two options on the artifact tag, either layout: `autorun=false` batches control changes behind an Apply button; `givens { CATEGORY=f'Outerwear' }` sets starting values (a URL parameter wins). A file-level `## autorun=false` does nothing on a `.malloy` report.\n\n### Binding, and givens that stay in the model\n\n**Binding is per declaration, not per name.** A report declaring its own `CATEGORY` over a source whose model-level `where:` reads the model's `CATEGORY` compiles, shows the control, and filters nothing. So bind on the tiles, with `+ { where: … }`. The other design, model-level scoping, still works: put a scoped source in an untagged model that imports the givens it reads, and import that model and those givens into the report. Do not mix the two on one given.\n\nA given stays in the model, declared once there, when a model source, view or measure, an `#(authorize)` or `#(access_filter)` gate, or an HTML data app reads it. The report imports it by name (`import { CATEGORY } from \"givens.malloy\"`) and never re-declares it:\n\n- **Import a name and also declare it: a compile error.** Drop the local declaration.\n- **Declare a name the model already reads, without importing it: no error at all.** The control renders and moves, and the model's own `where:` or gate never sees the value.\n\n**A model that already scopes a source by an optional filter given** (`where: Manufacturer ~ $manufacturer_filter` in the model) needs a decision:\n\n- **It is a reader's filter and you may change the model: move it.** Delete the given and its `where:` clause from the model, declare it in the report with its control tags, and bind it on each tile that should answer to it. A reader's filter is anything a person picks on the page; a given stays in the model only for the reasons `malloy-model` lists (a gate, a value every query must supply, a parameter a field is defined with, scoping meant for every consumer).\n- **The model can't change, or the given is semantic or governance: import it** as above. Say so plainly: the filter shows in the reader and in the builder, and can be set in both, but the builder cannot remove or reconfigure it. It is the model's.\n\nBefore, in the model:\n\n```malloy\n##! experimental.givens\ngiven:\n manufacturer_filter :: filter<string> is f''\n subject_filter :: filter<string> is f''\n major_recall_filter :: filter<boolean> is f''\n recall_date_filter :: filter<date> is f''\n\nsource: recalls is duckdb.table('data/auto_recalls.csv') extend {\n where:\n Manufacturer ~ $manufacturer_filter,\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n dimension: `Major Recall` is ifnull(`Potentially Affected`, 0) > 100000\n}\n```\n\nAfter: the model declares no givens and `recalls` has no `where:`; the report declares the four and binds each tile:\n\n```malloy\n##! experimental.givens\n##| artifact { layout=grid title=\"Recalls\"\n tiles=[\"recalls_tiles -> recalls_by_year\", \"recalls_tiles -> recalls_by_manufacturer\"]\n} dashboard { columns=12 }\n|##\nimport { recalls } from \"auto_recalls.malloy\"\n\n# label=\"Manufacturer\" control=multiselect suggest { source=recalls dimension=Manufacturer }\ngiven: manufacturer_filter :: filter<string> is f''\n# label=\"Subject\"\ngiven: subject_filter :: filter<string> is f''\n# label=\"Major recalls only\"\ngiven: major_recall_filter :: filter<boolean> is f''\n# label=\"Filed\"\ngiven: recall_date_filter :: filter<date> is f''\n\nsource: recalls_tiles is recalls extend {\n # colspan=6\n view: recalls_by_year is by_year + {\n where:\n Manufacturer ~ $manufacturer_filter,\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n }\n # colspan=6\n view: recalls_by_manufacturer is by_manufacturer + {\n where:\n Subject ~ $subject_filter,\n `Major Recall` ~ $major_recall_filter,\n `Report Received Date` ~ $recall_date_filter\n }\n}\n```\n\nA tile view takes a name of its own: `view: by_year is by_year + { … }` is \"Cannot redefine 'by_year'\". Each tile binds only the givens it should answer to: `recalls_by_manufacturer` leaves the manufacturer filter off, since it is the breakdown by manufacturer. Every given here is a `filter<…>`, so each binds with `~`; a plain `date` or `number` given binds with `>=`, `<=` or `=` (step 4).\n\nA given has to be in the report file's own scope to be bindable: declared there or imported. Never add a `#(filter)` annotation: it is deprecated, a report renders no control for it, and every use has a `given:` form (`malloy-model` covers the migration). `malloy-model` and `docs/givens.md` cover givens themselves.\n\n## Drill\n\n`# drill` goes on a model **dimension**, never on a report:\n\n```malloy\n# drill { to=[\"category\", \"self\"] given=CATEGORY }\ndimension: category is products.category\n\n# drill { to=\"regions/west\" given=REGION }\ndimension: region is regions.region\n```\n\n- **`to=<path>`** navigates to a report, of either layout, by its package path without `.malloy`, read from the package root: `to=category` is `category.malloy` at the root, `to=\"regions/west\"` is `regions/west.malloy`. Quote a path with a `/`. **Always write the path from the package root**: a bare name is a root file, never a report of that name elsewhere.\n- **`to=self`** filters in place.\n- **A click always opens a menu that says what it will do**, even with one entry, each entry naming the field (as its column header reads) and value: `Filter to Region: EU` (`to=self`), `Open <report title> (Region: EU)` (each `to=<path>`; the file name when the report has no title), then `Show rows (Region: EU, Month: 2025-12)` (every value of the row). A tag adds entries; it never replaces the rows.\n- **Always write `given=`.** Without it the given is the dimension name verbatim, so `dimension: category` seeds `category`, not `CATEGORY`. `to=self` survives that (it folds case); `to=<path>` does not, and lands on an unfiltered page with nothing erroring.\n- **The destination needs a control for the given**, declared as `filter<T>`. No lint checks that half, so click it and look.\n- **Only table cells are marked drillable.** A report meant to be drilled wants at least one untagged (table) tile, grouped by the tagged dimension. A column whose rendered header another, non-drillable tile also shows loses its marking: give the two different `# label=`s.\n- **Every value offers `Show rows`**, tagged or not: the tile's source, filtered by the tile's own `where:` (its control bindings included) and every dimension of the clicked row. That works for every one-stage tile: `\"source -> view\"`, a refined view, a tile naming a `query:`, an inline query, a view grouped by a joined field, a null value. **A view of more than one stage (`{ … } -> { … }`) opens no rows**, and its cells are not marked: a later stage's dimension is an earlier output the source cannot be filtered by. Keep a table tile meant to show its rows to one stage, and put the second stage in its own tile. A source with parameters opens no rows either. Neither does an unnamed time truncation inside a view (`group_by: created_at.month`), because the result loses which truncation it was: name it (`group_by: order_month is created_at.month`) in a view meant to show its rows.\n\n## Read the lint\n\nPackage warnings after a reload are the report's test suite. Fix all of them, and read the text rather than the severity or the count. **\"Dashboard lint stopped early, so this list is incomplete\"** means the rest of the list is partial.\n\n| Finding | Fix |\n| ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |\n| `# drill … targets \"x\", which is not a report in this package` | A dead click. Write the target's path |\n| `to=self, but no model in this package declares a given \"X\"` | The clicked value has nowhere to go. Declare the given, or fix `given=` |\n| `given \"X\" suggests options from source \"y\", which this file does not define` | Import `y` into the report |\n| `filters by given \"X\", which this file does not import` | No control is shown for it. Import the given |\n| A tile that does not resolve to a real view | Fix the view name, or declare the view |\n| `notebook-artifact-unparsed` | The tag does not parse, usually an unquoted `source -> view` entry |\n| `notebook-layout-run` | A `run:` beside `tiles`. Make it a view, list it, delete the `run:` |\n| `notebook-tile-layout-ignored` | `colspan` or `break` on a linear report. Remove them, or switch to `layout=grid` |\n| `report-kind-removed` | The tag sets `kind=`, which is no longer read, so the file is no report. Write the `layout=` the finding gives |\n| `report-cells-removed` | A linear tag with no `tiles`, the retired cell format, so the file is no report. List its tiles (`reference/linear.md`) |\n| `report-on-surface` | A model `index.malloy` imports (or `explores` lists) is tagged, so its sources left the surface. Untag it, or move the report out |\n| `index-is-not-a-report` | The root `index.malloy` carries a tag. Remove it; put the report in its own file |\n| `report-name-ambiguous` | Two grid reports share a file name, so the deprecated by-name dashboards route answers 409 for it. Rename one of the files |\n| `Tile … reads <source>, which index.malloy doesn't export` | Add the source to the `export { ... }` in `index.malloy`. Never add an `explores` to `publisher.json` |\n| A grid's `columns`, `colspan`, or `dashboard_columns=` findings | `reference/grid.md` |\n| Prose the reader cannot place (`notebook-markdown-attached-nowhere`, `notebook-markdown-block-unnamed`) | `reference/linear.md` |\n\n**Read the status the reload itself returns, not the listing.** One report that fails to compile fails the whole package load (424), and a package already serving keeps its previous version, so the listing still looks healthy. `GET /api/v0/status` still knows: a package serving an older model than its files appears in `loadErrors` with `stale: true`. If the reload is 200 and your report is not listed, discovery skipped it: usually a missing or misspelled artifact tag, or a file under `public/`.\n\n**A clean reload is not proof the tags are right.** The parse warning is syntax only and carries no position. That is why the last step is opening the page.\n\n## Template\n\n```malloy\n##! experimental.givens\n##\" [One-line description of the report.]\n##| artifact { layout=linear title=\"[Title]\"\n tiles=[\n intro { kind=text },\n \"main_source_tiles -> broad_tile\",\n \"main_source_tiles -> drill_tile\"\n ]\n}\n|##\nimport { main_source } from \"model.malloy\"\n\n##|(markdown) intro\n[Framing question: what are we trying to understand?]\n|##\n\nsource: main_source_tiles is main_source extend {\n view: broad_tile is [broad view]\n view: drill_tile is [drill view]\n}\n```\n\nFor a grid, write `layout=grid`, add `dashboard { columns=12 }` after the tag's closing `}`, and give every tile view a `# colspan` (`reference/grid.md`).\n\n## Editing in the Console\n\nA person can open any report in the Console's builder (**Edit** on the report page; **+ Report** on the package page writes `<slug>.malloy` at the package root). The builder splices the file rather than regenerating it, so comments and anything it does not model survive; it never rewrites `layout=`, so the layout is set in the file. A file written as `run:` cells, the retired linear form, is no report and does not open (`reference/linear.md`). A save whose text declares a real `#(authorize)` or `#(access_filter)` gate outside prose is refused with a 400: gates live in a model file the report imports.\n\n## What \"done\" means\n\n- The file is at the package root (or where the author asked), and its tag says `layout=`.\n- Every source, view, and field name came from the model you read in step 1.\n- The reload returned **200**, not 424, with **zero** report warnings, and your report is in `GET …/reports`.\n- You opened the page and every tile shows real numbers: not stuck loading, not an error, not an empty state you did not intend.\n- Each control renders as the widget you intended, and changing one changes the numbers.\n- If you added a `# drill`, its `to=` is a path, and you clicked it and landed where you meant to, with the given seeded.\n- On a grid: every tile carries a colspan, each row sums to `columns`, rows end flush, nothing is clipped, and no legend or card label ends in `…`.\n\n## Reference\n\n- `reference/grid.md`: the grid recipe, its traps, and one query as the whole page.\n- `reference/linear.md`: linear reports, rewriting a retired `run:`-cell file, legacy `.malloynb`, style, and common mistakes.\n- `docs/reports.md`: the full guide this skill condenses.\n- `docs/givens.md`: the givens the controls are generated from.\n\n## Reference files over MCP\n\nThis skill's `reference/` files are served as separate prompts, one per file, fetched only when you ask for them. Where the text above says to read `reference/<name>.md`, get the prompt named `malloy-reports/<name>` instead.\n\nAvailable: grid, linear."
|
|
338182
338243
|
},
|
|
338183
338244
|
{
|
|
338184
338245
|
name: "malloy-reports/grid",
|
|
@@ -342726,8 +342787,8 @@ var require_luxon = __commonJS((exports) => {
|
|
|
342726
342787
|
}
|
|
342727
342788
|
}
|
|
342728
342789
|
function explainFromTokens(locale, input, format) {
|
|
342729
|
-
const
|
|
342730
|
-
return
|
|
342790
|
+
const parser2 = new TokenParser(locale, format);
|
|
342791
|
+
return parser2.explainFromTokens(input);
|
|
342731
342792
|
}
|
|
342732
342793
|
function parseFromTokens(locale, input, format) {
|
|
342733
342794
|
const {
|
|
@@ -348054,7 +348115,7 @@ async function compileSynthesizedPreaggregation(args) {
|
|
|
348054
348115
|
const { runtime, importBaseURL } = await args.getRuntime(new Map([[synthesizedURL.href, text]]));
|
|
348055
348116
|
const materializer = runtime.loadModel(synthesizedURL, { importBaseURL });
|
|
348056
348117
|
const model = await materializer.getModel();
|
|
348057
|
-
return { plans, model, materializer, text };
|
|
348118
|
+
return { plans, model, materializer, runtime, text };
|
|
348058
348119
|
}
|
|
348059
348120
|
async function tryCompileSynthesizedPreaggregation(args) {
|
|
348060
348121
|
try {
|
|
@@ -348241,27 +348302,47 @@ function composeDeclaredQueryMetadata(layers) {
|
|
|
348241
348302
|
const resolved = Object.assign({}, ...ordered);
|
|
348242
348303
|
return Object.keys(resolved).length > 0 ? resolved : null;
|
|
348243
348304
|
}
|
|
348244
|
-
function
|
|
348245
|
-
|
|
348246
|
-
}
|
|
348247
|
-
|
|
348248
|
-
|
|
348249
|
-
|
|
348250
|
-
|
|
348251
|
-
|
|
348252
|
-
for (const dep of node.dependsOn) {
|
|
348253
|
-
yield* visit(dep);
|
|
348305
|
+
function sourceBuildOf(build, unbuildable) {
|
|
348306
|
+
const order = [];
|
|
348307
|
+
const dependsOn = {};
|
|
348308
|
+
for (const target of build.targets) {
|
|
348309
|
+
const reads = target.dependsOn.flatMap((t2) => t2.sources.map((s2) => s2.sourceID));
|
|
348310
|
+
for (const source of target.sources) {
|
|
348311
|
+
order.push(source.sourceID);
|
|
348312
|
+
dependsOn[source.sourceID] = reads;
|
|
348254
348313
|
}
|
|
348255
|
-
|
|
348256
|
-
|
|
348314
|
+
}
|
|
348315
|
+
for (const { source } of unbuildable) {
|
|
348316
|
+
if (source.connectionName !== build.connectionName)
|
|
348317
|
+
continue;
|
|
348318
|
+
order.push(source.sourceID);
|
|
348319
|
+
dependsOn[source.sourceID] = [];
|
|
348320
|
+
}
|
|
348321
|
+
return { connectionName: build.connectionName, order, dependsOn };
|
|
348322
|
+
}
|
|
348323
|
+
function sourceBuildsOf(targets) {
|
|
348324
|
+
const builds = targets.connections.map((c) => sourceBuildOf(c, targets.unbuildable));
|
|
348325
|
+
const planned = new Set(builds.map((b) => b.connectionName));
|
|
348326
|
+
for (const { source } of targets.unbuildable) {
|
|
348327
|
+
if (planned.has(source.connectionName))
|
|
348328
|
+
continue;
|
|
348329
|
+
planned.add(source.connectionName);
|
|
348330
|
+
builds.push(sourceBuildOf({ connectionName: source.connectionName, targets: [] }, targets.unbuildable));
|
|
348331
|
+
}
|
|
348332
|
+
return builds;
|
|
348333
|
+
}
|
|
348334
|
+
function plannedSources(targets) {
|
|
348335
|
+
return [
|
|
348336
|
+
...targets.connections.flatMap((c) => c.targets.flatMap((t2) => t2.sources)),
|
|
348337
|
+
...targets.unbuildable.map((u) => u.source)
|
|
348338
|
+
];
|
|
348339
|
+
}
|
|
348340
|
+
function* sourcesInBuildOrder(build, sources) {
|
|
348341
|
+
for (const sourceID of build.order) {
|
|
348342
|
+
const source = sources[sourceID];
|
|
348257
348343
|
if (source)
|
|
348258
348344
|
yield source;
|
|
348259
348345
|
}
|
|
348260
|
-
for (const level of graph.nodes) {
|
|
348261
|
-
for (const node of level) {
|
|
348262
|
-
yield* visit(node);
|
|
348263
|
-
}
|
|
348264
|
-
}
|
|
348265
348346
|
}
|
|
348266
348347
|
function computeSourceEntityId(source, connectionDigests) {
|
|
348267
348348
|
return source.makeBuildId(connectionDigests[source.connectionName], addressPartitionLayout(source, source.getSQL()));
|
|
@@ -348350,7 +348431,7 @@ async function classifyPersistSourceGate(persistSource, modelDef, materializer,
|
|
|
348350
348431
|
}
|
|
348351
348432
|
function emptyBuildPlanParts() {
|
|
348352
348433
|
return {
|
|
348353
|
-
|
|
348434
|
+
builds: [],
|
|
348354
348435
|
sources: {},
|
|
348355
348436
|
sourceModelPaths: {},
|
|
348356
348437
|
droppedPersistSources: [],
|
|
@@ -348359,7 +348440,7 @@ function emptyBuildPlanParts() {
|
|
|
348359
348440
|
};
|
|
348360
348441
|
}
|
|
348361
348442
|
function mergeBuildPlanParts(into, from) {
|
|
348362
|
-
into.
|
|
348443
|
+
into.builds.push(...from.builds);
|
|
348363
348444
|
Object.assign(into.sources, from.sources);
|
|
348364
348445
|
Object.assign(into.sourceModelPaths, from.sourceModelPaths);
|
|
348365
348446
|
into.droppedPersistSources.push(...from.droppedPersistSources);
|
|
@@ -348367,7 +348448,14 @@ function mergeBuildPlanParts(into, from) {
|
|
|
348367
348448
|
Object.assign(into.sourceGateOutcomes, from.sourceGateOutcomes);
|
|
348368
348449
|
}
|
|
348369
348450
|
async function collectModelBuildPlan(parts, args) {
|
|
348370
|
-
const {
|
|
348451
|
+
const {
|
|
348452
|
+
modelPath,
|
|
348453
|
+
packagePath,
|
|
348454
|
+
materializer,
|
|
348455
|
+
malloyModel,
|
|
348456
|
+
runtime,
|
|
348457
|
+
getRuntime
|
|
348458
|
+
} = args;
|
|
348371
348459
|
const synthesized = args.synthesized !== undefined ? args.synthesized : await tryCompileSynthesizedPreaggregation({
|
|
348372
348460
|
packagePath,
|
|
348373
348461
|
modelPath,
|
|
@@ -348375,14 +348463,14 @@ async function collectModelBuildPlan(parts, args) {
|
|
|
348375
348463
|
contents: malloyModel._modelDef?.contents ?? {}
|
|
348376
348464
|
});
|
|
348377
348465
|
if (synthesized) {
|
|
348378
|
-
const
|
|
348379
|
-
|
|
348380
|
-
parts.graphs.push(...rollupPlan.graphs);
|
|
348466
|
+
const rollupTargets = await synthesized.runtime.getBuildTargets(synthesized.model);
|
|
348467
|
+
parts.builds.push(...sourceBuildsOf(rollupTargets));
|
|
348381
348468
|
const planByName = new Map(synthesized.plans.map((p) => [p.rollupSourceName, p]));
|
|
348382
348469
|
let rollupClassificationCtx;
|
|
348383
|
-
for (const
|
|
348384
|
-
if (!
|
|
348470
|
+
for (const source of plannedSources(rollupTargets)) {
|
|
348471
|
+
if (!planByName.has(source.name))
|
|
348385
348472
|
continue;
|
|
348473
|
+
const sourceID = source.sourceID;
|
|
348386
348474
|
parts.sources[sourceID] = source;
|
|
348387
348475
|
parts.sourceModelPaths[sourceID] = modelPath;
|
|
348388
348476
|
const rollup = planByName.get(source.name);
|
|
@@ -348401,23 +348489,25 @@ async function collectModelBuildPlan(parts, args) {
|
|
|
348401
348489
|
const modelTag = malloyModel.modelAnnotations.parseAsTag("!").tag;
|
|
348402
348490
|
if (!modelTag.has("experimental", "persistence"))
|
|
348403
348491
|
return;
|
|
348404
|
-
const
|
|
348405
|
-
for (const msg of
|
|
348492
|
+
const targets = await runtime.getBuildTargets(malloyModel);
|
|
348493
|
+
for (const msg of targets.tagParseLog) {
|
|
348406
348494
|
logger.warn("Persist annotation issue", {
|
|
348407
348495
|
modelPath,
|
|
348408
348496
|
message: msg.message,
|
|
348409
348497
|
severity: msg.severity
|
|
348410
348498
|
});
|
|
348411
348499
|
}
|
|
348412
|
-
const
|
|
348500
|
+
const planned = plannedSources(targets);
|
|
348501
|
+
const recognizedNames = new Set(planned.map((s2) => s2.name));
|
|
348413
348502
|
for (const name of detectDroppedPersistSources(malloyModel, recognizedNames)) {
|
|
348414
348503
|
parts.droppedPersistSources.push({ name, modelPath });
|
|
348415
348504
|
}
|
|
348416
|
-
if (
|
|
348505
|
+
if (planned.length === 0)
|
|
348417
348506
|
return;
|
|
348418
|
-
parts.
|
|
348507
|
+
parts.builds.push(...sourceBuildsOf(targets));
|
|
348419
348508
|
let classificationCtx;
|
|
348420
|
-
for (const
|
|
348509
|
+
for (const source of planned) {
|
|
348510
|
+
const sourceID = source.sourceID;
|
|
348421
348511
|
parts.sources[sourceID] = source;
|
|
348422
348512
|
parts.sourceModelPaths[sourceID] = modelPath;
|
|
348423
348513
|
if (!classificationCtx) {
|
|
@@ -348430,28 +348520,30 @@ async function collectModelBuildPlan(parts, args) {
|
|
|
348430
348520
|
parts.sourceGateOutcomes[sourceID] = await classifyPersistSourceGate(source, classificationCtx.modelDef, materializer, classificationCtx.deps, modelPath);
|
|
348431
348521
|
}
|
|
348432
348522
|
}
|
|
348433
|
-
async function resolveConnectionDigests(connections,
|
|
348523
|
+
async function resolveConnectionDigests(connections, builds, onSkipped) {
|
|
348434
348524
|
const connectionDigests = {};
|
|
348435
|
-
for (const
|
|
348436
|
-
const conn = connections.get(
|
|
348525
|
+
for (const build of builds) {
|
|
348526
|
+
const conn = connections.get(build.connectionName);
|
|
348437
348527
|
if (!conn) {
|
|
348438
|
-
onSkipped(
|
|
348528
|
+
onSkipped(build.connectionName);
|
|
348439
348529
|
continue;
|
|
348440
348530
|
}
|
|
348441
|
-
if (!connectionDigests[
|
|
348442
|
-
connectionDigests[
|
|
348531
|
+
if (!connectionDigests[build.connectionName]) {
|
|
348532
|
+
connectionDigests[build.connectionName] = await conn.getDigest();
|
|
348443
348533
|
}
|
|
348444
348534
|
}
|
|
348445
348535
|
return connectionDigests;
|
|
348446
348536
|
}
|
|
348447
|
-
function deriveBuildPlan(
|
|
348537
|
+
function deriveBuildPlan(builds, sources, connectionDigests, sourceNames, sourceModelPaths, packageMaterialization, options) {
|
|
348448
348538
|
const include = sourceNames ? new Set(sourceNames) : null;
|
|
348449
|
-
const wireGraphs =
|
|
348450
|
-
connectionName:
|
|
348451
|
-
nodes:
|
|
348452
|
-
sourceID
|
|
348453
|
-
|
|
348454
|
-
|
|
348539
|
+
const wireGraphs = builds.map((build) => ({
|
|
348540
|
+
connectionName: build.connectionName,
|
|
348541
|
+
nodes: [
|
|
348542
|
+
build.order.map((sourceID) => ({
|
|
348543
|
+
sourceID,
|
|
348544
|
+
dependsOn: build.dependsOn[sourceID] ?? []
|
|
348545
|
+
}))
|
|
348546
|
+
]
|
|
348455
348547
|
}));
|
|
348456
348548
|
const wireSources = {};
|
|
348457
348549
|
const refusedSources = {};
|
|
@@ -348540,7 +348632,7 @@ function deriveBuildPlan(graphs, sources, connectionDigests, sourceNames, source
|
|
|
348540
348632
|
function deriveBuildPlanOutcome(compiled, materializationConfig) {
|
|
348541
348633
|
const droppedPersistSources = compiled.droppedPersistSources ?? [];
|
|
348542
348634
|
const incrementalDeclarations = collectIncrementalDeclarations(compiled.sources);
|
|
348543
|
-
const plan = compiled.
|
|
348635
|
+
const plan = compiled.builds.length === 0 ? null : deriveBuildPlan(compiled.builds, compiled.sources, compiled.connectionDigests, undefined, compiled.sourceModelPaths, materializationConfig, {
|
|
348544
348636
|
preaggregatePlans: compiled.preaggregatePlans,
|
|
348545
348637
|
sourceGateOutcomes: compiled.sourceGateOutcomes
|
|
348546
348638
|
});
|
|
@@ -350075,14 +350167,14 @@ async function buildDownstreamIntoStorage(params) {
|
|
|
350075
350167
|
message: `Chained build model did not compile over the rebound parents: ${errMessage(err)}`
|
|
350076
350168
|
});
|
|
350077
350169
|
}
|
|
350078
|
-
const
|
|
350079
|
-
|
|
350080
|
-
|
|
350081
|
-
|
|
350082
|
-
|
|
350083
|
-
|
|
350084
|
-
}
|
|
350170
|
+
const targets = await runtime.getBuildTargets(model, { virtualMap });
|
|
350171
|
+
const unbuildable = targets.unbuildable.find((u) => u.source.name === downstreamName);
|
|
350172
|
+
if (unbuildable) {
|
|
350173
|
+
throw new MaterializationEligibilityError({
|
|
350174
|
+
message: `Chained build model could not plan '${downstreamName}' over ` + `the rebound parents: ${unbuildable.problem.message}`
|
|
350175
|
+
});
|
|
350085
350176
|
}
|
|
350177
|
+
const downstream = plannedSources(targets).find((ps) => ps.name === downstreamName);
|
|
350086
350178
|
if (!downstream) {
|
|
350087
350179
|
throw new MaterializationEligibilityError({
|
|
350088
350180
|
message: `Chained build model did not yield a persist source named ` + `'${downstreamName}' (the downstream references something the ` + `rebound parents don't provide).`
|
|
@@ -357449,7 +357541,7 @@ var init_health = __esm(() => {
|
|
|
357449
357541
|
|
|
357450
357542
|
// src/storage/duckdb/DuckDBConnection.ts
|
|
357451
357543
|
import {
|
|
357452
|
-
DuckDBInstance
|
|
357544
|
+
DuckDBInstance as DuckDBInstance2
|
|
357453
357545
|
} from "@duckdb/node-api";
|
|
357454
357546
|
import * as path15 from "path";
|
|
357455
357547
|
function duckDBInstanceResourceOptions() {
|
|
@@ -357475,7 +357567,7 @@ class DuckDBConnection3 {
|
|
|
357475
357567
|
}
|
|
357476
357568
|
async initialize() {
|
|
357477
357569
|
try {
|
|
357478
|
-
this.instance = await
|
|
357570
|
+
this.instance = await DuckDBInstance2.create(this.dbPath, duckDBInstanceResourceOptions());
|
|
357479
357571
|
this.connection = await this.instance.connect();
|
|
357480
357572
|
await this.connection.run("SELECT 42 as answer");
|
|
357481
357573
|
} catch (err) {
|
|
@@ -359799,6 +359891,98 @@ var require_recursive_readdir = __commonJS((exports, module) => {
|
|
|
359799
359891
|
module.exports = readdir3;
|
|
359800
359892
|
});
|
|
359801
359893
|
|
|
359894
|
+
// src/service/preaggregation_coverage.ts
|
|
359895
|
+
function rollupMeasuresByMember(plans) {
|
|
359896
|
+
return new Map(plans.map((p2) => [
|
|
359897
|
+
p2.rollupSourceName,
|
|
359898
|
+
new Set(p2.measures.map((m3) => m3.name))
|
|
359899
|
+
]));
|
|
359900
|
+
}
|
|
359901
|
+
function rollupCoverageViolation(query, rollupMeasures) {
|
|
359902
|
+
const ir2 = query;
|
|
359903
|
+
const member = ir2?.compositeResolvedSourceDef?.as;
|
|
359904
|
+
if (!member)
|
|
359905
|
+
return;
|
|
359906
|
+
const merged = rollupMeasures.get(member);
|
|
359907
|
+
if (!merged)
|
|
359908
|
+
return;
|
|
359909
|
+
const memberFields = new Map((ir2?.compositeResolvedSourceDef?.fields ?? []).map((f5) => [
|
|
359910
|
+
f5.as ?? f5.name,
|
|
359911
|
+
f5
|
|
359912
|
+
]));
|
|
359913
|
+
const followed = new Set;
|
|
359914
|
+
let violation;
|
|
359915
|
+
const visitRef = (path16) => {
|
|
359916
|
+
if (!path16)
|
|
359917
|
+
return;
|
|
359918
|
+
if (path16.length !== 1) {
|
|
359919
|
+
violation = `reference ${path16.join(".")} reaches past the rollup's own fields`;
|
|
359920
|
+
return;
|
|
359921
|
+
}
|
|
359922
|
+
const name = path16[0];
|
|
359923
|
+
if (merged.has(name) || followed.has(name))
|
|
359924
|
+
return;
|
|
359925
|
+
followed.add(name);
|
|
359926
|
+
visitExpr(memberFields.get(name)?.e);
|
|
359927
|
+
};
|
|
359928
|
+
const visitExpr = (e2) => {
|
|
359929
|
+
if (violation || !e2 || typeof e2 !== "object")
|
|
359930
|
+
return;
|
|
359931
|
+
if (Array.isArray(e2)) {
|
|
359932
|
+
for (const item of e2)
|
|
359933
|
+
visitExpr(item);
|
|
359934
|
+
return;
|
|
359935
|
+
}
|
|
359936
|
+
const n = e2;
|
|
359937
|
+
if (n.node === "aggregate") {
|
|
359938
|
+
violation = `aggregate ${String(n.function)}() is computed over the rollup's rows, not from a stored partial`;
|
|
359939
|
+
return;
|
|
359940
|
+
}
|
|
359941
|
+
if (n.node === "function_call" && n.overload?.returnType?.expressionType === "aggregate") {
|
|
359942
|
+
violation = `aggregate function ${String(n.name)}() is computed over the rollup's rows, not from a stored partial`;
|
|
359943
|
+
return;
|
|
359944
|
+
}
|
|
359945
|
+
if (n.node === "field") {
|
|
359946
|
+
visitRef(n.path);
|
|
359947
|
+
return;
|
|
359948
|
+
}
|
|
359949
|
+
for (const [key, value] of Object.entries(n)) {
|
|
359950
|
+
if (key === "overload" || key === "at")
|
|
359951
|
+
continue;
|
|
359952
|
+
visitExpr(value);
|
|
359953
|
+
}
|
|
359954
|
+
};
|
|
359955
|
+
const visitStage = (stage) => {
|
|
359956
|
+
if (violation)
|
|
359957
|
+
return;
|
|
359958
|
+
if (stage?.type === "project" || stage?.type === "index") {
|
|
359959
|
+
violation = `${stage.type === "project" ? "a select:" : "an index:"} stage over the rollup returns one row per grain group, not the source's rows`;
|
|
359960
|
+
return;
|
|
359961
|
+
}
|
|
359962
|
+
for (const field of stage?.queryFields ?? []) {
|
|
359963
|
+
if (field.type === "fieldref")
|
|
359964
|
+
visitRef(field.path);
|
|
359965
|
+
else if (field.type === "turtle")
|
|
359966
|
+
visitStage(field.pipeline?.[0]);
|
|
359967
|
+
else
|
|
359968
|
+
visitExpr(field.e);
|
|
359969
|
+
}
|
|
359970
|
+
for (const filter2 of stage?.filterList ?? [])
|
|
359971
|
+
visitExpr(filter2.e);
|
|
359972
|
+
};
|
|
359973
|
+
visitStage(ir2?.pipeline?.[0]);
|
|
359974
|
+
return violation ? `${member}: ${violation}` : undefined;
|
|
359975
|
+
}
|
|
359976
|
+
var RollupCoverageError;
|
|
359977
|
+
var init_preaggregation_coverage = __esm(() => {
|
|
359978
|
+
RollupCoverageError = class RollupCoverageError extends Error {
|
|
359979
|
+
constructor(message) {
|
|
359980
|
+
super(message);
|
|
359981
|
+
this.name = "RollupCoverageError";
|
|
359982
|
+
}
|
|
359983
|
+
};
|
|
359984
|
+
});
|
|
359985
|
+
|
|
359802
359986
|
// src/service/render_tag_targets.ts
|
|
359803
359987
|
function renderTagTargets(queries, sources) {
|
|
359804
359988
|
const targets = [];
|
|
@@ -360127,6 +360311,62 @@ var init_json_utils = __esm(() => {
|
|
|
360127
360311
|
MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
|
360128
360312
|
});
|
|
360129
360313
|
|
|
360314
|
+
// src/service/drill_metadata.ts
|
|
360315
|
+
import { API } from "@malloydata/malloy";
|
|
360316
|
+
function correctDrillMetadata(result) {
|
|
360317
|
+
const structs = result._queryResult?.structs;
|
|
360318
|
+
const root = structs?.[structs.length - 1];
|
|
360319
|
+
if (root === undefined)
|
|
360320
|
+
return false;
|
|
360321
|
+
if (root.resultMetadata?.sourceField === LAST_STAGE)
|
|
360322
|
+
root.resultMetadata.drillable = false;
|
|
360323
|
+
correctFields(root.fields ?? []);
|
|
360324
|
+
return true;
|
|
360325
|
+
}
|
|
360326
|
+
function wrapWithCorrectDrillMetadata(result) {
|
|
360327
|
+
const corrected = correctDrillMetadata(result);
|
|
360328
|
+
const wrapped = API.util.wrapResult(result);
|
|
360329
|
+
if (!corrected)
|
|
360330
|
+
return wrapped;
|
|
360331
|
+
wrapped.annotations = [
|
|
360332
|
+
...wrapped.annotations ?? [],
|
|
360333
|
+
{ value: `#(malloy) ${DRILL_METADATA_CORRECTED}
|
|
360334
|
+
` }
|
|
360335
|
+
];
|
|
360336
|
+
return wrapped;
|
|
360337
|
+
}
|
|
360338
|
+
function correctFields(fields) {
|
|
360339
|
+
for (const field of fields) {
|
|
360340
|
+
if (field.fields)
|
|
360341
|
+
correctFields(field.fields);
|
|
360342
|
+
const meta = field.resultMetadata;
|
|
360343
|
+
if (meta?.fieldKind !== "dimension")
|
|
360344
|
+
continue;
|
|
360345
|
+
const code = drillCode(field, meta);
|
|
360346
|
+
if (code === null)
|
|
360347
|
+
delete meta.fieldKind;
|
|
360348
|
+
else if (code !== undefined)
|
|
360349
|
+
meta.sourceExpression = code;
|
|
360350
|
+
}
|
|
360351
|
+
}
|
|
360352
|
+
function drillCode(field, meta) {
|
|
360353
|
+
const { sourceField, sourceExpression, drillExpression } = meta;
|
|
360354
|
+
if (sourceExpression)
|
|
360355
|
+
return sourceField?.includes(".") ? sourceField : undefined;
|
|
360356
|
+
if (field.timeframe === undefined)
|
|
360357
|
+
return;
|
|
360358
|
+
const reference = drillExpression?.field_reference;
|
|
360359
|
+
if (drillExpression?.kind === "time_truncation" && reference !== undefined && drillExpression.truncation !== undefined)
|
|
360360
|
+
return [
|
|
360361
|
+
...reference.path ?? [],
|
|
360362
|
+
reference.name,
|
|
360363
|
+
drillExpression.truncation
|
|
360364
|
+
].join(".");
|
|
360365
|
+
return null;
|
|
360366
|
+
}
|
|
360367
|
+
var DRILL_METADATA_CORRECTED = "drill_metadata_corrected", LAST_STAGE = "~computeLastStage~";
|
|
360368
|
+
var init_drill_metadata = () => {};
|
|
360369
|
+
|
|
360130
360370
|
// src/service/statement_usage.ts
|
|
360131
360371
|
function emitStatementUsage(usage) {
|
|
360132
360372
|
try {
|
|
@@ -360179,7 +360419,6 @@ var init_statement_usage = __esm(() => {
|
|
|
360179
360419
|
|
|
360180
360420
|
// src/service/query_execution.ts
|
|
360181
360421
|
import {
|
|
360182
|
-
API,
|
|
360183
360422
|
MalloyError as MalloyError4
|
|
360184
360423
|
} from "@malloydata/malloy";
|
|
360185
360424
|
async function prepareAndRun(runnable, options, run) {
|
|
@@ -360211,7 +360450,7 @@ async function prepareAndRunUntraced(runnable, options, run) {
|
|
|
360211
360450
|
}
|
|
360212
360451
|
function serializeWithinCaps(result, caps) {
|
|
360213
360452
|
assertWithinModelRowLimit(result.totalRows, caps.maxRows, caps.source);
|
|
360214
|
-
const wrapped =
|
|
360453
|
+
const wrapped = wrapWithCorrectDrillMetadata(result);
|
|
360215
360454
|
const serialized = stringifyQueryResponse(caps.compact ? result.data.value : wrapped, result.totalRows, caps.maxBytes, caps.source, caps.compact ? bigIntReplacer : undefined);
|
|
360216
360455
|
assertWithinModelByteLimit(serialized, caps.maxBytes, caps.source);
|
|
360217
360456
|
return { wrapped, serialized };
|
|
@@ -360262,6 +360501,7 @@ var init_query_execution = __esm(() => {
|
|
|
360262
360501
|
init_errors();
|
|
360263
360502
|
init_logger();
|
|
360264
360503
|
init_json_utils();
|
|
360504
|
+
init_drill_metadata();
|
|
360265
360505
|
init_model_limits();
|
|
360266
360506
|
init_statement_usage();
|
|
360267
360507
|
});
|
|
@@ -362947,6 +363187,11 @@ class StorageServeShape {
|
|
|
362947
363187
|
await this.host.ensureSharedQueryModel(this.serveShapeCache.materializer);
|
|
362948
363188
|
const runnable = this.serveShapeCache.materializer.loadRestrictedQuery(queryString);
|
|
362949
363189
|
const probeSQL = await runnable.getSQL({ virtualMap, givens });
|
|
363190
|
+
if (groups.length > 0) {
|
|
363191
|
+
const uncovered = rollupCoverageViolation((await runnable.getPreparedQuery())._query, rollupMeasuresByMember(this.host.rollupPlans()));
|
|
363192
|
+
if (uncovered)
|
|
363193
|
+
throw new RollupCoverageError(uncovered);
|
|
363194
|
+
}
|
|
362950
363195
|
const rollupPaths = [
|
|
362951
363196
|
...buildVirtualMap(groups.flatMap((g2) => g2.members))
|
|
362952
363197
|
].flatMap(([, byHandle]) => [...byHandle.values()]);
|
|
@@ -363147,6 +363392,7 @@ var init_model_serve_shape = __esm(() => {
|
|
|
363147
363392
|
init_materialization_metrics();
|
|
363148
363393
|
init_connection();
|
|
363149
363394
|
init_materialization_serve_transform();
|
|
363395
|
+
init_preaggregation_coverage();
|
|
363150
363396
|
init_preaggregation_serve_bindings();
|
|
363151
363397
|
staleBindingsWithheld = lazyCounter("publisher_storage_bindings_withheld_total", "Storage serve bindings withheld from a routed query because the freshness gate found them stale; each one is read live instead. Until now staleness was visible only as a later live read.");
|
|
363152
363398
|
});
|
|
@@ -363189,6 +363435,7 @@ var init_model = __esm(() => {
|
|
|
363189
363435
|
init_config();
|
|
363190
363436
|
init_preaggregation_synthesis();
|
|
363191
363437
|
init_preaggregation_compile();
|
|
363438
|
+
init_preaggregation_coverage();
|
|
363192
363439
|
init_render_tag_targets();
|
|
363193
363440
|
init_model_info();
|
|
363194
363441
|
init_logger();
|
|
@@ -365370,13 +365617,17 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
365370
365617
|
});
|
|
365371
365618
|
} catch (shapeErr) {
|
|
365372
365619
|
recordStorageServeRouting("live_fallback");
|
|
365373
|
-
|
|
365374
|
-
|
|
365375
|
-
|
|
365376
|
-
|
|
365377
|
-
|
|
365378
|
-
|
|
365379
|
-
|
|
365620
|
+
if (shapeErr instanceof RollupCoverageError) {
|
|
365621
|
+
logger.debug("storage rollup cannot answer this query; serving live", { modelPath: this.modelPath, error: shapeErr.message });
|
|
365622
|
+
} else {
|
|
365623
|
+
const { shapeSources, staleSources } = serveShapeDiagnostics(this.storageServe.serveBindings, this.storageServe.freshServeBindings(Date.now()));
|
|
365624
|
+
logger.info("storage serve-shape ineligible for this query; serving live", {
|
|
365625
|
+
modelPath: this.modelPath,
|
|
365626
|
+
error: shapeErr instanceof Error ? shapeErr.message : String(shapeErr),
|
|
365627
|
+
shapeSources,
|
|
365628
|
+
staleSources
|
|
365629
|
+
});
|
|
365630
|
+
}
|
|
365380
365631
|
}
|
|
365381
365632
|
}
|
|
365382
365633
|
const everyRollupIsStorageBound = this.preaggregateRollupPlans.length > 0 && this.preaggregateRollupPlans.every((plan) => !!plan.storage);
|
|
@@ -365387,10 +365638,13 @@ run: ${sourceName ? `${quoteMalloyIdentifier2(sourceName)} -> ` : ""}${quoteMall
|
|
|
365387
365638
|
givens: querySurfaceGivens,
|
|
365388
365639
|
buildManifest
|
|
365389
365640
|
});
|
|
365641
|
+
const uncovered = rollupCoverageViolation((await candidate.getPreparedQuery())._query, rollupMeasuresByMember(this.preaggregateRollupPlans));
|
|
365642
|
+
if (uncovered)
|
|
365643
|
+
throw new RollupCoverageError(uncovered);
|
|
365390
365644
|
runnable = candidate;
|
|
365391
365645
|
preaggRouted = true;
|
|
365392
365646
|
} catch (preaggErr) {
|
|
365393
|
-
logger.debug("query
|
|
365647
|
+
logger.debug("query cannot be served from the pre-aggregation model; serving live", {
|
|
365394
365648
|
modelPath: this.modelPath,
|
|
365395
365649
|
error: preaggErr instanceof Error ? preaggErr.message : String(preaggErr)
|
|
365396
365650
|
});
|
|
@@ -366023,18 +366277,19 @@ async function compilePackageBuildPlan(pkg, signal) {
|
|
|
366023
366277
|
packagePath: pkg.getPackagePath(),
|
|
366024
366278
|
materializer,
|
|
366025
366279
|
malloyModel,
|
|
366280
|
+
runtime,
|
|
366026
366281
|
getRuntime: (overlay) => Model.getModelRuntime(pkg.getPackagePath(), modelPath, pkg.getMalloyConfig(), { overlay })
|
|
366027
366282
|
});
|
|
366028
366283
|
}
|
|
366029
|
-
const connections = await resolvePackageConnections(pkg, parts.
|
|
366030
|
-
const connectionDigests = await resolveConnectionDigests(connections, parts.
|
|
366284
|
+
const connections = await resolvePackageConnections(pkg, parts.builds.map((b3) => b3.connectionName));
|
|
366285
|
+
const connectionDigests = await resolveConnectionDigests(connections, parts.builds, (connectionName) => {
|
|
366031
366286
|
recordConnectionDigestSkipped();
|
|
366032
366287
|
logger.warn("Skipping connection digest; connection did not resolve", {
|
|
366033
366288
|
connectionName
|
|
366034
366289
|
});
|
|
366035
366290
|
});
|
|
366036
366291
|
return {
|
|
366037
|
-
|
|
366292
|
+
builds: parts.builds,
|
|
366038
366293
|
sources: parts.sources,
|
|
366039
366294
|
connectionDigests,
|
|
366040
366295
|
connections,
|
|
@@ -374474,11 +374729,11 @@ function resolveInstruction(index, persistSource, connectionDigests) {
|
|
|
374474
374729
|
return;
|
|
374475
374730
|
return { instruction, orchestrated: false, sourceEntityId };
|
|
374476
374731
|
}
|
|
374477
|
-
function assertNoTableCollisions(
|
|
374732
|
+
function assertNoTableCollisions(builds, sources, connectionDigests, index) {
|
|
374478
374733
|
const claimedBy = new Map;
|
|
374479
374734
|
const collisions = [];
|
|
374480
|
-
for (const
|
|
374481
|
-
for (const persistSource of
|
|
374735
|
+
for (const build of builds) {
|
|
374736
|
+
for (const persistSource of sourcesInBuildOrder(build, sources)) {
|
|
374482
374737
|
let address;
|
|
374483
374738
|
try {
|
|
374484
374739
|
address = computeSourceEntityId(persistSource, connectionDigests);
|
|
@@ -374488,7 +374743,7 @@ function assertNoTableCollisions(graphs, sources, connectionDigests, index) {
|
|
|
374488
374743
|
const instruction = index.bySourceID.get(persistSource.sourceID) ?? index.bySourceEntityId.get(address);
|
|
374489
374744
|
if (!instruction)
|
|
374490
374745
|
continue;
|
|
374491
|
-
const target = physicalTargetKey(instruction,
|
|
374746
|
+
const target = physicalTargetKey(instruction, build.connectionName);
|
|
374492
374747
|
const claim = claimedBy.get(target);
|
|
374493
374748
|
if (!claim) {
|
|
374494
374749
|
claimedBy.set(target, {
|
|
@@ -374779,8 +375034,8 @@ class MaterializationService {
|
|
|
374779
375034
|
const refusals = [];
|
|
374780
375035
|
const cleared = new Map;
|
|
374781
375036
|
const seen = new Set;
|
|
374782
|
-
for (const
|
|
374783
|
-
for (const persistSource of
|
|
375037
|
+
for (const build of compiled.builds) {
|
|
375038
|
+
for (const persistSource of sourcesInBuildOrder(build, compiled.sources)) {
|
|
374784
375039
|
if (include && !include.has(persistSource.name))
|
|
374785
375040
|
continue;
|
|
374786
375041
|
if (getPersistStorageMode() === "off" && declaredStorage(persistSource)) {
|
|
@@ -375036,7 +375291,7 @@ class MaterializationService {
|
|
|
375036
375291
|
return incrementalRunContext(compiled, run, this.repository);
|
|
375037
375292
|
}
|
|
375038
375293
|
async executeInstructedBuild(compiled, environment, instructions, seedEntries, signal, strict = false, owner, buildMetadata, incremental, priorRefusals = [], clearedTiers) {
|
|
375039
|
-
const {
|
|
375294
|
+
const { builds, sources, connectionDigests, connections } = compiled;
|
|
375040
375295
|
const index = indexInstructions(instructions);
|
|
375041
375296
|
const run = {
|
|
375042
375297
|
compiled,
|
|
@@ -375056,17 +375311,17 @@ class MaterializationService {
|
|
|
375056
375311
|
builtSources: [],
|
|
375057
375312
|
writtenTargets: new Map
|
|
375058
375313
|
};
|
|
375059
|
-
assertNoTableCollisions(
|
|
375314
|
+
assertNoTableCollisions(builds, sources, connectionDigests, index);
|
|
375060
375315
|
try {
|
|
375061
|
-
for (const
|
|
375062
|
-
const connection = connections.get(
|
|
375316
|
+
for (const build of builds) {
|
|
375317
|
+
const connection = connections.get(build.connectionName);
|
|
375063
375318
|
if (!connection) {
|
|
375064
|
-
throw new BadRequestError(`Connection '${
|
|
375319
|
+
throw new BadRequestError(`Connection '${build.connectionName}' not found`);
|
|
375065
375320
|
}
|
|
375066
|
-
for (const persistSource of
|
|
375321
|
+
for (const persistSource of sourcesInBuildOrder(build, sources)) {
|
|
375067
375322
|
if (signal.aborted)
|
|
375068
375323
|
throw new Error("Build cancelled");
|
|
375069
|
-
await this.buildInstructedSource(run, persistSource,
|
|
375324
|
+
await this.buildInstructedSource(run, persistSource, build.connectionName, connection);
|
|
375070
375325
|
}
|
|
375071
375326
|
}
|
|
375072
375327
|
if (run.builtSources.length === 0 && run.failedReasons.length > 0) {
|