@malloy-publisher/server 0.0.197 → 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.
- package/README.docker.md +47 -0
- package/build.ts +26 -1
- package/dist/app/api-doc.yaml +54 -20
- package/dist/app/assets/{EnvironmentPage-BVkQH_xQ.js → EnvironmentPage-Dpee_Kn6.js} +1 -1
- package/dist/app/assets/{HomePage-BgH9UkjK.js → HomePage-DLRWTNoL.js} +1 -1
- package/dist/app/assets/{MainPage-DiBxABem.js → MainPage-DsVt5QGM.js} +1 -1
- package/dist/app/assets/{ModelPage-oS70fj83.js → ModelPage-AwAugZ37.js} +1 -1
- package/dist/app/assets/{PackagePage-F_qLDAdv.js → PackagePage-XQ-EWGTC.js} +1 -1
- package/dist/app/assets/{RouteError-WqpffppN.js → RouteError-3Mv8JQw7.js} +1 -1
- package/dist/app/assets/{WorkbookPage-_YmC-ebR.js → WorkbookPage-DHYYpcYc.js} +1 -1
- package/dist/app/assets/{core-B8L9xCYT.es-BcRLJTnC.js → core-DfcpQGVP.es-DQggNOdX.js} +1 -1
- package/dist/app/assets/{index-C3XPaTaS.js → index-BUp81Qdm.js} +1 -1
- package/dist/app/assets/{index-rg8Ok8nl.js → index-D1pdwrUW.js} +1 -1
- package/dist/app/assets/{index-BMViiwtJ.js → index-Dv5bF4Ii.js} +4 -4
- package/dist/app/assets/{index.umd-CCAfKkxY.js → index.umd-CQH4LZU8.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/compile_worker.mjs +628 -0
- package/dist/instrumentation.mjs +36 -36
- package/dist/server.mjs +1781 -809
- package/package.json +1 -1
- package/src/compile/compile_pool.spec.ts +227 -0
- package/src/compile/compile_pool.ts +729 -0
- package/src/compile/compile_worker.ts +683 -0
- package/src/compile/protocol.ts +251 -0
- package/src/config.spec.ts +81 -0
- package/src/config.ts +126 -0
- package/src/controller/compile.controller.ts +3 -1
- package/src/controller/model.controller.ts +8 -1
- package/src/controller/package.controller.ts +70 -29
- package/src/controller/query.controller.ts +3 -0
- package/src/errors.ts +13 -0
- package/src/health.spec.ts +90 -0
- package/src/health.ts +86 -71
- package/src/mcp/tools/discovery_tools.ts +6 -2
- package/src/mcp/tools/execute_query_tool.ts +12 -0
- package/src/path_safety.spec.ts +158 -0
- package/src/path_safety.ts +140 -0
- package/src/server.ts +29 -0
- package/src/service/environment.ts +616 -199
- package/src/service/environment_admission.spec.ts +180 -0
- package/src/service/environment_store.spec.ts +0 -19
- package/src/service/environment_store.ts +24 -21
- package/src/service/filter_integration.spec.ts +110 -0
- package/src/service/givens_integration.spec.ts +192 -0
- package/src/service/manifest_service.spec.ts +7 -2
- package/src/service/manifest_service.ts +8 -2
- package/src/service/materialization_service.ts +14 -3
- package/src/service/model.spec.ts +105 -0
- package/src/service/model.ts +317 -10
- package/src/service/model_worker_path.spec.ts +125 -0
- package/src/service/package_memory_governor.spec.ts +173 -0
- package/src/service/package_memory_governor.ts +233 -0
- package/src/service/package_race.spec.ts +208 -0
- package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
|
@@ -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
|
+
});
|
|
@@ -355,15 +355,6 @@ describe("EnvironmentStore Service", () => {
|
|
|
355
355
|
expect(projects.length).toBe(2);
|
|
356
356
|
expect(projects.map((p) => p.name)).toContain(projectName1);
|
|
357
357
|
expect(projects.map((p) => p.name)).toContain(projectName2);
|
|
358
|
-
|
|
359
|
-
// All envs initialized cleanly → status is "serving" (not
|
|
360
|
-
// "degraded") and there's no failedEnvironments key on the
|
|
361
|
-
// response. This is the happy-path companion to the
|
|
362
|
-
// "should skip a project with invalid startup connection config"
|
|
363
|
-
// test which exercises the degraded path.
|
|
364
|
-
const status = await newEnvironmentStore.getStatus();
|
|
365
|
-
expect(status.operationalState).toBe("serving");
|
|
366
|
-
expect(status.failedEnvironments).toBeUndefined();
|
|
367
358
|
});
|
|
368
359
|
|
|
369
360
|
it("should skip a project with invalid startup connection config", async () => {
|
|
@@ -436,16 +427,6 @@ describe("EnvironmentStore Service", () => {
|
|
|
436
427
|
await expect(
|
|
437
428
|
newEnvironmentStore.getEnvironment(invalidProjectName),
|
|
438
429
|
).rejects.toThrow();
|
|
439
|
-
|
|
440
|
-
// The skipped environment should surface in the status response
|
|
441
|
-
// so external callers (CI smoke tests, dashboards) can tell the
|
|
442
|
-
// server is only partially serving.
|
|
443
|
-
const status = await newEnvironmentStore.getStatus();
|
|
444
|
-
expect(status.operationalState).toBe("degraded");
|
|
445
|
-
expect(status.failedEnvironments).toBeDefined();
|
|
446
|
-
expect(status.failedEnvironments?.map((f) => f.name)).toContain(
|
|
447
|
-
invalidProjectName,
|
|
448
|
-
);
|
|
449
430
|
});
|
|
450
431
|
|
|
451
432
|
it("should handle project updates", async () => {
|
|
@@ -25,16 +25,12 @@ import {
|
|
|
25
25
|
FrozenConfigError,
|
|
26
26
|
PackageNotFoundError,
|
|
27
27
|
} from "../errors";
|
|
28
|
-
import {
|
|
29
|
-
getOperationalState,
|
|
30
|
-
markDegraded,
|
|
31
|
-
markNotReady,
|
|
32
|
-
markReady,
|
|
33
|
-
} from "../health";
|
|
28
|
+
import { getOperationalState, markNotReady, markReady } from "../health";
|
|
34
29
|
import { formatDuration, logger } from "../logger";
|
|
35
30
|
import { Connection } from "../storage/DatabaseInterface";
|
|
36
31
|
import { StorageConfig, StorageManager } from "../storage/StorageManager";
|
|
37
32
|
import { Environment, PackageStatus } from "./environment";
|
|
33
|
+
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
38
34
|
type ApiEnvironment = components["schemas"]["Environment"];
|
|
39
35
|
|
|
40
36
|
const AZURE_SUPPORTED_SCHEMES = ["https://", "http://", "abfss://", "az://"];
|
|
@@ -101,12 +97,15 @@ export class EnvironmentStore {
|
|
|
101
97
|
public publisherConfigIsFrozen: boolean;
|
|
102
98
|
public finishedInitialization: Promise<void>;
|
|
103
99
|
private isInitialized: boolean = false;
|
|
104
|
-
private failedEnvironments: Array<{ name: string; error: string }> = [];
|
|
105
100
|
public storageManager: StorageManager;
|
|
106
101
|
private s3Client = new S3({
|
|
107
102
|
followRegionRedirects: true,
|
|
108
103
|
});
|
|
109
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;
|
|
110
109
|
|
|
111
110
|
constructor(serverRootPath: string) {
|
|
112
111
|
this.serverRootPath = serverRootPath;
|
|
@@ -123,6 +122,19 @@ export class EnvironmentStore {
|
|
|
123
122
|
this.finishedInitialization = this.initialize();
|
|
124
123
|
}
|
|
125
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
|
+
|
|
126
138
|
private async addConfiguredEnvironment(environment: ProcessedEnvironment) {
|
|
127
139
|
try {
|
|
128
140
|
await this.addEnvironment(
|
|
@@ -148,10 +160,6 @@ export class EnvironmentStore {
|
|
|
148
160
|
`Error initializing environment${label}; skipping environment`,
|
|
149
161
|
this.extractErrorDataFromError(error),
|
|
150
162
|
);
|
|
151
|
-
this.failedEnvironments.push({
|
|
152
|
-
name: environmentName ?? "<unknown>",
|
|
153
|
-
error: error instanceof Error ? error.message : String(error),
|
|
154
|
-
});
|
|
155
163
|
}
|
|
156
164
|
|
|
157
165
|
private async initialize() {
|
|
@@ -248,6 +256,9 @@ export class EnvironmentStore {
|
|
|
248
256
|
...conn.config,
|
|
249
257
|
})),
|
|
250
258
|
);
|
|
259
|
+
environmentInstance.setMemoryGovernor(
|
|
260
|
+
this.memoryGovernor,
|
|
261
|
+
);
|
|
251
262
|
|
|
252
263
|
// Get packages from database
|
|
253
264
|
const packages = await repository.listPackages(
|
|
@@ -285,11 +296,7 @@ export class EnvironmentStore {
|
|
|
285
296
|
}
|
|
286
297
|
|
|
287
298
|
this.isInitialized = true;
|
|
288
|
-
|
|
289
|
-
markDegraded();
|
|
290
|
-
} else {
|
|
291
|
-
markReady();
|
|
292
|
-
}
|
|
299
|
+
markReady();
|
|
293
300
|
const initializationDuration = performance.now() - initialTime;
|
|
294
301
|
logger.info(
|
|
295
302
|
`Environment store successfully initialized in ${formatDuration(initializationDuration)}`,
|
|
@@ -703,11 +710,6 @@ export class EnvironmentStore {
|
|
|
703
710
|
frozenConfig: isPublisherConfigFrozen(this.serverRootPath),
|
|
704
711
|
operationalState:
|
|
705
712
|
getOperationalState() as components["schemas"]["ServerStatus"]["operationalState"],
|
|
706
|
-
...(this.failedEnvironments.length > 0 && {
|
|
707
|
-
failedEnvironments: [
|
|
708
|
-
...this.failedEnvironments,
|
|
709
|
-
] as components["schemas"]["ServerStatus"]["failedEnvironments"],
|
|
710
|
-
}),
|
|
711
713
|
};
|
|
712
714
|
|
|
713
715
|
const environments = await this.listEnvironments(true);
|
|
@@ -856,6 +858,7 @@ export class EnvironmentStore {
|
|
|
856
858
|
absoluteEnvironmentPath,
|
|
857
859
|
environment.connections || [],
|
|
858
860
|
);
|
|
861
|
+
newEnvironment.setMemoryGovernor(this.memoryGovernor);
|
|
859
862
|
|
|
860
863
|
if (!newEnvironment.metadata) newEnvironment.metadata = {};
|
|
861
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(
|
|
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.
|
|
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
|
-
|
|
86
|
-
|
|
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,
|