@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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { AccessDeniedError } from "../errors";
|
|
6
|
+
import { Environment } from "./environment";
|
|
7
|
+
|
|
8
|
+
// End-to-end gate on the /compile path. Exercises environment.compileSource
|
|
9
|
+
// through a real installed package, not just the Model primitives — pins that
|
|
10
|
+
// the early gate AND the compiled-source backstop fire, the latter REGARDLESS of
|
|
11
|
+
// includeSql (a compile-time schema oracle is closed even with no SQL extraction).
|
|
12
|
+
|
|
13
|
+
const PUBLISHER_JSON = JSON.stringify({
|
|
14
|
+
name: "pkg",
|
|
15
|
+
description: "compile-gate",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// `gated` is locked to $ROLE='analyst'; `open_src` is unrestricted.
|
|
19
|
+
const MODEL = `##! experimental.givens
|
|
20
|
+
|
|
21
|
+
given:
|
|
22
|
+
ROLE :: string
|
|
23
|
+
|
|
24
|
+
#(authorize) "$ROLE = 'analyst'"
|
|
25
|
+
source: gated is duckdb.sql("SELECT 1 as x") extend { measure: c is count() }
|
|
26
|
+
|
|
27
|
+
source: open_src is duckdb.sql("SELECT 1 as x") extend { measure: c is count() }
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
describe("compile-path authorize gate (compileSource)", () => {
|
|
31
|
+
let rootDir: string;
|
|
32
|
+
let env: Environment;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-compile-"));
|
|
36
|
+
const envPath = path.join(rootDir, "env");
|
|
37
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
38
|
+
env = await Environment.create("testEnv", envPath, []);
|
|
39
|
+
await env.installPackage("pkg", async (stagingPath) => {
|
|
40
|
+
await fs.mkdir(stagingPath, { recursive: true });
|
|
41
|
+
await fs.writeFile(
|
|
42
|
+
path.join(stagingPath, "publisher.json"),
|
|
43
|
+
PUBLISHER_JSON,
|
|
44
|
+
);
|
|
45
|
+
await fs.writeFile(path.join(stagingPath, "model.malloy"), MODEL);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const compile = (source: string, givens?: Record<string, string>) =>
|
|
54
|
+
env.compileSource("pkg", "model.malloy", source, false, givens);
|
|
55
|
+
|
|
56
|
+
it("denies a direct gated source without the satisfying given (early gate)", async () => {
|
|
57
|
+
await expect(
|
|
58
|
+
compile("run: gated -> { aggregate: c }"),
|
|
59
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("denies a gated source reached via the LAST run: statement (backstop, includeSql=false)", async () => {
|
|
63
|
+
// Regression guard: the early gate only matches the first `run:` (ungated
|
|
64
|
+
// open_src here), so the gated source in the executed final statement is
|
|
65
|
+
// caught only by the compiled-source backstop — which must run even when
|
|
66
|
+
// no SQL is requested.
|
|
67
|
+
await expect(
|
|
68
|
+
compile(
|
|
69
|
+
"run: open_src -> { aggregate: c }\nrun: gated -> { aggregate: c }",
|
|
70
|
+
),
|
|
71
|
+
).rejects.toBeInstanceOf(AccessDeniedError);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("allows the gated source when the given satisfies the gate", async () => {
|
|
75
|
+
const { problems } = await compile("run: gated -> { aggregate: c }", {
|
|
76
|
+
ROLE: "analyst",
|
|
77
|
+
});
|
|
78
|
+
expect(problems).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("leaves an ungated source compilable without any given", async () => {
|
|
82
|
+
const { problems } = await compile("run: open_src -> { aggregate: c }");
|
|
83
|
+
expect(problems).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -5,6 +5,7 @@ import { Mutex } from "async-mutex";
|
|
|
5
5
|
import crypto from "crypto";
|
|
6
6
|
import * as fs from "fs";
|
|
7
7
|
import * as path from "path";
|
|
8
|
+
import { pathToFileURL } from "url";
|
|
8
9
|
import { components } from "../api";
|
|
9
10
|
import { API_PREFIX, README_NAME } from "../constants";
|
|
10
11
|
import {
|
|
@@ -137,6 +138,11 @@ export class Environment {
|
|
|
137
138
|
// governor as the single owner of the back-pressure boolean.
|
|
138
139
|
private memoryGovernor: PackageMemoryGovernor | null = null;
|
|
139
140
|
|
|
141
|
+
/** Absolute path on disk where this environment's package files live. */
|
|
142
|
+
public getEnvironmentPath(): string {
|
|
143
|
+
return this.environmentPath;
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
constructor(
|
|
141
147
|
environmentName: string,
|
|
142
148
|
environmentPath: string,
|
|
@@ -307,10 +313,18 @@ export class Environment {
|
|
|
307
313
|
packageName,
|
|
308
314
|
modelName,
|
|
309
315
|
);
|
|
310
|
-
// Place the virtual file in the model's directory so relative imports
|
|
316
|
+
// Place the virtual file in the model's directory so relative imports
|
|
317
|
+
// resolve correctly. Use `pathToFileURL` rather than hand-prefixing
|
|
318
|
+
// `file://`: on Windows the latter produces a malformed URL
|
|
319
|
+
// (`file://D:\Temp\…`) that round-trips differently than the URL the
|
|
320
|
+
// Malloy runtime synthesizes from the same path, breaking the
|
|
321
|
+
// intercepting reader's string comparison below and falling through
|
|
322
|
+
// to disk for a virtual file that doesn't exist.
|
|
311
323
|
const modelDir = path.dirname(modelPath);
|
|
312
|
-
const
|
|
313
|
-
|
|
324
|
+
const virtualUrl = pathToFileURL(
|
|
325
|
+
path.join(modelDir, "__compile_check.malloy"),
|
|
326
|
+
);
|
|
327
|
+
const virtualUri = virtualUrl.toString();
|
|
314
328
|
|
|
315
329
|
// Read the full model file so the submitted source inherits the model's
|
|
316
330
|
// complete namespace — imports, source definitions, queries, etc.
|
|
@@ -339,6 +353,21 @@ export class Environment {
|
|
|
339
353
|
// Use the locked variant — we already hold the per-package mutex.
|
|
340
354
|
const pkg = await this._loadOrGetPackageLocked(packageName);
|
|
341
355
|
|
|
356
|
+
// Authorize gate: /compile is compile-only, but it can still act
|
|
357
|
+
// as a schema oracle (a denied caller learns a gated source's columns
|
|
358
|
+
// from compile errors) and, with includeSql, leak its SQL. Gate the
|
|
359
|
+
// named source the submitted text targets BEFORE compiling — mirrors
|
|
360
|
+
// the query path's early surface-syntax gate. Unnamed/inline source
|
|
361
|
+
// text resolves to undefined, so only the model-wide file-level gate
|
|
362
|
+
// applies. The gate runs against the package's cached Model (its
|
|
363
|
+
// `given:` block + authorize annotations), independent of the virtual
|
|
364
|
+
// compile below. If the model isn't loaded, there's nothing to enforce
|
|
365
|
+
// and compilation surfaces its own error.
|
|
366
|
+
const gateModel = pkg.getModel(modelName);
|
|
367
|
+
if (gateModel) {
|
|
368
|
+
await gateModel.assertAuthorizedForText(source, givens ?? {});
|
|
369
|
+
}
|
|
370
|
+
|
|
342
371
|
// Initialize Runtime with the package's active MalloyConfig so compile
|
|
343
372
|
// checks see the same package-scoped duckdb as execution. This runtime
|
|
344
373
|
// borrows the package config; the package/environment lifecycle owns release.
|
|
@@ -352,11 +381,40 @@ export class Environment {
|
|
|
352
381
|
const modelMaterializer = runtime.loadModel(virtualUrl);
|
|
353
382
|
const model = await modelMaterializer.getModel();
|
|
354
383
|
|
|
384
|
+
// Resolve the final query's materializer once (if there is one).
|
|
385
|
+
let queryMaterializer: ReturnType<
|
|
386
|
+
typeof modelMaterializer.loadFinalQuery
|
|
387
|
+
> | null = null;
|
|
388
|
+
try {
|
|
389
|
+
queryMaterializer = modelMaterializer.loadFinalQuery();
|
|
390
|
+
} catch {
|
|
391
|
+
// No runnable query (e.g. only source definitions) — nothing to
|
|
392
|
+
// gate or extract beyond the early text gate already applied.
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Compiled-source backstop — runs REGARDLESS of includeSql. It gates
|
|
396
|
+
// the source the COMPILED final query actually reads, closing
|
|
397
|
+
// named-query / multi-statement indirection the early surface-syntax
|
|
398
|
+
// gate misses (e.g. `run: ungated\nrun: gated` — the early gate only
|
|
399
|
+
// matches the FIRST `run:`, but the LAST statement is what executes).
|
|
400
|
+
// Compiling a gated source even without SQL is a schema oracle
|
|
401
|
+
// (field-not-found errors leak its columns), so this must not be
|
|
402
|
+
// conditional on SQL extraction. (A `source: x is gated` alias makes
|
|
403
|
+
// a new ungated source — that's the documented "extend doesn't
|
|
404
|
+
// inherit authorize" footgun, the same as the query path.) Only run
|
|
405
|
+
// when the model declares gates so ungated compiles don't pay for
|
|
406
|
+
// the extra final-query compile.
|
|
407
|
+
if (queryMaterializer && gateModel?.hasAuthorize()) {
|
|
408
|
+
await gateModel.assertAuthorizedForRunnable(
|
|
409
|
+
queryMaterializer,
|
|
410
|
+
givens ?? {},
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
355
414
|
// If includeSql is requested and compilation succeeded, attempt to extract SQL
|
|
356
415
|
let sql: string | undefined;
|
|
357
|
-
if (includeSql) {
|
|
416
|
+
if (includeSql && queryMaterializer) {
|
|
358
417
|
try {
|
|
359
|
-
const queryMaterializer = modelMaterializer.loadFinalQuery();
|
|
360
418
|
sql = await queryMaterializer.getSQL({ givens });
|
|
361
419
|
} catch {
|
|
362
420
|
// Source may not contain a runnable query (e.g. only source definitions),
|
|
@@ -95,6 +95,26 @@ function validateEnvironmentAzureUrls(environment: ApiEnvironment): void {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Safely clear a prior mount target before (re)mounting at `targetPath`.
|
|
99
|
+
// Distinguishes symlinks from real dirs: fs.rm's recursive mode could otherwise
|
|
100
|
+
// traverse into a symlinked source and damage it, so unlink symlinks and rm real
|
|
101
|
+
// dirs. Both the in-place (symlink) and copy mount paths call this, so toggling
|
|
102
|
+
// watch mode across runs against the same publisher_data can't leave a stale
|
|
103
|
+
// entry that breaks the next mount (mkdir is a no-op on a symlink and fs.cp then
|
|
104
|
+
// throws ERR_FS_CP_DIR_TO_NON_DIR).
|
|
105
|
+
async function clearMountTarget(targetPath: string): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
const stats = await fs.promises.lstat(targetPath);
|
|
108
|
+
if (stats.isDirectory() && !stats.isSymbolicLink()) {
|
|
109
|
+
await fs.promises.rm(targetPath, { recursive: true, force: true });
|
|
110
|
+
} else {
|
|
111
|
+
await fs.promises.unlink(targetPath);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Nothing there, fine.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
export class EnvironmentStore {
|
|
99
119
|
public serverRootPath: string;
|
|
100
120
|
private environments: Map<string, Environment> = new Map();
|
|
@@ -112,10 +132,36 @@ export class EnvironmentStore {
|
|
|
112
132
|
// new Environments pick it up at construction.
|
|
113
133
|
private memoryGovernor: PackageMemoryGovernor | null = null;
|
|
114
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Set of environment names that should be loaded "in place" — i.e. the
|
|
137
|
+
* package directories under `publisher_data/<envName>/` are symlinks to
|
|
138
|
+
* the original local source directories instead of recursive copies.
|
|
139
|
+
*
|
|
140
|
+
* In-place mode powers the `npm run dev`-style live-reload story: edits
|
|
141
|
+
* to your source repo are visible to Publisher and trigger watch events
|
|
142
|
+
* that fan out via SSE to embedded HTML pages. Production mode (the
|
|
143
|
+
* default) keeps the safe copy semantics so the source repo and the
|
|
144
|
+
* served package are decoupled.
|
|
145
|
+
*
|
|
146
|
+
* Populated from the `PUBLISHER_WATCH` env var (comma-separated env
|
|
147
|
+
* names) at construction; can also be augmented via `markInPlace()` if
|
|
148
|
+
* a future feature wants to flip an environment to dev mode at runtime.
|
|
149
|
+
*
|
|
150
|
+
* Only LOCAL-DIR package locations are eligible for symlinking; remote
|
|
151
|
+
* sources (GitHub, GCS, S3) always copy regardless.
|
|
152
|
+
*/
|
|
153
|
+
private inPlaceEnvs = new Set<string>();
|
|
154
|
+
|
|
115
155
|
constructor(serverRootPath: string) {
|
|
116
156
|
this.serverRootPath = serverRootPath;
|
|
117
157
|
this.gcsClient = new Storage();
|
|
118
158
|
|
|
159
|
+
const watchEnvList = (process.env.PUBLISHER_WATCH || "")
|
|
160
|
+
.split(",")
|
|
161
|
+
.map((s) => s.trim())
|
|
162
|
+
.filter((s) => s.length > 0);
|
|
163
|
+
for (const envName of watchEnvList) this.inPlaceEnvs.add(envName);
|
|
164
|
+
|
|
119
165
|
const storageConfig: StorageConfig = {
|
|
120
166
|
type: "duckdb",
|
|
121
167
|
duckdb: {
|
|
@@ -127,6 +173,17 @@ export class EnvironmentStore {
|
|
|
127
173
|
this.finishedInitialization = this.initialize();
|
|
128
174
|
}
|
|
129
175
|
|
|
176
|
+
/** True if this env should mount package dirs in place (symlinks). */
|
|
177
|
+
public isInPlace(environmentName: string): boolean {
|
|
178
|
+
return this.inPlaceEnvs.has(environmentName);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Add an env to in-place mode at runtime. Caller is responsible for
|
|
182
|
+
* triggering a re-mount if the env was already loaded with copies. */
|
|
183
|
+
public markInPlace(environmentName: string): void {
|
|
184
|
+
this.inPlaceEnvs.add(environmentName);
|
|
185
|
+
}
|
|
186
|
+
|
|
130
187
|
/**
|
|
131
188
|
* Attach (or detach with `null`) the shared {@link PackageMemoryGovernor}.
|
|
132
189
|
* Propagated to every Environment so the back-pressure decision is
|
|
@@ -1202,7 +1259,15 @@ export class EnvironmentStore {
|
|
|
1202
1259
|
} else {
|
|
1203
1260
|
// For non-GitHub locations, use package name
|
|
1204
1261
|
if (this.isLocalPath(_package.location)) {
|
|
1205
|
-
|
|
1262
|
+
// Match the resolution rule used by
|
|
1263
|
+
// `downloadOrMountLocation` (line ~1352): relative
|
|
1264
|
+
// paths are anchored at `serverRootPath`. Without this
|
|
1265
|
+
// step the existing-source check below falls through
|
|
1266
|
+
// for any relative location, and the in-place mount
|
|
1267
|
+
// branch is unreachable.
|
|
1268
|
+
sourcePath = path.isAbsolute(_package.location)
|
|
1269
|
+
? _package.location
|
|
1270
|
+
: path.join(this.serverRootPath, _package.location);
|
|
1206
1271
|
} else {
|
|
1207
1272
|
sourcePath = safeJoinUnderRoot(
|
|
1208
1273
|
tempDownloadPath,
|
|
@@ -1217,18 +1282,84 @@ export class EnvironmentStore {
|
|
|
1217
1282
|
.catch(() => false);
|
|
1218
1283
|
|
|
1219
1284
|
if (sourceExists) {
|
|
1220
|
-
//
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1285
|
+
// In-place mount path (watch-mode-only, local-dir packages
|
|
1286
|
+
// only). Replace the recursive copy with a symlink so that
|
|
1287
|
+
// edits to the source repo are visible to Publisher and
|
|
1288
|
+
// can trigger live-reload events.
|
|
1289
|
+
//
|
|
1290
|
+
// Carefully: we MUST distinguish symlinks from real dirs
|
|
1291
|
+
// when removing the prior content, because `fs.rm`'s
|
|
1292
|
+
// recursive mode could in principle traverse into a
|
|
1293
|
+
// symlinked source dir and damage it. We lstat first and
|
|
1294
|
+
// call `unlink` for symlinks, `rm` for real directories.
|
|
1295
|
+
const isInPlace =
|
|
1296
|
+
this.inPlaceEnvs.has(environmentName) &&
|
|
1297
|
+
this.isLocalPath(_package.location);
|
|
1298
|
+
if (isInPlace) {
|
|
1299
|
+
await clearMountTarget(absolutePackagePath);
|
|
1300
|
+
const absoluteSourcePath = path.resolve(sourcePath);
|
|
1301
|
+
// On Windows a "dir" symlink needs elevation
|
|
1302
|
+
// (SeCreateSymbolicLinkPrivilege — admin or Developer
|
|
1303
|
+
// Mode); a junction does not and works for absolute
|
|
1304
|
+
// directory targets, so prefer it there. chokidar watches
|
|
1305
|
+
// through both.
|
|
1306
|
+
const linkType =
|
|
1307
|
+
process.platform === "win32" ? "junction" : "dir";
|
|
1308
|
+
try {
|
|
1309
|
+
await fs.promises.symlink(
|
|
1310
|
+
absoluteSourcePath,
|
|
1311
|
+
absolutePackagePath,
|
|
1312
|
+
linkType,
|
|
1313
|
+
);
|
|
1314
|
+
logger.info(
|
|
1315
|
+
`In-place mount (watch mode): linked package "${packageDir}" -> "${absoluteSourcePath}"`,
|
|
1316
|
+
);
|
|
1317
|
+
} catch (linkError) {
|
|
1318
|
+
// Degrade gracefully instead of failing the env load:
|
|
1319
|
+
// copy the package so it still serves. Live reload
|
|
1320
|
+
// won't work for it (source edits aren't seen), but the
|
|
1321
|
+
// environment comes up. Hit e.g. on locked-down Windows
|
|
1322
|
+
// where even junction creation is denied.
|
|
1323
|
+
const code =
|
|
1324
|
+
(linkError as NodeJS.ErrnoException)?.code ??
|
|
1325
|
+
String(linkError);
|
|
1326
|
+
logger.warn(
|
|
1327
|
+
`In-place mount failed for package "${packageDir}" (${code}); falling back to a copy. Source-edit live reload is disabled for this package.`,
|
|
1328
|
+
);
|
|
1329
|
+
await clearMountTarget(absolutePackagePath);
|
|
1330
|
+
await fs.promises.mkdir(absolutePackagePath, {
|
|
1331
|
+
recursive: true,
|
|
1332
|
+
});
|
|
1333
|
+
await fs.promises.cp(sourcePath, absolutePackagePath, {
|
|
1334
|
+
recursive: true,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
} else {
|
|
1338
|
+
if (
|
|
1339
|
+
this.inPlaceEnvs.has(environmentName) &&
|
|
1340
|
+
!this.isLocalPath(_package.location)
|
|
1341
|
+
) {
|
|
1342
|
+
logger.warn(
|
|
1343
|
+
`Watch mode: package "${packageDir}" has remote location "${_package.location}" — falling back to copy. Source-edit live reload won't work for this package; clone the source locally and use a local-dir location to enable it.`,
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
// Copy the specific directory. Clear any stale mount target
|
|
1347
|
+
// first (e.g. a symlink left by a prior --watch-env run), or
|
|
1348
|
+
// fs.cp would throw ERR_FS_CP_DIR_TO_NON_DIR onto it.
|
|
1349
|
+
await clearMountTarget(absolutePackagePath);
|
|
1350
|
+
await fs.promises.mkdir(absolutePackagePath, {
|
|
1351
|
+
recursive: true,
|
|
1352
|
+
});
|
|
1353
|
+
await fs.promises.cp(sourcePath, absolutePackagePath, {
|
|
1354
|
+
recursive: true,
|
|
1355
|
+
});
|
|
1356
|
+
logger.info(
|
|
1357
|
+
`Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"}`,
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1230
1360
|
} else {
|
|
1231
1361
|
// If source doesn't exist, copy the entire download as the package
|
|
1362
|
+
await clearMountTarget(absolutePackagePath);
|
|
1232
1363
|
await fs.promises.mkdir(absolutePackagePath, {
|
|
1233
1364
|
recursive: true,
|
|
1234
1365
|
});
|
package/src/service/model.ts
CHANGED
|
@@ -232,6 +232,18 @@ export class Model {
|
|
|
232
232
|
);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Whether the model declares any `#(authorize)` / `##(authorize)` gate at all
|
|
237
|
+
* (file-level or on any source). Lets callers cheaply skip authorize work for
|
|
238
|
+
* ungated models without compiling a probe.
|
|
239
|
+
*/
|
|
240
|
+
public hasAuthorize(): boolean {
|
|
241
|
+
return (
|
|
242
|
+
this.fileLevelAuthorize.length > 0 ||
|
|
243
|
+
(this.sources?.some((s) => (s.authorize?.length ?? 0) > 0) ?? false)
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
235
247
|
/**
|
|
236
248
|
* Effective authorize expressions for whatever a query runs against:
|
|
237
249
|
* - a declared model source → its own list (file-level ++ source-level);
|
|
@@ -288,6 +300,38 @@ export class Model {
|
|
|
288
300
|
if (!passed) deny();
|
|
289
301
|
}
|
|
290
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Gate ad-hoc compile/query text by the named source it targets. Resolves the
|
|
305
|
+
* source from surface syntax (`extractSourceName`) and applies the gate. An
|
|
306
|
+
* unnamed/inline source resolves to `undefined`, so only the model-wide
|
|
307
|
+
* file-level gate applies — the same top-level-only boundary as the query
|
|
308
|
+
* path's early gate. Used by the `/compile` path, which has no runnable to
|
|
309
|
+
* resolve before it decides whether to compile at all.
|
|
310
|
+
*/
|
|
311
|
+
public async assertAuthorizedForText(
|
|
312
|
+
text: string,
|
|
313
|
+
givens: Record<string, GivenValue>,
|
|
314
|
+
): Promise<void> {
|
|
315
|
+
await this.assertAuthorized(this.extractSourceName(text), givens);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Gate a compiled query by the source it actually reads, resolved from the
|
|
320
|
+
* prepared query's `structRef` (authoritative — survives named-query and
|
|
321
|
+
* multi-statement indirection that surface syntax misses, e.g. the executed
|
|
322
|
+
* `run:` statement isn't the first one). Used as the `/compile` backstop once
|
|
323
|
+
* a runnable exists.
|
|
324
|
+
*/
|
|
325
|
+
public async assertAuthorizedForRunnable(
|
|
326
|
+
runnable: { getPreparedQuery(): Promise<unknown> },
|
|
327
|
+
givens: Record<string, GivenValue>,
|
|
328
|
+
): Promise<void> {
|
|
329
|
+
await this.assertAuthorized(
|
|
330
|
+
await this.resolveAuthorizeSourceFromRunnable(runnable),
|
|
331
|
+
givens,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
291
335
|
/**
|
|
292
336
|
* Resolve the source a compiled query reads, from its prepared query's
|
|
293
337
|
* `structRef`. This is authoritative — it survives named-query indirection
|
package/src/service/package.ts
CHANGED
|
@@ -138,13 +138,24 @@ export class Package {
|
|
|
138
138
|
malloy_package_name: packageName,
|
|
139
139
|
status,
|
|
140
140
|
});
|
|
141
|
-
// Clean up package directory on failure
|
|
141
|
+
// Clean up the package directory on failure, but NOT when packagePath
|
|
142
|
+
// is an in-place mount symlink (watch mode). Removing it would unmount
|
|
143
|
+
// the package, so a transient compile error from a half-typed model
|
|
144
|
+
// saved mid-edit would brick the package until a restart. The symlink
|
|
145
|
+
// points at the user's live source, which is left untouched; the next
|
|
146
|
+
// save recompiles against it.
|
|
142
147
|
try {
|
|
143
|
-
await fs.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
const stat = await fs.lstat(packagePath).catch(() => null);
|
|
149
|
+
if (stat?.isSymbolicLink()) {
|
|
150
|
+
logger.info(
|
|
151
|
+
`Skipping cleanup of symlinked package path on failure: ${packagePath}`,
|
|
152
|
+
);
|
|
153
|
+
} else {
|
|
154
|
+
await fs.rm(packagePath, { recursive: true, force: true });
|
|
155
|
+
logger.info(
|
|
156
|
+
`Cleaned up failed package directory: ${packagePath}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
148
159
|
} catch (cleanupError) {
|
|
149
160
|
logger.warn(`Failed to clean up package directory ${packagePath}`, {
|
|
150
161
|
error: cleanupError,
|