@malloy-publisher/server 0.0.205 → 0.0.207

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 +394 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
  12. package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
  13. package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.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 +1272 -1299
  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 +121 -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 +270 -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 +584 -500
  44. package/src/service/materialization_service.ts +839 -657
  45. package/src/service/model.ts +419 -15
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +339 -29
  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 +194 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
  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
@@ -7,14 +7,21 @@ import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import { pathToFileURL } from "url";
9
9
  import { components } from "../api";
10
- import { API_PREFIX, README_NAME } from "../constants";
11
10
  import {
11
+ API_PREFIX,
12
+ normalizeModelPath,
13
+ NOTEBOOK_FILE_SUFFIX,
14
+ README_NAME,
15
+ } from "../constants";
16
+ import {
17
+ BadRequestError,
12
18
  ConnectionNotFoundError,
13
19
  EnvironmentNotFoundError,
14
20
  PackageNotFoundError,
15
21
  ServiceUnavailableError,
16
22
  } from "../errors";
17
23
  import { logger } from "../logger";
24
+ import { recordManifestBind } from "../materialization_metrics";
18
25
  import {
19
26
  assertSafeEnvironmentPath,
20
27
  assertSafePackageName,
@@ -29,6 +36,7 @@ import {
29
36
  EnvironmentMalloyConfig,
30
37
  InternalConnection,
31
38
  } from "./connection";
39
+ import { fetchManifestEntries } from "./manifest_loader";
32
40
  import { ApiConnection } from "./model";
33
41
  import { Package } from "./package";
34
42
  import type { PackageMemoryGovernor } from "./package_memory_governor";
@@ -49,6 +57,11 @@ import type { PackageMemoryGovernor } from "./package_memory_governor";
49
57
  const STAGING_DIR_NAME = ".staging";
50
58
  const RETIRED_DIR_NAME = ".retired";
51
59
 
60
+ // How long to wait for a control-plane manifest fetch during (re)bind before
61
+ // giving up and serving live. Binding happens before a package is marked
62
+ // SERVING, so an unreachable/slow manifest store must not block the package.
63
+ const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
64
+
52
65
  export enum PackageStatus {
53
66
  LOADING = "loading",
54
67
  SERVING = "serving",
@@ -187,10 +200,6 @@ export class Environment {
187
200
  await this.writeEnvironmentReadme(payload.readme);
188
201
  }
189
202
 
190
- if (payload.materializationStorage !== undefined) {
191
- this.metadata.materializationStorage = payload.materializationStorage;
192
- }
193
-
194
203
  // Handle connections update
195
204
  // TODO: Update environment connections should have its own API endpoint
196
205
  if (payload.connections) {
@@ -296,6 +305,17 @@ export class Environment {
296
305
  ): Promise<{ problems: LogMessage[]; sql?: string }> {
297
306
  assertSafePackageName(packageName);
298
307
  assertSafeRelativeModelPath(modelName);
308
+ // /compile appends the submitted source to the TARGET MODEL's content for
309
+ // namespace context. A notebook (.malloynb) is markdown + cells, not a
310
+ // model, so compiling against it only yields a confusing parse error —
311
+ // reject it up front with an actionable message. (Notebooks remain public
312
+ // for discovery/query; this is specific to the compile context.)
313
+ if (modelName.endsWith(NOTEBOOK_FILE_SUFFIX)) {
314
+ throw new BadRequestError(
315
+ `Cannot compile against a notebook ("${modelName}"). ` +
316
+ `/compile takes a .malloy model path for namespace context.`,
317
+ );
318
+ }
299
319
  // Hold the per-package mutex for the duration of every disk read —
300
320
  // both the explicit `fs.readFile(modelPath)` below and the implicit
301
321
  // import resolution that `runtime.loadModel` does through the URL
@@ -365,15 +385,31 @@ export class Environment {
365
385
  // and compilation surfaces its own error.
366
386
  const gateModel = pkg.getModel(modelName);
367
387
  if (gateModel) {
388
+ // Query boundary first (the *what* axis): /compile compiles ad-hoc
389
+ // text against a model, so gate it like an ad-hoc query — a
390
+ // non-`explores` model file, or text whose surface-resolved target
391
+ // is a non-curated model source (under queryableSources:
392
+ // "declared"), is rejected with a generic 404 before compilation
393
+ // can leak schema/SQL. Text the early gate can't pin is settled by
394
+ // the compiled backstop below.
395
+ gateModel.assertQueryBoundaryEarly(undefined, undefined, source);
368
396
  await gateModel.assertAuthorizedForText(source, givens ?? {});
369
397
  }
370
398
 
371
399
  // Initialize Runtime with the package's active MalloyConfig so compile
372
400
  // checks see the same package-scoped duckdb as execution. This runtime
373
401
  // borrows the package config; the package/environment lifecycle owns release.
402
+ // Thread the package's bound build manifest (when present) so the
403
+ // /compile preview routes persist sources to their materialized tables
404
+ // exactly like execution does — otherwise includeSql=true would always
405
+ // show base-table SQL and diverge from what executeQuery actually runs.
406
+ const boundManifestEntries = pkg.getBuildManifestEntries();
374
407
  const runtime = new Runtime({
375
408
  urlReader: interceptingReader,
376
409
  config: pkg.getMalloyConfig(),
410
+ buildManifest: boundManifestEntries
411
+ ? { entries: boundManifestEntries, strict: false }
412
+ : undefined,
377
413
  });
378
414
 
379
415
  // Attempt to compile
@@ -392,8 +428,8 @@ export class Environment {
392
428
  // gate or extract beyond the early text gate already applied.
393
429
  }
394
430
 
395
- // Compiled-source backstopruns REGARDLESS of includeSql. It gates
396
- // the source the COMPILED final query actually reads, closing
431
+ // Compiled-source backstopsrun REGARDLESS of includeSql. They
432
+ // gate the source the COMPILED final query actually reads, closing
397
433
  // named-query / multi-statement indirection the early surface-syntax
398
434
  // gate misses (e.g. `run: ungated\nrun: gated` — the early gate only
399
435
  // matches the FIRST `run:`, but the LAST statement is what executes).
@@ -401,9 +437,26 @@ export class Environment {
401
437
  // (field-not-found errors leak its columns), so this must not be
402
438
  // conditional on SQL extraction. (A `source: x is gated` alias makes
403
439
  // a new ungated source — that's the documented "extend doesn't
404
- // inherit authorize" footgun, the same as the query path.) Only run
405
- // when the model declares gates so ungated compiles don't pay for
406
- // the extra final-query compile.
440
+ // inherit authorize" footgun, the same as the query path.)
441
+
442
+ // Boundary backstop (the *what* axis, 404) before the authorize
443
+ // one (the *who* axis, 403). /compile text is always ad-hoc — the
444
+ // early gate can only positively deny, never fully clear — so the
445
+ // compiled final query's run target is the authority. Self-gates
446
+ // internally (no-ops when the boundary is inert: "all" / no
447
+ // explores), so it is deliberately NOT guarded by hasAuthorize().
448
+ // Text that compiles only source definitions (no final query) has
449
+ // no run target and nothing to gate.
450
+ if (queryMaterializer && gateModel) {
451
+ await gateModel.assertQueryBoundaryForRunnable(
452
+ queryMaterializer,
453
+ source,
454
+ );
455
+ }
456
+
457
+ // Authorize backstop (the *who* axis, 403). Only run when the model
458
+ // declares gates so ungated compiles don't pay for the extra
459
+ // final-query compile.
407
460
  if (queryMaterializer && gateModel?.hasAuthorize()) {
408
461
  await gateModel.assertAuthorizedForRunnable(
409
462
  queryMaterializer,
@@ -792,6 +845,7 @@ export class Environment {
792
845
  packagePath,
793
846
  () => this.malloyConfig.malloyConfig,
794
847
  );
848
+ await this.bindManifestIfConfigured(_package);
795
849
  if (existingPackage !== undefined && reload) {
796
850
  this.retireConnectionGeneration(`package ${packageName}`, () =>
797
851
  existingPackage.getMalloyConfig().shutdown("close"),
@@ -897,6 +951,7 @@ export class Environment {
897
951
  public async installPackage(
898
952
  packageName: string,
899
953
  downloader: (stagingPath: string) => Promise<void>,
954
+ validate?: (pkg: Package) => string | undefined,
900
955
  ): Promise<Package> {
901
956
  assertSafePackageName(packageName);
902
957
  const stagingPath = this.allocateStagingPath(packageName);
@@ -963,6 +1018,15 @@ export class Environment {
963
1018
  canonicalPath,
964
1019
  () => this.malloyConfig.malloyConfig,
965
1020
  );
1021
+ // Strict-reject hook (publish/update only — reload passes no
1022
+ // validator and stays fail-safe). Throw INSIDE the try so the
1023
+ // catch below rolls the swap back: the just-installed tree is
1024
+ // wiped and the retired tree (if any) is restored, so a rejected
1025
+ // publish/update never leaves the bad tree served on disk.
1026
+ const validationMsg = validate?.(newPackage);
1027
+ if (validationMsg) {
1028
+ throw new BadRequestError(validationMsg);
1029
+ }
966
1030
  logger.debug("install.phase2.committed", {
967
1031
  environmentName: this.environmentName,
968
1032
  packageName,
@@ -1008,6 +1072,11 @@ export class Environment {
1008
1072
  throw err;
1009
1073
  }
1010
1074
 
1075
+ // Best-effort manifest bind happens after the swap commits, outside the
1076
+ // rollback window: a manifest that can't be fetched must not undo an
1077
+ // otherwise-successful install (the package serves live instead).
1078
+ await this.bindManifestIfConfigured(newPackage);
1079
+
1011
1080
  this.packages.set(packageName, newPackage);
1012
1081
  this.setPackageStatus(packageName, PackageStatus.SERVING);
1013
1082
 
@@ -1062,6 +1131,91 @@ export class Environment {
1062
1131
  });
1063
1132
  }
1064
1133
 
1134
+ /**
1135
+ * If the freshly-loaded package declares a `manifestLocation`, fetch the
1136
+ * control-plane-computed build manifest and rebind its models so persist
1137
+ * references resolve to the materialized tables. Best-effort: a fetch/bind
1138
+ * failure logs a warning and leaves the package serving live (the models are
1139
+ * already loaded without a manifest). Callers must hold the package lock —
1140
+ * this rebinds `pkg` in place rather than re-entering {@link withPackageLock}.
1141
+ */
1142
+ private async bindManifestIfConfigured(pkg: Package): Promise<void> {
1143
+ const manifestLocation = pkg.getPackageMetadata().manifestLocation;
1144
+ if (!manifestLocation) {
1145
+ return;
1146
+ }
1147
+ await this.bindManifest(pkg, manifestLocation);
1148
+ }
1149
+
1150
+ /** Fetch + bind a specific manifest URI onto an already-loaded package. */
1151
+ private async bindManifest(
1152
+ pkg: Package,
1153
+ manifestLocation: string,
1154
+ ): Promise<void> {
1155
+ const packageName = pkg.getPackageName();
1156
+ try {
1157
+ // Bind runs before a package is marked SERVING, so a slow/unreachable
1158
+ // manifest store must not block serving indefinitely — bound is the
1159
+ // intended state, live is the degraded fallback. Race the fetch against
1160
+ // a timeout and fall back to live on either failure.
1161
+ const entries =
1162
+ await this.fetchManifestEntriesWithTimeout(manifestLocation);
1163
+ await pkg.reloadAllModels(entries);
1164
+ pkg.setBoundManifestUri(manifestLocation);
1165
+ recordManifestBind("success");
1166
+ logger.info("Bound build manifest to package", {
1167
+ environmentName: this.environmentName,
1168
+ packageName,
1169
+ manifestLocation,
1170
+ entryCount: Object.keys(entries).length,
1171
+ });
1172
+ } catch (err) {
1173
+ pkg.markManifestBindFailed();
1174
+ const timedOut =
1175
+ err instanceof Error && err.message.includes("Timed out after");
1176
+ recordManifestBind(timedOut ? "timeout" : "failure");
1177
+ logger.warn("Failed to bind build manifest; serving live", {
1178
+ environmentName: this.environmentName,
1179
+ packageName,
1180
+ manifestLocation,
1181
+ timedOut,
1182
+ error: err instanceof Error ? err.message : String(err),
1183
+ });
1184
+ }
1185
+ }
1186
+
1187
+ /**
1188
+ * Fetch manifest entries, rejecting if the fetch exceeds
1189
+ * {@link MANIFEST_FETCH_TIMEOUT_MS}. Keeps {@link bindManifest}'s bind-before-
1190
+ * serve guarantee from stalling on an unreachable manifest store.
1191
+ */
1192
+ private async fetchManifestEntriesWithTimeout(
1193
+ manifestLocation: string,
1194
+ ): Promise<BuildManifest["entries"]> {
1195
+ let timer: ReturnType<typeof setTimeout> | undefined;
1196
+ const timeout = new Promise<never>((_, reject) => {
1197
+ timer = setTimeout(
1198
+ () =>
1199
+ reject(
1200
+ new Error(
1201
+ `Timed out after ${MANIFEST_FETCH_TIMEOUT_MS}ms fetching manifest ${manifestLocation}`,
1202
+ ),
1203
+ ),
1204
+ MANIFEST_FETCH_TIMEOUT_MS,
1205
+ );
1206
+ });
1207
+ try {
1208
+ return await Promise.race([
1209
+ fetchManifestEntries(manifestLocation),
1210
+ timeout,
1211
+ ]);
1212
+ } finally {
1213
+ if (timer) {
1214
+ clearTimeout(timer);
1215
+ }
1216
+ }
1217
+ }
1218
+
1065
1219
  /**
1066
1220
  * Read a model's source text from disk, holding the per-package mutex
1067
1221
  * so the read is serialized against {@link installPackage} /
@@ -1086,7 +1240,13 @@ export class Environment {
1086
1240
 
1087
1241
  private async writePackageManifest(
1088
1242
  packageName: string,
1089
- metadata: { name: string; description?: string },
1243
+ metadata: {
1244
+ name: string;
1245
+ description?: string;
1246
+ explores?: string[];
1247
+ queryableSources?: "declared" | "all";
1248
+ manifestLocation?: string | null;
1249
+ },
1090
1250
  ): Promise<void> {
1091
1251
  const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
1092
1252
  const manifestPath = safeJoinUnderRoot(packagePath, "publisher.json");
@@ -1101,11 +1261,23 @@ export class Environment {
1101
1261
  logger.warn(`Could not read manifest for ${packageName}`);
1102
1262
  }
1103
1263
 
1104
- // Update with new metadata
1264
+ // Update with new metadata. `explores`/`queryableSources` are only
1265
+ // overwritten when the caller explicitly provides them; otherwise the
1266
+ // existing on-disk value is preserved via the spread (an undefined here
1267
+ // must not erase it).
1105
1268
  const updatedManifest = {
1106
1269
  ...existingManifest,
1107
1270
  name: metadata.name,
1108
1271
  description: metadata.description,
1272
+ ...(metadata.explores !== undefined
1273
+ ? { explores: metadata.explores }
1274
+ : {}),
1275
+ ...(metadata.queryableSources !== undefined
1276
+ ? { queryableSources: metadata.queryableSources }
1277
+ : {}),
1278
+ ...(metadata.manifestLocation !== undefined
1279
+ ? { manifestLocation: metadata.manifestLocation }
1280
+ : {}),
1109
1281
  };
1110
1282
 
1111
1283
  // Write back to file
@@ -1132,18 +1304,72 @@ export class Environment {
1132
1304
  if (body.name) {
1133
1305
  _package.setName(body.name);
1134
1306
  }
1307
+ // Preserve `explores` across a metadata PATCH. `setPackageMetadata`
1308
+ // replaces the whole object, so a name/description-only update must
1309
+ // carry the existing discovery surface through — otherwise the
1310
+ // in-memory `explores` is wiped and `listModels()` silently starts
1311
+ // serving every model until the next reload. When the body explicitly
1312
+ // carries `explores`, honor the new set instead.
1313
+ const existing = _package.getPackageMetadata();
1314
+ // Normalize API-body explores through the same helper the worker uses
1315
+ // for on-disk explores, so `["./index.malloy"]` / backslash paths
1316
+ // validate and persist identically regardless of input channel (no
1317
+ // misleading publish-time 400, no publish-vs-reload divergence).
1318
+ const normalizedExplores = body.explores?.map(normalizeModelPath);
1319
+ const explores =
1320
+ normalizedExplores !== undefined
1321
+ ? normalizedExplores
1322
+ : existing.explores;
1323
+ const queryableSources =
1324
+ body.queryableSources !== undefined
1325
+ ? body.queryableSources
1326
+ : existing.queryableSources;
1327
+ // Preserve the existing manifestLocation unless the body explicitly
1328
+ // sets it (including to null, which clears it and reverts to live).
1329
+ const manifestLocation =
1330
+ body.manifestLocation !== undefined
1331
+ ? body.manifestLocation
1332
+ : existing.manifestLocation;
1135
1333
  _package.setPackageMetadata({
1136
1334
  name: body.name,
1137
1335
  description: body.description,
1138
1336
  resource: body.resource,
1139
1337
  location: body.location,
1338
+ explores,
1339
+ queryableSources,
1340
+ manifestLocation,
1140
1341
  });
1141
1342
 
1343
+ // Strict-reject, symmetric with the publish path
1344
+ // (package.controller.addPackage): validate the resulting explores
1345
+ // against the live model set and restore the prior metadata before
1346
+ // rejecting, so a bad update neither persists nor mutates the served
1347
+ // surface.
1348
+ const invalidMsg = _package.formatInvalidExplores();
1349
+ if (invalidMsg) {
1350
+ _package.setPackageMetadata(existing);
1351
+ throw new BadRequestError(invalidMsg);
1352
+ }
1353
+
1142
1354
  await this.writePackageManifest(packageName, {
1143
1355
  name: packageName,
1144
1356
  description: body.description,
1357
+ explores: normalizedExplores,
1358
+ queryableSources: body.queryableSources,
1359
+ manifestLocation: body.manifestLocation,
1145
1360
  });
1146
1361
 
1362
+ // When the body changes manifestLocation, apply it now so the new
1363
+ // binding takes effect without a separate reload: a URI rebinds models
1364
+ // to the materialized tables; null/empty reverts the package to live.
1365
+ if (body.manifestLocation !== undefined) {
1366
+ if (body.manifestLocation) {
1367
+ await this.bindManifest(_package, body.manifestLocation);
1368
+ } else {
1369
+ await _package.reloadAllModels({});
1370
+ }
1371
+ }
1372
+
1147
1373
  return _package.getPackageMetadata();
1148
1374
  });
1149
1375
  }
@@ -1245,6 +1471,38 @@ export class Environment {
1245
1471
  });
1246
1472
  }
1247
1473
 
1474
+ /**
1475
+ * Evict a package from the in-memory caches WITHOUT touching its on-disk
1476
+ * directory — the non-destructive counterpart to {@link deletePackage}.
1477
+ *
1478
+ * Used to roll back a no-location `addPackage` (which registers a
1479
+ * *pre-existing*, user-owned directory) when post-load validation rejects
1480
+ * it: deleting the tree there would destroy content the publisher never
1481
+ * created. This still drains and closes the connections the just-created
1482
+ * `Package` opened, so the duckdb handle isn't leaked.
1483
+ */
1484
+ public async unloadPackage(packageName: string): Promise<void> {
1485
+ assertSafePackageName(packageName);
1486
+ return this.withPackageLock(packageName, async () => {
1487
+ const _package = this.packages.get(packageName);
1488
+ if (!_package) {
1489
+ return;
1490
+ }
1491
+ if (
1492
+ this.packageStatuses.get(packageName)?.status ===
1493
+ PackageStatus.SERVING
1494
+ ) {
1495
+ this.setPackageStatus(packageName, PackageStatus.UNLOADING);
1496
+ }
1497
+ // Same 30s connection drain as deletePackage — just no fs rename/rm.
1498
+ this.retireConnectionGeneration(`package ${packageName}`, () =>
1499
+ _package.getMalloyConfig().shutdown("close"),
1500
+ );
1501
+ this.packages.delete(packageName);
1502
+ this.packageStatuses.delete(packageName);
1503
+ });
1504
+ }
1505
+
1248
1506
  public updateConnections(
1249
1507
  malloyConfig: EnvironmentMalloyConfig,
1250
1508
  _apiConnections?: ApiConnection[],
@@ -11,12 +11,6 @@ import { EnvironmentStore } from "./environment_store";
11
11
 
12
12
  type MockData = Record<string, unknown>;
13
13
 
14
- const initializeDuckLakeCalls: Array<{
15
- environmentId: string;
16
- environmentName: string;
17
- config: { catalogUrl: string; dataPath: string };
18
- }> = [];
19
-
20
14
  mock.module("../storage/StorageManager", () => {
21
15
  return {
22
16
  StorageManager: class MockStorageManager {
@@ -24,18 +18,6 @@ mock.module("../storage/StorageManager", () => {
24
18
  return;
25
19
  }
26
20
 
27
- async initializeDuckLakeForEnvironment(
28
- environmentId: string,
29
- environmentName: string,
30
- config: { catalogUrl: string; dataPath: string },
31
- ): Promise<void> {
32
- initializeDuckLakeCalls.push({
33
- environmentId,
34
- environmentName,
35
- config,
36
- });
37
- }
38
-
39
21
  getRepository() {
40
22
  return {
41
23
  // ===== PROJECT METHODS =====
@@ -488,69 +470,6 @@ describe("EnvironmentStore Service", () => {
488
470
  expect(readmeContent).toBe("Updated README content");
489
471
  });
490
472
 
491
- it("should propagate materializationStorage on addEnvironment for new environment", async () => {
492
- writeFileSync(
493
- path.join(serverRootPath, "publisher.config.json"),
494
- JSON.stringify({ frozenConfig: false, environments: [] }),
495
- );
496
-
497
- await environmentStore.finishedInitialization;
498
-
499
- const materializationStorage = {
500
- catalogUrl:
501
- "postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
502
- dataPath: "gs://test-bucket",
503
- };
504
-
505
- initializeDuckLakeCalls.length = 0;
506
- const project = await environmentStore.addEnvironment({
507
- name: projectName,
508
- materializationStorage,
509
- });
510
-
511
- expect(project.metadata.materializationStorage).toEqual(
512
- materializationStorage,
513
- );
514
- expect(initializeDuckLakeCalls).toHaveLength(1);
515
- expect(initializeDuckLakeCalls[0].config).toEqual(materializationStorage);
516
- });
517
-
518
- it("should propagate materializationStorage on update", async () => {
519
- const projectPath = path.join(serverRootPath, projectName);
520
- mkdirSync(projectPath, { recursive: true });
521
- writeFileSync(
522
- path.join(projectPath, "publisher.json"),
523
- JSON.stringify({ name: projectName, description: "Test package" }),
524
- );
525
- writeFileSync(
526
- path.join(serverRootPath, "publisher.config.json"),
527
- JSON.stringify({
528
- frozenConfig: false,
529
- environments: [
530
- {
531
- name: projectName,
532
- packages: [{ name: projectName, location: projectPath }],
533
- },
534
- ],
535
- }),
536
- );
537
-
538
- await environmentStore.finishedInitialization;
539
- const project = await environmentStore.getEnvironment(projectName);
540
-
541
- const materializationStorage = {
542
- catalogUrl:
543
- "postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
544
- dataPath: "gs://test-bucket",
545
- };
546
-
547
- await project.update({ name: projectName, materializationStorage });
548
-
549
- expect(project.metadata.materializationStorage).toEqual(
550
- materializationStorage,
551
- );
552
- });
553
-
554
473
  it(
555
474
  "should handle project reload",
556
475
  async () => {
@@ -452,25 +452,6 @@ export class EnvironmentStore {
452
452
  dbEnvironment = await repository.createEnvironment(environmentData);
453
453
  }
454
454
 
455
- // Initialize DuckLake manifest storage if configured on the environment.
456
- const materializationStorage = environment.metadata
457
- ?.materializationStorage as
458
- | { catalogUrl?: string; dataPath?: string }
459
- | undefined;
460
- if (
461
- materializationStorage?.catalogUrl &&
462
- materializationStorage?.dataPath
463
- ) {
464
- await this.storageManager.initializeDuckLakeForEnvironment(
465
- dbEnvironment.id,
466
- dbEnvironment.name,
467
- {
468
- catalogUrl: materializationStorage.catalogUrl,
469
- dataPath: materializationStorage.dataPath,
470
- },
471
- );
472
- }
473
-
474
455
  return dbEnvironment;
475
456
  }
476
457
 
@@ -938,10 +919,6 @@ export class EnvironmentStore {
938
919
 
939
920
  if (!newEnvironment.metadata) newEnvironment.metadata = {};
940
921
  newEnvironment.metadata.location = absoluteEnvironmentPath;
941
- if (environment.materializationStorage !== undefined) {
942
- newEnvironment.metadata.materializationStorage =
943
- environment.materializationStorage;
944
- }
945
922
 
946
923
  this.environments.set(environmentName, newEnvironment);
947
924