@malloy-publisher/server 0.0.230 → 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.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- 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 +3186 -433
- 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/errors.ts +19 -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/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- 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/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -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 +540 -52
- 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_get_context_semantic.integration.spec.ts +235 -0
|
@@ -10,9 +10,11 @@ import {
|
|
|
10
10
|
deriveBuildPlan,
|
|
11
11
|
flattenDependsOn,
|
|
12
12
|
iterGraphSources,
|
|
13
|
+
projectToPublicColumns,
|
|
13
14
|
resolveFreshness,
|
|
14
15
|
resolvePackageConnections,
|
|
15
16
|
} from "./build_plan";
|
|
17
|
+
import { MaterializationEligibilityError } from "../errors";
|
|
16
18
|
import { fakeSource } from "./materialization_test_fixtures";
|
|
17
19
|
import { Model } from "./model";
|
|
18
20
|
|
|
@@ -150,6 +152,69 @@ describe("deriveAnnotationFields", () => {
|
|
|
150
152
|
});
|
|
151
153
|
});
|
|
152
154
|
|
|
155
|
+
describe("projectToPublicColumns", () => {
|
|
156
|
+
// A source whose PUBLIC surface (intrinsic atomic fields) is `cols` — i.e. any
|
|
157
|
+
// `except:`-ed / access-restricted column is already absent here, as Malloy
|
|
158
|
+
// reflects it. deriveColumns reads exactly this.
|
|
159
|
+
const sourceWithPublicCols = (cols: string[]): PersistSource =>
|
|
160
|
+
({
|
|
161
|
+
dialectName: "postgres",
|
|
162
|
+
_explore: {
|
|
163
|
+
intrinsicFields: cols.map((name) => ({
|
|
164
|
+
name,
|
|
165
|
+
isAtomicField: () => true,
|
|
166
|
+
type: "string",
|
|
167
|
+
})),
|
|
168
|
+
},
|
|
169
|
+
}) as unknown as PersistSource;
|
|
170
|
+
|
|
171
|
+
it("wraps the build SQL to project only the source's public columns", () => {
|
|
172
|
+
const src = sourceWithPublicCols(["order_date", "amount"]); // `region` hidden → absent
|
|
173
|
+
const out = projectToPublicColumns(
|
|
174
|
+
src,
|
|
175
|
+
"SELECT order_date, region, amount FROM t",
|
|
176
|
+
);
|
|
177
|
+
// Outer projection lists ONLY the public columns; the hidden one is dropped.
|
|
178
|
+
expect(out).toMatch(/^SELECT\b/);
|
|
179
|
+
expect(out).toContain("order_date");
|
|
180
|
+
expect(out).toContain("amount");
|
|
181
|
+
expect(out).toContain(
|
|
182
|
+
"FROM (SELECT order_date, region, amount FROM t) AS __public",
|
|
183
|
+
);
|
|
184
|
+
// `region` must not appear in the OUTER projection (before the subquery).
|
|
185
|
+
const outerProjection = out.slice(0, out.indexOf("FROM ("));
|
|
186
|
+
expect(outerProjection).not.toContain("region");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Fails closed, like the rest of the eligibility surface: a public surface we
|
|
190
|
+
// can't determine refuses the build rather than widening to everything getSQL
|
|
191
|
+
// projects (which is where the hidden columns are).
|
|
192
|
+
it("refuses the build when the field list can't be read", () => {
|
|
193
|
+
const noExplore = {} as unknown as PersistSource;
|
|
194
|
+
expect(() => projectToPublicColumns(noExplore, "SELECT 1")).toThrow(
|
|
195
|
+
/public column surface could not be determined/,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("refuses the build when the source exposes no public atomic columns", () => {
|
|
200
|
+
// Everything getSQL projects is hidden — the worst case to materialize.
|
|
201
|
+
expect(() =>
|
|
202
|
+
projectToPublicColumns(sourceWithPublicCols([]), "SELECT 1"),
|
|
203
|
+
).toThrow(/no public atomic columns/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("refuses with an eligibility error (422), naming the source", () => {
|
|
207
|
+
const src = { ...sourceWithPublicCols([]), name: "daily" };
|
|
208
|
+
try {
|
|
209
|
+
projectToPublicColumns(src as unknown as PersistSource, "SELECT 1");
|
|
210
|
+
throw new Error("expected a refusal");
|
|
211
|
+
} catch (err) {
|
|
212
|
+
expect(err).toBeInstanceOf(MaterializationEligibilityError);
|
|
213
|
+
expect((err as Error).message).toContain("'daily'");
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
153
218
|
describe("computeSourceEntityId", () => {
|
|
154
219
|
it("delegates to PersistSource.makeBuildId with the connection digest and SQL", () => {
|
|
155
220
|
const makeBuildId = sinon.stub().returns("computed-id");
|
|
@@ -418,10 +483,68 @@ describe("compilePackageBuildPlan", () => {
|
|
|
418
483
|
getModelRuntime.restore();
|
|
419
484
|
}
|
|
420
485
|
});
|
|
486
|
+
|
|
487
|
+
it("skips a model lacking ##! experimental.persistence instead of aborting the package", async () => {
|
|
488
|
+
// getBuildPlan() THROWS on a model without the flag (it does not return
|
|
489
|
+
// empty), so a header-less non-persist model — e.g. an imported base that
|
|
490
|
+
// only defines raw sources — must be skipped, or it would abort the whole
|
|
491
|
+
// package plan and drop the persist source in the sibling model.
|
|
492
|
+
const fakeModel = (hasFlag: boolean, graphs: MalloyBuildGraph[]) => ({
|
|
493
|
+
modelAnnotations: {
|
|
494
|
+
parseAsTag: () => ({ tag: { has: () => hasFlag } }),
|
|
495
|
+
},
|
|
496
|
+
getBuildPlan: () => {
|
|
497
|
+
if (!hasFlag) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
"Model must have ##! experimental.persistence to use getBuildPlan()",
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
return { graphs, sources: {}, tagParseLog: [] };
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
const models: Record<string, ReturnType<typeof fakeModel>> = {
|
|
506
|
+
"base.malloy": fakeModel(false, []), // header-less: must be skipped
|
|
507
|
+
"agg.malloy": fakeModel(true, [
|
|
508
|
+
{
|
|
509
|
+
connectionName: "duckdb",
|
|
510
|
+
nodes: [[{ sourceID: "daily@agg", dependsOn: [] }]],
|
|
511
|
+
},
|
|
512
|
+
] as unknown as MalloyBuildGraph[]),
|
|
513
|
+
};
|
|
514
|
+
const getModelRuntime = sinon
|
|
515
|
+
.stub(Model, "getModelRuntime")
|
|
516
|
+
.callsFake((async (_path: unknown, modelPath: unknown) => ({
|
|
517
|
+
runtime: {
|
|
518
|
+
loadModel: () => ({
|
|
519
|
+
getModel: async () => models[modelPath as string],
|
|
520
|
+
}),
|
|
521
|
+
},
|
|
522
|
+
modelURL: new URL(`file:///${modelPath}`),
|
|
523
|
+
importBaseURL: new URL("file:///"),
|
|
524
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
525
|
+
})) as any);
|
|
526
|
+
try {
|
|
527
|
+
const pkg = {
|
|
528
|
+
getModelPaths: () => ["base.malloy", "agg.malloy"],
|
|
529
|
+
getPackagePath: () => "/test",
|
|
530
|
+
getMalloyConfig: () => ({}),
|
|
531
|
+
getMalloyConnection: async () => ({ getDigest: async () => "dig" }),
|
|
532
|
+
} as unknown as Parameters<typeof compilePackageBuildPlan>[0];
|
|
533
|
+
|
|
534
|
+
// Would throw here before the guard (base.malloy's getBuildPlan).
|
|
535
|
+
const compiled = await compilePackageBuildPlan(pkg);
|
|
536
|
+
|
|
537
|
+
// The header-less model is skipped; the persist source in agg survives.
|
|
538
|
+
expect(compiled.graphs).toHaveLength(1);
|
|
539
|
+
expect(compiled.graphs[0].nodes[0][0].sourceID).toBe("daily@agg");
|
|
540
|
+
} finally {
|
|
541
|
+
getModelRuntime.restore();
|
|
542
|
+
}
|
|
543
|
+
});
|
|
421
544
|
});
|
|
422
545
|
|
|
423
546
|
describe("computePackageBuildPlan", () => {
|
|
424
|
-
it("returns null when the package declares no persist sources", async () => {
|
|
547
|
+
it("returns a null plan and no dropped sources when the package declares no persist sources", async () => {
|
|
425
548
|
const pkg = {
|
|
426
549
|
getModelPaths: () => [],
|
|
427
550
|
getPackagePath: () => "/test",
|
|
@@ -429,6 +552,9 @@ describe("computePackageBuildPlan", () => {
|
|
|
429
552
|
getMalloyConnection: async () => ({}),
|
|
430
553
|
} as unknown as Parameters<typeof computePackageBuildPlan>[0];
|
|
431
554
|
|
|
432
|
-
|
|
555
|
+
const { plan, droppedPersistSources } =
|
|
556
|
+
await computePackageBuildPlan(pkg);
|
|
557
|
+
expect(plan).toBeNull();
|
|
558
|
+
expect(droppedPersistSources).toEqual([]);
|
|
433
559
|
});
|
|
434
560
|
});
|
|
@@ -6,12 +6,19 @@ import type {
|
|
|
6
6
|
Connection as MalloyConnection,
|
|
7
7
|
PersistSource,
|
|
8
8
|
} from "@malloydata/malloy";
|
|
9
|
+
import { Annotations } from "@malloydata/malloy";
|
|
9
10
|
import { components } from "../api";
|
|
11
|
+
import { MaterializationEligibilityError } from "../errors";
|
|
10
12
|
import { MODEL_FILE_SUFFIX } from "../constants";
|
|
11
13
|
import { logger } from "../logger";
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
recordConnectionDigestSkipped,
|
|
16
|
+
recordEligibilityRefused,
|
|
17
|
+
} from "../materialization_metrics";
|
|
13
18
|
import { errMessage } from "../utils";
|
|
19
|
+
import { assertMaterializationEligible } from "./materialization_eligibility";
|
|
14
20
|
import { Model } from "./model";
|
|
21
|
+
import { quoteIdentifier } from "./quoting";
|
|
15
22
|
|
|
16
23
|
type WireBuildGraph = components["schemas"]["BuildGraph"];
|
|
17
24
|
type WirePersistSourcePlan = components["schemas"]["PersistSourcePlan"];
|
|
@@ -74,6 +81,15 @@ export interface CompiledBuildPlan {
|
|
|
74
81
|
* Optional so existing fixtures/callers that don't track it still typecheck.
|
|
75
82
|
*/
|
|
76
83
|
sourceModelPaths?: Record<string, string>;
|
|
84
|
+
/**
|
|
85
|
+
* Sources carrying a `#@ persist` annotation that Malloy's getBuildPlan() did
|
|
86
|
+
* NOT recognize as a materializable build root, so they produced no plan entry
|
|
87
|
+
* and would otherwise be a silent no-op (served live, never materialized). See
|
|
88
|
+
* {@link detectDroppedPersistSources}. Callers surface these: a load-time
|
|
89
|
+
* warning and a hard build failure, so a persist annotation is never silently
|
|
90
|
+
* dropped.
|
|
91
|
+
*/
|
|
92
|
+
droppedPersistSources?: { name: string; modelPath: string }[];
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
/** Output columns of a persist source, degrading to [] if unavailable. */
|
|
@@ -94,6 +110,85 @@ export function deriveColumns(persistSource: PersistSource): WireColumn[] {
|
|
|
94
110
|
}
|
|
95
111
|
}
|
|
96
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A source's PUBLIC column names, or a throw if they can't be determined. The
|
|
115
|
+
* strict counterpart to {@link deriveColumns}, which degrades to `[]` because its
|
|
116
|
+
* caller (the wire build plan) only reports columns. Here the list decides what
|
|
117
|
+
* gets WRITTEN, so "unknown" must not read as "nothing to narrow".
|
|
118
|
+
*
|
|
119
|
+
* A name is returned only for a field that is demonstrably a public atomic field;
|
|
120
|
+
* an unreadable field list, an unreadable individual field, or an empty result all
|
|
121
|
+
* throw. Empty is a throw rather than a pass-through because a source with no
|
|
122
|
+
* public atomic columns means everything `getSQL` projects is hidden — the worst
|
|
123
|
+
* case to materialize, not a benign one.
|
|
124
|
+
*/
|
|
125
|
+
function publicColumnNames(persistSource: PersistSource): string[] {
|
|
126
|
+
let names: string[];
|
|
127
|
+
try {
|
|
128
|
+
names = persistSource._explore.intrinsicFields
|
|
129
|
+
.filter((f) => f.isAtomicField())
|
|
130
|
+
.map((f) => f.name)
|
|
131
|
+
.filter((n): n is string => typeof n === "string" && n.length > 0);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`the compiled source's field list could not be read (${errMessage(err)})`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (names.length === 0) {
|
|
138
|
+
throw new Error("the compiled source exposes no public atomic columns");
|
|
139
|
+
}
|
|
140
|
+
return names;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Wrap a source's build SQL to project only its PUBLIC columns. `getSQL` emits
|
|
145
|
+
* every underlying column, including ones the source hides (`except:`, non-public
|
|
146
|
+
* access modifiers); this narrows the physical table to the public surface.
|
|
147
|
+
*
|
|
148
|
+
* A `storage=` build must not materialize a hidden column. Reachability THROUGH
|
|
149
|
+
* the source is separately bounded by the declared serve shape (see
|
|
150
|
+
* `narrowSchemaToPublic`, and the `shape-bounds-physical-columns` scenario that
|
|
151
|
+
* proves a physical column absent from the shape does not resolve). What this
|
|
152
|
+
* projection prevents is the hidden column's values sitting AT REST in the
|
|
153
|
+
* destination store, where direct catalog access reaches them and where the data
|
|
154
|
+
* may have crossed a trust boundary the source's visibility rules were meant to
|
|
155
|
+
* hold. Applies to BOTH storage build paths (the warehouse-passthrough
|
|
156
|
+
* single-source build and the chained "stack on the parent" downstream build).
|
|
157
|
+
*
|
|
158
|
+
* Fails CLOSED: if the public surface can't be determined the build is refused,
|
|
159
|
+
* rather than widening to everything `getSQL` projects. This matches the rest of
|
|
160
|
+
* the eligibility surface (see `assertMaterializationEligible`), and it fires
|
|
161
|
+
* before any warehouse or destination SQL runs, so a refusal writes nothing and
|
|
162
|
+
* leaves the previous generation serving.
|
|
163
|
+
*
|
|
164
|
+
* @throws {MaterializationEligibilityError} (HTTP 422) naming the source.
|
|
165
|
+
*/
|
|
166
|
+
export function projectToPublicColumns(
|
|
167
|
+
persistSource: PersistSource,
|
|
168
|
+
buildSQL: string,
|
|
169
|
+
): string {
|
|
170
|
+
let cols: string[];
|
|
171
|
+
try {
|
|
172
|
+
cols = publicColumnNames(persistSource);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
recordEligibilityRefused("public_surface_unknown");
|
|
175
|
+
throw new MaterializationEligibilityError({
|
|
176
|
+
message:
|
|
177
|
+
`Source '${persistSource.name}' cannot be materialized into a ` +
|
|
178
|
+
`storage destination: its public column surface could not be ` +
|
|
179
|
+
`determined — ${errMessage(err)}. The publisher narrows a stored ` +
|
|
180
|
+
`table to the source's public columns, so it refuses the build ` +
|
|
181
|
+
`rather than materialize columns the source hides. This usually ` +
|
|
182
|
+
`means the compiled-source shape changed; drop 'storage=' to serve ` +
|
|
183
|
+
`this source live in the meantime.`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
const projection = cols
|
|
187
|
+
.map((n) => quoteIdentifier(n, persistSource.dialectName))
|
|
188
|
+
.join(", ");
|
|
189
|
+
return `SELECT ${projection} FROM (${buildSQL}) AS __public`;
|
|
190
|
+
}
|
|
191
|
+
|
|
97
192
|
/**
|
|
98
193
|
* All key=value fields of a source's `#@ persist` annotation (e.g.
|
|
99
194
|
* `{ name: "engaged_events", realization: "COPY" }`), degrading to {} if the
|
|
@@ -320,6 +415,56 @@ export async function resolvePackageConnections(
|
|
|
320
415
|
* connections. The build plan is a pure function of the compiled model plus
|
|
321
416
|
* connection config (no warehouse access).
|
|
322
417
|
*/
|
|
418
|
+
/**
|
|
419
|
+
* Names of sources in a compiled model that carry a `#@ persist` annotation but
|
|
420
|
+
* are absent from the model's build plan — i.e. Malloy's getBuildPlan() did not
|
|
421
|
+
* recognize them as a materializable build root and silently returned no graph
|
|
422
|
+
* for them.
|
|
423
|
+
*
|
|
424
|
+
* BACKSTOP for a Malloy getBuildPlan() gap: it silently returns no graph for a
|
|
425
|
+
* `#@ persist` source whose shape it doesn't treat as a build root (observed for
|
|
426
|
+
* a filtered pass-through `X is <table> extend { where … }`, which stays type
|
|
427
|
+
* `table`; only query-shaped sources are treated as roots). Without this check
|
|
428
|
+
* the annotation is a silent no-op (served live, no build, no error). The primary
|
|
429
|
+
* fix is in Malloy (recognize the shape or emit a diagnostic); until then, detect
|
|
430
|
+
* the annotated-but-absent source here so callers can refuse loudly.
|
|
431
|
+
*
|
|
432
|
+
* Reads the annotation exactly as Malloy's own checkPersistAnnotation does
|
|
433
|
+
* (`Annotations(def.annotations).parseAsTag('@').tag.has('persist')`). Best-effort
|
|
434
|
+
* and fail-open: any introspection failure yields no dropped names rather than
|
|
435
|
+
* risking a false positive that would wrongly fail a healthy build.
|
|
436
|
+
*/
|
|
437
|
+
function detectDroppedPersistSources(
|
|
438
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
439
|
+
malloyModel: any,
|
|
440
|
+
recognizedNames: Set<string>,
|
|
441
|
+
): string[] {
|
|
442
|
+
const dropped: string[] = [];
|
|
443
|
+
try {
|
|
444
|
+
const contents = malloyModel?._modelDef?.contents as
|
|
445
|
+
| Record<string, { type?: string; annotations?: unknown }>
|
|
446
|
+
| undefined;
|
|
447
|
+
if (!contents) return dropped;
|
|
448
|
+
for (const [name, def] of Object.entries(contents)) {
|
|
449
|
+
if (recognizedNames.has(name)) continue;
|
|
450
|
+
if (!def || !def.annotations) continue;
|
|
451
|
+
let isPersist = false;
|
|
452
|
+
try {
|
|
453
|
+
isPersist = new Annotations(def.annotations)
|
|
454
|
+
.parseAsTag("@")
|
|
455
|
+
.tag.has("persist");
|
|
456
|
+
} catch {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (isPersist) dropped.push(name);
|
|
460
|
+
}
|
|
461
|
+
} catch {
|
|
462
|
+
// Fail open: never let introspection break the build plan.
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
return dropped;
|
|
466
|
+
}
|
|
467
|
+
|
|
323
468
|
export async function compilePackageBuildPlan(
|
|
324
469
|
pkg: BuildPlanPackage,
|
|
325
470
|
signal?: AbortSignal,
|
|
@@ -327,6 +472,7 @@ export async function compilePackageBuildPlan(
|
|
|
327
472
|
const allGraphs: MalloyBuildGraph[] = [];
|
|
328
473
|
const allSources: Record<string, PersistSource> = {};
|
|
329
474
|
const sourceModelPaths: Record<string, string> = {};
|
|
475
|
+
const droppedPersistSources: { name: string; modelPath: string }[] = [];
|
|
330
476
|
|
|
331
477
|
for (const modelPath of pkg.getModelPaths()) {
|
|
332
478
|
// Only `.malloy` models declare persist sources. Skip `.malloynb`
|
|
@@ -345,8 +491,18 @@ export async function compilePackageBuildPlan(
|
|
|
345
491
|
.loadModel(modelURL, { importBaseURL })
|
|
346
492
|
.getModel();
|
|
347
493
|
|
|
348
|
-
// getBuildPlan()
|
|
349
|
-
//
|
|
494
|
+
// getBuildPlan() THROWS "Model must have ##! experimental.persistence"
|
|
495
|
+
// on any model that lacks the flag — it does NOT return empty. So a
|
|
496
|
+
// header-less non-persist model in the package (e.g. an imported base
|
|
497
|
+
// model that only defines raw sources) would abort the entire package
|
|
498
|
+
// build plan, silently dropping every persist source in every other
|
|
499
|
+
// model. Mirror Malloy's own guard and skip such models: without the flag
|
|
500
|
+
// a model cannot carry a functioning persist source anyway. Models that
|
|
501
|
+
// DO have the flag but declare no persist source return empty graphs and
|
|
502
|
+
// are skipped by the `graphs.length === 0` check below.
|
|
503
|
+
const modelTag = malloyModel.modelAnnotations.parseAsTag("!").tag;
|
|
504
|
+
if (!modelTag.has("experimental", "persistence")) continue;
|
|
505
|
+
|
|
350
506
|
const buildPlan = malloyModel.getBuildPlan();
|
|
351
507
|
for (const msg of buildPlan.tagParseLog) {
|
|
352
508
|
logger.warn("Persist annotation issue", {
|
|
@@ -355,6 +511,20 @@ export async function compilePackageBuildPlan(
|
|
|
355
511
|
severity: msg.severity,
|
|
356
512
|
});
|
|
357
513
|
}
|
|
514
|
+
|
|
515
|
+
// Detect `#@ persist` sources the plan didn't recognize (see
|
|
516
|
+
// detectDroppedPersistSources). Runs BEFORE the empty-graphs `continue`, so
|
|
517
|
+
// a model whose ONLY persist source is a dropped shape is still caught.
|
|
518
|
+
const recognizedNames = new Set(
|
|
519
|
+
Object.values(buildPlan.sources).map((s) => s.name),
|
|
520
|
+
);
|
|
521
|
+
for (const name of detectDroppedPersistSources(
|
|
522
|
+
malloyModel,
|
|
523
|
+
recognizedNames,
|
|
524
|
+
)) {
|
|
525
|
+
droppedPersistSources.push({ name, modelPath });
|
|
526
|
+
}
|
|
527
|
+
|
|
358
528
|
if (buildPlan.graphs.length === 0) continue;
|
|
359
529
|
|
|
360
530
|
allGraphs.push(...buildPlan.graphs);
|
|
@@ -396,6 +566,7 @@ export async function compilePackageBuildPlan(
|
|
|
396
566
|
connectionDigests,
|
|
397
567
|
connections,
|
|
398
568
|
sourceModelPaths,
|
|
569
|
+
droppedPersistSources,
|
|
399
570
|
};
|
|
400
571
|
}
|
|
401
572
|
|
|
@@ -451,24 +622,75 @@ export function deriveBuildPlan(
|
|
|
451
622
|
}
|
|
452
623
|
|
|
453
624
|
/**
|
|
454
|
-
* Compile and project a package's build plan
|
|
455
|
-
*
|
|
456
|
-
*
|
|
625
|
+
* Compile and project a package's build plan (null when the package declares no
|
|
626
|
+
* materializable persist source), plus any `#@ persist` sources that were
|
|
627
|
+
* silently dropped from the plan (see {@link detectDroppedPersistSources}) so
|
|
628
|
+
* the caller can surface a load-time warning. A deterministic property of the
|
|
629
|
+
* compiled package; feeds the read-only `Package.buildPlan` field.
|
|
457
630
|
*/
|
|
458
631
|
export async function computePackageBuildPlan(
|
|
459
632
|
pkg: BuildPlanPackage,
|
|
460
633
|
signal?: AbortSignal,
|
|
461
|
-
): Promise<
|
|
634
|
+
): Promise<{
|
|
635
|
+
plan: BuildPlan | null;
|
|
636
|
+
droppedPersistSources: { name: string; modelPath: string }[];
|
|
637
|
+
sourceEligibility: SourceEligibility;
|
|
638
|
+
}> {
|
|
462
639
|
const compiled = await compilePackageBuildPlan(pkg, signal);
|
|
463
|
-
|
|
464
|
-
|
|
640
|
+
const droppedPersistSources = compiled.droppedPersistSources ?? [];
|
|
641
|
+
const plan =
|
|
642
|
+
compiled.graphs.length === 0
|
|
643
|
+
? null
|
|
644
|
+
: deriveBuildPlan(
|
|
645
|
+
compiled.graphs,
|
|
646
|
+
compiled.sources,
|
|
647
|
+
compiled.connectionDigests,
|
|
648
|
+
undefined,
|
|
649
|
+
compiled.sourceModelPaths,
|
|
650
|
+
pkg.getMaterializationConfig?.() ?? null,
|
|
651
|
+
);
|
|
652
|
+
return {
|
|
653
|
+
plan,
|
|
654
|
+
droppedPersistSources,
|
|
655
|
+
sourceEligibility: collectSourceEligibility(compiled.sources),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Which sources may be served from a materialized table, decided HERE because
|
|
661
|
+
* this is where the compiled sources exist. The serve side needs the answer
|
|
662
|
+
* without them: binding a host's manifest deliberately skips the recompile, so it
|
|
663
|
+
* cannot re-derive eligibility and would otherwise have to take the host's word
|
|
664
|
+
* for it.
|
|
665
|
+
*
|
|
666
|
+
* Carries the ELIGIBLE names, not just the refusals, so the serve side can
|
|
667
|
+
* require a positive result. Absence is not eligibility: Malloy admits only
|
|
668
|
+
* query-shaped sources as build roots, so a `#@ persist` on a filtered
|
|
669
|
+
* pass-through (`X is <table> extend { where … }`, which stays type `table`)
|
|
670
|
+
* never reaches this record at all — and that is the row-level-access shape. A
|
|
671
|
+
* refusal-only view would read that silence as consent.
|
|
672
|
+
*
|
|
673
|
+
* Keyed by source NAME because that is what a serve binding carries.
|
|
674
|
+
*/
|
|
675
|
+
export type SourceEligibility = {
|
|
676
|
+
/** Sources compiled, examined, and found eligible. */
|
|
677
|
+
eligible: string[];
|
|
678
|
+
/** Source name -> why it was refused; for the operator-facing log. */
|
|
679
|
+
refused: Record<string, string>;
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
function collectSourceEligibility(
|
|
683
|
+
sources: Record<string, PersistSource>,
|
|
684
|
+
): SourceEligibility {
|
|
685
|
+
const eligible: string[] = [];
|
|
686
|
+
const refused: Record<string, string> = {};
|
|
687
|
+
for (const source of Object.values(sources)) {
|
|
688
|
+
try {
|
|
689
|
+
assertMaterializationEligible(source);
|
|
690
|
+
eligible.push(source.name);
|
|
691
|
+
} catch (err) {
|
|
692
|
+
refused[source.name] = errMessage(err);
|
|
693
|
+
}
|
|
465
694
|
}
|
|
466
|
-
return
|
|
467
|
-
compiled.graphs,
|
|
468
|
-
compiled.sources,
|
|
469
|
-
compiled.connectionDigests,
|
|
470
|
-
undefined,
|
|
471
|
-
compiled.sourceModelPaths,
|
|
472
|
-
pkg.getMaterializationConfig?.() ?? null,
|
|
473
|
-
);
|
|
695
|
+
return { eligible, refused };
|
|
474
696
|
}
|