@malloy-publisher/server 0.0.205 → 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/dist/app/api-doc.yaml +363 -395
- package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-ivu4vdpj.js} +1 -1
- package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-BZEuwF9P.js} +1 -1
- package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-Dpu3bfPg.js} +1 -1
- package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BdOZDcce.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-DHHAcY5o.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-RX3QOTde.js → index-ChR1fKR2.js} +127 -127
- package/dist/app/assets/{index.umd-D2WH3D-f.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/server.mjs +1151 -1264
- package/package.json +1 -1
- 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/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +12 -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/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/server-old.ts +7 -149
- package/src/server.ts +9 -188
- package/src/service/authorize_integration.spec.ts +67 -0
- package/src/service/environment.ts +212 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +0 -23
- 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 +400 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +254 -14
- 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/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- 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
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
|
+
});
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
import { components } from "../api";
|
|
2
|
+
import { normalizeModelPath } from "../constants";
|
|
2
3
|
import { BadRequestError, FrozenConfigError } from "../errors";
|
|
3
|
-
import { logger } from "../logger";
|
|
4
4
|
import { EnvironmentStore } from "../service/environment_store";
|
|
5
|
-
import { ManifestService } from "../service/manifest_service";
|
|
6
5
|
|
|
7
6
|
type ApiPackage = components["schemas"]["Package"];
|
|
8
7
|
|
|
9
8
|
export class PackageController {
|
|
10
9
|
private environmentStore: EnvironmentStore;
|
|
11
|
-
private manifestService: ManifestService;
|
|
12
10
|
|
|
13
|
-
constructor(
|
|
14
|
-
environmentStore: EnvironmentStore,
|
|
15
|
-
manifestService: ManifestService,
|
|
16
|
-
) {
|
|
11
|
+
constructor(environmentStore: EnvironmentStore) {
|
|
17
12
|
this.environmentStore = environmentStore;
|
|
18
|
-
this.manifestService = manifestService;
|
|
19
13
|
}
|
|
20
14
|
|
|
21
15
|
public async listPackages(environmentName: string): Promise<ApiPackage[]> {
|
|
@@ -70,11 +64,7 @@ export class PackageController {
|
|
|
70
64
|
return _package.getPackageMetadata();
|
|
71
65
|
}
|
|
72
66
|
|
|
73
|
-
async addPackage(
|
|
74
|
-
environmentName: string,
|
|
75
|
-
body: ApiPackage,
|
|
76
|
-
options?: { autoLoadManifest?: boolean },
|
|
77
|
-
) {
|
|
67
|
+
async addPackage(environmentName: string, body: ApiPackage) {
|
|
78
68
|
if (this.environmentStore.publisherConfigIsFrozen) {
|
|
79
69
|
throw new FrozenConfigError();
|
|
80
70
|
}
|
|
@@ -86,73 +76,61 @@ export class PackageController {
|
|
|
86
76
|
environmentName,
|
|
87
77
|
false,
|
|
88
78
|
);
|
|
79
|
+
// Strict at publish: the author is in the loop here, so reject a bad
|
|
80
|
+
// explores with an actionable 400 instead of silently serving a hidden
|
|
81
|
+
// surface. (At startup/reload we fail safe and only warn — see
|
|
82
|
+
// Package.loadViaWorker.) The rollback differs by path so a rejected
|
|
83
|
+
// publish never destroys user content:
|
|
84
|
+
// - location: the tree was just downloaded into a fresh canonical, so
|
|
85
|
+
// validation runs inside installPackage's swap window and a failure
|
|
86
|
+
// wipes that download (the existing rollback) — nothing pre-existing
|
|
87
|
+
// to lose.
|
|
88
|
+
// - no-location: addPackage registered a *pre-existing* user directory,
|
|
89
|
+
// so we validate after the fact and `unloadPackage` (evict from
|
|
90
|
+
// memory, keep the files) rather than delete it.
|
|
89
91
|
let result;
|
|
90
92
|
if (body.location) {
|
|
91
93
|
const bodyLocation = body.location;
|
|
92
|
-
result = await environment.installPackage(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
result = await environment.installPackage(
|
|
95
|
+
packageName,
|
|
96
|
+
(stagingPath) =>
|
|
97
|
+
this.downloadInto(
|
|
98
|
+
environmentName,
|
|
99
|
+
packageName,
|
|
100
|
+
bodyLocation,
|
|
101
|
+
stagingPath,
|
|
102
|
+
),
|
|
103
|
+
(pkg) => pkg.formatInvalidExplores(),
|
|
99
104
|
);
|
|
100
105
|
} else {
|
|
101
106
|
result = await environment.addPackage(packageName);
|
|
102
107
|
}
|
|
108
|
+
|
|
109
|
+
// `addPackage`/`installPackage` are typed `Package | undefined`; a missing
|
|
110
|
+
// result here is a should-never-happen internal fault. Fail loudly rather
|
|
111
|
+
// than letting optional chaining silently skip the validation below.
|
|
112
|
+
if (!result) {
|
|
113
|
+
throw new Error(`Failed to create package ${packageName}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!body.location) {
|
|
117
|
+
const invalidMsg = result.formatInvalidExplores();
|
|
118
|
+
if (invalidMsg) {
|
|
119
|
+
await environment.unloadPackage(packageName).catch(() => {
|
|
120
|
+
/* best-effort; the package is not persisted below */
|
|
121
|
+
});
|
|
122
|
+
throw new BadRequestError(invalidMsg);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
103
126
|
await this.environmentStore.addPackageToDatabase(
|
|
104
127
|
environmentName,
|
|
105
128
|
packageName,
|
|
106
129
|
);
|
|
107
130
|
|
|
108
|
-
if (options?.autoLoadManifest === true) {
|
|
109
|
-
await this.tryLoadExistingManifest(environmentName, packageName);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
131
|
return result;
|
|
113
132
|
}
|
|
114
133
|
|
|
115
|
-
/**
|
|
116
|
-
* If there are already manifest entries for this package (e.g. from a
|
|
117
|
-
* previous materialization run), reload all models with the manifest so
|
|
118
|
-
* persist references resolve to the materialized tables immediately.
|
|
119
|
-
*/
|
|
120
|
-
private async tryLoadExistingManifest(
|
|
121
|
-
environmentName: string,
|
|
122
|
-
packageName: string,
|
|
123
|
-
): Promise<void> {
|
|
124
|
-
try {
|
|
125
|
-
const repository =
|
|
126
|
-
this.environmentStore.storageManager.getRepository();
|
|
127
|
-
const dbEnvironment =
|
|
128
|
-
await repository.getEnvironmentByName(environmentName);
|
|
129
|
-
if (!dbEnvironment) return;
|
|
130
|
-
|
|
131
|
-
const manifest = await this.manifestService.getManifest(
|
|
132
|
-
dbEnvironment.id,
|
|
133
|
-
packageName,
|
|
134
|
-
);
|
|
135
|
-
if (Object.keys(manifest.entries).length === 0) return;
|
|
136
|
-
|
|
137
|
-
await this.manifestService.reloadManifest(
|
|
138
|
-
dbEnvironment.id,
|
|
139
|
-
packageName,
|
|
140
|
-
environmentName,
|
|
141
|
-
);
|
|
142
|
-
logger.info("Auto-loaded existing manifest for added package", {
|
|
143
|
-
environmentName,
|
|
144
|
-
packageName,
|
|
145
|
-
entryCount: Object.keys(manifest.entries).length,
|
|
146
|
-
});
|
|
147
|
-
} catch (error) {
|
|
148
|
-
logger.warn("Failed to auto-load manifest for package", {
|
|
149
|
-
environmentName,
|
|
150
|
-
packageName,
|
|
151
|
-
error,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
134
|
public async deletePackage(environmentName: string, packageName: string) {
|
|
157
135
|
if (this.environmentStore.publisherConfigIsFrozen) {
|
|
158
136
|
throw new FrozenConfigError();
|
|
@@ -184,15 +162,24 @@ export class PackageController {
|
|
|
184
162
|
);
|
|
185
163
|
if (body.location) {
|
|
186
164
|
// Re-install: stream the new content into a staging dir (no lock)
|
|
187
|
-
// and atomically swap it in (under the lock).
|
|
165
|
+
// and atomically swap it in (under the lock). Validate the effective
|
|
166
|
+
// explores (the body override, else the new tree's own manifest)
|
|
167
|
+
// INSIDE the swap window, so a rejected update rolls back to the
|
|
168
|
+
// previous tree instead of swapping the bad one in and 400-ing after.
|
|
188
169
|
const bodyLocation = body.location;
|
|
189
|
-
await environment.installPackage(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
170
|
+
await environment.installPackage(
|
|
171
|
+
packageName,
|
|
172
|
+
(stagingPath) =>
|
|
173
|
+
this.downloadInto(
|
|
174
|
+
environmentName,
|
|
175
|
+
packageName,
|
|
176
|
+
bodyLocation,
|
|
177
|
+
stagingPath,
|
|
178
|
+
),
|
|
179
|
+
(pkg) =>
|
|
180
|
+
pkg.formatInvalidExplores(
|
|
181
|
+
body.explores?.map(normalizeModelPath),
|
|
182
|
+
),
|
|
196
183
|
);
|
|
197
184
|
}
|
|
198
185
|
// Apply metadata changes (publisher.json) under the same per-package
|
package/src/dto/package.dto.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
IsString,
|
|
3
|
+
IsNotEmpty,
|
|
4
|
+
IsOptional,
|
|
5
|
+
IsArray,
|
|
6
|
+
IsIn,
|
|
7
|
+
} from "class-validator";
|
|
2
8
|
import { ApiPackage } from "../service/package";
|
|
3
9
|
|
|
4
10
|
export class PackageDto implements ApiPackage {
|
|
@@ -9,4 +15,13 @@ export class PackageDto implements ApiPackage {
|
|
|
9
15
|
@IsString()
|
|
10
16
|
@IsNotEmpty()
|
|
11
17
|
description: string;
|
|
18
|
+
|
|
19
|
+
@IsOptional()
|
|
20
|
+
@IsArray()
|
|
21
|
+
@IsString({ each: true })
|
|
22
|
+
explores?: string[];
|
|
23
|
+
|
|
24
|
+
@IsOptional()
|
|
25
|
+
@IsIn(["declared", "all"])
|
|
26
|
+
queryableSources?: "declared" | "all";
|
|
12
27
|
}
|
package/src/errors.spec.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ConnectionError,
|
|
7
7
|
internalErrorToHttpError,
|
|
8
8
|
ModelCompilationError,
|
|
9
|
+
NotQueryableError,
|
|
9
10
|
PayloadTooLargeError,
|
|
10
11
|
QueryTimeoutError,
|
|
11
12
|
ServiceUnavailableError,
|
|
@@ -42,6 +43,17 @@ describe("internalErrorToHttpError", () => {
|
|
|
42
43
|
});
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
it("maps NotQueryableError to 404 (explore boundary)", () => {
|
|
47
|
+
const { status, json } = internalErrorToHttpError(
|
|
48
|
+
new NotQueryableError('No queryable source "hidden".'),
|
|
49
|
+
);
|
|
50
|
+
expect(status).toBe(404);
|
|
51
|
+
expect(json).toEqual({
|
|
52
|
+
code: 404,
|
|
53
|
+
message: 'No queryable source "hidden".',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
45
57
|
it("maps ModelCompilationError to 424", () => {
|
|
46
58
|
const { status, json } = internalErrorToHttpError(
|
|
47
59
|
new ModelCompilationError({ message: "compile failed" }),
|
package/src/errors.ts
CHANGED
|
@@ -14,6 +14,8 @@ export function internalErrorToHttpError(error: Error) {
|
|
|
14
14
|
return httpError(404, error.message);
|
|
15
15
|
} else if (error instanceof ModelNotFoundError) {
|
|
16
16
|
return httpError(404, error.message);
|
|
17
|
+
} else if (error instanceof NotQueryableError) {
|
|
18
|
+
return httpError(404, error.message);
|
|
17
19
|
} else if (error instanceof MalloyError) {
|
|
18
20
|
return httpError(400, error.message);
|
|
19
21
|
} else if (error instanceof ConnectionNotFoundError) {
|
|
@@ -128,6 +130,22 @@ export class AccessDeniedError extends Error {
|
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A query targeted a source/model that is not part of the package's queryable
|
|
135
|
+
* surface under `queryableSources: "declared"` (a non-`explores` model file, or
|
|
136
|
+
* a source not in a model's `export {}` closure). Mapped to HTTP **404**, not
|
|
137
|
+
* 403, and with a deliberately generic message: unlike `#(authorize)` (which is
|
|
138
|
+
* identity-scoped and answers "who"), the explore boundary is identity-free and
|
|
139
|
+
* answers "what is queryable" — so a hidden target should be indistinguishable
|
|
140
|
+
* from a non-existent one (no enumeration / existence oracle).
|
|
141
|
+
*/
|
|
142
|
+
export class NotQueryableError extends Error {
|
|
143
|
+
constructor(message: string) {
|
|
144
|
+
super(message);
|
|
145
|
+
this.name = "NotQueryableError";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
export class MaterializationNotFoundError extends Error {
|
|
132
150
|
constructor(message: string) {
|
|
133
151
|
super(message);
|