@malloy-publisher/server 0.0.231 → 0.0.233
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.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +135 -13
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
- package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +22627 -1260
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/controller/query.controller.ts +26 -21
- package/src/errors.ts +19 -0
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +230 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
- package/src/mcp/tools/execute_query_tool.ts +90 -151
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +358 -5
- package/src/mcp/tools/get_context_tool.ts +202 -56
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +339 -20
- package/src/service/connection_config.spec.ts +108 -0
- package/src/service/connection_config.ts +47 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +569 -59
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
package/src/service/package.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
636
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
(
|
|
1354
|
-
|
|
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(", "));
|
|
Binary file
|