@malloy-publisher/server 0.0.231 → 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.
Files changed (79) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +74 -3
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
  14. package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
  15. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  16. package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
  17. package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
  18. package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +3186 -433
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/errors.ts +19 -0
  32. package/src/logger.spec.ts +18 -1
  33. package/src/logger.ts +3 -1
  34. package/src/materialization_metrics.spec.ts +89 -4
  35. package/src/materialization_metrics.ts +155 -5
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  38. package/src/mcp/tools/embedding_index.ts +808 -0
  39. package/src/mcp/tools/get_context_eval.ts +194 -45
  40. package/src/mcp/tools/get_context_tool.spec.ts +295 -2
  41. package/src/mcp/tools/get_context_tool.ts +159 -10
  42. package/src/pg_helpers.spec.ts +201 -0
  43. package/src/pg_helpers.ts +44 -5
  44. package/src/server.ts +24 -0
  45. package/src/service/build_plan.spec.ts +128 -2
  46. package/src/service/build_plan.ts +239 -17
  47. package/src/service/connection.ts +263 -7
  48. package/src/service/connection_config.spec.ts +48 -0
  49. package/src/service/connection_config.ts +21 -8
  50. package/src/service/connection_federation.spec.ts +184 -0
  51. package/src/service/embedding_provider.spec.ts +329 -0
  52. package/src/service/embedding_provider.ts +236 -0
  53. package/src/service/environment.ts +274 -12
  54. package/src/service/environment_store.spec.ts +678 -3
  55. package/src/service/environment_store.ts +449 -33
  56. package/src/service/environment_store_clone.spec.ts +350 -0
  57. package/src/service/manifest_loader.spec.ts +68 -13
  58. package/src/service/manifest_loader.ts +67 -19
  59. package/src/service/materialization_build_session.spec.ts +435 -0
  60. package/src/service/materialization_build_session.ts +681 -0
  61. package/src/service/materialization_eligibility.spec.ts +158 -0
  62. package/src/service/materialization_eligibility.ts +305 -0
  63. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  64. package/src/service/materialization_serve_transform.ts +779 -0
  65. package/src/service/materialization_service.spec.ts +774 -7
  66. package/src/service/materialization_service.ts +1107 -42
  67. package/src/service/materialization_test_fixtures.ts +7 -0
  68. package/src/service/model.spec.ts +207 -0
  69. package/src/service/model.ts +540 -52
  70. package/src/service/model_storage_serve.spec.ts +193 -0
  71. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  72. package/src/service/package.spec.ts +196 -0
  73. package/src/service/package.ts +385 -17
  74. package/src/service/persistence_policy.spec.ts +109 -0
  75. package/src/storage/duckdb/schema.ts +37 -0
  76. package/tests/fixtures/xlsx/database.xlsx +0 -0
  77. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  78. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  79. 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
+ });