@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.
Files changed (55) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +133 -4
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-CAge6UHD.js} +1 -1
  4. package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-CeTxxGex.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-CpDHB70t.js +1 -0
  7. package/dist/app/assets/ModelPage-D9sSMb75.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-LRqQWrFY.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-xT6kuCNw.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-DsIh9svZ.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BdOZDcce.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-DHHAcY5o.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-RX3QOTde.js} +121 -121
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-D2WH3D-f.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/runtime/publisher.js +318 -0
  18. package/dist/server.mjs +567 -194
  19. package/package.json +5 -4
  20. package/scripts/bake-duckdb-extensions.js +104 -0
  21. package/src/controller/watch-mode.controller.ts +176 -46
  22. package/src/errors.spec.ts +21 -0
  23. package/src/mcp/error_messages.spec.ts +35 -0
  24. package/src/mcp/error_messages.ts +14 -1
  25. package/src/mcp/handler_utils.ts +12 -0
  26. package/src/runtime/publisher.js +318 -0
  27. package/src/server.ts +479 -2
  28. package/src/service/authorize_integration.spec.ts +96 -2
  29. package/src/service/compile_authorize.spec.ts +85 -0
  30. package/src/service/environment.ts +63 -5
  31. package/src/service/environment_store.ts +142 -11
  32. package/src/service/model.ts +44 -0
  33. package/src/service/package.ts +17 -6
  34. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  35. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  36. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  37. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  38. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  39. package/tests/fixtures/html-pages-test/data.csv +3 -0
  40. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  41. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  42. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  43. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  44. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  45. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  46. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  47. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  48. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  49. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  50. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  51. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  52. package/tests/unit/duckdb/repositories.test.ts +208 -0
  53. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  54. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  55. 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 resolve correctly.
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 virtualUri = `file://${path.join(modelDir, "__compile_check.malloy")}`;
313
- const virtualUrl = new URL(virtualUri);
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
- sourcePath = _package.location;
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
- // Copy the specific directory
1221
- await fs.promises.mkdir(absolutePackagePath, {
1222
- recursive: true,
1223
- });
1224
- await fs.promises.cp(sourcePath, absolutePackagePath, {
1225
- recursive: true,
1226
- });
1227
- logger.info(
1228
- `Extracted package "${packageDir}" from ${groupedLocation.startsWith("https://github.com/") && _package.location.includes("/tree/") ? "GitHub subdirectory" : "shared download"}`,
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
  });
@@ -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
@@ -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.rm(packagePath, {
144
- recursive: true,
145
- force: true,
146
- });
147
- logger.info(`Cleaned up failed package directory: ${packagePath}`);
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,