@malloy-publisher/server 0.0.230 → 0.0.232
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 +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +3186 -433
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/errors.ts +19 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +540 -52
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
4
|
+
import { type ChildProcess, spawn } from "child_process";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import net from "net";
|
|
7
|
+
import os from "os";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* End-to-end coverage for the PUBLISHER_READY line: the contract scripts grep
|
|
13
|
+
* for instead of polling /api/v0/status. It must appear on stderr exactly
|
|
14
|
+
* once, only when the server is actually serving, and carry the environment,
|
|
15
|
+
* package, and load-error counts. That contract spans process boundaries
|
|
16
|
+
* (a real stderr stream from a real boot), so it needs a dedicated server
|
|
17
|
+
* subprocess rather than the in-process REST harness.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
// tests/integration/first_boot -> packages/server
|
|
22
|
+
const SERVER_DIR = path.resolve(__dirname, "../../..");
|
|
23
|
+
|
|
24
|
+
const ENV_NAME = "ready-env";
|
|
25
|
+
const PKG_NAME = "ready-pkg";
|
|
26
|
+
|
|
27
|
+
/** Allocate an OS-assigned free TCP port (avoids fixed-port collisions). */
|
|
28
|
+
async function getFreePort(): Promise<number> {
|
|
29
|
+
return new Promise<number>((resolve, reject) => {
|
|
30
|
+
const srv = net.createServer();
|
|
31
|
+
srv.on("error", reject);
|
|
32
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
33
|
+
const addr = srv.address();
|
|
34
|
+
const found = typeof addr === "object" && addr ? addr.port : 0;
|
|
35
|
+
srv.close(() =>
|
|
36
|
+
found ? resolve(found) : reject(new Error("no free port")),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Returns true once `predicate` does, or false if `timeoutMs` elapses first. */
|
|
43
|
+
async function poll(
|
|
44
|
+
predicate: () => Promise<boolean>,
|
|
45
|
+
timeoutMs: number,
|
|
46
|
+
intervalMs = 300,
|
|
47
|
+
): Promise<boolean> {
|
|
48
|
+
const deadline = Date.now() + timeoutMs;
|
|
49
|
+
while (Date.now() < deadline) {
|
|
50
|
+
if (await predicate()) return true;
|
|
51
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("first-boot readiness line", () => {
|
|
57
|
+
let proc: ChildProcess | undefined;
|
|
58
|
+
let exited = false;
|
|
59
|
+
let stderrLog = "";
|
|
60
|
+
let combinedLog = "";
|
|
61
|
+
let srcDir = "";
|
|
62
|
+
let serverRoot = "";
|
|
63
|
+
let port = 0;
|
|
64
|
+
|
|
65
|
+
const readyLines = () =>
|
|
66
|
+
stderrLog
|
|
67
|
+
.split("\n")
|
|
68
|
+
.filter((line) => line.startsWith("PUBLISHER_READY"));
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "ready-src-"));
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
path.join(srcDir, "publisher.json"),
|
|
74
|
+
JSON.stringify({ name: PKG_NAME }),
|
|
75
|
+
);
|
|
76
|
+
fs.writeFileSync(
|
|
77
|
+
path.join(srcDir, "first.malloy"),
|
|
78
|
+
'source: first_source is duckdb.sql("SELECT 1 as n")\n',
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
serverRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ready-root-"));
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
path.join(serverRoot, "publisher.config.json"),
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
frozenConfig: false,
|
|
86
|
+
environments: [
|
|
87
|
+
{
|
|
88
|
+
name: ENV_NAME,
|
|
89
|
+
packages: [{ name: PKG_NAME, location: srcDir }],
|
|
90
|
+
connections: [],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
port = await getFreePort();
|
|
97
|
+
let mcpPort = await getFreePort();
|
|
98
|
+
while (mcpPort === port) mcpPort = await getFreePort();
|
|
99
|
+
|
|
100
|
+
proc = spawn("bun", ["src/server.ts"], {
|
|
101
|
+
cwd: SERVER_DIR,
|
|
102
|
+
env: {
|
|
103
|
+
...process.env,
|
|
104
|
+
SERVER_ROOT: serverRoot,
|
|
105
|
+
PUBLISHER_HOST: "127.0.0.1",
|
|
106
|
+
PUBLISHER_PORT: String(port),
|
|
107
|
+
MCP_PORT: String(mcpPort),
|
|
108
|
+
},
|
|
109
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
110
|
+
});
|
|
111
|
+
proc.stdout?.on("data", (d: Buffer) => {
|
|
112
|
+
combinedLog = (combinedLog + d.toString()).slice(-8000);
|
|
113
|
+
});
|
|
114
|
+
proc.stderr?.on("data", (d: Buffer) => {
|
|
115
|
+
stderrLog += d.toString();
|
|
116
|
+
combinedLog = (combinedLog + d.toString()).slice(-8000);
|
|
117
|
+
});
|
|
118
|
+
proc.on("exit", () => {
|
|
119
|
+
exited = true;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const serving = await poll(async () => {
|
|
123
|
+
if (exited) throw new Error("server exited before becoming ready");
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/v0/status`);
|
|
126
|
+
if (!res.ok) return false;
|
|
127
|
+
const body = (await res.json()) as { operationalState?: string };
|
|
128
|
+
return body.operationalState === "serving";
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}, 120_000);
|
|
133
|
+
if (!serving) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`server did not reach serving within 120s\n--- log tail ---\n${combinedLog}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
// The line is written in the same tick that flips the status to
|
|
139
|
+
// serving, but the pipe delivery can trail the HTTP response.
|
|
140
|
+
await poll(async () => readyLines().length > 0, 10_000, 100);
|
|
141
|
+
}, 150_000);
|
|
142
|
+
|
|
143
|
+
afterAll(async () => {
|
|
144
|
+
if (proc && !exited) {
|
|
145
|
+
await new Promise<void>((resolve) => {
|
|
146
|
+
// Backstop in case SIGTERM is ignored; cleared once the process exits.
|
|
147
|
+
const backstop = setTimeout(() => proc?.kill("SIGKILL"), 5_000);
|
|
148
|
+
proc?.on("exit", () => {
|
|
149
|
+
clearTimeout(backstop);
|
|
150
|
+
resolve();
|
|
151
|
+
});
|
|
152
|
+
proc?.kill("SIGTERM");
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
for (const dir of [srcDir, serverRoot]) {
|
|
156
|
+
try {
|
|
157
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
158
|
+
} catch {
|
|
159
|
+
// best-effort cleanup
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("prints one PUBLISHER_READY line on stderr once serving", () => {
|
|
165
|
+
const lines = readyLines();
|
|
166
|
+
expect(lines).toHaveLength(1);
|
|
167
|
+
// The URL host/port are the ones the server was actually started with,
|
|
168
|
+
// so a script can dial what the line says.
|
|
169
|
+
expect(lines[0]).toMatch(
|
|
170
|
+
new RegExp(
|
|
171
|
+
`^PUBLISHER_READY url=http://127\\.0\\.0\\.1:${port} ` +
|
|
172
|
+
`mcp=http://127\\.0\\.0\\.1:\\d+ ` +
|
|
173
|
+
`environments=1 packages=1 load_errors=0$`,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -192,6 +192,37 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
|
|
|
192
192
|
return file;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Write a manifest carrying a `storage=` (cross-connection) entry — one that
|
|
197
|
+
* names a `storageConnectionName` and carries a captured `schema`. Such an
|
|
198
|
+
* entry serves through the virtual-source transform, so it must bind as a
|
|
199
|
+
* serve BINDING, never as a same-connection tableName substitution.
|
|
200
|
+
*/
|
|
201
|
+
async function writeStorageManifest(
|
|
202
|
+
sourceEntityId: string,
|
|
203
|
+
): Promise<string> {
|
|
204
|
+
const file = path.join(tmpDir, `storage-manifest-${Date.now()}.json`);
|
|
205
|
+
await fsp.writeFile(
|
|
206
|
+
file,
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
builtAt: new Date().toISOString(),
|
|
209
|
+
strict: false,
|
|
210
|
+
entries: {
|
|
211
|
+
[sourceEntityId]: {
|
|
212
|
+
sourceEntityId,
|
|
213
|
+
sourceName: "order_summary",
|
|
214
|
+
physicalTableName: "order_summary",
|
|
215
|
+
connectionName: "duckdb",
|
|
216
|
+
storageConnectionName: "lake",
|
|
217
|
+
schema: [{ name: "c", type: "BIGINT" }],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
"utf8",
|
|
222
|
+
);
|
|
223
|
+
return file;
|
|
224
|
+
}
|
|
225
|
+
|
|
195
226
|
async function writeManifest(): Promise<string> {
|
|
196
227
|
const file = path.join(tmpDir, `manifest-${Date.now()}.json`);
|
|
197
228
|
await fsp.writeFile(
|
|
@@ -303,6 +334,79 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
|
|
|
303
334
|
{ timeout: 120_000 },
|
|
304
335
|
);
|
|
305
336
|
|
|
337
|
+
it(
|
|
338
|
+
"binds a storage= entry as a cross-connection serve binding, not a tableName substitution",
|
|
339
|
+
async () => {
|
|
340
|
+
await getPackage(true); // start from live (unbound)
|
|
341
|
+
const sourceEntityId = await orderSummarySourceEntityId();
|
|
342
|
+
const manifestFile = await writeStorageManifest(sourceEntityId);
|
|
343
|
+
|
|
344
|
+
const patchRes = await patchPackage({
|
|
345
|
+
manifestLocation: manifestFile,
|
|
346
|
+
});
|
|
347
|
+
expect(patchRes.status).toBe(200);
|
|
348
|
+
const patched = (await patchRes.json()) as Record<string, unknown>;
|
|
349
|
+
|
|
350
|
+
// The storage entry never enters the same-connection tableName manifest,
|
|
351
|
+
// so there is nothing to substitute (and no recompile happened).
|
|
352
|
+
expect(patched.manifestEntryCount).toBe(0);
|
|
353
|
+
// It surfaces instead as a cross-connection storage serve binding.
|
|
354
|
+
expect(patched.storageServeBindings).toEqual([
|
|
355
|
+
{
|
|
356
|
+
sourceName: "order_summary",
|
|
357
|
+
storageConnectionName: "lake",
|
|
358
|
+
tablePath: "lake.order_summary",
|
|
359
|
+
},
|
|
360
|
+
]);
|
|
361
|
+
// The bound URI is recorded even though no tableName manifest bound.
|
|
362
|
+
expect(patched.boundManifestUri).toBe(manifestFile);
|
|
363
|
+
|
|
364
|
+
// Clearing reverts: the storage serve binding is dropped.
|
|
365
|
+
const clearRes = await patchPackage({ manifestLocation: null });
|
|
366
|
+
expect(clearRes.status).toBe(200);
|
|
367
|
+
const cleared = (await clearRes.json()) as Record<string, unknown>;
|
|
368
|
+
expect(cleared.storageServeBindings ?? null).toBeNull();
|
|
369
|
+
await getPackage(true);
|
|
370
|
+
},
|
|
371
|
+
{ timeout: 60_000 },
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
it(
|
|
375
|
+
"a rebind to a manifest whose storage entries vanished clears the old bindings",
|
|
376
|
+
async () => {
|
|
377
|
+
// Regression (MED-2): bindManifest must drop storage serve bindings when
|
|
378
|
+
// a NEW manifest (still a real URI, not a clear) no longer carries them —
|
|
379
|
+
// otherwise stale bindings keep routing at a table the host no longer
|
|
380
|
+
// vouches for.
|
|
381
|
+
await getPackage(true);
|
|
382
|
+
const sourceEntityId = await orderSummarySourceEntityId();
|
|
383
|
+
const withStorage = await writeStorageManifest(sourceEntityId);
|
|
384
|
+
const bound = (await (
|
|
385
|
+
await patchPackage({ manifestLocation: withStorage })
|
|
386
|
+
).json()) as Record<string, unknown>;
|
|
387
|
+
expect(
|
|
388
|
+
(bound.storageServeBindings as unknown[] | undefined)?.length,
|
|
389
|
+
).toBe(1);
|
|
390
|
+
|
|
391
|
+
// Rebind to a DIFFERENT manifest URI that carries no storage entries.
|
|
392
|
+
const noStorage = path.join(tmpDir, `no-storage-${Date.now()}.json`);
|
|
393
|
+
await fsp.writeFile(
|
|
394
|
+
noStorage,
|
|
395
|
+
JSON.stringify({ builtAt: new Date().toISOString(), entries: {} }),
|
|
396
|
+
"utf8",
|
|
397
|
+
);
|
|
398
|
+
const rebound = (await (
|
|
399
|
+
await patchPackage({ manifestLocation: noStorage })
|
|
400
|
+
).json()) as Record<string, unknown>;
|
|
401
|
+
// Stale storage binding must be gone, not left routing.
|
|
402
|
+
expect(rebound.storageServeBindings ?? null).toBeNull();
|
|
403
|
+
|
|
404
|
+
await patchPackage({ manifestLocation: null });
|
|
405
|
+
await getPackage(true);
|
|
406
|
+
},
|
|
407
|
+
{ timeout: 60_000 },
|
|
408
|
+
);
|
|
409
|
+
|
|
306
410
|
it("degrades to serving live when the manifest is unreachable", async () => {
|
|
307
411
|
const missing = path.join(tmpDir, "does-not-exist.json");
|
|
308
412
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import {
|
|
4
|
+
Notification,
|
|
5
|
+
Request,
|
|
6
|
+
Result,
|
|
7
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import http from "http";
|
|
9
|
+
import { AddressInfo } from "net";
|
|
10
|
+
import {
|
|
11
|
+
McpE2ETestEnvironment,
|
|
12
|
+
cleanupE2ETestEnvironment,
|
|
13
|
+
setupE2ETestEnvironment,
|
|
14
|
+
} from "../../harness/mcp_test_setup";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* End-to-end coverage of semantic malloy_getContext retrieval: the real
|
|
18
|
+
* server, the real DuckDB vector cache, and a deterministic in-test
|
|
19
|
+
* embedding endpoint (letter-bigram bag-of-words vectors, so related
|
|
20
|
+
* phrases genuinely land near each other, no network, no key).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const ENVIRONMENT_NAME = "examples";
|
|
24
|
+
const PACKAGE_NAME = "storefront";
|
|
25
|
+
const DIMS = 64;
|
|
26
|
+
|
|
27
|
+
/** Deterministic unit vector from letter bigrams. */
|
|
28
|
+
function bigramVector(text: string): number[] {
|
|
29
|
+
const v = new Array<number>(DIMS).fill(0);
|
|
30
|
+
const s = ` ${text.toLowerCase().replace(/[^a-z0-9]+/g, " ")} `;
|
|
31
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
32
|
+
v[(s.charCodeAt(i) * 31 + s.charCodeAt(i + 1)) % DIMS] += 1;
|
|
33
|
+
}
|
|
34
|
+
const norm = Math.sqrt(v.reduce((acc, x) => acc + x * x, 0)) || 1;
|
|
35
|
+
return v.map((x) => x / norm);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let env: McpE2ETestEnvironment | null = null;
|
|
39
|
+
let mcpClient: Client<Request, Notification, Result>;
|
|
40
|
+
let embeddingStub: http.Server;
|
|
41
|
+
let stubFailing = false;
|
|
42
|
+
let stubRequests = 0;
|
|
43
|
+
|
|
44
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
45
|
+
const ENV_KEYS = [
|
|
46
|
+
"EMBEDDING_API_KEY",
|
|
47
|
+
"EMBEDDING_API_BASE",
|
|
48
|
+
"EMBEDDING_MODEL",
|
|
49
|
+
"EMBEDDING_DIMENSIONS",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
interface GetContextPayload {
|
|
53
|
+
retrieval?: string;
|
|
54
|
+
results?: Array<{
|
|
55
|
+
kind: string;
|
|
56
|
+
name: string;
|
|
57
|
+
source?: string;
|
|
58
|
+
score?: number;
|
|
59
|
+
}>;
|
|
60
|
+
error?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function callGetContext(
|
|
64
|
+
args: Record<string, unknown>,
|
|
65
|
+
): Promise<GetContextPayload> {
|
|
66
|
+
const result = (await mcpClient.callTool({
|
|
67
|
+
name: "malloy_getContext",
|
|
68
|
+
arguments: args,
|
|
69
|
+
})) as {
|
|
70
|
+
content: Array<{ type: string; resource?: { text?: string } }>;
|
|
71
|
+
};
|
|
72
|
+
const text = result.content?.[0]?.resource?.text;
|
|
73
|
+
if (!text) throw new Error("malloy_getContext returned no resource text");
|
|
74
|
+
return JSON.parse(text) as GetContextPayload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe.serial("MCP getContext semantic retrieval (E2E Integration)", () => {
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
// A local OpenAI-compatible /embeddings endpoint with deterministic
|
|
80
|
+
// vectors. Bound to 127.0.0.1:0; the real port comes from the OS.
|
|
81
|
+
embeddingStub = http.createServer((req, res) => {
|
|
82
|
+
let body = "";
|
|
83
|
+
req.on("data", (chunk) => (body += chunk));
|
|
84
|
+
req.on("end", () => {
|
|
85
|
+
stubRequests++;
|
|
86
|
+
if (stubFailing) {
|
|
87
|
+
res.writeHead(500).end("stub down");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const { input } = JSON.parse(body) as { input: string[] };
|
|
91
|
+
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
data: input.map((text, index) => ({
|
|
94
|
+
index,
|
|
95
|
+
embedding: bigramVector(text),
|
|
96
|
+
})),
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
await new Promise<void>((resolve) =>
|
|
102
|
+
embeddingStub.listen(0, "127.0.0.1", resolve),
|
|
103
|
+
);
|
|
104
|
+
const port = (embeddingStub.address() as AddressInfo).port;
|
|
105
|
+
|
|
106
|
+
// Set the embedding env vars BEFORE the server harness dynamically
|
|
107
|
+
// imports src/server; config is read per call, but this keeps the
|
|
108
|
+
// whole server lifetime consistently configured.
|
|
109
|
+
for (const key of ENV_KEYS) {
|
|
110
|
+
savedEnv[key] = process.env[key];
|
|
111
|
+
}
|
|
112
|
+
process.env.EMBEDDING_API_KEY = "integration-test-key";
|
|
113
|
+
process.env.EMBEDDING_API_BASE = `http://127.0.0.1:${port}/v1`;
|
|
114
|
+
// A stub-distinct model name: the suite shares publisher.db with
|
|
115
|
+
// local manual runs, and rows cached under the real default model
|
|
116
|
+
// name would poison a later real-provider run (the stub's bigram
|
|
117
|
+
// vectors are not text-embedding-3-small's). Under this name the
|
|
118
|
+
// leftovers are inert; a real run's model diff replaces them.
|
|
119
|
+
process.env.EMBEDDING_MODEL = "integration-test-bigram-stub";
|
|
120
|
+
delete process.env.EMBEDDING_DIMENSIONS;
|
|
121
|
+
|
|
122
|
+
env = await setupE2ETestEnvironment();
|
|
123
|
+
mcpClient = env.mcpClient;
|
|
124
|
+
}, 200000);
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
await cleanupE2ETestEnvironment(env);
|
|
128
|
+
env = null;
|
|
129
|
+
// Restore the embedding env so later suites in this process run
|
|
130
|
+
// unconfigured (the provider getter re-reads env per call).
|
|
131
|
+
for (const key of ENV_KEYS) {
|
|
132
|
+
if (savedEnv[key] === undefined) delete process.env[key];
|
|
133
|
+
else process.env[key] = savedEnv[key];
|
|
134
|
+
}
|
|
135
|
+
await new Promise<void>((resolve, reject) =>
|
|
136
|
+
embeddingStub.close((err) => (err ? reject(err) : resolve())),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it(
|
|
141
|
+
"answers lexically while indexing, then flips to semantic with scores",
|
|
142
|
+
async () => {
|
|
143
|
+
const first = await callGetContext({
|
|
144
|
+
environmentName: ENVIRONMENT_NAME,
|
|
145
|
+
packageName: PACKAGE_NAME,
|
|
146
|
+
query: "total sales revenue",
|
|
147
|
+
});
|
|
148
|
+
// Configured server: the marker is always present on tier 4.
|
|
149
|
+
expect(["lexical", "semantic"]).toContain(first.retrieval);
|
|
150
|
+
|
|
151
|
+
let payload = first;
|
|
152
|
+
for (let i = 0; i < 60 && payload.retrieval !== "semantic"; i++) {
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
154
|
+
payload = await callGetContext({
|
|
155
|
+
environmentName: ENVIRONMENT_NAME,
|
|
156
|
+
packageName: PACKAGE_NAME,
|
|
157
|
+
query: "total sales revenue",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
expect(payload.retrieval).toBe("semantic");
|
|
161
|
+
expect(stubRequests).toBeGreaterThan(0);
|
|
162
|
+
|
|
163
|
+
const results = payload.results ?? [];
|
|
164
|
+
expect(results.length).toBeGreaterThan(0);
|
|
165
|
+
for (const r of results) {
|
|
166
|
+
expect(typeof r.score).toBe("number");
|
|
167
|
+
}
|
|
168
|
+
// The bigram embedding puts "total sales revenue" on top of the
|
|
169
|
+
// total_sales measure ("total sales" once humanized).
|
|
170
|
+
expect(results.some((r) => r.name === "total_sales")).toBe(true);
|
|
171
|
+
},
|
|
172
|
+
{ timeout: 60000 },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
it(
|
|
176
|
+
"keeps the semantic index warm across calls (no re-embed per call)",
|
|
177
|
+
async () => {
|
|
178
|
+
const before = stubRequests;
|
|
179
|
+
const payload = await callGetContext({
|
|
180
|
+
environmentName: ENVIRONMENT_NAME,
|
|
181
|
+
packageName: PACKAGE_NAME,
|
|
182
|
+
query: "orders by month",
|
|
183
|
+
});
|
|
184
|
+
expect(payload.retrieval).toBe("semantic");
|
|
185
|
+
// Exactly one stub call: the query embedding. No bulk re-sync.
|
|
186
|
+
expect(stubRequests).toBe(before + 1);
|
|
187
|
+
},
|
|
188
|
+
{ timeout: 30000 },
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
it(
|
|
192
|
+
"narrows semantic retrieval with sourceName",
|
|
193
|
+
async () => {
|
|
194
|
+
const payload = await callGetContext({
|
|
195
|
+
environmentName: ENVIRONMENT_NAME,
|
|
196
|
+
packageName: PACKAGE_NAME,
|
|
197
|
+
query: "total sales revenue",
|
|
198
|
+
sourceName: "order_items",
|
|
199
|
+
});
|
|
200
|
+
expect(payload.retrieval).toBe("semantic");
|
|
201
|
+
const results = payload.results ?? [];
|
|
202
|
+
// Non-empty, or the per-result loop below pins nothing.
|
|
203
|
+
expect(results.length).toBeGreaterThan(0);
|
|
204
|
+
for (const r of results) {
|
|
205
|
+
expect(r.source).toBe("order_items");
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
{ timeout: 30000 },
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// Keep this test LAST: the induced failure starts the provider
|
|
212
|
+
// cool-down, which short-circuits the semantic path for its window.
|
|
213
|
+
it(
|
|
214
|
+
"falls back to lexical, marked, when the embedding endpoint fails",
|
|
215
|
+
async () => {
|
|
216
|
+
stubFailing = true;
|
|
217
|
+
try {
|
|
218
|
+
const payload = await callGetContext({
|
|
219
|
+
environmentName: ENVIRONMENT_NAME,
|
|
220
|
+
packageName: PACKAGE_NAME,
|
|
221
|
+
query: "top selling products",
|
|
222
|
+
});
|
|
223
|
+
expect(payload.retrieval).toBe("lexical");
|
|
224
|
+
const results = payload.results ?? [];
|
|
225
|
+
expect(results.length).toBeGreaterThan(0);
|
|
226
|
+
for (const r of results) {
|
|
227
|
+
expect(r.score).toBeUndefined();
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
stubFailing = false;
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
{ timeout: 30000 },
|
|
234
|
+
);
|
|
235
|
+
});
|