@malloy-publisher/server 0.0.198-dev → 0.0.198-dev1

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 (86) hide show
  1. package/README.docker.md +135 -20
  2. package/README.md +15 -0
  3. package/build.ts +42 -1
  4. package/dist/app/api-doc.yaml +51 -0
  5. package/dist/app/assets/EnvironmentPage-Dpee_Kn6.js +1 -0
  6. package/dist/app/assets/HomePage-DLRWTNoL.js +1 -0
  7. package/dist/app/assets/MainPage-DsVt5QGM.js +2 -0
  8. package/dist/app/assets/ModelPage-AwAugZ37.js +1 -0
  9. package/dist/app/assets/PackagePage-XQ-EWGTC.js +1 -0
  10. package/dist/app/assets/RouteError-3Mv8JQw7.js +1 -0
  11. package/dist/app/assets/WorkbookPage-DHYYpcYc.js +1 -0
  12. package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-DfcpQGVP.es-DQggNOdX.js} +14 -14
  13. package/dist/app/assets/{index-C513UodQ.js → index-BUp81Qdm.js} +15 -15
  14. package/dist/app/assets/index-D1pdwrUW.js +1803 -0
  15. package/dist/app/assets/index-Dv5bF4Ii.js +451 -0
  16. package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CQH4LZU8.js} +1 -1
  17. package/dist/app/index.html +2 -3
  18. package/dist/compile_worker.mjs +628 -0
  19. package/dist/default-publisher.config.json +23 -0
  20. package/dist/instrumentation.mjs +36 -38
  21. package/dist/server.mjs +2060 -913
  22. package/package.json +11 -12
  23. package/publisher.config.example.bigquery.json +33 -0
  24. package/publisher.config.example.duckdb.json +23 -0
  25. package/publisher.config.json +1 -11
  26. package/src/compile/compile_pool.spec.ts +227 -0
  27. package/src/compile/compile_pool.ts +729 -0
  28. package/src/compile/compile_worker.ts +683 -0
  29. package/src/compile/protocol.ts +251 -0
  30. package/src/config.spec.ts +306 -0
  31. package/src/config.ts +222 -2
  32. package/src/controller/compile.controller.ts +3 -1
  33. package/src/controller/connection.controller.ts +1 -1
  34. package/src/controller/model.controller.ts +8 -1
  35. package/src/controller/package.controller.ts +70 -29
  36. package/src/controller/query.controller.ts +3 -0
  37. package/src/default-publisher.config.json +23 -0
  38. package/src/errors.spec.ts +42 -0
  39. package/src/errors.ts +21 -0
  40. package/src/health.spec.ts +90 -0
  41. package/src/health.ts +86 -45
  42. package/src/logger.ts +1 -3
  43. package/src/mcp/tools/discovery_tools.ts +6 -2
  44. package/src/mcp/tools/execute_query_tool.ts +12 -0
  45. package/src/path_safety.spec.ts +158 -0
  46. package/src/path_safety.ts +140 -0
  47. package/src/pg_helpers.spec.ts +226 -0
  48. package/src/pg_helpers.ts +129 -0
  49. package/src/server-old.ts +3 -23
  50. package/src/server.ts +49 -0
  51. package/src/service/connection.spec.ts +6 -4
  52. package/src/service/connection.ts +8 -3
  53. package/src/service/connection_config.ts +2 -2
  54. package/src/service/environment.ts +621 -176
  55. package/src/service/environment_admission.spec.ts +180 -0
  56. package/src/service/environment_store.ts +22 -0
  57. package/src/service/filter_integration.spec.ts +110 -0
  58. package/src/service/givens_integration.spec.ts +192 -0
  59. package/src/service/manifest_service.spec.ts +7 -2
  60. package/src/service/manifest_service.ts +8 -2
  61. package/src/service/materialization_service.ts +14 -3
  62. package/src/service/model.spec.ts +105 -0
  63. package/src/service/model.ts +317 -10
  64. package/src/service/model_worker_path.spec.ts +125 -0
  65. package/src/service/package.ts +4 -3
  66. package/src/service/package_memory_governor.spec.ts +173 -0
  67. package/src/service/package_memory_governor.ts +233 -0
  68. package/src/service/package_race.spec.ts +208 -0
  69. package/src/storage/StorageManager.ts +71 -11
  70. package/src/storage/duckdb/schema.ts +41 -0
  71. package/src/utils.ts +11 -0
  72. package/tests/harness/rest_e2e.ts +2 -2
  73. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
  74. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
  75. package/tests/unit/duckdb/attached_databases.test.ts +5 -5
  76. package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
  77. package/tests/unit/storage/StorageManager.test.ts +166 -0
  78. package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
  79. package/dist/app/assets/HomePage-DMop21VG.js +0 -1
  80. package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
  81. package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
  82. package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
  83. package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
  84. package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
  85. package/dist/app/assets/index-5K9YjIxF.js +0 -456
  86. package/dist/app/assets/index-DIgzgp69.js +0 -1742
@@ -0,0 +1,180 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+
6
+ import { ServiceUnavailableError } from "../errors";
7
+ import { buildEnvironmentMalloyConfig } from "./connection";
8
+ import { Environment } from "./environment";
9
+ import type { PackageMemoryGovernor } from "./package_memory_governor";
10
+
11
+ /**
12
+ * Minimal subset of {@link PackageMemoryGovernor} that
13
+ * `Environment.assertCanAdmitNewPackage` actually consults. Allows us
14
+ * to drive the gate from the tests without spinning the real OTel
15
+ * instrumentation pipeline.
16
+ */
17
+ class StubGovernor {
18
+ public backpressured = false;
19
+ isBackpressured(): boolean {
20
+ return this.backpressured;
21
+ }
22
+ }
23
+
24
+ function makeEnvironment(envPath: string): Environment {
25
+ const malloyConfig = buildEnvironmentMalloyConfig([], envPath);
26
+ return new Environment("test-env", envPath, malloyConfig, []);
27
+ }
28
+
29
+ describe("Environment admission gate (memory governor choke point)", () => {
30
+ let envDir: string;
31
+
32
+ beforeEach(() => {
33
+ envDir = fs.mkdtempSync(
34
+ path.join(os.tmpdir(), "publisher-env-admission-"),
35
+ );
36
+ });
37
+
38
+ afterEach(() => {
39
+ fs.rmSync(envDir, { recursive: true, force: true });
40
+ });
41
+
42
+ it("admits new packages when no governor is attached (legacy behaviour)", async () => {
43
+ const env = makeEnvironment(envDir);
44
+ // No governor set; the gate must be a pure no-op. The package
45
+ // doesn't exist on disk, so addPackage rejects with
46
+ // PackageNotFoundError — that we get any error other than 503
47
+ // is the assertion.
48
+ let caught: unknown;
49
+ try {
50
+ await env.addPackage("does-not-exist");
51
+ } catch (err) {
52
+ caught = err;
53
+ }
54
+ expect(caught).toBeDefined();
55
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
56
+ });
57
+
58
+ it("rejects getPackage cache-miss with 503 when back-pressured", async () => {
59
+ const env = makeEnvironment(envDir);
60
+ const governor = new StubGovernor();
61
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
62
+ governor.backpressured = true;
63
+
64
+ // No package by this name has ever been loaded, so this is the
65
+ // exact "lazy-load on cache miss" path Monty flagged. The gate
66
+ // must throw before Package.create touches the disk.
67
+ await expect(env.getPackage("ghost", false)).rejects.toBeInstanceOf(
68
+ ServiceUnavailableError,
69
+ );
70
+ });
71
+
72
+ it("rejects getPackage reload=true with 503 when back-pressured", async () => {
73
+ const env = makeEnvironment(envDir);
74
+ const governor = new StubGovernor();
75
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
76
+ governor.backpressured = true;
77
+
78
+ await expect(env.getPackage("ghost", true)).rejects.toBeInstanceOf(
79
+ ServiceUnavailableError,
80
+ );
81
+ });
82
+
83
+ it("rejects addPackage with 503 when back-pressured (after the 404 check passes)", async () => {
84
+ // Create a real (empty) package directory so the existence
85
+ // check passes and the gate gets to run. Without this, the
86
+ // PackageNotFoundError would mask the 503 we want to assert.
87
+ const pkgName = "real-pkg";
88
+ fs.mkdirSync(path.join(envDir, pkgName));
89
+
90
+ const env = makeEnvironment(envDir);
91
+ const governor = new StubGovernor();
92
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
93
+ governor.backpressured = true;
94
+
95
+ await expect(env.addPackage(pkgName)).rejects.toBeInstanceOf(
96
+ ServiceUnavailableError,
97
+ );
98
+ });
99
+
100
+ it("returns 404 (not 503) when the package directory does not exist, even under pressure", async () => {
101
+ // 404 must take precedence over 503: a permanent "you forgot to
102
+ // upload the package" error should not be masked as a transient
103
+ // "retry later" — otherwise operators chase phantom memory
104
+ // problems while the real fix is a missing artifact.
105
+ const env = makeEnvironment(envDir);
106
+ const governor = new StubGovernor();
107
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
108
+ governor.backpressured = true;
109
+
110
+ let caught: unknown;
111
+ try {
112
+ await env.addPackage("never-existed");
113
+ } catch (err) {
114
+ caught = err;
115
+ }
116
+ expect(caught).toBeDefined();
117
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
118
+ });
119
+
120
+ it("allowAdmission=true bypasses the gate (for future warmup/probe callers)", async () => {
121
+ const env = makeEnvironment(envDir);
122
+ const governor = new StubGovernor();
123
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
124
+ governor.backpressured = true;
125
+
126
+ // With the bypass, the gate must not fire — the call should
127
+ // proceed to the real loader and fail there with some other
128
+ // error (PackageNotFoundError-equivalent from Package.create on
129
+ // a non-existent directory). The assertion is "not 503".
130
+ let caught: unknown;
131
+ try {
132
+ await env.getPackage("ghost", false, { allowAdmission: true });
133
+ } catch (err) {
134
+ caught = err;
135
+ }
136
+ expect(caught).toBeDefined();
137
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
138
+ });
139
+
140
+ it("clearing back-pressure on the governor immediately re-admits new loads", async () => {
141
+ const env = makeEnvironment(envDir);
142
+ const governor = new StubGovernor();
143
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
144
+
145
+ governor.backpressured = true;
146
+ await expect(env.getPackage("ghost", false)).rejects.toBeInstanceOf(
147
+ ServiceUnavailableError,
148
+ );
149
+
150
+ // Flip the flag (simulating the periodic poller crossing the
151
+ // low-water mark) and verify the next call no longer 503s.
152
+ governor.backpressured = false;
153
+ let caught: unknown;
154
+ try {
155
+ await env.getPackage("ghost", false);
156
+ } catch (err) {
157
+ caught = err;
158
+ }
159
+ expect(caught).toBeDefined();
160
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
161
+ });
162
+
163
+ it("detaching the governor (set null) reverts to legacy admit-everything", async () => {
164
+ const env = makeEnvironment(envDir);
165
+ const governor = new StubGovernor();
166
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
167
+ governor.backpressured = true;
168
+
169
+ env.setMemoryGovernor(null);
170
+
171
+ let caught: unknown;
172
+ try {
173
+ await env.getPackage("ghost", false);
174
+ } catch (err) {
175
+ caught = err;
176
+ }
177
+ expect(caught).toBeDefined();
178
+ expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
179
+ });
180
+ });
@@ -30,6 +30,7 @@ import { formatDuration, logger } from "../logger";
30
30
  import { Connection } from "../storage/DatabaseInterface";
31
31
  import { StorageConfig, StorageManager } from "../storage/StorageManager";
32
32
  import { Environment, PackageStatus } from "./environment";
33
+ import type { PackageMemoryGovernor } from "./package_memory_governor";
33
34
  type ApiEnvironment = components["schemas"]["Environment"];
34
35
 
35
36
  const AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
@@ -101,6 +102,10 @@ export class EnvironmentStore {
101
102
  followRegionRedirects: true,
102
103
  });
103
104
  private gcsClient: Storage;
105
+ // Shared by every Environment so the back-pressure decision is
106
+ // process-wide. Set once at server start via setMemoryGovernor;
107
+ // new Environments pick it up at construction.
108
+ private memoryGovernor: PackageMemoryGovernor | null = null;
104
109
 
105
110
  constructor(serverRootPath: string) {
106
111
  this.serverRootPath = serverRootPath;
@@ -117,6 +122,19 @@ export class EnvironmentStore {
117
122
  this.finishedInitialization = this.initialize();
118
123
  }
119
124
 
125
+ /**
126
+ * Attach (or detach with `null`) the shared {@link PackageMemoryGovernor}.
127
+ * Propagated to every Environment so the back-pressure decision is
128
+ * process-wide, and remembered so any Environment created *after*
129
+ * this call also picks it up at construction.
130
+ */
131
+ public setMemoryGovernor(governor: PackageMemoryGovernor | null): void {
132
+ this.memoryGovernor = governor;
133
+ for (const env of this.environments.values()) {
134
+ env.setMemoryGovernor(governor);
135
+ }
136
+ }
137
+
120
138
  private async addConfiguredEnvironment(environment: ProcessedEnvironment) {
121
139
  try {
122
140
  await this.addEnvironment(
@@ -238,6 +256,9 @@ export class EnvironmentStore {
238
256
  ...conn.config,
239
257
  })),
240
258
  );
259
+ environmentInstance.setMemoryGovernor(
260
+ this.memoryGovernor,
261
+ );
241
262
 
242
263
  // Get packages from database
243
264
  const packages = await repository.listPackages(
@@ -837,6 +858,7 @@ export class EnvironmentStore {
837
858
  absoluteEnvironmentPath,
838
859
  environment.connections || [],
839
860
  );
861
+ newEnvironment.setMemoryGovernor(this.memoryGovernor);
840
862
 
841
863
  if (!newEnvironment.metadata) newEnvironment.metadata = {};
842
864
  newEnvironment.metadata.location = absoluteEnvironmentPath;
@@ -133,6 +133,55 @@ import "child_orders.malloy"
133
133
  run: child_orders -> summary
134
134
  `;
135
135
 
136
+ // Model with a given: declaration — view filters rows by the given value
137
+ const MODEL_WITH_GIVENS = `##! experimental.givens
138
+
139
+ given: target_region :: string is 'US'
140
+
141
+ source: orders is duckdb.table('orders') extend {
142
+ primary_key: order_id
143
+
144
+ measure:
145
+ order_count is count()
146
+ total_amount is sum(amount)
147
+
148
+ view: by_given_region is {
149
+ where: region = $target_region
150
+ aggregate: order_count, total_amount
151
+ }
152
+ }
153
+ `;
154
+
155
+ // Model with both a #(filter) annotation and a given: declaration to verify composition
156
+ const MODEL_WITH_GIVENS_AND_FILTER = `##! experimental.givens
157
+
158
+ given: target_region :: string is 'US'
159
+
160
+ #(filter) dimension=status type=equal
161
+ source: orders is duckdb.table('orders') extend {
162
+ primary_key: order_id
163
+
164
+ measure:
165
+ order_count is count()
166
+ total_amount is sum(amount)
167
+
168
+ view: by_given_region is {
169
+ where: region = $target_region
170
+ aggregate: order_count, total_amount
171
+ }
172
+ }
173
+ `;
174
+
175
+ const NOTEBOOK_GIVENS = `>>>markdown
176
+ # Givens Test
177
+
178
+ >>>malloy
179
+ import "orders_givens.malloy"
180
+
181
+ >>>malloy
182
+ run: orders -> by_given_region
183
+ `;
184
+
136
185
  beforeAll(async () => {
137
186
  await fs.mkdir(TEST_DB_DIR, { recursive: true });
138
187
  await fs.mkdir(TEST_PKG_DIR, { recursive: true });
@@ -657,6 +706,67 @@ describe("filter integration", () => {
657
706
  expect(markdownCell.type).toBe("markdown");
658
707
  expect(markdownCell.text).toContain("Test Notebook");
659
708
  });
709
+
710
+ it("applies givens to notebook cell execution", async () => {
711
+ await writeFile("orders_givens.malloy", MODEL_WITH_GIVENS);
712
+ await writeFile("givens_notebook.malloynb", NOTEBOOK_GIVENS);
713
+ const model = await Model.create(
714
+ "test-pkg",
715
+ TEST_PKG_DIR,
716
+ "givens_notebook.malloynb",
717
+ getConnections(),
718
+ );
719
+
720
+ // Cell 2: run: orders -> by_given_region with target_region overridden to 'EU'
721
+ // EU rows: (3,'EU','active',150) and (4,'EU','cancelled',75) → order_count=2, total_amount=225
722
+ const codeCell = await model.executeNotebookCell(
723
+ 2,
724
+ undefined,
725
+ undefined,
726
+ { target_region: "EU" },
727
+ );
728
+ expect(codeCell.result).toBeDefined();
729
+
730
+ const notebookRows = parseNotebookResult(codeCell.result!);
731
+ expect(notebookRows.length).toBe(1);
732
+ expect(Number(notebookRows[0].order_count)).toBe(2);
733
+ expect(Number(notebookRows[0].total_amount)).toBe(225);
734
+ });
735
+
736
+ it("composes givens and filterParams in notebook cell execution", async () => {
737
+ await writeFile(
738
+ "orders_givens_filter.malloy",
739
+ MODEL_WITH_GIVENS_AND_FILTER,
740
+ );
741
+ await writeFile(
742
+ "givens_filter_notebook.malloynb",
743
+ NOTEBOOK_GIVENS.replace(
744
+ "orders_givens.malloy",
745
+ "orders_givens_filter.malloy",
746
+ ),
747
+ );
748
+ const model = await Model.create(
749
+ "test-pkg",
750
+ TEST_PKG_DIR,
751
+ "givens_filter_notebook.malloynb",
752
+ getConnections(),
753
+ );
754
+
755
+ // given restricts to APAC; filterParam restricts to active
756
+ // APAC + active: only (5,'APAC','active',300) → order_count=1, total_amount=300
757
+ const codeCell = await model.executeNotebookCell(
758
+ 2,
759
+ { status: "active" },
760
+ undefined,
761
+ { target_region: "APAC" },
762
+ );
763
+ expect(codeCell.result).toBeDefined();
764
+
765
+ const notebookRows = parseNotebookResult(codeCell.result!);
766
+ expect(notebookRows.length).toBe(1);
767
+ expect(Number(notebookRows[0].order_count)).toBe(1);
768
+ expect(Number(notebookRows[0].total_amount)).toBe(300);
769
+ });
660
770
  });
661
771
 
662
772
  // -----------------------------------------------------------------------
@@ -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
+ });
@@ -39,6 +39,7 @@ function createMocks() {
39
39
  } as unknown as sinon.SinonStubbedInstance<ResourceRepository>;
40
40
 
41
41
  const reloadAllModels = sandbox.stub().resolves();
42
+ const reloadAllModelsForPackage = sandbox.stub().resolves();
42
43
 
43
44
  const pkg = {
44
45
  reloadAllModels,
@@ -46,6 +47,7 @@ function createMocks() {
46
47
 
47
48
  const environment = {
48
49
  getPackage: sandbox.stub().resolves(pkg),
50
+ reloadAllModelsForPackage,
49
51
  };
50
52
 
51
53
  const environmentStore = {
@@ -66,6 +68,7 @@ function createMocks() {
66
68
  environment,
67
69
  pkg,
68
70
  reloadAllModels,
71
+ reloadAllModelsForPackage,
69
72
  service,
70
73
  };
71
74
  }
@@ -153,7 +156,9 @@ describe("ManifestService", () => {
153
156
  ),
154
157
  ).toBe(true);
155
158
  expect(ctx.environment.getPackage.calledWith("pkg", false)).toBe(true);
156
- expect(ctx.reloadAllModels.calledWith(manifest.entries)).toBe(true);
159
+ expect(
160
+ ctx.reloadAllModelsForPackage.calledWith("pkg", manifest.entries),
161
+ ).toBe(true);
157
162
  });
158
163
 
159
164
  it("should return an empty manifest when no entries exist", async () => {
@@ -170,7 +175,7 @@ describe("ManifestService", () => {
170
175
  );
171
176
 
172
177
  expect(result.entries).toEqual({});
173
- expect(ctx.reloadAllModels.calledWith({})).toBe(true);
178
+ expect(ctx.reloadAllModelsForPackage.calledWith("pkg", {})).toBe(true);
174
179
  });
175
180
  });
176
181
 
@@ -82,8 +82,14 @@ export class ManifestService {
82
82
  environmentName,
83
83
  false,
84
84
  );
85
- const pkg = await environment.getPackage(packageName, false);
86
- await pkg.reloadAllModels(manifest.entries);
85
+ // Ensure the package is loaded, then reload its models under the
86
+ // per-package mutex so the disk reads are serialized against
87
+ // installPackage / deletePackage.
88
+ await environment.getPackage(packageName, false);
89
+ await environment.reloadAllModelsForPackage(
90
+ packageName,
91
+ manifest.entries,
92
+ );
87
93
 
88
94
  logger.info("Reloaded manifest and recompiled models", {
89
95
  environmentId,
@@ -376,8 +376,14 @@ export class MaterializationService {
376
376
  environmentName,
377
377
  false,
378
378
  );
379
- const pkg = await environment.getPackage(packageName, false);
380
- await pkg.reloadAllModels(updatedManifest.entries);
379
+ // Ensure the package is loaded, then reload models under the
380
+ // per-package mutex so the disk reads are serialized against
381
+ // installPackage / deletePackage.
382
+ await environment.getPackage(packageName, false);
383
+ await environment.reloadAllModelsForPackage(
384
+ packageName,
385
+ updatedManifest.entries,
386
+ );
381
387
  }
382
388
 
383
389
  await this.transitionExecution(executionId, "SUCCESS", {
@@ -604,8 +610,13 @@ export class MaterializationService {
604
610
  // ── STEP 2: COMPILE & PLAN ─────────────────────────────────────
605
611
  // `connections` is built lazily from the connection names the plan
606
612
  // actually targets — no upfront ATTACH on every environment connection.
613
+ // Hold the per-package mutex for the duration of the compile so the
614
+ // `fs.stat` + `runtime.loadModel` calls inside `compilePackageBuildPlan`
615
+ // are serialized against `installPackage` / `deletePackage`.
607
616
  const { graphs, sources, connectionDigests, connections } =
608
- await this.compilePackageBuildPlan(pkg, signal);
617
+ await environment.withPackageLock(packageName, () =>
618
+ this.compilePackageBuildPlan(pkg, signal),
619
+ );
609
620
 
610
621
  if (graphs.length === 0) {
611
622
  logger.info("No persist sources to build");