@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.
- package/build.ts +10 -1
- package/dist/app/api-doc.yaml +146 -0
- package/dist/app/assets/{EnvironmentPage-BVQ7glKP.js → EnvironmentPage-CAge6UHD.js} +1 -1
- package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
- package/dist/app/assets/{MainPage-bYOWcgDP.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-LRqQWrFY.js +1 -0
- package/dist/app/assets/{RouteError-_J-EBz7W.js → RouteError-xT6kuCNw.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Bjs9Nm-_.js → WorkbookPage-DsIh9svZ.js} +1 -1
- package/dist/app/assets/{core-BPLlx5VM.es-C2ARtwWI.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
- package/dist/app/assets/{index-CqUWJELr.js → index-BdOZDcce.js} +2 -2
- package/dist/app/assets/index-DHHAcY5o.js +1812 -0
- package/dist/app/assets/index-RX3QOTde.js +455 -0
- package/dist/app/assets/index.umd-D2WH3D-f.js +2469 -0
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +392 -67
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +982 -346
- package/package.json +15 -14
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/ducklake_version.spec.ts +43 -0
- package/src/ducklake_version.ts +26 -0
- package/src/errors.spec.ts +21 -0
- package/src/errors.ts +18 -1
- 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 +0 -5
- package/src/package_load/package_load_worker.ts +41 -99
- package/src/package_load/protocol.ts +1 -7
- package/src/runtime/publisher.js +318 -0
- package/src/server.ts +479 -2
- package/src/service/annotations.spec.ts +118 -0
- package/src/service/annotations.ts +91 -0
- package/src/service/authorize.spec.ts +132 -0
- package/src/service/authorize.ts +241 -0
- package/src/service/authorize_integration.spec.ts +932 -0
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/connection.ts +1 -1
- package/src/service/environment.ts +67 -9
- package/src/service/environment_store.ts +142 -11
- package/src/service/filter.spec.ts +14 -3
- package/src/service/filter.ts +5 -1
- package/src/service/filter_bypass.spec.ts +418 -0
- package/src/service/given.ts +37 -12
- package/src/service/givens_integration.spec.ts +34 -7
- package/src/service/materialization_service.ts +25 -20
- package/src/service/materialized_table_gc.spec.ts +6 -5
- package/src/service/materialized_table_gc.ts +2 -50
- package/src/service/model.spec.ts +203 -8
- package/src/service/model.ts +349 -155
- package/src/service/package.ts +17 -6
- package/src/service/package_worker_path.spec.ts +113 -0
- package/src/service/quoting.ts +0 -20
- package/src/service/restricted_mode.spec.ts +299 -0
- package/src/service/source_extraction.ts +226 -0
- package/src/storage/StorageManager.ts +73 -0
- 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-D9drXoZX.js +0 -1
- package/dist/app/assets/ModelPage-DT0gjNy1.js +0 -1
- package/dist/app/assets/PackagePage-N1ZBNJul.js +0 -1
- package/dist/app/assets/index-BeNwIeYQ.js +0 -454
- package/dist/app/assets/index-Dx7qi2LO.js +0 -1803
- 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.
|
|
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
|
|
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),
|
|
@@ -736,7 +794,7 @@ export class Environment {
|
|
|
736
794
|
);
|
|
737
795
|
if (existingPackage !== undefined && reload) {
|
|
738
796
|
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
739
|
-
existingPackage.getMalloyConfig().
|
|
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().
|
|
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().
|
|
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().
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
|
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 }
|
|
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
|
|
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
|
});
|
package/src/service/filter.ts
CHANGED
|
@@ -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()}
|
|
288
|
+
return `${query.trimEnd()}\n+ {where: ${filterClause}}`;
|
|
285
289
|
}
|
|
286
290
|
|
|
287
291
|
// ---------------------------------------------------------------------------
|