@malloy-publisher/server 0.0.209 → 0.0.210
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/server.mjs +164 -166
- package/package.json +1 -1
- package/src/service/model.ts +25 -25
- package/src/service/package.ts +5 -5
- package/src/service/package_worker_path.spec.ts +65 -55
package/dist/server.mjs
CHANGED
|
@@ -237892,7 +237892,6 @@ import {
|
|
|
237892
237892
|
init_logger();
|
|
237893
237893
|
|
|
237894
237894
|
// src/service/model.ts
|
|
237895
|
-
init_package_load_pool();
|
|
237896
237895
|
var import_api5 = __toESM(require_src(), 1);
|
|
237897
237896
|
import {
|
|
237898
237897
|
Annotations as Annotations2,
|
|
@@ -237963,6 +237962,150 @@ class HackyDataStylesAccumulator {
|
|
|
237963
237962
|
// src/service/model.ts
|
|
237964
237963
|
init_errors();
|
|
237965
237964
|
init_logger();
|
|
237965
|
+
init_package_load_pool();
|
|
237966
|
+
|
|
237967
|
+
// src/service/annotations.ts
|
|
237968
|
+
import { Annotations } from "@malloydata/malloy";
|
|
237969
|
+
function isReservedRoute(route) {
|
|
237970
|
+
return route === "" || !/[\p{L}\p{N}]/u.test(route);
|
|
237971
|
+
}
|
|
237972
|
+
function modelAnnotations(modelDef) {
|
|
237973
|
+
const registry = modelDef.modelAnnotations ?? {};
|
|
237974
|
+
const visited = new Set;
|
|
237975
|
+
const order = [];
|
|
237976
|
+
const visit = (id) => {
|
|
237977
|
+
if (visited.has(id))
|
|
237978
|
+
return;
|
|
237979
|
+
visited.add(id);
|
|
237980
|
+
const entry = registry[id];
|
|
237981
|
+
if (!entry)
|
|
237982
|
+
return;
|
|
237983
|
+
for (const dep of entry.inheritsFrom)
|
|
237984
|
+
visit(dep);
|
|
237985
|
+
order.push(id);
|
|
237986
|
+
};
|
|
237987
|
+
visit(modelDef.modelID);
|
|
237988
|
+
let folded;
|
|
237989
|
+
for (const id of order) {
|
|
237990
|
+
const own = registry[id].ownNotes;
|
|
237991
|
+
if (!own.notes?.length && !own.blockNotes?.length)
|
|
237992
|
+
continue;
|
|
237993
|
+
folded = {
|
|
237994
|
+
notes: own.notes,
|
|
237995
|
+
blockNotes: own.blockNotes,
|
|
237996
|
+
inherits: folded
|
|
237997
|
+
};
|
|
237998
|
+
}
|
|
237999
|
+
return folded ?? {};
|
|
238000
|
+
}
|
|
238001
|
+
function annotationTexts(annote) {
|
|
238002
|
+
const texts = new Annotations(annote).texts();
|
|
238003
|
+
return texts.length > 0 ? texts : undefined;
|
|
238004
|
+
}
|
|
238005
|
+
|
|
238006
|
+
// src/service/authorize.ts
|
|
238007
|
+
init_errors();
|
|
238008
|
+
var SOURCE_PREFIX = "#(authorize)";
|
|
238009
|
+
var FILE_PREFIX = "##(authorize)";
|
|
238010
|
+
function buildAuthorizeProbe(exprs) {
|
|
238011
|
+
const selects = exprs.map((expr, i) => `__auth_${i} is (${expr})`).join(`
|
|
238012
|
+
`);
|
|
238013
|
+
return `run: duckdb.sql("SELECT 1 AS __authorize_probe_row") -> {
|
|
238014
|
+
select:
|
|
238015
|
+
${selects}
|
|
238016
|
+
limit: 1
|
|
238017
|
+
}`;
|
|
238018
|
+
}
|
|
238019
|
+
function isProbeTrue(cell) {
|
|
238020
|
+
return cell === true || cell === 1 || cell === "true";
|
|
238021
|
+
}
|
|
238022
|
+
async function evaluateAuthorize(executor, exprs, givens) {
|
|
238023
|
+
for (const expr of exprs) {
|
|
238024
|
+
try {
|
|
238025
|
+
const result = await executor.loadQuery(buildAuthorizeProbe([expr])).run({ rowLimit: 1, givens });
|
|
238026
|
+
const row = result?.data?.value?.[0];
|
|
238027
|
+
if (row && isProbeTrue(row.__auth_0)) {
|
|
238028
|
+
return true;
|
|
238029
|
+
}
|
|
238030
|
+
} catch {
|
|
238031
|
+
continue;
|
|
238032
|
+
}
|
|
238033
|
+
}
|
|
238034
|
+
return false;
|
|
238035
|
+
}
|
|
238036
|
+
async function validateAuthorizeProbes(compiler, sources) {
|
|
238037
|
+
for (const source of sources) {
|
|
238038
|
+
const exprs = source.authorize;
|
|
238039
|
+
if (!exprs || exprs.length === 0)
|
|
238040
|
+
continue;
|
|
238041
|
+
try {
|
|
238042
|
+
await compiler.loadQuery(buildAuthorizeProbe(exprs)).getPreparedQuery();
|
|
238043
|
+
} catch (err) {
|
|
238044
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
238045
|
+
throw new ModelCompilationError({
|
|
238046
|
+
message: `Invalid #(authorize) annotation on source "${source.name ?? "(unnamed)"}" [${exprs.join(" | ")}]: ${detail}`
|
|
238047
|
+
});
|
|
238048
|
+
}
|
|
238049
|
+
}
|
|
238050
|
+
}
|
|
238051
|
+
function parseAuthorizeAnnotation(annotation) {
|
|
238052
|
+
const trimmed2 = annotation.trim();
|
|
238053
|
+
let body;
|
|
238054
|
+
if (trimmed2.startsWith(FILE_PREFIX)) {
|
|
238055
|
+
body = trimmed2.slice(FILE_PREFIX.length).trim();
|
|
238056
|
+
} else if (trimmed2.startsWith(SOURCE_PREFIX)) {
|
|
238057
|
+
body = trimmed2.slice(SOURCE_PREFIX.length).trim();
|
|
238058
|
+
} else {
|
|
238059
|
+
return null;
|
|
238060
|
+
}
|
|
238061
|
+
return unwrapQuotedExpression(body);
|
|
238062
|
+
}
|
|
238063
|
+
function collectAuthorizeExprs(annotations) {
|
|
238064
|
+
const exprs = [];
|
|
238065
|
+
for (const annotation of annotations) {
|
|
238066
|
+
const expr = parseAuthorizeAnnotation(annotation);
|
|
238067
|
+
if (expr !== null) {
|
|
238068
|
+
exprs.push(expr);
|
|
238069
|
+
}
|
|
238070
|
+
}
|
|
238071
|
+
return exprs;
|
|
238072
|
+
}
|
|
238073
|
+
function unwrapQuotedExpression(body) {
|
|
238074
|
+
if (body.length < 2 || body[0] !== '"') {
|
|
238075
|
+
throw new Error(`authorize annotation expression must be a double-quoted string, got: ${body || "(empty)"}`);
|
|
238076
|
+
}
|
|
238077
|
+
let expr = "";
|
|
238078
|
+
let i = 1;
|
|
238079
|
+
let closed = false;
|
|
238080
|
+
for (;i < body.length; i++) {
|
|
238081
|
+
const ch = body[i];
|
|
238082
|
+
if (ch === "\\" && i + 1 < body.length) {
|
|
238083
|
+
const next = body[i + 1];
|
|
238084
|
+
if (next === '"' || next === "\\") {
|
|
238085
|
+
expr += next;
|
|
238086
|
+
i++;
|
|
238087
|
+
continue;
|
|
238088
|
+
}
|
|
238089
|
+
}
|
|
238090
|
+
if (ch === '"') {
|
|
238091
|
+
closed = true;
|
|
238092
|
+
i++;
|
|
238093
|
+
break;
|
|
238094
|
+
}
|
|
238095
|
+
expr += ch;
|
|
238096
|
+
}
|
|
238097
|
+
if (!closed) {
|
|
238098
|
+
throw new Error(`authorize annotation has mismatched quotes: ${body}`);
|
|
238099
|
+
}
|
|
238100
|
+
const rest = body.slice(i).trim();
|
|
238101
|
+
if (rest.length > 0) {
|
|
238102
|
+
throw new Error(`authorize annotation has unexpected content after the expression: ${rest}`);
|
|
238103
|
+
}
|
|
238104
|
+
if (expr.trim().length === 0) {
|
|
238105
|
+
throw new Error("authorize annotation has an empty expression body");
|
|
238106
|
+
}
|
|
238107
|
+
return expr;
|
|
238108
|
+
}
|
|
237966
238109
|
|
|
237967
238110
|
// src/service/filter.ts
|
|
237968
238111
|
var VALID_FILTER_TYPES = new Set([
|
|
@@ -238155,149 +238298,6 @@ function tokenize(input) {
|
|
|
238155
238298
|
return tokens;
|
|
238156
238299
|
}
|
|
238157
238300
|
|
|
238158
|
-
// src/service/authorize.ts
|
|
238159
|
-
init_errors();
|
|
238160
|
-
var SOURCE_PREFIX = "#(authorize)";
|
|
238161
|
-
var FILE_PREFIX = "##(authorize)";
|
|
238162
|
-
function buildAuthorizeProbe(exprs) {
|
|
238163
|
-
const selects = exprs.map((expr, i) => `__auth_${i} is (${expr})`).join(`
|
|
238164
|
-
`);
|
|
238165
|
-
return `run: duckdb.sql("SELECT 1 AS __authorize_probe_row") -> {
|
|
238166
|
-
select:
|
|
238167
|
-
${selects}
|
|
238168
|
-
limit: 1
|
|
238169
|
-
}`;
|
|
238170
|
-
}
|
|
238171
|
-
function isProbeTrue(cell) {
|
|
238172
|
-
return cell === true || cell === 1 || cell === "true";
|
|
238173
|
-
}
|
|
238174
|
-
async function evaluateAuthorize(executor, exprs, givens) {
|
|
238175
|
-
for (const expr of exprs) {
|
|
238176
|
-
try {
|
|
238177
|
-
const result = await executor.loadQuery(buildAuthorizeProbe([expr])).run({ rowLimit: 1, givens });
|
|
238178
|
-
const row = result?.data?.value?.[0];
|
|
238179
|
-
if (row && isProbeTrue(row.__auth_0)) {
|
|
238180
|
-
return true;
|
|
238181
|
-
}
|
|
238182
|
-
} catch {
|
|
238183
|
-
continue;
|
|
238184
|
-
}
|
|
238185
|
-
}
|
|
238186
|
-
return false;
|
|
238187
|
-
}
|
|
238188
|
-
async function validateAuthorizeProbes(compiler, sources) {
|
|
238189
|
-
for (const source of sources) {
|
|
238190
|
-
const exprs = source.authorize;
|
|
238191
|
-
if (!exprs || exprs.length === 0)
|
|
238192
|
-
continue;
|
|
238193
|
-
try {
|
|
238194
|
-
await compiler.loadQuery(buildAuthorizeProbe(exprs)).getPreparedQuery();
|
|
238195
|
-
} catch (err) {
|
|
238196
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
238197
|
-
throw new ModelCompilationError({
|
|
238198
|
-
message: `Invalid #(authorize) annotation on source "${source.name ?? "(unnamed)"}" [${exprs.join(" | ")}]: ${detail}`
|
|
238199
|
-
});
|
|
238200
|
-
}
|
|
238201
|
-
}
|
|
238202
|
-
}
|
|
238203
|
-
function parseAuthorizeAnnotation(annotation) {
|
|
238204
|
-
const trimmed2 = annotation.trim();
|
|
238205
|
-
let body;
|
|
238206
|
-
if (trimmed2.startsWith(FILE_PREFIX)) {
|
|
238207
|
-
body = trimmed2.slice(FILE_PREFIX.length).trim();
|
|
238208
|
-
} else if (trimmed2.startsWith(SOURCE_PREFIX)) {
|
|
238209
|
-
body = trimmed2.slice(SOURCE_PREFIX.length).trim();
|
|
238210
|
-
} else {
|
|
238211
|
-
return null;
|
|
238212
|
-
}
|
|
238213
|
-
return unwrapQuotedExpression(body);
|
|
238214
|
-
}
|
|
238215
|
-
function collectAuthorizeExprs(annotations) {
|
|
238216
|
-
const exprs = [];
|
|
238217
|
-
for (const annotation of annotations) {
|
|
238218
|
-
const expr = parseAuthorizeAnnotation(annotation);
|
|
238219
|
-
if (expr !== null) {
|
|
238220
|
-
exprs.push(expr);
|
|
238221
|
-
}
|
|
238222
|
-
}
|
|
238223
|
-
return exprs;
|
|
238224
|
-
}
|
|
238225
|
-
function unwrapQuotedExpression(body) {
|
|
238226
|
-
if (body.length < 2 || body[0] !== '"') {
|
|
238227
|
-
throw new Error(`authorize annotation expression must be a double-quoted string, got: ${body || "(empty)"}`);
|
|
238228
|
-
}
|
|
238229
|
-
let expr = "";
|
|
238230
|
-
let i = 1;
|
|
238231
|
-
let closed = false;
|
|
238232
|
-
for (;i < body.length; i++) {
|
|
238233
|
-
const ch = body[i];
|
|
238234
|
-
if (ch === "\\" && i + 1 < body.length) {
|
|
238235
|
-
const next = body[i + 1];
|
|
238236
|
-
if (next === '"' || next === "\\") {
|
|
238237
|
-
expr += next;
|
|
238238
|
-
i++;
|
|
238239
|
-
continue;
|
|
238240
|
-
}
|
|
238241
|
-
}
|
|
238242
|
-
if (ch === '"') {
|
|
238243
|
-
closed = true;
|
|
238244
|
-
i++;
|
|
238245
|
-
break;
|
|
238246
|
-
}
|
|
238247
|
-
expr += ch;
|
|
238248
|
-
}
|
|
238249
|
-
if (!closed) {
|
|
238250
|
-
throw new Error(`authorize annotation has mismatched quotes: ${body}`);
|
|
238251
|
-
}
|
|
238252
|
-
const rest = body.slice(i).trim();
|
|
238253
|
-
if (rest.length > 0) {
|
|
238254
|
-
throw new Error(`authorize annotation has unexpected content after the expression: ${rest}`);
|
|
238255
|
-
}
|
|
238256
|
-
if (expr.trim().length === 0) {
|
|
238257
|
-
throw new Error("authorize annotation has an empty expression body");
|
|
238258
|
-
}
|
|
238259
|
-
return expr;
|
|
238260
|
-
}
|
|
238261
|
-
|
|
238262
|
-
// src/service/annotations.ts
|
|
238263
|
-
import { Annotations } from "@malloydata/malloy";
|
|
238264
|
-
function isReservedRoute(route) {
|
|
238265
|
-
return route === "" || !/[\p{L}\p{N}]/u.test(route);
|
|
238266
|
-
}
|
|
238267
|
-
function modelAnnotations(modelDef) {
|
|
238268
|
-
const registry = modelDef.modelAnnotations ?? {};
|
|
238269
|
-
const visited = new Set;
|
|
238270
|
-
const order = [];
|
|
238271
|
-
const visit = (id) => {
|
|
238272
|
-
if (visited.has(id))
|
|
238273
|
-
return;
|
|
238274
|
-
visited.add(id);
|
|
238275
|
-
const entry = registry[id];
|
|
238276
|
-
if (!entry)
|
|
238277
|
-
return;
|
|
238278
|
-
for (const dep of entry.inheritsFrom)
|
|
238279
|
-
visit(dep);
|
|
238280
|
-
order.push(id);
|
|
238281
|
-
};
|
|
238282
|
-
visit(modelDef.modelID);
|
|
238283
|
-
let folded;
|
|
238284
|
-
for (const id of order) {
|
|
238285
|
-
const own = registry[id].ownNotes;
|
|
238286
|
-
if (!own.notes?.length && !own.blockNotes?.length)
|
|
238287
|
-
continue;
|
|
238288
|
-
folded = {
|
|
238289
|
-
notes: own.notes,
|
|
238290
|
-
blockNotes: own.blockNotes,
|
|
238291
|
-
inherits: folded
|
|
238292
|
-
};
|
|
238293
|
-
}
|
|
238294
|
-
return folded ?? {};
|
|
238295
|
-
}
|
|
238296
|
-
function annotationTexts(annote) {
|
|
238297
|
-
const texts = new Annotations(annote).texts();
|
|
238298
|
-
return texts.length > 0 ? texts : undefined;
|
|
238299
|
-
}
|
|
238300
|
-
|
|
238301
238301
|
// src/service/given.ts
|
|
238302
238302
|
function malloyGivenToApi(given) {
|
|
238303
238303
|
const type = given.type;
|
|
@@ -238310,6 +238310,25 @@ function malloyGivenToApi(given) {
|
|
|
238310
238310
|
};
|
|
238311
238311
|
}
|
|
238312
238312
|
|
|
238313
|
+
// src/service/model_limits.ts
|
|
238314
|
+
init_errors();
|
|
238315
|
+
function resolveModelQueryRowLimit(userLimit, { defaultLimit, maxRows }) {
|
|
238316
|
+
const requested = userLimit && userLimit > 0 ? userLimit : defaultLimit;
|
|
238317
|
+
if (maxRows <= 0)
|
|
238318
|
+
return requested;
|
|
238319
|
+
return Math.min(requested, maxRows + 1);
|
|
238320
|
+
}
|
|
238321
|
+
function assertWithinModelResponseLimits(rowCount, serializedBytes, { maxRows, maxBytes }, source) {
|
|
238322
|
+
if (maxRows > 0 && rowCount > maxRows) {
|
|
238323
|
+
recordQueryCapExceeded("rows", source);
|
|
238324
|
+
throw new PayloadTooLargeError(`Query returned more than ${maxRows} rows. Refine the query (add a LIMIT or more selective WHERE) or raise PUBLISHER_MAX_QUERY_ROWS.`);
|
|
238325
|
+
}
|
|
238326
|
+
if (maxBytes > 0 && serializedBytes > maxBytes) {
|
|
238327
|
+
recordQueryCapExceeded("bytes", source);
|
|
238328
|
+
throw new PayloadTooLargeError(`Query response exceeded ${maxBytes} bytes (was ${serializedBytes}). Project fewer columns, add a LIMIT, or raise PUBLISHER_MAX_RESPONSE_BYTES.`);
|
|
238329
|
+
}
|
|
238330
|
+
}
|
|
238331
|
+
|
|
238313
238332
|
// src/service/source_extraction.ts
|
|
238314
238333
|
import {
|
|
238315
238334
|
isSourceDef
|
|
@@ -238388,25 +238407,6 @@ function extractQueriesFromModelDef(modelDef) {
|
|
|
238388
238407
|
}));
|
|
238389
238408
|
}
|
|
238390
238409
|
|
|
238391
|
-
// src/service/model_limits.ts
|
|
238392
|
-
init_errors();
|
|
238393
|
-
function resolveModelQueryRowLimit(userLimit, { defaultLimit, maxRows }) {
|
|
238394
|
-
const requested = userLimit && userLimit > 0 ? userLimit : defaultLimit;
|
|
238395
|
-
if (maxRows <= 0)
|
|
238396
|
-
return requested;
|
|
238397
|
-
return Math.min(requested, maxRows + 1);
|
|
238398
|
-
}
|
|
238399
|
-
function assertWithinModelResponseLimits(rowCount, serializedBytes, { maxRows, maxBytes }, source) {
|
|
238400
|
-
if (maxRows > 0 && rowCount > maxRows) {
|
|
238401
|
-
recordQueryCapExceeded("rows", source);
|
|
238402
|
-
throw new PayloadTooLargeError(`Query returned more than ${maxRows} rows. Refine the query (add a LIMIT or more selective WHERE) or raise PUBLISHER_MAX_QUERY_ROWS.`);
|
|
238403
|
-
}
|
|
238404
|
-
if (maxBytes > 0 && serializedBytes > maxBytes) {
|
|
238405
|
-
recordQueryCapExceeded("bytes", source);
|
|
238406
|
-
throw new PayloadTooLargeError(`Query response exceeded ${maxBytes} bytes (was ${serializedBytes}). Project fewer columns, add a LIMIT, or raise PUBLISHER_MAX_RESPONSE_BYTES.`);
|
|
238407
|
-
}
|
|
238408
|
-
}
|
|
238409
|
-
|
|
238410
238410
|
// src/service/model.ts
|
|
238411
238411
|
var MALLOY_VERSION = createRequire2(import.meta.url)("@malloydata/malloy/package.json").version;
|
|
238412
238412
|
function quoteMalloyIdentifier(name) {
|
|
@@ -238788,9 +238788,7 @@ class Model {
|
|
|
238788
238788
|
}
|
|
238789
238789
|
const errors2 = validateRenderTags2(result).filter((log) => log.severity === "error");
|
|
238790
238790
|
if (errors2.length > 0) {
|
|
238791
|
-
|
|
238792
|
-
message: `Invalid renderer configuration on '${target.label}': ${errors2.map((e) => e.message).join("; ")}`
|
|
238793
|
-
});
|
|
238791
|
+
logger.warn(`Invalid renderer configuration on '${target.label}': ${errors2.map((e) => e.message).join("; ")}`);
|
|
238794
238792
|
}
|
|
238795
238793
|
}
|
|
238796
238794
|
}
|
package/package.json
CHANGED
package/src/service/model.ts
CHANGED
|
@@ -25,11 +25,6 @@ import * as fs from "fs/promises";
|
|
|
25
25
|
import { createRequire } from "module";
|
|
26
26
|
import * as path from "path";
|
|
27
27
|
import { components } from "../api";
|
|
28
|
-
import { deserializeError } from "../package_load/package_load_pool";
|
|
29
|
-
import type {
|
|
30
|
-
SerializedModel,
|
|
31
|
-
SerializedNotebookCell,
|
|
32
|
-
} from "../package_load/protocol";
|
|
33
28
|
import {
|
|
34
29
|
getDefaultQueryRowLimit,
|
|
35
30
|
getMaxQueryRows,
|
|
@@ -46,8 +41,19 @@ import {
|
|
|
46
41
|
PayloadTooLargeError,
|
|
47
42
|
} from "../errors";
|
|
48
43
|
import { logger } from "../logger";
|
|
44
|
+
import { deserializeError } from "../package_load/package_load_pool";
|
|
45
|
+
import type {
|
|
46
|
+
SerializedModel,
|
|
47
|
+
SerializedNotebookCell,
|
|
48
|
+
} from "../package_load/protocol";
|
|
49
49
|
import { BuildManifest } from "../storage/DatabaseInterface";
|
|
50
50
|
import { URL_READER } from "../utils";
|
|
51
|
+
import { modelAnnotations } from "./annotations";
|
|
52
|
+
import {
|
|
53
|
+
collectAuthorizeExprs,
|
|
54
|
+
evaluateAuthorize,
|
|
55
|
+
validateAuthorizeProbes,
|
|
56
|
+
} from "./authorize";
|
|
51
57
|
import {
|
|
52
58
|
buildFilterClause,
|
|
53
59
|
FilterValidationError,
|
|
@@ -55,21 +61,15 @@ import {
|
|
|
55
61
|
type FilterDefinition,
|
|
56
62
|
type FilterParams,
|
|
57
63
|
} from "./filter";
|
|
58
|
-
import {
|
|
59
|
-
collectAuthorizeExprs,
|
|
60
|
-
evaluateAuthorize,
|
|
61
|
-
validateAuthorizeProbes,
|
|
62
|
-
} from "./authorize";
|
|
63
|
-
import { modelAnnotations } from "./annotations";
|
|
64
64
|
import { malloyGivenToApi, type MalloyGiven } from "./given";
|
|
65
|
-
import {
|
|
66
|
-
extractQueriesFromModelDef,
|
|
67
|
-
extractSourcesFromModelDef,
|
|
68
|
-
} from "./source_extraction";
|
|
69
65
|
import {
|
|
70
66
|
assertWithinModelResponseLimits,
|
|
71
67
|
resolveModelQueryRowLimit,
|
|
72
68
|
} from "./model_limits";
|
|
69
|
+
import {
|
|
70
|
+
extractQueriesFromModelDef,
|
|
71
|
+
extractSourcesFromModelDef,
|
|
72
|
+
} from "./source_extraction";
|
|
73
73
|
|
|
74
74
|
type ApiCompiledModel = components["schemas"]["CompiledModel"];
|
|
75
75
|
type ApiNotebookCell = components["schemas"]["NotebookCell"];
|
|
@@ -980,13 +980,13 @@ export class Model {
|
|
|
980
980
|
* annotated source view (`run: <source> -> <view>`) compile-only -- no
|
|
981
981
|
* execution -- to get a stable result schema, then runs the renderer's
|
|
982
982
|
* headless `validateRenderTags`. Targets with no annotations carry no render
|
|
983
|
-
* tags, so they are skipped without compiling. Any
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
983
|
+
* tags, so they are skipped without compiling. Any error-severity finding
|
|
984
|
+
* (e.g. a child-only `# big_value { sparkline=... }` placed on a view with no
|
|
985
|
+
* activating big_value) is logged as a warning naming the offending target;
|
|
986
|
+
* it does not fail the package load. Such a tag still renders as
|
|
987
|
+
* "[object Object]" at query time, so the warning is the operator-facing
|
|
988
|
+
* signal. Lower-severity findings are left for the query-time `renderLogs`
|
|
989
|
+
* surface.
|
|
990
990
|
*/
|
|
991
991
|
public async validateRenderTags(): Promise<void> {
|
|
992
992
|
const mm = this.modelMaterializer;
|
|
@@ -1053,11 +1053,11 @@ export class Model {
|
|
|
1053
1053
|
(log) => log.severity === "error",
|
|
1054
1054
|
);
|
|
1055
1055
|
if (errors.length > 0) {
|
|
1056
|
-
|
|
1057
|
-
|
|
1056
|
+
logger.warn(
|
|
1057
|
+
`Invalid renderer configuration on '${target.label}': ${errors
|
|
1058
1058
|
.map((e) => e.message)
|
|
1059
1059
|
.join("; ")}`,
|
|
1060
|
-
|
|
1060
|
+
);
|
|
1061
1061
|
}
|
|
1062
1062
|
}
|
|
1063
1063
|
}
|
package/src/service/package.ts
CHANGED
|
@@ -359,8 +359,7 @@ export class Package {
|
|
|
359
359
|
);
|
|
360
360
|
// Validate renderer tags on the main thread (the renderer is too heavy
|
|
361
361
|
// to load inside the pure-CPU package-load worker). A misconfigured tag
|
|
362
|
-
//
|
|
363
|
-
// other per-model compile error above.
|
|
362
|
+
// is logged as a warning naming the target; it does not fail the load.
|
|
364
363
|
await model.validateRenderTags();
|
|
365
364
|
models.set(sm.modelPath, model);
|
|
366
365
|
}
|
|
@@ -709,9 +708,10 @@ export class Package {
|
|
|
709
708
|
{ buildManifest },
|
|
710
709
|
);
|
|
711
710
|
// Validate renderer tags here too (loadViaWorker does it for the
|
|
712
|
-
// create path).
|
|
713
|
-
//
|
|
714
|
-
// this model's
|
|
711
|
+
// create path). Render-tag findings are logged as warnings inside
|
|
712
|
+
// validateRenderTags and never throw. The catch is defensive: an
|
|
713
|
+
// unexpected internal failure is recorded as this model's
|
|
714
|
+
// compilationError rather than aborting the whole reload.
|
|
715
715
|
try {
|
|
716
716
|
await model.validateRenderTags();
|
|
717
717
|
nextModels.set(sm.modelPath, model);
|
|
@@ -28,10 +28,12 @@ import {
|
|
|
28
28
|
describe,
|
|
29
29
|
expect,
|
|
30
30
|
it,
|
|
31
|
+
spyOn,
|
|
31
32
|
} from "bun:test";
|
|
32
33
|
import * as fs from "fs";
|
|
33
34
|
import * as os from "os";
|
|
34
35
|
import * as path from "path";
|
|
36
|
+
import { logger } from "../logger";
|
|
35
37
|
import {
|
|
36
38
|
PackageLoadPool,
|
|
37
39
|
__setPackageLoadPoolForTests,
|
|
@@ -280,14 +282,13 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
280
282
|
}
|
|
281
283
|
});
|
|
282
284
|
|
|
283
|
-
it("
|
|
285
|
+
it("logs a warning for a package whose view carries an invalid renderer tag", async () => {
|
|
284
286
|
writeManifest();
|
|
285
287
|
// `# big_value { sparkline=... }` is a child-only renderer config placed on
|
|
286
288
|
// the view itself, with no activating big_value. The renderer declines to
|
|
287
|
-
// match it
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// ModelCompilationError naming the offending view.
|
|
289
|
+
// match it. Render-tag validation runs on the main thread after the worker
|
|
290
|
+
// hydrates the model. The misconfiguration is logged as a warning naming
|
|
291
|
+
// the offending view; it does not fail the package load.
|
|
291
292
|
fs.writeFileSync(
|
|
292
293
|
path.join(tempDir, "bad_render.malloy"),
|
|
293
294
|
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
@@ -302,21 +303,21 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
302
303
|
}`,
|
|
303
304
|
);
|
|
304
305
|
|
|
305
|
-
const
|
|
306
|
+
const warnSpy = spyOn(logger, "warn");
|
|
306
307
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
307
308
|
try {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
);
|
|
318
|
-
expect((caught as Error).message).toContain("nums -> card");
|
|
309
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
310
|
+
expect(pkg.getModelPaths()).toEqual(["bad_render.malloy"]);
|
|
311
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
312
|
+
expect(
|
|
313
|
+
warnings.some(
|
|
314
|
+
(m) =>
|
|
315
|
+
m.includes("Invalid renderer configuration") &&
|
|
316
|
+
m.includes("nums -> card"),
|
|
317
|
+
),
|
|
318
|
+
).toBe(true);
|
|
319
319
|
} finally {
|
|
320
|
+
warnSpy.mockRestore();
|
|
320
321
|
await duckdb.close();
|
|
321
322
|
}
|
|
322
323
|
});
|
|
@@ -341,7 +342,7 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
341
342
|
}
|
|
342
343
|
});
|
|
343
344
|
|
|
344
|
-
it("
|
|
345
|
+
it("logs a warning for a backtick-quoted (hyphenated) source with a bad view render tag", async () => {
|
|
345
346
|
writeManifest();
|
|
346
347
|
// A source whose name needs Malloy backtick-quoting (here, a hyphen). The
|
|
347
348
|
// validation target must quote the identifier; otherwise `run: bad-source
|
|
@@ -363,26 +364,26 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
363
364
|
}`,
|
|
364
365
|
);
|
|
365
366
|
|
|
366
|
-
const
|
|
367
|
+
const warnSpy = spyOn(logger, "warn");
|
|
367
368
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
368
369
|
try {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
);
|
|
379
|
-
expect((caught as Error).message).toContain("bad-source -> card");
|
|
370
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
371
|
+
expect(pkg.getModelPaths()).toEqual(["hyphen_render.malloy"]);
|
|
372
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
373
|
+
expect(
|
|
374
|
+
warnings.some(
|
|
375
|
+
(m) =>
|
|
376
|
+
m.includes("Invalid renderer configuration") &&
|
|
377
|
+
m.includes("bad-source -> card"),
|
|
378
|
+
),
|
|
379
|
+
).toBe(true);
|
|
380
380
|
} finally {
|
|
381
|
+
warnSpy.mockRestore();
|
|
381
382
|
await duckdb.close();
|
|
382
383
|
}
|
|
383
384
|
});
|
|
384
385
|
|
|
385
|
-
it("
|
|
386
|
+
it("logs a warning for a source name containing an escaped backtick with a bad view render tag", async () => {
|
|
386
387
|
writeManifest();
|
|
387
388
|
// Source name with a literal backtick (written escaped in Malloy as
|
|
388
389
|
// `wei\`rd`). The validation target must re-escape it; a bare wrap would
|
|
@@ -403,26 +404,26 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
403
404
|
}`,
|
|
404
405
|
);
|
|
405
406
|
|
|
406
|
-
const
|
|
407
|
+
const warnSpy = spyOn(logger, "warn");
|
|
407
408
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
408
409
|
try {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
);
|
|
419
|
-
expect((caught as Error).message).toContain("card");
|
|
410
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
411
|
+
expect(pkg.getModelPaths()).toEqual(["backtick_render.malloy"]);
|
|
412
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
413
|
+
expect(
|
|
414
|
+
warnings.some(
|
|
415
|
+
(m) =>
|
|
416
|
+
m.includes("Invalid renderer configuration") &&
|
|
417
|
+
m.includes("card"),
|
|
418
|
+
),
|
|
419
|
+
).toBe(true);
|
|
420
420
|
} finally {
|
|
421
|
+
warnSpy.mockRestore();
|
|
421
422
|
await duckdb.close();
|
|
422
423
|
}
|
|
423
424
|
});
|
|
424
425
|
|
|
425
|
-
it("
|
|
426
|
+
it("logs a warning for a .malloynb notebook whose source view carries an invalid renderer tag", async () => {
|
|
426
427
|
writeManifest();
|
|
427
428
|
fs.writeFileSync(
|
|
428
429
|
path.join(tempDir, "bad_render.malloynb"),
|
|
@@ -439,18 +440,22 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
439
440
|
}`,
|
|
440
441
|
);
|
|
441
442
|
|
|
442
|
-
const
|
|
443
|
+
const warnSpy = spyOn(logger, "warn");
|
|
443
444
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
444
445
|
try {
|
|
445
|
-
await
|
|
446
|
-
|
|
447
|
-
|
|
446
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
447
|
+
expect(pkg.getModelPaths()).toEqual(["bad_render.malloynb"]);
|
|
448
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
449
|
+
expect(
|
|
450
|
+
warnings.some((m) => m.includes("Invalid renderer configuration")),
|
|
451
|
+
).toBe(true);
|
|
448
452
|
} finally {
|
|
453
|
+
warnSpy.mockRestore();
|
|
449
454
|
await duckdb.close();
|
|
450
455
|
}
|
|
451
456
|
});
|
|
452
457
|
|
|
453
|
-
it("re-validates renderer tags on reload,
|
|
458
|
+
it("re-validates renderer tags on reload, logging a warning for a model that develops a bad tag", async () => {
|
|
454
459
|
writeManifest();
|
|
455
460
|
// Start valid so Package.create succeeds.
|
|
456
461
|
fs.writeFileSync(
|
|
@@ -463,13 +468,15 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
463
468
|
);
|
|
464
469
|
|
|
465
470
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
471
|
+
const warnSpy = spyOn(logger, "warn");
|
|
466
472
|
try {
|
|
467
473
|
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
468
474
|
expect(pkg.getModel("m.malloy")).toBeDefined();
|
|
475
|
+
warnSpy.mockClear();
|
|
469
476
|
|
|
470
|
-
// The model now develops a misconfigured render tag. Reload
|
|
471
|
-
//
|
|
472
|
-
//
|
|
477
|
+
// The model now develops a misconfigured render tag. Reload re-validates
|
|
478
|
+
// (not just Package.create) and logs a warning for it, without aborting
|
|
479
|
+
// the reload or failing the model.
|
|
473
480
|
fs.writeFileSync(
|
|
474
481
|
path.join(tempDir, "m.malloy"),
|
|
475
482
|
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
@@ -487,10 +494,13 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
487
494
|
|
|
488
495
|
const reloaded = pkg.getModel("m.malloy");
|
|
489
496
|
expect(reloaded).toBeDefined();
|
|
490
|
-
await expect(reloaded!.getModel()).
|
|
491
|
-
|
|
492
|
-
|
|
497
|
+
await expect(reloaded!.getModel()).resolves.toBeDefined();
|
|
498
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
499
|
+
expect(
|
|
500
|
+
warnings.some((m) => m.includes("Invalid renderer configuration")),
|
|
501
|
+
).toBe(true);
|
|
493
502
|
} finally {
|
|
503
|
+
warnSpy.mockRestore();
|
|
494
504
|
await duckdb.close();
|
|
495
505
|
}
|
|
496
506
|
});
|