@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.
Files changed (48) hide show
  1. package/dist/app/api-doc.yaml +61 -10
  2. package/dist/app/assets/{EnvironmentPage-DXEaZIPx.js → EnvironmentPage-DutP7T8h.js} +1 -1
  3. package/dist/app/assets/{HomePage-kofsqpZt.js → HomePage-BcxDrBfl.js} +1 -1
  4. package/dist/app/assets/{LightMode-CNhIlIlJ.js → LightMode-BJukGxgz.js} +1 -1
  5. package/dist/app/assets/{MainPage-Bgqo8jCy.js → MainPage-DXbwlMeF.js} +2 -2
  6. package/dist/app/assets/{MaterializationsPage-CgBlgGz2.js → MaterializationsPage-BBQksmTU.js} +1 -1
  7. package/dist/app/assets/{ModelPage-B0TjoDtf.js → ModelPage-C6tK51uU.js} +1 -1
  8. package/dist/app/assets/{PackagePage-BL8vnFj1.js → PackagePage-Bo3cwwZE.js} +1 -1
  9. package/dist/app/assets/{RouteError-BzPby0X2.js → RouteError-BufkcAKE.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-CTEP_9r3.js → ThemeEditorPage-DICvvKpa.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-BwM3BmKw.js → WorkbookPage-Dkwt75Nj.js} +1 -1
  12. package/dist/app/assets/{core-CK68iv6w.es-CpRxXBt7.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
  13. package/dist/app/assets/{index-CmkW1MiE.js → index-BusxL5Pt.js} +1 -1
  14. package/dist/app/assets/{index-B33zGctF.js → index-CmEVVe-8.js} +4 -4
  15. package/dist/app/assets/{index-tXJXwdyj.js → index-Cs4WVm2z.js} +1 -1
  16. package/dist/app/assets/{index-BkiWKaAF.js → index-qnhU9CGo.js} +1 -1
  17. package/dist/app/index.html +1 -1
  18. package/dist/server.mjs +19456 -842
  19. package/package.json +1 -1
  20. package/src/controller/query.controller.ts +26 -21
  21. package/src/json_utils.spec.ts +51 -0
  22. package/src/json_utils.ts +33 -0
  23. package/src/mcp/query_envelope.spec.ts +229 -0
  24. package/src/mcp/query_envelope.ts +230 -0
  25. package/src/mcp/server.protocol.spec.ts +128 -16
  26. package/src/mcp/skills/build_skills_bundle.ts +94 -4
  27. package/src/mcp/skills/skills_bundle.json +1 -1
  28. package/src/mcp/skills/skills_bundle.spec.ts +113 -4
  29. package/src/mcp/tool_response.spec.ts +108 -0
  30. package/src/mcp/tool_response.ts +138 -0
  31. package/src/mcp/tools/compile_tool.spec.ts +112 -4
  32. package/src/mcp/tools/compile_tool.ts +61 -30
  33. package/src/mcp/tools/docs_search_tool.ts +6 -16
  34. package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
  35. package/src/mcp/tools/execute_query_tool.ts +90 -151
  36. package/src/mcp/tools/get_context_tool.spec.ts +63 -3
  37. package/src/mcp/tools/get_context_tool.ts +43 -46
  38. package/src/mcp/tools/reload_package_tool.ts +3 -29
  39. package/src/service/compile_fragment_techniques.spec.ts +156 -0
  40. package/src/service/connection.spec.ts +371 -1
  41. package/src/service/connection.ts +77 -14
  42. package/src/service/connection_config.spec.ts +60 -0
  43. package/src/service/connection_config.ts +26 -0
  44. package/src/service/duckdb_instance_isolation.spec.ts +137 -0
  45. package/src/service/model.ts +41 -19
  46. package/src/service/model_limits.spec.ts +28 -0
  47. package/src/service/model_limits.ts +21 -0
  48. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
@@ -477,33 +477,72 @@ function runSQLRows(result: unknown): Record<string, unknown>[] {
477
477
  * the fixed name (which would make every later preflight for this connection
478
478
  * fail its ATTACH with "already exists" and silently skip), and so two
479
479
  * concurrent first-touch lookups can't cross-DETACH each other.
480
+ *
481
+ * {@link metadataSchema} must be the catalog's configured
482
+ * `catalog.metadataSchema`, because `ducklake_metadata` lives in that schema
483
+ * rather than the catalog connection's default one. Getting this wrong is not
484
+ * loud: the read would simply miss, the catch below would log and return, and the
485
+ * range check would stop protecting precisely the catalogs that set the option.
480
486
  */
481
487
  let ducklakePreflightSeq = 0;
482
488
  async function preflightDuckLakeCatalogFormat(
483
489
  connection: DuckDBConnection,
484
490
  dbName: string,
485
491
  pgConnString: string,
492
+ metadataSchema?: string,
486
493
  ): Promise<void> {
487
494
  const tempDb = `${dbName}_fmt_preflight_${++ducklakePreflightSeq}`;
495
+ // Identifier position, not a string literal, so the schema is double-quoted.
496
+ // Measured: with today's validator this is belt-and-braces rather than a fix —
497
+ // every name the regex admits resolves correctly unquoted, including reserved
498
+ // words (DuckDB parses them fine here) and mixed case (matching is
499
+ // case-insensitive). It is quoted anyway because this preflight fails SOFT: a
500
+ // read that misses logs and returns, silently disabling format range-checking
501
+ // for that connection, so the cost of ever getting this wrong is invisible.
502
+ // Quoting makes correctness local to this line instead of contingent on the
503
+ // validator's accept-set, so widening that regex later cannot break it. Safe
504
+ // by construction: the regex admits no quote character to break out with.
505
+ const metadataRef = metadataSchema
506
+ ? `${tempDb}."${metadataSchema}".ducklake_metadata`
507
+ : `${tempDb}.ducklake_metadata`;
488
508
  let catalogFormat: string | undefined;
489
509
  try {
490
510
  await connection.runSQL(
491
511
  `ATTACH '${escapeSQL(pgConnString)}' AS ${tempDb} (TYPE postgres, READ_ONLY);`,
492
512
  );
493
513
  const result = await connection.runSQL(
494
- `SELECT value FROM ${tempDb}.ducklake_metadata WHERE key = 'version' LIMIT 1;`,
514
+ `SELECT value FROM ${metadataRef} WHERE key = 'version' LIMIT 1;`,
495
515
  );
496
516
  const value = runSQLRows(result)[0]?.value;
497
517
  catalogFormat = typeof value === "string" ? value : undefined;
498
518
  } catch (error) {
519
+ const message = redactPgSecrets(
520
+ error instanceof Error ? error.message : String(error),
521
+ );
522
+ // A named metadata schema holding no catalog has two very different causes,
523
+ // and the preflight cannot tell them apart: it is the NORMAL state before the
524
+ // first read-write attach creates the catalog, and it is also what a typo (or
525
+ // adding `metadataSchema` to a catalog whose metadata lives elsewhere) looks
526
+ // like. Neither is loud on its own — a read-write attach CREATES an empty
527
+ // catalog there and materializes into it, and a read-only attach fails with an
528
+ // error that says nothing about the schema — so name the schema and say both,
529
+ // rather than implying a mistake on a path that is expected to hit this once
530
+ // per catalog. The message also has to hedge on the cause: `does not exist`
531
+ // matches a missing database or role too, not only a missing table.
532
+ if (metadataSchema !== undefined && /does not exist/i.test(message)) {
533
+ logger.warn(
534
+ "No DuckLake catalog found in the configured metadata schema. This is " +
535
+ "expected the first time a catalog is created there: a read-write " +
536
+ "attach will create it, and a read-only attach will fail until it " +
537
+ "exists. Otherwise check the schema name, and the catalog database " +
538
+ "and role, against the error.",
539
+ { dbName, metadataSchema, error: message },
540
+ );
541
+ return;
542
+ }
499
543
  logger.warn(
500
544
  "DuckLake catalog-format preflight read failed; falling back to ATTACH",
501
- {
502
- dbName,
503
- error: redactPgSecrets(
504
- error instanceof Error ? error.message : String(error),
505
- ),
506
- },
545
+ { dbName, error: message },
507
546
  );
508
547
  return;
509
548
  } finally {
@@ -633,21 +672,45 @@ async function attachDuckLakeWithMode(
633
672
  const mode = options.readOnly ? "READ_ONLY" : "READ_WRITE";
634
673
  // READ_ONLY: the client manages metadata, we only read the catalog.
635
674
  // READ_WRITE (build only): a build-scoped session materializes into it.
636
- logger.info(`pgConnString: ${redactPgSecrets(pgConnString)}`);
675
+ // Debug, not info. These three lines — this one, the escaped form below, and the
676
+ // assembled ATTACH — are per-attach diagnostics that between them print the catalog
677
+ // host and database twice and the storage path once. `redactPgSecrets` removes the
678
+ // password and any URI userinfo, but a DATA_PATH is not a secret it knows about, so
679
+ // logging the assembled command at info discloses the bucket and whatever the prefix
680
+ // encodes to every reader of the logs. What an operator watching a healthy fleet
681
+ // needs is the mode and the outcome, which stay at info below.
682
+ logger.debug(`pgConnString: ${redactPgSecrets(pgConnString)}`);
637
683
  const escapedPgConnString = escapeSQL(pgConnString);
638
- logger.info(
684
+ logger.debug(
639
685
  `Final escaped connection string: ${redactPgSecrets(escapedPgConnString)}`,
640
686
  );
641
687
  const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
642
- logger.info(`escapedBucketUrl: ${escapedBucketUrl}`);
688
+ // Optional metadata schema: which schema in the catalog database holds this
689
+ // DuckLake's `ducklake_*` tables. Absent keeps DuckLake's default (the catalog
690
+ // connection's default schema), so the emitted command is unchanged for every
691
+ // existing config. Validated to a plain identifier at config load, because it
692
+ // reaches a quoted literal here and an identifier position in the preflight.
693
+ const metadataSchema = ducklakeConfig.catalog.metadataSchema;
643
694
  // Range-preflight the catalog's recorded format version so an unsupported
644
- // catalog fails as a clean, actionable 422 rather than a deep DuckDB 500.
645
- await preflightDuckLakeCatalogFormat(connection, dbName, pgConnString);
695
+ // catalog fails as a clean, actionable 422 rather than a deep DuckDB 500. The
696
+ // schema must be threaded through: the preflight reads `ducklake_metadata`, so
697
+ // when the metadata does not live in the catalog's default schema an unqualified
698
+ // read misses it — and the preflight fails SOFT (logs and returns), so the range
699
+ // check would silently stop protecting exactly the catalogs using this option.
700
+ await preflightDuckLakeCatalogFormat(
701
+ connection,
702
+ dbName,
703
+ pgConnString,
704
+ metadataSchema,
705
+ );
646
706
  // READ_ONLY is stated explicitly; read-write omits the flag (DuckLake's
647
707
  // default is writable). AUTOMATIC_MIGRATION is never set in either mode.
648
708
  const readOnlyClause = options.readOnly ? ", READ_ONLY true" : "";
649
- const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true${readOnlyClause});`;
650
- logger.info(
709
+ const metadataSchemaClause = metadataSchema
710
+ ? `, METADATA_SCHEMA '${escapeSQL(metadataSchema)}'`
711
+ : "";
712
+ const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true${readOnlyClause}${metadataSchemaClause});`;
713
+ logger.debug(
651
714
  `Attaching DuckLake database using command: ${redactPgSecrets(attachCommand)}`,
652
715
  );
653
716
  try {
@@ -671,4 +671,64 @@ describe("ducklake shape validation", () => {
671
671
  /Storage bucketUrl is required for DuckLake/i,
672
672
  );
673
673
  });
674
+
675
+ // metadataSchema reaches TWO grammars: a quoted string literal in the ATTACH,
676
+ // and a quoted identifier in the catalog-format preflight's table reference.
677
+ // Restricting it to a plain identifier at load is what keeps one value valid in
678
+ // both, so these pin the accept/reject boundary rather than trusting escaping.
679
+ const withSchema = (metadataSchema: unknown): ApiConnection =>
680
+ ({
681
+ ...valid,
682
+ ducklakeConnection: {
683
+ ...valid.ducklakeConnection,
684
+ catalog: {
685
+ ...valid.ducklakeConnection!.catalog,
686
+ metadataSchema,
687
+ },
688
+ },
689
+ }) as ApiConnection;
690
+
691
+ it("accepts an absent metadataSchema", () => {
692
+ // Optional: absence keeps DuckLake's default schema, the prior behavior.
693
+ expect(() =>
694
+ assembleEnvironmentConnections([valid], "/tmp/env"),
695
+ ).not.toThrow();
696
+ });
697
+
698
+ it("accepts a plain identifier metadataSchema", () => {
699
+ for (const ok of ["org_a", "_private", "Lake1", "a"]) {
700
+ expect(() =>
701
+ assembleEnvironmentConnections([withSchema(ok)], "/tmp/env"),
702
+ ).not.toThrow();
703
+ }
704
+ });
705
+
706
+ it("rejects a metadataSchema that is not a plain identifier", () => {
707
+ for (const bad of [
708
+ "foo'; DROP TABLE x; --",
709
+ "has space",
710
+ "dotted.name",
711
+ "1leading_digit",
712
+ "",
713
+ '"quoted"',
714
+ ]) {
715
+ expect(() =>
716
+ assembleEnvironmentConnections([withSchema(bad)], "/tmp/env"),
717
+ ).toThrow(/metadataSchema must be a plain identifier/i);
718
+ }
719
+ });
720
+
721
+ it("rejects a non-string metadataSchema rather than coercing it", () => {
722
+ // The value comes from untyped JSON and RegExp.test() coerces, so `true` and
723
+ // `null` match the identifier pattern as "true"/"null" and would pass a
724
+ // pattern-only check — then reach escapeSQL's String.replace as a non-string
725
+ // and throw TypeError at the connection's first attach. That runtime failure is
726
+ // the thing this load-time check exists to prevent, so the type is part of the
727
+ // contract, not a formality.
728
+ for (const bad of [true, false, 0, 1, null, {}, [], ["org_a"]]) {
729
+ expect(() =>
730
+ assembleEnvironmentConnections([withSchema(bad)], "/tmp/env"),
731
+ ).toThrow(/metadataSchema must be a plain identifier/i);
732
+ }
733
+ });
674
734
  });
@@ -444,6 +444,32 @@ function validateConnectionShape(connection: ApiConnection): void {
444
444
  `Storage bucketUrl is required for DuckLake: ${connection.name}`,
445
445
  );
446
446
  }
447
+ // metadataSchema is optional, but when present it reaches the ATTACH as a
448
+ // quoted string literal AND the catalog-format preflight as a quoted
449
+ // identifier. Rather than escape one value for two grammars, restrict it
450
+ // to a plain identifier here — a deterministic config error, caught at
451
+ // load instead of at the connection's first attach.
452
+ //
453
+ // The typeof check is load-bearing, not defensive: the value arrives from
454
+ // untyped JSON, and RegExp.test() coerces its argument, so `true` and `null`
455
+ // both satisfy the pattern as "true"/"null" and would reach escapeSQL's
456
+ // String.replace as a non-string — a TypeError at the first attach, which is
457
+ // exactly the failure this check exists to turn into a config error.
458
+ if (
459
+ connection.ducklakeConnection.catalog.metadataSchema !== undefined
460
+ ) {
461
+ const schema = connection.ducklakeConnection.catalog.metadataSchema;
462
+ if (
463
+ typeof schema !== "string" ||
464
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)
465
+ ) {
466
+ throw new Error(
467
+ `DuckLake catalog metadataSchema must be a plain identifier ` +
468
+ `([A-Za-z_][A-Za-z0-9_]*), got '${schema}' for connection: ` +
469
+ `${connection.name}`,
470
+ );
471
+ }
472
+ }
447
473
  break;
448
474
  case "trino":
449
475
  if (!connection.trinoConnection) {
@@ -0,0 +1,137 @@
1
+ import { DuckDBConnection } from "@malloydata/db-duckdb";
2
+ import { afterEach, describe, expect, it } from "bun:test";
3
+ import fs from "fs/promises";
4
+ import os from "os";
5
+ import path from "path";
6
+
7
+ /**
8
+ * Pins the DuckDB instance-isolation contract that same-named connections depend
9
+ * on for multi-tenant safety.
10
+ *
11
+ * `@malloydata/db-duckdb` pools DuckDB instances in a process-global cache keyed
12
+ * by a share key that deliberately EXCLUDES the connection name, and it caches a
13
+ * `:memory:` primary like any other. Meanwhile the DuckLake attach aliases by
14
+ * connection name (`ATTACH OR REPLACE … AS <name>`). Put those together and two
15
+ * same-named connections that land on ONE pooled instance clobber each other's
16
+ * attach: one ends up reading the other's database.
17
+ *
18
+ * `createIsolatedBuildSession` prevents that by giving every build session a
19
+ * unique `workingDirectory`, relying on it participating in the share key. That
20
+ * is an upstream implementation detail, so nothing here asserted it — and the
21
+ * failure mode is silent: no error, just the wrong data. This spec asserts the
22
+ * behaviour instead of the mechanism, so it holds however upstream achieves it.
23
+ *
24
+ * Deliberately NOT asserted: the converse (same name AND same working directory
25
+ * DO share an instance). That is the pooling artifact, not a property we want; if
26
+ * upstream ever made pooling connection-aware, asserting it would fail on an
27
+ * improvement.
28
+ *
29
+ * Each connection creates and populates its OWN attached store rather than
30
+ * reopening a pre-seeded file. Handing a file from one connection to another
31
+ * requires the first to have released its OS lock, and `close()` is refcount-only
32
+ * — which fails outright under Windows' mandatory locking. Attaching a store the
33
+ * connection itself owns avoids the handoff, and matches how a DuckLake
34
+ * destination is actually used.
35
+ */
36
+ describe("DuckDB instance isolation", () => {
37
+ const tempDirs: string[] = [];
38
+ const openConnections: DuckDBConnection[] = [];
39
+
40
+ const tempDir = async (label: string): Promise<string> => {
41
+ const dir = await fs.mkdtemp(
42
+ path.join(os.tmpdir(), `duckdb-iso-${label}-`),
43
+ );
44
+ tempDirs.push(dir);
45
+ return dir;
46
+ };
47
+
48
+ afterEach(async () => {
49
+ for (const connection of openConnections) {
50
+ await connection.close().catch(() => undefined);
51
+ }
52
+ openConnections.length = 0;
53
+ for (const dir of tempDirs) {
54
+ // Best-effort: a still-locked file must not fail the test.
55
+ await fs
56
+ .rm(dir, { recursive: true, force: true })
57
+ .catch(() => undefined);
58
+ }
59
+ tempDirs.length = 0;
60
+ });
61
+
62
+ /**
63
+ * A connection with the given name and its own working directory, which
64
+ * attaches its own private store under `alias` and writes one distinguishing
65
+ * value into it.
66
+ */
67
+ const connectionWithOwnStore = async (
68
+ name: string,
69
+ label: string,
70
+ alias: string,
71
+ value: number,
72
+ ): Promise<DuckDBConnection> => {
73
+ const sessionDir = await tempDir(`session-${label}`);
74
+ const connection = new DuckDBConnection(name, ":memory:", sessionDir);
75
+ openConnections.push(connection);
76
+ const storeDir = await tempDir(`store-${label}`);
77
+ const storeFile = path.join(storeDir, `${label}.duckdb`);
78
+ // OR REPLACE mirrors the DuckLake attach: on a shared instance the second
79
+ // caller silently replaces the first caller's alias.
80
+ await connection.runSQL(`ATTACH OR REPLACE '${storeFile}' AS ${alias};`);
81
+ await connection.runSQL(
82
+ `CREATE OR REPLACE TABLE ${alias}.t AS SELECT ${value} AS x;`,
83
+ );
84
+ return connection;
85
+ };
86
+
87
+ const readValue = async (
88
+ connection: DuckDBConnection,
89
+ alias: string,
90
+ ): Promise<number> => {
91
+ const result = await connection.runSQL(`SELECT x FROM ${alias}.t;`);
92
+ return Number(Object.values(result.rows[0])[0]);
93
+ };
94
+
95
+ it("keeps same-named connections apart when their working directories differ", async () => {
96
+ // Identical connection NAME and identical `:memory:` primary — only the
97
+ // working directory differs. That is exactly the shape a build session
98
+ // uses, and the only thing standing between it and a shared instance.
99
+ //
100
+ // ORDERING IS LOAD-BEARING FOR THIS TEST. The instance cache is consulted
101
+ // inside an async `init()`, so constructing both connections before either
102
+ // is used lets both race past the cache miss and each create a private
103
+ // instance — isolation for the wrong reason, and a test that could never
104
+ // fail. Each helper call fully initializes its connection (the ATTACH
105
+ // forces it) before the next is built, so a shared share key really would
106
+ // hand back the cached instance. Verified: with identical working
107
+ // directories this ordering yields 2 for both connections — the clobber.
108
+ const a = await connectionWithOwnStore("store", "a", "lake", 1);
109
+ const b = await connectionWithOwnStore("store", "b", "lake", 2);
110
+
111
+ expect(await readValue(a, "lake")).toBe(1);
112
+ expect(await readValue(b, "lake")).toBe(2);
113
+ });
114
+
115
+ it("keeps a build-session-shaped connection apart from a long-lived one", async () => {
116
+ // The pairing the build-session comment calls out specifically: a transient
117
+ // build session overlapping a long-lived serve connection. Same name, same
118
+ // alias, both `:memory:` — distinct only by working directory.
119
+ const serve = await connectionWithOwnStore(
120
+ "credible",
121
+ "serve",
122
+ "lake",
123
+ 10,
124
+ );
125
+ const build = await connectionWithOwnStore(
126
+ "credible",
127
+ "build",
128
+ "lake",
129
+ 20,
130
+ );
131
+
132
+ // The serve connection must still see its own store after the build
133
+ // session attaches a different one under the same alias.
134
+ expect(await readValue(serve, "lake")).toBe(10);
135
+ expect(await readValue(build, "lake")).toBe(20);
136
+ });
137
+ });
@@ -92,6 +92,8 @@ import {
92
92
  import { malloyGivenToApi, type MalloyGiven } from "./given";
93
93
  import {
94
94
  assertWithinModelResponseLimits,
95
+ type QueryRowLimitSource,
96
+ queryRowLimitSource,
95
97
  resolveModelQueryRowLimit,
96
98
  } from "./model_limits";
97
99
  import { buildSourceAliasMap, extractRunTargetSourceName } from "./query_text";
@@ -2059,6 +2061,10 @@ export class Model {
2059
2061
  compactResult: QueryData;
2060
2062
  modelInfo: Malloy.ModelInfo;
2061
2063
  dataStyles: DataStyles;
2064
+ /** Row cap pushed into the SQL: the query's own LIMIT, else the default. */
2065
+ rowLimit: number;
2066
+ /** Which of those two the cap came from. */
2067
+ rowLimitSource: QueryRowLimitSource;
2062
2068
  }> {
2063
2069
  const startTime = performance.now();
2064
2070
  if (this.compilationError) {
@@ -2318,6 +2324,7 @@ export class Model {
2318
2324
  // a 500. `executionTime` is still captured after prepare and before run,
2319
2325
  // preserving the pre-existing timing recorded by the success histogram.
2320
2326
  let rowLimit = 0;
2327
+ let rowLimitSource: QueryRowLimitSource = "server_default";
2321
2328
  let executionTime = 0;
2322
2329
  let queryResults;
2323
2330
  // Givens supplied only so a joined source's authorize gate could see
@@ -2332,16 +2339,18 @@ export class Model {
2332
2339
  // shape can read them; the authorize gate above already saw the full set.
2333
2340
  const effectiveGivens = serveVirtualMap ? undefined : querySurfaceGivens;
2334
2341
  try {
2335
- rowLimit = resolveModelQueryRowLimit(
2336
- (
2337
- await runnable.getPreparedResult({
2338
- givens: effectiveGivens,
2339
- buildManifest: effectiveBuildManifest,
2340
- virtualMap: serveVirtualMap,
2341
- })
2342
- ).resultExplore.limit,
2343
- { defaultLimit: getDefaultQueryRowLimit(), maxRows },
2344
- );
2342
+ const preparedLimit = (
2343
+ await runnable.getPreparedResult({
2344
+ givens: effectiveGivens,
2345
+ buildManifest: effectiveBuildManifest,
2346
+ virtualMap: serveVirtualMap,
2347
+ })
2348
+ ).resultExplore.limit;
2349
+ rowLimitSource = queryRowLimitSource(preparedLimit);
2350
+ rowLimit = resolveModelQueryRowLimit(preparedLimit, {
2351
+ defaultLimit: getDefaultQueryRowLimit(),
2352
+ maxRows,
2353
+ });
2345
2354
  executionTime = performance.now() - startTime;
2346
2355
 
2347
2356
  queryResults = await runnable.run({
@@ -2471,15 +2480,17 @@ export class Model {
2471
2480
  // the connector reads as a hard cap and stops before the first row: a
2472
2481
  // successful, EMPTY answer. Asking the live shape is also the honest
2473
2482
  // limit, since the live shape is what runs.
2474
- rowLimit = resolveModelQueryRowLimit(
2475
- (
2476
- await liveRunnable!.getPreparedResult({
2477
- givens: querySurfaceGivens,
2478
- buildManifest,
2479
- })
2480
- ).resultExplore.limit,
2481
- { defaultLimit: getDefaultQueryRowLimit(), maxRows },
2482
- );
2483
+ const livePreparedLimit = (
2484
+ await liveRunnable!.getPreparedResult({
2485
+ givens: querySurfaceGivens,
2486
+ buildManifest,
2487
+ })
2488
+ ).resultExplore.limit;
2489
+ rowLimitSource = queryRowLimitSource(livePreparedLimit);
2490
+ rowLimit = resolveModelQueryRowLimit(livePreparedLimit, {
2491
+ defaultLimit: getDefaultQueryRowLimit(),
2492
+ maxRows,
2493
+ });
2483
2494
  queryResults = await liveRunnable!.run({
2484
2495
  rowLimit,
2485
2496
  givens: querySurfaceGivens,
@@ -2536,6 +2547,17 @@ export class Model {
2536
2547
  compactResult: queryResults.data.value,
2537
2548
  modelInfo: this.modelInfo,
2538
2549
  dataStyles: this.dataStyles,
2550
+ // The cap actually pushed into the SQL. A caller cannot otherwise tell
2551
+ // a complete result from one the row limit cut off: with no LIMIT of
2552
+ // its own a query silently gets DEFAULT_QUERY_ROW_LIMIT rows, and that
2553
+ // is under maxRows, so assertWithinModelResponseLimits raises nothing.
2554
+ // Returning the number lets a caller compare it against the row count
2555
+ // and say so, with no extra query.
2556
+ rowLimit,
2557
+ // Whether that cap was the author's own limit:/top: or the silent
2558
+ // default. Only the second means rows were probably left behind; a
2559
+ // deliberate `top: 10` returning 10 rows is a complete answer.
2560
+ rowLimitSource,
2539
2561
  };
2540
2562
  }
2541
2563
 
@@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test";
3
3
  import { PayloadTooLargeError } from "../errors";
4
4
  import {
5
5
  assertWithinModelResponseLimits,
6
+ queryRowLimitSource,
6
7
  resolveModelQueryRowLimit,
7
8
  } from "./model_limits";
8
9
 
@@ -179,3 +180,30 @@ describe("assertWithinModelResponseLimits", () => {
179
180
  ).not.toThrow();
180
181
  });
181
182
  });
183
+
184
+ describe("queryRowLimitSource", () => {
185
+ /**
186
+ * Must mirror resolveModelQueryRowLimit's own `requested` condition: if the
187
+ * two ever disagree, the reported source describes a different limit than
188
+ * the one actually pushed into the SQL.
189
+ */
190
+ it("reports the query when it carried its own positive limit", () => {
191
+ expect(queryRowLimitSource(10)).toBe("query");
192
+ expect(queryRowLimitSource(1)).toBe("query");
193
+ });
194
+
195
+ it("reports the server default when the query carried none", () => {
196
+ expect(queryRowLimitSource(undefined)).toBe("server_default");
197
+ expect(queryRowLimitSource(0)).toBe("server_default");
198
+ expect(queryRowLimitSource(-1)).toBe("server_default");
199
+ });
200
+
201
+ it("agrees with which limit resolveModelQueryRowLimit actually used", () => {
202
+ const config = { defaultLimit: 1000, maxRows: 0 };
203
+ for (const userLimit of [undefined, 0, -1, 1, 10, 5000]) {
204
+ const used = resolveModelQueryRowLimit(userLimit, config);
205
+ const cameFromQuery = queryRowLimitSource(userLimit) === "query";
206
+ expect(cameFromQuery).toBe(used !== config.defaultLimit);
207
+ }
208
+ });
209
+ });
@@ -67,6 +67,27 @@ export function resolveModelQueryRowLimit(
67
67
  return Math.min(requested, maxRows + 1);
68
68
  }
69
69
 
70
+ /** Whether the cap came from the query itself or from the server default. */
71
+ export type QueryRowLimitSource = "query" | "server_default";
72
+
73
+ /**
74
+ * Which of the two the cap in {@link resolveModelQueryRowLimit} came from.
75
+ *
76
+ * The distinction is the whole difference between "the database cut this off
77
+ * and you were not told" and "you asked for exactly this many". A `limit:` or
78
+ * `top:` the author wrote is deliberate and complete for what it asked; only the
79
+ * silently-applied default means rows were probably left behind.
80
+ *
81
+ * The condition deliberately mirrors `requested` above and must stay in step
82
+ * with it, so a change to which limit wins cannot leave the reported source
83
+ * describing the other one.
84
+ */
85
+ export function queryRowLimitSource(
86
+ userLimit: number | undefined,
87
+ ): QueryRowLimitSource {
88
+ return userLimit && userLimit > 0 ? "query" : "server_default";
89
+ }
90
+
70
91
  export interface ModelResponseLimitsConfig {
71
92
  /** Result of {@link getMaxQueryRows}. `0` disables the row cap. */
72
93
  maxRows: number;
@@ -71,7 +71,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
71
71
  for (const block of content) {
72
72
  expect(block.type).toBe("resource");
73
73
  expect(block.resource).toBeDefined();
74
- expect(block.resource.type).toBe("application/json");
74
+ expect(block.resource.mimeType).toBe("application/json");
75
75
  expect(block.resource.text).toBeDefined();
76
76
  expect(typeof block.resource.text).toBe("string");
77
77
  }
@@ -80,12 +80,24 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
80
80
 
81
81
  const queryResultBlock = content[0].resource;
82
82
  expect(queryResultBlock.uri).toContain("#result");
83
- const queryResultData = JSON.parse(queryResultBlock.text);
84
- expect(queryResultData).toBeDefined();
85
- // Check properties directly on the parsed Result object
86
- expect(queryResultData.data).toBeDefined();
87
- expect(Array.isArray(queryResultData.data.array_value)).toBe(true);
88
- // Could add more specific checks on data if needed
83
+ const envelope = JSON.parse(queryResultBlock.text);
84
+
85
+ // Flat rows keyed by column name: the same shape an in-package data
86
+ // app receives, not the type-tagged Malloy cell envelope.
87
+ expect(Array.isArray(envelope.rows)).toBe(true);
88
+ expect(envelope.rows.length).toBeGreaterThan(0);
89
+ expect(typeof envelope.rows[0]).toBe("object");
90
+ expect(envelope.rows[0].data).toBeUndefined();
91
+
92
+ // Credible's field names, so an agent sees one shape whether the
93
+ // app is authored locally against Publisher or served in production.
94
+ expect(typeof envelope._query_row_limit).toBe("number");
95
+ expect(typeof envelope._limit_hit).toBe("boolean");
96
+ // Absent rather than false when nothing was dropped.
97
+ expect("_rows_truncated" in envelope).toBe(false);
98
+ // The metadata flat rows drop.
99
+ expect(envelope._meta.schema).toBeDefined();
100
+ expect(typeof envelope._meta.connection_name).toBe("string");
89
101
  },
90
102
  { timeout: 30000 },
91
103
  );
@@ -120,7 +132,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
120
132
  const queryResultBlock = result.content![0];
121
133
  expect(queryResultBlock.type).toBe("resource");
122
134
  expect(queryResultBlock.resource).toBeDefined();
123
- expect(queryResultBlock.resource.type).toBe("application/json");
135
+ expect(queryResultBlock.resource.mimeType).toBe("application/json");
124
136
  expect(queryResultBlock.resource.uri).toMatch(/result/); // Check URI contains queryResult
125
137
  expect(queryResultBlock.resource.text).toBeDefined();
126
138
 
@@ -159,7 +171,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
159
171
  const errorBlockSyntax = result.content![0];
160
172
  expect(errorBlockSyntax.type).toBe("resource");
161
173
  expect(errorBlockSyntax.resource).toBeDefined();
162
- expect(errorBlockSyntax.resource.type).toBe("application/json");
174
+ expect(errorBlockSyntax.resource.mimeType).toBe("application/json");
163
175
 
164
176
  // Check for Malloy compilation error message from getMalloyErrorDetails
165
177
  const errorJsonTextSyntax = errorBlockSyntax.resource
@@ -169,6 +181,17 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
169
181
  /syntax error|no viable alternative/i,
170
182
  );
171
183
  expect(Array.isArray(errorPayloadSyntax.suggestions)).toBe(true);
184
+
185
+ // The resource block is invisible to a client that renders only
186
+ // text on an isError result, which is how a real diagnostic gets
187
+ // reported as a bare "Unknown error". Pinned here, over the real
188
+ // HTTP transport, because the unit spec calls the handler directly
189
+ // and so cannot catch the block being dropped in serialization.
190
+ const textBlockSyntax = result.content!.find(
191
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
192
+ (b: any) => b.type === "text",
193
+ );
194
+ expect(textBlockSyntax?.text).toContain(errorPayloadSyntax.error);
172
195
  },
173
196
  { timeout: 30000 },
174
197
  );
@@ -277,7 +300,9 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
277
300
  const errorBlockPkgNotFound = result.content![0];
278
301
  expect(errorBlockPkgNotFound.type).toBe("resource");
279
302
  expect(errorBlockPkgNotFound.resource).toBeDefined();
280
- expect(errorBlockPkgNotFound.resource.type).toBe("application/json");
303
+ expect(errorBlockPkgNotFound.resource.mimeType).toBe(
304
+ "application/json",
305
+ );
281
306
 
282
307
  // Parse the JSON string from the resource text content
283
308
  const errorJsonTextPkgNotFound = errorBlockPkgNotFound.resource
@@ -321,7 +346,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
321
346
  const errorBlockModel = result.content![0];
322
347
  expect(errorBlockModel.type).toBe("resource");
323
348
  expect(errorBlockModel.resource).toBeDefined();
324
- expect(errorBlockModel.resource.type).toBe("application/json");
349
+ expect(errorBlockModel.resource.mimeType).toBe("application/json");
325
350
 
326
351
  // Parse the JSON string from the resource text content
327
352
  const errorJsonTextModel = errorBlockModel.resource.text as string;
@@ -398,7 +423,7 @@ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
398
423
  const errorBlock = result.content![0];
399
424
  expect(errorBlock.type).toBe("resource");
400
425
  expect(errorBlock.resource).toBeDefined();
401
- expect(errorBlock.resource.type).toBe("application/json");
426
+ expect(errorBlock.resource.mimeType).toBe("application/json");
402
427
 
403
428
  // Check for Malloy error indicating the query/view wasn't found at the top level
404
429
  const errorJsonText = errorBlock.resource.text as string;