@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.
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
@@ -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
- expect(await computePackageBuildPlan(pkg)).toBeNull();
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 { recordConnectionDigestSkipped } from "../materialization_metrics";
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() returns empty graphs for models with no #@ persist
349
- // sources, so non-persist models are simply skipped below.
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, or null when the package
455
- * declares no persist source. Convenience for the read-only `Package.buildPlan`
456
- * field, which is a deterministic property of the compiled package.
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<BuildPlan | null> {
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
- if (compiled.graphs.length === 0) {
464
- return null;
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 deriveBuildPlan(
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
  }