@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
|
@@ -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 ${
|
|
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
|
-
|
|
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.
|
|
684
|
+
logger.debug(
|
|
639
685
|
`Final escaped connection string: ${redactPgSecrets(escapedPgConnString)}`,
|
|
640
686
|
);
|
|
641
687
|
const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
|
|
642
|
-
|
|
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
|
-
|
|
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
|
|
650
|
-
|
|
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
|
+
});
|
package/src/service/model.ts
CHANGED
|
@@ -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
|
-
|
|
2336
|
-
(
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
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
|
-
|
|
2475
|
-
(
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
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.
|
|
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
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
expect(Array.isArray(
|
|
88
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|