@malloy-publisher/server 0.0.198-dev → 0.0.198-dev1

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 (86) hide show
  1. package/README.docker.md +135 -20
  2. package/README.md +15 -0
  3. package/build.ts +42 -1
  4. package/dist/app/api-doc.yaml +51 -0
  5. package/dist/app/assets/EnvironmentPage-Dpee_Kn6.js +1 -0
  6. package/dist/app/assets/HomePage-DLRWTNoL.js +1 -0
  7. package/dist/app/assets/MainPage-DsVt5QGM.js +2 -0
  8. package/dist/app/assets/ModelPage-AwAugZ37.js +1 -0
  9. package/dist/app/assets/PackagePage-XQ-EWGTC.js +1 -0
  10. package/dist/app/assets/RouteError-3Mv8JQw7.js +1 -0
  11. package/dist/app/assets/WorkbookPage-DHYYpcYc.js +1 -0
  12. package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-DfcpQGVP.es-DQggNOdX.js} +14 -14
  13. package/dist/app/assets/{index-C513UodQ.js → index-BUp81Qdm.js} +15 -15
  14. package/dist/app/assets/index-D1pdwrUW.js +1803 -0
  15. package/dist/app/assets/index-Dv5bF4Ii.js +451 -0
  16. package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CQH4LZU8.js} +1 -1
  17. package/dist/app/index.html +2 -3
  18. package/dist/compile_worker.mjs +628 -0
  19. package/dist/default-publisher.config.json +23 -0
  20. package/dist/instrumentation.mjs +36 -38
  21. package/dist/server.mjs +2060 -913
  22. package/package.json +11 -12
  23. package/publisher.config.example.bigquery.json +33 -0
  24. package/publisher.config.example.duckdb.json +23 -0
  25. package/publisher.config.json +1 -11
  26. package/src/compile/compile_pool.spec.ts +227 -0
  27. package/src/compile/compile_pool.ts +729 -0
  28. package/src/compile/compile_worker.ts +683 -0
  29. package/src/compile/protocol.ts +251 -0
  30. package/src/config.spec.ts +306 -0
  31. package/src/config.ts +222 -2
  32. package/src/controller/compile.controller.ts +3 -1
  33. package/src/controller/connection.controller.ts +1 -1
  34. package/src/controller/model.controller.ts +8 -1
  35. package/src/controller/package.controller.ts +70 -29
  36. package/src/controller/query.controller.ts +3 -0
  37. package/src/default-publisher.config.json +23 -0
  38. package/src/errors.spec.ts +42 -0
  39. package/src/errors.ts +21 -0
  40. package/src/health.spec.ts +90 -0
  41. package/src/health.ts +86 -45
  42. package/src/logger.ts +1 -3
  43. package/src/mcp/tools/discovery_tools.ts +6 -2
  44. package/src/mcp/tools/execute_query_tool.ts +12 -0
  45. package/src/path_safety.spec.ts +158 -0
  46. package/src/path_safety.ts +140 -0
  47. package/src/pg_helpers.spec.ts +226 -0
  48. package/src/pg_helpers.ts +129 -0
  49. package/src/server-old.ts +3 -23
  50. package/src/server.ts +49 -0
  51. package/src/service/connection.spec.ts +6 -4
  52. package/src/service/connection.ts +8 -3
  53. package/src/service/connection_config.ts +2 -2
  54. package/src/service/environment.ts +621 -176
  55. package/src/service/environment_admission.spec.ts +180 -0
  56. package/src/service/environment_store.ts +22 -0
  57. package/src/service/filter_integration.spec.ts +110 -0
  58. package/src/service/givens_integration.spec.ts +192 -0
  59. package/src/service/manifest_service.spec.ts +7 -2
  60. package/src/service/manifest_service.ts +8 -2
  61. package/src/service/materialization_service.ts +14 -3
  62. package/src/service/model.spec.ts +105 -0
  63. package/src/service/model.ts +317 -10
  64. package/src/service/model_worker_path.spec.ts +125 -0
  65. package/src/service/package.ts +4 -3
  66. package/src/service/package_memory_governor.spec.ts +173 -0
  67. package/src/service/package_memory_governor.ts +233 -0
  68. package/src/service/package_race.spec.ts +208 -0
  69. package/src/storage/StorageManager.ts +71 -11
  70. package/src/storage/duckdb/schema.ts +41 -0
  71. package/src/utils.ts +11 -0
  72. package/tests/harness/rest_e2e.ts +2 -2
  73. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
  74. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
  75. package/tests/unit/duckdb/attached_databases.test.ts +5 -5
  76. package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
  77. package/tests/unit/storage/StorageManager.test.ts +166 -0
  78. package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
  79. package/dist/app/assets/HomePage-DMop21VG.js +0 -1
  80. package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
  81. package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
  82. package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
  83. package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
  84. package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
  85. package/dist/app/assets/index-5K9YjIxF.js +0 -456
  86. package/dist/app/assets/index-DIgzgp69.js +0 -1742
@@ -234,6 +234,111 @@ describe("service/model", () => {
234
234
 
235
235
  sinon.restore();
236
236
  });
237
+
238
+ it("forwards givens to runnable.getPreparedResult and .run", async () => {
239
+ const givensArg = { region: "EU" };
240
+ const preparedResultStub = sinon
241
+ .stub()
242
+ .resolves({ resultExplore: { limit: 10 } });
243
+ const runStub = sinon
244
+ .stub()
245
+ .rejects(new MalloyError("stub-stop", []));
246
+ const modelMaterializer = {
247
+ loadQuery: sinon.stub().returns({
248
+ getPreparedResult: preparedResultStub,
249
+ run: runStub,
250
+ }),
251
+ };
252
+
253
+ const model = new Model(
254
+ packageName,
255
+ mockModelPath,
256
+ {},
257
+ "model",
258
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
259
+ modelMaterializer as any,
260
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
261
+ { contents: {}, exports: [], queryList: [] } as any,
262
+ undefined,
263
+ undefined,
264
+ undefined,
265
+ undefined,
266
+ undefined,
267
+ );
268
+
269
+ await expect(
270
+ model.getQueryResults(
271
+ undefined,
272
+ undefined,
273
+ "run: orders -> summary",
274
+ undefined,
275
+ undefined,
276
+ givensArg,
277
+ ),
278
+ ).rejects.toThrow(MalloyError);
279
+
280
+ expect(preparedResultStub.calledOnce).toBe(true);
281
+ expect(preparedResultStub.firstCall.args[0]).toEqual({
282
+ givens: givensArg,
283
+ });
284
+ expect(runStub.firstCall.args[0]).toMatchObject({
285
+ givens: givensArg,
286
+ });
287
+
288
+ sinon.restore();
289
+ });
290
+ });
291
+
292
+ describe("executeNotebookCell", () => {
293
+ it("forwards givens to runnable.getPreparedResult and .run", async () => {
294
+ const givensArg = { target_code: "AA" };
295
+ const preparedResultStub = sinon
296
+ .stub()
297
+ .resolves({ resultExplore: { limit: 10 } });
298
+ const runStub = sinon
299
+ .stub()
300
+ .rejects(new MalloyError("stub-stop", []));
301
+ const cellRunnable = {
302
+ getPreparedResult: preparedResultStub,
303
+ run: runStub,
304
+ };
305
+ const runnableCells = [
306
+ {
307
+ type: "code" as const,
308
+ text: "run: orders -> by_code",
309
+ runnable: cellRunnable,
310
+ },
311
+ ];
312
+
313
+ const model = new Model(
314
+ packageName,
315
+ "test.malloynb",
316
+ {},
317
+ "notebook",
318
+ undefined,
319
+ undefined,
320
+ undefined,
321
+ undefined,
322
+ undefined,
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
+ runnableCells as any,
325
+ undefined,
326
+ );
327
+
328
+ await expect(
329
+ model.executeNotebookCell(0, undefined, undefined, givensArg),
330
+ ).rejects.toThrow(MalloyError);
331
+
332
+ expect(preparedResultStub.calledOnce).toBe(true);
333
+ expect(preparedResultStub.firstCall.args[0]).toEqual({
334
+ givens: givensArg,
335
+ });
336
+ expect(runStub.firstCall.args[0]).toMatchObject({
337
+ givens: givensArg,
338
+ });
339
+
340
+ sinon.restore();
341
+ });
237
342
  });
238
343
  });
239
344
 
@@ -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,7 @@ 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 { getCompilePool } from "../compile/compile_pool";
31
33
  import {
32
34
  MODEL_FILE_SUFFIX,
33
35
  NOTEBOOK_FILE_SUFFIX,
@@ -56,6 +58,7 @@ type ApiNotebookCell = components["schemas"]["NotebookCell"];
56
58
  type ApiRawNotebook = components["schemas"]["RawNotebook"];
57
59
  type ApiSource = components["schemas"]["Source"];
58
60
  type ApiFilter = components["schemas"]["Filter"];
61
+ type ApiGiven = components["schemas"]["Given"];
59
62
  type ApiView = components["schemas"]["View"];
60
63
  type ApiQuery = components["schemas"]["Query"];
61
64
  export type ApiConnection = components["schemas"]["Connection"];
@@ -73,6 +76,61 @@ const MALLOY_VERSION = (
73
76
  export type ModelType = "model" | "notebook";
74
77
  type ModelConnectionInput = MalloyConfig | Map<string, Connection>;
75
78
 
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
+
76
134
  interface RunnableNotebookCell {
77
135
  type: "code" | "markdown";
78
136
  text: string;
@@ -83,12 +141,31 @@ interface RunnableNotebookCell {
83
141
  queryInfo?: Malloy.QueryInfo;
84
142
  }
85
143
 
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
+
86
156
  export class Model {
87
157
  private packageName: string;
88
158
  private modelPath: string;
89
159
  private dataStyles: DataStyles;
90
160
  private modelType: ModelType;
91
161
  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;
92
169
  private modelDef: ModelDef | undefined;
93
170
  private modelInfo: Malloy.ModelInfo | undefined;
94
171
  private sources: ApiSource[] | undefined;
@@ -98,6 +175,12 @@ export class Model {
98
175
  private compilationError: MalloyError | Error | undefined;
99
176
  /** Parsed #(filter) definitions keyed by source name. */
100
177
  private filterMap: Map<string, FilterDefinition[]>;
178
+ /** Givens declared on the model, in declaration order. Malloy's
179
+ * `Model.givens` already collapses inheritance; we just stash the list
180
+ * for surfacing on the compiled-model response. */
181
+ 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;
101
184
  private meter = metrics.getMeter("publisher");
102
185
  private queryExecutionHistogram = this.meter.createHistogram(
103
186
  "malloy_model_query_duration",
@@ -121,6 +204,8 @@ export class Model {
121
204
  runnableNotebookCells: RunnableNotebookCell[] | undefined,
122
205
  compilationError: MalloyError | Error | undefined,
123
206
  filterMap?: Map<string, FilterDefinition[]>,
207
+ givens?: ApiGiven[],
208
+ materializerBuilder?: MaterializerBuilder,
124
209
  ) {
125
210
  this.packageName = packageName;
126
211
  this.modelPath = modelPath;
@@ -128,17 +213,41 @@ export class Model {
128
213
  this.modelType = modelType;
129
214
  this.modelDef = modelDef;
130
215
  this.modelMaterializer = modelMaterializer;
216
+ this.materializerBuilder = materializerBuilder;
131
217
  this.sources = sources;
132
218
  this.queries = queries;
133
219
  this.sourceInfos = sourceInfos;
134
220
  this.runnableNotebookCells = runnableNotebookCells;
135
221
  this.compilationError = compilationError;
136
222
  this.filterMap = filterMap ?? new Map();
223
+ this.givens = givens;
137
224
  this.modelInfo = this.modelDef
138
225
  ? modelDefToModelInfo(this.modelDef)
139
226
  : undefined;
140
227
  }
141
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;
249
+ }
250
+
142
251
  /**
143
252
  * Get the parsed filter definitions for a given source name.
144
253
  * Returns an empty array if no filters are declared.
@@ -164,6 +273,158 @@ export class Model {
164
273
  modelPath: string,
165
274
  malloyConfig: ModelConnectionInput,
166
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
+ /**
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.
335
+ */
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(
423
+ packageName: string,
424
+ packagePath: string,
425
+ modelPath: string,
426
+ malloyConfig: ModelConnectionInput,
427
+ options?: { buildManifest?: BuildManifest["entries"] },
167
428
  ): Promise<Model> {
168
429
  // getModelRuntime might throw a ModelNotFoundError. It's the callers responsibility
169
430
  // to pass a valid model path or handle the error.
@@ -188,10 +449,19 @@ export class Model {
188
449
  let sources = undefined;
189
450
  let queries = undefined;
190
451
  let filterMap: Map<string, FilterDefinition[]> | undefined;
452
+ let givens: ApiGiven[] | undefined;
191
453
  const sourceInfos: Malloy.SourceInfo[] = [];
192
454
  if (modelMaterializer) {
193
- modelDef = (await modelMaterializer.getModel())._modelDef;
194
- const sourceResult = Model.getSources(modelPath, modelDef);
455
+ const compiledModel = await modelMaterializer.getModel();
456
+ modelDef = compiledModel._modelDef;
457
+ // Malloy's `Model.givens` already collapses inheritance from imports
458
+ // and applies any `finalizeGivens` runtime config. Just read it.
459
+ const malloyGivens = Array.from(compiledModel.givens.values());
460
+ givens =
461
+ malloyGivens.length > 0
462
+ ? malloyGivens.map(malloyGivenToApi)
463
+ : undefined;
464
+ const sourceResult = Model.getSources(modelPath, modelDef, givens);
195
465
  sources = sourceResult.sources;
196
466
  filterMap = sourceResult.filterMap;
197
467
  queries = Model.getQueries(modelPath, modelDef);
@@ -255,6 +525,7 @@ export class Model {
255
525
  runnableNotebookCells,
256
526
  undefined,
257
527
  filterMap,
528
+ givens,
258
529
  );
259
530
  } catch (error) {
260
531
  let computedError = error;
@@ -342,6 +613,7 @@ export class Model {
342
613
  query?: string,
343
614
  filterParams?: FilterParams,
344
615
  bypassFilters?: boolean,
616
+ givens?: Record<string, GivenValue>,
345
617
  ): Promise<{
346
618
  result: Malloy.Result;
347
619
  compactResult: QueryData;
@@ -363,9 +635,25 @@ export class Model {
363
635
  );
364
636
  }
365
637
  let runnable: QueryMaterializer;
366
- if (!this.modelMaterializer || !this.modelDef || !this.modelInfo)
638
+ if (!this.modelDef || !this.modelInfo)
367
639
  throw new BadRequestError("Model has no queryable entities.");
368
640
 
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
+
369
657
  // Wrap loadQuery calls in try-catch to handle query parsing errors
370
658
  try {
371
659
  let queryString: string;
@@ -406,7 +694,7 @@ export class Model {
406
694
  }
407
695
  }
408
696
 
409
- runnable = this.modelMaterializer.loadQuery(queryString);
697
+ runnable = materializer.loadQuery(queryString);
410
698
  } catch (error) {
411
699
  // Re-throw BadRequestError as-is
412
700
  if (error instanceof BadRequestError) {
@@ -436,13 +724,14 @@ export class Model {
436
724
  }
437
725
 
438
726
  const rowLimit =
439
- (await runnable.getPreparedResult()).resultExplore.limit || ROW_LIMIT;
727
+ (await runnable.getPreparedResult({ givens })).resultExplore.limit ||
728
+ ROW_LIMIT;
440
729
  const endTime = performance.now();
441
730
  const executionTime = endTime - startTime;
442
731
 
443
732
  let queryResults;
444
733
  try {
445
- queryResults = await runnable.run({ rowLimit });
734
+ queryResults = await runnable.run({ rowLimit, givens });
446
735
  } catch (error) {
447
736
  // Record error metrics
448
737
  const errorEndTime = performance.now();
@@ -494,7 +783,14 @@ export class Model {
494
783
  }
495
784
 
496
785
  private getStandardModel(): ApiCompiledModel {
497
- return {
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 = {
498
794
  type: "source",
499
795
  packageName: this.packageName,
500
796
  modelPath: this.modelPath,
@@ -509,7 +805,10 @@ export class Model {
509
805
  ),
510
806
  sources: this.sources,
511
807
  queries: this.queries,
808
+ givens: this.givens,
512
809
  } as ApiCompiledModel;
810
+ this.cachedStandardModel = compiled;
811
+ return compiled;
513
812
  }
514
813
 
515
814
  private async getNotebookModel(): Promise<ApiRawNotebook> {
@@ -568,6 +867,7 @@ export class Model {
568
867
  cellIndex: number,
569
868
  filterParams?: FilterParams,
570
869
  bypassFilters?: boolean,
870
+ givens?: Record<string, GivenValue>,
571
871
  ): Promise<{
572
872
  type: "code" | "markdown";
573
873
  text: string;
@@ -629,9 +929,9 @@ export class Model {
629
929
  }
630
930
 
631
931
  const rowLimit =
632
- (await runnableToExecute.getPreparedResult()).resultExplore
633
- .limit || ROW_LIMIT;
634
- const result = await runnableToExecute.run({ rowLimit });
932
+ (await runnableToExecute.getPreparedResult({ givens }))
933
+ .resultExplore.limit || ROW_LIMIT;
934
+ const result = await runnableToExecute.run({ rowLimit, givens });
635
935
  const query = (await runnableToExecute.getPreparedQuery())._query;
636
936
  queryName = (query as NamedQueryDef).as || query.name;
637
937
  queryResult =
@@ -758,6 +1058,7 @@ export class Model {
758
1058
  private static getSources(
759
1059
  modelPath: string,
760
1060
  modelDef: ModelDef,
1061
+ givens?: ApiGiven[],
761
1062
  ): {
762
1063
  sources: ApiSource[];
763
1064
  filterMap: Map<string, FilterDefinition[]>;
@@ -846,6 +1147,12 @@ export class Model {
846
1147
  annotations,
847
1148
  views,
848
1149
  filters,
1150
+ // Malloy exposes givens at the model level, not per-source.
1151
+ // First pass: surface the full model-level list on every source
1152
+ // — matches how filter introspection already collapses
1153
+ // inheritance into the per-source list. Refine to view-scoped
1154
+ // filtering if a customer asks.
1155
+ givens,
849
1156
  } as ApiSource;
850
1157
  });
851
1158