@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
@@ -44,9 +44,12 @@ import {
44
44
  BuildManifest,
45
45
  BuildPlan,
46
46
  FreshnessManifest,
47
+ ManifestEntry,
47
48
  } from "../storage/DatabaseInterface";
48
49
  import { errMessage, ignoreDotfiles } from "../utils";
49
- import { computePackageBuildPlan } from "./build_plan";
50
+ import { getPersistCollisionEnforce, getPersistStorageMode } from "../config";
51
+ import { deriveServeBindings } from "./materialization_serve_transform";
52
+ import { computePackageBuildPlan, SourceEligibility } from "./build_plan";
50
53
  import { CronEvaluator } from "./cron_evaluator";
51
54
  import { filterFreshManifest } from "./freshness";
52
55
  import { isQuotedIdentifierPath, quoteManifestTablePath } from "./quoting";
@@ -124,6 +127,13 @@ export class Package {
124
127
  // freshness fields so the serve path can re-evaluate `age vs window` per
125
128
  // query. Undefined when the package is serving live (unbound).
126
129
  private freshnessEntries: FreshnessManifest | undefined;
130
+ // `storage=` serve bindings (materialized-into-storage sources), derived from
131
+ // a build's full manifest entries and pushed onto each Model so a query can
132
+ // route through the virtual-source serve transform. Distinct from the
133
+ // same-connection freshnessEntries above (which the manifest substitution
134
+ // uses); a storage source serves cross-connection via bindings, not the
135
+ // manifest. Empty ⇒ no storage serve routing.
136
+ private storageServeBindings: ReturnType<typeof deriveServeBindings> = [];
127
137
  // Memoized freshness-filtered manifest for the serve path. Almost all queries
128
138
  // in a window share the same included set, so this is recomputed only when a
129
139
  // retained entry actually crosses its window (`validUntil`, the next
@@ -144,6 +154,21 @@ export class Package {
144
154
  // no persist source. Surfaced read-only on getPackageMetadata() so a caller
145
155
  // can derive build instructions without a separate plan round-trip.
146
156
  private buildPlan: BuildPlan | null = null;
157
+ // Sources annotated `#@ persist` that Malloy's getBuildPlan() did not
158
+ // recognize as a materializable build root, so they produced no plan entry
159
+ // and would be a silent no-op (served live). Surfaced as an operator warning
160
+ // and hard-refused at build. See build_plan.detectDroppedPersistSources.
161
+ private droppedPersistSources: { name: string; modelPath: string }[] = [];
162
+ // Which sources may be served from a materialized table, decided at compile
163
+ // (build_plan.collectSourceEligibility) because that is where the compiled
164
+ // sources exist. Consulted when binding serve bindings, which deliberately
165
+ // does not recompile.
166
+ //
167
+ // `undefined` means NOT KNOWN — the plan compute failed (it does live schema
168
+ // RPCs, so a warehouse blip is the ordinary way) and the package loaded
169
+ // anyway. Distinct from "nothing is eligible", and refused the same way: an
170
+ // unknown answer must not read as consent. See bindStorageServeBindings.
171
+ private sourceEligibility: SourceEligibility | undefined = undefined;
147
172
  // Non-fatal render-tag findings aggregated across the package's models (each
148
173
  // tagged with its model path), surfaced read-only on
149
174
  // getPackageMetadata().warnings. Refreshed on load and reload. A bad render
@@ -536,9 +561,18 @@ export class Package {
536
561
  // failure is logged, not fatal — the package still serves; the plan is
537
562
  // just absent. Recompiles the models (duplicate schema RPCs vs the worker
538
563
  // compile); accepted for now.
564
+ //
565
+ // A failure also leaves `sourceEligibility` unknown, which refuses every
566
+ // storage serve binding until a load succeeds (see
567
+ // bindStorageServeBindings). Serving live is a slower answer, not a wrong
568
+ // one; admitting an unexamined binding would be a wrong one.
539
569
  try {
540
570
  const buildPlanStart = Date.now();
541
- pkg.buildPlan = await computePackageBuildPlan(pkg);
571
+ const { plan, droppedPersistSources, sourceEligibility } =
572
+ await computePackageBuildPlan(pkg);
573
+ pkg.buildPlan = plan;
574
+ pkg.droppedPersistSources = droppedPersistSources;
575
+ pkg.sourceEligibility = sourceEligibility;
542
576
  recordBuildPlanComputeDuration(Date.now() - buildPlanStart);
543
577
  } catch (err) {
544
578
  logger.warn(
@@ -575,6 +609,17 @@ export class Package {
575
609
  },
576
610
  );
577
611
  }
612
+ // Persist-target collisions are ALWAYS warn-only at load (never fail an
613
+ // already-published package), regardless of PERSIST_COLLISION_ENFORCE —
614
+ // the flag only governs whether they REJECT a publish (see
615
+ // package.controller). Surface them so an operator can remediate.
616
+ const collisions = pkg.persistenceCollisionWarnings();
617
+ if (collisions.length > 0) {
618
+ logger.warn(`Package ${packageName} has persist-target collisions`, {
619
+ packageName,
620
+ detail: collisions.join("\n"),
621
+ });
622
+ }
578
623
  pkg.logEmptyDiscoveryWarnings();
579
624
 
580
625
  return pkg;
@@ -632,8 +677,29 @@ export class Package {
632
677
  if (warnings.length > 0) {
633
678
  metadata.exploresWarnings = warnings;
634
679
  }
635
- if (this.renderTagWarnings.length > 0) {
636
- metadata.warnings = [...this.renderTagWarnings];
680
+ // Render-tag findings and storage= warnings share the one operator-facing
681
+ // warnings array (the {model, target, message} shape carries both).
682
+ const allWarnings = [
683
+ ...this.renderTagWarnings,
684
+ ...this.storageWarnings(),
685
+ ...this.droppedPersistWarnings(),
686
+ // A within-package persist-target collision spans two or more sources, so
687
+ // there is no single target field; the message names them. Surfaced here
688
+ // (alongside the load-path log) so an operator can see it on the status
689
+ // API like the other persist warnings — see persistenceCollisionWarnings.
690
+ ...this.persistenceCollisionWarnings().map((message) => ({ message })),
691
+ ];
692
+ if (allWarnings.length > 0) {
693
+ metadata.warnings = allWarnings;
694
+ }
695
+ // Surface what's bound for the cross-connection storage serve so a caller
696
+ // can confirm a materialized source is routed (vs. inferring from logs).
697
+ if (this.storageServeBindings.length > 0) {
698
+ metadata.storageServeBindings = this.storageServeBindings.map((b) => ({
699
+ sourceName: b.sourceName,
700
+ storageConnectionName: b.connectionName,
701
+ tablePath: b.tablePath,
702
+ }));
637
703
  }
638
704
  return metadata;
639
705
  }
@@ -648,6 +714,33 @@ export class Package {
648
714
  return this.buildManifestEntries;
649
715
  }
650
716
 
717
+ /**
718
+ * Whether the package currently has a bound (non-empty) same-connection
719
+ * `tableName` manifest — i.e. a prior bind substituted colocated physical tables
720
+ * at compile time. Used by the manifest-rebind tier split to decide whether a
721
+ * pure-storage refresh can skip the {@link reloadAllModels} recompile: it can
722
+ * only skip when there is nothing to substitute now AND nothing was
723
+ * substituted before (otherwise dropping the last colocated entry must recompile
724
+ * to revert it).
725
+ */
726
+ public hasBoundTableNameManifest(): boolean {
727
+ return (
728
+ this.buildManifestEntries !== undefined &&
729
+ Object.keys(this.buildManifestEntries).length > 0
730
+ );
731
+ }
732
+
733
+ /**
734
+ * Whether the package currently has (non-empty) `storage=` serve bindings.
735
+ * Used by the manifest-rebind tier split so a refresh whose storage entries
736
+ * vanished still clears the old bindings (rather than leaving them routing at
737
+ * a table the host no longer vouches for) — the storage mirror of
738
+ * {@link hasBoundTableNameManifest}.
739
+ */
740
+ public hasStorageServeBindings(): boolean {
741
+ return this.storageServeBindings.length > 0;
742
+ }
743
+
651
744
  /**
652
745
  * The freshness-filtered build manifest for the serve path, evaluated at
653
746
  * `now`. Persistence.md §9.3 Phase B: a query on a `#@ persist` source may
@@ -732,6 +825,96 @@ export class Package {
732
825
  }
733
826
  }
734
827
 
828
+ /**
829
+ * Bind (or clear) the package's `storage=` serve bindings from a build's full
830
+ * manifest entries, and push them onto every loaded model so a query can be
831
+ * routed through the virtual-source serve transform. Called by the build's
832
+ * post-run distribution with the full {@link ManifestEntry} map (which
833
+ * carries `storageConnectionName` + captured `schema`); only entries that
834
+ * were materialized into a storage destination produce a binding. Re-applied
835
+ * on model reload via {@link pushStorageServeBindingsToModels}.
836
+ */
837
+ /**
838
+ * Re-establish the colocated (same-connection) serve routing from a
839
+ * FreshnessManifest WITHOUT recompiling — the analogue of
840
+ * {@link bindStorageServeBindings} for the in-warehouse tier, used to
841
+ * restore serving on load from the package's own latest persisted
842
+ * materialization. Colocated routing is applied at query time as a per-query
843
+ * `buildManifest` override (see {@link getFreshBuildManifest} and
844
+ * Model.getQueryResults), so setting the in-memory entries is sufficient; the
845
+ * freshness resolver is already wired on every model from
846
+ * {@link Package.create}. Distinct from {@link reloadAllModels}, which also
847
+ * recompiles — the recompile is not what routes the query, so a pure
848
+ * restore-on-load skips it (no double compile after the load-time compile).
849
+ * An empty map clears the binding (reverts to serving live).
850
+ */
851
+ public bindColocatedServeManifest(entries: FreshnessManifest): void {
852
+ this.recordManifestBinding(entries);
853
+ }
854
+
855
+ /**
856
+ * Bind the storage serve bindings a host vouches for — minus any whose source
857
+ * this package's own eligibility gate refuses.
858
+ *
859
+ * The host is authoritative about WHICH TABLE backs a source; it owns
860
+ * generations and rollout. It cannot be authoritative about WHETHER a source
861
+ * may be served from a frozen table at all, because that is decided by
862
+ * compiling the model, which the host does not do. A `given`-referencing
863
+ * source is the case that matters: a given binds per query for row-level
864
+ * access control, so one table built once and served to everyone hands every
865
+ * caller the rows filtered for whoever built it.
866
+ *
867
+ * A manifest can name such a source without anyone being careless — a source
868
+ * that was given-free when it was built acquires a `given` on the next model
869
+ * edit, and the old manifest still points at a real table until convergence
870
+ * catches up.
871
+ *
872
+ * Eligibility must be established POSITIVELY: a binding is honored only if its
873
+ * source is in the eligible set. Two states would otherwise pass as consent,
874
+ * and both are reachable without anyone forging anything —
875
+ * - the plan compute failed, so nothing was examined at all;
876
+ * - the source never reached the plan, because Malloy admits only
877
+ * query-shaped sources as build roots and a `#@ persist` on a filtered
878
+ * pass-through is dropped (see `droppedPersistSources`) — the same
879
+ * row-level-access shape this gate exists for.
880
+ *
881
+ * Refused bindings are DROPPED, not fatal: that source serves live, which is
882
+ * always correct because the tier is a performance tier. The rest bind.
883
+ */
884
+ public bindStorageServeBindings(
885
+ entries: Record<string, ManifestEntry>,
886
+ ): void {
887
+ const derived = deriveServeBindings(entries);
888
+ const eligibility = this.sourceEligibility;
889
+ const eligible = new Set(eligibility?.eligible ?? []);
890
+ const allowed = derived.filter((binding) => {
891
+ if (eligible.has(binding.sourceName)) return true;
892
+ const reason =
893
+ eligibility === undefined
894
+ ? "the package build plan could not be computed, so no source was examined for eligibility"
895
+ : (eligibility.refused[binding.sourceName] ??
896
+ "the source is not in the package build plan, so it was never examined (a `#@ persist` on a non-query-shaped source is dropped)");
897
+ logger.warn(
898
+ "Refusing a storage serve binding: the source is not eligible to be served from a materialized table",
899
+ {
900
+ packageName: this.packageName,
901
+ sourceName: binding.sourceName,
902
+ reason,
903
+ },
904
+ );
905
+ return false;
906
+ });
907
+ this.storageServeBindings = allowed;
908
+ this.pushStorageServeBindingsToModels();
909
+ }
910
+
911
+ /** Push the current storage serve bindings onto every loaded model. */
912
+ private pushStorageServeBindingsToModels(): void {
913
+ for (const model of this.models.values()) {
914
+ model.setServeBindings(this.storageServeBindings);
915
+ }
916
+ }
917
+
735
918
  /**
736
919
  * Declared `explores` (publisher.json) that don't resolve to a real
737
920
  * `.malloy` model in this package, each with an actionable reason. Empty
@@ -815,6 +998,66 @@ export class Package {
815
998
  * Strict at publish (package.controller), warn-only at load/reload
816
999
  * (loadViaWorker) — same split as explores.
817
1000
  */
1001
+ /**
1002
+ * Operator-facing warnings for `#@ persist storage=<conn>` sources whose
1003
+ * storage annotation is NOT being honored on the serve path, so a degraded
1004
+ * source is visible on `/status` rather than silently serving live. Emitted
1005
+ * per {@link getPersistStorageMode}:
1006
+ * - `off`: the annotation is ignored entirely (serving live from the source
1007
+ * warehouse) — the kill-switch resting state.
1008
+ * - `write-only`: the source materializes into storage but the serve path is
1009
+ * not routed to the materialized table (served live).
1010
+ * - `on`: no warning — the source is (or falls back to being) served per the
1011
+ * transform; per-query fallback is a query-time event, not a load warning.
1012
+ * Read straight off the compiled build plan's `annotationFields.storage`
1013
+ * (undefined when the package declares no persist sources).
1014
+ */
1015
+ /**
1016
+ * Operator-facing warnings for `#@ persist` sources that Malloy's
1017
+ * getBuildPlan() did not recognize as a materializable build root (observed
1018
+ * for a filtered pass-through `X is <table> extend { where … }`). Without a
1019
+ * signal the annotation is a silent no-op — no build, no error, served live —
1020
+ * so surface it on `/status`. The build path additionally hard-refuses (see
1021
+ * MaterializationService). Independent of {@link buildPlan} being null, since
1022
+ * a package whose ONLY persist source is dropped has no plan at all.
1023
+ */
1024
+ private droppedPersistWarnings(): ApiPackageWarning[] {
1025
+ return this.droppedPersistSources.map((d) => ({
1026
+ model: d.modelPath,
1027
+ target: d.name,
1028
+ message:
1029
+ `is annotated '#@ persist' but was not recognized as a ` +
1030
+ `materializable source, so nothing is materialized and it is served ` +
1031
+ `live. Only query/aggregate sources build; a filtered pass-through ` +
1032
+ `does not. Persist a query source, or invoke a parameterized source ` +
1033
+ `with a bound argument.`,
1034
+ }));
1035
+ }
1036
+
1037
+ private storageWarnings(): ApiPackageWarning[] {
1038
+ const mode = getPersistStorageMode();
1039
+ if (mode === "on" || !this.buildPlan?.sources) return [];
1040
+ const warnings: ApiPackageWarning[] = [];
1041
+ for (const source of Object.values(this.buildPlan.sources)) {
1042
+ const storage = source.annotationFields?.storage?.trim();
1043
+ if (!storage) continue;
1044
+ const message =
1045
+ mode === "off"
1046
+ ? `declares storage="${storage}" but PERSIST_STORAGE_MODE is off; ` +
1047
+ `the annotation is ignored and the source is served live from ` +
1048
+ `its own warehouse.`
1049
+ : `is materialized into storage "${storage}" but ` +
1050
+ `PERSIST_STORAGE_MODE is write-only; the serve path is not ` +
1051
+ `routed to the materialized table (served live).`;
1052
+ warnings.push({
1053
+ model: source.modelPath ?? "",
1054
+ target: source.name,
1055
+ message,
1056
+ });
1057
+ }
1058
+ return warnings;
1059
+ }
1060
+
818
1061
  public persistencePolicyWarnings(): string[] {
819
1062
  const warnings: string[] = [];
820
1063
  const sources = this.buildPlan?.sources
@@ -897,6 +1140,95 @@ export class Package {
897
1140
  return this.persistencePolicyWarnings().join("\n");
898
1141
  }
899
1142
 
1143
+ /**
1144
+ * Within-package persist-target COLLISION warnings: two DISTINCT persist
1145
+ * sources (different `sourceEntityId`) that resolve to the same physical
1146
+ * table — the same resolved `name=` (or source-name fallback) in the same
1147
+ * destination connection. The publisher self-assigns a materialized table's
1148
+ * physical name from `name=` verbatim, so two such sources would clobber each
1149
+ * other: the second build's `CREATE OR REPLACE` overwrites the first, two
1150
+ * manifest entries point at one table, and a GC drop of one takes out the
1151
+ * other. Two IMPORTS of the SAME source (identical `sourceEntityId`) are
1152
+ * intentional dedup and NOT flagged.
1153
+ *
1154
+ * The destination is resolved from the DECLARED annotation, independent of
1155
+ * {@link getPersistStorageMode}, so the kill switch never changes whether a
1156
+ * package has a latent collision.
1157
+ *
1158
+ * Deliberately SEPARATE from {@link persistencePolicyWarnings} because the
1159
+ * rollout is staged: these are surfaced warn-only at load AND publish, and
1160
+ * only block a publish once `PERSIST_COLLISION_ENFORCE` is set (see
1161
+ * {@link getPersistCollisionEnforce}) — a package published before this check
1162
+ * existed may carry a latent collision, so an un-gated reject would break a
1163
+ * routine re-publish. This is a WITHIN-package/version check only: a `name=`
1164
+ * change ACROSS versions, or a cross-package collision, needs the
1165
+ * cross-version/global view the host has and the publisher
1166
+ * does not.
1167
+ */
1168
+ public persistenceCollisionWarnings(): string[] {
1169
+ const sources = this.buildPlan?.sources
1170
+ ? Object.values(this.buildPlan.sources)
1171
+ : [];
1172
+ const targets = new Map<
1173
+ string,
1174
+ { name: string; destination: string; sources: Map<string, string> }
1175
+ >();
1176
+ for (const source of sources) {
1177
+ const fields = source.annotationFields ?? {};
1178
+ const physicalName = (fields.name || source.name).trim();
1179
+ const storage = fields.storage?.trim();
1180
+ // storage= into a real destination lands there; absent, the source is
1181
+ // built colocated (into its own warehouse).
1182
+ const destination = storage ? storage : source.connectionName;
1183
+ // Verbatim key. Limitation: names that differ only by case or quoting
1184
+ // (`Foo` vs `foo`, `"foo"` vs `foo`) key distinctly here but may fold to
1185
+ // the same physical table on a case-folding destination — such variants
1186
+ // evade this within-package check. Normalizing correctly is
1187
+ // destination-dialect-dependent, so it's left verbatim; the host's
1188
+ // ownership-scoped production naming and the destination's own identifier
1189
+ // rules are the backstop.
1190
+ const key = `${destination} ${physicalName}`;
1191
+ const bucket = targets.get(key) ?? {
1192
+ name: physicalName,
1193
+ destination,
1194
+ sources: new Map<string, string>(),
1195
+ };
1196
+ // Keyed by sourceEntityId so identical-content imports collapse to one.
1197
+ bucket.sources.set(source.sourceEntityId, source.name);
1198
+ targets.set(key, bucket);
1199
+ }
1200
+ const warnings: string[] = [];
1201
+ for (const {
1202
+ name,
1203
+ destination,
1204
+ sources: colliding,
1205
+ } of targets.values()) {
1206
+ if (colliding.size < 2) continue;
1207
+ const names = [...colliding.values()].sort();
1208
+ warnings.push(
1209
+ `#@ persist sources ${names
1210
+ .map((n) => `"${n}"`)
1211
+ .join(", ")} all resolve to the same materialized table ` +
1212
+ `"${name}" in destination "${destination}". Distinct sources ` +
1213
+ `must not share a physical target — the second build would ` +
1214
+ `overwrite the first, and a serve binding or GC drop for one ` +
1215
+ `would affect the other. Give each a distinct #@ persist name=.`,
1216
+ );
1217
+ }
1218
+ return warnings;
1219
+ }
1220
+
1221
+ /**
1222
+ * Collision warnings joined for the publish gate: the string is non-empty
1223
+ * (and thus a publish rejection) only when `PERSIST_COLLISION_ENFORCE` is set;
1224
+ * otherwise collisions are surfaced warn-only (at load and publish) without
1225
+ * blocking. See {@link persistenceCollisionWarnings}.
1226
+ */
1227
+ public formatPersistenceCollisionRejections(): string {
1228
+ if (!getPersistCollisionEnforce()) return "";
1229
+ return this.persistenceCollisionWarnings().join("\n");
1230
+ }
1231
+
900
1232
  /**
901
1233
  * One message per LISTED model whose discovery surface is empty because it
902
1234
  * is import-only (imports other files, declares/re-exports nothing). Such a
@@ -1144,6 +1476,9 @@ export class Package {
1144
1476
  }
1145
1477
  }
1146
1478
  this.models = nextModels;
1479
+ // The freshly-compiled models start with no serve bindings; re-apply the
1480
+ // package's current storage= bindings so a reload preserves serve routing.
1481
+ this.pushStorageServeBindingsToModels();
1147
1482
  this.renderTagWarnings = renderTagWarnings;
1148
1483
  // A reload re-reads publisher.json in the worker; pick up any change to
1149
1484
  // the explore set and query-boundary mode so listModels()/the gate
@@ -1329,15 +1664,39 @@ export class Package {
1329
1664
  // (each doing its own native init + extension load) to one.
1330
1665
  const conn = await malloyConfig.connections.lookupConnection("duckdb");
1331
1666
  return await Promise.all(
1332
- databasePaths.map(async (databasePath) => ({
1333
- path: databasePath,
1334
- info: await Package.getDatabaseInfo(
1335
- packagePath,
1336
- databasePath,
1337
- conn,
1338
- ),
1339
- type: "embedded" as const,
1340
- })),
1667
+ databasePaths.map(async (databasePath): Promise<ApiDatabase> => {
1668
+ try {
1669
+ return {
1670
+ path: databasePath,
1671
+ info: await Package.getDatabaseInfo(
1672
+ packagePath,
1673
+ databasePath,
1674
+ conn,
1675
+ ),
1676
+ type: "embedded" as const,
1677
+ };
1678
+ } catch (error) {
1679
+ // One unreadable data file (a partial or corrupt spreadsheet,
1680
+ // an interrupted download, an extension that failed to bake)
1681
+ // must not drop the whole package. Report it the way a model
1682
+ // that fails to compile is reported: the entry stays in the
1683
+ // listing carrying `error` instead of `info`, so the failure
1684
+ // is visible over the API rather than looking like the file
1685
+ // was never there.
1686
+ const message =
1687
+ error instanceof Error ? error.message : String(error);
1688
+ logger.warn("Could not read package database", {
1689
+ packagePath,
1690
+ databasePath,
1691
+ error: message,
1692
+ });
1693
+ return {
1694
+ path: databasePath,
1695
+ type: "embedded" as const,
1696
+ error: message,
1697
+ };
1698
+ }
1699
+ }),
1341
1700
  );
1342
1701
  }
1343
1702
 
@@ -1349,10 +1708,19 @@ export class Package {
1349
1708
  .map((fullPath: string) => {
1350
1709
  return path.relative(packagePath, fullPath).replace(/\\/g, "/");
1351
1710
  })
1352
- .filter(
1353
- (modelPath: string) =>
1354
- modelPath.endsWith(".parquet") || modelPath.endsWith(".csv"),
1355
- );
1711
+ .filter((modelPath: string) => {
1712
+ // Excel writes a sibling owner file (~$name.xlsx) whenever the
1713
+ // workbook is open. It is not real data and is not a valid zip, so
1714
+ // skip it before the extension match rather than fail to probe it.
1715
+ if (path.basename(modelPath).startsWith("~$")) {
1716
+ return false;
1717
+ }
1718
+ return (
1719
+ modelPath.endsWith(".parquet") ||
1720
+ modelPath.endsWith(".csv") ||
1721
+ modelPath.endsWith(".xlsx")
1722
+ );
1723
+ });
1356
1724
  }
1357
1725
 
1358
1726
  private static async getDatabaseInfo(
@@ -262,4 +262,113 @@ source: f is duckdb.sql("SELECT 1 as x")
262
262
  },
263
263
  { timeout: 30000 },
264
264
  );
265
+
266
+ // ── within-package persist-target collisions ──────────────────────────
267
+
268
+ // Two DISTINCT sources both resolving to name="dup" in the same destination
269
+ // (here their own duckdb warehouse — colocated) — a self-assign clobber.
270
+ const COLLIDING_MODEL = `##! experimental.persistence
271
+
272
+ #@ persist name="dup"
273
+ source: a is duckdb.sql("SELECT 1 as x")
274
+
275
+ #@ persist name="dup"
276
+ source: b is duckdb.sql("SELECT 2 as y")
277
+ `;
278
+
279
+ // Same name= but DIFFERENT destinations — no collision (different tables).
280
+ const SAME_NAME_DIFF_DEST_MODEL = `##! experimental.persistence
281
+
282
+ #@ persist name="dup" storage=lake
283
+ source: a is duckdb.sql("SELECT 1 as x")
284
+
285
+ #@ persist name="dup"
286
+ source: b is duckdb.sql("SELECT 2 as y")
287
+ `;
288
+
289
+ it(
290
+ "warns when two distinct sources resolve to the same name= + destination",
291
+ async () => {
292
+ const pkg = await loadPackage(COLLIDING_MODEL);
293
+ const warnings = pkg.persistenceCollisionWarnings();
294
+ expect(warnings).toHaveLength(1);
295
+ expect(warnings[0]).toContain('"a"');
296
+ expect(warnings[0]).toContain('"b"');
297
+ expect(warnings[0]).toContain('"dup"');
298
+ },
299
+ { timeout: 30000 },
300
+ );
301
+
302
+ it(
303
+ "surfaces the collision on the package status warnings (not just the log)",
304
+ async () => {
305
+ const pkg = await loadPackage(COLLIDING_MODEL);
306
+ const warnings = pkg.getPackageMetadata().warnings ?? [];
307
+ const collision = warnings.find((w) =>
308
+ w.message?.includes("resolve to the same materialized table"),
309
+ );
310
+ expect(collision).toBeDefined();
311
+ expect(collision!.message).toContain('"dup"');
312
+ },
313
+ { timeout: 30000 },
314
+ );
315
+
316
+ it(
317
+ "does not warn when the same name= targets different destinations",
318
+ async () => {
319
+ const pkg = await loadPackage(SAME_NAME_DIFF_DEST_MODEL);
320
+ expect(pkg.persistenceCollisionWarnings()).toEqual([]);
321
+ const warnings = pkg.getPackageMetadata().warnings ?? [];
322
+ expect(
323
+ warnings.some((w) =>
324
+ w.message?.includes("resolve to the same materialized table"),
325
+ ),
326
+ ).toBe(false);
327
+ },
328
+ { timeout: 30000 },
329
+ );
330
+
331
+ it(
332
+ "does not warn for a plain package with distinct persist names",
333
+ async () => {
334
+ const pkg = await loadPackage(PLAIN_MODEL);
335
+ expect(pkg.persistenceCollisionWarnings()).toEqual([]);
336
+ },
337
+ { timeout: 30000 },
338
+ );
339
+
340
+ it(
341
+ "collisions never enter persistencePolicyWarnings (the strict-publish set)",
342
+ async () => {
343
+ const pkg = await loadPackage(COLLIDING_MODEL);
344
+ // The collision is real…
345
+ expect(pkg.persistenceCollisionWarnings()).toHaveLength(1);
346
+ // …but it must not ride the always-strict-at-publish policy set.
347
+ expect(pkg.persistencePolicyWarnings()).toEqual([]);
348
+ },
349
+ { timeout: 30000 },
350
+ );
351
+
352
+ it(
353
+ "gates the publish rejection behind PERSIST_COLLISION_ENFORCE",
354
+ async () => {
355
+ const pkg = await loadPackage(COLLIDING_MODEL);
356
+ const prev = process.env.PERSIST_COLLISION_ENFORCE;
357
+ try {
358
+ delete process.env.PERSIST_COLLISION_ENFORCE;
359
+ // Default: warn-only, so the publish gate string is empty.
360
+ expect(pkg.formatPersistenceCollisionRejections()).toBe("");
361
+ process.env.PERSIST_COLLISION_ENFORCE = "true";
362
+ // Enforced: the collision becomes a publish rejection.
363
+ expect(pkg.formatPersistenceCollisionRejections()).toContain(
364
+ '"dup"',
365
+ );
366
+ } finally {
367
+ if (prev === undefined)
368
+ delete process.env.PERSIST_COLLISION_ENFORCE;
369
+ else process.env.PERSIST_COLLISION_ENFORCE = prev;
370
+ }
371
+ },
372
+ { timeout: 30000 },
373
+ );
265
374
  });
@@ -122,6 +122,8 @@ export async function initializeSchema(
122
122
  )
123
123
  `);
124
124
 
125
+ await createEntityEmbeddingsTable(db);
126
+
125
127
  // Create indexes for better query performance
126
128
  await db.run(
127
129
  "CREATE INDEX IF NOT EXISTS idx_packages_environment_id ON packages(environment_id)",
@@ -137,6 +139,40 @@ export async function initializeSchema(
137
139
  );
138
140
  }
139
141
 
142
+ /**
143
+ * Vector cache for semantic `malloy_getContext` retrieval (see
144
+ * mcp/tools/embedding_index.ts). One row per discoverable model entity;
145
+ * the primary key mirrors the tool's in-memory dedup key. Rows are
146
+ * content-addressed: the index sync compares `content_hash` (over the
147
+ * embedded text) plus `embedding_model` and re-embeds on mismatch, while
148
+ * a dimensionality change is detected at query time by the stale-row
149
+ * heal, so staleness always self-corrects and wiping the table only
150
+ * ever costs re-embedding. `embedding` is a LIST column searched with
151
+ * list_cosine_similarity; at the entity counts a single Publisher
152
+ * serves, a brute-force scan is faster and simpler than a vector-index
153
+ * extension.
154
+ */
155
+ export async function createEntityEmbeddingsTable(
156
+ db: DuckDBConnection,
157
+ ): Promise<void> {
158
+ await db.run(`
159
+ CREATE TABLE IF NOT EXISTS entity_embeddings (
160
+ environment_name VARCHAR NOT NULL,
161
+ package_name VARCHAR NOT NULL,
162
+ entity_kind VARCHAR NOT NULL,
163
+ entity_source VARCHAR NOT NULL,
164
+ entity_name VARCHAR NOT NULL,
165
+ model_path VARCHAR NOT NULL,
166
+ content_hash VARCHAR NOT NULL,
167
+ embedding_model VARCHAR NOT NULL,
168
+ dims INTEGER NOT NULL,
169
+ embedding FLOAT[] NOT NULL,
170
+ updated_at TIMESTAMP NOT NULL,
171
+ PRIMARY KEY (environment_name, package_name, entity_kind, entity_source, entity_name)
172
+ )
173
+ `);
174
+ }
175
+
140
176
  // TODO: Remove this during projects cleanup
141
177
  // Tables in the pre-rename schema, listed children-first so DROP order
142
178
  // satisfies foreign-key dependencies on the legacy `projects` table.
@@ -177,6 +213,7 @@ async function dropAllTables(db: DuckDBConnection): Promise<void> {
177
213
  "connections",
178
214
  "environments",
179
215
  "themes",
216
+ "entity_embeddings",
180
217
  ];
181
218
 
182
219
  logger.info("Dropping tables:", tables.join(", "));