@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
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared utilities for surfacing Malloy `Given` declarations on
3
+ * compiled models.
4
+ *
5
+ * The Malloy SDK's `Given` class is declared in
6
+ * `@malloydata/malloy/dist/api/foundation/core.d.ts` but is not
7
+ * re-exported from the package root, so we duck-type against the
8
+ * surface we actually use and don't pull in the private type.
9
+ *
10
+ * Lives here so both the main-thread `Model` constructor and the
11
+ * package-load worker can use the same conversion. The worker
12
+ * imports this file directly (it's pure TypeScript with no native
13
+ * deps, so it's safe to bundle into the worker entry).
14
+ */
15
+
16
+ /**
17
+ * Duck-typed shape of a Malloy SDK `Given` instance (the value type
18
+ * of `Model.givens`).
19
+ */
20
+ export interface MalloyGiven {
21
+ readonly name: string;
22
+ readonly type: { type: string; filterType?: string };
23
+ getTaglines(prefix?: RegExp): string[];
24
+ }
25
+
26
+ /**
27
+ * Wire/API shape of a given. Structurally identical to the
28
+ * `components["schemas"]["Given"]` shape from the OpenAPI spec —
29
+ * callers can cast freely.
30
+ */
31
+ export interface MalloyGivenApi {
32
+ name: string;
33
+ type: string;
34
+ annotations?: string[];
35
+ }
36
+
37
+ /**
38
+ * Convert a Malloy SDK `Given` to the wire/API shape.
39
+ *
40
+ * Two fields are deliberately not surfaced:
41
+ *
42
+ * - `location` — Malloy's `DocumentLocation.url` is an absolute
43
+ * `file://` path on the publisher's filesystem. Surfacing it
44
+ * would leak the OS user, install directory, and internal
45
+ * layout. Existing `Filter` introspection does not expose
46
+ * location either; matching that floor. A future PR can add a
47
+ * sanitised package-relative path if a client needs it.
48
+ *
49
+ * - `default` / `defaultText` — Malloy's API only exposes the
50
+ * parsed `ConstantExpr` AST, not a rendered source string.
51
+ * Rendering it here would duplicate the Malloy printer. Add
52
+ * when Malloy surfaces a stringified accessor.
53
+ *
54
+ * `annotations` is restricted to `#(...)` declaration annotations
55
+ * (the caller-facing kind, e.g. `#(doc)`). `getTaglines()` with no
56
+ * prefix would also return `##` doc-comment lines and the
57
+ * model-level `##!` pragma, which aren't part of the given's
58
+ * surface contract.
59
+ *
60
+ * Type rendering: `GivenTypeDef` is typed as `AtomicTypeDef |
61
+ * FilterExpressionParamTypeDef`, but Malloy's grammar only emits
62
+ * the scalar parameter types (`string` | `number` | `boolean` |
63
+ * `date` | `timestamp` | `timestamptz` | `filter expression` |
64
+ * `error`) for given declarations today. If the grammar expands
65
+ * to allow array or record givens, the bare `type.type`
66
+ * discriminator (`'array'`, `'record'`) will land in the wire
67
+ * response with no element info — revisit when that happens.
68
+ */
69
+ export function malloyGivenToApi(given: MalloyGiven): MalloyGivenApi {
70
+ const type = given.type;
71
+ const renderedType =
72
+ type.type === "filter expression"
73
+ ? `filter<${type.filterType}>`
74
+ : type.type;
75
+ return {
76
+ name: given.name,
77
+ type: renderedType,
78
+ annotations: given.getTaglines(/^#\(/),
79
+ };
80
+ }
@@ -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
+ });
@@ -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