@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 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
- throw new ModelCompilationError({
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.209",
4
+ "version": "0.0.210",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -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
- * error-severity finding throws a `ModelCompilationError` (HTTP 424) so a
985
- * misconfigured tag (e.g. a child-only `# big_value { sparkline=... }` placed
986
- * on a view with no activating big_value) fails the package load with a clear
987
- * message instead of rendering as "[object Object]" at query time. Warnings
988
- * (e.g. unread tags) are left for the query-time `renderLogs` surface so a
989
- * benign render lint never blocks a load.
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
- throw new ModelCompilationError({
1057
- message: `Invalid renderer configuration on '${target.label}': ${errors
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
  }
@@ -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
- // throws a ModelCompilationError (424), aborting the whole load like any
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). Reload keeps per-model placeholders rather than
713
- // aborting the whole package, so a render-tag error is recorded as
714
- // this model's compilationError instead of thrown.
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("rejects a package whose view carries an invalid renderer tag", async () => {
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; without compile-time validation it renders as "[object Object]"
288
- // at query time. Render-tag validation runs on the main thread after the
289
- // worker hydrates the model, so the load must reject with a 424
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 { ModelCompilationError } = await import("../errors");
306
+ const warnSpy = spyOn(logger, "warn");
306
307
  const { malloyConfig, duckdb } = await makeMalloyConfig();
307
308
  try {
308
- let caught: unknown;
309
- try {
310
- await Package.create("env", "pkg", tempDir, malloyConfig);
311
- } catch (err) {
312
- caught = err;
313
- }
314
- expect(caught).toBeInstanceOf(ModelCompilationError);
315
- expect((caught as Error).message).toContain(
316
- "Invalid renderer configuration",
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("rejects a package whose backtick-quoted (hyphenated) source has a bad view render tag", async () => {
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 { ModelCompilationError } = await import("../errors");
367
+ const warnSpy = spyOn(logger, "warn");
367
368
  const { malloyConfig, duckdb } = await makeMalloyConfig();
368
369
  try {
369
- let caught: unknown;
370
- try {
371
- await Package.create("env", "pkg", tempDir, malloyConfig);
372
- } catch (err) {
373
- caught = err;
374
- }
375
- expect(caught).toBeInstanceOf(ModelCompilationError);
376
- expect((caught as Error).message).toContain(
377
- "Invalid renderer configuration",
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("rejects a package whose source name contains an escaped backtick with a bad view render tag", async () => {
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 { ModelCompilationError } = await import("../errors");
407
+ const warnSpy = spyOn(logger, "warn");
407
408
  const { malloyConfig, duckdb } = await makeMalloyConfig();
408
409
  try {
409
- let caught: unknown;
410
- try {
411
- await Package.create("env", "pkg", tempDir, malloyConfig);
412
- } catch (err) {
413
- caught = err;
414
- }
415
- expect(caught).toBeInstanceOf(ModelCompilationError);
416
- expect((caught as Error).message).toContain(
417
- "Invalid renderer configuration",
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("rejects a .malloynb notebook whose source view carries an invalid renderer tag", async () => {
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 { ModelCompilationError } = await import("../errors");
443
+ const warnSpy = spyOn(logger, "warn");
443
444
  const { malloyConfig, duckdb } = await makeMalloyConfig();
444
445
  try {
445
- await expect(
446
- Package.create("env", "pkg", tempDir, malloyConfig),
447
- ).rejects.toBeInstanceOf(ModelCompilationError);
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, recording a model that develops a bad tag as a per-model error", async () => {
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 must catch
471
- // it (not just Package.create) and surface it as the model's error,
472
- // without aborting the whole reload.
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()).rejects.toThrow(
491
- "Invalid renderer configuration",
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
  });