@malloy-publisher/server 0.0.204 → 0.0.205
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/build.ts +10 -1
- package/dist/app/api-doc.yaml +133 -4
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-CAge6UHD.js} +1 -1
- package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-CeTxxGex.js} +2 -2
- package/dist/app/assets/MaterializationsPage-CpDHB70t.js +1 -0
- package/dist/app/assets/ModelPage-D9sSMb75.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-LRqQWrFY.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-xT6kuCNw.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-DsIh9svZ.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BdOZDcce.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-DHHAcY5o.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-RX3QOTde.js} +121 -121
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-D2WH3D-f.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +567 -194
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/errors.spec.ts +21 -0
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/runtime/publisher.js +318 -0
- package/src/server.ts +479 -2
- package/src/service/authorize_integration.spec.ts +96 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +63 -5
- package/src/service/environment_store.ts +142 -11
- package/src/service/model.ts +44 -0
- package/src/service/package.ts +17 -6
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
- package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
- package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloy-publisher/server",
|
|
3
3
|
"description": "Malloy Publisher Server",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.205",
|
|
5
5
|
"main": "dist/server.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"malloy-publisher": "dist/server.mjs"
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
"test": "bun run test:unit && bun run test:integration",
|
|
18
18
|
"test:unit": "bun test --timeout 100000 src",
|
|
19
19
|
"test:integration": "bun test --timeout 200000 tests --max-workers=1",
|
|
20
|
-
"build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts",
|
|
21
|
-
"build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts",
|
|
20
|
+
"build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts && bun run bake-duckdb-extensions",
|
|
21
|
+
"build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts && bun run bake-duckdb-extensions",
|
|
22
|
+
"bake-duckdb-extensions": "bun scripts/bake-duckdb-extensions.js",
|
|
22
23
|
"start": "NODE_ENV=production bun run ./dist/server.mjs",
|
|
23
24
|
"start:init": "NODE_ENV=production bun run ./dist/server.mjs --init",
|
|
24
25
|
"start:dev": "NODE_ENV=development bun --watch src/server.ts",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"@aws-sdk/client-s3": "^3.958.0",
|
|
34
35
|
"@azure/identity": "^4.13.0",
|
|
35
36
|
"@azure/storage-blob": "^12.26.0",
|
|
37
|
+
"@duckdb/node-api": "1.5.3-r.2",
|
|
36
38
|
"@google-cloud/storage": "^7.16.0",
|
|
37
39
|
"@malloydata/db-bigquery": "^0.0.405",
|
|
38
40
|
"@malloydata/db-databricks": "^0.0.405",
|
|
@@ -58,7 +60,6 @@
|
|
|
58
60
|
"class-transformer": "^0.5.1",
|
|
59
61
|
"class-validator": "^0.14.1",
|
|
60
62
|
"cors": "^2.8.5",
|
|
61
|
-
"duckdb": "1.4.4",
|
|
62
63
|
"express": "^4.21.0",
|
|
63
64
|
"extract-zip": "^2.0.1",
|
|
64
65
|
"globals": "^15.9.0",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pre-download ("bake") the DuckDB extensions the server loads at runtime so
|
|
5
|
+
* they are on disk before any query runs.
|
|
6
|
+
*
|
|
7
|
+
* DuckDB fetches extensions on first use from extensions.duckdb.org and caches
|
|
8
|
+
* them under ~/.duckdb/extensions/v<version>/<platform>/. Some hosts cannot
|
|
9
|
+
* reach that CDN (notably the macOS GitHub Actions fleet), so an on-demand
|
|
10
|
+
* fetch at query time fails and takes the query (or a test) down. Baking
|
|
11
|
+
* populates the cache via the same @duckdb/node-api engine the runtime uses,
|
|
12
|
+
* so later INSTALL/LOAD calls are served from disk.
|
|
13
|
+
*
|
|
14
|
+
* Runs as the last step of this package's build (`build` / `build:server-only`),
|
|
15
|
+
* so local dev, CI, and the Docker builder all bake the same set. The Docker
|
|
16
|
+
* final stage copies the baked ~/.duckdb/extensions from the builder rather
|
|
17
|
+
* than re-baking, keeping a single bake mechanism.
|
|
18
|
+
*
|
|
19
|
+
* Each extension is baked independently: a failure (offline build, transient
|
|
20
|
+
* CDN error) is logged and skipped, never aborting the others or the build.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { DuckDBInstance } from "@duckdb/node-api";
|
|
24
|
+
|
|
25
|
+
// Every extension the connection layer (packages/server/src/service/connection.ts)
|
|
26
|
+
// and storage manager INSTALL/LOAD at runtime, for cloud attach, the per-package
|
|
27
|
+
// sandbox, federated-database attach, and the materialization catalog. Keep this
|
|
28
|
+
// in sync with the install sites in those files.
|
|
29
|
+
//
|
|
30
|
+
// `community: true` mirrors the runtime's `FORCE INSTALL '<name>' FROM community`
|
|
31
|
+
// (bigquery, snowflake); the rest are core extensions installed by name.
|
|
32
|
+
// `registered` is the name the extension reports in duckdb_extensions() when it
|
|
33
|
+
// differs from the INSTALL name (only postgres -> postgres_scanner).
|
|
34
|
+
const EXTENSIONS = [
|
|
35
|
+
{ name: "httpfs", community: false }, // cloud storage (gcs/s3/azure) + per-package sandbox
|
|
36
|
+
{ name: "aws", community: false }, // s3 credential chain
|
|
37
|
+
{ name: "azure", community: false }, // azure blob storage
|
|
38
|
+
{ name: "postgres", community: false, registered: "postgres_scanner" }, // postgres attach + ducklake postgres catalog
|
|
39
|
+
{ name: "ducklake", community: false }, // materialization catalog
|
|
40
|
+
{ name: "bigquery", community: true },
|
|
41
|
+
{ name: "snowflake", community: true },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
const instance = await DuckDBInstance.create(":memory:");
|
|
46
|
+
const connection = await instance.connect();
|
|
47
|
+
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const { name, community, registered } of EXTENSIONS) {
|
|
50
|
+
try {
|
|
51
|
+
const install = community
|
|
52
|
+
? `FORCE INSTALL '${name}' FROM community;`
|
|
53
|
+
: `INSTALL ${name};`;
|
|
54
|
+
await connection.run(`${install} LOAD ${name};`);
|
|
55
|
+
|
|
56
|
+
// Verify the extension actually reports as loaded, rather than trusting
|
|
57
|
+
// that INSTALL/LOAD returned without error.
|
|
58
|
+
const reader = await connection.runAndReadAll(
|
|
59
|
+
`SELECT loaded, installed FROM duckdb_extensions() WHERE extension_name = '${registered ?? name}';`,
|
|
60
|
+
);
|
|
61
|
+
const row = reader.getRowObjectsJS()[0];
|
|
62
|
+
const loaded = row?.loaded === true;
|
|
63
|
+
const installed = row?.installed === true;
|
|
64
|
+
|
|
65
|
+
results.push({ name, installed, loaded });
|
|
66
|
+
if (loaded) {
|
|
67
|
+
console.log(`baked DuckDB extension: ${name} (loaded)`);
|
|
68
|
+
} else {
|
|
69
|
+
console.warn(
|
|
70
|
+
`DuckDB extension "${name}" installed=${installed} but not loaded`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
+
results.push({
|
|
76
|
+
name,
|
|
77
|
+
installed: false,
|
|
78
|
+
loaded: false,
|
|
79
|
+
error: message,
|
|
80
|
+
});
|
|
81
|
+
console.warn(`skipped DuckDB extension "${name}": ${message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ok = results.filter((r) => r.loaded).map((r) => r.name);
|
|
86
|
+
const missing = results.filter((r) => !r.loaded).map((r) => r.name);
|
|
87
|
+
console.log(
|
|
88
|
+
`DuckDB extensions baked: ${ok.length}/${results.length} loaded` +
|
|
89
|
+
(ok.length ? ` [${ok.join(", ")}]` : "") +
|
|
90
|
+
(missing.length ? `; not loaded [${missing.join(", ")}]` : ""),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
connection.closeSync();
|
|
94
|
+
instance.closeSync();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main().catch((err) => {
|
|
98
|
+
// Never fail the build on a bake error -- the extensions are an optimization,
|
|
99
|
+
// not a correctness requirement (the runtime can still fetch on demand where
|
|
100
|
+
// the network allows).
|
|
101
|
+
console.warn(
|
|
102
|
+
`DuckDB extension bake skipped: ${err instanceof Error ? err.message : String(err)}`,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
@@ -1,23 +1,185 @@
|
|
|
1
1
|
import chokidar, { FSWatcher } from "chokidar";
|
|
2
|
+
import { EventEmitter } from "events";
|
|
2
3
|
import { RequestHandler } from "express";
|
|
4
|
+
import path from "path";
|
|
3
5
|
import { components } from "../api";
|
|
4
6
|
import { internalErrorToHttpError } from "../errors";
|
|
5
7
|
import { logger } from "../logger";
|
|
6
|
-
import { assertSafePackageName
|
|
8
|
+
import { assertSafePackageName } from "../path_safety";
|
|
7
9
|
import { EnvironmentStore } from "../service/environment_store";
|
|
8
10
|
|
|
9
11
|
type StartWatchReq = components["schemas"]["StartWatchRequest"];
|
|
10
12
|
type WatchStatusRes = components["schemas"]["WatchStatus"];
|
|
11
13
|
type Handler<Req = object, Res = void> = RequestHandler<object, Res, Req>;
|
|
12
14
|
|
|
15
|
+
// File extensions that should trigger a live-reload event but NOT a Malloy
|
|
16
|
+
// environment reload (these are package assets, not semantic-model sources).
|
|
17
|
+
const ASSET_EXTS = new Set([
|
|
18
|
+
".html",
|
|
19
|
+
".htm",
|
|
20
|
+
".css",
|
|
21
|
+
".js",
|
|
22
|
+
".mjs",
|
|
23
|
+
".json",
|
|
24
|
+
".png",
|
|
25
|
+
".jpg",
|
|
26
|
+
".jpeg",
|
|
27
|
+
".gif",
|
|
28
|
+
".svg",
|
|
29
|
+
".webp",
|
|
30
|
+
".ico",
|
|
31
|
+
".woff",
|
|
32
|
+
".woff2",
|
|
33
|
+
]);
|
|
34
|
+
const MODEL_EXTS = new Set([".malloy", ".malloynb", ".md"]);
|
|
35
|
+
|
|
13
36
|
export class WatchModeController {
|
|
14
37
|
watchingPath: string | null;
|
|
15
38
|
watchingEnvironmentName: string | null;
|
|
16
|
-
watcher: FSWatcher;
|
|
39
|
+
watcher: FSWatcher | null = null;
|
|
40
|
+
// Serializes ensureWatching so concurrent callers can't both close and
|
|
41
|
+
// recreate the watcher and orphan one (each chokidar watcher holds OS
|
|
42
|
+
// handles). See ensureWatching.
|
|
43
|
+
private setupChain: Promise<void> | null = null;
|
|
44
|
+
/**
|
|
45
|
+
* Per-package change bus. Event name is `<environmentName>/<packageName>`.
|
|
46
|
+
* Used by the SSE live-reload endpoint to push refreshes to embedded HTML
|
|
47
|
+
* dashboards. Each emit carries `{ path, kind }` for diagnostics; the SSE
|
|
48
|
+
* handler currently ignores the payload and just sends "changed".
|
|
49
|
+
*/
|
|
50
|
+
public events = new EventEmitter();
|
|
17
51
|
|
|
18
52
|
constructor(private environmentStore: EnvironmentStore) {
|
|
19
53
|
this.watchingPath = null;
|
|
20
54
|
this.watchingEnvironmentName = null;
|
|
55
|
+
// Live-reload subscribers can be many (one per open browser tab);
|
|
56
|
+
// bump the default cap so Node doesn't warn under normal use.
|
|
57
|
+
this.events.setMaxListeners(100);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Idempotent: starts watching `environmentName` if not already.
|
|
62
|
+
*
|
|
63
|
+
* Throws if the environment can't be resolved — never falls back to a
|
|
64
|
+
* `<serverRoot>/<envName>` path that an attacker could control via
|
|
65
|
+
* URL-encoded path traversal. Callers (the SSE handler in particular)
|
|
66
|
+
* must validate the env name before calling this.
|
|
67
|
+
*/
|
|
68
|
+
public async ensureWatching(environmentName: string): Promise<void> {
|
|
69
|
+
if (this.watchingEnvironmentName === environmentName && this.watcher) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// Serialize setup behind any in-flight one, then re-check the guard, so
|
|
73
|
+
// concurrent callers don't both close + recreate the watcher (which would
|
|
74
|
+
// orphan a chokidar instance and leak its OS file handles).
|
|
75
|
+
const run = (this.setupChain ?? Promise.resolve())
|
|
76
|
+
.catch(() => {})
|
|
77
|
+
.then(async () => {
|
|
78
|
+
if (
|
|
79
|
+
this.watchingEnvironmentName === environmentName &&
|
|
80
|
+
this.watcher
|
|
81
|
+
) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const env = await this.environmentStore.getEnvironment(
|
|
85
|
+
environmentName,
|
|
86
|
+
false,
|
|
87
|
+
);
|
|
88
|
+
const watchPath = env.getEnvironmentPath();
|
|
89
|
+
if (this.watcher) {
|
|
90
|
+
await this.watcher.close();
|
|
91
|
+
this.watcher = null;
|
|
92
|
+
}
|
|
93
|
+
this.startWatcher(environmentName, watchPath);
|
|
94
|
+
});
|
|
95
|
+
this.setupChain = run;
|
|
96
|
+
await run;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether watch mode is currently running for the given env. */
|
|
100
|
+
public isWatching(environmentName: string): boolean {
|
|
101
|
+
return !!this.watcher && this.watchingEnvironmentName === environmentName;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private startWatcher(watchName: string, watchPath: string) {
|
|
105
|
+
this.watchingEnvironmentName = watchName;
|
|
106
|
+
this.watchingPath = watchPath;
|
|
107
|
+
this.watcher = chokidar.watch(this.watchingPath, {
|
|
108
|
+
ignored: (filePath, stats) => {
|
|
109
|
+
if (!stats?.isFile()) return false;
|
|
110
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
111
|
+
return !MODEL_EXTS.has(ext) && !ASSET_EXTS.has(ext);
|
|
112
|
+
},
|
|
113
|
+
ignoreInitial: true,
|
|
114
|
+
});
|
|
115
|
+
// Reload just the package a changed file belongs to: this recompiles
|
|
116
|
+
// that package's models from disk (Package.create) and replaces the
|
|
117
|
+
// cached entry, so the next query sees the edit. Scoped to watch mode by
|
|
118
|
+
// construction — it only runs from this watcher, which is started only
|
|
119
|
+
// when watch mode is active.
|
|
120
|
+
//
|
|
121
|
+
// The previous env-level reload (getEnvironment(reload) + addEnvironment)
|
|
122
|
+
// was a no-op for compilation: addEnvironment on an existing env calls
|
|
123
|
+
// Environment.update(), which refreshes metadata/connections only and
|
|
124
|
+
// never touches this.packages, so edits never took effect.
|
|
125
|
+
// Returns true if the package recompiled cleanly. A transient compile
|
|
126
|
+
// error (e.g. a half-typed model saved mid-edit) returns false so we can
|
|
127
|
+
// avoid bouncing open browser tabs into a compile-error/404 — see onEvent.
|
|
128
|
+
const reloadPackage = async (pkgName: string): Promise<boolean> => {
|
|
129
|
+
try {
|
|
130
|
+
const environment = await this.environmentStore.getEnvironment(
|
|
131
|
+
watchName,
|
|
132
|
+
false,
|
|
133
|
+
);
|
|
134
|
+
await environment.getPackage(pkgName, true);
|
|
135
|
+
logger.info(
|
|
136
|
+
`Watch: recompiled package "${pkgName}" in environment "${watchName}"`,
|
|
137
|
+
);
|
|
138
|
+
return true;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logger.error(
|
|
141
|
+
`Watch: failed to recompile package "${pkgName}" in environment "${watchName}"`,
|
|
142
|
+
{ error },
|
|
143
|
+
);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const onEvent =
|
|
148
|
+
(kind: "add" | "change" | "unlink") => async (filePath: string) => {
|
|
149
|
+
logger.info(`Watch ${kind}: ${filePath}; environment=${watchName}`);
|
|
150
|
+
// Resolve which package the file belongs to. Packages are
|
|
151
|
+
// subdirectories of the environment (env/<pkg>/...), so a file must
|
|
152
|
+
// be nested at least one level to belong to a package; files at the
|
|
153
|
+
// environment root belong to no package and are ignored.
|
|
154
|
+
const rel = path.relative(this.watchingPath ?? "", filePath);
|
|
155
|
+
const segments = rel.split(path.sep);
|
|
156
|
+
const pkgName =
|
|
157
|
+
segments.length > 1 &&
|
|
158
|
+
segments[0] &&
|
|
159
|
+
!segments[0].startsWith("..")
|
|
160
|
+
? segments[0]
|
|
161
|
+
: null;
|
|
162
|
+
if (!pkgName) return;
|
|
163
|
+
|
|
164
|
+
// Recompile Malloy state only for model files. Asset edits
|
|
165
|
+
// (HTML/CSS/JS) skip recompile — they just need the live-reload
|
|
166
|
+
// fanout below. For model edits, only signal a reload once the
|
|
167
|
+
// recompile succeeds: a transient syntax error shouldn't bounce
|
|
168
|
+
// open pages into a compile error or 404.
|
|
169
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
170
|
+
if (MODEL_EXTS.has(ext)) {
|
|
171
|
+
const recompiled = await reloadPackage(pkgName);
|
|
172
|
+
if (!recompiled) return;
|
|
173
|
+
}
|
|
174
|
+
// Fan out to SSE clients embedded in the affected package.
|
|
175
|
+
this.events.emit(`${watchName}/${pkgName}`, {
|
|
176
|
+
path: filePath,
|
|
177
|
+
kind,
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
this.watcher.on("add", onEvent("add"));
|
|
181
|
+
this.watcher.on("change", onEvent("change"));
|
|
182
|
+
this.watcher.on("unlink", onEvent("unlink"));
|
|
21
183
|
}
|
|
22
184
|
|
|
23
185
|
public getWatchStatus: Handler<void, WatchStatusRes> = async (_req, res) => {
|
|
@@ -45,9 +207,6 @@ export class WatchModeController {
|
|
|
45
207
|
await EnvironmentStore.reloadEnvironmentManifest(
|
|
46
208
|
this.environmentStore.serverRootPath,
|
|
47
209
|
);
|
|
48
|
-
this.watchingEnvironmentName = watchName || null;
|
|
49
|
-
|
|
50
|
-
// Find the environment in the manifest
|
|
51
210
|
const environment = environmentManifest.environments.find(
|
|
52
211
|
(e) => e.name === watchName,
|
|
53
212
|
);
|
|
@@ -61,51 +220,22 @@ export class WatchModeController {
|
|
|
61
220
|
});
|
|
62
221
|
return;
|
|
63
222
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
!path.endsWith(".malloy") &&
|
|
73
|
-
!path.endsWith(".md"),
|
|
74
|
-
ignoreInitial: true,
|
|
75
|
-
});
|
|
76
|
-
const reloadEnvironment = async () => {
|
|
77
|
-
// Overwrite the environment with its existing metadata to trigger a re-read
|
|
78
|
-
const environment = await this.environmentStore.getEnvironment(
|
|
79
|
-
watchName,
|
|
80
|
-
true,
|
|
81
|
-
);
|
|
82
|
-
await this.environmentStore.addEnvironment(environment.metadata);
|
|
83
|
-
logger.info(`Reloaded environment ${watchName}`);
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
this.watcher.on("add", async (path) => {
|
|
87
|
-
logger.info(
|
|
88
|
-
`Detected new file ${path}, reloading environment ${watchName}`,
|
|
89
|
-
);
|
|
90
|
-
await reloadEnvironment();
|
|
91
|
-
});
|
|
92
|
-
this.watcher.on("unlink", async (path) => {
|
|
93
|
-
logger.info(
|
|
94
|
-
`Detected deletion of ${path}, reloading environment ${watchName}`,
|
|
95
|
-
);
|
|
96
|
-
await reloadEnvironment();
|
|
97
|
-
});
|
|
98
|
-
this.watcher.on("change", async (path) => {
|
|
99
|
-
logger.info(
|
|
100
|
-
`Detected change on ${path}, reloading environment ${watchName}`,
|
|
101
|
-
);
|
|
102
|
-
await reloadEnvironment();
|
|
103
|
-
});
|
|
223
|
+
try {
|
|
224
|
+
await this.ensureWatching(watchName);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
logger.error(error);
|
|
227
|
+
const { status } = internalErrorToHttpError(error as Error);
|
|
228
|
+
res.status(status).json({ error: (error as Error).message });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
104
231
|
res.json();
|
|
105
232
|
};
|
|
106
233
|
|
|
107
234
|
public stopWatchMode: Handler = async (_req, res) => {
|
|
108
|
-
this.watcher
|
|
235
|
+
if (this.watcher) {
|
|
236
|
+
await this.watcher.close();
|
|
237
|
+
this.watcher = null;
|
|
238
|
+
}
|
|
109
239
|
this.watchingPath = null;
|
|
110
240
|
this.watchingEnvironmentName = null;
|
|
111
241
|
res.json();
|
package/src/errors.spec.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
AccessDeniedError,
|
|
3
4
|
BadRequestError,
|
|
4
5
|
ConnectionAuthError,
|
|
5
6
|
ConnectionError,
|
|
6
7
|
internalErrorToHttpError,
|
|
8
|
+
ModelCompilationError,
|
|
7
9
|
PayloadTooLargeError,
|
|
8
10
|
QueryTimeoutError,
|
|
9
11
|
ServiceUnavailableError,
|
|
@@ -29,6 +31,25 @@ describe("internalErrorToHttpError", () => {
|
|
|
29
31
|
expect(json).toEqual({ code: 400, message: "bad input" });
|
|
30
32
|
});
|
|
31
33
|
|
|
34
|
+
it("maps AccessDeniedError to 403 (authorize gate)", () => {
|
|
35
|
+
const { status, json } = internalErrorToHttpError(
|
|
36
|
+
new AccessDeniedError('Access denied for source "gated".'),
|
|
37
|
+
);
|
|
38
|
+
expect(status).toBe(403);
|
|
39
|
+
expect(json).toEqual({
|
|
40
|
+
code: 403,
|
|
41
|
+
message: 'Access denied for source "gated".',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("maps ModelCompilationError to 424", () => {
|
|
46
|
+
const { status, json } = internalErrorToHttpError(
|
|
47
|
+
new ModelCompilationError({ message: "compile failed" }),
|
|
48
|
+
);
|
|
49
|
+
expect(status).toBe(424);
|
|
50
|
+
expect(json).toEqual({ code: 424, message: "compile failed" });
|
|
51
|
+
});
|
|
52
|
+
|
|
32
53
|
it("maps ConnectionError to 502 (distinct from auth, still retryable)", () => {
|
|
33
54
|
const { status, json } = internalErrorToHttpError(
|
|
34
55
|
new ConnectionError("upstream broken"),
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { AccessDeniedError } from "../errors";
|
|
3
|
+
import { getMalloyErrorDetails } from "./error_messages";
|
|
4
|
+
|
|
5
|
+
describe("getMalloyErrorDetails — access-denied branch", () => {
|
|
6
|
+
it("recognizes an authorize denial and gives access-relevant (not syntax) advice", () => {
|
|
7
|
+
const details = getMalloyErrorDetails(
|
|
8
|
+
"executeQuery",
|
|
9
|
+
"env/pkg/model.malloy",
|
|
10
|
+
new AccessDeniedError('Access denied for source "gated".'),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
// Message carries the source name, never the gate expression.
|
|
14
|
+
expect(details.message).toContain('Access denied for source "gated".');
|
|
15
|
+
|
|
16
|
+
// The suggestion is about satisfying access (givens/role), and the
|
|
17
|
+
// generic Malloy-syntax suggestions are replaced, not appended.
|
|
18
|
+
expect(details.suggestions).toHaveLength(1);
|
|
19
|
+
expect(details.suggestions[0]).toMatch(/given|authorize|restricted/i);
|
|
20
|
+
// Not the generic "check the database connection / consult the language
|
|
21
|
+
// docs" advice that an unrecognized error would yield.
|
|
22
|
+
expect(details.suggestions.join(" ")).not.toMatch(
|
|
23
|
+
/database connection configuration|language documentation/i,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("still falls back to generic suggestions for an unrecognized error", () => {
|
|
28
|
+
const details = getMalloyErrorDetails(
|
|
29
|
+
"executeQuery",
|
|
30
|
+
"env/pkg/model.malloy",
|
|
31
|
+
new Error("something unexpected"),
|
|
32
|
+
);
|
|
33
|
+
expect(details.suggestions.length).toBeGreaterThan(1);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -126,8 +126,21 @@ export function getMalloyErrorDetails(
|
|
|
126
126
|
const invalidRequestMatch = error.message.match(
|
|
127
127
|
/Invalid query request\\. Query OR queryName must be defined/i,
|
|
128
128
|
);
|
|
129
|
+
// `#(authorize)` gate denial. The message names only the source (gate
|
|
130
|
+
// logic is never leaked), so the suggestion is about satisfying access,
|
|
131
|
+
// not fixing syntax — otherwise this falls through to generic Malloy
|
|
132
|
+
// syntax advice that misleads the caller.
|
|
133
|
+
const accessDeniedMatch = error.message.match(
|
|
134
|
+
/Access denied for source "([^"]+)"/i,
|
|
135
|
+
);
|
|
129
136
|
|
|
130
|
-
if (
|
|
137
|
+
if (accessDeniedMatch) {
|
|
138
|
+
refined = true;
|
|
139
|
+
const [, sourceName] = accessDeniedMatch;
|
|
140
|
+
suggestions = [
|
|
141
|
+
`Suggestion: Access to source '${sourceName}' is restricted by an #(authorize) gate. Supply the givens its authorize expression requires (e.g. a role/region given) and retry. This is an authorization denial, not a syntax error.`,
|
|
142
|
+
];
|
|
143
|
+
} else if (viewNotFoundMatch) {
|
|
131
144
|
refined = true;
|
|
132
145
|
const [, viewName, sourceName] = viewNotFoundMatch;
|
|
133
146
|
suggestions.unshift(
|
package/src/mcp/handler_utils.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ResourceMetadata } from "@modelcontextprotocol/sdk/server/mcp";
|
|
|
3
3
|
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { EnvironmentStore } from "../service/environment_store";
|
|
5
5
|
import {
|
|
6
|
+
AccessDeniedError,
|
|
6
7
|
PackageNotFoundError,
|
|
7
8
|
ModelNotFoundError,
|
|
8
9
|
ModelCompilationError,
|
|
@@ -167,6 +168,17 @@ export async function getModelForQuery(
|
|
|
167
168
|
`${environmentName}/${packageName}/${modelPath}`,
|
|
168
169
|
error,
|
|
169
170
|
);
|
|
171
|
+
} else if (error instanceof AccessDeniedError) {
|
|
172
|
+
// An #(authorize) denial during model setup. Funnel through
|
|
173
|
+
// getMalloyErrorDetails (which recognizes the access-denied message and
|
|
174
|
+
// gives supply-the-givens guidance) so a 403 never surfaces as an
|
|
175
|
+
// opaque internal error. Defensive: the gate fires during query
|
|
176
|
+
// execution today, not setup, but this keeps every error class homed.
|
|
177
|
+
errorDetails = getMalloyErrorDetails(
|
|
178
|
+
"executeQuery (load model)",
|
|
179
|
+
`${environmentName}/${packageName}/${modelPath}`,
|
|
180
|
+
error,
|
|
181
|
+
);
|
|
170
182
|
} else if (error instanceof ServiceUnavailableError) {
|
|
171
183
|
// Back-pressure: don't dress this up as a 404/500. Surface the
|
|
172
184
|
// server's own message so the MCP caller knows to retry.
|