@malloy-publisher/server 0.0.205 → 0.0.207
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 +394 -395
- package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
- package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
- package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
- package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
- package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
- package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
- package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
- package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
- package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
- package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
- package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.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 +1272 -1299
- 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 +121 -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 +270 -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 +584 -500
- package/src/service/materialization_service.ts +839 -657
- package/src/service/model.ts +419 -15
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +339 -29
- 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 +194 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
- 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
|
@@ -7,14 +7,21 @@ import * as fs from "fs";
|
|
|
7
7
|
import * as path from "path";
|
|
8
8
|
import { pathToFileURL } from "url";
|
|
9
9
|
import { components } from "../api";
|
|
10
|
-
import { API_PREFIX, README_NAME } from "../constants";
|
|
11
10
|
import {
|
|
11
|
+
API_PREFIX,
|
|
12
|
+
normalizeModelPath,
|
|
13
|
+
NOTEBOOK_FILE_SUFFIX,
|
|
14
|
+
README_NAME,
|
|
15
|
+
} from "../constants";
|
|
16
|
+
import {
|
|
17
|
+
BadRequestError,
|
|
12
18
|
ConnectionNotFoundError,
|
|
13
19
|
EnvironmentNotFoundError,
|
|
14
20
|
PackageNotFoundError,
|
|
15
21
|
ServiceUnavailableError,
|
|
16
22
|
} from "../errors";
|
|
17
23
|
import { logger } from "../logger";
|
|
24
|
+
import { recordManifestBind } from "../materialization_metrics";
|
|
18
25
|
import {
|
|
19
26
|
assertSafeEnvironmentPath,
|
|
20
27
|
assertSafePackageName,
|
|
@@ -29,6 +36,7 @@ import {
|
|
|
29
36
|
EnvironmentMalloyConfig,
|
|
30
37
|
InternalConnection,
|
|
31
38
|
} from "./connection";
|
|
39
|
+
import { fetchManifestEntries } from "./manifest_loader";
|
|
32
40
|
import { ApiConnection } from "./model";
|
|
33
41
|
import { Package } from "./package";
|
|
34
42
|
import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
@@ -49,6 +57,11 @@ import type { PackageMemoryGovernor } from "./package_memory_governor";
|
|
|
49
57
|
const STAGING_DIR_NAME = ".staging";
|
|
50
58
|
const RETIRED_DIR_NAME = ".retired";
|
|
51
59
|
|
|
60
|
+
// How long to wait for a control-plane manifest fetch during (re)bind before
|
|
61
|
+
// giving up and serving live. Binding happens before a package is marked
|
|
62
|
+
// SERVING, so an unreachable/slow manifest store must not block the package.
|
|
63
|
+
const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
|
|
64
|
+
|
|
52
65
|
export enum PackageStatus {
|
|
53
66
|
LOADING = "loading",
|
|
54
67
|
SERVING = "serving",
|
|
@@ -187,10 +200,6 @@ export class Environment {
|
|
|
187
200
|
await this.writeEnvironmentReadme(payload.readme);
|
|
188
201
|
}
|
|
189
202
|
|
|
190
|
-
if (payload.materializationStorage !== undefined) {
|
|
191
|
-
this.metadata.materializationStorage = payload.materializationStorage;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
203
|
// Handle connections update
|
|
195
204
|
// TODO: Update environment connections should have its own API endpoint
|
|
196
205
|
if (payload.connections) {
|
|
@@ -296,6 +305,17 @@ export class Environment {
|
|
|
296
305
|
): Promise<{ problems: LogMessage[]; sql?: string }> {
|
|
297
306
|
assertSafePackageName(packageName);
|
|
298
307
|
assertSafeRelativeModelPath(modelName);
|
|
308
|
+
// /compile appends the submitted source to the TARGET MODEL's content for
|
|
309
|
+
// namespace context. A notebook (.malloynb) is markdown + cells, not a
|
|
310
|
+
// model, so compiling against it only yields a confusing parse error —
|
|
311
|
+
// reject it up front with an actionable message. (Notebooks remain public
|
|
312
|
+
// for discovery/query; this is specific to the compile context.)
|
|
313
|
+
if (modelName.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
314
|
+
throw new BadRequestError(
|
|
315
|
+
`Cannot compile against a notebook ("${modelName}"). ` +
|
|
316
|
+
`/compile takes a .malloy model path for namespace context.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
299
319
|
// Hold the per-package mutex for the duration of every disk read —
|
|
300
320
|
// both the explicit `fs.readFile(modelPath)` below and the implicit
|
|
301
321
|
// import resolution that `runtime.loadModel` does through the URL
|
|
@@ -365,15 +385,31 @@ export class Environment {
|
|
|
365
385
|
// and compilation surfaces its own error.
|
|
366
386
|
const gateModel = pkg.getModel(modelName);
|
|
367
387
|
if (gateModel) {
|
|
388
|
+
// Query boundary first (the *what* axis): /compile compiles ad-hoc
|
|
389
|
+
// text against a model, so gate it like an ad-hoc query — a
|
|
390
|
+
// non-`explores` model file, or text whose surface-resolved target
|
|
391
|
+
// is a non-curated model source (under queryableSources:
|
|
392
|
+
// "declared"), is rejected with a generic 404 before compilation
|
|
393
|
+
// can leak schema/SQL. Text the early gate can't pin is settled by
|
|
394
|
+
// the compiled backstop below.
|
|
395
|
+
gateModel.assertQueryBoundaryEarly(undefined, undefined, source);
|
|
368
396
|
await gateModel.assertAuthorizedForText(source, givens ?? {});
|
|
369
397
|
}
|
|
370
398
|
|
|
371
399
|
// Initialize Runtime with the package's active MalloyConfig so compile
|
|
372
400
|
// checks see the same package-scoped duckdb as execution. This runtime
|
|
373
401
|
// borrows the package config; the package/environment lifecycle owns release.
|
|
402
|
+
// Thread the package's bound build manifest (when present) so the
|
|
403
|
+
// /compile preview routes persist sources to their materialized tables
|
|
404
|
+
// exactly like execution does — otherwise includeSql=true would always
|
|
405
|
+
// show base-table SQL and diverge from what executeQuery actually runs.
|
|
406
|
+
const boundManifestEntries = pkg.getBuildManifestEntries();
|
|
374
407
|
const runtime = new Runtime({
|
|
375
408
|
urlReader: interceptingReader,
|
|
376
409
|
config: pkg.getMalloyConfig(),
|
|
410
|
+
buildManifest: boundManifestEntries
|
|
411
|
+
? { entries: boundManifestEntries, strict: false }
|
|
412
|
+
: undefined,
|
|
377
413
|
});
|
|
378
414
|
|
|
379
415
|
// Attempt to compile
|
|
@@ -392,8 +428,8 @@ export class Environment {
|
|
|
392
428
|
// gate or extract beyond the early text gate already applied.
|
|
393
429
|
}
|
|
394
430
|
|
|
395
|
-
// Compiled-source
|
|
396
|
-
// the source the COMPILED final query actually reads, closing
|
|
431
|
+
// Compiled-source backstops — run REGARDLESS of includeSql. They
|
|
432
|
+
// gate the source the COMPILED final query actually reads, closing
|
|
397
433
|
// named-query / multi-statement indirection the early surface-syntax
|
|
398
434
|
// gate misses (e.g. `run: ungated\nrun: gated` — the early gate only
|
|
399
435
|
// matches the FIRST `run:`, but the LAST statement is what executes).
|
|
@@ -401,9 +437,26 @@ export class Environment {
|
|
|
401
437
|
// (field-not-found errors leak its columns), so this must not be
|
|
402
438
|
// conditional on SQL extraction. (A `source: x is gated` alias makes
|
|
403
439
|
// a new ungated source — that's the documented "extend doesn't
|
|
404
|
-
// inherit authorize" footgun, the same as the query path.)
|
|
405
|
-
|
|
406
|
-
// the
|
|
440
|
+
// inherit authorize" footgun, the same as the query path.)
|
|
441
|
+
|
|
442
|
+
// Boundary backstop (the *what* axis, 404) before the authorize
|
|
443
|
+
// one (the *who* axis, 403). /compile text is always ad-hoc — the
|
|
444
|
+
// early gate can only positively deny, never fully clear — so the
|
|
445
|
+
// compiled final query's run target is the authority. Self-gates
|
|
446
|
+
// internally (no-ops when the boundary is inert: "all" / no
|
|
447
|
+
// explores), so it is deliberately NOT guarded by hasAuthorize().
|
|
448
|
+
// Text that compiles only source definitions (no final query) has
|
|
449
|
+
// no run target and nothing to gate.
|
|
450
|
+
if (queryMaterializer && gateModel) {
|
|
451
|
+
await gateModel.assertQueryBoundaryForRunnable(
|
|
452
|
+
queryMaterializer,
|
|
453
|
+
source,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Authorize backstop (the *who* axis, 403). Only run when the model
|
|
458
|
+
// declares gates so ungated compiles don't pay for the extra
|
|
459
|
+
// final-query compile.
|
|
407
460
|
if (queryMaterializer && gateModel?.hasAuthorize()) {
|
|
408
461
|
await gateModel.assertAuthorizedForRunnable(
|
|
409
462
|
queryMaterializer,
|
|
@@ -792,6 +845,7 @@ export class Environment {
|
|
|
792
845
|
packagePath,
|
|
793
846
|
() => this.malloyConfig.malloyConfig,
|
|
794
847
|
);
|
|
848
|
+
await this.bindManifestIfConfigured(_package);
|
|
795
849
|
if (existingPackage !== undefined && reload) {
|
|
796
850
|
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
797
851
|
existingPackage.getMalloyConfig().shutdown("close"),
|
|
@@ -897,6 +951,7 @@ export class Environment {
|
|
|
897
951
|
public async installPackage(
|
|
898
952
|
packageName: string,
|
|
899
953
|
downloader: (stagingPath: string) => Promise<void>,
|
|
954
|
+
validate?: (pkg: Package) => string | undefined,
|
|
900
955
|
): Promise<Package> {
|
|
901
956
|
assertSafePackageName(packageName);
|
|
902
957
|
const stagingPath = this.allocateStagingPath(packageName);
|
|
@@ -963,6 +1018,15 @@ export class Environment {
|
|
|
963
1018
|
canonicalPath,
|
|
964
1019
|
() => this.malloyConfig.malloyConfig,
|
|
965
1020
|
);
|
|
1021
|
+
// Strict-reject hook (publish/update only — reload passes no
|
|
1022
|
+
// validator and stays fail-safe). Throw INSIDE the try so the
|
|
1023
|
+
// catch below rolls the swap back: the just-installed tree is
|
|
1024
|
+
// wiped and the retired tree (if any) is restored, so a rejected
|
|
1025
|
+
// publish/update never leaves the bad tree served on disk.
|
|
1026
|
+
const validationMsg = validate?.(newPackage);
|
|
1027
|
+
if (validationMsg) {
|
|
1028
|
+
throw new BadRequestError(validationMsg);
|
|
1029
|
+
}
|
|
966
1030
|
logger.debug("install.phase2.committed", {
|
|
967
1031
|
environmentName: this.environmentName,
|
|
968
1032
|
packageName,
|
|
@@ -1008,6 +1072,11 @@ export class Environment {
|
|
|
1008
1072
|
throw err;
|
|
1009
1073
|
}
|
|
1010
1074
|
|
|
1075
|
+
// Best-effort manifest bind happens after the swap commits, outside the
|
|
1076
|
+
// rollback window: a manifest that can't be fetched must not undo an
|
|
1077
|
+
// otherwise-successful install (the package serves live instead).
|
|
1078
|
+
await this.bindManifestIfConfigured(newPackage);
|
|
1079
|
+
|
|
1011
1080
|
this.packages.set(packageName, newPackage);
|
|
1012
1081
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1013
1082
|
|
|
@@ -1062,6 +1131,91 @@ export class Environment {
|
|
|
1062
1131
|
});
|
|
1063
1132
|
}
|
|
1064
1133
|
|
|
1134
|
+
/**
|
|
1135
|
+
* If the freshly-loaded package declares a `manifestLocation`, fetch the
|
|
1136
|
+
* control-plane-computed build manifest and rebind its models so persist
|
|
1137
|
+
* references resolve to the materialized tables. Best-effort: a fetch/bind
|
|
1138
|
+
* failure logs a warning and leaves the package serving live (the models are
|
|
1139
|
+
* already loaded without a manifest). Callers must hold the package lock —
|
|
1140
|
+
* this rebinds `pkg` in place rather than re-entering {@link withPackageLock}.
|
|
1141
|
+
*/
|
|
1142
|
+
private async bindManifestIfConfigured(pkg: Package): Promise<void> {
|
|
1143
|
+
const manifestLocation = pkg.getPackageMetadata().manifestLocation;
|
|
1144
|
+
if (!manifestLocation) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
await this.bindManifest(pkg, manifestLocation);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Fetch + bind a specific manifest URI onto an already-loaded package. */
|
|
1151
|
+
private async bindManifest(
|
|
1152
|
+
pkg: Package,
|
|
1153
|
+
manifestLocation: string,
|
|
1154
|
+
): Promise<void> {
|
|
1155
|
+
const packageName = pkg.getPackageName();
|
|
1156
|
+
try {
|
|
1157
|
+
// Bind runs before a package is marked SERVING, so a slow/unreachable
|
|
1158
|
+
// manifest store must not block serving indefinitely — bound is the
|
|
1159
|
+
// intended state, live is the degraded fallback. Race the fetch against
|
|
1160
|
+
// a timeout and fall back to live on either failure.
|
|
1161
|
+
const entries =
|
|
1162
|
+
await this.fetchManifestEntriesWithTimeout(manifestLocation);
|
|
1163
|
+
await pkg.reloadAllModels(entries);
|
|
1164
|
+
pkg.setBoundManifestUri(manifestLocation);
|
|
1165
|
+
recordManifestBind("success");
|
|
1166
|
+
logger.info("Bound build manifest to package", {
|
|
1167
|
+
environmentName: this.environmentName,
|
|
1168
|
+
packageName,
|
|
1169
|
+
manifestLocation,
|
|
1170
|
+
entryCount: Object.keys(entries).length,
|
|
1171
|
+
});
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
pkg.markManifestBindFailed();
|
|
1174
|
+
const timedOut =
|
|
1175
|
+
err instanceof Error && err.message.includes("Timed out after");
|
|
1176
|
+
recordManifestBind(timedOut ? "timeout" : "failure");
|
|
1177
|
+
logger.warn("Failed to bind build manifest; serving live", {
|
|
1178
|
+
environmentName: this.environmentName,
|
|
1179
|
+
packageName,
|
|
1180
|
+
manifestLocation,
|
|
1181
|
+
timedOut,
|
|
1182
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Fetch manifest entries, rejecting if the fetch exceeds
|
|
1189
|
+
* {@link MANIFEST_FETCH_TIMEOUT_MS}. Keeps {@link bindManifest}'s bind-before-
|
|
1190
|
+
* serve guarantee from stalling on an unreachable manifest store.
|
|
1191
|
+
*/
|
|
1192
|
+
private async fetchManifestEntriesWithTimeout(
|
|
1193
|
+
manifestLocation: string,
|
|
1194
|
+
): Promise<BuildManifest["entries"]> {
|
|
1195
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1196
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
1197
|
+
timer = setTimeout(
|
|
1198
|
+
() =>
|
|
1199
|
+
reject(
|
|
1200
|
+
new Error(
|
|
1201
|
+
`Timed out after ${MANIFEST_FETCH_TIMEOUT_MS}ms fetching manifest ${manifestLocation}`,
|
|
1202
|
+
),
|
|
1203
|
+
),
|
|
1204
|
+
MANIFEST_FETCH_TIMEOUT_MS,
|
|
1205
|
+
);
|
|
1206
|
+
});
|
|
1207
|
+
try {
|
|
1208
|
+
return await Promise.race([
|
|
1209
|
+
fetchManifestEntries(manifestLocation),
|
|
1210
|
+
timeout,
|
|
1211
|
+
]);
|
|
1212
|
+
} finally {
|
|
1213
|
+
if (timer) {
|
|
1214
|
+
clearTimeout(timer);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1065
1219
|
/**
|
|
1066
1220
|
* Read a model's source text from disk, holding the per-package mutex
|
|
1067
1221
|
* so the read is serialized against {@link installPackage} /
|
|
@@ -1086,7 +1240,13 @@ export class Environment {
|
|
|
1086
1240
|
|
|
1087
1241
|
private async writePackageManifest(
|
|
1088
1242
|
packageName: string,
|
|
1089
|
-
metadata: {
|
|
1243
|
+
metadata: {
|
|
1244
|
+
name: string;
|
|
1245
|
+
description?: string;
|
|
1246
|
+
explores?: string[];
|
|
1247
|
+
queryableSources?: "declared" | "all";
|
|
1248
|
+
manifestLocation?: string | null;
|
|
1249
|
+
},
|
|
1090
1250
|
): Promise<void> {
|
|
1091
1251
|
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
1092
1252
|
const manifestPath = safeJoinUnderRoot(packagePath, "publisher.json");
|
|
@@ -1101,11 +1261,23 @@ export class Environment {
|
|
|
1101
1261
|
logger.warn(`Could not read manifest for ${packageName}`);
|
|
1102
1262
|
}
|
|
1103
1263
|
|
|
1104
|
-
// Update with new metadata
|
|
1264
|
+
// Update with new metadata. `explores`/`queryableSources` are only
|
|
1265
|
+
// overwritten when the caller explicitly provides them; otherwise the
|
|
1266
|
+
// existing on-disk value is preserved via the spread (an undefined here
|
|
1267
|
+
// must not erase it).
|
|
1105
1268
|
const updatedManifest = {
|
|
1106
1269
|
...existingManifest,
|
|
1107
1270
|
name: metadata.name,
|
|
1108
1271
|
description: metadata.description,
|
|
1272
|
+
...(metadata.explores !== undefined
|
|
1273
|
+
? { explores: metadata.explores }
|
|
1274
|
+
: {}),
|
|
1275
|
+
...(metadata.queryableSources !== undefined
|
|
1276
|
+
? { queryableSources: metadata.queryableSources }
|
|
1277
|
+
: {}),
|
|
1278
|
+
...(metadata.manifestLocation !== undefined
|
|
1279
|
+
? { manifestLocation: metadata.manifestLocation }
|
|
1280
|
+
: {}),
|
|
1109
1281
|
};
|
|
1110
1282
|
|
|
1111
1283
|
// Write back to file
|
|
@@ -1132,18 +1304,72 @@ export class Environment {
|
|
|
1132
1304
|
if (body.name) {
|
|
1133
1305
|
_package.setName(body.name);
|
|
1134
1306
|
}
|
|
1307
|
+
// Preserve `explores` across a metadata PATCH. `setPackageMetadata`
|
|
1308
|
+
// replaces the whole object, so a name/description-only update must
|
|
1309
|
+
// carry the existing discovery surface through — otherwise the
|
|
1310
|
+
// in-memory `explores` is wiped and `listModels()` silently starts
|
|
1311
|
+
// serving every model until the next reload. When the body explicitly
|
|
1312
|
+
// carries `explores`, honor the new set instead.
|
|
1313
|
+
const existing = _package.getPackageMetadata();
|
|
1314
|
+
// Normalize API-body explores through the same helper the worker uses
|
|
1315
|
+
// for on-disk explores, so `["./index.malloy"]` / backslash paths
|
|
1316
|
+
// validate and persist identically regardless of input channel (no
|
|
1317
|
+
// misleading publish-time 400, no publish-vs-reload divergence).
|
|
1318
|
+
const normalizedExplores = body.explores?.map(normalizeModelPath);
|
|
1319
|
+
const explores =
|
|
1320
|
+
normalizedExplores !== undefined
|
|
1321
|
+
? normalizedExplores
|
|
1322
|
+
: existing.explores;
|
|
1323
|
+
const queryableSources =
|
|
1324
|
+
body.queryableSources !== undefined
|
|
1325
|
+
? body.queryableSources
|
|
1326
|
+
: existing.queryableSources;
|
|
1327
|
+
// Preserve the existing manifestLocation unless the body explicitly
|
|
1328
|
+
// sets it (including to null, which clears it and reverts to live).
|
|
1329
|
+
const manifestLocation =
|
|
1330
|
+
body.manifestLocation !== undefined
|
|
1331
|
+
? body.manifestLocation
|
|
1332
|
+
: existing.manifestLocation;
|
|
1135
1333
|
_package.setPackageMetadata({
|
|
1136
1334
|
name: body.name,
|
|
1137
1335
|
description: body.description,
|
|
1138
1336
|
resource: body.resource,
|
|
1139
1337
|
location: body.location,
|
|
1338
|
+
explores,
|
|
1339
|
+
queryableSources,
|
|
1340
|
+
manifestLocation,
|
|
1140
1341
|
});
|
|
1141
1342
|
|
|
1343
|
+
// Strict-reject, symmetric with the publish path
|
|
1344
|
+
// (package.controller.addPackage): validate the resulting explores
|
|
1345
|
+
// against the live model set and restore the prior metadata before
|
|
1346
|
+
// rejecting, so a bad update neither persists nor mutates the served
|
|
1347
|
+
// surface.
|
|
1348
|
+
const invalidMsg = _package.formatInvalidExplores();
|
|
1349
|
+
if (invalidMsg) {
|
|
1350
|
+
_package.setPackageMetadata(existing);
|
|
1351
|
+
throw new BadRequestError(invalidMsg);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1142
1354
|
await this.writePackageManifest(packageName, {
|
|
1143
1355
|
name: packageName,
|
|
1144
1356
|
description: body.description,
|
|
1357
|
+
explores: normalizedExplores,
|
|
1358
|
+
queryableSources: body.queryableSources,
|
|
1359
|
+
manifestLocation: body.manifestLocation,
|
|
1145
1360
|
});
|
|
1146
1361
|
|
|
1362
|
+
// When the body changes manifestLocation, apply it now so the new
|
|
1363
|
+
// binding takes effect without a separate reload: a URI rebinds models
|
|
1364
|
+
// to the materialized tables; null/empty reverts the package to live.
|
|
1365
|
+
if (body.manifestLocation !== undefined) {
|
|
1366
|
+
if (body.manifestLocation) {
|
|
1367
|
+
await this.bindManifest(_package, body.manifestLocation);
|
|
1368
|
+
} else {
|
|
1369
|
+
await _package.reloadAllModels({});
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1147
1373
|
return _package.getPackageMetadata();
|
|
1148
1374
|
});
|
|
1149
1375
|
}
|
|
@@ -1245,6 +1471,38 @@ export class Environment {
|
|
|
1245
1471
|
});
|
|
1246
1472
|
}
|
|
1247
1473
|
|
|
1474
|
+
/**
|
|
1475
|
+
* Evict a package from the in-memory caches WITHOUT touching its on-disk
|
|
1476
|
+
* directory — the non-destructive counterpart to {@link deletePackage}.
|
|
1477
|
+
*
|
|
1478
|
+
* Used to roll back a no-location `addPackage` (which registers a
|
|
1479
|
+
* *pre-existing*, user-owned directory) when post-load validation rejects
|
|
1480
|
+
* it: deleting the tree there would destroy content the publisher never
|
|
1481
|
+
* created. This still drains and closes the connections the just-created
|
|
1482
|
+
* `Package` opened, so the duckdb handle isn't leaked.
|
|
1483
|
+
*/
|
|
1484
|
+
public async unloadPackage(packageName: string): Promise<void> {
|
|
1485
|
+
assertSafePackageName(packageName);
|
|
1486
|
+
return this.withPackageLock(packageName, async () => {
|
|
1487
|
+
const _package = this.packages.get(packageName);
|
|
1488
|
+
if (!_package) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (
|
|
1492
|
+
this.packageStatuses.get(packageName)?.status ===
|
|
1493
|
+
PackageStatus.SERVING
|
|
1494
|
+
) {
|
|
1495
|
+
this.setPackageStatus(packageName, PackageStatus.UNLOADING);
|
|
1496
|
+
}
|
|
1497
|
+
// Same 30s connection drain as deletePackage — just no fs rename/rm.
|
|
1498
|
+
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
1499
|
+
_package.getMalloyConfig().shutdown("close"),
|
|
1500
|
+
);
|
|
1501
|
+
this.packages.delete(packageName);
|
|
1502
|
+
this.packageStatuses.delete(packageName);
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1248
1506
|
public updateConnections(
|
|
1249
1507
|
malloyConfig: EnvironmentMalloyConfig,
|
|
1250
1508
|
_apiConnections?: ApiConnection[],
|
|
@@ -11,12 +11,6 @@ import { EnvironmentStore } from "./environment_store";
|
|
|
11
11
|
|
|
12
12
|
type MockData = Record<string, unknown>;
|
|
13
13
|
|
|
14
|
-
const initializeDuckLakeCalls: Array<{
|
|
15
|
-
environmentId: string;
|
|
16
|
-
environmentName: string;
|
|
17
|
-
config: { catalogUrl: string; dataPath: string };
|
|
18
|
-
}> = [];
|
|
19
|
-
|
|
20
14
|
mock.module("../storage/StorageManager", () => {
|
|
21
15
|
return {
|
|
22
16
|
StorageManager: class MockStorageManager {
|
|
@@ -24,18 +18,6 @@ mock.module("../storage/StorageManager", () => {
|
|
|
24
18
|
return;
|
|
25
19
|
}
|
|
26
20
|
|
|
27
|
-
async initializeDuckLakeForEnvironment(
|
|
28
|
-
environmentId: string,
|
|
29
|
-
environmentName: string,
|
|
30
|
-
config: { catalogUrl: string; dataPath: string },
|
|
31
|
-
): Promise<void> {
|
|
32
|
-
initializeDuckLakeCalls.push({
|
|
33
|
-
environmentId,
|
|
34
|
-
environmentName,
|
|
35
|
-
config,
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
21
|
getRepository() {
|
|
40
22
|
return {
|
|
41
23
|
// ===== PROJECT METHODS =====
|
|
@@ -488,69 +470,6 @@ describe("EnvironmentStore Service", () => {
|
|
|
488
470
|
expect(readmeContent).toBe("Updated README content");
|
|
489
471
|
});
|
|
490
472
|
|
|
491
|
-
it("should propagate materializationStorage on addEnvironment for new environment", async () => {
|
|
492
|
-
writeFileSync(
|
|
493
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
494
|
-
JSON.stringify({ frozenConfig: false, environments: [] }),
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
await environmentStore.finishedInitialization;
|
|
498
|
-
|
|
499
|
-
const materializationStorage = {
|
|
500
|
-
catalogUrl:
|
|
501
|
-
"postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
|
|
502
|
-
dataPath: "gs://test-bucket",
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
initializeDuckLakeCalls.length = 0;
|
|
506
|
-
const project = await environmentStore.addEnvironment({
|
|
507
|
-
name: projectName,
|
|
508
|
-
materializationStorage,
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
expect(project.metadata.materializationStorage).toEqual(
|
|
512
|
-
materializationStorage,
|
|
513
|
-
);
|
|
514
|
-
expect(initializeDuckLakeCalls).toHaveLength(1);
|
|
515
|
-
expect(initializeDuckLakeCalls[0].config).toEqual(materializationStorage);
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
it("should propagate materializationStorage on update", async () => {
|
|
519
|
-
const projectPath = path.join(serverRootPath, projectName);
|
|
520
|
-
mkdirSync(projectPath, { recursive: true });
|
|
521
|
-
writeFileSync(
|
|
522
|
-
path.join(projectPath, "publisher.json"),
|
|
523
|
-
JSON.stringify({ name: projectName, description: "Test package" }),
|
|
524
|
-
);
|
|
525
|
-
writeFileSync(
|
|
526
|
-
path.join(serverRootPath, "publisher.config.json"),
|
|
527
|
-
JSON.stringify({
|
|
528
|
-
frozenConfig: false,
|
|
529
|
-
environments: [
|
|
530
|
-
{
|
|
531
|
-
name: projectName,
|
|
532
|
-
packages: [{ name: projectName, location: projectPath }],
|
|
533
|
-
},
|
|
534
|
-
],
|
|
535
|
-
}),
|
|
536
|
-
);
|
|
537
|
-
|
|
538
|
-
await environmentStore.finishedInitialization;
|
|
539
|
-
const project = await environmentStore.getEnvironment(projectName);
|
|
540
|
-
|
|
541
|
-
const materializationStorage = {
|
|
542
|
-
catalogUrl:
|
|
543
|
-
"postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
|
|
544
|
-
dataPath: "gs://test-bucket",
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
await project.update({ name: projectName, materializationStorage });
|
|
548
|
-
|
|
549
|
-
expect(project.metadata.materializationStorage).toEqual(
|
|
550
|
-
materializationStorage,
|
|
551
|
-
);
|
|
552
|
-
});
|
|
553
|
-
|
|
554
473
|
it(
|
|
555
474
|
"should handle project reload",
|
|
556
475
|
async () => {
|
|
@@ -452,25 +452,6 @@ export class EnvironmentStore {
|
|
|
452
452
|
dbEnvironment = await repository.createEnvironment(environmentData);
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
-
// Initialize DuckLake manifest storage if configured on the environment.
|
|
456
|
-
const materializationStorage = environment.metadata
|
|
457
|
-
?.materializationStorage as
|
|
458
|
-
| { catalogUrl?: string; dataPath?: string }
|
|
459
|
-
| undefined;
|
|
460
|
-
if (
|
|
461
|
-
materializationStorage?.catalogUrl &&
|
|
462
|
-
materializationStorage?.dataPath
|
|
463
|
-
) {
|
|
464
|
-
await this.storageManager.initializeDuckLakeForEnvironment(
|
|
465
|
-
dbEnvironment.id,
|
|
466
|
-
dbEnvironment.name,
|
|
467
|
-
{
|
|
468
|
-
catalogUrl: materializationStorage.catalogUrl,
|
|
469
|
-
dataPath: materializationStorage.dataPath,
|
|
470
|
-
},
|
|
471
|
-
);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
455
|
return dbEnvironment;
|
|
475
456
|
}
|
|
476
457
|
|
|
@@ -938,10 +919,6 @@ export class EnvironmentStore {
|
|
|
938
919
|
|
|
939
920
|
if (!newEnvironment.metadata) newEnvironment.metadata = {};
|
|
940
921
|
newEnvironment.metadata.location = absoluteEnvironmentPath;
|
|
941
|
-
if (environment.materializationStorage !== undefined) {
|
|
942
|
-
newEnvironment.metadata.materializationStorage =
|
|
943
|
-
environment.materializationStorage;
|
|
944
|
-
}
|
|
945
922
|
|
|
946
923
|
this.environments.set(environmentName, newEnvironment);
|
|
947
924
|
|