@malloy-publisher/server 0.0.198 → 0.0.199

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 (45) hide show
  1. package/build.ts +30 -1
  2. package/dist/app/api-doc.yaml +51 -0
  3. package/dist/app/assets/{EnvironmentPage-C7rtH4mC.js → EnvironmentPage-Dpee_Kn6.js} +1 -1
  4. package/dist/app/assets/{HomePage-DwkH7OrS.js → HomePage-DLRWTNoL.js} +1 -1
  5. package/dist/app/assets/{MainPage-D38LtZDV.js → MainPage-DsVt5QGM.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DOol8Mz7.js → ModelPage-AwAugZ37.js} +1 -1
  7. package/dist/app/assets/{PackagePage-0tgzA_kO.js → PackagePage-XQ-EWGTC.js} +1 -1
  8. package/dist/app/assets/{RouteError-BaMsOSly.js → RouteError-3Mv8JQw7.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Cx4SePkx.js → WorkbookPage-DHYYpcYc.js} +1 -1
  10. package/dist/app/assets/{core-CbsC6R_Y.es-Cwf6asf3.js → core-DfcpQGVP.es-DQggNOdX.js} +1 -1
  11. package/dist/app/assets/{index-DNofXMxi.js → index-BUp81Qdm.js} +1 -1
  12. package/dist/app/assets/{index-DL6BZTuw.js → index-D1pdwrUW.js} +1 -1
  13. package/dist/app/assets/{index-U38AyjJL.js → index-Dv5bF4Ii.js} +4 -4
  14. package/dist/app/assets/{index.umd-B68wGGkM.js → index.umd-CQH4LZU8.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +57 -36
  17. package/dist/package_load_worker.mjs +12213 -0
  18. package/dist/server.mjs +2807 -2729
  19. package/package.json +2 -3
  20. package/src/controller/compile.controller.ts +3 -1
  21. package/src/controller/model.controller.ts +8 -1
  22. package/src/controller/query.controller.ts +3 -0
  23. package/src/health.spec.ts +90 -0
  24. package/src/health.ts +88 -45
  25. package/src/instrumentation.ts +50 -0
  26. package/src/mcp/tools/execute_query_tool.ts +12 -0
  27. package/src/package_load/package_load_pool.spec.ts +252 -0
  28. package/src/package_load/package_load_pool.ts +920 -0
  29. package/src/package_load/package_load_worker.ts +980 -0
  30. package/src/package_load/protocol.ts +336 -0
  31. package/src/query_param_utils.ts +18 -0
  32. package/src/server-old.ts +1 -1
  33. package/src/server.ts +36 -10
  34. package/src/service/db_utils.spec.ts +1 -1
  35. package/src/service/environment.ts +3 -2
  36. package/src/service/environment_store.ts +24 -3
  37. package/src/service/filter_integration.spec.ts +110 -0
  38. package/src/service/given.ts +80 -0
  39. package/src/service/givens_integration.spec.ts +192 -0
  40. package/src/service/model.spec.ts +105 -0
  41. package/src/service/model.ts +287 -16
  42. package/src/service/package.spec.ts +10 -0
  43. package/src/service/package.ts +257 -145
  44. package/src/service/package_worker_path.spec.ts +196 -0
  45. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
@@ -3,6 +3,7 @@ import {
3
3
  API,
4
4
  Connection,
5
5
  FixedConnectionMap,
6
+ GivenValue,
6
7
  isSourceDef,
7
8
  MalloyConfig,
8
9
  MalloyError,
@@ -28,6 +29,11 @@ import * as fs from "fs/promises";
28
29
  import { createRequire } from "module";
29
30
  import * as path from "path";
30
31
  import { components } from "../api";
32
+ import { deserializeError } from "../package_load/package_load_pool";
33
+ import type {
34
+ SerializedModel,
35
+ SerializedNotebookCell,
36
+ } from "../package_load/protocol";
31
37
  import {
32
38
  MODEL_FILE_SUFFIX,
33
39
  NOTEBOOK_FILE_SUFFIX,
@@ -50,12 +56,14 @@ import {
50
56
  type FilterDefinition,
51
57
  type FilterParams,
52
58
  } from "./filter";
59
+ import { malloyGivenToApi, type MalloyGiven } from "./given";
53
60
 
54
61
  type ApiCompiledModel = components["schemas"]["CompiledModel"];
55
62
  type ApiNotebookCell = components["schemas"]["NotebookCell"];
56
63
  type ApiRawNotebook = components["schemas"]["RawNotebook"];
57
64
  type ApiSource = components["schemas"]["Source"];
58
65
  type ApiFilter = components["schemas"]["Filter"];
66
+ type ApiGiven = components["schemas"]["Given"];
59
67
  type ApiView = components["schemas"]["View"];
60
68
  type ApiQuery = components["schemas"]["Query"];
61
69
  export type ApiConnection = components["schemas"]["Connection"];
@@ -98,6 +106,10 @@ export class Model {
98
106
  private compilationError: MalloyError | Error | undefined;
99
107
  /** Parsed #(filter) definitions keyed by source name. */
100
108
  private filterMap: Map<string, FilterDefinition[]>;
109
+ /** Givens declared on the model, in declaration order. Malloy's
110
+ * `Model.givens` already collapses inheritance; we just stash the list
111
+ * for surfacing on the compiled-model response. */
112
+ private givens: ApiGiven[] | undefined;
101
113
  private meter = metrics.getMeter("publisher");
102
114
  private queryExecutionHistogram = this.meter.createHistogram(
103
115
  "malloy_model_query_duration",
@@ -121,6 +133,16 @@ export class Model {
121
133
  runnableNotebookCells: RunnableNotebookCell[] | undefined,
122
134
  compilationError: MalloyError | Error | undefined,
123
135
  filterMap?: Map<string, FilterDefinition[]>,
136
+ givens?: ApiGiven[],
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,
124
146
  ) {
125
147
  this.packageName = packageName;
126
148
  this.modelPath = modelPath;
@@ -134,9 +156,10 @@ export class Model {
134
156
  this.runnableNotebookCells = runnableNotebookCells;
135
157
  this.compilationError = compilationError;
136
158
  this.filterMap = filterMap ?? new Map();
137
- this.modelInfo = this.modelDef
138
- ? modelDefToModelInfo(this.modelDef)
139
- : undefined;
159
+ this.givens = givens;
160
+ this.modelInfo =
161
+ modelInfo ??
162
+ (this.modelDef ? modelDefToModelInfo(this.modelDef) : undefined);
140
163
  }
141
164
 
142
165
  /**
@@ -158,6 +181,15 @@ export class Model {
158
181
  return runMatch?.[1] ?? arrowMatch?.[1];
159
182
  }
160
183
 
184
+ /**
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.
192
+ */
161
193
  public static async create(
162
194
  packageName: string,
163
195
  packagePath: string,
@@ -188,10 +220,21 @@ export class Model {
188
220
  let sources = undefined;
189
221
  let queries = undefined;
190
222
  let filterMap: Map<string, FilterDefinition[]> | undefined;
223
+ let givens: ApiGiven[] | undefined;
191
224
  const sourceInfos: Malloy.SourceInfo[] = [];
192
225
  if (modelMaterializer) {
193
- modelDef = (await modelMaterializer.getModel())._modelDef;
194
- const sourceResult = Model.getSources(modelPath, modelDef);
226
+ const compiledModel = await modelMaterializer.getModel();
227
+ modelDef = compiledModel._modelDef;
228
+ // Malloy's `Model.givens` already collapses inheritance from imports
229
+ // and applies any `finalizeGivens` runtime config. Just read it.
230
+ const malloyGivens = Array.from(
231
+ compiledModel.givens.values(),
232
+ ) as MalloyGiven[];
233
+ givens =
234
+ malloyGivens.length > 0
235
+ ? (malloyGivens.map(malloyGivenToApi) as ApiGiven[])
236
+ : undefined;
237
+ const sourceResult = Model.getSources(modelPath, modelDef, givens);
195
238
  sources = sourceResult.sources;
196
239
  filterMap = sourceResult.filterMap;
197
240
  queries = Model.getQueries(modelPath, modelDef);
@@ -255,6 +298,7 @@ export class Model {
255
298
  runnableNotebookCells,
256
299
  undefined,
257
300
  filterMap,
301
+ givens,
258
302
  );
259
303
  } catch (error) {
260
304
  let computedError = error;
@@ -285,6 +329,123 @@ export class Model {
285
329
  }
286
330
  }
287
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
+
288
449
  public getPath(): string {
289
450
  return this.modelPath;
290
451
  }
@@ -342,6 +503,7 @@ export class Model {
342
503
  query?: string,
343
504
  filterParams?: FilterParams,
344
505
  bypassFilters?: boolean,
506
+ givens?: Record<string, GivenValue>,
345
507
  ): Promise<{
346
508
  result: Malloy.Result;
347
509
  compactResult: QueryData;
@@ -436,13 +598,14 @@ export class Model {
436
598
  }
437
599
 
438
600
  const rowLimit =
439
- (await runnable.getPreparedResult()).resultExplore.limit || ROW_LIMIT;
601
+ (await runnable.getPreparedResult({ givens })).resultExplore.limit ||
602
+ ROW_LIMIT;
440
603
  const endTime = performance.now();
441
604
  const executionTime = endTime - startTime;
442
605
 
443
606
  let queryResults;
444
607
  try {
445
- queryResults = await runnable.run({ rowLimit });
608
+ queryResults = await runnable.run({ rowLimit, givens });
446
609
  } catch (error) {
447
610
  // Record error metrics
448
611
  const errorEndTime = performance.now();
@@ -501,14 +664,16 @@ export class Model {
501
664
  malloyVersion: MALLOY_VERSION,
502
665
  dataStyles: JSON.stringify(this.dataStyles),
503
666
  modelDef: JSON.stringify(this.modelDef),
504
- modelInfo: JSON.stringify(
505
- this.modelDef ? modelDefToModelInfo(this.modelDef) : {},
506
- ),
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 ?? {}),
507
671
  sourceInfos: this.getSourceInfos()?.map((sourceInfo) =>
508
672
  JSON.stringify(sourceInfo),
509
673
  ),
510
674
  sources: this.sources,
511
675
  queries: this.queries,
676
+ givens: this.givens,
512
677
  } as ApiCompiledModel;
513
678
  }
514
679
 
@@ -554,9 +719,7 @@ export class Model {
554
719
  packageName: this.packageName,
555
720
  modelPath: this.modelPath,
556
721
  malloyVersion: MALLOY_VERSION,
557
- modelInfo: JSON.stringify(
558
- this.modelDef ? modelDefToModelInfo(this.modelDef) : {},
559
- ),
722
+ modelInfo: JSON.stringify(this.modelInfo ?? {}),
560
723
  sources: this.modelDef && this.sources,
561
724
  queries: this.modelDef && this.queries,
562
725
  annotations: allAnnotations,
@@ -568,6 +731,7 @@ export class Model {
568
731
  cellIndex: number,
569
732
  filterParams?: FilterParams,
570
733
  bypassFilters?: boolean,
734
+ givens?: Record<string, GivenValue>,
571
735
  ): Promise<{
572
736
  type: "code" | "markdown";
573
737
  text: string;
@@ -629,9 +793,9 @@ export class Model {
629
793
  }
630
794
 
631
795
  const rowLimit =
632
- (await runnableToExecute.getPreparedResult()).resultExplore
633
- .limit || ROW_LIMIT;
634
- const result = await runnableToExecute.run({ rowLimit });
796
+ (await runnableToExecute.getPreparedResult({ givens }))
797
+ .resultExplore.limit || ROW_LIMIT;
798
+ const result = await runnableToExecute.run({ rowLimit, givens });
635
799
  const query = (await runnableToExecute.getPreparedQuery())._query;
636
800
  queryName = (query as NamedQueryDef).as || query.name;
637
801
  queryResult =
@@ -758,6 +922,7 @@ export class Model {
758
922
  private static getSources(
759
923
  modelPath: string,
760
924
  modelDef: ModelDef,
925
+ givens?: ApiGiven[],
761
926
  ): {
762
927
  sources: ApiSource[];
763
928
  filterMap: Map<string, FilterDefinition[]>;
@@ -846,6 +1011,12 @@ export class Model {
846
1011
  annotations,
847
1012
  views,
848
1013
  filters,
1014
+ // Malloy exposes givens at the model level, not per-source.
1015
+ // First pass: surface the full model-level list on every source
1016
+ // — matches how filter introspection already collapses
1017
+ // inheritance into the per-source list. Refine to view-scoped
1018
+ // filtering if a customer asks.
1019
+ givens,
849
1020
  } as ApiSource;
850
1021
  });
851
1022
 
@@ -1068,3 +1239,103 @@ export class Model {
1068
1239
  }
1069
1240
  }
1070
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({