@malloy-publisher/server 0.0.205 → 0.0.206

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 (69) hide show
  1. package/dist/app/api-doc.yaml +363 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-ivu4vdpj.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-B2DnHEDU.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-BZEuwF9P.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-Dpu3bfPg.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-B8PwcRHt.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-BhbywAeC.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-C-JXsJG0.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-BHEm8Egc.js} +1 -1
  12. package/dist/app/assets/{index-DHHAcY5o.js → index-BsvDrV14.js} +1 -1
  13. package/dist/app/assets/{index-RX3QOTde.js → index-ChR1fKR2.js} +127 -127
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-BVLPYNuj.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +18 -8
  17. package/dist/package_load_worker.mjs +19 -2
  18. package/dist/server.mjs +1151 -1264
  19. package/package.json +1 -1
  20. package/src/constants.ts +12 -0
  21. package/src/controller/materialization.controller.ts +79 -35
  22. package/src/controller/package.controller.spec.ts +179 -0
  23. package/src/controller/package.controller.ts +60 -73
  24. package/src/dto/package.dto.ts +16 -1
  25. package/src/errors.spec.ts +12 -0
  26. package/src/errors.ts +18 -0
  27. package/src/health.spec.ts +34 -1
  28. package/src/instrumentation.ts +33 -17
  29. package/src/materialization_metrics.ts +66 -0
  30. package/src/package_load/package_load_pool.ts +7 -1
  31. package/src/package_load/package_load_worker.ts +44 -4
  32. package/src/package_load/protocol.ts +7 -1
  33. package/src/server-old.ts +7 -149
  34. package/src/server.ts +9 -188
  35. package/src/service/authorize_integration.spec.ts +67 -0
  36. package/src/service/environment.ts +212 -12
  37. package/src/service/environment_store.spec.ts +0 -81
  38. package/src/service/environment_store.ts +0 -23
  39. package/src/service/explore_visibility.spec.ts +434 -0
  40. package/src/service/exports_probe.spec.ts +107 -0
  41. package/src/service/manifest_loader.spec.ts +99 -0
  42. package/src/service/manifest_loader.ts +95 -0
  43. package/src/service/materialization_service.spec.ts +324 -512
  44. package/src/service/materialization_service.ts +816 -656
  45. package/src/service/model.ts +400 -13
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +254 -14
  48. package/src/service/package_rollback.spec.ts +190 -0
  49. package/src/service/package_worker_path.spec.ts +223 -0
  50. package/src/service/query_boundary.spec.ts +470 -0
  51. package/src/storage/DatabaseInterface.ts +35 -57
  52. package/src/storage/StorageManager.mock.ts +0 -9
  53. package/src/storage/StorageManager.ts +7 -290
  54. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  55. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  56. package/src/storage/duckdb/schema.ts +4 -20
  57. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  59. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  60. package/src/controller/manifest.controller.ts +0 -38
  61. package/src/service/manifest_service.spec.ts +0 -206
  62. package/src/service/manifest_service.ts +0 -117
  63. package/src/service/materialized_table_gc.spec.ts +0 -384
  64. package/src/service/materialized_table_gc.ts +0 -231
  65. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  66. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  67. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  68. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  69. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -83,6 +83,64 @@ export class Package {
83
83
  this.databases = databases;
84
84
  this.models = models;
85
85
  this.malloyConfig = malloyConfig;
86
+ this.applyDiscoveryPolicyToModels();
87
+ this.applyQueryBoundaryToModels();
88
+ }
89
+
90
+ /**
91
+ * Push the discovery-curation policy down onto each Model. Curation (file
92
+ * listing via `explores` and within-file `export {}` filtering) is enabled
93
+ * only when `explores` is declared in publisher.json — absent/empty
94
+ * `explores` preserves legacy listings. Re-derived on reload and metadata
95
+ * PATCH (the inputs can change there).
96
+ */
97
+ /** True when the package opts into curated discovery via a non-empty
98
+ * `explores`. Single source of truth so the curation/boundary/listing
99
+ * derivations can't drift out of sync. */
100
+ private exploresDeclared(): boolean {
101
+ const explores = this.packageMetadata.explores;
102
+ return !!(explores && explores.length > 0);
103
+ }
104
+
105
+ /** The declared explore set, or null when discovery is uncurated. */
106
+ private exploreSet(): Set<string> | null {
107
+ const explores = this.packageMetadata.explores;
108
+ return explores && explores.length > 0 ? new Set(explores) : null;
109
+ }
110
+
111
+ private applyDiscoveryPolicyToModels(): void {
112
+ const curationEnabled = this.exploresDeclared();
113
+ for (const model of this.models.values()) {
114
+ model.setDiscoveryCuration(curationEnabled);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Push the package-level query-boundary policy down onto each Model so the
120
+ * query chokepoints can enforce it without a back-reference to the Package:
121
+ * `Model.getQueryResults` (the HTTP query route and the MCP tool) and the
122
+ * `/compile` path (via `assertQueryBoundaryForRunnable`). Derived once here
123
+ * (and on reload) rather than per query: the policy only changes when the
124
+ * manifest is (re)read.
125
+ *
126
+ * Policy: queryable == discoverable. The boundary is inert unless `explores`
127
+ * is declared (no curated surface ⇒ nothing to restrict) AND
128
+ * `queryableSources` is "declared" (the default; "all" decouples the axes).
129
+ * When active, a model file is a query entry point only if it is listed in
130
+ * `explores`; within-file curation (`export {}`) is read off each Model.
131
+ */
132
+ private applyQueryBoundaryToModels(): void {
133
+ const exploresDeclared = this.exploresDeclared();
134
+ const exploreSet = this.exploreSet();
135
+ const mode =
136
+ this.packageMetadata.queryableSources === "all" ? "all" : "declared";
137
+ for (const [modelPath, model] of this.models) {
138
+ model.setQueryBoundary({
139
+ mode,
140
+ exploresDeclared,
141
+ isQueryEntryPoint: exploreSet ? exploreSet.has(modelPath) : true,
142
+ });
143
+ }
86
144
  }
87
145
 
88
146
  static async create(
@@ -251,6 +309,9 @@ export class Package {
251
309
  name: outcome.packageMetadata.name,
252
310
  description: outcome.packageMetadata.description,
253
311
  resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}`,
312
+ explores: outcome.packageMetadata.explores,
313
+ queryableSources: outcome.packageMetadata.queryableSources,
314
+ manifestLocation: outcome.packageMetadata.manifestLocation ?? null,
254
315
  };
255
316
 
256
317
  // Build live `Model`s from worker output. Any per-model compile
@@ -272,10 +333,18 @@ export class Package {
272
333
  // cleans the package directory.
273
334
  throw err;
274
335
  }
275
- models.set(
276
- sm.modelPath,
277
- Model.fromSerialized(packageName, packagePath, malloyConfig, sm),
336
+ const model = Model.fromSerialized(
337
+ packageName,
338
+ packagePath,
339
+ malloyConfig,
340
+ sm,
278
341
  );
342
+ // Validate renderer tags on the main thread (the renderer is too heavy
343
+ // to load inside the pure-CPU package-load worker). A misconfigured tag
344
+ // throws a ModelCompilationError (424), aborting the whole load like any
345
+ // other per-model compile error above.
346
+ await model.validateRenderTags();
347
+ models.set(sm.modelPath, model);
279
348
  }
280
349
 
281
350
  const endTime = performance.now();
@@ -289,7 +358,7 @@ export class Package {
289
358
  duration: formatDuration(executionTime),
290
359
  });
291
360
 
292
- return new Package(
361
+ const pkg = new Package(
293
362
  environmentName,
294
363
  packageName,
295
364
  packagePath,
@@ -298,6 +367,21 @@ export class Package {
298
367
  models,
299
368
  malloyConfig,
300
369
  );
370
+
371
+ // Fail-safe at load: a bad explores entry doesn't fail the package
372
+ // (its models still load and listModels hides the unmatched entry — it
373
+ // never falls back to listing everything). Warn so the misconfig is
374
+ // visible; the publish path rejects it outright (see package.controller).
375
+ const invalidMsg = pkg.formatInvalidExplores();
376
+ if (invalidMsg) {
377
+ logger.warn(`Package ${packageName} has invalid explores`, {
378
+ packageName,
379
+ detail: invalidMsg,
380
+ });
381
+ }
382
+ pkg.logEmptyDiscoveryWarnings();
383
+
384
+ return pkg;
301
385
  }
302
386
 
303
387
  public getPackageName(): string {
@@ -305,7 +389,109 @@ export class Package {
305
389
  }
306
390
 
307
391
  public getPackageMetadata(): ApiPackage {
308
- return this.packageMetadata;
392
+ // Surface explores misconfig so consumers/UI can show it (loading is
393
+ // fail-safe — the package still serves with the bad entry hidden — so this
394
+ // is the only non-log signal that it's broken). Computed fresh against the
395
+ // current models; absent when everything resolves. Returns a copy in that
396
+ // case so the added field never mutates the stored metadata.
397
+ const warnings = this.exploreWarnings();
398
+ if (warnings.length === 0) return this.packageMetadata;
399
+ return { ...this.packageMetadata, exploresWarnings: warnings };
400
+ }
401
+
402
+ /**
403
+ * Declared `explores` (publisher.json) that don't resolve to a real
404
+ * `.malloy` model in this package, each with an actionable reason. Empty
405
+ * when explores is absent/empty or every entry resolves.
406
+ *
407
+ * The listing already fails safe — a non-resolving entry matches no model in
408
+ * `listModels`, so it hides rather than exposes. This surfaces *why*, so the
409
+ * load path can warn and the publish path can reject (see package.controller).
410
+ */
411
+ public getInvalidExplores(
412
+ exploresOverride?: string[],
413
+ ): { entry: string; reason: string }[] {
414
+ const declared = exploresOverride ?? this.packageMetadata.explores;
415
+ if (!declared || declared.length === 0) return [];
416
+ const malloyModels = new Set(
417
+ Array.from(this.models.keys()).filter((p) =>
418
+ p.endsWith(MODEL_FILE_SUFFIX),
419
+ ),
420
+ );
421
+ const problems: { entry: string; reason: string }[] = [];
422
+ for (const entry of declared) {
423
+ if (entry.endsWith(NOTEBOOK_FILE_SUFFIX)) {
424
+ problems.push({
425
+ entry,
426
+ reason:
427
+ `notebooks are always public and cannot be explores. ` +
428
+ `Fix: remove it, and list a ${MODEL_FILE_SUFFIX} model file instead.`,
429
+ });
430
+ } else if (!malloyModels.has(entry)) {
431
+ problems.push({
432
+ entry,
433
+ reason:
434
+ `file not found in the package. Fix: list a ${MODEL_FILE_SUFFIX} ` +
435
+ `file relative to the package root (e.g. "index.malloy").`,
436
+ });
437
+ }
438
+ }
439
+ return problems;
440
+ }
441
+
442
+ /** One actionable message per invalid entry (empty when all resolve). */
443
+ public exploreWarnings(exploresOverride?: string[]): string[] {
444
+ return this.getInvalidExplores(exploresOverride).map(
445
+ (p) =>
446
+ `Invalid explores entry '${p.entry}' in ${PACKAGE_MANIFEST_NAME}: ${p.reason}`,
447
+ );
448
+ }
449
+
450
+ /**
451
+ * The {@link exploreWarnings} joined into one string, or "" if none.
452
+ * Newline-separated so multiple invalid entries stay one-per-line in the
453
+ * 400 message rather than running together.
454
+ */
455
+ public formatInvalidExplores(exploresOverride?: string[]): string {
456
+ return this.exploreWarnings(exploresOverride).join("\n");
457
+ }
458
+
459
+ /**
460
+ * One message per LISTED model whose discovery surface is empty because it
461
+ * is import-only (imports other files, declares/re-exports nothing). Such a
462
+ * model renders a blank page, which reads as broken; the fix is an explicit
463
+ * re-export. Log-only (see loadViaWorker/reloadAllModels) — deliberately
464
+ * NOT part of exploreWarnings, which is strict-at-publish: import-only
465
+ * files are a legitimate pattern and must not block a publish. Hidden
466
+ * (non-listed) models are skipped — nobody browses them, so an empty
467
+ * surface there is just normal plumbing.
468
+ */
469
+ public emptyDiscoveryWarnings(): string[] {
470
+ const exploreSet = this.exploreSet();
471
+ const warnings: string[] = [];
472
+ for (const [modelPath, model] of this.models) {
473
+ if (!modelPath.endsWith(MODEL_FILE_SUFFIX)) continue;
474
+ if (exploreSet && !exploreSet.has(modelPath)) continue;
475
+ if (model.hasEmptyDiscoverySurface()) {
476
+ warnings.push(
477
+ `Model "${modelPath}" is listed but exposes nothing: it only ` +
478
+ `imports other files and re-exports none of their sources. ` +
479
+ `Add e.g. 'export { source_name }' to surface sources on ` +
480
+ `this model.`,
481
+ );
482
+ }
483
+ }
484
+ return warnings;
485
+ }
486
+
487
+ /** Log {@link emptyDiscoveryWarnings}; shared by load and reload. */
488
+ private logEmptyDiscoveryWarnings(): void {
489
+ for (const warning of this.emptyDiscoveryWarnings()) {
490
+ logger.warn(`Package ${this.packageName} has a blank-looking model`, {
491
+ packageName: this.packageName,
492
+ detail: warning,
493
+ });
494
+ }
309
495
  }
310
496
 
311
497
  public listDatabases(): ApiDatabase[] {
@@ -404,18 +590,63 @@ export class Package {
404
590
  ),
405
591
  );
406
592
  } else {
407
- nextModels.set(
408
- sm.modelPath,
409
- Model.fromSerialized(
410
- this.packageName,
411
- this.packagePath,
412
- this.malloyConfig,
413
- sm,
414
- ),
593
+ const model = Model.fromSerialized(
594
+ this.packageName,
595
+ this.packagePath,
596
+ this.malloyConfig,
597
+ sm,
415
598
  );
599
+ // Validate renderer tags here too (loadViaWorker does it for the
600
+ // create path). Reload keeps per-model placeholders rather than
601
+ // aborting the whole package, so a render-tag error is recorded as
602
+ // this model's compilationError instead of thrown.
603
+ try {
604
+ await model.validateRenderTags();
605
+ nextModels.set(sm.modelPath, model);
606
+ } catch (renderErr) {
607
+ const err =
608
+ renderErr instanceof Error
609
+ ? renderErr
610
+ : new Error(String(renderErr));
611
+ logger.warn("Render-tag validation failed during reload", {
612
+ packageName: this.packageName,
613
+ modelPath: sm.modelPath,
614
+ error: err.message,
615
+ });
616
+ nextModels.set(
617
+ sm.modelPath,
618
+ Model.fromCompilationError(
619
+ this.packageName,
620
+ sm.modelPath,
621
+ sm.modelType,
622
+ err,
623
+ ),
624
+ );
625
+ }
416
626
  }
417
627
  }
418
628
  this.models = nextModels;
629
+ // A reload re-reads publisher.json in the worker; pick up any change to
630
+ // the explore set and query-boundary mode so listModels()/the gate
631
+ // reflect edited explores without a full Package.create.
632
+ this.packageMetadata.explores = outcome.packageMetadata.explores;
633
+ this.packageMetadata.queryableSources =
634
+ outcome.packageMetadata.queryableSources;
635
+ this.packageMetadata.manifestLocation =
636
+ outcome.packageMetadata.manifestLocation ?? null;
637
+ this.applyDiscoveryPolicyToModels();
638
+ this.applyQueryBoundaryToModels();
639
+ // Re-run the fail-safe warning against the refreshed model set: an edit
640
+ // to publisher.json that introduces a bad entry should surface in the
641
+ // logs on reload too, not only at initial load (loadViaWorker).
642
+ const invalidMsg = this.formatInvalidExplores();
643
+ if (invalidMsg) {
644
+ logger.warn(`Package ${this.packageName} has invalid explores`, {
645
+ packageName: this.packageName,
646
+ detail: invalidMsg,
647
+ });
648
+ }
649
+ this.logEmptyDiscoveryWarnings();
419
650
  }
420
651
 
421
652
  public async getModelFileText(modelPath: string): Promise<string> {
@@ -427,10 +658,17 @@ export class Package {
427
658
  }
428
659
 
429
660
  public async listModels(): Promise<ApiModel[]> {
661
+ // When `explores` is declared in publisher.json, only those models
662
+ // form the public surface; every other .malloy file still compiles for
663
+ // import/join resolution but is hidden from the listing. Absent/empty →
664
+ // every model is listed (backward-compatible default). Notebooks are
665
+ // unaffected (see listNotebooks) — they are always public.
666
+ const exploreSet = this.exploreSet();
430
667
  const values = await Promise.all(
431
668
  Array.from(this.models.keys())
432
669
  .filter((modelPath) => {
433
- return modelPath.endsWith(MODEL_FILE_SUFFIX);
670
+ if (!modelPath.endsWith(MODEL_FILE_SUFFIX)) return false;
671
+ return exploreSet ? exploreSet.has(modelPath) : true;
434
672
  })
435
673
  .map(async (modelPath) => {
436
674
  let error: string | undefined;
@@ -632,5 +870,7 @@ export class Package {
632
870
 
633
871
  public setPackageMetadata(packageMetadata: ApiPackage) {
634
872
  this.packageMetadata = packageMetadata;
873
+ this.applyDiscoveryPolicyToModels();
874
+ this.applyQueryBoundaryToModels();
635
875
  }
636
876
  }
@@ -0,0 +1,190 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "fs/promises";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+
6
+ import { BadRequestError } from "../errors";
7
+ import { Environment } from "./environment";
8
+
9
+ /**
10
+ * Regression tests for explores-validation ROLLBACK, guarding against the two
11
+ * data-loss bugs a code review found:
12
+ *
13
+ * 1. No-location create rollback must NOT delete a pre-existing user
14
+ * directory. The controller now `unloadPackage`s (evict from memory, keep
15
+ * files) instead of `deletePackage` (which renames + rm's the tree).
16
+ *
17
+ * 2. A location update/reinstall with invalid explores must roll back to the
18
+ * PREVIOUS tree rather than swapping the rejected one in and 400-ing after.
19
+ * Validation now happens inside `installPackage`'s swap window.
20
+ *
21
+ * Both run against a real `Environment` + real `Package.create` over temp dirs.
22
+ */
23
+ describe("explores-validation rollback (real Environment)", () => {
24
+ let rootDir: string;
25
+ let envPath: string;
26
+
27
+ const GOOD_MODEL = `source: ones is duckdb.sql("SELECT 1 as x")\n`;
28
+ const BAD_MODEL = `source: twos is duckdb.sql("SELECT 2 as y")\n`;
29
+
30
+ async function writePackageDir(
31
+ dir: string,
32
+ manifest: Record<string, unknown>,
33
+ model: string,
34
+ ): Promise<void> {
35
+ await fs.mkdir(dir, { recursive: true });
36
+ await fs.writeFile(
37
+ path.join(dir, "publisher.json"),
38
+ JSON.stringify({
39
+ name: "pkg",
40
+ description: "rollback fixture",
41
+ ...manifest,
42
+ }),
43
+ );
44
+ await fs.writeFile(path.join(dir, "model.malloy"), model);
45
+ }
46
+
47
+ async function copyDir(src: string, dst: string): Promise<void> {
48
+ await fs.mkdir(dst, { recursive: true });
49
+ await fs.cp(src, dst, { recursive: true });
50
+ }
51
+
52
+ beforeEach(async () => {
53
+ rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-rollback-"));
54
+ envPath = path.join(rootDir, "env");
55
+ await fs.mkdir(envPath, { recursive: true });
56
+ });
57
+
58
+ afterEach(async () => {
59
+ await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
60
+ });
61
+
62
+ it("Fix 1: unloadPackage evicts from memory but keeps the on-disk directory", async () => {
63
+ // Mimics the no-location create path: a pre-existing package directory is
64
+ // registered, then rolled back. unloadPackage must not touch the files.
65
+ const env = await Environment.create("testEnv", envPath, []);
66
+ const pkgDir = path.join(envPath, "pkg");
67
+ await writePackageDir(pkgDir, {}, GOOD_MODEL);
68
+
69
+ await env.addPackage("pkg");
70
+ expect(env.getPackageStatus("pkg")).toBeDefined();
71
+
72
+ await env.unloadPackage("pkg");
73
+
74
+ // Evicted from memory...
75
+ expect(env.getPackageStatus("pkg")).toBeUndefined();
76
+ // ...but the user's files are untouched.
77
+ const modelText = await fs.readFile(
78
+ path.join(pkgDir, "model.malloy"),
79
+ "utf-8",
80
+ );
81
+ expect(modelText).toBe(GOOD_MODEL);
82
+ });
83
+
84
+ it("Fix 2: a rejected location reinstall restores the previous tree", async () => {
85
+ const env = await Environment.create("testEnv", envPath, []);
86
+
87
+ const goodFixture = path.join(rootDir, "good");
88
+ const badFixture = path.join(rootDir, "bad");
89
+ await writePackageDir(goodFixture, {}, GOOD_MODEL);
90
+ await writePackageDir(
91
+ badFixture,
92
+ { explores: ["missing.malloy"] },
93
+ BAD_MODEL,
94
+ );
95
+
96
+ // First install: the good tree is served.
97
+ await env.installPackage("pkg", (stagingPath) =>
98
+ copyDir(goodFixture, stagingPath),
99
+ );
100
+ expect(await env.getModelFileText("pkg", "model.malloy")).toBe(
101
+ GOOD_MODEL,
102
+ );
103
+
104
+ // Reinstall with an invalid-explores tree, validated inside the swap.
105
+ await expect(
106
+ env.installPackage(
107
+ "pkg",
108
+ (stagingPath) => copyDir(badFixture, stagingPath),
109
+ (pkg) => pkg.formatInvalidExplores(),
110
+ ),
111
+ ).rejects.toBeInstanceOf(BadRequestError);
112
+
113
+ // The previous (good) tree is still the one on disk — the rejected tree
114
+ // was rolled back, not swapped in.
115
+ expect(await env.getModelFileText("pkg", "model.malloy")).toBe(
116
+ GOOD_MODEL,
117
+ );
118
+ });
119
+
120
+ it("a valid location reinstall still succeeds (no false rollback)", async () => {
121
+ const env = await Environment.create("testEnv", envPath, []);
122
+ const goodFixture = path.join(rootDir, "good");
123
+ const nextFixture = path.join(rootDir, "next");
124
+ await writePackageDir(goodFixture, {}, GOOD_MODEL);
125
+ await writePackageDir(
126
+ nextFixture,
127
+ { explores: ["model.malloy"] },
128
+ BAD_MODEL,
129
+ );
130
+
131
+ await env.installPackage("pkg", (stagingPath) =>
132
+ copyDir(goodFixture, stagingPath),
133
+ );
134
+ await env.installPackage(
135
+ "pkg",
136
+ (stagingPath) => copyDir(nextFixture, stagingPath),
137
+ (pkg) => pkg.formatInvalidExplores(),
138
+ );
139
+ expect(await env.getModelFileText("pkg", "model.malloy")).toBe(BAD_MODEL);
140
+ });
141
+
142
+ it("updatePackage normalizes a ./-prefixed body explores (no false 400, persists normalized)", async () => {
143
+ // API-body explores must go through the same normalization the worker
144
+ // applies to on-disk explores, so `./model.malloy` validates and persists
145
+ // as `model.malloy` rather than being rejected with a misleading 404.
146
+ const env = await Environment.create("testEnv", envPath, []);
147
+ const pkgDir = path.join(envPath, "pkg");
148
+ await writePackageDir(pkgDir, {}, GOOD_MODEL);
149
+ await env.addPackage("pkg");
150
+
151
+ const updated = await env.updatePackage("pkg", {
152
+ name: "pkg",
153
+ explores: ["./model.malloy"],
154
+ });
155
+
156
+ // Accepted (no BadRequestError) and stored in normalized form...
157
+ expect(updated.explores).toEqual(["model.malloy"]);
158
+ // ...both in memory and on disk.
159
+ const manifest = JSON.parse(
160
+ await fs.readFile(path.join(pkgDir, "publisher.json"), "utf-8"),
161
+ );
162
+ expect(manifest.explores).toEqual(["model.malloy"]);
163
+ });
164
+
165
+ it("compileSource rejects a notebook path; a model path still compiles", async () => {
166
+ // /compile appends source to the target MODEL's content for context; a
167
+ // notebook isn't a valid target and must be rejected up front (not left to
168
+ // a confusing downstream parse error).
169
+ const env = await Environment.create("testEnv", envPath, []);
170
+ const pkgDir = path.join(envPath, "pkg");
171
+ await writePackageDir(pkgDir, {}, GOOD_MODEL);
172
+ await fs.writeFile(
173
+ path.join(pkgDir, "report.malloynb"),
174
+ `>>>markdown\n# Report\n`,
175
+ );
176
+ await env.addPackage("pkg");
177
+
178
+ await expect(
179
+ env.compileSource("pkg", "report.malloynb", "run: ones"),
180
+ ).rejects.toBeInstanceOf(BadRequestError);
181
+
182
+ // The .malloy model still compiles ad-hoc source (no regression).
183
+ const { problems } = await env.compileSource(
184
+ "pkg",
185
+ "model.malloy",
186
+ "run: ones -> { select: x }",
187
+ );
188
+ expect(problems.some((p) => p.severity === "error")).toBe(false);
189
+ });
190
+ });