@malloy-publisher/server 0.0.232 → 0.0.233
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/dist/app/api-doc.yaml +61 -10
- package/dist/app/assets/{EnvironmentPage-DXEaZIPx.js → EnvironmentPage-DutP7T8h.js} +1 -1
- package/dist/app/assets/{HomePage-kofsqpZt.js → HomePage-BcxDrBfl.js} +1 -1
- package/dist/app/assets/{LightMode-CNhIlIlJ.js → LightMode-BJukGxgz.js} +1 -1
- package/dist/app/assets/{MainPage-Bgqo8jCy.js → MainPage-DXbwlMeF.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CgBlgGz2.js → MaterializationsPage-BBQksmTU.js} +1 -1
- package/dist/app/assets/{ModelPage-B0TjoDtf.js → ModelPage-C6tK51uU.js} +1 -1
- package/dist/app/assets/{PackagePage-BL8vnFj1.js → PackagePage-Bo3cwwZE.js} +1 -1
- package/dist/app/assets/{RouteError-BzPby0X2.js → RouteError-BufkcAKE.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTEP_9r3.js → ThemeEditorPage-DICvvKpa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-BwM3BmKw.js → WorkbookPage-Dkwt75Nj.js} +1 -1
- package/dist/app/assets/{core-CK68iv6w.es-CpRxXBt7.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
- package/dist/app/assets/{index-CmkW1MiE.js → index-BusxL5Pt.js} +1 -1
- package/dist/app/assets/{index-B33zGctF.js → index-CmEVVe-8.js} +4 -4
- package/dist/app/assets/{index-tXJXwdyj.js → index-Cs4WVm2z.js} +1 -1
- package/dist/app/assets/{index-BkiWKaAF.js → index-qnhU9CGo.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +19456 -842
- package/package.json +1 -1
- package/src/controller/query.controller.ts +26 -21
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +230 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
- package/src/mcp/tools/execute_query_tool.ts +90 -151
- package/src/mcp/tools/get_context_tool.spec.ts +63 -3
- package/src/mcp/tools/get_context_tool.ts +43 -46
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +77 -14
- package/src/service/connection_config.spec.ts +60 -0
- package/src/service/connection_config.ts +26 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/model.ts +41 -19
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import { components } from "../api";
|
|
|
3
3
|
import { getQueryTimeoutMs } from "../config";
|
|
4
4
|
import { API_PREFIX } from "../constants";
|
|
5
5
|
import { ModelNotFoundError } from "../errors";
|
|
6
|
+
import { bigIntReplacer } from "../json_utils";
|
|
6
7
|
import { runWithQueryTimeout } from "../query_timeout";
|
|
7
8
|
import { EnvironmentStore } from "../service/environment_store";
|
|
8
9
|
import type { FilterParams } from "../service/filter";
|
|
@@ -10,14 +11,6 @@ import type { GivenValue } from "@malloydata/malloy";
|
|
|
10
11
|
|
|
11
12
|
type ApiQuery = components["schemas"]["QueryResult"];
|
|
12
13
|
|
|
13
|
-
// Replacer function to handle BigInt serialization
|
|
14
|
-
function bigIntReplacer(_key: string, value: unknown): unknown {
|
|
15
|
-
if (typeof value === "bigint") {
|
|
16
|
-
return Number(value);
|
|
17
|
-
}
|
|
18
|
-
return value;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
14
|
export class QueryController {
|
|
22
15
|
private environmentStore: EnvironmentStore;
|
|
23
16
|
|
|
@@ -53,19 +46,20 @@ export class QueryController {
|
|
|
53
46
|
if (!model) {
|
|
54
47
|
throw new ModelNotFoundError(`${modelPath} does not exist`);
|
|
55
48
|
} else {
|
|
56
|
-
const { result, compactResult } =
|
|
57
|
-
(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
49
|
+
const { result, compactResult, rowLimit, rowLimitSource } =
|
|
50
|
+
await runWithQueryTimeout(
|
|
51
|
+
(abortSignal) =>
|
|
52
|
+
model.getQueryResults(
|
|
53
|
+
sourceName,
|
|
54
|
+
queryName,
|
|
55
|
+
query,
|
|
56
|
+
filterParams,
|
|
57
|
+
bypassFilters,
|
|
58
|
+
givens,
|
|
59
|
+
abortSignal,
|
|
60
|
+
),
|
|
61
|
+
getQueryTimeoutMs(),
|
|
62
|
+
);
|
|
69
63
|
const renderLogs = validateRenderTags(result);
|
|
70
64
|
return {
|
|
71
65
|
result: compactJson
|
|
@@ -73,6 +67,17 @@ export class QueryController {
|
|
|
73
67
|
: JSON.stringify(result),
|
|
74
68
|
resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}/models/${modelPath}/query`,
|
|
75
69
|
renderLogs: renderLogs.length > 0 ? renderLogs : undefined,
|
|
70
|
+
// The cap the database applied. A caller counting the rows it got
|
|
71
|
+
// back cannot otherwise tell a complete result from one the limit
|
|
72
|
+
// cut off: a query with no LIMIT of its own silently gets the
|
|
73
|
+
// server default, which is well under the hard ceiling, so nothing
|
|
74
|
+
// raises. Deriving it client-side is not possible, since it depends
|
|
75
|
+
// on server config and on the query's own LIMIT.
|
|
76
|
+
queryRowLimit: rowLimit,
|
|
77
|
+
// Which of the two that cap was. Without it a caller cannot tell a
|
|
78
|
+
// deliberate `limit:`/`top:` from the silently-applied default, and
|
|
79
|
+
// so cannot reproduce the MCP envelope's _limit_hit.
|
|
80
|
+
queryRowLimitSource: rowLimitSource,
|
|
76
81
|
} as ApiQuery;
|
|
77
82
|
}
|
|
78
83
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { bigIntReplacer } from "./json_utils";
|
|
3
|
+
|
|
4
|
+
const ser = (v: unknown) => JSON.stringify(v, bigIntReplacer);
|
|
5
|
+
|
|
6
|
+
describe("bigIntReplacer", () => {
|
|
7
|
+
it("still renders ordinary BigInt ids as numbers", () => {
|
|
8
|
+
// The common case, and the one that must not change: a DuckDB BIGINT
|
|
9
|
+
// column of ordinary ids has always reached callers as JSON numbers, and
|
|
10
|
+
// the MCP rows are byte-compared against the REST payload.
|
|
11
|
+
expect(ser({ id: 100001n })).toBe('{"id":100001}');
|
|
12
|
+
expect(ser({ c: 0n })).toBe('{"c":0}');
|
|
13
|
+
expect(ser({ c: -42n })).toBe('{"c":-42}');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("keeps the largest exactly-representable values as numbers", () => {
|
|
17
|
+
expect(ser({ v: 9007199254740991n })).toBe('{"v":9007199254740991}');
|
|
18
|
+
expect(ser({ v: -9007199254740991n })).toBe('{"v":-9007199254740991}');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The regression this guards. Number() rounds past 2^53, so an id came back
|
|
23
|
+
* altered with no error: an agent filtering or joining on it gets a wrong or
|
|
24
|
+
* empty answer. Malloy's own wrapResult keeps an exact string for the same
|
|
25
|
+
* case, so a string here matches what the typed-cell path already did.
|
|
26
|
+
*/
|
|
27
|
+
it("preserves values past the safe range exactly, as strings", () => {
|
|
28
|
+
expect(ser({ id: 9007199254740993n })).toBe('{"id":"9007199254740993"}');
|
|
29
|
+
expect(ser({ id: 1234567890123456789n })).toBe(
|
|
30
|
+
'{"id":"1234567890123456789"}',
|
|
31
|
+
);
|
|
32
|
+
expect(ser({ id: -9007199254740993n })).toBe(
|
|
33
|
+
'{"id":"-9007199254740993"}',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("round-trips a large id without losing a digit", () => {
|
|
38
|
+
const exact = "9223372036854775807"; // int64 max
|
|
39
|
+
expect(JSON.parse(ser({ id: BigInt(exact) })).id).toBe(exact);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("leaves non-BigInt values alone", () => {
|
|
43
|
+
expect(ser({ a: 1, b: "x", c: null, d: 1.5 })).toBe(
|
|
44
|
+
'{"a":1,"b":"x","c":null,"d":1.5}',
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not throw on the BigInt a plain stringify would reject", () => {
|
|
49
|
+
expect(() => ser({ c: 150930n })).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON.stringify replacer for raw driver output, which can carry BigInt.
|
|
3
|
+
*
|
|
4
|
+
* Load-bearing wherever `compactResult` is serialized: it is
|
|
5
|
+
* `queryResults.data.value` straight from the connector, and a DuckDB BIGINT
|
|
6
|
+
* column arrives as a JS BigInt, so a plain JSON.stringify throws
|
|
7
|
+
* `TypeError: Do not know how to serialize a BigInt`. The wrapped
|
|
8
|
+
* `Malloy.Result` path does not need this, because API.util.wrapResult has
|
|
9
|
+
* already normalized values into typed cells.
|
|
10
|
+
*
|
|
11
|
+
* Values inside the safe integer range become numbers, which is what callers
|
|
12
|
+
* have always received and what keeps the MCP and REST payloads byte-identical.
|
|
13
|
+
* Outside it `Number()` rounds silently: 9007199254740993 becomes ...992, and an
|
|
14
|
+
* agent that filters or joins on that id gets a wrong or empty answer with no
|
|
15
|
+
* error. Those are emitted as strings instead, which is what Malloy itself does
|
|
16
|
+
* for the same case (API.util.wrapResult attaches an exact `string_value`
|
|
17
|
+
* beside the lossy `number_value` whenever the subtype is bigint).
|
|
18
|
+
*
|
|
19
|
+
* So the JSON type varies with magnitude. That is deliberate: a column only
|
|
20
|
+
* yields a string for a value a JSON number cannot represent, and every such
|
|
21
|
+
* value was previously being corrupted, so nothing a caller correctly receives
|
|
22
|
+
* today changes.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
|
25
|
+
|
|
26
|
+
export function bigIntReplacer(_key: string, value: unknown): unknown {
|
|
27
|
+
if (typeof value === "bigint") {
|
|
28
|
+
return value > MAX_SAFE_BIGINT || value < -MAX_SAFE_BIGINT
|
|
29
|
+
? value.toString()
|
|
30
|
+
: Number(value);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type * as Malloy from "@malloydata/malloy-interfaces";
|
|
3
|
+
import {
|
|
4
|
+
buildQueryEnvelope,
|
|
5
|
+
serializeEnvelope,
|
|
6
|
+
MAX_RESULT_CHARS,
|
|
7
|
+
} from "./query_envelope";
|
|
8
|
+
|
|
9
|
+
const rows = (n: number) =>
|
|
10
|
+
Array.from({ length: n }, (_, i) => ({ id: i, name: `row ${i}` }));
|
|
11
|
+
|
|
12
|
+
/** A minimal Malloy.Result: only the metadata the envelope reads. */
|
|
13
|
+
const result = (extra: Partial<Malloy.Result> = {}): Malloy.Result =>
|
|
14
|
+
({
|
|
15
|
+
schema: { fields: [{ kind: "dimension", name: "id" }] },
|
|
16
|
+
connection_name: "duckdb",
|
|
17
|
+
...extra,
|
|
18
|
+
}) as Malloy.Result;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The field names here are Credible's, on purpose: a data app authored against
|
|
22
|
+
* Publisher is served through Credible, and an agent that sees one shape while
|
|
23
|
+
* developing and another in production has to learn both. These assertions are
|
|
24
|
+
* the contract that keeps them the same, so renaming a field should fail here.
|
|
25
|
+
*/
|
|
26
|
+
describe("buildQueryEnvelope", () => {
|
|
27
|
+
it("returns flat rows plus Credible's metadata block", () => {
|
|
28
|
+
const e = buildQueryEnvelope(rows(3), 1000, result());
|
|
29
|
+
expect(e.rows).toHaveLength(3);
|
|
30
|
+
expect(e._meta.connection_name).toBe("duckdb");
|
|
31
|
+
expect(e._meta.schema.fields).toHaveLength(1);
|
|
32
|
+
expect(e._meta.annotations).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("carries the Malloy metadata that flat rows drop", () => {
|
|
36
|
+
// Field types and render tags live in the schema, not the rows, so an
|
|
37
|
+
// agent would otherwise need a second verbose call to reach them.
|
|
38
|
+
const e = buildQueryEnvelope(rows(1), 1000, {
|
|
39
|
+
...result(),
|
|
40
|
+
query_timezone: "UTC",
|
|
41
|
+
model_annotations: [{ value: "# dashboard" }],
|
|
42
|
+
} as Malloy.Result);
|
|
43
|
+
expect(e._meta.query_timezone).toBe("UTC");
|
|
44
|
+
expect(e._meta.model_annotations).toEqual([{ value: "# dashboard" }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("omits metadata keys the result did not carry", () => {
|
|
48
|
+
const e = buildQueryEnvelope(rows(1), 1000, result());
|
|
49
|
+
expect("query_timezone" in e._meta).toBe(false);
|
|
50
|
+
expect("source_annotations" in e._meta).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The correctness fix, and the one field a client cannot compute for itself:
|
|
55
|
+
* the cap depends on server config and on the query's own LIMIT.
|
|
56
|
+
*/
|
|
57
|
+
describe("_limit_hit", () => {
|
|
58
|
+
it("is true when the row count lands exactly on the cap", () => {
|
|
59
|
+
const e = buildQueryEnvelope(rows(1000), 1000, result());
|
|
60
|
+
expect(e._limit_hit).toBe(true);
|
|
61
|
+
expect(e._query_row_limit).toBe(1000);
|
|
62
|
+
expect(e.warning).toContain("not a complete result");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("is false when the result came in under the cap", () => {
|
|
66
|
+
const e = buildQueryEnvelope(rows(999), 1000, result());
|
|
67
|
+
expect(e._limit_hit).toBe(false);
|
|
68
|
+
expect(e.warning).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The false positive this guards. resolveModelQueryRowLimit folds the
|
|
73
|
+
* query's own limit:/top: into the same cap, so equality holds every time
|
|
74
|
+
* an author limited the query deliberately. Three of the eight views in
|
|
75
|
+
* the bundled storefront example use `top:`, so a modelled top-N would
|
|
76
|
+
* otherwise report its complete answer as cut off, and the Contract rule
|
|
77
|
+
* would tell an agent that a top-N is "not the answer".
|
|
78
|
+
*/
|
|
79
|
+
it("is false when the author's own limit: produced the cap", () => {
|
|
80
|
+
const e = buildQueryEnvelope(
|
|
81
|
+
rows(10),
|
|
82
|
+
10,
|
|
83
|
+
result(),
|
|
84
|
+
[],
|
|
85
|
+
undefined,
|
|
86
|
+
"query",
|
|
87
|
+
);
|
|
88
|
+
expect(e._limit_hit).toBe(false);
|
|
89
|
+
expect(e._limit_source).toBe("query");
|
|
90
|
+
expect(e.warning).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("still fires when the silent server default produced the cap", () => {
|
|
94
|
+
const e = buildQueryEnvelope(
|
|
95
|
+
rows(1000),
|
|
96
|
+
1000,
|
|
97
|
+
result(),
|
|
98
|
+
[],
|
|
99
|
+
undefined,
|
|
100
|
+
"server_default",
|
|
101
|
+
);
|
|
102
|
+
expect(e._limit_hit).toBe(true);
|
|
103
|
+
expect(e._limit_source).toBe("server_default");
|
|
104
|
+
expect(e.warning).toContain("not a complete result");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reports the source even when the cap was not reached", () => {
|
|
108
|
+
const e = buildQueryEnvelope(
|
|
109
|
+
rows(3),
|
|
110
|
+
10,
|
|
111
|
+
result(),
|
|
112
|
+
[],
|
|
113
|
+
undefined,
|
|
114
|
+
"query",
|
|
115
|
+
);
|
|
116
|
+
expect(e._limit_hit).toBe(false);
|
|
117
|
+
expect(e._limit_source).toBe("query");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("is false when no cap was applied", () => {
|
|
121
|
+
// rowLimit 0 means uncapped; equality against 0 would otherwise call
|
|
122
|
+
// an empty result "limited".
|
|
123
|
+
expect(buildQueryEnvelope([], 0, result())._limit_hit).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not call an empty result limited", () => {
|
|
127
|
+
expect(buildQueryEnvelope([], 1000, result())._limit_hit).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* compactResult is raw driver output and DuckDB returns count() as a BigInt,
|
|
133
|
+
* so a plain JSON.stringify throws on the most common query anyone writes.
|
|
134
|
+
*/
|
|
135
|
+
it("serializes BigInt values instead of throwing", () => {
|
|
136
|
+
const e = buildQueryEnvelope([{ c: 150930n }], 1000, result());
|
|
137
|
+
expect(() => serializeEnvelope(e)).not.toThrow();
|
|
138
|
+
expect(JSON.parse(serializeEnvelope(e)).rows[0].c).toBe(150930);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("payload truncation", () => {
|
|
142
|
+
it("drops rows to fit and reports both counts", () => {
|
|
143
|
+
const e = buildQueryEnvelope(rows(200), 100_000, result(), [], 3_000);
|
|
144
|
+
expect(e._rows_truncated).toBe(true);
|
|
145
|
+
expect(e._total_rows).toBe(200);
|
|
146
|
+
expect(e._returned_rows).toBe((e.rows as unknown[]).length);
|
|
147
|
+
expect(e._returned_rows).toBeGreaterThan(0);
|
|
148
|
+
expect(e._returned_rows).toBeLessThan(200);
|
|
149
|
+
expect(e.warning).toContain("of 200 rows");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("keeps the finished payload under the cap", () => {
|
|
153
|
+
// The bug this pins: setting the marker fields and the warning AFTER
|
|
154
|
+
// the search pushes the payload back over the limit the truncation
|
|
155
|
+
// existed to respect.
|
|
156
|
+
const e = buildQueryEnvelope(rows(400), 100_000, result(), [], 3_000);
|
|
157
|
+
expect(serializeEnvelope(e).length).toBeLessThanOrEqual(3_000);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reachable because the hard ceiling is maxBytes (50MB), so one row with a
|
|
162
|
+
* large text column passes assertWithinModelResponseLimits and lands here.
|
|
163
|
+
* "Showing 0 of N rows" reads as an empty result set, and an agent will
|
|
164
|
+
* report "no rows matched" for a row that was merely too big to send.
|
|
165
|
+
*/
|
|
166
|
+
it("says no rows fit rather than implying nothing matched", () => {
|
|
167
|
+
const big = [{ doc: "z".repeat(5_000) }, { doc: "small" }];
|
|
168
|
+
const e = buildQueryEnvelope(big, 1000, result(), [], 2_000);
|
|
169
|
+
expect((e.rows as unknown[]).length).toBe(0);
|
|
170
|
+
expect(e._returned_rows).toBe(0);
|
|
171
|
+
expect(e._total_rows).toBe(2);
|
|
172
|
+
expect(e.warning).toContain("No rows fit the result size limit");
|
|
173
|
+
expect(e.warning).toContain("NOT an empty result");
|
|
174
|
+
expect(e.warning).not.toContain("Showing 0 of");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("keeps the zero-row payload under the cap too", () => {
|
|
178
|
+
// The longer of the two wordings is what the search measures, so
|
|
179
|
+
// swapping it in afterwards must not push the payload back over.
|
|
180
|
+
const big = [{ doc: "z".repeat(5_000) }, { doc: "small" }];
|
|
181
|
+
const e = buildQueryEnvelope(big, 1000, result(), [], 2_000);
|
|
182
|
+
expect(serializeEnvelope(e).length).toBeLessThanOrEqual(2_000);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("still reports the row cap when no rows fit", () => {
|
|
186
|
+
// Both shortenings apply at once; neither may swallow the other.
|
|
187
|
+
const big = [{ doc: "z".repeat(5_000) }, { doc: "y".repeat(5_000) }];
|
|
188
|
+
const e = buildQueryEnvelope(big, 2, result(), [], 2_000);
|
|
189
|
+
expect(e._limit_hit).toBe(true);
|
|
190
|
+
expect(e.warning).toContain("not a complete result");
|
|
191
|
+
expect(e.warning).toContain("No rows fit the result size limit");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("omits the truncation fields entirely when nothing was dropped", () => {
|
|
195
|
+
// Credible's shape: absent rather than false.
|
|
196
|
+
const e = buildQueryEnvelope(rows(3), 1000, result());
|
|
197
|
+
expect("_rows_truncated" in e).toBe(false);
|
|
198
|
+
expect("_total_rows" in e).toBe(false);
|
|
199
|
+
expect("_returned_rows" in e).toBe(false);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("reports both shortenings when they happen together", () => {
|
|
203
|
+
const e = buildQueryEnvelope(rows(200), 200, result(), [], 3_000);
|
|
204
|
+
expect(e._limit_hit).toBe(true);
|
|
205
|
+
expect(e._rows_truncated).toBe(true);
|
|
206
|
+
// One says the query was capped, the other that the payload was.
|
|
207
|
+
// Reporting only one would understate the loss.
|
|
208
|
+
expect(e.warning).toContain("not a complete result");
|
|
209
|
+
expect(e.warning).toContain("result size limit");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("leaves an ordinary result well inside the default budget", () => {
|
|
213
|
+
const e = buildQueryEnvelope(rows(500), 1000, result());
|
|
214
|
+
expect("_rows_truncated" in e).toBe(false);
|
|
215
|
+
expect(serializeEnvelope(e).length).toBeLessThan(MAX_RESULT_CHARS);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("passes render-tag messages through under Credible's key", () => {
|
|
220
|
+
const e = buildQueryEnvelope(rows(1), 1000, result(), ["bad tag on x"]);
|
|
221
|
+
expect(e.renderLogErrors).toEqual(["bad tag on x"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("omits optional keys when they do not apply", () => {
|
|
225
|
+
const e = buildQueryEnvelope(rows(1), 1000, result());
|
|
226
|
+
expect("warning" in e).toBe(false);
|
|
227
|
+
expect("renderLogErrors" in e).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type * as Malloy from "@malloydata/malloy-interfaces";
|
|
2
|
+
import { bigIntReplacer } from "../json_utils";
|
|
3
|
+
import type { QueryRowLimitSource } from "../service/model_limits";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The agent-facing shape for a query result, matched to what Credible's
|
|
7
|
+
* `execute_query` returns.
|
|
8
|
+
*
|
|
9
|
+
* Matching matters for a specific workflow: a data app is authored locally
|
|
10
|
+
* against Publisher and then served through Credible. An agent that sees one
|
|
11
|
+
* response shape while developing and a different one in production has to
|
|
12
|
+
* learn both, and a shared skill cannot describe either without forking. So the
|
|
13
|
+
* field names here are Credible's, deliberately, including the leading
|
|
14
|
+
* underscores and the mixed casing of `renderLogErrors`.
|
|
15
|
+
*
|
|
16
|
+
* Also following Credible: the truncation fields appear only when truncation
|
|
17
|
+
* happened, rather than always carrying `false`.
|
|
18
|
+
*
|
|
19
|
+
* The two `_limit_...` fields are new to both products. Three separate things
|
|
20
|
+
* can shorten a result and only one of them was ever reported:
|
|
21
|
+
*
|
|
22
|
+
* 1. The query row cap, pushed into the SQL. A query with no `limit:` of its
|
|
23
|
+
* own gets DEFAULT_QUERY_ROW_LIMIT (1000) rows, which is far under
|
|
24
|
+
* PUBLISHER_MAX_QUERY_ROWS, so nothing raises and nothing warns. An agent
|
|
25
|
+
* reports statistics on a silent sample. This is why `_limit_hit` exists,
|
|
26
|
+
* and unlike the others it is not derivable by a client: the cap depends on
|
|
27
|
+
* server config and on the query's own LIMIT.
|
|
28
|
+
* 2. The hard ceiling (maxRows / maxBytes), which throws 413 and is loud.
|
|
29
|
+
* 3. The payload cap below, which degrades to a truncated result plus a
|
|
30
|
+
* warning instead of overflowing the client's per-result limit.
|
|
31
|
+
*
|
|
32
|
+
* `_limit_hit` is a bound, not a total. The server cannot know the true row
|
|
33
|
+
* count, because the database applied the cap. Landing exactly on the limit is
|
|
34
|
+
* the only evidence available that rows were left behind.
|
|
35
|
+
*
|
|
36
|
+
* It is reported only when the cap was the server default, which `_limit_source`
|
|
37
|
+
* names. `resolveModelQueryRowLimit` folds the query's own `limit:`/`top:` into
|
|
38
|
+
* the same number, so equality also holds every time an author limited the query
|
|
39
|
+
* on purpose: three of the eight views in the bundled storefront example use
|
|
40
|
+
* `top:`, and each would otherwise report its complete answer as cut off. The
|
|
41
|
+
* silent sample this exists to catch only ever happens under the default.
|
|
42
|
+
*/
|
|
43
|
+
export interface QueryEnvelope {
|
|
44
|
+
rows: unknown;
|
|
45
|
+
/** Malloy metadata the flat rows drop: field types, render tags, timezone. */
|
|
46
|
+
_meta: {
|
|
47
|
+
schema: Malloy.Schema;
|
|
48
|
+
annotations: Malloy.Annotation[];
|
|
49
|
+
connection_name: string;
|
|
50
|
+
model_annotations?: Malloy.Annotation[];
|
|
51
|
+
query_timezone?: string;
|
|
52
|
+
source_annotations?: Malloy.Annotation[];
|
|
53
|
+
};
|
|
54
|
+
/** The cap pushed into the SQL: the query's own LIMIT, else the server default. */
|
|
55
|
+
_query_row_limit: number;
|
|
56
|
+
/** Which of those two the cap came from. */
|
|
57
|
+
_limit_source: QueryRowLimitSource;
|
|
58
|
+
/**
|
|
59
|
+
* Row count equals the cap AND the cap was the server default, so rows were
|
|
60
|
+
* almost certainly left behind. A deliberate `limit:`/`top:` that returns
|
|
61
|
+
* exactly what it asked for is a complete answer and does not set this.
|
|
62
|
+
*/
|
|
63
|
+
_limit_hit: boolean;
|
|
64
|
+
/** Present only when the payload cap dropped rows. */
|
|
65
|
+
_rows_truncated?: boolean;
|
|
66
|
+
_total_rows?: number;
|
|
67
|
+
_returned_rows?: number;
|
|
68
|
+
warning?: string;
|
|
69
|
+
renderLogErrors?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Cap on the serialized envelope, in characters. Same value Credible uses.
|
|
74
|
+
*
|
|
75
|
+
* Host-loop MCP clients enforce a per-tool-result ceiling of roughly 25k tokens;
|
|
76
|
+
* past it the result is spilled to disk or rejected outright, and the model then
|
|
77
|
+
* struggles to recover it. Chars stand in for tokens at about 4:1, with headroom
|
|
78
|
+
* for the envelope itself.
|
|
79
|
+
*/
|
|
80
|
+
export const MAX_RESULT_CHARS = 90_000;
|
|
81
|
+
|
|
82
|
+
function serialize(envelope: QueryEnvelope): string {
|
|
83
|
+
return JSON.stringify(envelope, bigIntReplacer, 2);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the envelope, applying the payload cap.
|
|
88
|
+
*
|
|
89
|
+
* @param rows compactResult: flat row objects, straight from the driver.
|
|
90
|
+
* @param rowLimit the cap pushed into the SQL.
|
|
91
|
+
* @param result the full Malloy result, read only for its metadata.
|
|
92
|
+
*/
|
|
93
|
+
export function buildQueryEnvelope(
|
|
94
|
+
rows: unknown,
|
|
95
|
+
rowLimit: number,
|
|
96
|
+
result: Malloy.Result,
|
|
97
|
+
renderLogErrors: string[] = [],
|
|
98
|
+
limit = MAX_RESULT_CHARS,
|
|
99
|
+
rowLimitSource: QueryRowLimitSource = "server_default",
|
|
100
|
+
): QueryEnvelope {
|
|
101
|
+
const rowCount = Array.isArray(rows) ? rows.length : 0;
|
|
102
|
+
// Equality, not >=: the cap is pushed into the SQL, so the database cannot
|
|
103
|
+
// return more than it. Landing exactly on it is the signal.
|
|
104
|
+
//
|
|
105
|
+
// Restricted to the server default on purpose. resolveModelQueryRowLimit
|
|
106
|
+
// folds the query's own limit:/top: into the same cap, so equality also holds
|
|
107
|
+
// every time an author deliberately limited the query: a modelled `top: 10`
|
|
108
|
+
// view returning its 10 rows would otherwise be reported as cut off, and the
|
|
109
|
+
// Contract rule would tell an agent that a top-N is "not the answer". Only
|
|
110
|
+
// the silently-applied default is evidence that rows were left behind.
|
|
111
|
+
const limitHit =
|
|
112
|
+
rowLimitSource === "server_default" &&
|
|
113
|
+
rowLimit > 0 &&
|
|
114
|
+
rowCount === rowLimit;
|
|
115
|
+
|
|
116
|
+
const envelope: QueryEnvelope = {
|
|
117
|
+
rows,
|
|
118
|
+
_meta: {
|
|
119
|
+
schema: result.schema,
|
|
120
|
+
annotations: result.annotations ?? [],
|
|
121
|
+
connection_name: result.connection_name,
|
|
122
|
+
...(result.model_annotations !== undefined && {
|
|
123
|
+
model_annotations: result.model_annotations,
|
|
124
|
+
}),
|
|
125
|
+
...(result.query_timezone !== undefined && {
|
|
126
|
+
query_timezone: result.query_timezone,
|
|
127
|
+
}),
|
|
128
|
+
...(result.source_annotations !== undefined && {
|
|
129
|
+
source_annotations: result.source_annotations,
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
_query_row_limit: rowLimit,
|
|
133
|
+
_limit_source: rowLimitSource,
|
|
134
|
+
_limit_hit: limitHit,
|
|
135
|
+
...(renderLogErrors.length > 0 && { renderLogErrors }),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (limitHit) {
|
|
139
|
+
envelope.warning =
|
|
140
|
+
`Returned exactly ${rowLimit} rows, the row limit applied to this query, so there are probably more. ` +
|
|
141
|
+
`This is not a complete result: add an explicit limit, aggregate, or filter rather than reporting these rows as the whole set.`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return fitToBudget(envelope, limit);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Drop rows until the serialized envelope fits, by binary search on the row
|
|
149
|
+
* count.
|
|
150
|
+
*
|
|
151
|
+
* The marker fields are set BEFORE the search, so their serialized size counts
|
|
152
|
+
* against the limit. Adding them afterwards pushes the payload back over the cap
|
|
153
|
+
* the truncation existed to respect. `_returned_rows` is measured at `total`
|
|
154
|
+
* first, whose digit width is at least that of any value it ends up holding, so
|
|
155
|
+
* the finished payload stays under the limit. Credible's `_truncate_rows` does
|
|
156
|
+
* the same thing for the same reason.
|
|
157
|
+
*
|
|
158
|
+
* The search can land on zero rows, which needs its own wording rather than
|
|
159
|
+
* "Showing 0 of N rows": that reads as an empty result set, and an agent will
|
|
160
|
+
* report "no rows matched" for what is really one row too large to send. It is
|
|
161
|
+
* reachable, because the hard ceiling upstream is maxBytes (50MB by default), so
|
|
162
|
+
* a single row carrying a large text or JSON column passes
|
|
163
|
+
* assertWithinModelResponseLimits and arrives here. Both wordings are measured,
|
|
164
|
+
* and the search uses whichever is longer, so swapping one for the other after
|
|
165
|
+
* the fact cannot push the payload back over the cap.
|
|
166
|
+
*
|
|
167
|
+
* One case cannot be fixed by dropping rows: when the envelope minus its rows
|
|
168
|
+
* already exceeds the limit (a very wide schema in `_meta`). The result is then
|
|
169
|
+
* returned over-limit rather than emptied further, and the warning says so.
|
|
170
|
+
*/
|
|
171
|
+
function fitToBudget(envelope: QueryEnvelope, limit: number): QueryEnvelope {
|
|
172
|
+
if (serialize(envelope).length <= limit) return envelope;
|
|
173
|
+
|
|
174
|
+
const rows = envelope.rows;
|
|
175
|
+
if (!Array.isArray(rows) || rows.length === 0) return envelope;
|
|
176
|
+
const total = rows.length;
|
|
177
|
+
|
|
178
|
+
const truncating: QueryEnvelope = {
|
|
179
|
+
...envelope,
|
|
180
|
+
_rows_truncated: true,
|
|
181
|
+
_total_rows: total,
|
|
182
|
+
_returned_rows: total,
|
|
183
|
+
};
|
|
184
|
+
// The size warning joins any limit warning already present, and is included
|
|
185
|
+
// in the measurement for the same reason as the counts.
|
|
186
|
+
const sizeWarning = (kept: number) =>
|
|
187
|
+
`Showing ${kept} of ${total} rows; the rest were dropped to fit the result size limit. Narrow the query rather than paging through it.`;
|
|
188
|
+
const noneFitWarning =
|
|
189
|
+
`No rows fit the result size limit: the first of ${total} rows is too large to send on its own. ` +
|
|
190
|
+
`This is NOT an empty result and does not mean nothing matched. Select fewer columns, or truncate the oversized field, then run it again.`;
|
|
191
|
+
// Measure with whichever wording is longer, so the one actually returned is
|
|
192
|
+
// never bigger than the one the search sized the payload against.
|
|
193
|
+
const measured =
|
|
194
|
+
noneFitWarning.length > sizeWarning(total).length
|
|
195
|
+
? noneFitWarning
|
|
196
|
+
: sizeWarning(total);
|
|
197
|
+
truncating.warning = [envelope.warning, measured].filter(Boolean).join(" ");
|
|
198
|
+
|
|
199
|
+
let low = 0;
|
|
200
|
+
let high = total;
|
|
201
|
+
let best = 0;
|
|
202
|
+
while (low <= high) {
|
|
203
|
+
const mid = Math.floor((low + high) / 2);
|
|
204
|
+
if (
|
|
205
|
+
serialize({ ...truncating, rows: rows.slice(0, mid) }).length <= limit
|
|
206
|
+
) {
|
|
207
|
+
best = mid;
|
|
208
|
+
low = mid + 1;
|
|
209
|
+
} else {
|
|
210
|
+
high = mid - 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
...truncating,
|
|
216
|
+
rows: rows.slice(0, best),
|
|
217
|
+
_returned_rows: best,
|
|
218
|
+
warning: [
|
|
219
|
+
envelope.warning,
|
|
220
|
+
best === 0 ? noneFitWarning : sizeWarning(best),
|
|
221
|
+
]
|
|
222
|
+
.filter(Boolean)
|
|
223
|
+
.join(" "),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Serialize an envelope for transport, BigInt-safe. */
|
|
228
|
+
export function serializeEnvelope(envelope: QueryEnvelope): string {
|
|
229
|
+
return serialize(envelope);
|
|
230
|
+
}
|