@malloy-publisher/server 0.0.182 → 0.0.183-dev

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 (69) hide show
  1. package/build.ts +7 -3
  2. package/dist/app/api-doc.yaml +423 -60
  3. package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
  4. package/dist/app/assets/{MainPage-DINuSDg0.js → MainPage-BzB3yoqi.js} +1 -1
  5. package/dist/app/assets/{ModelPage-BMcaV1YQ.js → ModelPage-C9O_sAXT.js} +1 -1
  6. package/dist/app/assets/PackagePage-DcxKEjBX.js +1 -0
  7. package/dist/app/assets/ProjectPage-BDj307rF.js +1 -0
  8. package/dist/app/assets/{RouteError-r14osUo0.js → RouteError-DAShbVCG.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-HI39NTWs.js → WorkbookPage-Cs_XYEaB.js} +1 -1
  10. package/dist/app/assets/core-CjeTkq8O.es-BqRc6yhC.js +148 -0
  11. package/dist/app/assets/engine-oniguruma-C4vnmooL.es-jdkXmgTr.js +1 -0
  12. package/dist/app/assets/github-light-JYsPkUQd.es-DAi9KRSo.js +1 -0
  13. package/dist/app/assets/index-15BOvhp0.js +456 -0
  14. package/dist/app/assets/{index-Dd6uCk_C.js → index-Bb2jqquW.js} +1 -1
  15. package/dist/app/assets/{index-JqHhhRqY.js → index-D68X76-7.js} +98 -98
  16. package/dist/app/assets/{index.umd-lwkX_kFe.js → index.umd-DGBekgSu.js} +1 -1
  17. package/dist/app/assets/json-71t8ZF9g.es-BQoSv7ci.js +1 -0
  18. package/dist/app/assets/sql-DCkt643-.es-COK4E0Yg.js +1 -0
  19. package/dist/app/assets/typescript-buWNZFwO.es-Dj6nwHGl.js +1 -0
  20. package/dist/app/index.html +1 -1
  21. package/dist/{instrumentation.js → instrumentation.mjs} +10567 -10584
  22. package/dist/{server.js → server.mjs} +16642 -15332
  23. package/package.json +19 -17
  24. package/src/controller/connection.controller.ts +27 -20
  25. package/src/controller/manifest.controller.ts +29 -0
  26. package/src/controller/materialization.controller.ts +125 -0
  27. package/src/controller/model.controller.ts +0 -2
  28. package/src/controller/package.controller.ts +53 -2
  29. package/src/errors.ts +24 -0
  30. package/src/mcp/prompts/handlers.ts +1 -1
  31. package/src/server.ts +202 -15
  32. package/src/service/connection.ts +1 -4
  33. package/src/service/filter.spec.ts +55 -0
  34. package/src/service/filter.ts +8 -3
  35. package/src/service/filter_integration.spec.ts +203 -0
  36. package/src/service/manifest_service.spec.ts +201 -0
  37. package/src/service/manifest_service.ts +106 -0
  38. package/src/service/materialization_service.spec.ts +648 -0
  39. package/src/service/materialization_service.ts +929 -0
  40. package/src/service/materialized_table_gc.spec.ts +383 -0
  41. package/src/service/materialized_table_gc.ts +279 -0
  42. package/src/service/model.ts +54 -13
  43. package/src/service/package.ts +50 -0
  44. package/src/service/project_store.ts +21 -2
  45. package/src/service/quoting.ts +41 -0
  46. package/src/service/resolve_project.ts +13 -0
  47. package/src/storage/DatabaseInterface.ts +103 -1
  48. package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
  49. package/src/storage/StorageManager.ts +119 -1
  50. package/src/storage/duckdb/DuckDBConnection.ts +1 -1
  51. package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
  52. package/src/storage/duckdb/DuckDBRepository.ts +99 -9
  53. package/src/storage/duckdb/ManifestRepository.ts +119 -0
  54. package/src/storage/duckdb/MaterializationRepository.ts +249 -0
  55. package/src/storage/duckdb/manifest_store.spec.ts +133 -0
  56. package/src/storage/duckdb/schema.ts +59 -1
  57. package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
  58. package/tests/fixtures/persist-test/data/orders.csv +5 -0
  59. package/tests/fixtures/persist-test/persist_test.malloy +11 -0
  60. package/tests/fixtures/persist-test/publisher.json +5 -0
  61. package/tests/fixtures/publisher.config.json +15 -0
  62. package/tests/harness/rest_e2e.ts +68 -0
  63. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
  64. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
  65. package/tsconfig.json +1 -1
  66. package/dist/app/assets/HomePage-or6BbD5P.js +0 -1
  67. package/dist/app/assets/PackagePage-DXxlQcCj.js +0 -1
  68. package/dist/app/assets/ProjectPage-vfZc_Kvu.js +0 -1
  69. package/dist/app/assets/index-Bw1lh09G.js +0 -467
package/src/server.ts CHANGED
@@ -6,13 +6,14 @@ import {
6
6
  } from "./instrumentation";
7
7
 
8
8
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
- import * as bodyParser from "body-parser";
9
+ import bodyParser from "body-parser";
10
10
  import cors from "cors";
11
11
  import express from "express";
12
12
  import * as http from "http";
13
13
  import { createProxyMiddleware } from "http-proxy-middleware";
14
14
  import { AddressInfo } from "net";
15
15
  import * as path from "path";
16
+ import { fileURLToPath } from "url";
16
17
  import { CompileController } from "./controller/compile.controller";
17
18
  import { ConnectionController } from "./controller/connection.controller";
18
19
  import { DatabaseController } from "./controller/database.controller";
@@ -20,7 +21,11 @@ import { ModelController } from "./controller/model.controller";
20
21
  import { PackageController } from "./controller/package.controller";
21
22
  import { QueryController } from "./controller/query.controller";
22
23
  import { WatchModeController } from "./controller/watch-mode.controller";
23
- import { internalErrorToHttpError, NotImplementedError } from "./errors";
24
+ import {
25
+ BadRequestError,
26
+ internalErrorToHttpError,
27
+ NotImplementedError,
28
+ } from "./errors";
24
29
  import {
25
30
  drainingGuard,
26
31
  registerHealthEndpoints,
@@ -28,7 +33,11 @@ import {
28
33
  } from "./health";
29
34
  import { logger, loggerMiddleware } from "./logger";
30
35
 
36
+ import { ManifestController } from "./controller/manifest.controller";
37
+ import { MaterializationController } from "./controller/materialization.controller";
31
38
  import { initializeMcpServer } from "./mcp/server";
39
+ import { ManifestService } from "./service/manifest_service";
40
+ import { MaterializationService } from "./service/materialization_service";
32
41
  import { ProjectStore } from "./service/project_store";
33
42
 
34
43
  /** Normalize an Express query param into a string[] or undefined. */
@@ -112,30 +121,37 @@ const SHUTDOWN_DRAIN_DURATION_SECONDS = Number(
112
121
  const SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS = Number(
113
122
  process.env.SHUTDOWN_GRACEFUL_CLOSE_TIMEOUT_SECONDS || 0,
114
123
  );
115
- // Find the app directory - handle NPX vs local execution
116
- let ROOT: string;
117
- if (require.main) {
118
- // Use the main module's directory (works for NPX and direct execution)
119
- ROOT = path.join(path.dirname(require.main.filename), "app");
120
- } else {
121
- // Fallback to current script directory
122
- ROOT = path.join(path.dirname(process.argv[1] || __filename), "app");
123
- }
124
+ // Find the app directory relative to this bundled server file.
125
+ // Works under both ESM (import.meta.url) and when invoked via NPX.
126
+ const __filename_esm = fileURLToPath(import.meta.url);
127
+ const ROOT = path.join(path.dirname(__filename_esm), "app");
124
128
  const SERVER_ROOT = path.resolve(process.cwd(), process.env.SERVER_ROOT || ".");
125
129
  const API_PREFIX = "/api/v0";
126
130
  const isDevelopment = process.env["NODE_ENV"] === "development";
127
131
 
128
- const app = express();
132
+ export const app = express();
129
133
  app.use(loggerMiddleware);
130
134
  app.use(httpMetricsMiddleware);
131
135
  const projectStore = new ProjectStore(SERVER_ROOT);
136
+ const manifestService = new ManifestService(projectStore);
132
137
  const watchModeController = new WatchModeController(projectStore);
133
138
  const connectionController = new ConnectionController(projectStore);
134
139
  const modelController = new ModelController(projectStore);
135
- const packageController = new PackageController(projectStore);
140
+ const packageController = new PackageController(projectStore, manifestService);
136
141
  const databaseController = new DatabaseController(projectStore);
137
142
  const queryController = new QueryController(projectStore);
138
143
  const compileController = new CompileController(projectStore);
144
+ const materializationService = new MaterializationService(
145
+ projectStore,
146
+ manifestService,
147
+ );
148
+ const materializationController = new MaterializationController(
149
+ materializationService,
150
+ );
151
+ const manifestController = new ManifestController(
152
+ projectStore,
153
+ manifestService,
154
+ );
139
155
 
140
156
  export const mcpApp = express();
141
157
 
@@ -262,7 +278,9 @@ app.use(
262
278
  credentials: true,
263
279
  }),
264
280
  );
265
- app.use(bodyParser.json());
281
+
282
+ // Set body-parser JSON limit to 1Mb (default: 100kb)
283
+ app.use(bodyParser.json({ limit: "1mb" }));
266
284
 
267
285
  // Register health check endpoints on main app:
268
286
  // - Required for production/Kubernetes monitoring (main server on PUBLISHER_PORT)
@@ -579,7 +597,7 @@ app.post(
579
597
  req.params.projectName,
580
598
  req.params.connectionName,
581
599
  req.body.sqlStatement as string,
582
- req.query.options as string,
600
+ req.body.options as string,
583
601
  ),
584
602
  );
585
603
  } catch (error) {
@@ -650,9 +668,11 @@ app.get(`${API_PREFIX}/projects/:projectName/packages`, async (req, res) => {
650
668
 
651
669
  app.post(`${API_PREFIX}/projects/:projectName/packages`, async (req, res) => {
652
670
  try {
671
+ const autoLoadManifest = req.query.autoLoadManifest === "true";
653
672
  const _package = await packageController.addPackage(
654
673
  req.params.projectName,
655
674
  req.body,
675
+ { autoLoadManifest },
656
676
  );
657
677
  res.status(200).json(_package?.getPackageMetadata());
658
678
  } catch (error) {
@@ -953,6 +973,173 @@ app.post(
953
973
  },
954
974
  );
955
975
 
976
+ // ==================== MATERIALIZATION ROUTES ====================
977
+
978
+ app.post(
979
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations`,
980
+ async (req, res) => {
981
+ try {
982
+ const build = await materializationController.createMaterialization(
983
+ req.params.projectName,
984
+ req.params.packageName,
985
+ req.body || {},
986
+ );
987
+ res.status(201).json(build);
988
+ } catch (error) {
989
+ const { json, status } = internalErrorToHttpError(error as Error);
990
+ res.status(status).json(json);
991
+ }
992
+ },
993
+ );
994
+
995
+ app.get(
996
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations`,
997
+ async (req, res) => {
998
+ try {
999
+ const limit = req.query.limit
1000
+ ? parseInt(req.query.limit as string, 10)
1001
+ : undefined;
1002
+ const offset = req.query.offset
1003
+ ? parseInt(req.query.offset as string, 10)
1004
+ : undefined;
1005
+ const builds = await materializationController.listMaterializations(
1006
+ req.params.projectName,
1007
+ req.params.packageName,
1008
+ { limit, offset },
1009
+ );
1010
+ res.status(200).json(builds);
1011
+ } catch (error) {
1012
+ const { json, status } = internalErrorToHttpError(error as Error);
1013
+ res.status(status).json(json);
1014
+ }
1015
+ },
1016
+ );
1017
+
1018
+ app.get(
1019
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
1020
+ async (req, res) => {
1021
+ try {
1022
+ const build = await materializationController.getMaterialization(
1023
+ req.params.projectName,
1024
+ req.params.packageName,
1025
+ req.params.materializationId,
1026
+ );
1027
+ res.status(200).json(build);
1028
+ } catch (error) {
1029
+ const { json, status } = internalErrorToHttpError(error as Error);
1030
+ res.status(status).json(json);
1031
+ }
1032
+ },
1033
+ );
1034
+
1035
+ app.post(
1036
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/teardown`,
1037
+ async (req, res) => {
1038
+ try {
1039
+ const result = await materializationController.teardownPackage(
1040
+ req.params.projectName,
1041
+ req.params.packageName,
1042
+ req.body || {},
1043
+ );
1044
+ res.status(200).json(result);
1045
+ } catch (error) {
1046
+ const { json, status } = internalErrorToHttpError(error as Error);
1047
+ res.status(status).json(json);
1048
+ }
1049
+ },
1050
+ );
1051
+
1052
+ app.post(
1053
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
1054
+ async (req, res) => {
1055
+ try {
1056
+ const action = req.query.action;
1057
+ if (action === "start") {
1058
+ const build = await materializationController.startMaterialization(
1059
+ req.params.projectName,
1060
+ req.params.packageName,
1061
+ req.params.materializationId,
1062
+ );
1063
+ res.status(202).json(build);
1064
+ } else if (action === "stop") {
1065
+ const build = await materializationController.stopMaterialization(
1066
+ req.params.projectName,
1067
+ req.params.packageName,
1068
+ req.params.materializationId,
1069
+ );
1070
+ res.status(200).json(build);
1071
+ } else {
1072
+ throw new BadRequestError(
1073
+ `Unsupported action '${String(action ?? "")}'. Expected 'start' or 'stop'.`,
1074
+ );
1075
+ }
1076
+ } catch (error) {
1077
+ const { json, status } = internalErrorToHttpError(error as Error);
1078
+ res.status(status).json(json);
1079
+ }
1080
+ },
1081
+ );
1082
+
1083
+ app.delete(
1084
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
1085
+ async (req, res) => {
1086
+ try {
1087
+ await materializationController.deleteMaterialization(
1088
+ req.params.projectName,
1089
+ req.params.packageName,
1090
+ req.params.materializationId,
1091
+ );
1092
+ res.status(204).send();
1093
+ } catch (error) {
1094
+ const { json, status } = internalErrorToHttpError(error as Error);
1095
+ res.status(status).json(json);
1096
+ }
1097
+ },
1098
+ );
1099
+
1100
+ // ==================== MANIFEST ROUTES ====================
1101
+
1102
+ app.get(
1103
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/manifest`,
1104
+ async (req, res) => {
1105
+ try {
1106
+ const manifest = await manifestController.getManifest(
1107
+ req.params.projectName,
1108
+ req.params.packageName,
1109
+ );
1110
+ res.status(200).json(manifest);
1111
+ } catch (error) {
1112
+ logger.error("Get manifest error", { error });
1113
+ const { json, status } = internalErrorToHttpError(error as Error);
1114
+ res.status(status).json(json);
1115
+ }
1116
+ },
1117
+ );
1118
+
1119
+ app.post(
1120
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/manifest`,
1121
+ async (req, res) => {
1122
+ try {
1123
+ const action = req.query.action;
1124
+ if (action === "reload") {
1125
+ const manifest = await manifestController.reloadManifest(
1126
+ req.params.projectName,
1127
+ req.params.packageName,
1128
+ );
1129
+ res.status(200).json(manifest);
1130
+ } else {
1131
+ throw new BadRequestError(
1132
+ `Unsupported action '${String(action ?? "")}'. Expected 'reload'.`,
1133
+ );
1134
+ }
1135
+ } catch (error) {
1136
+ logger.error("Manifest action error", { error });
1137
+ const { json, status } = internalErrorToHttpError(error as Error);
1138
+ res.status(status).json(json);
1139
+ }
1140
+ },
1141
+ );
1142
+
956
1143
  // Modify the catch-all route to only serve index.html in production
957
1144
  if (!isDevelopment) {
958
1145
  app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));
@@ -1093,10 +1093,7 @@ export async function createProjectConnections(
1093
1093
  },
1094
1094
  poolOptions: {
1095
1095
  min: 1,
1096
- max: 5,
1097
- testOnBorrow: false,
1098
- testOnReturn: false,
1099
- testWhileIdle: true,
1096
+ max: 20,
1100
1097
  },
1101
1098
  };
1102
1099
  const snowflakeConnection = new SnowflakeConnection(
@@ -170,6 +170,61 @@ describe("service/filter", () => {
170
170
  const filters = parseFilters(["#(doc) some docs", "# hidden"]);
171
171
  expect(filters).toHaveLength(0);
172
172
  });
173
+
174
+ it("deduplicates by name, later annotations win (extend pattern)", () => {
175
+ const annotations = [
176
+ // Base source annotations (come first in blockNotes via inherits chain)
177
+ "#(filter) name=Manufacturer dimension=Manufacturer type=in",
178
+ "#(filter) name=Subject dimension=Subject type=like",
179
+ '#(filter) name="Major Recall" dimension="Major Recall" type=equal',
180
+ // Extending source annotations (come later, should win)
181
+ "#(filter) name=Manufacturer dimension=Manufacturer type=equal required",
182
+ "#(filter) name=Subject dimension=Subject type=like",
183
+ ];
184
+ const filters = parseFilters(annotations);
185
+ // 3 unique names: Manufacturer, Subject, Major Recall
186
+ expect(filters).toHaveLength(3);
187
+
188
+ // Manufacturer: child overrides base (in → equal, gains required)
189
+ const mfr = filters.find((f) => f.name === "Manufacturer");
190
+ expect(mfr).toBeDefined();
191
+ expect(mfr!.type).toBe("equal");
192
+ expect(mfr!.required).toBe(true);
193
+
194
+ // Subject: child re-declares identically, no visible change
195
+ const subj = filters.find((f) => f.name === "Subject");
196
+ expect(subj).toBeDefined();
197
+ expect(subj!.type).toBe("like");
198
+ expect(subj!.required).toBeFalsy();
199
+
200
+ // Major Recall: only on base, preserved in child
201
+ const major = filters.find((f) => f.name === "Major Recall");
202
+ expect(major).toBeDefined();
203
+ expect(major!.type).toBe("equal");
204
+ expect(major!.dimension).toBe("Major Recall");
205
+ });
206
+
207
+ it("child can remove required flag by overriding", () => {
208
+ const annotations = [
209
+ "#(filter) name=status dimension=status type=equal required",
210
+ "#(filter) name=status dimension=status type=equal",
211
+ ];
212
+ const filters = parseFilters(annotations);
213
+ expect(filters).toHaveLength(1);
214
+ expect(filters[0].name).toBe("status");
215
+ expect(filters[0].required).toBeFalsy();
216
+ });
217
+
218
+ it("child can change filter type by overriding", () => {
219
+ const annotations = [
220
+ "#(filter) name=category dimension=category type=in",
221
+ "#(filter) name=category dimension=category type=equal required",
222
+ ];
223
+ const filters = parseFilters(annotations);
224
+ expect(filters).toHaveLength(1);
225
+ expect(filters[0].type).toBe("equal");
226
+ expect(filters[0].required).toBe(true);
227
+ });
173
228
  });
174
229
 
175
230
  // -----------------------------------------------------------------------
@@ -127,14 +127,19 @@ export function parseFilterAnnotation(
127
127
  * (as found on a Malloy source's `blockNotes`).
128
128
  */
129
129
  export function parseFilters(annotations: string[]): FilterDefinition[] {
130
- const filters: FilterDefinition[] = [];
130
+ // Use a Map keyed by filter name so that later annotations (from an
131
+ // extending source) override earlier ones (from the base source).
132
+ // This is important when `source: child is parent extend {}` inherits
133
+ // blockNotes from the parent — the child's annotations come last and
134
+ // should win.
135
+ const byName = new Map<string, FilterDefinition>();
131
136
  for (const annotation of annotations) {
132
137
  const parsed = parseFilterAnnotation(annotation);
133
138
  if (parsed) {
134
- filters.push(parsed);
139
+ byName.set(parsed.name, parsed);
135
140
  }
136
141
  }
137
- return filters;
142
+ return [...byName.values()];
138
143
  }
139
144
 
140
145
  // ---------------------------------------------------------------------------
@@ -94,6 +94,45 @@ import "orders_optional.malloy"
94
94
  run: orders -> summary
95
95
  `;
96
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
+
97
136
  beforeAll(async () => {
98
137
  await fs.mkdir(TEST_DB_DIR, { recursive: true });
99
138
  await fs.mkdir(TEST_PKG_DIR, { recursive: true });
@@ -619,4 +658,168 @@ describe("filter integration", () => {
619
658
  expect(markdownCell.text).toContain("Test Notebook");
620
659
  });
621
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
+ });
622
825
  });