@malloy-publisher/server 0.0.231 → 0.0.232

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 (79) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +74 -3
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
  14. package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
  15. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  16. package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
  17. package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
  18. package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +3186 -433
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/errors.ts +19 -0
  32. package/src/logger.spec.ts +18 -1
  33. package/src/logger.ts +3 -1
  34. package/src/materialization_metrics.spec.ts +89 -4
  35. package/src/materialization_metrics.ts +155 -5
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  38. package/src/mcp/tools/embedding_index.ts +808 -0
  39. package/src/mcp/tools/get_context_eval.ts +194 -45
  40. package/src/mcp/tools/get_context_tool.spec.ts +295 -2
  41. package/src/mcp/tools/get_context_tool.ts +159 -10
  42. package/src/pg_helpers.spec.ts +201 -0
  43. package/src/pg_helpers.ts +44 -5
  44. package/src/server.ts +24 -0
  45. package/src/service/build_plan.spec.ts +128 -2
  46. package/src/service/build_plan.ts +239 -17
  47. package/src/service/connection.ts +263 -7
  48. package/src/service/connection_config.spec.ts +48 -0
  49. package/src/service/connection_config.ts +21 -8
  50. package/src/service/connection_federation.spec.ts +184 -0
  51. package/src/service/embedding_provider.spec.ts +329 -0
  52. package/src/service/embedding_provider.ts +236 -0
  53. package/src/service/environment.ts +274 -12
  54. package/src/service/environment_store.spec.ts +678 -3
  55. package/src/service/environment_store.ts +449 -33
  56. package/src/service/environment_store_clone.spec.ts +350 -0
  57. package/src/service/manifest_loader.spec.ts +68 -13
  58. package/src/service/manifest_loader.ts +67 -19
  59. package/src/service/materialization_build_session.spec.ts +435 -0
  60. package/src/service/materialization_build_session.ts +681 -0
  61. package/src/service/materialization_eligibility.spec.ts +158 -0
  62. package/src/service/materialization_eligibility.ts +305 -0
  63. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  64. package/src/service/materialization_serve_transform.ts +779 -0
  65. package/src/service/materialization_service.spec.ts +774 -7
  66. package/src/service/materialization_service.ts +1107 -42
  67. package/src/service/materialization_test_fixtures.ts +7 -0
  68. package/src/service/model.spec.ts +207 -0
  69. package/src/service/model.ts +540 -52
  70. package/src/service/model_storage_serve.spec.ts +193 -0
  71. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  72. package/src/service/package.spec.ts +196 -0
  73. package/src/service/package.ts +385 -17
  74. package/src/service/persistence_policy.spec.ts +109 -0
  75. package/src/storage/duckdb/schema.ts +37 -0
  76. package/tests/fixtures/xlsx/database.xlsx +0 -0
  77. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  78. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  79. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
@@ -20,6 +20,7 @@ import {
20
20
  PackageNotFoundError,
21
21
  ServiceUnavailableError,
22
22
  } from "../errors";
23
+ import { getPersistStorageMode } from "../config";
23
24
  import { logger } from "../logger";
24
25
  import { redactPgSecrets } from "../pg_helpers";
25
26
  import { recordManifestBind } from "../materialization_metrics";
@@ -29,7 +30,7 @@ import {
29
30
  assertSafeRelativeModelPath,
30
31
  safeJoinUnderRoot,
31
32
  } from "../path_safety";
32
- import { FreshnessManifest } from "../storage/DatabaseInterface";
33
+ import { FreshnessManifest, ManifestEntry } from "../storage/DatabaseInterface";
33
34
  import { URL_READER } from "../utils";
34
35
  import {
35
36
  buildEnvironmentMalloyConfig,
@@ -37,7 +38,11 @@ import {
37
38
  EnvironmentMalloyConfig,
38
39
  InternalConnection,
39
40
  } from "./connection";
40
- import { fetchManifestEntries } from "./manifest_loader";
41
+ import {
42
+ fetchManifestEntries,
43
+ splitManifestEntries,
44
+ type FetchedManifest,
45
+ } from "./manifest_loader";
41
46
  import { ApiConnection } from "./model";
42
47
  import { Package } from "./package";
43
48
  import type { PackageMemoryGovernor } from "./package_memory_governor";
@@ -167,6 +172,17 @@ export class Environment {
167
172
  private apiConnections: ApiConnection[];
168
173
  private environmentPath: string;
169
174
  private environmentName: string;
175
+ // Resolves a package's latest persisted materialization manifest entries
176
+ // (the full map — colocated tableName entries AND `storage=` cross-connection
177
+ // entries), so serve routing for BOTH tiers is re-established when a package
178
+ // (re)loads — e.g. after a worker restart — instead of only when a build's
179
+ // auto-load runs. Injected by the EnvironmentStore, which owns the
180
+ // materialization repository. Undefined ⇒ no re-bind on load (routing then
181
+ // depends on a fresh build, the old behavior). See
182
+ // {@link rebindServeBindingsFromLocalStore}.
183
+ private storageBindingResolver?: (
184
+ packageName: string,
185
+ ) => Promise<Record<string, ManifestEntry>>;
170
186
  public metadata: ApiEnvironment;
171
187
  // The shared memory governor that consults process RSS. Optional —
172
188
  // when null the gate is a no-op and the environment behaves exactly
@@ -663,11 +679,10 @@ export class Environment {
663
679
  // the same reason). A log line was the old destination; this
664
680
  // one is an HTTP response body.
665
681
  //
666
- // Reduces the exposure, does not remove it: redactPgSecrets
667
- // only covers keyword-form `password=`, which is what
668
- // buildPgConnectionString emits. A URL-form connectionString
669
- // supplied verbatim in config still carries its credentials
670
- // through. Widen the helper rather than trusting this call.
682
+ // redactPgSecrets covers both keyword-form `password=` (what
683
+ // buildPgConnectionString emits) and URI userinfo
684
+ // (`postgres://user:pass@host`), so a URL-form connectionString
685
+ // supplied verbatim in config is redacted here too.
671
686
  this.failedPackages.set(
672
687
  packageName,
673
688
  redactPgSecrets(
@@ -803,6 +818,88 @@ export class Environment {
803
818
  this.memoryGovernor = governor;
804
819
  }
805
820
 
821
+ /**
822
+ * Inject the resolver that fetches a package's latest persisted
823
+ * materialization entries — both tiers (see {@link storageBindingResolver}).
824
+ */
825
+ public setStorageBindingResolver(
826
+ resolver: (packageName: string) => Promise<Record<string, ManifestEntry>>,
827
+ ): void {
828
+ this.storageBindingResolver = resolver;
829
+ }
830
+
831
+ /**
832
+ * Re-establish a package's serve routing from its latest persisted
833
+ * materialization when it (re)loads, so serving survives a restart — both
834
+ * tiers, not just one. Serve bindings are otherwise in-memory, set only by a
835
+ * build's post-run auto-load, so a worker restart silently reverted a
836
+ * materialized source to serving live until the next build. Runs beside
837
+ * {@link bindManifestIfConfigured} — the same "bind serve state on load" step.
838
+ * Best-effort: a lookup failure logs and leaves the package serving live (a
839
+ * subsequent build will bind it).
840
+ *
841
+ * Both tiers are restored from the SAME persisted manifest, split by
842
+ * {@link splitManifestEntries}, mirroring {@link bindManifest}:
843
+ * - **colocated** (same-connection) → {@link Package.bindColocatedServeManifest},
844
+ * applied as a per-query `buildManifest` override at serve time (no
845
+ * recompile — the load-time compile already produced flag-carrying models).
846
+ * Independent of `PERSIST_STORAGE_MODE`: colocated is the v0 path and is
847
+ * not gated by the storage kill switch (a plain `#@ persist` materializes
848
+ * and serves even when the tier is `off`), so its routing is restored
849
+ * regardless of mode.
850
+ * - **storage=** (cross-connection) → {@link Package.bindStorageServeBindings},
851
+ * the virtual-source transform. Restored only when the tier is not `off`:
852
+ * storage serve routing requires the tier on, so an off deployment skips it.
853
+ *
854
+ * Skipped entirely when the package has a bound `manifestLocation`: the two
855
+ * binding producers (this local store and the host's fetched manifest, applied
856
+ * by {@link bindManifest}) are mutually exclusive by manifest presence, and the
857
+ * host is authoritative when one is set. An orchestrated publisher STILL
858
+ * persists its own local materialization records (the host triggers builds
859
+ * through it), so without this guard the local rebind — which runs AFTER
860
+ * {@link bindManifestIfConfigured} on load — would overwrite the host's
861
+ * bindings with a possibly-staler local generation.
862
+ */
863
+ private async rebindServeBindingsFromLocalStore(
864
+ pkg: Package,
865
+ ): Promise<void> {
866
+ if (!this.storageBindingResolver) return;
867
+ // Host-authoritative: a bound manifestLocation means bindManifest already
868
+ // supplied both tiers' serve bindings from the host's manifest; the local
869
+ // store must not clobber them (mutually-exclusive binding sources).
870
+ if (pkg.getPackageMetadata().manifestLocation) return;
871
+ const packageName = pkg.getPackageName();
872
+ try {
873
+ const rawEntries = await this.storageBindingResolver(packageName);
874
+ if (Object.keys(rawEntries).length === 0) return;
875
+ const { tableNameManifest, storageEntries } = splitManifestEntries(
876
+ rawEntries,
877
+ `local store (package ${packageName})`,
878
+ );
879
+ // Colocated: restore regardless of PERSIST_STORAGE_MODE (v0 path, not
880
+ // gated by the storage kill switch).
881
+ if (Object.keys(tableNameManifest).length > 0) {
882
+ pkg.bindColocatedServeManifest(tableNameManifest);
883
+ }
884
+ // Storage=: only meaningful when the tier is not off (serve routing to
885
+ // the external store requires it). Ships dark otherwise.
886
+ if (
887
+ getPersistStorageMode() !== "off" &&
888
+ Object.keys(storageEntries).length > 0
889
+ ) {
890
+ pkg.bindStorageServeBindings(storageEntries);
891
+ }
892
+ } catch (err) {
893
+ logger.warn(
894
+ "Failed to rebind serve bindings from local store on load",
895
+ {
896
+ packageName,
897
+ error: err instanceof Error ? err.message : String(err),
898
+ },
899
+ );
900
+ }
901
+ }
902
+
806
903
  /**
807
904
  * Choke-point check called from every code path that would allocate
808
905
  * a *new* package into the in-memory map (lazy load on cache miss,
@@ -930,6 +1027,7 @@ export class Environment {
930
1027
  () => this.malloyConfig.malloyConfig,
931
1028
  );
932
1029
  await this.bindManifestIfConfigured(_package);
1030
+ await this.rebindServeBindingsFromLocalStore(_package);
933
1031
  if (existingPackage !== undefined && reload) {
934
1032
  this.retireConnectionGeneration(`package ${packageName}`, () =>
935
1033
  existingPackage.getMalloyConfig().shutdown("close"),
@@ -1210,6 +1308,7 @@ export class Environment {
1210
1308
  // rollback window: a manifest that can't be fetched must not undo an
1211
1309
  // otherwise-successful install (the package serves live instead).
1212
1310
  await this.bindManifestIfConfigured(newPackage);
1311
+ await this.rebindServeBindingsFromLocalStore(newPackage);
1213
1312
 
1214
1313
  this.packages.set(packageName, newPackage);
1215
1314
  this.setPackageStatus(packageName, PackageStatus.SERVING);
@@ -1250,6 +1349,19 @@ export class Environment {
1250
1349
  * holding the per-package mutex for the duration of the disk reads.
1251
1350
  * Replaces direct `Package.reloadAllModels` calls from outside
1252
1351
  * `Environment`.
1352
+ *
1353
+ * Skips the recompile when there is nothing to substitute now AND nothing was
1354
+ * substituted before — the same condition {@link bindManifest} applies to a
1355
+ * pure-storage manifest flip, and for the same reason: a same-connection
1356
+ * `tableName` manifest is resolved at COMPILE time, so an empty one over an
1357
+ * already-empty one changes nothing a recompile could express. The previous
1358
+ * state has to be checked too, because a package that just dropped its last
1359
+ * colocated entry still has to recompile to revert the substitution.
1360
+ *
1361
+ * This matters because every materialization run lands here. Without the
1362
+ * guard a `storage=`-only package — or one with no persist sources at all —
1363
+ * paid a full package recompile per run: measured at ~1.1MB of RSS per run on
1364
+ * the production image, never reclaimed, against a `main` build that reclaims.
1253
1365
  */
1254
1366
  public async reloadAllModelsForPackage(
1255
1367
  packageName: string,
@@ -1263,10 +1375,107 @@ export class Environment {
1263
1375
  `Package ${packageName} is not loaded`,
1264
1376
  );
1265
1377
  }
1378
+ const has = Object.keys(manifest).length > 0;
1379
+ const had = pkg.hasBoundTableNameManifest();
1380
+ if (!has && !had) return;
1266
1381
  await pkg.reloadAllModels(manifest);
1267
1382
  });
1268
1383
  }
1269
1384
 
1385
+ /**
1386
+ * Bind a package's `storage=` serve bindings from a build's FULL manifest
1387
+ * entries (carrying `storageConnectionName` + captured `schema`), so a query
1388
+ * against a materialized-into-storage source can be routed through the
1389
+ * virtual-source serve transform. Distinct from
1390
+ * {@link reloadAllModelsForPackage}, which binds the tableName-only manifest
1391
+ * for same-connection persistence. No-op (logged) if the package isn't
1392
+ * loaded — best-effort, like the post-build auto-load itself.
1393
+ */
1394
+ public async bindPackageStorageServeBindings(
1395
+ packageName: string,
1396
+ entries: Record<string, ManifestEntry>,
1397
+ ): Promise<void> {
1398
+ assertSafePackageName(packageName);
1399
+ return this.withPackageLock(packageName, async () => {
1400
+ const pkg = this.packages.get(packageName);
1401
+ if (!pkg) {
1402
+ logger.warn(
1403
+ "Cannot bind storage serve bindings: package not loaded",
1404
+ { packageName },
1405
+ );
1406
+ return;
1407
+ }
1408
+ // Host-authoritative: when a manifestLocation is bound, the host's
1409
+ // manifest is the sole source of storage serve bindings (see
1410
+ // bindManifest + rebindServeBindingsFromLocalStore' matching guard). This
1411
+ // method is the LOCAL-store binding path (post-build auto-load and the
1412
+ // post-delete rebind); on an orchestrated deployment the publisher
1413
+ // still writes its own local materialization records, so without this
1414
+ // guard a build's auto-load or a routine retire of a superseded record
1415
+ // would clobber the host's bindings with a possibly-staler local
1416
+ // generation. Standalone (no manifestLocation) is unaffected.
1417
+ if (pkg.getPackageMetadata().manifestLocation) {
1418
+ logger.debug(
1419
+ "Skipping local-store storage serve binding: manifestLocation " +
1420
+ "is bound (host authoritative)",
1421
+ { packageName },
1422
+ );
1423
+ return;
1424
+ }
1425
+ pkg.bindStorageServeBindings(entries);
1426
+ });
1427
+ }
1428
+
1429
+ /**
1430
+ * Re-establish a package's COLOCATED (same-connection) serve routing from a
1431
+ * FreshnessManifest, holding the package lock — the colocated analogue of
1432
+ * {@link bindPackageStorageServeBindings}, used by the post-delete rebind so a
1433
+ * reclaimed colocated table is not left routed. No-op (logged) if the package
1434
+ * isn't loaded, and skipped when a `manifestLocation` is bound (host
1435
+ * authoritative, same as the storage variant). An empty manifest reverts to
1436
+ * serving live.
1437
+ *
1438
+ * Unlike the storage tier — and unlike the load-time colocated rebind
1439
+ * ({@link Package.bindColocatedServeManifest}, which restores routing onto
1440
+ * FRESHLY-compiled models with a per-query overlay, no recompile) — this path
1441
+ * runs against models that a build's auto-load already recompiled with the
1442
+ * substitution BAKED IN. Clearing the per-query overlay would not strip a
1443
+ * baked substitution, so reverting/rebinding here requires a recompile
1444
+ * ({@link Package.reloadAllModels}), exactly as {@link bindManifest} does for
1445
+ * colocated. Skipped when there is nothing to substitute AND nothing was
1446
+ * previously substituted to clear (no needless recompile for a storage-only or
1447
+ * never-materialized package).
1448
+ */
1449
+ public async bindPackageColocatedServeManifest(
1450
+ packageName: string,
1451
+ entries: FreshnessManifest,
1452
+ ): Promise<void> {
1453
+ assertSafePackageName(packageName);
1454
+ return this.withPackageLock(packageName, async () => {
1455
+ const pkg = this.packages.get(packageName);
1456
+ if (!pkg) {
1457
+ logger.warn(
1458
+ "Cannot bind colocated serve manifest: package not loaded",
1459
+ { packageName },
1460
+ );
1461
+ return;
1462
+ }
1463
+ if (pkg.getPackageMetadata().manifestLocation) {
1464
+ logger.debug(
1465
+ "Skipping local-store colocated serve binding: manifestLocation " +
1466
+ "is bound (host authoritative)",
1467
+ { packageName },
1468
+ );
1469
+ return;
1470
+ }
1471
+ const hasColocated = Object.keys(entries).length > 0;
1472
+ const hadColocated = pkg.hasBoundTableNameManifest();
1473
+ if (hasColocated || hadColocated) {
1474
+ await pkg.reloadAllModels(entries);
1475
+ }
1476
+ });
1477
+ }
1478
+
1270
1479
  /**
1271
1480
  * If the freshly-loaded package declares a `manifestLocation`, fetch the
1272
1481
  * control-plane-computed build manifest and rebind its models so persist
@@ -1294,16 +1503,47 @@ export class Environment {
1294
1503
  // manifest store must not block serving indefinitely — bound is the
1295
1504
  // intended state, live is the degraded fallback. Race the fetch against
1296
1505
  // a timeout and fall back to live on either failure.
1297
- const entries =
1506
+ const { tableNameManifest, storageEntries } =
1298
1507
  await this.fetchManifestEntriesWithTimeout(manifestLocation);
1299
- await pkg.reloadAllModels(entries);
1508
+
1509
+ // Tier split. Storage entries (cross-connection) bind as serve bindings
1510
+ // WITHOUT a recompile — they apply to the already-compiled models via
1511
+ // Model.setServeBindings. The host is the authoritative producer here,
1512
+ // so this also supersedes the local-store rebind (see
1513
+ // rebindServeBindingsFromLocalStore, which no-ops when manifestLocation
1514
+ // is set).
1515
+ // Bind whenever there ARE storage entries OR there WERE (bindStorage-
1516
+ // ServeBindings with the now-empty set clears them): a manifest whose
1517
+ // storage entries vanished must drop the old bindings, not leave them
1518
+ // routing at a table the host no longer vouches for. Mirrors the
1519
+ // hadColocated guard below.
1520
+ const hasStorage = Object.keys(storageEntries).length > 0;
1521
+ const hadStorage = pkg.hasStorageServeBindings();
1522
+ if (hasStorage || hadStorage) {
1523
+ pkg.bindStorageServeBindings(storageEntries);
1524
+ }
1525
+
1526
+ // colocated entries drive the same-connection tableName substitution, which
1527
+ // is resolved at COMPILE time — so they require a reloadAllModels
1528
+ // recompile (v0's existing cost). Skip the recompile for a pure-storage
1529
+ // manifest flip: only when there is nothing to substitute AND nothing
1530
+ // previously substituted to clear (otherwise a package that just dropped
1531
+ // its last colocated entry must still recompile to revert it).
1532
+ const hasColocated = Object.keys(tableNameManifest).length > 0;
1533
+ const hadColocated = pkg.hasBoundTableNameManifest();
1534
+ if (hasColocated || hadColocated) {
1535
+ await pkg.reloadAllModels(tableNameManifest);
1536
+ }
1537
+
1300
1538
  pkg.setBoundManifestUri(manifestLocation);
1301
1539
  recordManifestBind("success");
1302
1540
  logger.info("Bound build manifest to package", {
1303
1541
  environmentName: this.environmentName,
1304
1542
  packageName,
1305
1543
  manifestLocation,
1306
- entryCount: Object.keys(entries).length,
1544
+ tableNameEntryCount: Object.keys(tableNameManifest).length,
1545
+ storageEntryCount: Object.keys(storageEntries).length,
1546
+ recompiled: hasColocated || hadColocated,
1307
1547
  });
1308
1548
  } catch (err) {
1309
1549
  pkg.markManifestBindFailed();
@@ -1327,7 +1567,7 @@ export class Environment {
1327
1567
  */
1328
1568
  private async fetchManifestEntriesWithTimeout(
1329
1569
  manifestLocation: string,
1330
- ): Promise<FreshnessManifest> {
1570
+ ): Promise<FetchedManifest> {
1331
1571
  let timer: ReturnType<typeof setTimeout> | undefined;
1332
1572
  const timeout = new Promise<never>((_, reject) => {
1333
1573
  timer = setTimeout(
@@ -1487,7 +1727,7 @@ export class Environment {
1487
1727
  // policy gate below (a rejection there fails the orchestrated run) or
1488
1728
  // reset the persisted policy. `manifestLocation` is deliberately
1489
1729
  // different — null there means "clear" (revert to live), which the
1490
- // orchestrator relies on.
1730
+ // caller's orchestrated build path relies on.
1491
1731
  const scopeProvided = body.scope != null;
1492
1732
  const materializationProvided = body.materialization != null;
1493
1733
  const editingPolicy = scopeProvided || materializationProvided;
@@ -1551,7 +1791,12 @@ export class Environment {
1551
1791
  if (body.manifestLocation) {
1552
1792
  await this.bindManifest(_package, body.manifestLocation);
1553
1793
  } else {
1794
+ // Revert to live: drop the colocated tableName substitution AND the
1795
+ // cross-connection storage serve bindings the prior bindManifest
1796
+ // applied, so no query still routes to a materialized table after
1797
+ // the operator explicitly cleared the manifest.
1554
1798
  await _package.reloadAllModels({});
1799
+ _package.bindStorageServeBindings({});
1555
1800
  }
1556
1801
  }
1557
1802
 
@@ -1563,6 +1808,23 @@ export class Environment {
1563
1808
  return this.packageStatuses.get(packageName);
1564
1809
  }
1565
1810
 
1811
+ /**
1812
+ * Packages this environment is actually serving: registered statuses minus
1813
+ * any recorded as failed or un-mounted. Disjoint from getFailedPackages()
1814
+ * by construction, so the readiness line's packages= and load_errors=
1815
+ * cannot double-count a package that is seeded SERVING at boot and only
1816
+ * pruned later by a side-effect load (which a transient DB or memory-
1817
+ * pressure error can skip). Cheap: no package load is triggered.
1818
+ */
1819
+ public getServingPackageCount(): number {
1820
+ const failed = this.getFailedPackages();
1821
+ let serving = 0;
1822
+ for (const name of this.packageStatuses.keys()) {
1823
+ if (!failed.has(name)) serving += 1;
1824
+ }
1825
+ return serving;
1826
+ }
1827
+
1566
1828
  /**
1567
1829
  * Record why a configured package's location never mounted, so /status can
1568
1830
  * name the real cause instead of the missing-manifest fallout it produces.