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