@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.
- package/build.ts +30 -1
- package/dist/app/api-doc.yaml +51 -0
- package/dist/app/assets/{EnvironmentPage-C7rtH4mC.js → EnvironmentPage-Dpee_Kn6.js} +1 -1
- package/dist/app/assets/{HomePage-DwkH7OrS.js → HomePage-DLRWTNoL.js} +1 -1
- package/dist/app/assets/{MainPage-D38LtZDV.js → MainPage-DsVt5QGM.js} +1 -1
- package/dist/app/assets/{ModelPage-DOol8Mz7.js → ModelPage-AwAugZ37.js} +1 -1
- package/dist/app/assets/{PackagePage-0tgzA_kO.js → PackagePage-XQ-EWGTC.js} +1 -1
- package/dist/app/assets/{RouteError-BaMsOSly.js → RouteError-3Mv8JQw7.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Cx4SePkx.js → WorkbookPage-DHYYpcYc.js} +1 -1
- package/dist/app/assets/{core-CbsC6R_Y.es-Cwf6asf3.js → core-DfcpQGVP.es-DQggNOdX.js} +1 -1
- package/dist/app/assets/{index-DNofXMxi.js → index-BUp81Qdm.js} +1 -1
- package/dist/app/assets/{index-DL6BZTuw.js → index-D1pdwrUW.js} +1 -1
- package/dist/app/assets/{index-U38AyjJL.js → index-Dv5bF4Ii.js} +4 -4
- package/dist/app/assets/{index.umd-B68wGGkM.js → index.umd-CQH4LZU8.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +57 -36
- package/dist/package_load_worker.mjs +12213 -0
- package/dist/server.mjs +2807 -2729
- package/package.json +2 -3
- package/src/controller/compile.controller.ts +3 -1
- package/src/controller/model.controller.ts +8 -1
- package/src/controller/query.controller.ts +3 -0
- package/src/health.spec.ts +90 -0
- package/src/health.ts +88 -45
- package/src/instrumentation.ts +50 -0
- package/src/mcp/tools/execute_query_tool.ts +12 -0
- package/src/package_load/package_load_pool.spec.ts +252 -0
- package/src/package_load/package_load_pool.ts +920 -0
- package/src/package_load/package_load_worker.ts +980 -0
- package/src/package_load/protocol.ts +336 -0
- package/src/query_param_utils.ts +18 -0
- package/src/server-old.ts +1 -1
- package/src/server.ts +36 -10
- package/src/service/db_utils.spec.ts +1 -1
- package/src/service/environment.ts +3 -2
- package/src/service/environment_store.ts +24 -3
- package/src/service/filter_integration.spec.ts +110 -0
- package/src/service/given.ts +80 -0
- package/src/service/givens_integration.spec.ts +192 -0
- package/src/service/model.spec.ts +105 -0
- package/src/service/model.ts +287 -16
- package/src/service/package.spec.ts +10 -0
- package/src/service/package.ts +257 -145
- package/src/service/package_worker_path.spec.ts +196 -0
- 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
|
|