@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.
Files changed (101) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +135 -13
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
  14. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  15. package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
  16. package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
  17. package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
  18. package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
  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 +22627 -1260
  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/controller/query.controller.ts +26 -21
  32. package/src/errors.ts +19 -0
  33. package/src/json_utils.spec.ts +51 -0
  34. package/src/json_utils.ts +33 -0
  35. package/src/logger.spec.ts +18 -1
  36. package/src/logger.ts +3 -1
  37. package/src/materialization_metrics.spec.ts +89 -4
  38. package/src/materialization_metrics.ts +155 -5
  39. package/src/mcp/query_envelope.spec.ts +229 -0
  40. package/src/mcp/query_envelope.ts +230 -0
  41. package/src/mcp/server.protocol.spec.ts +128 -16
  42. package/src/mcp/skills/build_skills_bundle.ts +94 -4
  43. package/src/mcp/skills/skills_bundle.json +1 -1
  44. package/src/mcp/skills/skills_bundle.spec.ts +113 -4
  45. package/src/mcp/tool_response.spec.ts +108 -0
  46. package/src/mcp/tool_response.ts +138 -0
  47. package/src/mcp/tools/compile_tool.spec.ts +112 -4
  48. package/src/mcp/tools/compile_tool.ts +61 -30
  49. package/src/mcp/tools/docs_search_tool.ts +6 -16
  50. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  51. package/src/mcp/tools/embedding_index.ts +808 -0
  52. package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
  53. package/src/mcp/tools/execute_query_tool.ts +90 -151
  54. package/src/mcp/tools/get_context_eval.ts +194 -45
  55. package/src/mcp/tools/get_context_tool.spec.ts +358 -5
  56. package/src/mcp/tools/get_context_tool.ts +202 -56
  57. package/src/mcp/tools/reload_package_tool.ts +3 -29
  58. package/src/pg_helpers.spec.ts +201 -0
  59. package/src/pg_helpers.ts +44 -5
  60. package/src/server.ts +24 -0
  61. package/src/service/build_plan.spec.ts +128 -2
  62. package/src/service/build_plan.ts +239 -17
  63. package/src/service/compile_fragment_techniques.spec.ts +156 -0
  64. package/src/service/connection.spec.ts +371 -1
  65. package/src/service/connection.ts +339 -20
  66. package/src/service/connection_config.spec.ts +108 -0
  67. package/src/service/connection_config.ts +47 -8
  68. package/src/service/connection_federation.spec.ts +184 -0
  69. package/src/service/duckdb_instance_isolation.spec.ts +137 -0
  70. package/src/service/embedding_provider.spec.ts +329 -0
  71. package/src/service/embedding_provider.ts +236 -0
  72. package/src/service/environment.ts +274 -12
  73. package/src/service/environment_store.spec.ts +678 -3
  74. package/src/service/environment_store.ts +449 -33
  75. package/src/service/environment_store_clone.spec.ts +350 -0
  76. package/src/service/manifest_loader.spec.ts +68 -13
  77. package/src/service/manifest_loader.ts +67 -19
  78. package/src/service/materialization_build_session.spec.ts +435 -0
  79. package/src/service/materialization_build_session.ts +681 -0
  80. package/src/service/materialization_eligibility.spec.ts +158 -0
  81. package/src/service/materialization_eligibility.ts +305 -0
  82. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  83. package/src/service/materialization_serve_transform.ts +779 -0
  84. package/src/service/materialization_service.spec.ts +774 -7
  85. package/src/service/materialization_service.ts +1107 -42
  86. package/src/service/materialization_test_fixtures.ts +7 -0
  87. package/src/service/model.spec.ts +207 -0
  88. package/src/service/model.ts +569 -59
  89. package/src/service/model_limits.spec.ts +28 -0
  90. package/src/service/model_limits.ts +21 -0
  91. package/src/service/model_storage_serve.spec.ts +193 -0
  92. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  93. package/src/service/package.spec.ts +196 -0
  94. package/src/service/package.ts +385 -17
  95. package/src/service/persistence_policy.spec.ts +109 -0
  96. package/src/storage/duckdb/schema.ts +37 -0
  97. package/tests/fixtures/xlsx/database.xlsx +0 -0
  98. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  99. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  100. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
  101. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
@@ -0,0 +1,779 @@
1
+ import {
2
+ InMemoryURLReader,
3
+ type LookupConnection,
4
+ type Connection as MalloyConnection,
5
+ Runtime,
6
+ } from "@malloydata/malloy";
7
+ import { MaterializationEligibilityError } from "../errors";
8
+ import { logger } from "../logger";
9
+ import {
10
+ recordEligibilityRefused,
11
+ recordServeShapeTypeFallback,
12
+ } from "../materialization_metrics";
13
+ import type { ManifestEntry } from "../storage/DatabaseInterface";
14
+ import { quoteManifestTablePath } from "./quoting";
15
+
16
+ /**
17
+ * The per-source serve pointer the virtual-source serve transform consumes. Two
18
+ * origins, one shape (the "injectable binding seam"): the publisher self-derives
19
+ * it from a build's manifest entry (standalone), or a host
20
+ * supplies it. `tablePath` is the LOGICAL, unquoted physical table path; `schema`
21
+ * is the AUTHORITATIVE DuckDB column schema captured post-build (raw DuckDB type
22
+ * strings), which the transform declares verbatim — see {@link buildServeShapeModel}.
23
+ */
24
+ /**
25
+ * A dimension or measure defined on the materialized source in the author's
26
+ * model, re-declared on the serve shape's virtual base so it is computed at
27
+ * serve time from the stored columns rather than forcing a live fallback.
28
+ * `code` is the original Malloy expression text.
29
+ */
30
+ export interface FieldRefinement {
31
+ kind: "dimension" | "measure";
32
+ name: string;
33
+ code: string;
34
+ }
35
+
36
+ /**
37
+ * A join defined on the materialized source in the author's model, re-declared
38
+ * on the serve shape's virtual base so a query traversing it serves from the
39
+ * materialized tables (DuckDB runs the join over the stored tables) instead of
40
+ * falling back to live.
41
+ *
42
+ * A join is only re-emitted when its joined source is ITSELF materialized (has a
43
+ * binding) — the serve shape is one model, so a join to a source that is not a
44
+ * sibling virtual source would fail to compile the WHOLE shape and disable
45
+ * storage serving for the entire package (see {@link extractJoins}'s gate). The
46
+ * `text` is the author's verbatim join declaration (`<alias> is <source> [on|
47
+ * with ...]`), lifted from the source by location — the on-condition is an
48
+ * arbitrary expression, so lifting text carries any condition for free, whereas
49
+ * reconstructing it from the compiled expression tree would not.
50
+ */
51
+ export interface JoinRefinement {
52
+ kind: "join";
53
+ /** The join alias (`join_one: <alias> is ...`); used for logging/ordering. */
54
+ name: string;
55
+ /** The join keyword to emit, from the compiled join relationship. */
56
+ keyword: "join_one" | "join_many" | "join_cross";
57
+ /** Verbatim author declaration `<alias> is <source> [on|with ...]`. */
58
+ text: string;
59
+ /** The joined source's author name; emitted only when it is materialized. */
60
+ dependsOn: string;
61
+ }
62
+
63
+ /**
64
+ * A view (turtle) defined on the materialized source in the author's model,
65
+ * re-declared on the serve shape so a query invoking it by name serves from the
66
+ * materialized tables. A view is a nested query pipeline, not a single
67
+ * expression, so — like a join — its declaration `text` is lifted verbatim from
68
+ * the author's source by location rather than reconstructed.
69
+ *
70
+ * A view has no cheap dependency gate (it can reference any of the source's
71
+ * columns, dimensions, measures, and joins): it is emitted optimistically and,
72
+ * if the resulting shape does not compile (it reaches a refinement not carried —
73
+ * a join to a non-materialized source, a nested view), the serve path drops the
74
+ * view category and falls back for those queries (see the shape-compile
75
+ * escalation in the model's serve path).
76
+ */
77
+ export interface ViewRefinement {
78
+ kind: "view";
79
+ /** The view name (`view: <name> is { ... }`); used for logging/ordering. */
80
+ name: string;
81
+ /** Verbatim author declaration `<name> is { ... }`. */
82
+ text: string;
83
+ }
84
+
85
+ /**
86
+ * A refinement to re-declare on the serve shape's virtual base: a dimension or
87
+ * measure ({@link FieldRefinement}), a join ({@link JoinRefinement}), or a view
88
+ * ({@link ViewRefinement}).
89
+ */
90
+ export type SourceRefinement =
91
+ | FieldRefinement
92
+ | JoinRefinement
93
+ | ViewRefinement;
94
+
95
+ export interface ServeBinding {
96
+ /** The Malloy source name to rebind (`source: <sourceName> is ...`). */
97
+ sourceName: string;
98
+ /** The DuckDB/DuckLake connection the physical table lives in. */
99
+ connectionName: string;
100
+ /** The virtualMap handle for this source (its build-posture identity). */
101
+ virtualHandle: string;
102
+ /** Logical (unquoted) physical table path; quoted for DuckDB at bind time. */
103
+ tablePath: string;
104
+ /** Authoritative DuckDB columns captured post-build (raw DuckDB types). */
105
+ schema: { name: string; type: string }[];
106
+ /**
107
+ * Refinements defined on the source in the author's model, re-emitted as an
108
+ * `extend {}` on the virtual base so queries using them serve from the
109
+ * materialized tables instead of falling back to live: dimensions/measures
110
+ * (computed over the stored columns), joins whose target is also materialized
111
+ * (the join runs over the stored tables), and views (turtles) reproducible
112
+ * from those. Analytic source-fields are still not carried — a query using
113
+ * one falls back. Attached at serve time from the compiled model, not the
114
+ * build.
115
+ */
116
+ refinements?: SourceRefinement[];
117
+ /** Optional freshness anchor (data-as-of instant); carried through verbatim. */
118
+ freshAsOf?: string;
119
+ /**
120
+ * Optional freshness window + fallback, carried through verbatim so the serve
121
+ * path can gate this binding per query the SAME way the colocated
122
+ * serve does — a stale binding whose fallback is `live`/`fail` is dropped from
123
+ * the serve shape and served live; `stale_ok` (or un-gated) keeps serving the
124
+ * materialized table. Placement (`storage=`) is orthogonal to freshness.
125
+ */
126
+ freshnessWindowSeconds?: number;
127
+ freshnessFallback?: "live" | "stale_ok" | "fail";
128
+ }
129
+
130
+ /**
131
+ * Derive the publisher's self-maintained serve bindings from a build's manifest
132
+ * entries — the standalone half of the injectable binding seam (a host/control
133
+ * plane can supply {@link ServeBinding}s directly instead). Only entries that
134
+ * were materialized into a storage destination (carrying `storageConnectionName`
135
+ * and a captured `schema`) produce a binding; colocated entries do
136
+ * not (they serve through the same-connection manifest, not the transform).
137
+ *
138
+ * The virtual handle is the source's build-posture **content identity**
139
+ * (`sourceEntityId`): identity-scoped so two imports of the same source dedup to
140
+ * one shared virtual table, and stable across generations — the generation lives
141
+ * in the mapped table path (`physicalTableName`), not the handle. This is the one
142
+ * hard cross-producer contract: whoever supplies the binding (this function, or
143
+ * a host) must key the handle the same way the build did.
144
+ */
145
+ export function deriveServeBindings(
146
+ entries: Record<string, ManifestEntry>,
147
+ ): ServeBinding[] {
148
+ const bindings: ServeBinding[] = [];
149
+ for (const entry of Object.values(entries)) {
150
+ // Need the source name to rebind it, plus a storage destination + table.
151
+ if (
152
+ !entry.sourceName ||
153
+ !entry.storageConnectionName ||
154
+ !entry.physicalTableName
155
+ ) {
156
+ continue;
157
+ }
158
+ // The wire Column has optional name/type; keep only complete columns.
159
+ const schema = (entry.schema ?? [])
160
+ .filter((c) => c.name && c.type)
161
+ .map((c) => ({ name: c.name as string, type: c.type as string }));
162
+ if (schema.length === 0) continue;
163
+ bindings.push({
164
+ sourceName: entry.sourceName,
165
+ connectionName: entry.storageConnectionName,
166
+ virtualHandle: entry.sourceEntityId,
167
+ // Qualify the table with the destination catalog (the attach alias) so
168
+ // the serve reads `<store>.<table>` — the build wrote it there, and an
169
+ // unqualified name would resolve against the serve session's default
170
+ // catalog, not the attached store.
171
+ tablePath: `${entry.storageConnectionName}.${entry.physicalTableName}`,
172
+ schema,
173
+ freshAsOf: entry.dataAsOf,
174
+ freshnessWindowSeconds: entry.freshnessWindowSeconds,
175
+ freshnessFallback: entry.freshnessFallback,
176
+ });
177
+ }
178
+ return bindings;
179
+ }
180
+
181
+ /**
182
+ * Map a DuckDB column type (as reported by `DESCRIBE`) to a Malloy basic type
183
+ * for the serve-shape `type:` declaration.
184
+ *
185
+ * Only Malloy's basic scalar types are legal in a `type:` field
186
+ * (`number` | `string` | `boolean` | `date` | `timestamp` | `json`), so every
187
+ * DuckDB type must collapse onto one. Numeric widths all become `number`;
188
+ * temporal-with-time becomes `timestamp`; anything unrecognized (nested/array/
189
+ * struct/enum/blob) falls back to `json` — a safe, lossy carrier so an exotic
190
+ * column never breaks the whole serve shape — and is logged so the gap is
191
+ * visible. Matching is case-insensitive and ignores precision args (`DECIMAL(18,2)`)
192
+ * and array suffixes (`INTEGER[]`).
193
+ */
194
+ export function duckdbTypeToMalloy(duckdbType: string): string {
195
+ const raw = duckdbType.trim();
196
+ // Arrays / nested collections carry as json (lossy but safe) for now.
197
+ if (/\[\s*\]/.test(raw)) {
198
+ recordServeShapeTypeFallback("array");
199
+ logger.warn(
200
+ "Mapping DuckDB array/collection type to json for serve shape",
201
+ {
202
+ duckdbType: raw,
203
+ },
204
+ );
205
+ return "json";
206
+ }
207
+ // Strip precision/scale args: DECIMAL(18,2) -> DECIMAL, VARCHAR(255) -> VARCHAR.
208
+ const base = raw
209
+ .replace(/\(.*\)/, "")
210
+ .trim()
211
+ .toUpperCase();
212
+
213
+ // Temporal-with-time first (so "TIMESTAMP WITH TIME ZONE" doesn't fall through).
214
+ if (base === "DATE") return "date";
215
+ if (
216
+ base.startsWith("TIMESTAMP") ||
217
+ base === "DATETIME" ||
218
+ base === "TIMESTAMPTZ"
219
+ ) {
220
+ return "timestamp";
221
+ }
222
+ if (base === "TIME") {
223
+ // Malloy has no time-of-day scalar; carry as string to preserve the value.
224
+ return "string";
225
+ }
226
+
227
+ switch (base) {
228
+ case "TINYINT":
229
+ case "SMALLINT":
230
+ case "INTEGER":
231
+ case "INT":
232
+ case "INT4":
233
+ case "BIGINT":
234
+ case "INT8":
235
+ case "HUGEINT":
236
+ case "UTINYINT":
237
+ case "USMALLINT":
238
+ case "UINTEGER":
239
+ case "UBIGINT":
240
+ case "UHUGEINT":
241
+ case "DOUBLE":
242
+ case "FLOAT":
243
+ case "FLOAT4":
244
+ case "FLOAT8":
245
+ case "REAL":
246
+ case "DECIMAL":
247
+ case "NUMERIC":
248
+ return "number";
249
+ case "VARCHAR":
250
+ case "TEXT":
251
+ case "CHAR":
252
+ case "BPCHAR":
253
+ case "STRING":
254
+ case "UUID":
255
+ return "string";
256
+ case "BOOLEAN":
257
+ case "BOOL":
258
+ return "boolean";
259
+ default:
260
+ recordServeShapeTypeFallback("unrecognized");
261
+ logger.warn(
262
+ "Unrecognized DuckDB type; mapping to json for serve shape",
263
+ {
264
+ duckdbType: raw,
265
+ },
266
+ );
267
+ return "json";
268
+ }
269
+ }
270
+
271
+ /** A Malloy identifier that needs no quoting. */
272
+ const BARE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
273
+
274
+ /** Emit a field name bare when it is a plain identifier, else backtick-quoted. */
275
+ function emitFieldName(name: string): string {
276
+ return BARE_IDENTIFIER.test(name) ? name : `\`${name.replace(/`/g, "``")}\``;
277
+ }
278
+
279
+ /**
280
+ * Generate the transient serve-shape model text that rebinds a materialized
281
+ * source to a virtual source on its storage connection, declaring the captured
282
+ * authoritative schema.
283
+ *
284
+ * The declared `type:` fields are the schema-authority contract: the compiler
285
+ * does NOT type-check a virtual source's declared columns against the real
286
+ * table, so whatever this declares is trusted — a wrong or approximated schema
287
+ * surfaces only as a serve-time execution error. That is why the binding must
288
+ * carry the post-build DESCRIBE schema and this function declares exactly it.
289
+ *
290
+ * Field syntax is DuckDB user-type double-colon (`name::type`); the source line
291
+ * binds to `<conn>.virtual('<handle>')` with the `::<Shape>` constraint. The
292
+ * `##! experimental.virtual_source` flag is injected on this transient model
293
+ * ONLY — it never touches the author's model.
294
+ */
295
+ export function buildServeShapeModel(
296
+ sourceName: string,
297
+ binding: ServeBinding,
298
+ ): { modelText: string; shapeTypeName: string } {
299
+ const shapeTypeName = `${sourceName}__shape`;
300
+ const fields = binding.schema
301
+ .map((c) => ` ${emitFieldName(c.name)}::${duckdbTypeToMalloy(c.type)}`)
302
+ .join(",\n");
303
+ const modelText = `##! experimental.virtual_source
304
+ type: ${shapeTypeName} is {
305
+ ${fields}
306
+ }
307
+ source: ${sourceName} is ${binding.connectionName}.virtual('${binding.virtualHandle}')::${shapeTypeName}
308
+ `;
309
+ return { modelText, shapeTypeName };
310
+ }
311
+
312
+ /**
313
+ * The `type:` + `source:` fragment that rebinds ONE materialized source to its
314
+ * virtual form (no flag line — callers emit `##! experimental.virtual_source`
315
+ * once for the whole model).
316
+ */
317
+ function serveShapeFragment(binding: ServeBinding): string {
318
+ const shapeTypeName = `${binding.sourceName}__shape`;
319
+ const fields = binding.schema
320
+ .map((c) => ` ${emitFieldName(c.name)}::${duckdbTypeToMalloy(c.type)}`)
321
+ .join(",\n");
322
+ let source =
323
+ `source: ${binding.sourceName} is ` +
324
+ `${binding.connectionName}.virtual('${binding.virtualHandle}')::${shapeTypeName}`;
325
+ // Re-declare the source's refinements on the virtual base so queries that use
326
+ // them are computed from the stored tables at serve time (the wrapper) rather
327
+ // than falling back to live. Emission order matters for resolution: joins
328
+ // first (a dimension/measure/view may reference a joined field), then
329
+ // dimensions/measures, then views (a view may reference any of them).
330
+ // Everything here references the shape's columns, a sibling virtual source, or
331
+ // an earlier refinement; anything it references that the shape lacks makes the
332
+ // serve shape fail to compile, which safely falls back.
333
+ const refinements = binding.refinements ?? [];
334
+ const lines: string[] = [];
335
+ for (const r of refinements) {
336
+ if (r.kind === "join") lines.push(` ${r.keyword}: ${r.text}`);
337
+ }
338
+ for (const r of refinements) {
339
+ if (r.kind === "dimension" || r.kind === "measure") {
340
+ lines.push(` ${r.kind}: ${r.name} is ${r.code}`);
341
+ }
342
+ }
343
+ for (const r of refinements) {
344
+ if (r.kind === "view") lines.push(` view: ${r.text}`);
345
+ }
346
+ if (lines.length > 0) {
347
+ source += ` extend {\n${lines.join("\n")}\n}`;
348
+ }
349
+ return `type: ${shapeTypeName} is {\n${fields}\n}\n${source}`;
350
+ }
351
+
352
+ /**
353
+ * Generate ONE transient serve-shape model rebinding every supplied
354
+ * materialized source to its virtual form — the model the serve path compiles
355
+ * queries against when `PERSIST_STORAGE_MODE=on`. The `##! experimental.virtual_source`
356
+ * flag is emitted once. Each source declares the authoritative captured schema
357
+ * (see {@link buildServeShapeModel} for why the declared schema is trusted on
358
+ * faith and must match the built table).
359
+ *
360
+ * Coverage note: this rebinds each source's BASE to the virtual table with the
361
+ * captured columns, and re-declares the source's dimensions, measures,
362
+ * materialized-target joins, and views (see {@link ServeBinding.refinements}) on
363
+ * top. A query relying on a refinement not carried here (an analytic field, or a
364
+ * join/view that reaches a non-materialized source) does not compile against
365
+ * this model, and the serve path falls back to serving it live.
366
+ */
367
+ export function buildServeShapeModelForBindings(bindings: ServeBinding[]): {
368
+ modelText: string;
369
+ } {
370
+ const fragments = orderBindingsByJoinDeps(bindings)
371
+ .map(serveShapeFragment)
372
+ .join("\n");
373
+ return {
374
+ modelText: `##! experimental.virtual_source\n${fragments}\n`,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Assemble the transient BUILD model for a chained `storage=` source — the
380
+ * "stack on the parent" build. Every materialized upstream is rebound
381
+ * to a virtual source on its storage connection (via the SAME serve-shape rebind
382
+ * as the serve path), so the downstream computes over the parents' STORED lake
383
+ * tables in DuckDB; the downstream source is then re-declared over them and
384
+ * annotated `#@ persist` so it surfaces as a persist source when the caller
385
+ * compiles this model and reads `PersistSource.getSQL({ virtualMap })` (the exact
386
+ * mirror of the warehouse build's `getSQL`, only over rebound parents).
387
+ *
388
+ * `downstreamDefText` is the RHS of the author's `source: <name> is …` statement,
389
+ * lifted verbatim by location (a top-level source's `location.range` starts just
390
+ * after the `source: ` keyword), so this prepends `source: `. The `#@ persist
391
+ * storage=<dest>` annotation only makes the source appear in the transient build
392
+ * plan — the value is not otherwise consumed (the build session already knows the
393
+ * destination), but naming the real destination keeps the model self-consistent.
394
+ *
395
+ * Upstream rebinds here are base-only (the captured schema, no re-emitted
396
+ * dimensions/joins/views): the dominant chained case is a rollup over the
397
+ * parent's stored OUTPUT columns. A downstream that references a parent
398
+ * refinement not carried here fails to compile against this model, and the
399
+ * caller falls back to recompute-from-raw — re-emitting parent
400
+ * refinements to widen coverage is a follow-on.
401
+ */
402
+ export function buildChainedStorageBuildModel(params: {
403
+ upstreams: ServeBinding[];
404
+ downstreamName: string;
405
+ downstreamDefText: string;
406
+ destinationName: string;
407
+ }): string {
408
+ const upstreamFragments = orderBindingsByJoinDeps(params.upstreams)
409
+ .map(serveShapeFragment)
410
+ .join("\n");
411
+ return (
412
+ `##! experimental { persistence virtual_source }\n` +
413
+ `${upstreamFragments}\n` +
414
+ `#@ persist storage=${params.destinationName}\n` +
415
+ `source: ${params.downstreamDefText}\n`
416
+ );
417
+ }
418
+
419
+ /**
420
+ * Order bindings so a joined source is declared before the source that joins it:
421
+ * Malloy resolves `source:` statements top-to-bottom, so a join to a source not
422
+ * yet declared is an "undefined object" error. Only intra-set join dependencies
423
+ * are ordered (a join is emitted only when its target is in the set anyway).
424
+ * Stable: independent sources keep their original order. On a dependency cycle
425
+ * (mutual joins, which cannot be single-pass forward-referenced), the remaining
426
+ * sources are appended in original order — the resulting shape fails to compile
427
+ * and the caller drops joins / falls back, rather than looping.
428
+ */
429
+ function orderBindingsByJoinDeps(bindings: ServeBinding[]): ServeBinding[] {
430
+ const present = new Set(bindings.map((b) => b.sourceName));
431
+ const dependsOn = new Map<string, Set<string>>();
432
+ for (const b of bindings) {
433
+ const set = new Set<string>();
434
+ for (const r of b.refinements ?? []) {
435
+ if (
436
+ r.kind === "join" &&
437
+ r.dependsOn !== b.sourceName &&
438
+ present.has(r.dependsOn)
439
+ ) {
440
+ set.add(r.dependsOn);
441
+ }
442
+ }
443
+ dependsOn.set(b.sourceName, set);
444
+ }
445
+ const ordered: ServeBinding[] = [];
446
+ const emitted = new Set<string>();
447
+ const remaining = new Set(present);
448
+ while (remaining.size > 0) {
449
+ let progressed = false;
450
+ for (const b of bindings) {
451
+ if (!remaining.has(b.sourceName)) continue;
452
+ const ready = [...dependsOn.get(b.sourceName)!].every((d) =>
453
+ emitted.has(d),
454
+ );
455
+ if (ready) {
456
+ ordered.push(b);
457
+ emitted.add(b.sourceName);
458
+ remaining.delete(b.sourceName);
459
+ progressed = true;
460
+ }
461
+ }
462
+ if (!progressed) {
463
+ for (const b of bindings) {
464
+ if (remaining.has(b.sourceName)) {
465
+ ordered.push(b);
466
+ remaining.delete(b.sourceName);
467
+ }
468
+ }
469
+ }
470
+ }
471
+ return ordered;
472
+ }
473
+
474
+ /**
475
+ * Extract a materialized source's re-emittable refinements — the dimensions and
476
+ * measures defined on it in the author's model — from its compiled field list,
477
+ * so they can be re-declared on the serve shape's virtual base.
478
+ *
479
+ * A derived field carries its original expression as `code` and an
480
+ * `expressionType`; the source's raw output columns have neither (they are the
481
+ * stored columns, already in the shape's `::` type). Scalar → dimension,
482
+ * aggregate → measure. Joins are handled separately ({@link extractJoins});
483
+ * views (turtles) and analytic/calculation fields are deliberately skipped —
484
+ * re-emitting them is out of scope, and a query that uses one simply falls back
485
+ * to live (safe).
486
+ */
487
+ /**
488
+ * Whether a compiled field carries a non-public access modifier
489
+ * (`private`/`internal`). Such a field is hidden by the live model's access
490
+ * control, so it must NEVER be re-emitted onto the serve shape: the served
491
+ * virtual source carries no access modifiers, so re-declaring a private/internal
492
+ * dimension, measure, join, or view would expose — over the stored table — a
493
+ * field the live path refuses. Skipping it makes any query that references it
494
+ * fall back to live, where the modifier is enforced (fail-safe). Defensive on
495
+ * the value: anything other than an absent/`public` modifier is treated as
496
+ * restricted, so a future modifier kind also fails closed.
497
+ */
498
+ function isAccessRestricted(field: unknown): boolean {
499
+ const am = (field as { accessModifier?: unknown }).accessModifier;
500
+ return am != null && am !== "public";
501
+ }
502
+
503
+ /**
504
+ * Narrow a binding's captured DESCRIBE schema to the columns the source
505
+ * PUBLICLY exposes, dropping any physical column the source hides.
506
+ *
507
+ * The build's `getSQL` projects the source's underlying columns, so a column
508
+ * hidden at the source level — removed by `except:`, or carrying a non-public
509
+ * access modifier — is still materialized into the table and captured by
510
+ * `DESCRIBE`. Declaring it on the serve shape would make it reachable through
511
+ * the virtual source even though the live path hides it (v0 never does this: it
512
+ * recompiles the original source definition with only the base table swapped, so
513
+ * the source's own visibility always applies). A column is kept only if it names
514
+ * a publicly-visible field of the compiled source; an `except:`-ed column is
515
+ * absent from the field list, and an access-restricted one is caught by
516
+ * {@link isAccessRestricted} — both are dropped. Dropped columns stay physically
517
+ * in the table but become unreachable through the source: a query that
518
+ * references one fails the shape compile and falls back to live, where the
519
+ * source's visibility rules are enforced (fail-safe). This mirrors the
520
+ * refinement/join/view access filtering — the serve surface must reproduce the
521
+ * source's PUBLIC surface exactly, never widen it.
522
+ */
523
+ export function narrowSchemaToPublic(
524
+ schema: { name: string; type: string }[],
525
+ fields: readonly unknown[] | undefined,
526
+ ): { name: string; type: string }[] {
527
+ const publicNames = new Set<string>();
528
+ for (const f of fields ?? []) {
529
+ const name = (f as { name?: unknown }).name;
530
+ if (typeof name === "string" && !isAccessRestricted(f)) {
531
+ publicNames.add(name);
532
+ }
533
+ }
534
+ return schema.filter((c) => publicNames.has(c.name));
535
+ }
536
+
537
+ export function extractRefinements(
538
+ fields: readonly unknown[] | undefined,
539
+ ): FieldRefinement[] {
540
+ const out: FieldRefinement[] = [];
541
+ for (const field of fields ?? []) {
542
+ if (isAccessRestricted(field)) continue; // never re-emit a hidden field
543
+ const f = field as {
544
+ name?: string;
545
+ code?: unknown;
546
+ expressionType?: unknown;
547
+ };
548
+ if (typeof f.name !== "string") continue;
549
+ if (typeof f.code !== "string" || f.code.length === 0) continue; // raw column
550
+ if (f.expressionType === "scalar") {
551
+ out.push({ kind: "dimension", name: f.name, code: f.code });
552
+ } else if (f.expressionType === "aggregate") {
553
+ out.push({ kind: "measure", name: f.name, code: f.code });
554
+ }
555
+ // analytic / calculation / ungrouped-aggregate and non-atomic fields
556
+ // (joins, turtles have no `code`) are skipped → those queries fall back.
557
+ }
558
+ return out;
559
+ }
560
+
561
+ /** A zero-based Malloy source range (as carried on a compiled field's `location`). */
562
+ export interface SourceRange {
563
+ start: { line: number; character: number };
564
+ end: { line: number; character: number };
565
+ }
566
+
567
+ /** A compiled field's source location: the file URL plus the range it spans. */
568
+ export interface SourceLocation {
569
+ url: string;
570
+ range: SourceRange;
571
+ }
572
+
573
+ /**
574
+ * Slice the substring covered by a Malloy `location.range` out of the full
575
+ * source text (both line and character are zero-based). Returns undefined when
576
+ * the range is out of bounds — a stale or mismatched source — so the caller
577
+ * skips the refinement and falls back to live rather than emitting garbage.
578
+ */
579
+ export function sliceSourceRange(
580
+ text: string,
581
+ range: SourceRange,
582
+ ): string | undefined {
583
+ const lines = text.split("\n");
584
+ const { start, end } = range;
585
+ if (start.line < 0 || start.line > end.line || end.line >= lines.length) {
586
+ return undefined;
587
+ }
588
+ if (start.line === end.line) {
589
+ return lines[start.line].slice(start.character, end.character);
590
+ }
591
+ let out = lines[start.line].slice(start.character);
592
+ for (let i = start.line + 1; i < end.line; i++) {
593
+ out += "\n" + lines[i];
594
+ }
595
+ out += "\n" + lines[end.line].slice(0, end.character);
596
+ return out;
597
+ }
598
+
599
+ /** What {@link extractJoins} needs beyond the compiled field list. */
600
+ export interface JoinExtractionContext {
601
+ /**
602
+ * The joined source's author name, keyed by the compiled join field's
603
+ * `sourceID` (which equals the joined source's own `sourceID`). Only a join
604
+ * whose target maps here — a named, in-model source — is a candidate; an
605
+ * anonymous/inline join target is absent from the map and is skipped.
606
+ */
607
+ sourceNameById: Map<string, string>;
608
+ /** Author source names that are materialized (have a serve binding). */
609
+ materializedSourceNames: ReadonlySet<string>;
610
+ /** Lift a field's verbatim source text from its `location`, or undefined. */
611
+ liftText: (location: SourceLocation) => string | undefined;
612
+ }
613
+
614
+ /**
615
+ * Extract a materialized source's re-emittable joins from its compiled field
616
+ * list. A join is carried only when BOTH hold: (1) its joined source is a
617
+ * named, in-model source (its `sourceID` maps to a name), and (2) that source
618
+ * is itself materialized (has a binding). The second is load-bearing, not an
619
+ * optimization: the serve shape is one model, so a join to a source it does not
620
+ * declare would fail the whole shape's compile and disable storage serving for
621
+ * every source in the package.
622
+ *
623
+ * The declaration text is lifted verbatim from the author's source by location
624
+ * (the on-condition is an arbitrary expression; lifting text carries any
625
+ * condition, whereas reconstructing it from the compiled expression tree would
626
+ * not); the keyword comes from the compiled relationship. A join we cannot map
627
+ * or lift is skipped, and a query using it falls back to live (safe).
628
+ */
629
+ export function extractJoins(
630
+ fields: readonly unknown[] | undefined,
631
+ ctx: JoinExtractionContext,
632
+ ): JoinRefinement[] {
633
+ const out: JoinRefinement[] = [];
634
+ for (const field of fields ?? []) {
635
+ if (isAccessRestricted(field)) continue; // never re-emit a hidden join
636
+ const f = field as {
637
+ as?: unknown;
638
+ name?: unknown;
639
+ join?: unknown;
640
+ sourceID?: unknown;
641
+ location?: SourceLocation;
642
+ };
643
+ if (typeof f.join !== "string") continue;
644
+ const keyword =
645
+ f.join === "one"
646
+ ? "join_one"
647
+ : f.join === "many"
648
+ ? "join_many"
649
+ : f.join === "cross"
650
+ ? "join_cross"
651
+ : undefined;
652
+ if (!keyword) continue;
653
+ if (typeof f.sourceID !== "string") continue;
654
+ const dependsOn = ctx.sourceNameById.get(f.sourceID);
655
+ if (!dependsOn) continue; // anonymous/inline join target → skip
656
+ if (!ctx.materializedSourceNames.has(dependsOn)) continue; // the gate
657
+ if (!f.location) continue;
658
+ const text = ctx.liftText(f.location);
659
+ if (!text) continue; // couldn't recover the declaration → skip
660
+ const alias =
661
+ typeof f.as === "string"
662
+ ? f.as
663
+ : typeof f.name === "string"
664
+ ? f.name
665
+ : dependsOn;
666
+ out.push({ kind: "join", name: alias, keyword, text, dependsOn });
667
+ }
668
+ return out;
669
+ }
670
+
671
+ /**
672
+ * Extract a materialized source's re-emittable views (turtles) from its compiled
673
+ * field list. A view is a nested query pipeline, not a single expression, so its
674
+ * declaration text is lifted verbatim from the author's source by location
675
+ * rather than reconstructed. Unlike a join, a view has no cheap dependency gate
676
+ * (it can reference any column/dimension/measure/join of the source), so it is
677
+ * emitted optimistically: if the resulting shape does not compile — the view
678
+ * reaches something not carried (a join to a non-materialized source, a nested
679
+ * view) — the serve path drops the view category and those queries fall back to
680
+ * live (safe). A view we cannot lift is skipped.
681
+ */
682
+ export function extractViews(
683
+ fields: readonly unknown[] | undefined,
684
+ liftText: (location: SourceLocation) => string | undefined,
685
+ ): ViewRefinement[] {
686
+ const out: ViewRefinement[] = [];
687
+ for (const field of fields ?? []) {
688
+ if (isAccessRestricted(field)) continue; // never re-emit a hidden view
689
+ const f = field as {
690
+ type?: unknown;
691
+ name?: unknown;
692
+ location?: SourceLocation;
693
+ };
694
+ if (f.type !== "turtle") continue;
695
+ if (typeof f.name !== "string") continue;
696
+ if (!f.location) continue;
697
+ const text = liftText(f.location);
698
+ if (!text) continue;
699
+ out.push({ kind: "view", name: f.name, text });
700
+ }
701
+ return out;
702
+ }
703
+
704
+ /**
705
+ * Assemble the per-call `virtualMap` (`connectionName -> handle -> canonical
706
+ * table path`) core resolves a virtual source through. The table path is quoted
707
+ * canonical for DuckDB — core validates every entry is canonical SQL for some
708
+ * dialect and then pastes it verbatim (it does not quote it), so an unquoted or
709
+ * mis-cased path is a run-time error. Multiple bindings on one connection fold
710
+ * into that connection's inner map.
711
+ *
712
+ * This is a FROM-bind site, so it uses {@link quoteManifestTablePath} — the same
713
+ * quoting authority as the build-side manifest seed and the serve-side manifest
714
+ * bind (publisher #904) — which quotes for the dialect UNLESS the path is already
715
+ * canonical SQL (an author-supplied `name=` the author already quoted), so an
716
+ * already-quoted name is never double-quoted.
717
+ */
718
+ export function buildVirtualMap(
719
+ bindings: ServeBinding[],
720
+ ): Map<string, Map<string, string>> {
721
+ const map = new Map<string, Map<string, string>>();
722
+ for (const b of bindings) {
723
+ let inner = map.get(b.connectionName);
724
+ if (!inner) {
725
+ inner = new Map<string, string>();
726
+ map.set(b.connectionName, inner);
727
+ }
728
+ inner.set(b.virtualHandle, quoteManifestTablePath(b.tablePath, "duckdb"));
729
+ }
730
+ return map;
731
+ }
732
+
733
+ /**
734
+ * The DuckDB-compile portability gate (the third materialization-eligibility
735
+ * check, deferred from the pre-build pass because it needs the post-build
736
+ * schema): compile the serve-shape model against DuckDB and refuse the source if
737
+ * it does not compile. Because the served table lives in DuckDB, a source
738
+ * authored against a warehouse must have a DuckDB-compilable served shape — this
739
+ * turns a serve-time 500 into a build-time refusal.
740
+ *
741
+ * Scope note: the current serve shape declares the captured columns (a
742
+ * simple-semantic-layer serve); preserving a rich extend block (measures/dims/
743
+ * joins) across the base swap and compiling THAT in DuckDB is the
744
+ * base-swap-preserve-refinements follow-on. This gate therefore proves the
745
+ * captured schema forms a valid DuckDB virtual source; it is a floor, not the
746
+ * full semantic-layer portability check.
747
+ *
748
+ * @throws {MaterializationEligibilityError} (HTTP 422) if the serve shape does
749
+ * not compile in DuckDB.
750
+ */
751
+ export async function assertServesInDuckDB(
752
+ sourceName: string,
753
+ binding: ServeBinding,
754
+ connections: LookupConnection<MalloyConnection>,
755
+ ): Promise<void> {
756
+ const { modelText } = buildServeShapeModel(sourceName, binding);
757
+ const root = "file:///serve-shape/";
758
+ const urlReader = new InMemoryURLReader(
759
+ new Map([[`${root}shape.malloy`, modelText]]),
760
+ );
761
+ const runtime = new Runtime({ urlReader, connections });
762
+ try {
763
+ await runtime
764
+ .loadModel(new URL(`${root}shape.malloy`), {
765
+ importBaseURL: new URL(root),
766
+ })
767
+ .getModel();
768
+ } catch (err) {
769
+ recordEligibilityRefused("not_duckdb_portable");
770
+ throw new MaterializationEligibilityError({
771
+ message:
772
+ `Source '${sourceName}' cannot be served from its storage ` +
773
+ `destination: its materialized shape does not compile in DuckDB ` +
774
+ `(${err instanceof Error ? err.message : String(err)}). A source ` +
775
+ `materialized into a DuckDB/DuckLake store must have a ` +
776
+ `DuckDB-portable served shape.`,
777
+ });
778
+ }
779
+ }