@malloy-publisher/server 0.0.382-dev → 0.2.1

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 (175) hide show
  1. package/README.docker.md +145 -20
  2. package/README.md +65 -12
  3. package/dist/app/api-doc.html +5 -0
  4. package/dist/app/api-doc.yaml +3929 -534
  5. package/dist/app/assets/EnvironmentPage-CHq5BF4l.js +1 -0
  6. package/dist/app/assets/HomePage-CIp-pJCa.js +1 -0
  7. package/dist/app/assets/LightMode-mHHPjEeA.js +1 -0
  8. package/dist/app/assets/MainPage-CylxO5Yu.js +2 -0
  9. package/dist/app/assets/MaterializationsPage-a0vjqxib.js +1 -0
  10. package/dist/app/assets/ModelPage-Cz-MrSZQ.js +1 -0
  11. package/dist/app/assets/PackagePage-Cdg5D7AN.js +1 -0
  12. package/dist/app/assets/RouteError-B5SIyn6G.js +1 -0
  13. package/dist/app/assets/ThemeEditorPage-B62rRc-y.js +1 -0
  14. package/dist/app/assets/WorkbookPage-45cG1Gx6.js +1 -0
  15. package/dist/app/assets/{core-CjeTkq8O.es-BqRc6yhC.js → core-DyU47reh.es-DY9b3WjS.js} +1 -1
  16. package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
  17. package/dist/app/assets/index-5eLCcNmP.css +1 -0
  18. package/dist/app/assets/index-C5ClsnS7.js +1774 -0
  19. package/dist/app/assets/index-DF9tVqwN.js +1 -0
  20. package/dist/app/assets/index-DSoCUF-f.js +2725 -0
  21. package/dist/app/assets/index-DgVRz2wm.js +2 -0
  22. package/dist/app/assets/index-P-pF8nU5.js +632 -0
  23. package/dist/app/assets/index-w_0OQJgZ.js +23 -0
  24. package/dist/app/index.html +8 -4
  25. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  26. package/dist/default-publisher.config.json +23 -0
  27. package/dist/{instrumentation.js → instrumentation.mjs} +10631 -10573
  28. package/dist/package_load_worker.mjs +15219 -0
  29. package/dist/runtime/publisher.js +331 -0
  30. package/dist/{server.js → server.mjs} +141276 -74705
  31. package/dist/sshcrypto-vd2k5hq9.node +0 -0
  32. package/package.json +41 -28
  33. package/publisher.config.example.bigquery.json +33 -0
  34. package/publisher.config.example.duckdb.json +23 -0
  35. package/.eslintrc.json +0 -44
  36. package/.prettierignore +0 -1
  37. package/.prettierrc +0 -4
  38. package/build.ts +0 -38
  39. package/dist/app/assets/HomePage-Dn3E4CuB.js +0 -1
  40. package/dist/app/assets/MainPage-BzB3yoqi.js +0 -2
  41. package/dist/app/assets/ModelPage-C9O_sAXT.js +0 -1
  42. package/dist/app/assets/PackagePage-DcxKEjBX.js +0 -1
  43. package/dist/app/assets/ProjectPage-BDj307rF.js +0 -1
  44. package/dist/app/assets/RouteError-DAShbVCG.js +0 -1
  45. package/dist/app/assets/WorkbookPage-Cs_XYEaB.js +0 -1
  46. package/dist/app/assets/index-15BOvhp0.js +0 -456
  47. package/dist/app/assets/index-Bb2jqquW.js +0 -676
  48. package/dist/app/assets/index-CMlGQMcl.css +0 -1
  49. package/dist/app/assets/index-D68X76-7.js +0 -1276
  50. package/dist/app/assets/index.umd-DGBekgSu.js +0 -1145
  51. package/dxt/malloy_bridge.py +0 -354
  52. package/dxt/manifest.json +0 -22
  53. package/eslint.config.mjs +0 -76
  54. package/k6-tests/README.md +0 -144
  55. package/k6-tests/bun.lock +0 -797
  56. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  57. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  58. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  59. package/k6-tests/load-test/load-test-crud.ts +0 -142
  60. package/k6-tests/load-test/load-test.ts +0 -784
  61. package/k6-tests/package.json +0 -27
  62. package/k6-tests/scripts/run-with-otel.sh +0 -50
  63. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  64. package/k6-tests/tsconfig.json +0 -29
  65. package/k6-tests/utils/client_factory.ts +0 -128
  66. package/k6-tests/utils/common.ts +0 -489
  67. package/k6-tests/utils/logger.ts +0 -62
  68. package/k6-tests/utils/types.d.ts +0 -35
  69. package/malloy_mcp.dxt +0 -0
  70. package/publisher.config.json +0 -33
  71. package/src/config.spec.ts +0 -850
  72. package/src/config.ts +0 -305
  73. package/src/constants.ts +0 -10
  74. package/src/controller/compile.controller.ts +0 -35
  75. package/src/controller/connection.controller.ts +0 -515
  76. package/src/controller/database.controller.ts +0 -21
  77. package/src/controller/manifest.controller.ts +0 -29
  78. package/src/controller/materialization.controller.ts +0 -125
  79. package/src/controller/model.controller.ts +0 -107
  80. package/src/controller/package.controller.ts +0 -182
  81. package/src/controller/query.controller.ts +0 -60
  82. package/src/controller/watch-mode.controller.ts +0 -98
  83. package/src/data_styles.ts +0 -71
  84. package/src/dto/connection.dto.spec.ts +0 -131
  85. package/src/dto/connection.dto.ts +0 -174
  86. package/src/dto/index.ts +0 -2
  87. package/src/dto/package.dto.spec.ts +0 -42
  88. package/src/dto/package.dto.ts +0 -12
  89. package/src/dto/validate.spec.ts +0 -76
  90. package/src/dto/validate.ts +0 -31
  91. package/src/errors.ts +0 -116
  92. package/src/health.ts +0 -189
  93. package/src/instrumentation.ts +0 -148
  94. package/src/logger.ts +0 -154
  95. package/src/mcp/error_messages.ts +0 -199
  96. package/src/mcp/handler_utils.ts +0 -239
  97. package/src/mcp/mcp_constants.ts +0 -22
  98. package/src/mcp/prompts/handlers.ts +0 -84
  99. package/src/mcp/prompts/index.ts +0 -11
  100. package/src/mcp/prompts/prompt_definitions.ts +0 -160
  101. package/src/mcp/prompts/prompt_service.ts +0 -67
  102. package/src/mcp/prompts/utils.ts +0 -62
  103. package/src/mcp/resource_metadata.ts +0 -47
  104. package/src/mcp/resources/model_resource.ts +0 -153
  105. package/src/mcp/resources/notebook_resource.ts +0 -137
  106. package/src/mcp/resources/package_resource.ts +0 -370
  107. package/src/mcp/resources/project_resource.ts +0 -184
  108. package/src/mcp/resources/query_resource.ts +0 -117
  109. package/src/mcp/resources/source_resource.ts +0 -141
  110. package/src/mcp/resources/view_resource.ts +0 -136
  111. package/src/mcp/server.ts +0 -62
  112. package/src/mcp/tools/discovery_tools.ts +0 -258
  113. package/src/mcp/tools/execute_query_tool.ts +0 -265
  114. package/src/server.ts +0 -1195
  115. package/src/service/connection.spec.ts +0 -1331
  116. package/src/service/connection.ts +0 -1511
  117. package/src/service/connection_service.spec.ts +0 -641
  118. package/src/service/connection_service.ts +0 -181
  119. package/src/service/db_utils.spec.ts +0 -714
  120. package/src/service/db_utils.ts +0 -1227
  121. package/src/service/filter.spec.ts +0 -447
  122. package/src/service/filter.ts +0 -337
  123. package/src/service/filter_integration.spec.ts +0 -825
  124. package/src/service/gcs_s3_utils.ts +0 -427
  125. package/src/service/manifest_service.spec.ts +0 -201
  126. package/src/service/manifest_service.ts +0 -106
  127. package/src/service/materialization_service.spec.ts +0 -648
  128. package/src/service/materialization_service.ts +0 -929
  129. package/src/service/materialized_table_gc.spec.ts +0 -383
  130. package/src/service/materialized_table_gc.ts +0 -279
  131. package/src/service/model.spec.ts +0 -257
  132. package/src/service/model.ts +0 -1069
  133. package/src/service/package.spec.ts +0 -236
  134. package/src/service/package.ts +0 -463
  135. package/src/service/project.ts +0 -681
  136. package/src/service/project_compile.spec.ts +0 -197
  137. package/src/service/project_store.spec.ts +0 -859
  138. package/src/service/project_store.ts +0 -1531
  139. package/src/service/quoting.ts +0 -41
  140. package/src/service/resolve_project.ts +0 -13
  141. package/src/storage/BaseRepository.ts +0 -31
  142. package/src/storage/DatabaseInterface.ts +0 -180
  143. package/src/storage/StorageManager.mock.ts +0 -59
  144. package/src/storage/StorageManager.ts +0 -210
  145. package/src/storage/duckdb/ConnectionRepository.ts +0 -141
  146. package/src/storage/duckdb/DuckDBConnection.ts +0 -177
  147. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  148. package/src/storage/duckdb/DuckDBRepository.ts +0 -224
  149. package/src/storage/duckdb/ManifestRepository.ts +0 -119
  150. package/src/storage/duckdb/MaterializationRepository.ts +0 -249
  151. package/src/storage/duckdb/PackageRepository.ts +0 -135
  152. package/src/storage/duckdb/ProjectRepository.ts +0 -148
  153. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  154. package/src/storage/duckdb/schema.ts +0 -147
  155. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -146
  156. package/src/utils.ts +0 -13
  157. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  158. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  159. package/tests/fixtures/persist-test/publisher.json +0 -5
  160. package/tests/fixtures/publisher.config.json +0 -15
  161. package/tests/harness/e2e.ts +0 -96
  162. package/tests/harness/mcp_test_setup.ts +0 -344
  163. package/tests/harness/mocks.ts +0 -37
  164. package/tests/harness/rest_e2e.ts +0 -68
  165. package/tests/harness/uris.ts +0 -31
  166. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -470
  167. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -434
  168. package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -663
  169. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -122
  170. package/tests/integration/mcp/setup.spec.ts +0 -5
  171. package/tests/unit/duckdb/attached_databases.test.ts +0 -1132
  172. package/tests/unit/ducklake/ducklake.test.ts +0 -950
  173. package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
  174. package/tests/unit/mcp/prompt_happy.test.ts +0 -51
  175. package/tsconfig.json +0 -32
@@ -1,825 +0,0 @@
1
- import { DuckDBConnection } from "@malloydata/db-duckdb";
2
- import { Connection } from "@malloydata/malloy";
3
- import {
4
- afterAll,
5
- afterEach,
6
- beforeAll,
7
- beforeEach,
8
- describe,
9
- expect,
10
- it,
11
- } from "bun:test";
12
- import fs from "fs/promises";
13
- import os from "os";
14
- import path from "path";
15
- import { BadRequestError } from "../errors";
16
- import { Model } from "./model";
17
-
18
- const TEST_DIR = path.join(os.tmpdir(), "filter-integration-tests");
19
- const TEST_DB_DIR = path.join(TEST_DIR, "db");
20
- const TEST_DB_PATH = path.join(TEST_DB_DIR, "test.duckdb");
21
- const TEST_PKG_DIR = path.join(TEST_DIR, "pkg");
22
-
23
- let duckdbConnection: DuckDBConnection;
24
-
25
- const SEED_SQL = `
26
- CREATE TABLE IF NOT EXISTS orders (
27
- order_id INTEGER,
28
- region VARCHAR,
29
- status VARCHAR,
30
- customer_id VARCHAR,
31
- amount DOUBLE
32
- );
33
- INSERT INTO orders VALUES
34
- (1, 'US', 'active', 'cust_a', 100.0),
35
- (2, 'US', 'active', 'cust_a', 200.0),
36
- (3, 'EU', 'active', 'cust_b', 150.0),
37
- (4, 'EU', 'cancelled', 'cust_b', 75.0),
38
- (5, 'APAC', 'active', 'cust_c', 300.0),
39
- (6, 'APAC', 'cancelled', 'cust_c', 50.0);
40
- `;
41
-
42
- const MODEL_WITH_REQUIRED = `
43
- #(filter) dimension=region type=in
44
- #(filter) dimension=status type=equal
45
- #(filter) name=tenant dimension=customer_id type=equal implicit required
46
- source: orders is duckdb.table('orders') extend {
47
- primary_key: order_id
48
-
49
- measure:
50
- order_count is count()
51
- total_amount is sum(amount)
52
-
53
- view: summary is {
54
- aggregate: order_count, total_amount
55
- }
56
-
57
- view: by_region is {
58
- group_by: region
59
- aggregate: order_count, total_amount
60
- order_by: region
61
- }
62
- }
63
- `;
64
-
65
- const MODEL_OPTIONAL_ONLY = `
66
- #(filter) dimension=region type=in
67
- #(filter) dimension=status type=equal
68
- source: orders is duckdb.table('orders') extend {
69
- primary_key: order_id
70
-
71
- measure:
72
- order_count is count()
73
- total_amount is sum(amount)
74
-
75
- view: summary is {
76
- aggregate: order_count, total_amount
77
- }
78
-
79
- view: by_region is {
80
- group_by: region
81
- aggregate: order_count, total_amount
82
- order_by: region
83
- }
84
- }
85
- `;
86
-
87
- const NOTEBOOK_MALLOYNB = `>>>markdown
88
- # Test Notebook
89
-
90
- >>>malloy
91
- import "orders_optional.malloy"
92
-
93
- >>>malloy
94
- run: orders -> summary
95
- `;
96
-
97
- // Base source with 3 filters: region (in), status (equal), customer_id (equal, required)
98
- const MODEL_BASE_FOR_EXTEND = `
99
- #(filter) name=region dimension=region type=in
100
- #(filter) name=status dimension=status type=equal
101
- #(filter) name=tenant dimension=customer_id type=equal required
102
- source: base_orders is duckdb.table('orders') extend {
103
- primary_key: order_id
104
-
105
- measure:
106
- order_count is count()
107
- total_amount is sum(amount)
108
-
109
- view: summary is {
110
- aggregate: order_count, total_amount
111
- }
112
- }
113
- `;
114
-
115
- // Extending source: overrides region (in → equal), overrides tenant
116
- // (removes required), keeps status from base unchanged
117
- const MODEL_CHILD_EXTEND = `
118
- import "base_orders.malloy"
119
-
120
- #(filter) name=region dimension=region type=equal
121
- #(filter) name=tenant dimension=customer_id type=equal
122
- source: child_orders is base_orders extend {}
123
- `;
124
-
125
- // Notebook against the extended source
126
- const NOTEBOOK_EXTEND = `>>>markdown
127
- # Extend Test
128
-
129
- >>>malloy
130
- import "child_orders.malloy"
131
-
132
- >>>malloy
133
- run: child_orders -> summary
134
- `;
135
-
136
- beforeAll(async () => {
137
- await fs.mkdir(TEST_DB_DIR, { recursive: true });
138
- await fs.mkdir(TEST_PKG_DIR, { recursive: true });
139
- duckdbConnection = new DuckDBConnection("duckdb", TEST_DB_PATH, TEST_DB_DIR);
140
- for (const stmt of SEED_SQL.trim().split(";").filter(Boolean)) {
141
- await duckdbConnection.runSQL(stmt.trim() + ";");
142
- }
143
- });
144
-
145
- afterAll(async () => {
146
- try {
147
- await duckdbConnection.close();
148
- await new Promise((resolve) => setTimeout(resolve, 100));
149
- await fs.rm(TEST_DIR, { recursive: true, force: true });
150
- } catch {
151
- // Ignore cleanup errors
152
- }
153
- });
154
-
155
- function getConnections(): Map<string, Connection> {
156
- const map = new Map<string, Connection>();
157
- map.set("duckdb", duckdbConnection);
158
- return map;
159
- }
160
-
161
- async function writeFile(filename: string, content: string): Promise<void> {
162
- await fs.writeFile(path.join(TEST_PKG_DIR, filename), content, "utf-8");
163
- }
164
-
165
- type Row = Record<string, unknown>;
166
-
167
- /**
168
- * Malloy's compactResult (queryResults.data.value) is the raw array of row objects.
169
- */
170
- function asRows(compactResult: unknown): Row[] {
171
- return compactResult as Row[];
172
- }
173
-
174
- /**
175
- * Parse a notebook cell result (JSON-stringified Malloy result).
176
- * The shape is: { schema, data: { kind, array_value: [{ record_value: { field_name: {kind, ...value} } }, ...] }, ... }
177
- * We extract column values from the record structure.
178
- */
179
- function parseNotebookResult(resultJson: string): Row[] {
180
- const parsed = JSON.parse(resultJson);
181
- const arrayValue = parsed?.data?.array_value;
182
- if (!Array.isArray(arrayValue)) {
183
- throw new Error(
184
- `Cannot extract rows from notebook result: ${JSON.stringify(Object.keys(parsed?.data ?? {}))}`,
185
- );
186
- }
187
-
188
- const schema = parsed.schema?.fields ?? [];
189
- const fieldNames = schema.map((f: { name: string }) => f.name);
190
-
191
- return arrayValue.map(
192
- (record: { record_value?: Array<Record<string, unknown>> }) => {
193
- const row: Row = {};
194
- const cells = record.record_value ?? [];
195
- for (let i = 0; i < fieldNames.length; i++) {
196
- const cell = cells[i];
197
- if (!cell) continue;
198
- row[fieldNames[i]] =
199
- cell.number_value ??
200
- cell.string_value ??
201
- cell.boolean_value ??
202
- cell.timestamp_value ??
203
- null;
204
- }
205
- return row;
206
- },
207
- );
208
- }
209
-
210
- describe("filter integration", () => {
211
- beforeEach(async () => {
212
- await writeFile("orders.malloy", MODEL_WITH_REQUIRED);
213
- await writeFile("orders_optional.malloy", MODEL_OPTIONAL_ONLY);
214
- await writeFile("test_notebook.malloynb", NOTEBOOK_MALLOYNB);
215
- });
216
-
217
- afterEach(async () => {
218
- const files = await fs.readdir(TEST_PKG_DIR);
219
- for (const f of files) {
220
- if (f.endsWith(".malloy") || f.endsWith(".malloynb")) {
221
- await fs.unlink(path.join(TEST_PKG_DIR, f));
222
- }
223
- }
224
- });
225
-
226
- // -----------------------------------------------------------------------
227
- // Model loading & filter metadata
228
- // -----------------------------------------------------------------------
229
- describe("model loading", () => {
230
- it("parses filter annotations and exposes them via getSources()", async () => {
231
- const model = await Model.create(
232
- "test-pkg",
233
- TEST_PKG_DIR,
234
- "orders.malloy",
235
- getConnections(),
236
- );
237
-
238
- const sources = model.getSources();
239
- expect(sources).toBeDefined();
240
- expect(sources!.length).toBeGreaterThanOrEqual(1);
241
-
242
- const ordersSource = sources!.find((s) => s.name === "orders");
243
- expect(ordersSource).toBeDefined();
244
- expect(ordersSource!.filters).toBeDefined();
245
- expect(ordersSource!.filters!.length).toBe(3);
246
-
247
- const regionFilter = ordersSource!.filters!.find(
248
- (f) => f.dimension === "region",
249
- );
250
- expect(regionFilter).toBeDefined();
251
- expect(regionFilter!.type).toBe("in");
252
- expect(regionFilter!.required).toBe(false);
253
- expect(regionFilter!.implicit).toBe(false);
254
-
255
- const statusFilter = ordersSource!.filters!.find(
256
- (f) => f.dimension === "status",
257
- );
258
- expect(statusFilter).toBeDefined();
259
- expect(statusFilter!.type).toBe("equal");
260
- expect(statusFilter!.required).toBe(false);
261
-
262
- const tenantFilter = ordersSource!.filters!.find(
263
- (f) => f.dimension === "customer_id",
264
- );
265
- expect(tenantFilter).toBeDefined();
266
- expect(tenantFilter!.name).toBe("tenant");
267
- expect(tenantFilter!.type).toBe("equal");
268
- expect(tenantFilter!.implicit).toBe(true);
269
- expect(tenantFilter!.required).toBe(true);
270
- });
271
-
272
- it("loads a model with optional-only filters", async () => {
273
- const model = await Model.create(
274
- "test-pkg",
275
- TEST_PKG_DIR,
276
- "orders_optional.malloy",
277
- getConnections(),
278
- );
279
-
280
- const sources = model.getSources();
281
- const ordersSource = sources!.find((s) => s.name === "orders");
282
- expect(ordersSource!.filters!.length).toBe(2);
283
- expect(ordersSource!.filters!.every((f) => f.required === false)).toBe(
284
- true,
285
- );
286
- });
287
- });
288
-
289
- // -----------------------------------------------------------------------
290
- // Query execution with optional filters
291
- // -----------------------------------------------------------------------
292
- describe("query execution with optional filters", () => {
293
- it("runs unfiltered query (no filterParams provided)", async () => {
294
- const model = await Model.create(
295
- "test-pkg",
296
- TEST_PKG_DIR,
297
- "orders_optional.malloy",
298
- getConnections(),
299
- );
300
-
301
- const { compactResult } = await model.getQueryResults(
302
- "orders",
303
- "summary",
304
- );
305
-
306
- const r = asRows(compactResult);
307
- expect(r.length).toBe(1);
308
- expect(Number(r[0].order_count)).toBe(6);
309
- expect(Number(r[0].total_amount)).toBe(875);
310
- });
311
-
312
- it("applies region=in filter with single value", async () => {
313
- const model = await Model.create(
314
- "test-pkg",
315
- TEST_PKG_DIR,
316
- "orders_optional.malloy",
317
- getConnections(),
318
- );
319
-
320
- const { compactResult } = await model.getQueryResults(
321
- "orders",
322
- "summary",
323
- undefined,
324
- { region: ["US"] },
325
- );
326
-
327
- const r = asRows(compactResult);
328
- expect(r.length).toBe(1);
329
- expect(Number(r[0].order_count)).toBe(2);
330
- expect(Number(r[0].total_amount)).toBe(300);
331
- });
332
-
333
- it("applies region=in filter with multiple values", async () => {
334
- const model = await Model.create(
335
- "test-pkg",
336
- TEST_PKG_DIR,
337
- "orders_optional.malloy",
338
- getConnections(),
339
- );
340
-
341
- const { compactResult } = await model.getQueryResults(
342
- "orders",
343
- "summary",
344
- undefined,
345
- { region: ["US", "EU"] },
346
- );
347
-
348
- const r = asRows(compactResult);
349
- expect(r.length).toBe(1);
350
- expect(Number(r[0].order_count)).toBe(4);
351
- expect(Number(r[0].total_amount)).toBe(525);
352
- });
353
-
354
- it("applies status=equal filter", async () => {
355
- const model = await Model.create(
356
- "test-pkg",
357
- TEST_PKG_DIR,
358
- "orders_optional.malloy",
359
- getConnections(),
360
- );
361
-
362
- const { compactResult } = await model.getQueryResults(
363
- "orders",
364
- "summary",
365
- undefined,
366
- { status: "active" },
367
- );
368
-
369
- const r = asRows(compactResult);
370
- expect(r.length).toBe(1);
371
- expect(Number(r[0].order_count)).toBe(4);
372
- expect(Number(r[0].total_amount)).toBe(750);
373
- });
374
-
375
- it("applies combined region + status filters", async () => {
376
- const model = await Model.create(
377
- "test-pkg",
378
- TEST_PKG_DIR,
379
- "orders_optional.malloy",
380
- getConnections(),
381
- );
382
-
383
- const { compactResult } = await model.getQueryResults(
384
- "orders",
385
- "summary",
386
- undefined,
387
- { region: ["EU"], status: "cancelled" },
388
- );
389
-
390
- const r = asRows(compactResult);
391
- expect(r.length).toBe(1);
392
- expect(Number(r[0].order_count)).toBe(1);
393
- expect(Number(r[0].total_amount)).toBe(75);
394
- });
395
-
396
- it("works with by_region view and filters", async () => {
397
- const model = await Model.create(
398
- "test-pkg",
399
- TEST_PKG_DIR,
400
- "orders_optional.malloy",
401
- getConnections(),
402
- );
403
-
404
- const { compactResult } = await model.getQueryResults(
405
- "orders",
406
- "by_region",
407
- undefined,
408
- { status: "cancelled" },
409
- );
410
-
411
- const r = asRows(compactResult);
412
- expect(r.length).toBe(2);
413
- const regions = r.map((row) => row.region);
414
- expect(regions).toContain("EU");
415
- expect(regions).toContain("APAC");
416
- });
417
-
418
- it("works with ad-hoc query string and filters", async () => {
419
- const model = await Model.create(
420
- "test-pkg",
421
- TEST_PKG_DIR,
422
- "orders_optional.malloy",
423
- getConnections(),
424
- );
425
-
426
- const { compactResult } = await model.getQueryResults(
427
- undefined,
428
- undefined,
429
- "run: orders -> { aggregate: order_count is count() }",
430
- { region: ["APAC"] },
431
- );
432
-
433
- const r = asRows(compactResult);
434
- expect(r.length).toBe(1);
435
- expect(Number(r[0].order_count)).toBe(2);
436
- });
437
- });
438
-
439
- // -----------------------------------------------------------------------
440
- // Required filter enforcement
441
- // -----------------------------------------------------------------------
442
- describe("required filter enforcement", () => {
443
- it("throws when required filter is missing", async () => {
444
- const model = await Model.create(
445
- "test-pkg",
446
- TEST_PKG_DIR,
447
- "orders.malloy",
448
- getConnections(),
449
- );
450
-
451
- await expect(
452
- model.getQueryResults("orders", "summary", undefined, {
453
- region: ["US"],
454
- }),
455
- ).rejects.toThrow(BadRequestError);
456
- });
457
-
458
- it("throws descriptive error for missing required filter", async () => {
459
- const model = await Model.create(
460
- "test-pkg",
461
- TEST_PKG_DIR,
462
- "orders.malloy",
463
- getConnections(),
464
- );
465
-
466
- try {
467
- await model.getQueryResults("orders", "summary", undefined, {});
468
- throw new Error("Should have thrown");
469
- } catch (error) {
470
- expect(error).toBeInstanceOf(BadRequestError);
471
- expect((error as Error).message).toContain("tenant");
472
- }
473
- });
474
-
475
- it("succeeds when required filter is provided", async () => {
476
- const model = await Model.create(
477
- "test-pkg",
478
- TEST_PKG_DIR,
479
- "orders.malloy",
480
- getConnections(),
481
- );
482
-
483
- const { compactResult } = await model.getQueryResults(
484
- "orders",
485
- "summary",
486
- undefined,
487
- { tenant: "cust_a", region: ["US"] },
488
- );
489
-
490
- const r = asRows(compactResult);
491
- expect(r.length).toBe(1);
492
- expect(Number(r[0].order_count)).toBe(2);
493
- expect(Number(r[0].total_amount)).toBe(300);
494
- });
495
-
496
- it("applies required + optional filters together", async () => {
497
- const model = await Model.create(
498
- "test-pkg",
499
- TEST_PKG_DIR,
500
- "orders.malloy",
501
- getConnections(),
502
- );
503
-
504
- const { compactResult } = await model.getQueryResults(
505
- "orders",
506
- "summary",
507
- undefined,
508
- { tenant: "cust_b", status: "cancelled" },
509
- );
510
-
511
- const r = asRows(compactResult);
512
- expect(r.length).toBe(1);
513
- expect(Number(r[0].order_count)).toBe(1);
514
- expect(Number(r[0].total_amount)).toBe(75);
515
- });
516
- });
517
-
518
- // -----------------------------------------------------------------------
519
- // bypassFilters
520
- // -----------------------------------------------------------------------
521
- describe("bypassFilters", () => {
522
- it("skips required filter validation when bypassFilters=true", async () => {
523
- const model = await Model.create(
524
- "test-pkg",
525
- TEST_PKG_DIR,
526
- "orders.malloy",
527
- getConnections(),
528
- );
529
-
530
- const { compactResult } = await model.getQueryResults(
531
- "orders",
532
- "summary",
533
- undefined,
534
- {},
535
- true,
536
- );
537
-
538
- const r = asRows(compactResult);
539
- expect(r.length).toBe(1);
540
- expect(Number(r[0].order_count)).toBe(6);
541
- expect(Number(r[0].total_amount)).toBe(875);
542
- });
543
-
544
- it("ignores provided filters when bypassFilters=true", async () => {
545
- const model = await Model.create(
546
- "test-pkg",
547
- TEST_PKG_DIR,
548
- "orders_optional.malloy",
549
- getConnections(),
550
- );
551
-
552
- const { compactResult } = await model.getQueryResults(
553
- "orders",
554
- "summary",
555
- undefined,
556
- { region: ["US"] },
557
- true,
558
- );
559
-
560
- const r = asRows(compactResult);
561
- expect(Number(r[0].order_count)).toBe(6);
562
- });
563
- });
564
-
565
- // -----------------------------------------------------------------------
566
- // Notebook cell execution with filters
567
- // -----------------------------------------------------------------------
568
- describe("notebook cell execution", () => {
569
- it("executes notebook code cell without filters", async () => {
570
- const model = await Model.create(
571
- "test-pkg",
572
- TEST_PKG_DIR,
573
- "test_notebook.malloynb",
574
- getConnections(),
575
- );
576
-
577
- // Cell 0 = markdown ("# Test Notebook")
578
- // Cell 1 = code (model definition — no query, just source)
579
- // Cell 2 = code (run: orders -> summary)
580
- const codeCell = await model.executeNotebookCell(2);
581
- expect(codeCell.type).toBe("code");
582
- expect(codeCell.result).toBeDefined();
583
-
584
- const notebookRows = parseNotebookResult(codeCell.result!);
585
- expect(notebookRows.length).toBe(1);
586
- expect(Number(notebookRows[0].order_count)).toBe(6);
587
- expect(Number(notebookRows[0].total_amount)).toBe(875);
588
- });
589
-
590
- it("applies filterParams to notebook cell execution", async () => {
591
- const model = await Model.create(
592
- "test-pkg",
593
- TEST_PKG_DIR,
594
- "test_notebook.malloynb",
595
- getConnections(),
596
- );
597
-
598
- const codeCell = await model.executeNotebookCell(2, {
599
- region: ["US"],
600
- });
601
- expect(codeCell.result).toBeDefined();
602
-
603
- const notebookRows = parseNotebookResult(codeCell.result!);
604
- expect(notebookRows.length).toBe(1);
605
- expect(Number(notebookRows[0].order_count)).toBe(2);
606
- expect(Number(notebookRows[0].total_amount)).toBe(300);
607
- });
608
-
609
- it("applies status filter to notebook cell execution", async () => {
610
- const model = await Model.create(
611
- "test-pkg",
612
- TEST_PKG_DIR,
613
- "test_notebook.malloynb",
614
- getConnections(),
615
- );
616
-
617
- const codeCell = await model.executeNotebookCell(2, {
618
- status: "cancelled",
619
- });
620
- expect(codeCell.result).toBeDefined();
621
-
622
- const notebookRows = parseNotebookResult(codeCell.result!);
623
- expect(notebookRows.length).toBe(1);
624
- expect(Number(notebookRows[0].order_count)).toBe(2);
625
- expect(Number(notebookRows[0].total_amount)).toBe(125);
626
- });
627
-
628
- it("bypassFilters skips filter injection on notebook cells", async () => {
629
- const model = await Model.create(
630
- "test-pkg",
631
- TEST_PKG_DIR,
632
- "test_notebook.malloynb",
633
- getConnections(),
634
- );
635
-
636
- const codeCell = await model.executeNotebookCell(
637
- 2,
638
- { region: ["US"] },
639
- true,
640
- );
641
- expect(codeCell.result).toBeDefined();
642
-
643
- const notebookRows = parseNotebookResult(codeCell.result!);
644
- expect(notebookRows.length).toBe(1);
645
- expect(Number(notebookRows[0].order_count)).toBe(6);
646
- });
647
-
648
- it("returns markdown cells unchanged", async () => {
649
- const model = await Model.create(
650
- "test-pkg",
651
- TEST_PKG_DIR,
652
- "test_notebook.malloynb",
653
- getConnections(),
654
- );
655
-
656
- const markdownCell = await model.executeNotebookCell(0);
657
- expect(markdownCell.type).toBe("markdown");
658
- expect(markdownCell.text).toContain("Test Notebook");
659
- });
660
- });
661
-
662
- // -----------------------------------------------------------------------
663
- // Extended source filter inheritance
664
- // -----------------------------------------------------------------------
665
- describe("extended source filter inheritance", () => {
666
- beforeEach(async () => {
667
- await writeFile("base_orders.malloy", MODEL_BASE_FOR_EXTEND);
668
- await writeFile("child_orders.malloy", MODEL_CHILD_EXTEND);
669
- await writeFile("extend_notebook.malloynb", NOTEBOOK_EXTEND);
670
- });
671
-
672
- it("inherits base-only filters on extended source", async () => {
673
- const model = await Model.create(
674
- "test-pkg",
675
- TEST_PKG_DIR,
676
- "child_orders.malloy",
677
- getConnections(),
678
- );
679
-
680
- const sources = model.getSources();
681
- const child = sources!.find((s) => s.name === "child_orders");
682
- expect(child).toBeDefined();
683
- expect(child!.filters).toBeDefined();
684
-
685
- // status is defined only on the base — it should carry through
686
- const statusFilter = child!.filters!.find((f) => f.name === "status");
687
- expect(statusFilter).toBeDefined();
688
- expect(statusFilter!.type).toBe("equal");
689
- });
690
-
691
- it("child overrides base filter type", async () => {
692
- const model = await Model.create(
693
- "test-pkg",
694
- TEST_PKG_DIR,
695
- "child_orders.malloy",
696
- getConnections(),
697
- );
698
-
699
- const sources = model.getSources();
700
- const child = sources!.find((s) => s.name === "child_orders");
701
- expect(child).toBeDefined();
702
-
703
- // region: base=in, child overrides to equal
704
- const regionFilter = child!.filters!.find((f) => f.name === "region");
705
- expect(regionFilter).toBeDefined();
706
- expect(regionFilter!.type).toBe("equal");
707
- });
708
-
709
- it("child can remove required flag by overriding", async () => {
710
- const model = await Model.create(
711
- "test-pkg",
712
- TEST_PKG_DIR,
713
- "child_orders.malloy",
714
- getConnections(),
715
- );
716
-
717
- const sources = model.getSources();
718
- const child = sources!.find((s) => s.name === "child_orders");
719
- expect(child).toBeDefined();
720
-
721
- // tenant: base=required, child overrides without required
722
- const tenantFilter = child!.filters!.find((f) => f.name === "tenant");
723
- expect(tenantFilter).toBeDefined();
724
- expect(tenantFilter!.required).toBeFalsy();
725
- });
726
-
727
- it("has exactly the expected merged filter set", async () => {
728
- const model = await Model.create(
729
- "test-pkg",
730
- TEST_PKG_DIR,
731
- "child_orders.malloy",
732
- getConnections(),
733
- );
734
-
735
- const sources = model.getSources();
736
- const child = sources!.find((s) => s.name === "child_orders");
737
- expect(child).toBeDefined();
738
-
739
- // 3 unique filter names: region, status (from base), tenant
740
- const filterNames = child!.filters!.map((f) => f.name).sort();
741
- expect(filterNames).toEqual(["region", "status", "tenant"]);
742
- });
743
-
744
- it("applies inherited filter to query on extended source", async () => {
745
- const model = await Model.create(
746
- "test-pkg",
747
- TEST_PKG_DIR,
748
- "child_orders.malloy",
749
- getConnections(),
750
- );
751
-
752
- // status=active is inherited from the base; should work on child
753
- const { compactResult } = await model.getQueryResults(
754
- "child_orders",
755
- "summary",
756
- undefined,
757
- { status: "active" },
758
- );
759
- const rows = asRows(compactResult);
760
- expect(rows.length).toBe(1);
761
- // 4 active rows: US(2), EU(1), APAC(1)
762
- expect(Number(rows[0].order_count)).toBe(4);
763
- });
764
-
765
- it("applies overridden filter to query on extended source", async () => {
766
- const model = await Model.create(
767
- "test-pkg",
768
- TEST_PKG_DIR,
769
- "child_orders.malloy",
770
- getConnections(),
771
- );
772
-
773
- // region is overridden to type=equal on the child
774
- const { compactResult } = await model.getQueryResults(
775
- "child_orders",
776
- "summary",
777
- undefined,
778
- { region: "US" },
779
- );
780
- const rows = asRows(compactResult);
781
- expect(rows.length).toBe(1);
782
- // 2 US rows
783
- expect(Number(rows[0].order_count)).toBe(2);
784
- });
785
-
786
- it("no longer requires base's required filter after child override", async () => {
787
- const model = await Model.create(
788
- "test-pkg",
789
- TEST_PKG_DIR,
790
- "child_orders.malloy",
791
- getConnections(),
792
- );
793
-
794
- // On the base, tenant is required. On the child, it's not.
795
- // Running without tenant should NOT throw.
796
- const { compactResult } = await model.getQueryResults(
797
- "child_orders",
798
- "summary",
799
- );
800
- const rows = asRows(compactResult);
801
- expect(rows.length).toBe(1);
802
- expect(Number(rows[0].order_count)).toBe(6);
803
- });
804
-
805
- it("applies inherited filters to notebook cells", async () => {
806
- const model = await Model.create(
807
- "test-pkg",
808
- TEST_PKG_DIR,
809
- "extend_notebook.malloynb",
810
- getConnections(),
811
- );
812
-
813
- // Apply status=cancelled (inherited from base) via notebook cell
814
- const codeCell = await model.executeNotebookCell(2, {
815
- status: "cancelled",
816
- });
817
- expect(codeCell.result).toBeDefined();
818
-
819
- const rows = parseNotebookResult(codeCell.result!);
820
- expect(rows.length).toBe(1);
821
- // 2 cancelled rows: EU(1), APAC(1)
822
- expect(Number(rows[0].order_count)).toBe(2);
823
- });
824
- });
825
- });