@malloy-publisher/server 0.0.204 → 0.0.206
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.ts +10 -1
- package/dist/app/api-doc.yaml +494 -397
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
- package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +1703 -1443
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +33 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +66 -0
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/runtime/publisher.js +318 -0
- package/src/server-old.ts +7 -149
- package/src/server.ts +488 -190
- package/src/service/authorize_integration.spec.ts +163 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +142 -34
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +444 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +271 -20
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
- package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
- package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloy-publisher/server",
|
|
3
3
|
"description": "Malloy Publisher Server",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.206",
|
|
5
5
|
"main": "dist/server.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"malloy-publisher": "dist/server.mjs"
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
"test": "bun run test:unit && bun run test:integration",
|
|
18
18
|
"test:unit": "bun test --timeout 100000 src",
|
|
19
19
|
"test:integration": "bun test --timeout 200000 tests --max-workers=1",
|
|
20
|
-
"build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts",
|
|
21
|
-
"build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts",
|
|
20
|
+
"build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts && bun run bake-duckdb-extensions",
|
|
21
|
+
"build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts && bun run bake-duckdb-extensions",
|
|
22
|
+
"bake-duckdb-extensions": "bun scripts/bake-duckdb-extensions.js",
|
|
22
23
|
"start": "NODE_ENV=production bun run ./dist/server.mjs",
|
|
23
24
|
"start:init": "NODE_ENV=production bun run ./dist/server.mjs --init",
|
|
24
25
|
"start:dev": "NODE_ENV=development bun --watch src/server.ts",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"@aws-sdk/client-s3": "^3.958.0",
|
|
34
35
|
"@azure/identity": "^4.13.0",
|
|
35
36
|
"@azure/storage-blob": "^12.26.0",
|
|
37
|
+
"@duckdb/node-api": "1.5.3-r.2",
|
|
36
38
|
"@google-cloud/storage": "^7.16.0",
|
|
37
39
|
"@malloydata/db-bigquery": "^0.0.405",
|
|
38
40
|
"@malloydata/db-databricks": "^0.0.405",
|
|
@@ -58,7 +60,6 @@
|
|
|
58
60
|
"class-transformer": "^0.5.1",
|
|
59
61
|
"class-validator": "^0.14.1",
|
|
60
62
|
"cors": "^2.8.5",
|
|
61
|
-
"duckdb": "1.4.4",
|
|
62
63
|
"express": "^4.21.0",
|
|
63
64
|
"extract-zip": "^2.0.1",
|
|
64
65
|
"globals": "^15.9.0",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pre-download ("bake") the DuckDB extensions the server loads at runtime so
|
|
5
|
+
* they are on disk before any query runs.
|
|
6
|
+
*
|
|
7
|
+
* DuckDB fetches extensions on first use from extensions.duckdb.org and caches
|
|
8
|
+
* them under ~/.duckdb/extensions/v<version>/<platform>/. Some hosts cannot
|
|
9
|
+
* reach that CDN (notably the macOS GitHub Actions fleet), so an on-demand
|
|
10
|
+
* fetch at query time fails and takes the query (or a test) down. Baking
|
|
11
|
+
* populates the cache via the same @duckdb/node-api engine the runtime uses,
|
|
12
|
+
* so later INSTALL/LOAD calls are served from disk.
|
|
13
|
+
*
|
|
14
|
+
* Runs as the last step of this package's build (`build` / `build:server-only`),
|
|
15
|
+
* so local dev, CI, and the Docker builder all bake the same set. The Docker
|
|
16
|
+
* final stage copies the baked ~/.duckdb/extensions from the builder rather
|
|
17
|
+
* than re-baking, keeping a single bake mechanism.
|
|
18
|
+
*
|
|
19
|
+
* Each extension is baked independently: a failure (offline build, transient
|
|
20
|
+
* CDN error) is logged and skipped, never aborting the others or the build.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { DuckDBInstance } from "@duckdb/node-api";
|
|
24
|
+
|
|
25
|
+
// Every extension the connection layer (packages/server/src/service/connection.ts)
|
|
26
|
+
// and storage manager INSTALL/LOAD at runtime, for cloud attach, the per-package
|
|
27
|
+
// sandbox, federated-database attach, and the materialization catalog. Keep this
|
|
28
|
+
// in sync with the install sites in those files.
|
|
29
|
+
//
|
|
30
|
+
// `community: true` mirrors the runtime's `FORCE INSTALL '<name>' FROM community`
|
|
31
|
+
// (bigquery, snowflake); the rest are core extensions installed by name.
|
|
32
|
+
// `registered` is the name the extension reports in duckdb_extensions() when it
|
|
33
|
+
// differs from the INSTALL name (only postgres -> postgres_scanner).
|
|
34
|
+
const EXTENSIONS = [
|
|
35
|
+
{ name: "httpfs", community: false }, // cloud storage (gcs/s3/azure) + per-package sandbox
|
|
36
|
+
{ name: "aws", community: false }, // s3 credential chain
|
|
37
|
+
{ name: "azure", community: false }, // azure blob storage
|
|
38
|
+
{ name: "postgres", community: false, registered: "postgres_scanner" }, // postgres attach + ducklake postgres catalog
|
|
39
|
+
{ name: "ducklake", community: false }, // materialization catalog
|
|
40
|
+
{ name: "bigquery", community: true },
|
|
41
|
+
{ name: "snowflake", community: true },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
const instance = await DuckDBInstance.create(":memory:");
|
|
46
|
+
const connection = await instance.connect();
|
|
47
|
+
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const { name, community, registered } of EXTENSIONS) {
|
|
50
|
+
try {
|
|
51
|
+
const install = community
|
|
52
|
+
? `FORCE INSTALL '${name}' FROM community;`
|
|
53
|
+
: `INSTALL ${name};`;
|
|
54
|
+
await connection.run(`${install} LOAD ${name};`);
|
|
55
|
+
|
|
56
|
+
// Verify the extension actually reports as loaded, rather than trusting
|
|
57
|
+
// that INSTALL/LOAD returned without error.
|
|
58
|
+
const reader = await connection.runAndReadAll(
|
|
59
|
+
`SELECT loaded, installed FROM duckdb_extensions() WHERE extension_name = '${registered ?? name}';`,
|
|
60
|
+
);
|
|
61
|
+
const row = reader.getRowObjectsJS()[0];
|
|
62
|
+
const loaded = row?.loaded === true;
|
|
63
|
+
const installed = row?.installed === true;
|
|
64
|
+
|
|
65
|
+
results.push({ name, installed, loaded });
|
|
66
|
+
if (loaded) {
|
|
67
|
+
console.log(`baked DuckDB extension: ${name} (loaded)`);
|
|
68
|
+
} else {
|
|
69
|
+
console.warn(
|
|
70
|
+
`DuckDB extension "${name}" installed=${installed} but not loaded`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
} catch (err) {
|
|
74
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
+
results.push({
|
|
76
|
+
name,
|
|
77
|
+
installed: false,
|
|
78
|
+
loaded: false,
|
|
79
|
+
error: message,
|
|
80
|
+
});
|
|
81
|
+
console.warn(`skipped DuckDB extension "${name}": ${message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ok = results.filter((r) => r.loaded).map((r) => r.name);
|
|
86
|
+
const missing = results.filter((r) => !r.loaded).map((r) => r.name);
|
|
87
|
+
console.log(
|
|
88
|
+
`DuckDB extensions baked: ${ok.length}/${results.length} loaded` +
|
|
89
|
+
(ok.length ? ` [${ok.join(", ")}]` : "") +
|
|
90
|
+
(missing.length ? `; not loaded [${missing.join(", ")}]` : ""),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
connection.closeSync();
|
|
94
|
+
instance.closeSync();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main().catch((err) => {
|
|
98
|
+
// Never fail the build on a bake error -- the extensions are an optimization,
|
|
99
|
+
// not a correctness requirement (the runtime can still fetch on demand where
|
|
100
|
+
// the network allows).
|
|
101
|
+
console.warn(
|
|
102
|
+
`DuckDB extension bake skipped: ${err instanceof Error ? err.message : String(err)}`,
|
|
103
|
+
);
|
|
104
|
+
});
|
package/src/constants.ts
CHANGED
|
@@ -5,6 +5,18 @@ export const PUBLISHER_CONFIG_NAME = "publisher.config.json";
|
|
|
5
5
|
export const PACKAGE_MANIFEST_NAME = "publisher.json";
|
|
6
6
|
export const MODEL_FILE_SUFFIX = ".malloy";
|
|
7
7
|
export const NOTEBOOK_FILE_SUFFIX = ".malloynb";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalize a package-relative model path so author-written `explores`
|
|
11
|
+
* entries compare equal to the paths produced by `listPackageFiles`
|
|
12
|
+
* (forward slashes, no leading "./"). Shared by every input channel — the
|
|
13
|
+
* worker's on-disk parse and the API publish/update path — so a manifest
|
|
14
|
+
* behaves the same whether it's loaded from disk or written via the API.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeModelPath(p: string): string {
|
|
17
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
/**
|
|
9
21
|
* Default row cap applied to Malloy model queries (the `runnable.run`
|
|
10
22
|
* path used by `getQueryResults` and notebook cell execution) when
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BadRequestError } from "../errors";
|
|
2
|
+
import { BuildInstruction } from "../storage/DatabaseInterface";
|
|
2
3
|
import { MaterializationService } from "../service/materialization_service";
|
|
3
4
|
|
|
4
5
|
export class MaterializationController {
|
|
@@ -9,46 +10,113 @@ export class MaterializationController {
|
|
|
9
10
|
packageName: string,
|
|
10
11
|
body: Record<string, unknown>,
|
|
11
12
|
) {
|
|
12
|
-
const options = this.validateCreateBody(body);
|
|
13
13
|
return this.materializationService.createMaterialization(
|
|
14
14
|
environmentName,
|
|
15
15
|
packageName,
|
|
16
|
-
|
|
16
|
+
this.validateCreateBody(body),
|
|
17
17
|
);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
private validateCreateBody(body: Record<string, unknown>): {
|
|
21
21
|
forceRefresh?: boolean;
|
|
22
|
-
|
|
22
|
+
sourceNames?: string[];
|
|
23
|
+
pauseBetweenPhases?: boolean;
|
|
23
24
|
} {
|
|
24
|
-
const result: {
|
|
25
|
+
const result: {
|
|
26
|
+
forceRefresh?: boolean;
|
|
27
|
+
sourceNames?: string[];
|
|
28
|
+
pauseBetweenPhases?: boolean;
|
|
29
|
+
} = {};
|
|
30
|
+
if (body.pauseBetweenPhases !== undefined) {
|
|
31
|
+
if (typeof body.pauseBetweenPhases !== "boolean") {
|
|
32
|
+
throw new BadRequestError("pauseBetweenPhases must be a boolean");
|
|
33
|
+
}
|
|
34
|
+
result.pauseBetweenPhases = body.pauseBetweenPhases;
|
|
35
|
+
}
|
|
25
36
|
if (body.forceRefresh !== undefined) {
|
|
26
37
|
if (typeof body.forceRefresh !== "boolean") {
|
|
27
38
|
throw new BadRequestError("forceRefresh must be a boolean");
|
|
28
39
|
}
|
|
29
40
|
result.forceRefresh = body.forceRefresh;
|
|
30
41
|
}
|
|
31
|
-
if (body.
|
|
32
|
-
if (
|
|
33
|
-
|
|
42
|
+
if (body.sourceNames !== undefined) {
|
|
43
|
+
if (
|
|
44
|
+
!Array.isArray(body.sourceNames) ||
|
|
45
|
+
body.sourceNames.some((n) => typeof n !== "string")
|
|
46
|
+
) {
|
|
47
|
+
throw new BadRequestError(
|
|
48
|
+
"sourceNames must be an array of strings",
|
|
49
|
+
);
|
|
34
50
|
}
|
|
35
|
-
result.
|
|
51
|
+
result.sourceNames = body.sourceNames as string[];
|
|
36
52
|
}
|
|
37
53
|
return result;
|
|
38
54
|
}
|
|
39
55
|
|
|
40
|
-
async
|
|
56
|
+
async buildMaterialization(
|
|
41
57
|
environmentName: string,
|
|
42
58
|
packageName: string,
|
|
43
59
|
materializationId: string,
|
|
60
|
+
body: Record<string, unknown>,
|
|
44
61
|
) {
|
|
45
|
-
return this.materializationService.
|
|
62
|
+
return this.materializationService.buildMaterialization(
|
|
46
63
|
environmentName,
|
|
47
64
|
packageName,
|
|
48
65
|
materializationId,
|
|
66
|
+
this.validateBuildBody(body),
|
|
49
67
|
);
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
private validateBuildBody(
|
|
71
|
+
body: Record<string, unknown>,
|
|
72
|
+
): BuildInstruction[] {
|
|
73
|
+
const sources = body.sources;
|
|
74
|
+
if (!Array.isArray(sources) || sources.length === 0) {
|
|
75
|
+
throw new BadRequestError(
|
|
76
|
+
"build requires a non-empty 'sources' array of BuildInstruction",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return sources.map((raw) => this.validateInstruction(raw));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private validateInstruction(raw: unknown): BuildInstruction {
|
|
83
|
+
if (typeof raw !== "object" || raw === null) {
|
|
84
|
+
throw new BadRequestError("Each build instruction must be an object");
|
|
85
|
+
}
|
|
86
|
+
const instruction = raw as Record<string, unknown>;
|
|
87
|
+
const required = [
|
|
88
|
+
"buildId",
|
|
89
|
+
"materializedTableId",
|
|
90
|
+
"physicalTableName",
|
|
91
|
+
"realization",
|
|
92
|
+
] as const;
|
|
93
|
+
for (const field of required) {
|
|
94
|
+
if (typeof instruction[field] !== "string") {
|
|
95
|
+
throw new BadRequestError(
|
|
96
|
+
`Build instruction is missing required string field '${field}'`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (
|
|
101
|
+
instruction.realization !== "COPY" &&
|
|
102
|
+
instruction.realization !== "SNAPSHOT"
|
|
103
|
+
) {
|
|
104
|
+
throw new BadRequestError(
|
|
105
|
+
"Build instruction 'realization' must be COPY or SNAPSHOT",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
buildId: instruction.buildId as string,
|
|
110
|
+
sourceID:
|
|
111
|
+
typeof instruction.sourceID === "string"
|
|
112
|
+
? instruction.sourceID
|
|
113
|
+
: undefined,
|
|
114
|
+
materializedTableId: instruction.materializedTableId as string,
|
|
115
|
+
physicalTableName: instruction.physicalTableName as string,
|
|
116
|
+
realization: instruction.realization,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
52
120
|
async stopMaterialization(
|
|
53
121
|
environmentName: string,
|
|
54
122
|
packageName: string,
|
|
@@ -89,37 +157,13 @@ export class MaterializationController {
|
|
|
89
157
|
environmentName: string,
|
|
90
158
|
packageName: string,
|
|
91
159
|
materializationId: string,
|
|
160
|
+
options: { dropTables?: boolean } = {},
|
|
92
161
|
) {
|
|
93
162
|
return this.materializationService.deleteMaterialization(
|
|
94
163
|
environmentName,
|
|
95
164
|
packageName,
|
|
96
165
|
materializationId,
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async teardownPackage(
|
|
101
|
-
environmentName: string,
|
|
102
|
-
packageName: string,
|
|
103
|
-
body: Record<string, unknown>,
|
|
104
|
-
) {
|
|
105
|
-
const options = this.validateTeardownBody(body);
|
|
106
|
-
return this.materializationService.teardownPackage(
|
|
107
|
-
environmentName,
|
|
108
|
-
packageName,
|
|
109
166
|
options,
|
|
110
167
|
);
|
|
111
168
|
}
|
|
112
|
-
|
|
113
|
-
private validateTeardownBody(body: Record<string, unknown>): {
|
|
114
|
-
dryRun?: boolean;
|
|
115
|
-
} {
|
|
116
|
-
const options: { dryRun?: boolean } = {};
|
|
117
|
-
if (body.dryRun !== undefined) {
|
|
118
|
-
if (typeof body.dryRun !== "boolean") {
|
|
119
|
-
throw new BadRequestError("dryRun must be a boolean");
|
|
120
|
-
}
|
|
121
|
-
options.dryRun = body.dryRun;
|
|
122
|
-
}
|
|
123
|
-
return options;
|
|
124
|
-
}
|
|
125
169
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import sinon from "sinon";
|
|
3
|
+
|
|
4
|
+
import { BadRequestError } from "../errors";
|
|
5
|
+
import type { EnvironmentStore } from "../service/environment_store";
|
|
6
|
+
import { PackageController } from "./package.controller";
|
|
7
|
+
|
|
8
|
+
describe("PackageController.addPackage explores validation", () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
sinon.restore();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("no-location: rejects invalid explores and rolls back via unloadPackage (NOT deletePackage)", async () => {
|
|
14
|
+
// The no-location path registers a PRE-EXISTING user directory, so a bad
|
|
15
|
+
// manifest must unload it from memory — never deletePackage, which would
|
|
16
|
+
// delete the user's files.
|
|
17
|
+
const invalidMsg =
|
|
18
|
+
"Invalid explores entry 'missing.malloy' in publisher.json: file not found";
|
|
19
|
+
const mockPackage = {
|
|
20
|
+
formatInvalidExplores: () => invalidMsg,
|
|
21
|
+
};
|
|
22
|
+
const unloadPackage = sinon.stub().resolves(undefined);
|
|
23
|
+
const deletePackage = sinon.stub().resolves(undefined);
|
|
24
|
+
const addPackage = sinon.stub().resolves(mockPackage);
|
|
25
|
+
const environment = { addPackage, unloadPackage, deletePackage };
|
|
26
|
+
const getEnvironment = sinon.stub().resolves(environment);
|
|
27
|
+
const addPackageToDatabase = sinon.stub().resolves(undefined);
|
|
28
|
+
const environmentStore = {
|
|
29
|
+
publisherConfigIsFrozen: false,
|
|
30
|
+
getEnvironment,
|
|
31
|
+
addPackageToDatabase,
|
|
32
|
+
} as unknown as EnvironmentStore;
|
|
33
|
+
|
|
34
|
+
const controller = new PackageController(environmentStore);
|
|
35
|
+
|
|
36
|
+
await expect(
|
|
37
|
+
controller.addPackage("env", {
|
|
38
|
+
name: "pkg",
|
|
39
|
+
description: "test",
|
|
40
|
+
explores: ["missing.malloy"],
|
|
41
|
+
}),
|
|
42
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
43
|
+
|
|
44
|
+
expect(unloadPackage.calledOnceWith("pkg")).toBe(true);
|
|
45
|
+
expect(deletePackage.called).toBe(false);
|
|
46
|
+
expect(addPackageToDatabase.called).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("location: validation runs inside installPackage's rollback window, not as a controller delete", async () => {
|
|
50
|
+
// For the location path the tree was freshly downloaded, so validation is
|
|
51
|
+
// delegated to installPackage (which rolls the swap back on failure). The
|
|
52
|
+
// controller passes a validator and does NOT call delete/unload itself.
|
|
53
|
+
const invalidMsg =
|
|
54
|
+
"Invalid explores entry 'missing.malloy' in publisher.json: file not found";
|
|
55
|
+
const mockPackage = { formatInvalidExplores: () => invalidMsg };
|
|
56
|
+
// installPackage mimics the real contract: invoke the validator and, if it
|
|
57
|
+
// returns a message, throw BadRequestError (after its internal rollback).
|
|
58
|
+
const installPackage = sinon
|
|
59
|
+
.stub()
|
|
60
|
+
.callsFake(
|
|
61
|
+
async (
|
|
62
|
+
_name: string,
|
|
63
|
+
_downloader: unknown,
|
|
64
|
+
validate?: (pkg: unknown) => string | undefined,
|
|
65
|
+
) => {
|
|
66
|
+
const msg = validate?.(mockPackage);
|
|
67
|
+
if (msg) throw new BadRequestError(msg);
|
|
68
|
+
return mockPackage;
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
const unloadPackage = sinon.stub().resolves(undefined);
|
|
72
|
+
const deletePackage = sinon.stub().resolves(undefined);
|
|
73
|
+
const environment = { installPackage, unloadPackage, deletePackage };
|
|
74
|
+
const getEnvironment = sinon.stub().resolves(environment);
|
|
75
|
+
const addPackageToDatabase = sinon.stub().resolves(undefined);
|
|
76
|
+
const environmentStore = {
|
|
77
|
+
publisherConfigIsFrozen: false,
|
|
78
|
+
getEnvironment,
|
|
79
|
+
addPackageToDatabase,
|
|
80
|
+
} as unknown as EnvironmentStore;
|
|
81
|
+
|
|
82
|
+
const controller = new PackageController(environmentStore);
|
|
83
|
+
|
|
84
|
+
await expect(
|
|
85
|
+
controller.addPackage("env", {
|
|
86
|
+
name: "pkg",
|
|
87
|
+
description: "test",
|
|
88
|
+
location: "gs://bucket/pkg.zip",
|
|
89
|
+
explores: ["missing.malloy"],
|
|
90
|
+
}),
|
|
91
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
92
|
+
|
|
93
|
+
expect(installPackage.calledOnce).toBe(true);
|
|
94
|
+
expect(typeof installPackage.firstCall.args[2]).toBe("function");
|
|
95
|
+
expect(deletePackage.called).toBe(false);
|
|
96
|
+
expect(unloadPackage.called).toBe(false);
|
|
97
|
+
expect(addPackageToDatabase.called).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("persists when explores are valid (no-location)", async () => {
|
|
101
|
+
const mockPackage = {
|
|
102
|
+
formatInvalidExplores: () => "",
|
|
103
|
+
};
|
|
104
|
+
const addPackage = sinon.stub().resolves(mockPackage);
|
|
105
|
+
const getEnvironment = sinon.stub().resolves({ addPackage });
|
|
106
|
+
const addPackageToDatabase = sinon.stub().resolves(undefined);
|
|
107
|
+
const environmentStore = {
|
|
108
|
+
publisherConfigIsFrozen: false,
|
|
109
|
+
getEnvironment,
|
|
110
|
+
addPackageToDatabase,
|
|
111
|
+
} as unknown as EnvironmentStore;
|
|
112
|
+
|
|
113
|
+
const controller = new PackageController(environmentStore);
|
|
114
|
+
|
|
115
|
+
await controller.addPackage("env", {
|
|
116
|
+
name: "pkg",
|
|
117
|
+
description: "test",
|
|
118
|
+
explores: ["index.malloy"],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(addPackageToDatabase.calledOnceWith("env", "pkg")).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("PackageController.updatePackage explores validation", () => {
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
sinon.restore();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("location update: validates the EFFECTIVE explores (body override) before the swap commits", async () => {
|
|
131
|
+
// body.location triggers a reinstall (atomic swap). The effective explores
|
|
132
|
+
// — the body override here — must be validated inside installPackage so a
|
|
133
|
+
// bad update rolls back to the previous tree instead of swapping in the
|
|
134
|
+
// rejected one and 400-ing after the fact.
|
|
135
|
+
const invalidMsg =
|
|
136
|
+
"Invalid explores entry 'nope.malloy' in publisher.json: file not found";
|
|
137
|
+
// The mock package validates whatever override it's handed.
|
|
138
|
+
const mockPackage = {
|
|
139
|
+
formatInvalidExplores: (override?: string[]) =>
|
|
140
|
+
override?.includes("nope.malloy") ? invalidMsg : "",
|
|
141
|
+
};
|
|
142
|
+
const installPackage = sinon
|
|
143
|
+
.stub()
|
|
144
|
+
.callsFake(
|
|
145
|
+
async (
|
|
146
|
+
_name: string,
|
|
147
|
+
_downloader: unknown,
|
|
148
|
+
validate?: (pkg: unknown) => string | undefined,
|
|
149
|
+
) => {
|
|
150
|
+
const msg = validate?.(mockPackage);
|
|
151
|
+
if (msg) throw new BadRequestError(msg);
|
|
152
|
+
return mockPackage;
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
const updatePackage = sinon.stub().resolves(mockPackage);
|
|
156
|
+
const environment = { installPackage, updatePackage };
|
|
157
|
+
const getEnvironment = sinon.stub().resolves(environment);
|
|
158
|
+
const addPackageToDatabase = sinon.stub().resolves(undefined);
|
|
159
|
+
const environmentStore = {
|
|
160
|
+
publisherConfigIsFrozen: false,
|
|
161
|
+
getEnvironment,
|
|
162
|
+
addPackageToDatabase,
|
|
163
|
+
} as unknown as EnvironmentStore;
|
|
164
|
+
|
|
165
|
+
const controller = new PackageController(environmentStore);
|
|
166
|
+
|
|
167
|
+
await expect(
|
|
168
|
+
controller.updatePackage("env", "pkg", {
|
|
169
|
+
name: "pkg",
|
|
170
|
+
location: "gs://bucket/pkg.zip",
|
|
171
|
+
explores: ["nope.malloy"],
|
|
172
|
+
}),
|
|
173
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
174
|
+
|
|
175
|
+
// The rejected swap never reached the metadata-apply / persist steps.
|
|
176
|
+
expect(updatePackage.called).toBe(false);
|
|
177
|
+
expect(addPackageToDatabase.called).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
});
|