@malloy-publisher/server 0.0.203 → 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 (84) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +146 -0
  3. package/dist/app/assets/{EnvironmentPage-BVQ7glKP.js → EnvironmentPage-CAge6UHD.js} +1 -1
  4. package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
  5. package/dist/app/assets/{MainPage-bYOWcgDP.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-LRqQWrFY.js +1 -0
  9. package/dist/app/assets/{RouteError-_J-EBz7W.js → RouteError-xT6kuCNw.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-Bjs9Nm-_.js → WorkbookPage-DsIh9svZ.js} +1 -1
  11. package/dist/app/assets/{core-BPLlx5VM.es-C2ARtwWI.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
  12. package/dist/app/assets/{index-CqUWJELr.js → index-BdOZDcce.js} +2 -2
  13. package/dist/app/assets/index-DHHAcY5o.js +1812 -0
  14. package/dist/app/assets/index-RX3QOTde.js +455 -0
  15. package/dist/app/assets/index.umd-D2WH3D-f.js +2469 -0
  16. package/dist/app/index.html +1 -1
  17. package/dist/package_load_worker.mjs +392 -67
  18. package/dist/runtime/publisher.js +318 -0
  19. package/dist/server.mjs +982 -346
  20. package/package.json +15 -14
  21. package/scripts/bake-duckdb-extensions.js +104 -0
  22. package/src/controller/watch-mode.controller.ts +176 -46
  23. package/src/ducklake_version.spec.ts +43 -0
  24. package/src/ducklake_version.ts +26 -0
  25. package/src/errors.spec.ts +21 -0
  26. package/src/errors.ts +18 -1
  27. package/src/mcp/error_messages.spec.ts +35 -0
  28. package/src/mcp/error_messages.ts +14 -1
  29. package/src/mcp/handler_utils.ts +12 -0
  30. package/src/package_load/package_load_pool.ts +0 -5
  31. package/src/package_load/package_load_worker.ts +41 -99
  32. package/src/package_load/protocol.ts +1 -7
  33. package/src/runtime/publisher.js +318 -0
  34. package/src/server.ts +479 -2
  35. package/src/service/annotations.spec.ts +118 -0
  36. package/src/service/annotations.ts +91 -0
  37. package/src/service/authorize.spec.ts +132 -0
  38. package/src/service/authorize.ts +241 -0
  39. package/src/service/authorize_integration.spec.ts +932 -0
  40. package/src/service/compile_authorize.spec.ts +85 -0
  41. package/src/service/connection.ts +1 -1
  42. package/src/service/environment.ts +67 -9
  43. package/src/service/environment_store.ts +142 -11
  44. package/src/service/filter.spec.ts +14 -3
  45. package/src/service/filter.ts +5 -1
  46. package/src/service/filter_bypass.spec.ts +418 -0
  47. package/src/service/given.ts +37 -12
  48. package/src/service/givens_integration.spec.ts +34 -7
  49. package/src/service/materialization_service.ts +25 -20
  50. package/src/service/materialized_table_gc.spec.ts +6 -5
  51. package/src/service/materialized_table_gc.ts +2 -50
  52. package/src/service/model.spec.ts +203 -8
  53. package/src/service/model.ts +349 -155
  54. package/src/service/package.ts +17 -6
  55. package/src/service/package_worker_path.spec.ts +113 -0
  56. package/src/service/quoting.ts +0 -20
  57. package/src/service/restricted_mode.spec.ts +299 -0
  58. package/src/service/source_extraction.ts +226 -0
  59. package/src/storage/StorageManager.ts +73 -0
  60. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  61. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  62. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  63. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  64. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  65. package/tests/fixtures/html-pages-test/data.csv +3 -0
  66. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  67. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  68. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  69. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  70. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  72. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  73. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  74. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  75. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  76. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  77. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  78. package/tests/unit/duckdb/repositories.test.ts +208 -0
  79. package/dist/app/assets/HomePage-D9drXoZX.js +0 -1
  80. package/dist/app/assets/ModelPage-DT0gjNy1.js +0 -1
  81. package/dist/app/assets/PackagePage-N1ZBNJul.js +0 -1
  82. package/dist/app/assets/index-BeNwIeYQ.js +0 -454
  83. package/dist/app/assets/index-Dx7qi2LO.js +0 -1803
  84. package/dist/app/assets/index.umd-BXm2lnUO.js +0 -1145
@@ -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
+ });
@@ -1084,7 +1084,7 @@ export function buildEnvironmentMalloyConfig(
1084
1084
  ...azureDuckDBCache.values(),
1085
1085
  ];
1086
1086
  const closeResults = await Promise.allSettled([
1087
- malloyConfig.releaseConnections(),
1087
+ malloyConfig.shutdown("close"),
1088
1088
  ...wrapperPromises.map(async (promise) => {
1089
1089
  const connection = await promise;
1090
1090
  await connection.close();
@@ -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),
@@ -736,7 +794,7 @@ export class Environment {
736
794
  );
737
795
  if (existingPackage !== undefined && reload) {
738
796
  this.retireConnectionGeneration(`package ${packageName}`, () =>
739
- existingPackage.getMalloyConfig().releaseConnections(),
797
+ existingPackage.getMalloyConfig().shutdown("close"),
740
798
  );
741
799
  }
742
800
  this.packages.set(packageName, _package);
@@ -955,7 +1013,7 @@ export class Environment {
955
1013
 
956
1014
  if (oldPackage) {
957
1015
  this.retireConnectionGeneration(`package ${packageName}`, () =>
958
- oldPackage.getMalloyConfig().releaseConnections(),
1016
+ oldPackage.getMalloyConfig().shutdown("close"),
959
1017
  );
960
1018
  }
961
1019
 
@@ -1139,7 +1197,7 @@ export class Environment {
1139
1197
  // any in-flight queries that already acquired a connection finish
1140
1198
  // before the underlying duckdb handle is released.
1141
1199
  this.retireConnectionGeneration(`package ${packageName}`, () =>
1142
- _package.getMalloyConfig().releaseConnections(),
1200
+ _package.getMalloyConfig().shutdown("close"),
1143
1201
  );
1144
1202
 
1145
1203
  // Atomically rename the canonical tree out of the way so no reader
@@ -1235,7 +1293,7 @@ export class Environment {
1235
1293
  // they wrap. Without this, hard unload leaks per-package DuckDB handles.
1236
1294
  const packageReleases = await Promise.allSettled(
1237
1295
  Array.from(this.packages.values(), (pkg) =>
1238
- pkg.getMalloyConfig().releaseConnections(),
1296
+ pkg.getMalloyConfig().shutdown("close"),
1239
1297
  ),
1240
1298
  );
1241
1299
  for (const result of packageReleases) {
@@ -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
  });
@@ -423,7 +423,7 @@ describe("service/filter", () => {
423
423
  const query = "run: orders -> summary";
424
424
  const clause = "`status` = 'active'";
425
425
  expect(injectFilterRefinement(query, clause)).toBe(
426
- "run: orders -> summary + {where: `status` = 'active'}",
426
+ "run: orders -> summary\n+ {where: `status` = 'active'}",
427
427
  );
428
428
  });
429
429
 
@@ -432,7 +432,7 @@ describe("service/filter", () => {
432
432
  "run: orders -> { group_by: status; aggregate: order_count }";
433
433
  const clause = "`region` = 'US'";
434
434
  expect(injectFilterRefinement(query, clause)).toBe(
435
- "run: orders -> { group_by: status; aggregate: order_count } + {where: `region` = 'US'}",
435
+ "run: orders -> { group_by: status; aggregate: order_count }\n+ {where: `region` = 'US'}",
436
436
  );
437
437
  });
438
438
 
@@ -440,8 +440,19 @@ describe("service/filter", () => {
440
440
  const query = "run: orders -> summary \n ";
441
441
  const clause = "`status` = 'active'";
442
442
  expect(injectFilterRefinement(query, clause)).toBe(
443
- "run: orders -> summary + {where: `status` = 'active'}",
443
+ "run: orders -> summary\n+ {where: `status` = 'active'}",
444
444
  );
445
445
  });
446
+
447
+ it("places refinement on its own line so a trailing comment cannot swallow it", () => {
448
+ const clause = "`org_id` = 'acme'";
449
+ // A trailing line comment must not extend over the injected filter.
450
+ for (const comment of ["//", "-- sneaky"]) {
451
+ const query = `run: orders -> { group_by: status } ${comment}`;
452
+ const refined = injectFilterRefinement(query, clause);
453
+ const lastLine = refined.split("\n").pop() ?? "";
454
+ expect(lastLine).toBe(`+ {where: ${clause}}`);
455
+ }
456
+ });
446
457
  });
447
458
  });
@@ -272,6 +272,10 @@ export function buildFilterClause(
272
272
  * Append a filter refinement to a Malloy query string.
273
273
  * Uses Malloy's `+ {where: ...}` refinement syntax.
274
274
  *
275
+ * The refinement is placed on its own line so that a trailing line comment
276
+ * (`//` or `--`) on the caller's query cannot extend over it and neutralize
277
+ * the filter.
278
+ *
275
279
  * If `filterClause` is empty, returns the original query unchanged.
276
280
  */
277
281
  export function injectFilterRefinement(
@@ -281,7 +285,7 @@ export function injectFilterRefinement(
281
285
  if (!filterClause) {
282
286
  return query;
283
287
  }
284
- return `${query.trimEnd()} + {where: ${filterClause}}`;
288
+ return `${query.trimEnd()}\n+ {where: ${filterClause}}`;
285
289
  }
286
290
 
287
291
  // ---------------------------------------------------------------------------