@malloy-publisher/server 0.0.198-dev4 → 0.0.198-dev6

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.
@@ -29,7 +29,11 @@ import * as fs from "fs/promises";
29
29
  import { createRequire } from "module";
30
30
  import * as path from "path";
31
31
  import { components } from "../api";
32
- import { getCompilePool } from "../compile/compile_pool";
32
+ import { deserializeError } from "../package_load/package_load_pool";
33
+ import type {
34
+ SerializedModel,
35
+ SerializedNotebookCell,
36
+ } from "../package_load/protocol";
33
37
  import {
34
38
  MODEL_FILE_SUFFIX,
35
39
  NOTEBOOK_FILE_SUFFIX,
@@ -52,6 +56,7 @@ import {
52
56
  type FilterDefinition,
53
57
  type FilterParams,
54
58
  } from "./filter";
59
+ import { malloyGivenToApi, type MalloyGiven } from "./given";
55
60
 
56
61
  type ApiCompiledModel = components["schemas"]["CompiledModel"];
57
62
  type ApiNotebookCell = components["schemas"]["NotebookCell"];
@@ -76,61 +81,6 @@ const MALLOY_VERSION = (
76
81
  export type ModelType = "model" | "notebook";
77
82
  type ModelConnectionInput = MalloyConfig | Map<string, Connection>;
78
83
 
79
- /**
80
- * Structural type for a Malloy SDK `Given` instance (the value type of
81
- * `Model.givens`). The `Given` class is declared in
82
- * `@malloydata/malloy/dist/api/foundation/core.d.ts` but is not re-exported
83
- * from the package root, so we duck-type against the surface we use rather
84
- * than importing it.
85
- */
86
- interface MalloyGiven {
87
- readonly name: string;
88
- readonly type: { type: string; filterType?: string };
89
- getTaglines(prefix?: RegExp): string[];
90
- }
91
-
92
- /**
93
- * Convert a Malloy SDK `Given` (returned from `Model.givens`) to the wire
94
- * shape declared in `api-doc.yaml`. Two fields are deliberately not surfaced:
95
- *
96
- * - `location` — Malloy's `DocumentLocation.url` is an absolute `file://`
97
- * path on the publisher's filesystem. Surfacing it would leak the OS user,
98
- * install directory, and internal layout. Existing `Filter` introspection
99
- * does not expose location either; matching that floor. A future PR can
100
- * add a sanitized package-relative path if a client needs it.
101
- *
102
- * - `default` / `defaultText` — Malloy's API only exposes the parsed
103
- * `ConstantExpr` AST, not a rendered source string. Rendering it here
104
- * would duplicate the Malloy printer. Add when Malloy surfaces a
105
- * stringified accessor.
106
- *
107
- * `annotations` is restricted to `#(...)` declaration annotations (the
108
- * caller-facing kind, e.g. `#(doc)`). `getTaglines()` with no prefix would
109
- * also return `##` doc-comment lines and the model-level `##!` pragma,
110
- * which aren't part of the given's surface contract.
111
- *
112
- * Type rendering: `GivenTypeDef` is typed as `AtomicTypeDef |
113
- * FilterExpressionParamTypeDef`, but Malloy's grammar only emits the
114
- * scalar parameter types (`string` | `number` | `boolean` | `date` |
115
- * `timestamp` | `timestamptz` | `filter expression` | `error`) for
116
- * given declarations today. If the grammar expands to allow array or
117
- * record givens, the bare `type.type` discriminator (`'array'`,
118
- * `'record'`) will land in the wire response with no element info —
119
- * revisit when that happens.
120
- */
121
- function malloyGivenToApi(given: MalloyGiven): ApiGiven {
122
- const type = given.type;
123
- const renderedType =
124
- type.type === "filter expression"
125
- ? `filter<${type.filterType}>`
126
- : type.type;
127
- return {
128
- name: given.name,
129
- type: renderedType,
130
- annotations: given.getTaglines(/^#\(/),
131
- };
132
- }
133
-
134
84
  interface RunnableNotebookCell {
135
85
  type: "code" | "markdown";
136
86
  text: string;
@@ -141,31 +91,12 @@ interface RunnableNotebookCell {
141
91
  queryInfo?: Malloy.QueryInfo;
142
92
  }
143
93
 
144
- /**
145
- * Lazily produces a `ModelMaterializer` on demand. Used by the worker-
146
- * compile path: the worker returns a fully-built `modelDef` but cannot
147
- * ship the materializer (it binds to a Runtime that holds live native
148
- * connection handles and would not survive a structured-clone). The
149
- * first query that actually needs to execute calls this builder,
150
- * which constructs the materializer in-process. After construction
151
- * Malloy caches the compiled model internally on the materializer,
152
- * so subsequent queries pay no recompile cost.
153
- */
154
- type MaterializerBuilder = () => Promise<ModelMaterializer>;
155
-
156
94
  export class Model {
157
95
  private packageName: string;
158
96
  private modelPath: string;
159
97
  private dataStyles: DataStyles;
160
98
  private modelType: ModelType;
161
99
  private modelMaterializer: ModelMaterializer | undefined;
162
- /**
163
- * Lazy builder used when the model was compiled in a worker_threads
164
- * worker. The first `getQueryResults`/`executeNotebookCell` call
165
- * invokes this and caches the result in `modelMaterializer`.
166
- */
167
- private materializerBuilder: MaterializerBuilder | undefined;
168
- private materializerBuildPromise: Promise<ModelMaterializer> | undefined;
169
100
  private modelDef: ModelDef | undefined;
170
101
  private modelInfo: Malloy.ModelInfo | undefined;
171
102
  private sources: ApiSource[] | undefined;
@@ -179,8 +110,6 @@ export class Model {
179
110
  * `Model.givens` already collapses inheritance; we just stash the list
180
111
  * for surfacing on the compiled-model response. */
181
112
  private givens: ApiGiven[] | undefined;
182
- /** Cached responses from `getStandardModel()` so we don't re-stringify a multi-MB modelDef on every GET. */
183
- private cachedStandardModel: ApiCompiledModel | undefined;
184
113
  private meter = metrics.getMeter("publisher");
185
114
  private queryExecutionHistogram = this.meter.createHistogram(
186
115
  "malloy_model_query_duration",
@@ -205,7 +134,15 @@ export class Model {
205
134
  compilationError: MalloyError | Error | undefined,
206
135
  filterMap?: Map<string, FilterDefinition[]>,
207
136
  givens?: ApiGiven[],
208
- materializerBuilder?: MaterializerBuilder,
137
+ /**
138
+ * Precomputed `modelDefToModelInfo(modelDef)`. The package-load
139
+ * worker emits it as part of `SerializedModel` so we don't
140
+ * re-derive it on every package load. Callers that build a
141
+ * `Model` from a raw `modelDef` (e.g. test fixtures via
142
+ * `Model.create`) can omit this and let the constructor
143
+ * derive it lazily.
144
+ */
145
+ modelInfo?: Malloy.ModelInfo,
209
146
  ) {
210
147
  this.packageName = packageName;
211
148
  this.modelPath = modelPath;
@@ -213,7 +150,6 @@ export class Model {
213
150
  this.modelType = modelType;
214
151
  this.modelDef = modelDef;
215
152
  this.modelMaterializer = modelMaterializer;
216
- this.materializerBuilder = materializerBuilder;
217
153
  this.sources = sources;
218
154
  this.queries = queries;
219
155
  this.sourceInfos = sourceInfos;
@@ -221,31 +157,9 @@ export class Model {
221
157
  this.compilationError = compilationError;
222
158
  this.filterMap = filterMap ?? new Map();
223
159
  this.givens = givens;
224
- this.modelInfo = this.modelDef
225
- ? modelDefToModelInfo(this.modelDef)
226
- : undefined;
227
- }
228
-
229
- /**
230
- * Resolve the in-process `ModelMaterializer`, building it lazily if
231
- * the model was compiled in a worker_threads worker. Memoizes both
232
- * the materializer and the in-flight build promise so concurrent
233
- * queries on the same model share a single construction.
234
- */
235
- private async ensureMaterializer(): Promise<ModelMaterializer> {
236
- if (this.modelMaterializer) return this.modelMaterializer;
237
- if (!this.materializerBuilder) {
238
- throw new BadRequestError("Model has no queryable entities.");
239
- }
240
- if (!this.materializerBuildPromise) {
241
- this.materializerBuildPromise = this.materializerBuilder().then(
242
- (mm) => {
243
- this.modelMaterializer = mm;
244
- return mm;
245
- },
246
- );
247
- }
248
- return this.materializerBuildPromise;
160
+ this.modelInfo =
161
+ modelInfo ??
162
+ (this.modelDef ? modelDefToModelInfo(this.modelDef) : undefined);
249
163
  }
250
164
 
251
165
  /**
@@ -267,159 +181,16 @@ export class Model {
267
181
  return runMatch?.[1] ?? arrowMatch?.[1];
268
182
  }
269
183
 
270
- public static async create(
271
- packageName: string,
272
- packagePath: string,
273
- modelPath: string,
274
- malloyConfig: ModelConnectionInput,
275
- options?: { buildManifest?: BuildManifest["entries"] },
276
- ): Promise<Model> {
277
- // Worker-pool fast path for plain `.malloy` files. Notebooks
278
- // stay in-process for v1 — their per-cell ModelMaterializer
279
- // chain is too entangled to ship across a worker boundary.
280
- // The MALLOY_COMPILE_WORKERS=0 kill switch / pool.enabled check
281
- // funnels everything through the legacy in-process path when
282
- // the pool is disabled, so this is safe to land dark.
283
- const pool = getCompilePool();
284
- if (pool.enabled && modelPath.endsWith(MODEL_FILE_SUFFIX)) {
285
- try {
286
- return await Model.createViaWorker(
287
- packageName,
288
- packagePath,
289
- modelPath,
290
- malloyConfig,
291
- pool,
292
- options,
293
- );
294
- } catch (poolError) {
295
- // Real compile errors propagate to the caller as a Model
296
- // with `compilationError` populated, matching the
297
- // in-process path's contract.
298
- if (
299
- poolError instanceof ModelCompilationError ||
300
- poolError instanceof MalloyError
301
- ) {
302
- return Model.makeErrorModel(
303
- packageName,
304
- modelPath,
305
- poolError instanceof MalloyError
306
- ? new ModelCompilationError(poolError)
307
- : poolError,
308
- );
309
- }
310
- // Anything else (worker exited, RPC timeout) — fall back
311
- // to in-process compile so a transient pool failure
312
- // doesn't take a package down.
313
- logger.warn(
314
- "Compile worker failed; falling back to in-process compile",
315
- { packageName, modelPath, error: poolError },
316
- );
317
- }
318
- }
319
- return Model.createInProcess(
320
- packageName,
321
- packagePath,
322
- modelPath,
323
- malloyConfig,
324
- options,
325
- );
326
- }
327
-
328
184
  /**
329
- * Compile via the {@link CompileWorkerPool}. Builds a `Model` whose
330
- * `modelDef` / `sources` / `queries` / `sourceInfos` / `givens` are
331
- * populated up-front, but whose `ModelMaterializer` is constructed
332
- * lazily on the first query through {@link ensureMaterializer}.
333
- * This keeps the heavy CPU work (parse, type-check, IR build) off
334
- * the main event loop so the K8s liveness probe stays responsive.
185
+ * Compile a single model in-process. Kept as a library entry point
186
+ * for test fixtures and any future caller that needs an ad-hoc
187
+ * `Model` from a `.malloy` / `.malloynb` file. Production package
188
+ * loads (`Package.create`) and reloads (`Package.reloadAllModels`)
189
+ * route through the package-load worker pool and dispatch through
190
+ * {@link Model.fromSerialized} instead neither calls this on the
191
+ * main thread.
335
192
  */
336
- private static async createViaWorker(
337
- packageName: string,
338
- packagePath: string,
339
- modelPath: string,
340
- malloyConfig: ModelConnectionInput,
341
- pool: ReturnType<typeof getCompilePool>,
342
- options?: { buildManifest?: BuildManifest["entries"] },
343
- ): Promise<Model> {
344
- const resolvedConfig = Model.toMalloyConfig(malloyConfig);
345
- const outcome = await pool.compile({
346
- packagePath,
347
- modelPath,
348
- malloyConfig: resolvedConfig,
349
- // Package-level configs wrap a "duckdb" default; matches
350
- // Package.buildPackageMalloyConfig.
351
- defaultConnectionName: "duckdb",
352
- urlReader: URL_READER,
353
- buildManifest: options?.buildManifest,
354
- });
355
-
356
- // Materializer construction is deferred until a query actually
357
- // runs. Build it the same way the in-process path does so
358
- // execution semantics stay identical.
359
- const materializerBuilder: MaterializerBuilder = async () => {
360
- const { runtime, modelURL, importBaseURL } =
361
- await Model.getModelRuntime(
362
- packagePath,
363
- modelPath,
364
- malloyConfig,
365
- options,
366
- );
367
- return Model.getStandardModelMaterializer(
368
- runtime,
369
- importBaseURL,
370
- modelURL,
371
- modelPath,
372
- );
373
- };
374
-
375
- return new Model(
376
- packageName,
377
- modelPath,
378
- {} as DataStyles,
379
- "model",
380
- undefined, // modelMaterializer — built lazily
381
- outcome.modelDef,
382
- outcome.sources as ApiSource[],
383
- outcome.queries as ApiQuery[],
384
- outcome.sourceInfos.length > 0 ? outcome.sourceInfos : undefined,
385
- undefined, // runnableNotebookCells — .malloy is not a notebook
386
- undefined, // compilationError
387
- outcome.filterMap,
388
- outcome.givens as ApiGiven[] | undefined,
389
- materializerBuilder,
390
- );
391
- }
392
-
393
- private static makeErrorModel(
394
- packageName: string,
395
- modelPath: string,
396
- error: Error,
397
- ): Model {
398
- const isNotebook = modelPath.endsWith(NOTEBOOK_FILE_SUFFIX);
399
- return new Model(
400
- packageName,
401
- modelPath,
402
- {} as DataStyles,
403
- isNotebook ? "notebook" : "model",
404
- undefined,
405
- undefined,
406
- undefined,
407
- undefined,
408
- undefined,
409
- undefined,
410
- error,
411
- );
412
- }
413
-
414
- /**
415
- * Legacy in-process compile path. Retained for:
416
- * - notebooks (`.malloynb`), whose per-cell materializer chain
417
- * is too coupled to the Runtime to ship to a worker for v1.
418
- * - environments where MALLOY_COMPILE_WORKERS=0.
419
- * - fallback when the worker pool encounters a non-compile
420
- * failure (worker exit, RPC timeout).
421
- */
422
- private static async createInProcess(
193
+ public static async create(
423
194
  packageName: string,
424
195
  packagePath: string,
425
196
  modelPath: string,
@@ -456,10 +227,12 @@ export class Model {
456
227
  modelDef = compiledModel._modelDef;
457
228
  // Malloy's `Model.givens` already collapses inheritance from imports
458
229
  // and applies any `finalizeGivens` runtime config. Just read it.
459
- const malloyGivens = Array.from(compiledModel.givens.values());
230
+ const malloyGivens = Array.from(
231
+ compiledModel.givens.values(),
232
+ ) as MalloyGiven[];
460
233
  givens =
461
234
  malloyGivens.length > 0
462
- ? malloyGivens.map(malloyGivenToApi)
235
+ ? (malloyGivens.map(malloyGivenToApi) as ApiGiven[])
463
236
  : undefined;
464
237
  const sourceResult = Model.getSources(modelPath, modelDef, givens);
465
238
  sources = sourceResult.sources;
@@ -556,6 +329,123 @@ export class Model {
556
329
  }
557
330
  }
558
331
 
332
+ /**
333
+ * Construct a `Model` from a worker-compiled `SerializedModel`. All
334
+ * the heavy compile work (parse, type check, IR build, sourceInfo
335
+ * extraction, per-cell notebook compile) already ran inside a
336
+ * `worker_threads` worker; this factory just rewraps the wire data
337
+ * into a live `Model`.
338
+ *
339
+ * Hydrates the `ModelMaterializer` (and, for notebooks, the
340
+ * per-cell materializers + runnables) **eagerly** via
341
+ * `Runtime._loadModelFromModelDef` /
342
+ * `ModelMaterializer._loadQueryFromQueryDef`. These are constant-time
343
+ * wraps around the worker's pre-compiled `modelDef` / `queryDef` —
344
+ * no parse, no type-check, no schema fetch — so doing it here at
345
+ * package-load time costs microseconds per model and keeps the
346
+ * resulting `Model` interchangeable with one produced by
347
+ * `Model.create` (no lazy-init branches in the hot path).
348
+ */
349
+ public static fromSerialized(
350
+ packageName: string,
351
+ _packagePath: string,
352
+ malloyConfig: ModelConnectionInput,
353
+ data: SerializedModel,
354
+ ): Model {
355
+ const modelDef = data.modelDef as ModelDef | undefined;
356
+ const modelInfo = data.modelInfo as Malloy.ModelInfo | undefined;
357
+ const dataStyles = (data.dataStyles ?? {}) as DataStyles;
358
+ const sources = data.sources as ApiSource[] | undefined;
359
+ const queries = data.queries as ApiQuery[] | undefined;
360
+ const sourceInfos = data.sourceInfos as Malloy.SourceInfo[] | undefined;
361
+ const givens = data.givens as ApiGiven[] | undefined;
362
+ const filterMap = data.filterMap
363
+ ? new Map(data.filterMap as Array<[string, FilterDefinition[]]>)
364
+ : undefined;
365
+
366
+ // No modelDef → either an empty notebook (no MALLOY statements)
367
+ // or a corrupt worker payload. Build a Model with no materializer;
368
+ // downstream getQueryResults / executeNotebookCell will throw a
369
+ // clean BadRequestError if a caller tries to run a query. We
370
+ // still preserve markdown cells for an all-markdown notebook so
371
+ // `getNotebook()` can serve raw text.
372
+ if (!modelDef) {
373
+ return new Model(
374
+ packageName,
375
+ data.modelPath,
376
+ dataStyles,
377
+ data.modelType,
378
+ undefined,
379
+ undefined,
380
+ sources,
381
+ queries,
382
+ sourceInfos,
383
+ data.modelType === "notebook"
384
+ ? hydrateMarkdownOnlyCells(data.notebookCells)
385
+ : undefined,
386
+ undefined,
387
+ filterMap,
388
+ givens,
389
+ modelInfo,
390
+ );
391
+ }
392
+
393
+ const runtime = makeHydrationRuntime(malloyConfig);
394
+ const modelMaterializer = runtime._loadModelFromModelDef(modelDef);
395
+ const runnableNotebookCells =
396
+ data.modelType === "notebook"
397
+ ? hydrateNotebookCells(runtime, data.notebookCells)
398
+ : undefined;
399
+
400
+ return new Model(
401
+ packageName,
402
+ data.modelPath,
403
+ dataStyles,
404
+ data.modelType,
405
+ modelMaterializer,
406
+ modelDef,
407
+ sources,
408
+ queries,
409
+ sourceInfos,
410
+ runnableNotebookCells,
411
+ undefined, // compilationError
412
+ filterMap,
413
+ givens,
414
+ modelInfo,
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Build a Model representing a compilation failure (no modelDef,
420
+ * no materializer). Matches the shape `Model.create` returns when
421
+ * it catches a `MalloyError`, so the rest of the system handles
422
+ * both paths uniformly (the iteration loop in `Package.create`
423
+ * reads `compilationError` via a structural cast).
424
+ */
425
+ public static fromCompilationError(
426
+ packageName: string,
427
+ modelPath: string,
428
+ modelType: ModelType,
429
+ error: Error,
430
+ ): Model {
431
+ return new Model(
432
+ packageName,
433
+ modelPath,
434
+ {} as DataStyles,
435
+ modelType,
436
+ undefined,
437
+ undefined,
438
+ undefined,
439
+ undefined,
440
+ undefined,
441
+ undefined,
442
+ error,
443
+ );
444
+ }
445
+
446
+ /** Look up the deserialized error helper for callers (e.g. Package.create). */
447
+ public static deserializeCompilationError = deserializeError;
448
+
559
449
  public getPath(): string {
560
450
  return this.modelPath;
561
451
  }
@@ -635,25 +525,9 @@ export class Model {
635
525
  );
636
526
  }
637
527
  let runnable: QueryMaterializer;
638
- if (!this.modelDef || !this.modelInfo)
528
+ if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
639
529
  throw new BadRequestError("Model has no queryable entities.");
640
530
 
641
- // Resolve the materializer — either already-built (in-process
642
- // create path, or a previous query on this Model) or lazily
643
- // constructed now (worker-compile path on first query).
644
- let materializer: ModelMaterializer;
645
- try {
646
- materializer = await this.ensureMaterializer();
647
- } catch (error) {
648
- if (error instanceof BadRequestError) throw error;
649
- if (error instanceof MalloyError) throw error;
650
- throw new BadRequestError(
651
- error instanceof Error
652
- ? `Failed to prepare model: ${error.message}`
653
- : "Failed to prepare model.",
654
- );
655
- }
656
-
657
531
  // Wrap loadQuery calls in try-catch to handle query parsing errors
658
532
  try {
659
533
  let queryString: string;
@@ -694,7 +568,7 @@ export class Model {
694
568
  }
695
569
  }
696
570
 
697
- runnable = materializer.loadQuery(queryString);
571
+ runnable = this.modelMaterializer.loadQuery(queryString);
698
572
  } catch (error) {
699
573
  // Re-throw BadRequestError as-is
700
574
  if (error instanceof BadRequestError) {
@@ -783,23 +657,17 @@ export class Model {
783
657
  }
784
658
 
785
659
  private getStandardModel(): ApiCompiledModel {
786
- // modelDef is immutable for the lifetime of this Model, so the
787
- // (potentially multi-MB) JSON.stringify result can be memoised.
788
- // Without this cache every `GET /environments/:e/packages/:p/
789
- // models/:m` re-stringifies the whole tree on the main thread —
790
- // a known source of multi-hundred-ms event-loop pauses that
791
- // chips away at the K8s liveness budget.
792
- if (this.cachedStandardModel) return this.cachedStandardModel;
793
- const compiled: ApiCompiledModel = {
660
+ return {
794
661
  type: "source",
795
662
  packageName: this.packageName,
796
663
  modelPath: this.modelPath,
797
664
  malloyVersion: MALLOY_VERSION,
798
665
  dataStyles: JSON.stringify(this.dataStyles),
799
666
  modelDef: JSON.stringify(this.modelDef),
800
- modelInfo: JSON.stringify(
801
- this.modelDef ? modelDefToModelInfo(this.modelDef) : {},
802
- ),
667
+ // `this.modelInfo` is precomputed once at construction (either
668
+ // by the worker or in the Model.create constructor); don't
669
+ // re-run `modelDefToModelInfo` on every API hit.
670
+ modelInfo: JSON.stringify(this.modelInfo ?? {}),
803
671
  sourceInfos: this.getSourceInfos()?.map((sourceInfo) =>
804
672
  JSON.stringify(sourceInfo),
805
673
  ),
@@ -807,8 +675,6 @@ export class Model {
807
675
  queries: this.queries,
808
676
  givens: this.givens,
809
677
  } as ApiCompiledModel;
810
- this.cachedStandardModel = compiled;
811
- return compiled;
812
678
  }
813
679
 
814
680
  private async getNotebookModel(): Promise<ApiRawNotebook> {
@@ -853,9 +719,7 @@ export class Model {
853
719
  packageName: this.packageName,
854
720
  modelPath: this.modelPath,
855
721
  malloyVersion: MALLOY_VERSION,
856
- modelInfo: JSON.stringify(
857
- this.modelDef ? modelDefToModelInfo(this.modelDef) : {},
858
- ),
722
+ modelInfo: JSON.stringify(this.modelInfo ?? {}),
859
723
  sources: this.modelDef && this.sources,
860
724
  queries: this.modelDef && this.queries,
861
725
  annotations: allAnnotations,
@@ -1375,3 +1239,103 @@ export class Model {
1375
1239
  }
1376
1240
  }
1377
1241
  }
1242
+
1243
+ // ──────────────────────────────────────────────────────────────────────
1244
+ // Helpers for hydrating worker-compiled models on the main thread
1245
+ // ──────────────────────────────────────────────────────────────────────
1246
+
1247
+ /**
1248
+ * Minimal subset of `Runtime` we use here. The `_` methods are
1249
+ * marked `@internal` in Malloy but are the only API for constructing
1250
+ * a materializer / query materializer from an existing `modelDef` /
1251
+ * queryDef — the public `loadModel(url)` path always recompiles.
1252
+ */
1253
+ type HydrationRuntime = Runtime & {
1254
+ _loadModelFromModelDef(modelDef: ModelDef): ModelMaterializer;
1255
+ };
1256
+ type HydrationMaterializer = ModelMaterializer & {
1257
+ _loadQueryFromQueryDef(query: unknown): QueryMaterializer;
1258
+ };
1259
+
1260
+ function makeHydrationRuntime(
1261
+ malloyConfig: ModelConnectionInput,
1262
+ ): HydrationRuntime {
1263
+ const urlReader = new HackyDataStylesAccumulator(URL_READER);
1264
+ const config =
1265
+ malloyConfig instanceof MalloyConfig
1266
+ ? malloyConfig
1267
+ : (() => {
1268
+ const c = new MalloyConfig({ connections: {} });
1269
+ c.wrapConnections(
1270
+ () => new FixedConnectionMap(malloyConfig, "duckdb"),
1271
+ );
1272
+ return c;
1273
+ })();
1274
+ return new Runtime({ urlReader, config }) as HydrationRuntime;
1275
+ }
1276
+
1277
+ /**
1278
+ * Build the live `RunnableNotebookCell[]` from worker-emitted
1279
+ * per-cell data. Each MALLOY cell is hydrated via
1280
+ * `Runtime._loadModelFromModelDef` (for the cell's scope) and
1281
+ * `ModelMaterializer._loadQueryFromQueryDef` (for the cell's
1282
+ * runnable) — no recompile.
1283
+ */
1284
+ function hydrateNotebookCells(
1285
+ runtime: HydrationRuntime,
1286
+ notebookCells: SerializedNotebookCell[] | undefined,
1287
+ ): RunnableNotebookCell[] {
1288
+ if (!notebookCells) return [];
1289
+ return notebookCells.map((sc): RunnableNotebookCell => {
1290
+ if (sc.type === "markdown") {
1291
+ return { type: "markdown", text: sc.text };
1292
+ }
1293
+ const cellModelDef = sc.cellModelDef as ModelDef | undefined;
1294
+ let modelMaterializer: ModelMaterializer | undefined;
1295
+ let runnable: QueryMaterializer | undefined;
1296
+ if (cellModelDef) {
1297
+ modelMaterializer = runtime._loadModelFromModelDef(cellModelDef);
1298
+ if (sc.cellQueryDef !== undefined) {
1299
+ try {
1300
+ runnable = (
1301
+ modelMaterializer as HydrationMaterializer
1302
+ )._loadQueryFromQueryDef(sc.cellQueryDef);
1303
+ } catch (error) {
1304
+ // Hydration shouldn't fail for a queryDef the worker
1305
+ // already prepared, but if Malloy's internal shape
1306
+ // drifts we'd rather drop the runnable than crash the
1307
+ // whole notebook. The cell remains markdown-runnable.
1308
+ logger.warn("Failed to hydrate notebook cell queryDef", {
1309
+ error,
1310
+ });
1311
+ }
1312
+ }
1313
+ }
1314
+ return {
1315
+ type: "code",
1316
+ text: sc.text,
1317
+ runnable,
1318
+ modelMaterializer,
1319
+ newSources: sc.newSources as Malloy.SourceInfo[] | undefined,
1320
+ queryInfo: sc.queryInfo as Malloy.QueryInfo | undefined,
1321
+ };
1322
+ });
1323
+ }
1324
+
1325
+ /**
1326
+ * For an all-markdown notebook (no MALLOY statements → no
1327
+ * `modelDef`), we still want to preserve the cell list so
1328
+ * `getNotebook()` can serve raw text. This skips materializer
1329
+ * hydration (there's nothing to hydrate) and returns markdown-only
1330
+ * cells.
1331
+ */
1332
+ function hydrateMarkdownOnlyCells(
1333
+ notebookCells: SerializedNotebookCell[] | undefined,
1334
+ ): RunnableNotebookCell[] | undefined {
1335
+ if (!notebookCells) return undefined;
1336
+ return notebookCells.map((sc): RunnableNotebookCell => {
1337
+ if (sc.type === "markdown") return { type: "markdown", text: sc.text };
1338
+ // A code cell without a hydratable scope — surface text only.
1339
+ return { type: "code", text: sc.text };
1340
+ });
1341
+ }
@@ -1,3 +1,5 @@
1
+ import { DuckDBConnection } from "@malloydata/db-duckdb";
2
+ import "@malloydata/db-duckdb/native";
1
3
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
4
  import { Stats } from "fs";
3
5
  import fs from "fs/promises";
@@ -340,10 +342,18 @@ describe("service/package", () => {
340
342
  it("should return the size of the database file", async () => {
341
343
  sinon.stub(fs, "stat").resolves({ size: 13 } as Stats);
342
344
 
345
+ // `getDatabaseInfo` now requires the caller to pass in the
346
+ // shared DuckDB connection (resolved once by `readDatabases`
347
+ // off the package's MalloyConfig). For this isolated unit
348
+ // test we mint a fresh ephemeral one — production paths
349
+ // reuse a single connection per package via `Package.create`.
350
+ const conn = new DuckDBConnection("duckdb");
351
+
343
352
  // @ts-expect-error Accessing private static method for testing
344
353
  const info = await Package.getDatabaseInfo(
345
354
  testPackageDirectory,
346
355
  "database.csv",
356
+ conn,
347
357
  );
348
358
 
349
359
  expect(info).toEqual({