@malloy-publisher/server 0.0.209 → 0.0.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/app/api-doc.yaml +20 -3
  2. package/dist/app/assets/{EnvironmentPage-BRMCY9d8.js → EnvironmentPage-CuO6sty5.js} +1 -1
  3. package/dist/app/assets/HomePage-u3vxROIF.js +1 -0
  4. package/dist/app/assets/{MainPage-sZdUjUcu.js → MainPage-Bt2sYLbr.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-C_jQQUCJ.js → MaterializationsPage-B1rj61zs.js} +1 -1
  6. package/dist/app/assets/{ModelPage-Bh62OIEq.js → ModelPage-BpvONvSR.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DJW4xjLp.js → PackagePage-DHNcwboW.js} +1 -1
  8. package/dist/app/assets/{RouteError-Vi5yGs_F.js → RouteError-D1vhLJvr.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-BvJMi21d.js → WorkbookPage-CZmud-yI.js} +1 -1
  10. package/dist/app/assets/{core-BbW0t3RQ.es-L-mZcOk3.js → core-XtBSnW2U.es-DE88sVsZ.js} +1 -1
  11. package/dist/app/assets/{index-CCcHdeew.js → index-BQdOB6m9.js} +1 -1
  12. package/dist/app/assets/{index-DuqTjxM_.js → index-BlnQtDZj.js} +137 -137
  13. package/dist/app/assets/{index-CNFX-CGL.js → index-CYf2akGH.js} +1 -1
  14. package/dist/app/assets/{index.umd-GgEb4WfT.js → index.umd-BdQ0R4hx.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/package_load_worker.mjs +3 -0
  17. package/dist/server.mjs +314 -306
  18. package/package.json +1 -1
  19. package/src/materialization_metrics.spec.ts +84 -0
  20. package/src/materialization_metrics.ts +78 -119
  21. package/src/service/build_plan.spec.ts +84 -0
  22. package/src/service/build_plan.ts +53 -2
  23. package/src/service/materialization_service.spec.ts +126 -8
  24. package/src/service/materialization_service.ts +96 -83
  25. package/src/service/materialization_test_fixtures.ts +2 -0
  26. package/src/service/model.ts +25 -25
  27. package/src/service/package.ts +19 -7
  28. package/src/service/package_worker_path.spec.ts +65 -55
  29. package/src/service/persist_annotation_validation.spec.ts +77 -0
  30. package/src/service/persist_annotation_validation.ts +54 -0
  31. package/src/service/quoting.spec.ts +50 -0
  32. package/src/service/quoting.ts +37 -16
  33. package/src/utils.ts +5 -0
  34. package/dist/app/assets/HomePage-DQzgkiMI.js +0 -1
@@ -11,6 +11,7 @@ import {
11
11
  } from "../errors";
12
12
  import {
13
13
  BuildInstruction,
14
+ MaterializationStatus,
14
15
  ResourceRepository,
15
16
  } from "../storage/DatabaseInterface";
16
17
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
@@ -366,7 +367,9 @@ describe("MaterializationService", () => {
366
367
 
367
368
  it("drops manifest tables (and staging) when dropTables is set", async () => {
368
369
  const runSQL = sinon.stub().resolves();
369
- const getMalloyConnection = sinon.stub().resolves({ runSQL });
370
+ const getMalloyConnection = sinon
371
+ .stub()
372
+ .resolves({ runSQL, dialectName: "duckdb" });
370
373
  (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
371
374
  getPackage: sinon.stub().resolves({ getMalloyConnection }),
372
375
  withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
@@ -395,13 +398,52 @@ describe("MaterializationService", () => {
395
398
 
396
399
  expect(getMalloyConnection.calledOnceWith("duckdb")).toBe(true);
397
400
  const dropped = runSQL.getCalls().map((c) => c.args[0] as string);
398
- expect(dropped).toContain("DROP TABLE IF EXISTS orders_mz");
399
- expect(dropped.some((s) => s.includes("orders_mz_"))).toBe(true);
401
+ expect(dropped).toContain('DROP TABLE IF EXISTS "orders_mz"');
402
+ expect(dropped.some((s) => s.includes('"orders_mz_'))).toBe(true);
400
403
  expect(
401
404
  ctx.repository.deleteMaterialization.calledOnceWith("mat-1"),
402
405
  ).toBe(true);
403
406
  });
404
407
 
408
+ it("dialect-quotes the drop DDL from the live connection (backtick + container path)", async () => {
409
+ const runSQL = sinon.stub().resolves();
410
+ const getMalloyConnection = sinon
411
+ .stub()
412
+ .resolves({ runSQL, dialectName: "standardsql" });
413
+ (ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
414
+ getPackage: sinon.stub().resolves({ getMalloyConnection }),
415
+ withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
416
+ fn(),
417
+ });
418
+ ctx.repository.getMaterializationById.resolves(
419
+ makeMaterialization({
420
+ status: "MANIFEST_FILE_READY",
421
+ manifest: {
422
+ builtAt: new Date().toISOString(),
423
+ strict: false,
424
+ entries: {
425
+ b1: {
426
+ buildId: "b1",
427
+ // Container path on a hyphenated BigQuery project: each
428
+ // segment must be backtick-quoted independently.
429
+ physicalTableName: "my-proj.ds.engaged",
430
+ connectionName: "bq",
431
+ },
432
+ },
433
+ },
434
+ }),
435
+ );
436
+
437
+ await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
438
+ dropTables: true,
439
+ });
440
+
441
+ const dropped = runSQL.getCalls().map((c) => c.args[0] as string);
442
+ expect(dropped).toContain(
443
+ "DROP TABLE IF EXISTS `my-proj`.`ds`.`engaged`",
444
+ );
445
+ });
446
+
405
447
  it("still deletes the record when a table drop fails", async () => {
406
448
  const runSQL = sinon.stub().rejects(new Error("boom"));
407
449
  const getMalloyConnection = sinon.stub().resolves({ runSQL });
@@ -706,11 +748,13 @@ describe("buildOneSource", () => {
706
748
  function callBuildOneSource(
707
749
  connection: { runSQL: sinon.SinonStub },
708
750
  physicalTableName: string,
751
+ dialectName?: string,
709
752
  ): Promise<{ buildId: string; physicalTableName: string }> {
710
753
  const source = fakeSource({
711
754
  name: "orders",
712
755
  buildId: "abcdef1234567890",
713
756
  sql: "SELECT * FROM t",
757
+ dialectName,
714
758
  });
715
759
  const instruction: BuildInstruction = {
716
760
  buildId: "abcdef1234567890",
@@ -743,10 +787,10 @@ describe("buildOneSource", () => {
743
787
 
744
788
  const sql = runSQL.getCalls().map((c) => c.args[0] as string);
745
789
  expect(sql).toEqual([
746
- "DROP TABLE IF EXISTS orders_v1_abcdef123456",
747
- "CREATE TABLE orders_v1_abcdef123456 AS (SELECT * FROM t)",
748
- "DROP TABLE IF EXISTS orders_v1",
749
- "ALTER TABLE orders_v1_abcdef123456 RENAME TO orders_v1",
790
+ 'DROP TABLE IF EXISTS "orders_v1_abcdef123456"',
791
+ 'CREATE TABLE "orders_v1_abcdef123456" AS (SELECT * FROM t)',
792
+ 'DROP TABLE IF EXISTS "orders_v1"',
793
+ 'ALTER TABLE "orders_v1_abcdef123456" RENAME TO "orders_v1"',
750
794
  ]);
751
795
  expect(entry.physicalTableName).toBe("orders_v1");
752
796
  expect(entry.buildId).toBe("abcdef1234567890");
@@ -761,8 +805,28 @@ describe("buildOneSource", () => {
761
805
  "create boom",
762
806
  );
763
807
  expect(runSQL.lastCall.args[0]).toBe(
764
- "DROP TABLE IF EXISTS orders_v1_abcdef123456",
808
+ 'DROP TABLE IF EXISTS "orders_v1_abcdef123456"',
809
+ );
810
+ });
811
+
812
+ it("quotes each segment of a container path for a backtick dialect", async () => {
813
+ const runSQL = sinon.stub().resolves();
814
+ // Container path (dataset.table) on a backtick dialect (BigQuery): each
815
+ // segment is quoted independently, and the rename targets the bare name.
816
+ const entry = await callBuildOneSource(
817
+ { runSQL },
818
+ "ds.orders_v1",
819
+ "standardsql",
765
820
  );
821
+
822
+ const sql = runSQL.getCalls().map((c) => c.args[0] as string);
823
+ expect(sql).toEqual([
824
+ "DROP TABLE IF EXISTS `ds`.`orders_v1_abcdef123456`",
825
+ "CREATE TABLE `ds`.`orders_v1_abcdef123456` AS (SELECT * FROM t)",
826
+ "DROP TABLE IF EXISTS `ds`.`orders_v1`",
827
+ "ALTER TABLE `ds`.`orders_v1_abcdef123456` RENAME TO `orders_v1`",
828
+ ]);
829
+ expect(entry.physicalTableName).toBe("ds.orders_v1");
766
830
  });
767
831
  });
768
832
 
@@ -974,3 +1038,57 @@ describe("runInBackground (terminal recording)", () => {
974
1038
  expect(bg.runningAbortControllers.has("bg-3")).toBe(false);
975
1039
  });
976
1040
  });
1041
+
1042
+ describe("transition (state machine)", () => {
1043
+ let ctx: ReturnType<typeof createMocks>;
1044
+ beforeEach(() => {
1045
+ ctx = createMocks();
1046
+ });
1047
+
1048
+ function transition(): (
1049
+ id: string,
1050
+ next: MaterializationStatus,
1051
+ ) => Promise<unknown> {
1052
+ return (
1053
+ ctx.service as unknown as {
1054
+ transition: (
1055
+ id: string,
1056
+ next: MaterializationStatus,
1057
+ ) => Promise<unknown>;
1058
+ }
1059
+ ).transition.bind(ctx.service);
1060
+ }
1061
+
1062
+ it("throws MaterializationNotFoundError when the record is gone", async () => {
1063
+ ctx.repository.getMaterializationById.resolves(undefined);
1064
+ await expect(
1065
+ transition()("mat-1", "MANIFEST_ROWS_READY"),
1066
+ ).rejects.toThrow(MaterializationNotFoundError);
1067
+ });
1068
+
1069
+ it("rejects a transition the state machine disallows", async () => {
1070
+ ctx.repository.getMaterializationById.resolves(
1071
+ makeMaterialization({ status: "MANIFEST_FILE_READY" }),
1072
+ );
1073
+ // MANIFEST_FILE_READY is terminal; nothing follows it.
1074
+ await expect(transition()("mat-1", "PENDING")).rejects.toThrow(
1075
+ InvalidStateTransitionError,
1076
+ );
1077
+ expect(ctx.repository.updateMaterialization.called).toBe(false);
1078
+ });
1079
+
1080
+ it("persists a valid transition", async () => {
1081
+ ctx.repository.getMaterializationById.resolves(
1082
+ makeMaterialization({ status: "PENDING" }),
1083
+ );
1084
+ ctx.repository.updateMaterialization.resolves(
1085
+ makeMaterialization({ status: "MANIFEST_ROWS_READY" }),
1086
+ );
1087
+ await transition()("mat-1", "MANIFEST_ROWS_READY");
1088
+ expect(
1089
+ ctx.repository.updateMaterialization.calledOnceWith("mat-1", {
1090
+ status: "MANIFEST_ROWS_READY",
1091
+ }),
1092
+ ).toBe(true);
1093
+ });
1094
+ });
@@ -30,13 +30,16 @@ import {
30
30
  ResourceRepository,
31
31
  } from "../storage/DatabaseInterface";
32
32
  import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
33
+ import { errMessage } from "../utils";
33
34
  import {
34
35
  CompiledBuildPlan,
35
36
  compilePackageBuildPlan,
36
37
  computeBuildId,
38
+ deriveAnnotationFields,
39
+ iterGraphSources,
37
40
  } from "./build_plan";
38
41
  import { EnvironmentStore } from "./environment_store";
39
- import { splitTablePath } from "./quoting";
42
+ import { bareTableName, quoteIdentifier, quoteTablePath } from "./quoting";
40
43
  import { resolveEnvironmentId } from "./resolve_environment";
41
44
 
42
45
  /**
@@ -58,14 +61,7 @@ export function stagingSuffix(buildId: string): string {
58
61
  * The author owns quoting the `name=` value for the dialect.
59
62
  */
60
63
  function selfAssignTableName(persistSource: PersistSource): string {
61
- try {
62
- return (
63
- persistSource.annotations.parseAsTag("@").tag.text("name") ||
64
- persistSource.name
65
- );
66
- } catch {
67
- return persistSource.name;
68
- }
64
+ return deriveAnnotationFields(persistSource).name || persistSource.name;
69
65
  }
70
66
 
71
67
  /** Classify a thrown build error as cancelled (cooperative abort) or failed. */
@@ -239,9 +235,7 @@ export class MaterializationService {
239
235
  packageName,
240
236
  );
241
237
  if (active) {
242
- throw new MaterializationConflictError(
243
- `Package ${packageName} already has an active materialization (${active.id})`,
244
- );
238
+ throw this.activeConflict(packageName, active.id);
245
239
  }
246
240
 
247
241
  const forceRefresh = options.forceRefresh ?? false;
@@ -265,11 +259,7 @@ export class MaterializationService {
265
259
  environmentId,
266
260
  packageName,
267
261
  );
268
- throw new MaterializationConflictError(
269
- winner
270
- ? `Package ${packageName} already has an active materialization (${winner.id})`
271
- : `Package ${packageName} already has an active materialization`,
272
- );
262
+ throw this.activeConflict(packageName, winner?.id);
273
263
  }
274
264
  throw err;
275
265
  }
@@ -320,6 +310,12 @@ export class MaterializationService {
320
310
  const startedAt = Date.now();
321
311
 
322
312
  try {
313
+ // Persist the run's start time so the UI can compute a duration
314
+ // (start -> now while in-flight, start -> completed once terminal).
315
+ await this.repository.updateMaterialization(id, {
316
+ startedAt: new Date(startedAt),
317
+ });
318
+
323
319
  const environmentId = await this.resolveEnvironmentId(environmentName);
324
320
  const environment = await this.environmentStore.getEnvironment(
325
321
  environmentName,
@@ -418,35 +414,34 @@ export class MaterializationService {
418
414
  const seen = new Set<string>();
419
415
 
420
416
  for (const graph of compiled.graphs) {
421
- for (const level of graph.nodes) {
422
- for (const node of level) {
423
- const persistSource = compiled.sources[node.sourceID];
424
- if (!persistSource) continue;
425
- if (include && !include.has(persistSource.name)) continue;
426
-
427
- const buildId = computeBuildId(
428
- persistSource,
429
- compiled.connectionDigests,
430
- );
431
- if (seen.has(buildId)) continue;
432
- seen.add(buildId);
433
-
434
- const prior = priorEntries[buildId];
435
- if (prior && prior.physicalTableName) {
436
- carried[buildId] = prior;
437
- continue;
438
- }
439
-
440
- instructions.push({
441
- buildId,
442
- materializedTableId: `local-${buildId.substring(
443
- 0,
444
- STAGING_BUILD_ID_LEN,
445
- )}`,
446
- physicalTableName: selfAssignTableName(persistSource),
447
- realization: "COPY",
448
- });
417
+ for (const persistSource of iterGraphSources(
418
+ graph,
419
+ compiled.sources,
420
+ )) {
421
+ if (include && !include.has(persistSource.name)) continue;
422
+
423
+ const buildId = computeBuildId(
424
+ persistSource,
425
+ compiled.connectionDigests,
426
+ );
427
+ if (seen.has(buildId)) continue;
428
+ seen.add(buildId);
429
+
430
+ const prior = priorEntries[buildId];
431
+ if (prior && prior.physicalTableName) {
432
+ carried[buildId] = prior;
433
+ continue;
449
434
  }
435
+
436
+ instructions.push({
437
+ buildId,
438
+ materializedTableId: `local-${buildId.substring(
439
+ 0,
440
+ STAGING_BUILD_ID_LEN,
441
+ )}`,
442
+ physicalTableName: selfAssignTableName(persistSource),
443
+ realization: "COPY",
444
+ });
450
445
  }
451
446
  }
452
447
 
@@ -589,25 +584,21 @@ export class MaterializationService {
589
584
  `Connection '${graph.connectionName}' not found`,
590
585
  );
591
586
  }
592
- for (const level of graph.nodes) {
593
- for (const node of level) {
594
- if (signal.aborted) throw new Error("Build cancelled");
595
- const persistSource = sources[node.sourceID];
596
- if (!persistSource) continue;
597
-
598
- const buildId = computeBuildId(persistSource, connectionDigests);
599
- const instruction = byBuildId.get(buildId);
600
- if (!instruction) continue;
601
-
602
- const entry = await this.buildOneSource(
603
- persistSource,
604
- instruction,
605
- connection,
606
- connectionDigests,
607
- manifest,
608
- );
609
- entries[buildId] = entry;
610
- }
587
+ for (const persistSource of iterGraphSources(graph, sources)) {
588
+ if (signal.aborted) throw new Error("Build cancelled");
589
+
590
+ const buildId = computeBuildId(persistSource, connectionDigests);
591
+ const instruction = byBuildId.get(buildId);
592
+ if (!instruction) continue;
593
+
594
+ const entry = await this.buildOneSource(
595
+ persistSource,
596
+ instruction,
597
+ connection,
598
+ connectionDigests,
599
+ manifest,
600
+ );
601
+ entries[buildId] = entry;
611
602
  }
612
603
  }
613
604
 
@@ -633,32 +624,37 @@ export class MaterializationService {
633
624
  connectionDigests,
634
625
  });
635
626
 
636
- const { bareName } = splitTablePath(physicalTableName);
627
+ const bareName = bareTableName(physicalTableName);
637
628
  const stagingTableName = `${physicalTableName}${stagingSuffix(buildId)}`;
629
+ // The control plane sends the logical (unquoted) physical name; dialect-
630
+ // quote each identifier here so a container path or quote-requiring name
631
+ // (e.g. a hyphenated BigQuery project id) produces valid DDL. The manifest
632
+ // echoes the logical name (below) so the CP stays in logical-name space.
633
+ const dialect = persistSource.dialectName;
634
+ const quotedStaging = quoteTablePath(stagingTableName, dialect);
635
+ const quotedPhysical = quoteTablePath(physicalTableName, dialect);
636
+ const quotedBareName = quoteIdentifier(bareName, dialect);
638
637
 
639
638
  const startTime = performance.now();
640
- await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
639
+ await connection.runSQL(`DROP TABLE IF EXISTS ${quotedStaging}`);
641
640
  try {
642
641
  await connection.runSQL(
643
- `CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
642
+ `CREATE TABLE ${quotedStaging} AS (${buildSQL})`,
644
643
  );
645
- await connection.runSQL(`DROP TABLE IF EXISTS ${physicalTableName}`);
644
+ await connection.runSQL(`DROP TABLE IF EXISTS ${quotedPhysical}`);
646
645
  await connection.runSQL(
647
- `ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
646
+ `ALTER TABLE ${quotedStaging} RENAME TO ${quotedBareName}`,
648
647
  );
649
648
  } catch (err) {
650
649
  try {
651
- await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
650
+ await connection.runSQL(`DROP TABLE IF EXISTS ${quotedStaging}`);
652
651
  } catch (cleanupErr) {
653
652
  logger.warn(
654
653
  "Failed to clean up staging table after a failed build; physical leak",
655
654
  {
656
655
  stagingTableName,
657
656
  connectionName: persistSource.connectionName,
658
- cleanupError:
659
- cleanupErr instanceof Error
660
- ? cleanupErr.message
661
- : String(cleanupErr),
657
+ cleanupError: errMessage(cleanupErr),
662
658
  },
663
659
  );
664
660
  }
@@ -795,14 +791,23 @@ export class MaterializationService {
795
791
  connection = await pkg.getMalloyConnection(connectionName);
796
792
  connectionCache.set(connectionName, connection);
797
793
  }
794
+ // Dialect-quote from the live connection, the same way
795
+ // buildOneSource quoted at build time, so a name that built
796
+ // successfully also drops successfully (container paths, hyphenated
797
+ // BigQuery project ids, etc.).
798
+ const dialect = connection.dialectName;
798
799
  await connection.runSQL(
799
- `DROP TABLE IF EXISTS ${physicalTableName}`,
800
+ `DROP TABLE IF EXISTS ${quoteTablePath(
801
+ physicalTableName,
802
+ dialect,
803
+ )}`,
800
804
  );
801
805
  // A crash between staging-create and rename can leave the staging
802
806
  // table behind; clean it up too while we hold the connection.
803
807
  await connection.runSQL(
804
- `DROP TABLE IF EXISTS ${physicalTableName}${stagingSuffix(
805
- entry.buildId,
808
+ `DROP TABLE IF EXISTS ${quoteTablePath(
809
+ `${physicalTableName}${stagingSuffix(entry.buildId)}`,
810
+ dialect,
806
811
  )}`,
807
812
  );
808
813
  recordDropTables("success");
@@ -817,7 +822,7 @@ export class MaterializationService {
817
822
  materializationId: m.id,
818
823
  physicalTableName,
819
824
  connectionName,
820
- error: err instanceof Error ? err.message : String(err),
825
+ error: errMessage(err),
821
826
  });
822
827
  }
823
828
  }
@@ -848,6 +853,17 @@ export class MaterializationService {
848
853
 
849
854
  // ==================== HELPERS ====================
850
855
 
856
+ /** The single-active conflict error, with the winning run's id when known. */
857
+ private activeConflict(
858
+ packageName: string,
859
+ activeId?: string,
860
+ ): MaterializationConflictError {
861
+ const suffix = activeId ? ` (${activeId})` : "";
862
+ return new MaterializationConflictError(
863
+ `Package ${packageName} already has an active materialization${suffix}`,
864
+ );
865
+ }
866
+
851
867
  private recordRun(
852
868
  mode: MaterializationMode,
853
869
  outcome: "success" | "failed" | "cancelled",
@@ -865,7 +881,7 @@ export class MaterializationService {
865
881
 
866
882
  run(abortController.signal)
867
883
  .catch(async (err) => {
868
- const message = err instanceof Error ? err.message : String(err);
884
+ const message = errMessage(err);
869
885
  const next = abortController.signal.aborted
870
886
  ? "CANCELLED"
871
887
  : "FAILED";
@@ -879,10 +895,7 @@ export class MaterializationService {
879
895
  logger.error("Failed to record materialization failure", {
880
896
  materializationId: id,
881
897
  originalError: message,
882
- transitionError:
883
- transitionErr instanceof Error
884
- ? transitionErr.message
885
- : String(transitionErr),
898
+ transitionError: errMessage(transitionErr),
886
899
  });
887
900
  }
888
901
  })
@@ -80,11 +80,13 @@ export function fakeSource(opts: {
80
80
  buildId: string;
81
81
  sql?: string;
82
82
  connectionName?: string;
83
+ dialectName?: string;
83
84
  }): PersistSource {
84
85
  return {
85
86
  name: opts.name,
86
87
  sourceID: opts.name,
87
88
  connectionName: opts.connectionName ?? "duckdb",
89
+ dialectName: opts.dialectName ?? "duckdb",
88
90
  makeBuildId: () => opts.buildId,
89
91
  getSQL: () => opts.sql ?? "SELECT 1",
90
92
  annotations: {
@@ -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
  }
@@ -31,9 +31,10 @@ import { formatDuration, logger } from "../logger";
31
31
  import { recordBuildPlanComputeDuration } from "../materialization_metrics";
32
32
  import { assertSafeEnvironmentPath, safeJoinUnderRoot } from "../path_safety";
33
33
  import { BuildManifest, BuildPlan } from "../storage/DatabaseInterface";
34
- import { ignoreDotfiles } from "../utils";
34
+ import { errMessage, ignoreDotfiles } from "../utils";
35
35
  import { computePackageBuildPlan } from "./build_plan";
36
36
  import { Model } from "./model";
37
+ import { assertPersistNamesQuoted } from "./persist_annotation_validation";
37
38
 
38
39
  type ApiDatabase = components["schemas"]["Database"];
39
40
  type ApiModel = components["schemas"]["Model"];
@@ -359,9 +360,19 @@ export class Package {
359
360
  );
360
361
  // Validate renderer tags on the main thread (the renderer is too heavy
361
362
  // 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.
363
+ // is logged as a warning naming the target; it does not fail the load.
364
364
  await model.validateRenderTags();
365
+ // Reject unquoted `#@ persist name=` annotations the same way: an
366
+ // unquoted name is dropped from the build plan, so the source would
367
+ // publish but never materialize. Scan the raw `.malloy` source (the
368
+ // ground truth for quoting); throws a ModelCompilationError (424).
369
+ if (sm.modelPath.endsWith(MODEL_FILE_SUFFIX)) {
370
+ const modelSource = await fs.readFile(
371
+ path.join(packagePath, sm.modelPath),
372
+ "utf-8",
373
+ );
374
+ assertPersistNamesQuoted(modelSource, sm.modelPath);
375
+ }
365
376
  models.set(sm.modelPath, model);
366
377
  }
367
378
 
@@ -401,7 +412,7 @@ export class Package {
401
412
  `Failed to compute build plan for package ${packageName}`,
402
413
  {
403
414
  packageName,
404
- error: err instanceof Error ? err.message : String(err),
415
+ error: errMessage(err),
405
416
  },
406
417
  );
407
418
  }
@@ -709,9 +720,10 @@ export class Package {
709
720
  { buildManifest },
710
721
  );
711
722
  // 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.
723
+ // create path). Render-tag findings are logged as warnings inside
724
+ // validateRenderTags and never throw. The catch is defensive: an
725
+ // unexpected internal failure is recorded as this model's
726
+ // compilationError rather than aborting the whole reload.
715
727
  try {
716
728
  await model.validateRenderTags();
717
729
  nextModels.set(sm.modelPath, model);