@malloy-publisher/server 0.0.187 → 0.0.189-dev

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 (61) hide show
  1. package/dist/app/api-doc.yaml +423 -60
  2. package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
  3. package/dist/app/assets/{MainPage-B-RVib7-.js → MainPage-BzB3yoqi.js} +1 -1
  4. package/dist/app/assets/{ModelPage-Cv7TxfHc.js → ModelPage-C9O_sAXT.js} +1 -1
  5. package/dist/app/assets/PackagePage-DcxKEjBX.js +1 -0
  6. package/dist/app/assets/ProjectPage-BDj307rF.js +1 -0
  7. package/dist/app/assets/{RouteError-B7433Znd.js → RouteError-DAShbVCG.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-BBMaiWJS.js → WorkbookPage-Cs_XYEaB.js} +1 -1
  9. package/dist/app/assets/core-CjeTkq8O.es-BqRc6yhC.js +148 -0
  10. package/dist/app/assets/engine-oniguruma-C4vnmooL.es-jdkXmgTr.js +1 -0
  11. package/dist/app/assets/github-light-JYsPkUQd.es-DAi9KRSo.js +1 -0
  12. package/dist/app/assets/index-15BOvhp0.js +456 -0
  13. package/dist/app/assets/{index-CpOUcMUD.js → index-Bb2jqquW.js} +1 -1
  14. package/dist/app/assets/{index-DfdCzzKW.js → index-D68X76-7.js} +1 -1
  15. package/dist/app/assets/{index.umd-CAdcmnBE.js → index.umd-DGBekgSu.js} +1 -1
  16. package/dist/app/assets/json-71t8ZF9g.es-BQoSv7ci.js +1 -0
  17. package/dist/app/assets/sql-DCkt643-.es-COK4E0Yg.js +1 -0
  18. package/dist/app/assets/typescript-buWNZFwO.es-Dj6nwHGl.js +1 -0
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.js +98 -77
  21. package/dist/server.js +1834 -450
  22. package/package.json +5 -3
  23. package/src/controller/connection.controller.ts +27 -20
  24. package/src/controller/manifest.controller.ts +29 -0
  25. package/src/controller/materialization.controller.ts +125 -0
  26. package/src/controller/model.controller.ts +0 -2
  27. package/src/controller/package.controller.ts +53 -2
  28. package/src/errors.ts +24 -0
  29. package/src/server.ts +196 -5
  30. package/src/service/manifest_service.spec.ts +201 -0
  31. package/src/service/manifest_service.ts +106 -0
  32. package/src/service/materialization_service.spec.ts +648 -0
  33. package/src/service/materialization_service.ts +929 -0
  34. package/src/service/materialized_table_gc.spec.ts +383 -0
  35. package/src/service/materialized_table_gc.ts +279 -0
  36. package/src/service/model.ts +25 -4
  37. package/src/service/package.ts +50 -0
  38. package/src/service/project_store.ts +21 -2
  39. package/src/service/quoting.ts +41 -0
  40. package/src/service/resolve_project.ts +13 -0
  41. package/src/storage/DatabaseInterface.ts +103 -1
  42. package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
  43. package/src/storage/StorageManager.ts +119 -1
  44. package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
  45. package/src/storage/duckdb/DuckDBRepository.ts +99 -9
  46. package/src/storage/duckdb/ManifestRepository.ts +119 -0
  47. package/src/storage/duckdb/MaterializationRepository.ts +249 -0
  48. package/src/storage/duckdb/manifest_store.spec.ts +133 -0
  49. package/src/storage/duckdb/schema.ts +59 -1
  50. package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
  51. package/tests/fixtures/persist-test/data/orders.csv +5 -0
  52. package/tests/fixtures/persist-test/persist_test.malloy +11 -0
  53. package/tests/fixtures/persist-test/publisher.json +5 -0
  54. package/tests/fixtures/publisher.config.json +15 -0
  55. package/tests/harness/rest_e2e.ts +68 -0
  56. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
  57. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
  58. package/dist/app/assets/HomePage-qyt2wz79.js +0 -1
  59. package/dist/app/assets/PackagePage-BOnk1rJb.js +0 -1
  60. package/dist/app/assets/ProjectPage-DFn9Ek1J.js +0 -1
  61. package/dist/app/assets/index-DjdBB2D6.js +0 -467
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.187",
4
+ "version": "0.0.189-dev",
5
5
  "main": "dist/server.js",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.js"
@@ -14,7 +14,9 @@
14
14
  "access": "public"
15
15
  },
16
16
  "scripts": {
17
- "test": "bun test --timeout 100000",
17
+ "test": "bun run test:unit && bun run test:integration",
18
+ "test:unit": "bun test --timeout 100000 src",
19
+ "test:integration": "bun test --timeout 100000 tests",
18
20
  "build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts",
19
21
  "build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts",
20
22
  "start": "NODE_ENV=production node ./dist/server.js",
@@ -56,7 +58,7 @@
56
58
  "class-transformer": "^0.5.1",
57
59
  "class-validator": "^0.14.1",
58
60
  "cors": "^2.8.5",
59
- "duckdb": "1.4.3",
61
+ "duckdb": "1.4.4",
60
62
  "express": "^4.21.0",
61
63
  "globals": "^15.9.0",
62
64
  "handlebars": "^4.7.8",
@@ -141,7 +141,10 @@ export class ConnectionController {
141
141
  if (!source) {
142
142
  throw new ConnectionError(`Table ${tablePath} not found`);
143
143
  }
144
-
144
+ // BigQueryConnection returns `error.message` as a string on failure instead of throwing.
145
+ if (typeof source === "string") {
146
+ throw new ConnectionError(source);
147
+ }
145
148
  return {
146
149
  source: JSON.stringify(source),
147
150
  resource: tablePath,
@@ -173,11 +176,11 @@ export class ConnectionController {
173
176
  if (!projectName || !connectionName) {
174
177
  throw new BadRequestError("Connection payload is required");
175
178
  }
176
- const { dbConnection } = await this.connectionService.getConnection(
177
- projectName,
178
- connectionName,
179
- );
180
- return dbConnection;
179
+ // Prefer the in-memory API connection (which was materialized by the
180
+ // project on load and carries `attributes`). The DB row stores the
181
+ // raw config and FK columns, which aren't the ApiConnection shape.
182
+ const project = await this.projectStore.getProject(projectName, false);
183
+ return project.getApiConnection(connectionName);
181
184
  }
182
185
 
183
186
  public async listConnections(projectName: string): Promise<ApiConnection[]> {
@@ -231,22 +234,26 @@ export class ConnectionController {
231
234
  projectName,
232
235
  connectionName,
233
236
  );
234
-
235
237
  try {
238
+ const schema = await (
239
+ malloyConnection as Connection & {
240
+ fetchSelectSchema: (params: {
241
+ connection: string;
242
+ selectStr: string;
243
+ }) => Promise<unknown>;
244
+ }
245
+ ).fetchSelectSchema({
246
+ connection: connectionName,
247
+ selectStr: sqlStatement,
248
+ });
249
+
250
+ // BigQueryConnection returns `error.message` as a string on failure instead of throwing.
251
+ if (typeof schema === "string") {
252
+ throw new ConnectionError(schema);
253
+ }
254
+
236
255
  return {
237
- source: JSON.stringify(
238
- await (
239
- malloyConnection as Connection & {
240
- fetchSelectSchema: (params: {
241
- connection: string;
242
- selectStr: string;
243
- }) => Promise<unknown>;
244
- }
245
- ).fetchSelectSchema({
246
- connection: connectionName,
247
- selectStr: sqlStatement,
248
- }),
249
- ),
256
+ source: JSON.stringify(schema),
250
257
  };
251
258
  } catch (error) {
252
259
  throw new ConnectionError((error as Error).message);
@@ -0,0 +1,29 @@
1
+ import { ManifestService } from "../service/manifest_service";
2
+ import { ProjectStore } from "../service/project_store";
3
+ import { resolveProjectId } from "../service/resolve_project";
4
+
5
+ export class ManifestController {
6
+ constructor(
7
+ private projectStore: ProjectStore,
8
+ private manifestService: ManifestService,
9
+ ) {}
10
+
11
+ async getManifest(projectName: string, packageName: string) {
12
+ const repository = this.projectStore.storageManager.getRepository();
13
+ const projectId = await resolveProjectId(repository, projectName);
14
+ // Verify the package exists so we return 404 instead of an empty manifest.
15
+ const project = await this.projectStore.getProject(projectName, false);
16
+ await project.getPackage(packageName, false);
17
+ return this.manifestService.getManifest(projectId, packageName);
18
+ }
19
+
20
+ async reloadManifest(projectName: string, packageName: string) {
21
+ const repository = this.projectStore.storageManager.getRepository();
22
+ const projectId = await resolveProjectId(repository, projectName);
23
+ return this.manifestService.reloadManifest(
24
+ projectId,
25
+ packageName,
26
+ projectName,
27
+ );
28
+ }
29
+ }
@@ -0,0 +1,125 @@
1
+ import { BadRequestError } from "../errors";
2
+ import { MaterializationService } from "../service/materialization_service";
3
+
4
+ export class MaterializationController {
5
+ constructor(private materializationService: MaterializationService) {}
6
+
7
+ async createMaterialization(
8
+ projectName: string,
9
+ packageName: string,
10
+ body: Record<string, unknown>,
11
+ ) {
12
+ const options = this.validateCreateBody(body);
13
+ return this.materializationService.createMaterialization(
14
+ projectName,
15
+ packageName,
16
+ options,
17
+ );
18
+ }
19
+
20
+ private validateCreateBody(body: Record<string, unknown>): {
21
+ forceRefresh?: boolean;
22
+ autoLoadManifest?: boolean;
23
+ } {
24
+ const result: { forceRefresh?: boolean; autoLoadManifest?: boolean } = {};
25
+ if (body.forceRefresh !== undefined) {
26
+ if (typeof body.forceRefresh !== "boolean") {
27
+ throw new BadRequestError("forceRefresh must be a boolean");
28
+ }
29
+ result.forceRefresh = body.forceRefresh;
30
+ }
31
+ if (body.autoLoadManifest !== undefined) {
32
+ if (typeof body.autoLoadManifest !== "boolean") {
33
+ throw new BadRequestError("autoLoadManifest must be a boolean");
34
+ }
35
+ result.autoLoadManifest = body.autoLoadManifest;
36
+ }
37
+ return result;
38
+ }
39
+
40
+ async startMaterialization(
41
+ projectName: string,
42
+ packageName: string,
43
+ materializationId: string,
44
+ ) {
45
+ return this.materializationService.startMaterialization(
46
+ projectName,
47
+ packageName,
48
+ materializationId,
49
+ );
50
+ }
51
+
52
+ async stopMaterialization(
53
+ projectName: string,
54
+ packageName: string,
55
+ materializationId: string,
56
+ ) {
57
+ return this.materializationService.stopMaterialization(
58
+ projectName,
59
+ packageName,
60
+ materializationId,
61
+ );
62
+ }
63
+
64
+ async listMaterializations(
65
+ projectName: string,
66
+ packageName: string,
67
+ options?: { limit?: number; offset?: number },
68
+ ) {
69
+ return this.materializationService.listMaterializations(
70
+ projectName,
71
+ packageName,
72
+ options,
73
+ );
74
+ }
75
+
76
+ async getMaterialization(
77
+ projectName: string,
78
+ packageName: string,
79
+ materializationId: string,
80
+ ) {
81
+ return this.materializationService.getMaterialization(
82
+ projectName,
83
+ packageName,
84
+ materializationId,
85
+ );
86
+ }
87
+
88
+ async deleteMaterialization(
89
+ projectName: string,
90
+ packageName: string,
91
+ materializationId: string,
92
+ ) {
93
+ return this.materializationService.deleteMaterialization(
94
+ projectName,
95
+ packageName,
96
+ materializationId,
97
+ );
98
+ }
99
+
100
+ async teardownPackage(
101
+ projectName: string,
102
+ packageName: string,
103
+ body: Record<string, unknown>,
104
+ ) {
105
+ const options = this.validateTeardownBody(body);
106
+ return this.materializationService.teardownPackage(
107
+ projectName,
108
+ packageName,
109
+ options,
110
+ );
111
+ }
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
+ }
@@ -7,8 +7,6 @@ type ApiNotebook = components["schemas"]["Notebook"];
7
7
  type ApiModel = components["schemas"]["Model"];
8
8
  type ApiCompiledModel = components["schemas"]["CompiledModel"];
9
9
  type ApiRawNotebook = components["schemas"]["RawNotebook"];
10
- export type ListModelsFilterEnum =
11
- components["parameters"]["ListModelsFilterEnum"];
12
10
  export class ModelController {
13
11
  private projectStore: ProjectStore;
14
12
 
@@ -2,15 +2,19 @@ import * as path from "path";
2
2
  import { components } from "../api";
3
3
  import { PUBLISHER_DATA_DIR } from "../constants";
4
4
  import { BadRequestError, FrozenConfigError } from "../errors";
5
+ import { logger } from "../logger";
6
+ import { ManifestService } from "../service/manifest_service";
5
7
  import { ProjectStore } from "../service/project_store";
6
8
 
7
9
  type ApiPackage = components["schemas"]["Package"];
8
10
 
9
11
  export class PackageController {
10
12
  private projectStore: ProjectStore;
13
+ private manifestService: ManifestService;
11
14
 
12
- constructor(projectStore: ProjectStore) {
15
+ constructor(projectStore: ProjectStore, manifestService: ManifestService) {
13
16
  this.projectStore = projectStore;
17
+ this.manifestService = manifestService;
14
18
  }
15
19
 
16
20
  public async listPackages(projectName: string): Promise<ApiPackage[]> {
@@ -32,7 +36,11 @@ export class PackageController {
32
36
  return _package.getPackageMetadata();
33
37
  }
34
38
 
35
- async addPackage(projectName: string, body: ApiPackage) {
39
+ async addPackage(
40
+ projectName: string,
41
+ body: ApiPackage,
42
+ options?: { autoLoadManifest?: boolean },
43
+ ) {
36
44
  if (this.projectStore.publisherConfigIsFrozen) {
37
45
  throw new FrozenConfigError();
38
46
  }
@@ -46,9 +54,52 @@ export class PackageController {
46
54
  const result = await project.addPackage(body.name);
47
55
  await this.projectStore.addPackageToDatabase(projectName, body.name);
48
56
 
57
+ if (options?.autoLoadManifest === true) {
58
+ await this.tryLoadExistingManifest(projectName, body.name);
59
+ }
60
+
49
61
  return result;
50
62
  }
51
63
 
64
+ /**
65
+ * If there are already manifest entries for this package (e.g. from a
66
+ * previous materialization run), reload all models with the manifest so
67
+ * persist references resolve to the materialized tables immediately.
68
+ */
69
+ private async tryLoadExistingManifest(
70
+ projectName: string,
71
+ packageName: string,
72
+ ): Promise<void> {
73
+ try {
74
+ const repository = this.projectStore.storageManager.getRepository();
75
+ const dbProject = await repository.getProjectByName(projectName);
76
+ if (!dbProject) return;
77
+
78
+ const manifest = await this.manifestService.getManifest(
79
+ dbProject.id,
80
+ packageName,
81
+ );
82
+ if (Object.keys(manifest.entries).length === 0) return;
83
+
84
+ await this.manifestService.reloadManifest(
85
+ dbProject.id,
86
+ packageName,
87
+ projectName,
88
+ );
89
+ logger.info("Auto-loaded existing manifest for added package", {
90
+ projectName,
91
+ packageName,
92
+ entryCount: Object.keys(manifest.entries).length,
93
+ });
94
+ } catch (error) {
95
+ logger.warn("Failed to auto-load manifest for package", {
96
+ projectName,
97
+ packageName,
98
+ error,
99
+ });
100
+ }
101
+ }
102
+
52
103
  public async deletePackage(projectName: string, packageName: string) {
53
104
  if (this.projectStore.publisherConfigIsFrozen) {
54
105
  throw new FrozenConfigError();
package/src/errors.ts CHANGED
@@ -20,6 +20,12 @@ export function internalErrorToHttpError(error: Error) {
20
20
  return httpError(424, error.message);
21
21
  } else if (error instanceof ConnectionError) {
22
22
  return httpError(502, error.message);
23
+ } else if (error instanceof MaterializationNotFoundError) {
24
+ return httpError(404, error.message);
25
+ } else if (error instanceof MaterializationConflictError) {
26
+ return httpError(409, error.message);
27
+ } else if (error instanceof InvalidStateTransitionError) {
28
+ return httpError(409, error.message);
23
29
  } else {
24
30
  return httpError(500, error.message);
25
31
  }
@@ -90,3 +96,21 @@ export class FrozenConfigError extends Error {
90
96
  super(message);
91
97
  }
92
98
  }
99
+
100
+ export class MaterializationNotFoundError extends Error {
101
+ constructor(message: string) {
102
+ super(message);
103
+ }
104
+ }
105
+
106
+ export class MaterializationConflictError extends Error {
107
+ constructor(message: string) {
108
+ super(message);
109
+ }
110
+ }
111
+
112
+ export class InvalidStateTransitionError extends Error {
113
+ constructor(message: string) {
114
+ super(message);
115
+ }
116
+ }
package/src/server.ts CHANGED
@@ -20,7 +20,11 @@ import { ModelController } from "./controller/model.controller";
20
20
  import { PackageController } from "./controller/package.controller";
21
21
  import { QueryController } from "./controller/query.controller";
22
22
  import { WatchModeController } from "./controller/watch-mode.controller";
23
- import { internalErrorToHttpError, NotImplementedError } from "./errors";
23
+ import {
24
+ BadRequestError,
25
+ internalErrorToHttpError,
26
+ NotImplementedError,
27
+ } from "./errors";
24
28
  import {
25
29
  drainingGuard,
26
30
  registerHealthEndpoints,
@@ -28,7 +32,11 @@ import {
28
32
  } from "./health";
29
33
  import { logger, loggerMiddleware } from "./logger";
30
34
 
35
+ import { ManifestController } from "./controller/manifest.controller";
36
+ import { MaterializationController } from "./controller/materialization.controller";
31
37
  import { initializeMcpServer } from "./mcp/server";
38
+ import { ManifestService } from "./service/manifest_service";
39
+ import { MaterializationService } from "./service/materialization_service";
32
40
  import { ProjectStore } from "./service/project_store";
33
41
 
34
42
  /** Normalize an Express query param into a string[] or undefined. */
@@ -125,17 +133,29 @@ const SERVER_ROOT = path.resolve(process.cwd(), process.env.SERVER_ROOT || ".");
125
133
  const API_PREFIX = "/api/v0";
126
134
  const isDevelopment = process.env["NODE_ENV"] === "development";
127
135
 
128
- const app = express();
136
+ export const app = express();
129
137
  app.use(loggerMiddleware);
130
138
  app.use(httpMetricsMiddleware);
131
139
  const projectStore = new ProjectStore(SERVER_ROOT);
140
+ const manifestService = new ManifestService(projectStore);
132
141
  const watchModeController = new WatchModeController(projectStore);
133
142
  const connectionController = new ConnectionController(projectStore);
134
143
  const modelController = new ModelController(projectStore);
135
- const packageController = new PackageController(projectStore);
144
+ const packageController = new PackageController(projectStore, manifestService);
136
145
  const databaseController = new DatabaseController(projectStore);
137
146
  const queryController = new QueryController(projectStore);
138
147
  const compileController = new CompileController(projectStore);
148
+ const materializationService = new MaterializationService(
149
+ projectStore,
150
+ manifestService,
151
+ );
152
+ const materializationController = new MaterializationController(
153
+ materializationService,
154
+ );
155
+ const manifestController = new ManifestController(
156
+ projectStore,
157
+ manifestService,
158
+ );
139
159
 
140
160
  export const mcpApp = express();
141
161
 
@@ -262,7 +282,9 @@ app.use(
262
282
  credentials: true,
263
283
  }),
264
284
  );
265
- app.use(bodyParser.json());
285
+
286
+ // Set body-parser JSON limit to 1Mb (default: 100kb)
287
+ app.use(bodyParser.json({ limit: "1mb" }));
266
288
 
267
289
  // Register health check endpoints on main app:
268
290
  // - Required for production/Kubernetes monitoring (main server on PUBLISHER_PORT)
@@ -579,7 +601,7 @@ app.post(
579
601
  req.params.projectName,
580
602
  req.params.connectionName,
581
603
  req.body.sqlStatement as string,
582
- req.query.options as string,
604
+ req.body.options as string,
583
605
  ),
584
606
  );
585
607
  } catch (error) {
@@ -650,9 +672,11 @@ app.get(`${API_PREFIX}/projects/:projectName/packages`, async (req, res) => {
650
672
 
651
673
  app.post(`${API_PREFIX}/projects/:projectName/packages`, async (req, res) => {
652
674
  try {
675
+ const autoLoadManifest = req.query.autoLoadManifest === "true";
653
676
  const _package = await packageController.addPackage(
654
677
  req.params.projectName,
655
678
  req.body,
679
+ { autoLoadManifest },
656
680
  );
657
681
  res.status(200).json(_package?.getPackageMetadata());
658
682
  } catch (error) {
@@ -953,6 +977,173 @@ app.post(
953
977
  },
954
978
  );
955
979
 
980
+ // ==================== MATERIALIZATION ROUTES ====================
981
+
982
+ app.post(
983
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations`,
984
+ async (req, res) => {
985
+ try {
986
+ const build = await materializationController.createMaterialization(
987
+ req.params.projectName,
988
+ req.params.packageName,
989
+ req.body || {},
990
+ );
991
+ res.status(201).json(build);
992
+ } catch (error) {
993
+ const { json, status } = internalErrorToHttpError(error as Error);
994
+ res.status(status).json(json);
995
+ }
996
+ },
997
+ );
998
+
999
+ app.get(
1000
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations`,
1001
+ async (req, res) => {
1002
+ try {
1003
+ const limit = req.query.limit
1004
+ ? parseInt(req.query.limit as string, 10)
1005
+ : undefined;
1006
+ const offset = req.query.offset
1007
+ ? parseInt(req.query.offset as string, 10)
1008
+ : undefined;
1009
+ const builds = await materializationController.listMaterializations(
1010
+ req.params.projectName,
1011
+ req.params.packageName,
1012
+ { limit, offset },
1013
+ );
1014
+ res.status(200).json(builds);
1015
+ } catch (error) {
1016
+ const { json, status } = internalErrorToHttpError(error as Error);
1017
+ res.status(status).json(json);
1018
+ }
1019
+ },
1020
+ );
1021
+
1022
+ app.get(
1023
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
1024
+ async (req, res) => {
1025
+ try {
1026
+ const build = await materializationController.getMaterialization(
1027
+ req.params.projectName,
1028
+ req.params.packageName,
1029
+ req.params.materializationId,
1030
+ );
1031
+ res.status(200).json(build);
1032
+ } catch (error) {
1033
+ const { json, status } = internalErrorToHttpError(error as Error);
1034
+ res.status(status).json(json);
1035
+ }
1036
+ },
1037
+ );
1038
+
1039
+ app.post(
1040
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/teardown`,
1041
+ async (req, res) => {
1042
+ try {
1043
+ const result = await materializationController.teardownPackage(
1044
+ req.params.projectName,
1045
+ req.params.packageName,
1046
+ req.body || {},
1047
+ );
1048
+ res.status(200).json(result);
1049
+ } catch (error) {
1050
+ const { json, status } = internalErrorToHttpError(error as Error);
1051
+ res.status(status).json(json);
1052
+ }
1053
+ },
1054
+ );
1055
+
1056
+ app.post(
1057
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
1058
+ async (req, res) => {
1059
+ try {
1060
+ const action = req.query.action;
1061
+ if (action === "start") {
1062
+ const build = await materializationController.startMaterialization(
1063
+ req.params.projectName,
1064
+ req.params.packageName,
1065
+ req.params.materializationId,
1066
+ );
1067
+ res.status(202).json(build);
1068
+ } else if (action === "stop") {
1069
+ const build = await materializationController.stopMaterialization(
1070
+ req.params.projectName,
1071
+ req.params.packageName,
1072
+ req.params.materializationId,
1073
+ );
1074
+ res.status(200).json(build);
1075
+ } else {
1076
+ throw new BadRequestError(
1077
+ `Unsupported action '${String(action ?? "")}'. Expected 'start' or 'stop'.`,
1078
+ );
1079
+ }
1080
+ } catch (error) {
1081
+ const { json, status } = internalErrorToHttpError(error as Error);
1082
+ res.status(status).json(json);
1083
+ }
1084
+ },
1085
+ );
1086
+
1087
+ app.delete(
1088
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/materializations/:materializationId`,
1089
+ async (req, res) => {
1090
+ try {
1091
+ await materializationController.deleteMaterialization(
1092
+ req.params.projectName,
1093
+ req.params.packageName,
1094
+ req.params.materializationId,
1095
+ );
1096
+ res.status(204).send();
1097
+ } catch (error) {
1098
+ const { json, status } = internalErrorToHttpError(error as Error);
1099
+ res.status(status).json(json);
1100
+ }
1101
+ },
1102
+ );
1103
+
1104
+ // ==================== MANIFEST ROUTES ====================
1105
+
1106
+ app.get(
1107
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/manifest`,
1108
+ async (req, res) => {
1109
+ try {
1110
+ const manifest = await manifestController.getManifest(
1111
+ req.params.projectName,
1112
+ req.params.packageName,
1113
+ );
1114
+ res.status(200).json(manifest);
1115
+ } catch (error) {
1116
+ logger.error("Get manifest error", { error });
1117
+ const { json, status } = internalErrorToHttpError(error as Error);
1118
+ res.status(status).json(json);
1119
+ }
1120
+ },
1121
+ );
1122
+
1123
+ app.post(
1124
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/manifest`,
1125
+ async (req, res) => {
1126
+ try {
1127
+ const action = req.query.action;
1128
+ if (action === "reload") {
1129
+ const manifest = await manifestController.reloadManifest(
1130
+ req.params.projectName,
1131
+ req.params.packageName,
1132
+ );
1133
+ res.status(200).json(manifest);
1134
+ } else {
1135
+ throw new BadRequestError(
1136
+ `Unsupported action '${String(action ?? "")}'. Expected 'reload'.`,
1137
+ );
1138
+ }
1139
+ } catch (error) {
1140
+ logger.error("Manifest action error", { error });
1141
+ const { json, status } = internalErrorToHttpError(error as Error);
1142
+ res.status(status).json(json);
1143
+ }
1144
+ },
1145
+ );
1146
+
956
1147
  // Modify the catch-all route to only serve index.html in production
957
1148
  if (!isDevelopment) {
958
1149
  app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));