@malloy-publisher/server 0.0.198 → 0.0.200

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 (75) hide show
  1. package/build.ts +30 -1
  2. package/dist/app/api-doc.yaml +127 -111
  3. package/dist/app/assets/{EnvironmentPage-C7rtH4mC.js → EnvironmentPage-CgKNjySu.js} +1 -1
  4. package/dist/app/assets/HomePage-BPIpMBjW.js +1 -0
  5. package/dist/app/assets/{MainPage-D38LtZDV.js → MainPage-CAwb8U82.js} +2 -2
  6. package/dist/app/assets/{ModelPage-DOol8Mz7.js → ModelPage-C0Uevsw9.js} +1 -1
  7. package/dist/app/assets/{PackagePage-0tgzA_kO.js → PackagePage-Cu-u9k1g.js} +1 -1
  8. package/dist/app/assets/{RouteError-BaMsOSly.js → RouteError-DVwPh2Ql.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Cx4SePkx.js → WorkbookPage-DW38R2Zv.js} +1 -1
  10. package/dist/app/assets/{core-CbsC6R_Y.es-Cwf6asf3.js → core-C0vCMRDQ.es-D_ytHhjS.js} +10 -10
  11. package/dist/app/assets/{index-DL6BZTuw.js → index-BGdcKsFF.js} +1 -1
  12. package/dist/app/assets/{index-DNofXMxi.js → index-CTx4v4_3.js} +1 -1
  13. package/dist/app/assets/index-DE6d5jEy.js +452 -0
  14. package/dist/app/assets/{index.umd-B68wGGkM.js → index.umd-C1Mi1uRm.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 +4198 -3648
  19. package/package.json +2 -3
  20. package/src/config.spec.ts +246 -0
  21. package/src/config.ts +121 -1
  22. package/src/constants.ts +84 -1
  23. package/src/controller/compile.controller.ts +3 -1
  24. package/src/controller/connection.controller.spec.ts +803 -0
  25. package/src/controller/connection.controller.ts +207 -20
  26. package/src/controller/model.controller.ts +19 -1
  27. package/src/controller/query.controller.ts +22 -6
  28. package/src/controller/watch-mode.controller.ts +11 -2
  29. package/src/errors.spec.ts +44 -0
  30. package/src/errors.ts +34 -0
  31. package/src/health.spec.ts +90 -0
  32. package/src/health.ts +88 -45
  33. package/src/heap_check.spec.ts +144 -0
  34. package/src/heap_check.ts +144 -0
  35. package/src/instrumentation.ts +50 -0
  36. package/src/mcp/handler_utils.ts +14 -0
  37. package/src/mcp/tools/execute_query_tool.ts +52 -10
  38. package/src/oom_guards.integration.spec.ts +261 -0
  39. package/src/package_load/package_load_pool.spec.ts +252 -0
  40. package/src/package_load/package_load_pool.ts +920 -0
  41. package/src/package_load/package_load_worker.ts +980 -0
  42. package/src/package_load/protocol.ts +336 -0
  43. package/src/path_safety.ts +9 -3
  44. package/src/query_cap_metrics.spec.ts +89 -0
  45. package/src/query_cap_metrics.ts +115 -0
  46. package/src/query_concurrency.spec.ts +247 -0
  47. package/src/query_concurrency.ts +236 -0
  48. package/src/query_param_utils.ts +18 -0
  49. package/src/query_timeout.spec.ts +224 -0
  50. package/src/query_timeout.ts +178 -0
  51. package/src/server-old.ts +21 -1
  52. package/src/server.ts +61 -57
  53. package/src/service/connection.ts +8 -2
  54. package/src/service/db_utils.spec.ts +1 -1
  55. package/src/service/environment.ts +85 -4
  56. package/src/service/environment_admission.spec.ts +165 -1
  57. package/src/service/environment_store.spec.ts +103 -0
  58. package/src/service/environment_store.ts +98 -26
  59. package/src/service/filter_integration.spec.ts +110 -0
  60. package/src/service/given.ts +80 -0
  61. package/src/service/givens_integration.spec.ts +192 -0
  62. package/src/service/model.spec.ts +298 -3
  63. package/src/service/model.ts +362 -23
  64. package/src/service/model_limits.spec.ts +181 -0
  65. package/src/service/model_limits.ts +110 -0
  66. package/src/service/package.spec.ts +12 -6
  67. package/src/service/package.ts +263 -146
  68. package/src/service/package_worker_path.spec.ts +196 -0
  69. package/src/service/path_injection.spec.ts +39 -0
  70. package/src/stream_helpers.spec.ts +280 -0
  71. package/src/stream_helpers.ts +162 -0
  72. package/src/test_helpers/metrics_harness.ts +126 -0
  73. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
  74. package/dist/app/assets/HomePage-DwkH7OrS.js +0 -1
  75. package/dist/app/assets/index-U38AyjJL.js +0 -451
@@ -0,0 +1,192 @@
1
+ import { DuckDBConnection } from "@malloydata/db-duckdb";
2
+ import { Connection } from "@malloydata/malloy";
3
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
4
+ import fs from "fs/promises";
5
+ import os from "os";
6
+ import path from "path";
7
+ import { Model } from "./model";
8
+
9
+ const TEST_DIR = path.join(os.tmpdir(), "givens-integration-tests");
10
+ const TEST_DB_DIR = path.join(TEST_DIR, "db");
11
+ const TEST_DB_PATH = path.join(TEST_DB_DIR, "test.duckdb");
12
+ const TEST_PKG_DIR = path.join(TEST_DIR, "pkg");
13
+
14
+ let duckdbConnection: DuckDBConnection;
15
+
16
+ const SEED_SQL = `
17
+ CREATE TABLE IF NOT EXISTS orders (
18
+ order_id INTEGER,
19
+ region VARCHAR,
20
+ order_date DATE
21
+ );
22
+ INSERT INTO orders VALUES
23
+ (1, 'US', '2024-01-15'),
24
+ (2, 'EU', '2024-02-10'),
25
+ (3, 'APAC', '2024-03-05');
26
+ `;
27
+
28
+ const MODEL_WITH_GIVENS = `
29
+ ##! experimental.givens
30
+
31
+ given: region_filter :: string is 'US'
32
+ given: cutoff_date :: date is @2024-02-01
33
+
34
+ source: orders is duckdb.table('orders') extend {
35
+ primary_key: order_id
36
+
37
+ measure: order_count is count()
38
+ }
39
+ `;
40
+
41
+ const MODEL_WITHOUT_GIVENS = `
42
+ source: orders is duckdb.table('orders') extend {
43
+ primary_key: order_id
44
+
45
+ measure: order_count is count()
46
+ }
47
+ `;
48
+
49
+ const MODEL_WITH_ANNOTATED_GIVEN = `
50
+ ##! experimental.givens
51
+
52
+ #(doc) Region code, e.g. US, EU
53
+ #(label) Region
54
+ given: region_filter :: string is 'US'
55
+
56
+ source: orders is duckdb.table('orders') extend {
57
+ primary_key: order_id
58
+ }
59
+ `;
60
+
61
+ beforeAll(async () => {
62
+ await fs.mkdir(TEST_DB_DIR, { recursive: true });
63
+ await fs.mkdir(TEST_PKG_DIR, { recursive: true });
64
+ duckdbConnection = new DuckDBConnection("duckdb", TEST_DB_PATH, TEST_DB_DIR);
65
+ for (const stmt of SEED_SQL.trim().split(";").filter(Boolean)) {
66
+ await duckdbConnection.runSQL(stmt.trim() + ";");
67
+ }
68
+ // Each fixture lives in its own file. Tests share `beforeAll` for harness
69
+ // setup but never edit these files at runtime, so no `beforeEach` /
70
+ // `afterEach` cleanup is needed.
71
+ await fs.writeFile(
72
+ path.join(TEST_PKG_DIR, "orders.malloy"),
73
+ MODEL_WITH_GIVENS,
74
+ "utf-8",
75
+ );
76
+ await fs.writeFile(
77
+ path.join(TEST_PKG_DIR, "orders_no_givens.malloy"),
78
+ MODEL_WITHOUT_GIVENS,
79
+ "utf-8",
80
+ );
81
+ await fs.writeFile(
82
+ path.join(TEST_PKG_DIR, "orders_annotated.malloy"),
83
+ MODEL_WITH_ANNOTATED_GIVEN,
84
+ "utf-8",
85
+ );
86
+ });
87
+
88
+ afterAll(async () => {
89
+ try {
90
+ await duckdbConnection.close();
91
+ await new Promise((resolve) => setTimeout(resolve, 100));
92
+ await fs.rm(TEST_DIR, { recursive: true, force: true });
93
+ } catch {
94
+ // Ignore cleanup errors
95
+ }
96
+ });
97
+
98
+ function getConnections(): Map<string, Connection> {
99
+ const map = new Map<string, Connection>();
100
+ map.set("duckdb", duckdbConnection);
101
+ return map;
102
+ }
103
+
104
+ describe("givens introspection", () => {
105
+ it("surfaces declared givens on the compiled-model response", async () => {
106
+ const model = await Model.create(
107
+ "test-pkg",
108
+ TEST_PKG_DIR,
109
+ "orders.malloy",
110
+ getConnections(),
111
+ );
112
+
113
+ const compiledModel = await model.getModel();
114
+
115
+ expect(compiledModel.givens).toBeDefined();
116
+ expect(compiledModel.givens).toHaveLength(2);
117
+
118
+ const byName = new Map(
119
+ (compiledModel.givens ?? []).map((g) => [g.name, g]),
120
+ );
121
+ const region = byName.get("region_filter");
122
+ const cutoff = byName.get("cutoff_date");
123
+
124
+ expect(region).toBeDefined();
125
+ expect(region?.type).toBe("string");
126
+ expect(cutoff).toBeDefined();
127
+ expect(cutoff?.type).toBe("date");
128
+ });
129
+
130
+ it("attaches the model-level givens list to every source", async () => {
131
+ const model = await Model.create(
132
+ "test-pkg",
133
+ TEST_PKG_DIR,
134
+ "orders.malloy",
135
+ getConnections(),
136
+ );
137
+
138
+ const sources = model.getSources();
139
+ expect(sources).toBeDefined();
140
+ expect(sources).toHaveLength(1);
141
+
142
+ const ordersSource = sources?.[0];
143
+ expect(ordersSource?.name).toBe("orders");
144
+ expect(ordersSource?.givens).toBeDefined();
145
+ expect(ordersSource?.givens).toHaveLength(2);
146
+
147
+ const names = (ordersSource?.givens ?? []).map((g) => g.name).sort();
148
+ expect(names).toEqual(["cutoff_date", "region_filter"]);
149
+ });
150
+
151
+ it("returns undefined for givens when the model declares none", async () => {
152
+ const model = await Model.create(
153
+ "test-pkg",
154
+ TEST_PKG_DIR,
155
+ "orders_no_givens.malloy",
156
+ getConnections(),
157
+ );
158
+
159
+ const compiledModel = await model.getModel();
160
+
161
+ // Absent rather than empty: matches how `sources`/`queries` behave when
162
+ // there are none, and lets OpenAPI clients distinguish "feature
163
+ // unsupported" from "supported but no declarations."
164
+ expect(compiledModel.givens).toBeUndefined();
165
+ expect(model.getSources()?.[0]?.givens).toBeUndefined();
166
+ });
167
+
168
+ it("surfaces only `#(...)` annotations, not pragmas or doc comments", async () => {
169
+ const model = await Model.create(
170
+ "test-pkg",
171
+ TEST_PKG_DIR,
172
+ "orders_annotated.malloy",
173
+ getConnections(),
174
+ );
175
+
176
+ const compiledModel = await model.getModel();
177
+
178
+ expect(compiledModel.givens).toHaveLength(1);
179
+ const region = compiledModel.givens?.[0];
180
+ expect(region?.name).toBe("region_filter");
181
+
182
+ // The model declares two `#(...)` annotations plus a `##!` pragma.
183
+ // Only the `#(...)` lines should land on the wire.
184
+ const annotations = region?.annotations ?? [];
185
+ expect(annotations.length).toBeGreaterThanOrEqual(2);
186
+ for (const line of annotations) {
187
+ expect(line.startsWith("#(")).toBe(true);
188
+ }
189
+ // Negative assertion: no pragma leakage.
190
+ expect(annotations.some((a) => a.startsWith("##!"))).toBe(false);
191
+ });
192
+ });
@@ -1,9 +1,13 @@
1
- import { MalloyError, Runtime } from "@malloydata/malloy";
2
- import { describe, expect, it } from "bun:test";
1
+ import { API, MalloyError, Runtime } from "@malloydata/malloy";
2
+ import { afterEach, describe, expect, it } from "bun:test";
3
3
  import fs from "fs/promises";
4
4
  import sinon from "sinon";
5
5
 
6
- import { BadRequestError, ModelNotFoundError } from "../errors";
6
+ import {
7
+ BadRequestError,
8
+ ModelNotFoundError,
9
+ PayloadTooLargeError,
10
+ } from "../errors";
7
11
  import { Model, ModelType } from "./model";
8
12
 
9
13
  describe("service/model", () => {
@@ -234,6 +238,297 @@ describe("service/model", () => {
234
238
 
235
239
  sinon.restore();
236
240
  });
241
+
242
+ it("forwards givens to runnable.getPreparedResult and .run", async () => {
243
+ const givensArg = { region: "EU" };
244
+ const preparedResultStub = sinon
245
+ .stub()
246
+ .resolves({ resultExplore: { limit: 10 } });
247
+ const runStub = sinon
248
+ .stub()
249
+ .rejects(new MalloyError("stub-stop", []));
250
+ const modelMaterializer = {
251
+ loadQuery: sinon.stub().returns({
252
+ getPreparedResult: preparedResultStub,
253
+ run: runStub,
254
+ }),
255
+ };
256
+
257
+ const model = new Model(
258
+ packageName,
259
+ mockModelPath,
260
+ {},
261
+ "model",
262
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
263
+ modelMaterializer as any,
264
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
+ { contents: {}, exports: [], queryList: [] } as any,
266
+ undefined,
267
+ undefined,
268
+ undefined,
269
+ undefined,
270
+ undefined,
271
+ );
272
+
273
+ await expect(
274
+ model.getQueryResults(
275
+ undefined,
276
+ undefined,
277
+ "run: orders -> summary",
278
+ undefined,
279
+ undefined,
280
+ givensArg,
281
+ ),
282
+ ).rejects.toThrow(MalloyError);
283
+
284
+ expect(preparedResultStub.calledOnce).toBe(true);
285
+ expect(preparedResultStub.firstCall.args[0]).toEqual({
286
+ givens: givensArg,
287
+ });
288
+ expect(runStub.firstCall.args[0]).toMatchObject({
289
+ givens: givensArg,
290
+ });
291
+
292
+ sinon.restore();
293
+ });
294
+
295
+ /**
296
+ * The row/byte caps live in `model_limits.ts` (unit-tested in
297
+ * `model_limits.spec.ts`); these tests just confirm the wiring —
298
+ * that `Model.getQueryResults` calls the helpers with the right
299
+ * values and that an overflow propagates as `PayloadTooLargeError`
300
+ * (HTTP 413), not the generic `BadRequestError` (HTTP 400).
301
+ */
302
+ describe("response caps", () => {
303
+ const originalRowsEnv = process.env.PUBLISHER_MAX_QUERY_ROWS;
304
+ const originalBytesEnv = process.env.PUBLISHER_MAX_RESPONSE_BYTES;
305
+ const originalDefaultEnv =
306
+ process.env.PUBLISHER_DEFAULT_QUERY_ROW_LIMIT;
307
+
308
+ afterEach(() => {
309
+ sinon.restore();
310
+ for (const [name, original] of [
311
+ ["PUBLISHER_MAX_QUERY_ROWS", originalRowsEnv],
312
+ ["PUBLISHER_MAX_RESPONSE_BYTES", originalBytesEnv],
313
+ ["PUBLISHER_DEFAULT_QUERY_ROW_LIMIT", originalDefaultEnv],
314
+ ] as const) {
315
+ if (original === undefined) {
316
+ delete process.env[name];
317
+ } else {
318
+ process.env[name] = original;
319
+ }
320
+ }
321
+ });
322
+
323
+ /**
324
+ * Build a Model whose `runnable.run` resolves to a fake Result
325
+ * with the given totalRows; stub `API.util.wrapResult` so we
326
+ * don't need to construct a real Malloy schema/queryResult.
327
+ */
328
+ function buildModelWithFakeRun(opts: {
329
+ userLimit?: number;
330
+ totalRows: number;
331
+ wrappedJson: object;
332
+ }): { model: Model; runStub: sinon.SinonStub } {
333
+ const preparedResultStub = sinon
334
+ .stub()
335
+ .resolves({ resultExplore: { limit: opts.userLimit ?? 0 } });
336
+ const fakeResult = {
337
+ _queryResult: { data: { rawData: [] } },
338
+ totalRows: opts.totalRows,
339
+ data: { value: [] },
340
+ connectionName: "fake",
341
+ };
342
+ const runStub = sinon.stub().resolves(fakeResult);
343
+ sinon
344
+ .stub(API.util, "wrapResult")
345
+ .returns(
346
+ opts.wrappedJson as unknown as ReturnType<
347
+ typeof API.util.wrapResult
348
+ >,
349
+ );
350
+ const modelMaterializer = {
351
+ loadQuery: sinon.stub().returns({
352
+ getPreparedResult: preparedResultStub,
353
+ run: runStub,
354
+ }),
355
+ };
356
+ const model = new Model(
357
+ packageName,
358
+ mockModelPath,
359
+ {},
360
+ "model",
361
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
+ modelMaterializer as any,
363
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
364
+ { contents: {}, exports: [], queryList: [] } as any,
365
+ undefined,
366
+ undefined,
367
+ undefined,
368
+ undefined,
369
+ undefined,
370
+ );
371
+ return { model, runStub };
372
+ }
373
+
374
+ it("clamps user LIMIT to maxRows + 1 when the user requested more than the cap", async () => {
375
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "100";
376
+ const { model, runStub } = buildModelWithFakeRun({
377
+ userLimit: 1_000_000,
378
+ totalRows: 10,
379
+ wrappedJson: { rows: [] },
380
+ });
381
+
382
+ await model.getQueryResults(
383
+ undefined,
384
+ undefined,
385
+ "run: orders -> summary",
386
+ );
387
+
388
+ expect(runStub.firstCall.args[0].rowLimit).toBe(101);
389
+ });
390
+
391
+ it("passes user LIMIT through when below maxRows", async () => {
392
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "100";
393
+ const { model, runStub } = buildModelWithFakeRun({
394
+ userLimit: 50,
395
+ totalRows: 10,
396
+ wrappedJson: { rows: [] },
397
+ });
398
+
399
+ await model.getQueryResults(
400
+ undefined,
401
+ undefined,
402
+ "run: orders -> summary",
403
+ );
404
+
405
+ expect(runStub.firstCall.args[0].rowLimit).toBe(50);
406
+ });
407
+
408
+ it("falls back to PUBLISHER_DEFAULT_QUERY_ROW_LIMIT when the user query has no LIMIT", async () => {
409
+ process.env.PUBLISHER_DEFAULT_QUERY_ROW_LIMIT = "42";
410
+ delete process.env.PUBLISHER_MAX_QUERY_ROWS;
411
+ const { model, runStub } = buildModelWithFakeRun({
412
+ userLimit: 0,
413
+ totalRows: 10,
414
+ wrappedJson: { rows: [] },
415
+ });
416
+
417
+ await model.getQueryResults(
418
+ undefined,
419
+ undefined,
420
+ "run: orders -> summary",
421
+ );
422
+
423
+ expect(runStub.firstCall.args[0].rowLimit).toBe(42);
424
+ });
425
+
426
+ it("throws PayloadTooLargeError (not BadRequestError) when totalRows exceeds the cap", async () => {
427
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "100";
428
+ const { model } = buildModelWithFakeRun({
429
+ userLimit: 1000,
430
+ totalRows: 101,
431
+ wrappedJson: { rows: [] },
432
+ });
433
+
434
+ await expect(
435
+ model.getQueryResults(
436
+ undefined,
437
+ undefined,
438
+ "run: orders -> summary",
439
+ ),
440
+ ).rejects.toBeInstanceOf(PayloadTooLargeError);
441
+ });
442
+
443
+ it("throws PayloadTooLargeError when the wrapped response exceeds the byte cap", async () => {
444
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "1000";
445
+ process.env.PUBLISHER_MAX_RESPONSE_BYTES = "100";
446
+ const huge = "x".repeat(500);
447
+ const { model } = buildModelWithFakeRun({
448
+ userLimit: 10,
449
+ totalRows: 10,
450
+ wrappedJson: { rows: [{ s: huge }] },
451
+ });
452
+
453
+ await expect(
454
+ model.getQueryResults(
455
+ undefined,
456
+ undefined,
457
+ "run: orders -> summary",
458
+ ),
459
+ ).rejects.toBeInstanceOf(PayloadTooLargeError);
460
+ });
461
+
462
+ it("does not throw when both counts are within their caps", async () => {
463
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "1000";
464
+ process.env.PUBLISHER_MAX_RESPONSE_BYTES = "10000";
465
+ const { model } = buildModelWithFakeRun({
466
+ userLimit: 10,
467
+ totalRows: 10,
468
+ wrappedJson: { rows: [{ a: 1 }] },
469
+ });
470
+
471
+ await expect(
472
+ model.getQueryResults(
473
+ undefined,
474
+ undefined,
475
+ "run: orders -> summary",
476
+ ),
477
+ ).resolves.toBeDefined();
478
+ });
479
+ });
480
+ });
481
+
482
+ describe("executeNotebookCell", () => {
483
+ it("forwards givens to runnable.getPreparedResult and .run", async () => {
484
+ const givensArg = { target_code: "AA" };
485
+ const preparedResultStub = sinon
486
+ .stub()
487
+ .resolves({ resultExplore: { limit: 10 } });
488
+ const runStub = sinon
489
+ .stub()
490
+ .rejects(new MalloyError("stub-stop", []));
491
+ const cellRunnable = {
492
+ getPreparedResult: preparedResultStub,
493
+ run: runStub,
494
+ };
495
+ const runnableCells = [
496
+ {
497
+ type: "code" as const,
498
+ text: "run: orders -> by_code",
499
+ runnable: cellRunnable,
500
+ },
501
+ ];
502
+
503
+ const model = new Model(
504
+ packageName,
505
+ "test.malloynb",
506
+ {},
507
+ "notebook",
508
+ undefined,
509
+ undefined,
510
+ undefined,
511
+ undefined,
512
+ undefined,
513
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
514
+ runnableCells as any,
515
+ undefined,
516
+ );
517
+
518
+ await expect(
519
+ model.executeNotebookCell(0, undefined, undefined, givensArg),
520
+ ).rejects.toThrow(MalloyError);
521
+
522
+ expect(preparedResultStub.calledOnce).toBe(true);
523
+ expect(preparedResultStub.firstCall.args[0]).toEqual({
524
+ givens: givensArg,
525
+ });
526
+ expect(runStub.firstCall.args[0]).toMatchObject({
527
+ givens: givensArg,
528
+ });
529
+
530
+ sinon.restore();
531
+ });
237
532
  });
238
533
  });
239
534