@malloy-publisher/server 0.0.204 → 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 (100) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +494 -397
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  4. package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
  7. package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/instrumentation.mjs +18 -8
  18. package/dist/package_load_worker.mjs +19 -2
  19. package/dist/runtime/publisher.js +318 -0
  20. package/dist/server.mjs +1703 -1443
  21. package/package.json +5 -4
  22. package/scripts/bake-duckdb-extensions.js +104 -0
  23. package/src/constants.ts +12 -0
  24. package/src/controller/materialization.controller.ts +79 -35
  25. package/src/controller/package.controller.spec.ts +179 -0
  26. package/src/controller/package.controller.ts +60 -73
  27. package/src/controller/watch-mode.controller.ts +176 -46
  28. package/src/dto/package.dto.ts +16 -1
  29. package/src/errors.spec.ts +33 -0
  30. package/src/errors.ts +18 -0
  31. package/src/health.spec.ts +34 -1
  32. package/src/instrumentation.ts +33 -17
  33. package/src/materialization_metrics.ts +66 -0
  34. package/src/mcp/error_messages.spec.ts +35 -0
  35. package/src/mcp/error_messages.ts +14 -1
  36. package/src/mcp/handler_utils.ts +12 -0
  37. package/src/package_load/package_load_pool.ts +7 -1
  38. package/src/package_load/package_load_worker.ts +44 -4
  39. package/src/package_load/protocol.ts +7 -1
  40. package/src/runtime/publisher.js +318 -0
  41. package/src/server-old.ts +7 -149
  42. package/src/server.ts +488 -190
  43. package/src/service/authorize_integration.spec.ts +163 -2
  44. package/src/service/compile_authorize.spec.ts +85 -0
  45. package/src/service/environment.ts +270 -12
  46. package/src/service/environment_store.spec.ts +0 -81
  47. package/src/service/environment_store.ts +142 -34
  48. package/src/service/explore_visibility.spec.ts +434 -0
  49. package/src/service/exports_probe.spec.ts +107 -0
  50. package/src/service/manifest_loader.spec.ts +99 -0
  51. package/src/service/manifest_loader.ts +95 -0
  52. package/src/service/materialization_service.spec.ts +324 -512
  53. package/src/service/materialization_service.ts +816 -656
  54. package/src/service/model.ts +444 -13
  55. package/src/service/package.spec.ts +14 -2
  56. package/src/service/package.ts +271 -20
  57. package/src/service/package_rollback.spec.ts +190 -0
  58. package/src/service/package_worker_path.spec.ts +223 -0
  59. package/src/service/query_boundary.spec.ts +470 -0
  60. package/src/storage/DatabaseInterface.ts +35 -57
  61. package/src/storage/StorageManager.mock.ts +0 -9
  62. package/src/storage/StorageManager.ts +7 -290
  63. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  64. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  65. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  66. package/src/storage/duckdb/schema.ts +4 -20
  67. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  68. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  69. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  70. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/data.csv +3 -0
  72. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  73. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  74. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  75. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  76. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  77. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  78. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  79. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  80. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  81. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  82. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  83. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  84. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  85. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  86. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  87. package/tests/unit/duckdb/repositories.test.ts +208 -0
  88. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  89. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  90. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
  91. package/src/controller/manifest.controller.ts +0 -38
  92. package/src/service/manifest_service.spec.ts +0 -206
  93. package/src/service/manifest_service.ts +0 -117
  94. package/src/service/materialized_table_gc.spec.ts +0 -384
  95. package/src/service/materialized_table_gc.ts +0 -231
  96. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  97. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  98. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  99. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  100. 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(
@@ -138,13 +196,24 @@ export class Package {
138
196
  malloy_package_name: packageName,
139
197
  status,
140
198
  });
141
- // Clean up package directory on failure
199
+ // Clean up the package directory on failure, but NOT when packagePath
200
+ // is an in-place mount symlink (watch mode). Removing it would unmount
201
+ // the package, so a transient compile error from a half-typed model
202
+ // saved mid-edit would brick the package until a restart. The symlink
203
+ // points at the user's live source, which is left untouched; the next
204
+ // save recompiles against it.
142
205
  try {
143
- await fs.rm(packagePath, {
144
- recursive: true,
145
- force: true,
146
- });
147
- logger.info(`Cleaned up failed package directory: ${packagePath}`);
206
+ const stat = await fs.lstat(packagePath).catch(() => null);
207
+ if (stat?.isSymbolicLink()) {
208
+ logger.info(
209
+ `Skipping cleanup of symlinked package path on failure: ${packagePath}`,
210
+ );
211
+ } else {
212
+ await fs.rm(packagePath, { recursive: true, force: true });
213
+ logger.info(
214
+ `Cleaned up failed package directory: ${packagePath}`,
215
+ );
216
+ }
148
217
  } catch (cleanupError) {
149
218
  logger.warn(`Failed to clean up package directory ${packagePath}`, {
150
219
  error: cleanupError,
@@ -240,6 +309,9 @@ export class Package {
240
309
  name: outcome.packageMetadata.name,
241
310
  description: outcome.packageMetadata.description,
242
311
  resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}`,
312
+ explores: outcome.packageMetadata.explores,
313
+ queryableSources: outcome.packageMetadata.queryableSources,
314
+ manifestLocation: outcome.packageMetadata.manifestLocation ?? null,
243
315
  };
244
316
 
245
317
  // Build live `Model`s from worker output. Any per-model compile
@@ -261,10 +333,18 @@ export class Package {
261
333
  // cleans the package directory.
262
334
  throw err;
263
335
  }
264
- models.set(
265
- sm.modelPath,
266
- Model.fromSerialized(packageName, packagePath, malloyConfig, sm),
336
+ const model = Model.fromSerialized(
337
+ packageName,
338
+ packagePath,
339
+ malloyConfig,
340
+ sm,
267
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);
268
348
  }
269
349
 
270
350
  const endTime = performance.now();
@@ -278,7 +358,7 @@ export class Package {
278
358
  duration: formatDuration(executionTime),
279
359
  });
280
360
 
281
- return new Package(
361
+ const pkg = new Package(
282
362
  environmentName,
283
363
  packageName,
284
364
  packagePath,
@@ -287,6 +367,21 @@ export class Package {
287
367
  models,
288
368
  malloyConfig,
289
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;
290
385
  }
291
386
 
292
387
  public getPackageName(): string {
@@ -294,7 +389,109 @@ export class Package {
294
389
  }
295
390
 
296
391
  public getPackageMetadata(): ApiPackage {
297
- 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
+ }
298
495
  }
299
496
 
300
497
  public listDatabases(): ApiDatabase[] {
@@ -393,18 +590,63 @@ export class Package {
393
590
  ),
394
591
  );
395
592
  } else {
396
- nextModels.set(
397
- sm.modelPath,
398
- Model.fromSerialized(
399
- this.packageName,
400
- this.packagePath,
401
- this.malloyConfig,
402
- sm,
403
- ),
593
+ const model = Model.fromSerialized(
594
+ this.packageName,
595
+ this.packagePath,
596
+ this.malloyConfig,
597
+ sm,
404
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
+ }
405
626
  }
406
627
  }
407
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();
408
650
  }
409
651
 
410
652
  public async getModelFileText(modelPath: string): Promise<string> {
@@ -416,10 +658,17 @@ export class Package {
416
658
  }
417
659
 
418
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();
419
667
  const values = await Promise.all(
420
668
  Array.from(this.models.keys())
421
669
  .filter((modelPath) => {
422
- return modelPath.endsWith(MODEL_FILE_SUFFIX);
670
+ if (!modelPath.endsWith(MODEL_FILE_SUFFIX)) return false;
671
+ return exploreSet ? exploreSet.has(modelPath) : true;
423
672
  })
424
673
  .map(async (modelPath) => {
425
674
  let error: string | undefined;
@@ -621,5 +870,7 @@ export class Package {
621
870
 
622
871
  public setPackageMetadata(packageMetadata: ApiPackage) {
623
872
  this.packageMetadata = packageMetadata;
873
+ this.applyDiscoveryPolicyToModels();
874
+ this.applyQueryBoundaryToModels();
624
875
  }
625
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
+ });