@malloy-publisher/server 0.0.204 → 0.0.206
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 +494 -397
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
- package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +1703 -1443
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +33 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +66 -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/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/runtime/publisher.js +318 -0
- package/src/server-old.ts +7 -149
- package/src/server.ts +488 -190
- package/src/service/authorize_integration.spec.ts +163 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +142 -34
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +444 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +271 -20
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- 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/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- 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/legacy_schema_migration.test.ts +7 -4
- 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/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
import { components } from "../api";
|
|
2
|
+
import { normalizeModelPath } from "../constants";
|
|
2
3
|
import { BadRequestError, FrozenConfigError } from "../errors";
|
|
3
|
-
import { logger } from "../logger";
|
|
4
4
|
import { EnvironmentStore } from "../service/environment_store";
|
|
5
|
-
import { ManifestService } from "../service/manifest_service";
|
|
6
5
|
|
|
7
6
|
type ApiPackage = components["schemas"]["Package"];
|
|
8
7
|
|
|
9
8
|
export class PackageController {
|
|
10
9
|
private environmentStore: EnvironmentStore;
|
|
11
|
-
private manifestService: ManifestService;
|
|
12
10
|
|
|
13
|
-
constructor(
|
|
14
|
-
environmentStore: EnvironmentStore,
|
|
15
|
-
manifestService: ManifestService,
|
|
16
|
-
) {
|
|
11
|
+
constructor(environmentStore: EnvironmentStore) {
|
|
17
12
|
this.environmentStore = environmentStore;
|
|
18
|
-
this.manifestService = manifestService;
|
|
19
13
|
}
|
|
20
14
|
|
|
21
15
|
public async listPackages(environmentName: string): Promise<ApiPackage[]> {
|
|
@@ -70,11 +64,7 @@ export class PackageController {
|
|
|
70
64
|
return _package.getPackageMetadata();
|
|
71
65
|
}
|
|
72
66
|
|
|
73
|
-
async addPackage(
|
|
74
|
-
environmentName: string,
|
|
75
|
-
body: ApiPackage,
|
|
76
|
-
options?: { autoLoadManifest?: boolean },
|
|
77
|
-
) {
|
|
67
|
+
async addPackage(environmentName: string, body: ApiPackage) {
|
|
78
68
|
if (this.environmentStore.publisherConfigIsFrozen) {
|
|
79
69
|
throw new FrozenConfigError();
|
|
80
70
|
}
|
|
@@ -86,73 +76,61 @@ export class PackageController {
|
|
|
86
76
|
environmentName,
|
|
87
77
|
false,
|
|
88
78
|
);
|
|
79
|
+
// Strict at publish: the author is in the loop here, so reject a bad
|
|
80
|
+
// explores with an actionable 400 instead of silently serving a hidden
|
|
81
|
+
// surface. (At startup/reload we fail safe and only warn — see
|
|
82
|
+
// Package.loadViaWorker.) The rollback differs by path so a rejected
|
|
83
|
+
// publish never destroys user content:
|
|
84
|
+
// - location: the tree was just downloaded into a fresh canonical, so
|
|
85
|
+
// validation runs inside installPackage's swap window and a failure
|
|
86
|
+
// wipes that download (the existing rollback) — nothing pre-existing
|
|
87
|
+
// to lose.
|
|
88
|
+
// - no-location: addPackage registered a *pre-existing* user directory,
|
|
89
|
+
// so we validate after the fact and `unloadPackage` (evict from
|
|
90
|
+
// memory, keep the files) rather than delete it.
|
|
89
91
|
let result;
|
|
90
92
|
if (body.location) {
|
|
91
93
|
const bodyLocation = body.location;
|
|
92
|
-
result = await environment.installPackage(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
result = await environment.installPackage(
|
|
95
|
+
packageName,
|
|
96
|
+
(stagingPath) =>
|
|
97
|
+
this.downloadInto(
|
|
98
|
+
environmentName,
|
|
99
|
+
packageName,
|
|
100
|
+
bodyLocation,
|
|
101
|
+
stagingPath,
|
|
102
|
+
),
|
|
103
|
+
(pkg) => pkg.formatInvalidExplores(),
|
|
99
104
|
);
|
|
100
105
|
} else {
|
|
101
106
|
result = await environment.addPackage(packageName);
|
|
102
107
|
}
|
|
108
|
+
|
|
109
|
+
// `addPackage`/`installPackage` are typed `Package | undefined`; a missing
|
|
110
|
+
// result here is a should-never-happen internal fault. Fail loudly rather
|
|
111
|
+
// than letting optional chaining silently skip the validation below.
|
|
112
|
+
if (!result) {
|
|
113
|
+
throw new Error(`Failed to create package ${packageName}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!body.location) {
|
|
117
|
+
const invalidMsg = result.formatInvalidExplores();
|
|
118
|
+
if (invalidMsg) {
|
|
119
|
+
await environment.unloadPackage(packageName).catch(() => {
|
|
120
|
+
/* best-effort; the package is not persisted below */
|
|
121
|
+
});
|
|
122
|
+
throw new BadRequestError(invalidMsg);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
103
126
|
await this.environmentStore.addPackageToDatabase(
|
|
104
127
|
environmentName,
|
|
105
128
|
packageName,
|
|
106
129
|
);
|
|
107
130
|
|
|
108
|
-
if (options?.autoLoadManifest === true) {
|
|
109
|
-
await this.tryLoadExistingManifest(environmentName, packageName);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
131
|
return result;
|
|
113
132
|
}
|
|
114
133
|
|
|
115
|
-
/**
|
|
116
|
-
* If there are already manifest entries for this package (e.g. from a
|
|
117
|
-
* previous materialization run), reload all models with the manifest so
|
|
118
|
-
* persist references resolve to the materialized tables immediately.
|
|
119
|
-
*/
|
|
120
|
-
private async tryLoadExistingManifest(
|
|
121
|
-
environmentName: string,
|
|
122
|
-
packageName: string,
|
|
123
|
-
): Promise<void> {
|
|
124
|
-
try {
|
|
125
|
-
const repository =
|
|
126
|
-
this.environmentStore.storageManager.getRepository();
|
|
127
|
-
const dbEnvironment =
|
|
128
|
-
await repository.getEnvironmentByName(environmentName);
|
|
129
|
-
if (!dbEnvironment) return;
|
|
130
|
-
|
|
131
|
-
const manifest = await this.manifestService.getManifest(
|
|
132
|
-
dbEnvironment.id,
|
|
133
|
-
packageName,
|
|
134
|
-
);
|
|
135
|
-
if (Object.keys(manifest.entries).length === 0) return;
|
|
136
|
-
|
|
137
|
-
await this.manifestService.reloadManifest(
|
|
138
|
-
dbEnvironment.id,
|
|
139
|
-
packageName,
|
|
140
|
-
environmentName,
|
|
141
|
-
);
|
|
142
|
-
logger.info("Auto-loaded existing manifest for added package", {
|
|
143
|
-
environmentName,
|
|
144
|
-
packageName,
|
|
145
|
-
entryCount: Object.keys(manifest.entries).length,
|
|
146
|
-
});
|
|
147
|
-
} catch (error) {
|
|
148
|
-
logger.warn("Failed to auto-load manifest for package", {
|
|
149
|
-
environmentName,
|
|
150
|
-
packageName,
|
|
151
|
-
error,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
134
|
public async deletePackage(environmentName: string, packageName: string) {
|
|
157
135
|
if (this.environmentStore.publisherConfigIsFrozen) {
|
|
158
136
|
throw new FrozenConfigError();
|
|
@@ -184,15 +162,24 @@ export class PackageController {
|
|
|
184
162
|
);
|
|
185
163
|
if (body.location) {
|
|
186
164
|
// Re-install: stream the new content into a staging dir (no lock)
|
|
187
|
-
// and atomically swap it in (under the lock).
|
|
165
|
+
// and atomically swap it in (under the lock). Validate the effective
|
|
166
|
+
// explores (the body override, else the new tree's own manifest)
|
|
167
|
+
// INSIDE the swap window, so a rejected update rolls back to the
|
|
168
|
+
// previous tree instead of swapping the bad one in and 400-ing after.
|
|
188
169
|
const bodyLocation = body.location;
|
|
189
|
-
await environment.installPackage(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
170
|
+
await environment.installPackage(
|
|
171
|
+
packageName,
|
|
172
|
+
(stagingPath) =>
|
|
173
|
+
this.downloadInto(
|
|
174
|
+
environmentName,
|
|
175
|
+
packageName,
|
|
176
|
+
bodyLocation,
|
|
177
|
+
stagingPath,
|
|
178
|
+
),
|
|
179
|
+
(pkg) =>
|
|
180
|
+
pkg.formatInvalidExplores(
|
|
181
|
+
body.explores?.map(normalizeModelPath),
|
|
182
|
+
),
|
|
196
183
|
);
|
|
197
184
|
}
|
|
198
185
|
// Apply metadata changes (publisher.json) under the same per-package
|
|
@@ -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/dto/package.dto.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
IsString,
|
|
3
|
+
IsNotEmpty,
|
|
4
|
+
IsOptional,
|
|
5
|
+
IsArray,
|
|
6
|
+
IsIn,
|
|
7
|
+
} from "class-validator";
|
|
2
8
|
import { ApiPackage } from "../service/package";
|
|
3
9
|
|
|
4
10
|
export class PackageDto implements ApiPackage {
|
|
@@ -9,4 +15,13 @@ export class PackageDto implements ApiPackage {
|
|
|
9
15
|
@IsString()
|
|
10
16
|
@IsNotEmpty()
|
|
11
17
|
description: string;
|
|
18
|
+
|
|
19
|
+
@IsOptional()
|
|
20
|
+
@IsArray()
|
|
21
|
+
@IsString({ each: true })
|
|
22
|
+
explores?: string[];
|
|
23
|
+
|
|
24
|
+
@IsOptional()
|
|
25
|
+
@IsIn(["declared", "all"])
|
|
26
|
+
queryableSources?: "declared" | "all";
|
|
12
27
|
}
|
package/src/errors.spec.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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,
|
|
9
|
+
NotQueryableError,
|
|
7
10
|
PayloadTooLargeError,
|
|
8
11
|
QueryTimeoutError,
|
|
9
12
|
ServiceUnavailableError,
|
|
@@ -29,6 +32,36 @@ describe("internalErrorToHttpError", () => {
|
|
|
29
32
|
expect(json).toEqual({ code: 400, message: "bad input" });
|
|
30
33
|
});
|
|
31
34
|
|
|
35
|
+
it("maps AccessDeniedError to 403 (authorize gate)", () => {
|
|
36
|
+
const { status, json } = internalErrorToHttpError(
|
|
37
|
+
new AccessDeniedError('Access denied for source "gated".'),
|
|
38
|
+
);
|
|
39
|
+
expect(status).toBe(403);
|
|
40
|
+
expect(json).toEqual({
|
|
41
|
+
code: 403,
|
|
42
|
+
message: 'Access denied for source "gated".',
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("maps NotQueryableError to 404 (explore boundary)", () => {
|
|
47
|
+
const { status, json } = internalErrorToHttpError(
|
|
48
|
+
new NotQueryableError('No queryable source "hidden".'),
|
|
49
|
+
);
|
|
50
|
+
expect(status).toBe(404);
|
|
51
|
+
expect(json).toEqual({
|
|
52
|
+
code: 404,
|
|
53
|
+
message: 'No queryable source "hidden".',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("maps ModelCompilationError to 424", () => {
|
|
58
|
+
const { status, json } = internalErrorToHttpError(
|
|
59
|
+
new ModelCompilationError({ message: "compile failed" }),
|
|
60
|
+
);
|
|
61
|
+
expect(status).toBe(424);
|
|
62
|
+
expect(json).toEqual({ code: 424, message: "compile failed" });
|
|
63
|
+
});
|
|
64
|
+
|
|
32
65
|
it("maps ConnectionError to 502 (distinct from auth, still retryable)", () => {
|
|
33
66
|
const { status, json } = internalErrorToHttpError(
|
|
34
67
|
new ConnectionError("upstream broken"),
|
package/src/errors.ts
CHANGED
|
@@ -14,6 +14,8 @@ export function internalErrorToHttpError(error: Error) {
|
|
|
14
14
|
return httpError(404, error.message);
|
|
15
15
|
} else if (error instanceof ModelNotFoundError) {
|
|
16
16
|
return httpError(404, error.message);
|
|
17
|
+
} else if (error instanceof NotQueryableError) {
|
|
18
|
+
return httpError(404, error.message);
|
|
17
19
|
} else if (error instanceof MalloyError) {
|
|
18
20
|
return httpError(400, error.message);
|
|
19
21
|
} else if (error instanceof ConnectionNotFoundError) {
|
|
@@ -128,6 +130,22 @@ export class AccessDeniedError extends Error {
|
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A query targeted a source/model that is not part of the package's queryable
|
|
135
|
+
* surface under `queryableSources: "declared"` (a non-`explores` model file, or
|
|
136
|
+
* a source not in a model's `export {}` closure). Mapped to HTTP **404**, not
|
|
137
|
+
* 403, and with a deliberately generic message: unlike `#(authorize)` (which is
|
|
138
|
+
* identity-scoped and answers "who"), the explore boundary is identity-free and
|
|
139
|
+
* answers "what is queryable" — so a hidden target should be indistinguishable
|
|
140
|
+
* from a non-existent one (no enumeration / existence oracle).
|
|
141
|
+
*/
|
|
142
|
+
export class NotQueryableError extends Error {
|
|
143
|
+
constructor(message: string) {
|
|
144
|
+
super(message);
|
|
145
|
+
this.name = "NotQueryableError";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
export class MaterializationNotFoundError extends Error {
|
|
132
150
|
constructor(message: string) {
|
|
133
151
|
super(message);
|
package/src/health.spec.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
it,
|
|
8
|
+
spyOn,
|
|
9
|
+
} from "bun:test";
|
|
2
10
|
import { Server } from "http";
|
|
3
11
|
import { performGracefulShutdownAfterDrain } from "./health";
|
|
4
12
|
import { logger } from "./logger";
|
|
13
|
+
import { __setPackageLoadPoolForTests } from "./package_load/package_load_pool";
|
|
5
14
|
|
|
6
15
|
// Regression test for the graceful-shutdown ordering bug that caused
|
|
7
16
|
// [winston] Attempt to write logs with no transports: {"message":"Waiting 50 seconds..."}
|
|
@@ -16,6 +25,18 @@ describe("performGracefulShutdownAfterDrain: shutdown ordering", () => {
|
|
|
16
25
|
const originalExit = process.exit;
|
|
17
26
|
let callOrder: string[];
|
|
18
27
|
|
|
28
|
+
afterAll(async () => {
|
|
29
|
+
// performGracefulShutdownAfterDrain drains the package-load worker pool
|
|
30
|
+
// via getPackageLoadPool().shutdown() — which lazily CREATES the global
|
|
31
|
+
// singleton and leaves it installed in its shut-down state. In prod the
|
|
32
|
+
// process exits right after, so that's fine; in this shared test process
|
|
33
|
+
// it poisons every later spec that touches the worker path
|
|
34
|
+
// (Package.create → "PackageLoadPool is shutting down"), with failures
|
|
35
|
+
// that come and go with bun's platform-dependent file order. Reset the
|
|
36
|
+
// singleton so the next user lazily creates a fresh pool.
|
|
37
|
+
await __setPackageLoadPoolForTests(null);
|
|
38
|
+
});
|
|
39
|
+
|
|
19
40
|
beforeEach(() => {
|
|
20
41
|
callOrder = [];
|
|
21
42
|
|
|
@@ -41,6 +62,18 @@ describe("performGracefulShutdownAfterDrain: shutdown ordering", () => {
|
|
|
41
62
|
process.exit = originalExit;
|
|
42
63
|
});
|
|
43
64
|
|
|
65
|
+
// performGracefulShutdownAfterDrain lazily creates and shuts down the
|
|
66
|
+
// module-level PackageLoadPool singleton. If we leave that shut-down
|
|
67
|
+
// pool installed, sibling specs that run later in the same Bun process
|
|
68
|
+
// (e.g. package.spec.ts, package_race.spec.ts) inherit a dead pool and
|
|
69
|
+
// fail with "PackageLoadPool is shutting down". Reset the singleton so
|
|
70
|
+
// the next getPackageLoadPool() spins up a fresh one. The leaked-pool
|
|
71
|
+
// ordering is filesystem-readdir dependent, which is why it only
|
|
72
|
+
// surfaced on Linux CI.
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
await __setPackageLoadPoolForTests(null);
|
|
75
|
+
});
|
|
76
|
+
|
|
44
77
|
const fakeServer = (): Server => ({ listening: false }) as unknown as Server;
|
|
45
78
|
|
|
46
79
|
it("logs the 'Waiting ...' message before closing the logger", async () => {
|
package/src/instrumentation.ts
CHANGED
|
@@ -121,8 +121,20 @@ const httpRequestCount = meter.createCounter("http_server_requests_total", {
|
|
|
121
121
|
// /health/liveness probe (a pure synchronous 200 handler) can fail under K8s,
|
|
122
122
|
// so we surface p50/p99/max so an operator can correlate liveness restarts
|
|
123
123
|
// with sustained event-loop pressure (large Malloy compiles, GC, etc.).
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
//
|
|
125
|
+
// `monitorEventLoopDelay` is a Node-only API; some runtimes (e.g. Bun on
|
|
126
|
+
// certain platforms, used by the integration test suite) don't implement it.
|
|
127
|
+
// Degrade gracefully there rather than crashing module load: the metrics are
|
|
128
|
+
// simply not registered.
|
|
129
|
+
const eventLoopHistogram = (() => {
|
|
130
|
+
try {
|
|
131
|
+
const histogram = monitorEventLoopDelay({ resolution: 20 });
|
|
132
|
+
histogram.enable();
|
|
133
|
+
return histogram;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
126
138
|
|
|
127
139
|
const eventLoopLagP50 = meter.createObservableGauge(
|
|
128
140
|
"publisher_event_loop_lag_p50_ms",
|
|
@@ -150,21 +162,25 @@ const eventLoopLagMax = meter.createObservableGauge(
|
|
|
150
162
|
);
|
|
151
163
|
|
|
152
164
|
// Sample all three in one batch so the histogram reset can't race the reads.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
);
|
|
165
|
+
// Skipped entirely when the runtime lacks event-loop-delay monitoring.
|
|
166
|
+
if (eventLoopHistogram) {
|
|
167
|
+
const histogram = eventLoopHistogram;
|
|
168
|
+
meter.addBatchObservableCallback(
|
|
169
|
+
(observableResult) => {
|
|
170
|
+
observableResult.observe(
|
|
171
|
+
eventLoopLagP50,
|
|
172
|
+
histogram.percentile(50) / 1e6,
|
|
173
|
+
);
|
|
174
|
+
observableResult.observe(
|
|
175
|
+
eventLoopLagP99,
|
|
176
|
+
histogram.percentile(99) / 1e6,
|
|
177
|
+
);
|
|
178
|
+
observableResult.observe(eventLoopLagMax, histogram.max / 1e6);
|
|
179
|
+
histogram.reset();
|
|
180
|
+
},
|
|
181
|
+
[eventLoopLagP50, eventLoopLagP99, eventLoopLagMax],
|
|
182
|
+
);
|
|
183
|
+
}
|
|
168
184
|
|
|
169
185
|
const IGNORED_PATHS = new Set([
|
|
170
186
|
"/health",
|