@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/src/service/package.ts
CHANGED
|
@@ -83,6 +83,64 @@ export class Package {
|
|
|
83
83
|
this.databases = databases;
|
|
84
84
|
this.models = models;
|
|
85
85
|
this.malloyConfig = malloyConfig;
|
|
86
|
+
this.applyDiscoveryPolicyToModels();
|
|
87
|
+
this.applyQueryBoundaryToModels();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Push the discovery-curation policy down onto each Model. Curation (file
|
|
92
|
+
* listing via `explores` and within-file `export {}` filtering) is enabled
|
|
93
|
+
* only when `explores` is declared in publisher.json — absent/empty
|
|
94
|
+
* `explores` preserves legacy listings. Re-derived on reload and metadata
|
|
95
|
+
* PATCH (the inputs can change there).
|
|
96
|
+
*/
|
|
97
|
+
/** True when the package opts into curated discovery via a non-empty
|
|
98
|
+
* `explores`. Single source of truth so the curation/boundary/listing
|
|
99
|
+
* derivations can't drift out of sync. */
|
|
100
|
+
private exploresDeclared(): boolean {
|
|
101
|
+
const explores = this.packageMetadata.explores;
|
|
102
|
+
return !!(explores && explores.length > 0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The declared explore set, or null when discovery is uncurated. */
|
|
106
|
+
private exploreSet(): Set<string> | null {
|
|
107
|
+
const explores = this.packageMetadata.explores;
|
|
108
|
+
return explores && explores.length > 0 ? new Set(explores) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private applyDiscoveryPolicyToModels(): void {
|
|
112
|
+
const curationEnabled = this.exploresDeclared();
|
|
113
|
+
for (const model of this.models.values()) {
|
|
114
|
+
model.setDiscoveryCuration(curationEnabled);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Push the package-level query-boundary policy down onto each Model so the
|
|
120
|
+
* query chokepoints can enforce it without a back-reference to the Package:
|
|
121
|
+
* `Model.getQueryResults` (the HTTP query route and the MCP tool) and the
|
|
122
|
+
* `/compile` path (via `assertQueryBoundaryForRunnable`). Derived once here
|
|
123
|
+
* (and on reload) rather than per query: the policy only changes when the
|
|
124
|
+
* manifest is (re)read.
|
|
125
|
+
*
|
|
126
|
+
* Policy: queryable == discoverable. The boundary is inert unless `explores`
|
|
127
|
+
* is declared (no curated surface ⇒ nothing to restrict) AND
|
|
128
|
+
* `queryableSources` is "declared" (the default; "all" decouples the axes).
|
|
129
|
+
* When active, a model file is a query entry point only if it is listed in
|
|
130
|
+
* `explores`; within-file curation (`export {}`) is read off each Model.
|
|
131
|
+
*/
|
|
132
|
+
private applyQueryBoundaryToModels(): void {
|
|
133
|
+
const exploresDeclared = this.exploresDeclared();
|
|
134
|
+
const exploreSet = this.exploreSet();
|
|
135
|
+
const mode =
|
|
136
|
+
this.packageMetadata.queryableSources === "all" ? "all" : "declared";
|
|
137
|
+
for (const [modelPath, model] of this.models) {
|
|
138
|
+
model.setQueryBoundary({
|
|
139
|
+
mode,
|
|
140
|
+
exploresDeclared,
|
|
141
|
+
isQueryEntryPoint: exploreSet ? exploreSet.has(modelPath) : true,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
86
144
|
}
|
|
87
145
|
|
|
88
146
|
static async create(
|
|
@@ -138,13 +196,24 @@ export class Package {
|
|
|
138
196
|
malloy_package_name: packageName,
|
|
139
197
|
status,
|
|
140
198
|
});
|
|
141
|
-
// Clean up package directory on failure
|
|
199
|
+
// Clean up the package directory on failure, but NOT when packagePath
|
|
200
|
+
// is an in-place mount symlink (watch mode). Removing it would unmount
|
|
201
|
+
// the package, so a transient compile error from a half-typed model
|
|
202
|
+
// saved mid-edit would brick the package until a restart. The symlink
|
|
203
|
+
// points at the user's live source, which is left untouched; the next
|
|
204
|
+
// save recompiles against it.
|
|
142
205
|
try {
|
|
143
|
-
await fs.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
206
|
+
const stat = await fs.lstat(packagePath).catch(() => null);
|
|
207
|
+
if (stat?.isSymbolicLink()) {
|
|
208
|
+
logger.info(
|
|
209
|
+
`Skipping cleanup of symlinked package path on failure: ${packagePath}`,
|
|
210
|
+
);
|
|
211
|
+
} else {
|
|
212
|
+
await fs.rm(packagePath, { recursive: true, force: true });
|
|
213
|
+
logger.info(
|
|
214
|
+
`Cleaned up failed package directory: ${packagePath}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
148
217
|
} catch (cleanupError) {
|
|
149
218
|
logger.warn(`Failed to clean up package directory ${packagePath}`, {
|
|
150
219
|
error: cleanupError,
|
|
@@ -240,6 +309,9 @@ export class Package {
|
|
|
240
309
|
name: outcome.packageMetadata.name,
|
|
241
310
|
description: outcome.packageMetadata.description,
|
|
242
311
|
resource: `${API_PREFIX}/environments/${environmentName}/packages/${packageName}`,
|
|
312
|
+
explores: outcome.packageMetadata.explores,
|
|
313
|
+
queryableSources: outcome.packageMetadata.queryableSources,
|
|
314
|
+
manifestLocation: outcome.packageMetadata.manifestLocation ?? null,
|
|
243
315
|
};
|
|
244
316
|
|
|
245
317
|
// Build live `Model`s from worker output. Any per-model compile
|
|
@@ -261,10 +333,18 @@ export class Package {
|
|
|
261
333
|
// cleans the package directory.
|
|
262
334
|
throw err;
|
|
263
335
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
336
|
+
const model = Model.fromSerialized(
|
|
337
|
+
packageName,
|
|
338
|
+
packagePath,
|
|
339
|
+
malloyConfig,
|
|
340
|
+
sm,
|
|
267
341
|
);
|
|
342
|
+
// Validate renderer tags on the main thread (the renderer is too heavy
|
|
343
|
+
// to load inside the pure-CPU package-load worker). A misconfigured tag
|
|
344
|
+
// throws a ModelCompilationError (424), aborting the whole load like any
|
|
345
|
+
// other per-model compile error above.
|
|
346
|
+
await model.validateRenderTags();
|
|
347
|
+
models.set(sm.modelPath, model);
|
|
268
348
|
}
|
|
269
349
|
|
|
270
350
|
const endTime = performance.now();
|
|
@@ -278,7 +358,7 @@ export class Package {
|
|
|
278
358
|
duration: formatDuration(executionTime),
|
|
279
359
|
});
|
|
280
360
|
|
|
281
|
-
|
|
361
|
+
const pkg = new Package(
|
|
282
362
|
environmentName,
|
|
283
363
|
packageName,
|
|
284
364
|
packagePath,
|
|
@@ -287,6 +367,21 @@ export class Package {
|
|
|
287
367
|
models,
|
|
288
368
|
malloyConfig,
|
|
289
369
|
);
|
|
370
|
+
|
|
371
|
+
// Fail-safe at load: a bad explores entry doesn't fail the package
|
|
372
|
+
// (its models still load and listModels hides the unmatched entry — it
|
|
373
|
+
// never falls back to listing everything). Warn so the misconfig is
|
|
374
|
+
// visible; the publish path rejects it outright (see package.controller).
|
|
375
|
+
const invalidMsg = pkg.formatInvalidExplores();
|
|
376
|
+
if (invalidMsg) {
|
|
377
|
+
logger.warn(`Package ${packageName} has invalid explores`, {
|
|
378
|
+
packageName,
|
|
379
|
+
detail: invalidMsg,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
pkg.logEmptyDiscoveryWarnings();
|
|
383
|
+
|
|
384
|
+
return pkg;
|
|
290
385
|
}
|
|
291
386
|
|
|
292
387
|
public getPackageName(): string {
|
|
@@ -294,7 +389,109 @@ export class Package {
|
|
|
294
389
|
}
|
|
295
390
|
|
|
296
391
|
public getPackageMetadata(): ApiPackage {
|
|
297
|
-
|
|
392
|
+
// Surface explores misconfig so consumers/UI can show it (loading is
|
|
393
|
+
// fail-safe — the package still serves with the bad entry hidden — so this
|
|
394
|
+
// is the only non-log signal that it's broken). Computed fresh against the
|
|
395
|
+
// current models; absent when everything resolves. Returns a copy in that
|
|
396
|
+
// case so the added field never mutates the stored metadata.
|
|
397
|
+
const warnings = this.exploreWarnings();
|
|
398
|
+
if (warnings.length === 0) return this.packageMetadata;
|
|
399
|
+
return { ...this.packageMetadata, exploresWarnings: warnings };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Declared `explores` (publisher.json) that don't resolve to a real
|
|
404
|
+
* `.malloy` model in this package, each with an actionable reason. Empty
|
|
405
|
+
* when explores is absent/empty or every entry resolves.
|
|
406
|
+
*
|
|
407
|
+
* The listing already fails safe — a non-resolving entry matches no model in
|
|
408
|
+
* `listModels`, so it hides rather than exposes. This surfaces *why*, so the
|
|
409
|
+
* load path can warn and the publish path can reject (see package.controller).
|
|
410
|
+
*/
|
|
411
|
+
public getInvalidExplores(
|
|
412
|
+
exploresOverride?: string[],
|
|
413
|
+
): { entry: string; reason: string }[] {
|
|
414
|
+
const declared = exploresOverride ?? this.packageMetadata.explores;
|
|
415
|
+
if (!declared || declared.length === 0) return [];
|
|
416
|
+
const malloyModels = new Set(
|
|
417
|
+
Array.from(this.models.keys()).filter((p) =>
|
|
418
|
+
p.endsWith(MODEL_FILE_SUFFIX),
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
const problems: { entry: string; reason: string }[] = [];
|
|
422
|
+
for (const entry of declared) {
|
|
423
|
+
if (entry.endsWith(NOTEBOOK_FILE_SUFFIX)) {
|
|
424
|
+
problems.push({
|
|
425
|
+
entry,
|
|
426
|
+
reason:
|
|
427
|
+
`notebooks are always public and cannot be explores. ` +
|
|
428
|
+
`Fix: remove it, and list a ${MODEL_FILE_SUFFIX} model file instead.`,
|
|
429
|
+
});
|
|
430
|
+
} else if (!malloyModels.has(entry)) {
|
|
431
|
+
problems.push({
|
|
432
|
+
entry,
|
|
433
|
+
reason:
|
|
434
|
+
`file not found in the package. Fix: list a ${MODEL_FILE_SUFFIX} ` +
|
|
435
|
+
`file relative to the package root (e.g. "index.malloy").`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return problems;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** One actionable message per invalid entry (empty when all resolve). */
|
|
443
|
+
public exploreWarnings(exploresOverride?: string[]): string[] {
|
|
444
|
+
return this.getInvalidExplores(exploresOverride).map(
|
|
445
|
+
(p) =>
|
|
446
|
+
`Invalid explores entry '${p.entry}' in ${PACKAGE_MANIFEST_NAME}: ${p.reason}`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The {@link exploreWarnings} joined into one string, or "" if none.
|
|
452
|
+
* Newline-separated so multiple invalid entries stay one-per-line in the
|
|
453
|
+
* 400 message rather than running together.
|
|
454
|
+
*/
|
|
455
|
+
public formatInvalidExplores(exploresOverride?: string[]): string {
|
|
456
|
+
return this.exploreWarnings(exploresOverride).join("\n");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* One message per LISTED model whose discovery surface is empty because it
|
|
461
|
+
* is import-only (imports other files, declares/re-exports nothing). Such a
|
|
462
|
+
* model renders a blank page, which reads as broken; the fix is an explicit
|
|
463
|
+
* re-export. Log-only (see loadViaWorker/reloadAllModels) — deliberately
|
|
464
|
+
* NOT part of exploreWarnings, which is strict-at-publish: import-only
|
|
465
|
+
* files are a legitimate pattern and must not block a publish. Hidden
|
|
466
|
+
* (non-listed) models are skipped — nobody browses them, so an empty
|
|
467
|
+
* surface there is just normal plumbing.
|
|
468
|
+
*/
|
|
469
|
+
public emptyDiscoveryWarnings(): string[] {
|
|
470
|
+
const exploreSet = this.exploreSet();
|
|
471
|
+
const warnings: string[] = [];
|
|
472
|
+
for (const [modelPath, model] of this.models) {
|
|
473
|
+
if (!modelPath.endsWith(MODEL_FILE_SUFFIX)) continue;
|
|
474
|
+
if (exploreSet && !exploreSet.has(modelPath)) continue;
|
|
475
|
+
if (model.hasEmptyDiscoverySurface()) {
|
|
476
|
+
warnings.push(
|
|
477
|
+
`Model "${modelPath}" is listed but exposes nothing: it only ` +
|
|
478
|
+
`imports other files and re-exports none of their sources. ` +
|
|
479
|
+
`Add e.g. 'export { source_name }' to surface sources on ` +
|
|
480
|
+
`this model.`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return warnings;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Log {@link emptyDiscoveryWarnings}; shared by load and reload. */
|
|
488
|
+
private logEmptyDiscoveryWarnings(): void {
|
|
489
|
+
for (const warning of this.emptyDiscoveryWarnings()) {
|
|
490
|
+
logger.warn(`Package ${this.packageName} has a blank-looking model`, {
|
|
491
|
+
packageName: this.packageName,
|
|
492
|
+
detail: warning,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
298
495
|
}
|
|
299
496
|
|
|
300
497
|
public listDatabases(): ApiDatabase[] {
|
|
@@ -393,18 +590,63 @@ export class Package {
|
|
|
393
590
|
),
|
|
394
591
|
);
|
|
395
592
|
} else {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
this.malloyConfig,
|
|
402
|
-
sm,
|
|
403
|
-
),
|
|
593
|
+
const model = Model.fromSerialized(
|
|
594
|
+
this.packageName,
|
|
595
|
+
this.packagePath,
|
|
596
|
+
this.malloyConfig,
|
|
597
|
+
sm,
|
|
404
598
|
);
|
|
599
|
+
// Validate renderer tags here too (loadViaWorker does it for the
|
|
600
|
+
// create path). Reload keeps per-model placeholders rather than
|
|
601
|
+
// aborting the whole package, so a render-tag error is recorded as
|
|
602
|
+
// this model's compilationError instead of thrown.
|
|
603
|
+
try {
|
|
604
|
+
await model.validateRenderTags();
|
|
605
|
+
nextModels.set(sm.modelPath, model);
|
|
606
|
+
} catch (renderErr) {
|
|
607
|
+
const err =
|
|
608
|
+
renderErr instanceof Error
|
|
609
|
+
? renderErr
|
|
610
|
+
: new Error(String(renderErr));
|
|
611
|
+
logger.warn("Render-tag validation failed during reload", {
|
|
612
|
+
packageName: this.packageName,
|
|
613
|
+
modelPath: sm.modelPath,
|
|
614
|
+
error: err.message,
|
|
615
|
+
});
|
|
616
|
+
nextModels.set(
|
|
617
|
+
sm.modelPath,
|
|
618
|
+
Model.fromCompilationError(
|
|
619
|
+
this.packageName,
|
|
620
|
+
sm.modelPath,
|
|
621
|
+
sm.modelType,
|
|
622
|
+
err,
|
|
623
|
+
),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
405
626
|
}
|
|
406
627
|
}
|
|
407
628
|
this.models = nextModels;
|
|
629
|
+
// A reload re-reads publisher.json in the worker; pick up any change to
|
|
630
|
+
// the explore set and query-boundary mode so listModels()/the gate
|
|
631
|
+
// reflect edited explores without a full Package.create.
|
|
632
|
+
this.packageMetadata.explores = outcome.packageMetadata.explores;
|
|
633
|
+
this.packageMetadata.queryableSources =
|
|
634
|
+
outcome.packageMetadata.queryableSources;
|
|
635
|
+
this.packageMetadata.manifestLocation =
|
|
636
|
+
outcome.packageMetadata.manifestLocation ?? null;
|
|
637
|
+
this.applyDiscoveryPolicyToModels();
|
|
638
|
+
this.applyQueryBoundaryToModels();
|
|
639
|
+
// Re-run the fail-safe warning against the refreshed model set: an edit
|
|
640
|
+
// to publisher.json that introduces a bad entry should surface in the
|
|
641
|
+
// logs on reload too, not only at initial load (loadViaWorker).
|
|
642
|
+
const invalidMsg = this.formatInvalidExplores();
|
|
643
|
+
if (invalidMsg) {
|
|
644
|
+
logger.warn(`Package ${this.packageName} has invalid explores`, {
|
|
645
|
+
packageName: this.packageName,
|
|
646
|
+
detail: invalidMsg,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
this.logEmptyDiscoveryWarnings();
|
|
408
650
|
}
|
|
409
651
|
|
|
410
652
|
public async getModelFileText(modelPath: string): Promise<string> {
|
|
@@ -416,10 +658,17 @@ export class Package {
|
|
|
416
658
|
}
|
|
417
659
|
|
|
418
660
|
public async listModels(): Promise<ApiModel[]> {
|
|
661
|
+
// When `explores` is declared in publisher.json, only those models
|
|
662
|
+
// form the public surface; every other .malloy file still compiles for
|
|
663
|
+
// import/join resolution but is hidden from the listing. Absent/empty →
|
|
664
|
+
// every model is listed (backward-compatible default). Notebooks are
|
|
665
|
+
// unaffected (see listNotebooks) — they are always public.
|
|
666
|
+
const exploreSet = this.exploreSet();
|
|
419
667
|
const values = await Promise.all(
|
|
420
668
|
Array.from(this.models.keys())
|
|
421
669
|
.filter((modelPath) => {
|
|
422
|
-
|
|
670
|
+
if (!modelPath.endsWith(MODEL_FILE_SUFFIX)) return false;
|
|
671
|
+
return exploreSet ? exploreSet.has(modelPath) : true;
|
|
423
672
|
})
|
|
424
673
|
.map(async (modelPath) => {
|
|
425
674
|
let error: string | undefined;
|
|
@@ -621,5 +870,7 @@ export class Package {
|
|
|
621
870
|
|
|
622
871
|
public setPackageMetadata(packageMetadata: ApiPackage) {
|
|
623
872
|
this.packageMetadata = packageMetadata;
|
|
873
|
+
this.applyDiscoveryPolicyToModels();
|
|
874
|
+
this.applyQueryBoundaryToModels();
|
|
624
875
|
}
|
|
625
876
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
import { BadRequestError } from "../errors";
|
|
7
|
+
import { Environment } from "./environment";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression tests for explores-validation ROLLBACK, guarding against the two
|
|
11
|
+
* data-loss bugs a code review found:
|
|
12
|
+
*
|
|
13
|
+
* 1. No-location create rollback must NOT delete a pre-existing user
|
|
14
|
+
* directory. The controller now `unloadPackage`s (evict from memory, keep
|
|
15
|
+
* files) instead of `deletePackage` (which renames + rm's the tree).
|
|
16
|
+
*
|
|
17
|
+
* 2. A location update/reinstall with invalid explores must roll back to the
|
|
18
|
+
* PREVIOUS tree rather than swapping the rejected one in and 400-ing after.
|
|
19
|
+
* Validation now happens inside `installPackage`'s swap window.
|
|
20
|
+
*
|
|
21
|
+
* Both run against a real `Environment` + real `Package.create` over temp dirs.
|
|
22
|
+
*/
|
|
23
|
+
describe("explores-validation rollback (real Environment)", () => {
|
|
24
|
+
let rootDir: string;
|
|
25
|
+
let envPath: string;
|
|
26
|
+
|
|
27
|
+
const GOOD_MODEL = `source: ones is duckdb.sql("SELECT 1 as x")\n`;
|
|
28
|
+
const BAD_MODEL = `source: twos is duckdb.sql("SELECT 2 as y")\n`;
|
|
29
|
+
|
|
30
|
+
async function writePackageDir(
|
|
31
|
+
dir: string,
|
|
32
|
+
manifest: Record<string, unknown>,
|
|
33
|
+
model: string,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
await fs.mkdir(dir, { recursive: true });
|
|
36
|
+
await fs.writeFile(
|
|
37
|
+
path.join(dir, "publisher.json"),
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
name: "pkg",
|
|
40
|
+
description: "rollback fixture",
|
|
41
|
+
...manifest,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
await fs.writeFile(path.join(dir, "model.malloy"), model);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function copyDir(src: string, dst: string): Promise<void> {
|
|
48
|
+
await fs.mkdir(dst, { recursive: true });
|
|
49
|
+
await fs.cp(src, dst, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-rollback-"));
|
|
54
|
+
envPath = path.join(rootDir, "env");
|
|
55
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("Fix 1: unloadPackage evicts from memory but keeps the on-disk directory", async () => {
|
|
63
|
+
// Mimics the no-location create path: a pre-existing package directory is
|
|
64
|
+
// registered, then rolled back. unloadPackage must not touch the files.
|
|
65
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
66
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
67
|
+
await writePackageDir(pkgDir, {}, GOOD_MODEL);
|
|
68
|
+
|
|
69
|
+
await env.addPackage("pkg");
|
|
70
|
+
expect(env.getPackageStatus("pkg")).toBeDefined();
|
|
71
|
+
|
|
72
|
+
await env.unloadPackage("pkg");
|
|
73
|
+
|
|
74
|
+
// Evicted from memory...
|
|
75
|
+
expect(env.getPackageStatus("pkg")).toBeUndefined();
|
|
76
|
+
// ...but the user's files are untouched.
|
|
77
|
+
const modelText = await fs.readFile(
|
|
78
|
+
path.join(pkgDir, "model.malloy"),
|
|
79
|
+
"utf-8",
|
|
80
|
+
);
|
|
81
|
+
expect(modelText).toBe(GOOD_MODEL);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("Fix 2: a rejected location reinstall restores the previous tree", async () => {
|
|
85
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
86
|
+
|
|
87
|
+
const goodFixture = path.join(rootDir, "good");
|
|
88
|
+
const badFixture = path.join(rootDir, "bad");
|
|
89
|
+
await writePackageDir(goodFixture, {}, GOOD_MODEL);
|
|
90
|
+
await writePackageDir(
|
|
91
|
+
badFixture,
|
|
92
|
+
{ explores: ["missing.malloy"] },
|
|
93
|
+
BAD_MODEL,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// First install: the good tree is served.
|
|
97
|
+
await env.installPackage("pkg", (stagingPath) =>
|
|
98
|
+
copyDir(goodFixture, stagingPath),
|
|
99
|
+
);
|
|
100
|
+
expect(await env.getModelFileText("pkg", "model.malloy")).toBe(
|
|
101
|
+
GOOD_MODEL,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Reinstall with an invalid-explores tree, validated inside the swap.
|
|
105
|
+
await expect(
|
|
106
|
+
env.installPackage(
|
|
107
|
+
"pkg",
|
|
108
|
+
(stagingPath) => copyDir(badFixture, stagingPath),
|
|
109
|
+
(pkg) => pkg.formatInvalidExplores(),
|
|
110
|
+
),
|
|
111
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
112
|
+
|
|
113
|
+
// The previous (good) tree is still the one on disk — the rejected tree
|
|
114
|
+
// was rolled back, not swapped in.
|
|
115
|
+
expect(await env.getModelFileText("pkg", "model.malloy")).toBe(
|
|
116
|
+
GOOD_MODEL,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("a valid location reinstall still succeeds (no false rollback)", async () => {
|
|
121
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
122
|
+
const goodFixture = path.join(rootDir, "good");
|
|
123
|
+
const nextFixture = path.join(rootDir, "next");
|
|
124
|
+
await writePackageDir(goodFixture, {}, GOOD_MODEL);
|
|
125
|
+
await writePackageDir(
|
|
126
|
+
nextFixture,
|
|
127
|
+
{ explores: ["model.malloy"] },
|
|
128
|
+
BAD_MODEL,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await env.installPackage("pkg", (stagingPath) =>
|
|
132
|
+
copyDir(goodFixture, stagingPath),
|
|
133
|
+
);
|
|
134
|
+
await env.installPackage(
|
|
135
|
+
"pkg",
|
|
136
|
+
(stagingPath) => copyDir(nextFixture, stagingPath),
|
|
137
|
+
(pkg) => pkg.formatInvalidExplores(),
|
|
138
|
+
);
|
|
139
|
+
expect(await env.getModelFileText("pkg", "model.malloy")).toBe(BAD_MODEL);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("updatePackage normalizes a ./-prefixed body explores (no false 400, persists normalized)", async () => {
|
|
143
|
+
// API-body explores must go through the same normalization the worker
|
|
144
|
+
// applies to on-disk explores, so `./model.malloy` validates and persists
|
|
145
|
+
// as `model.malloy` rather than being rejected with a misleading 404.
|
|
146
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
147
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
148
|
+
await writePackageDir(pkgDir, {}, GOOD_MODEL);
|
|
149
|
+
await env.addPackage("pkg");
|
|
150
|
+
|
|
151
|
+
const updated = await env.updatePackage("pkg", {
|
|
152
|
+
name: "pkg",
|
|
153
|
+
explores: ["./model.malloy"],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Accepted (no BadRequestError) and stored in normalized form...
|
|
157
|
+
expect(updated.explores).toEqual(["model.malloy"]);
|
|
158
|
+
// ...both in memory and on disk.
|
|
159
|
+
const manifest = JSON.parse(
|
|
160
|
+
await fs.readFile(path.join(pkgDir, "publisher.json"), "utf-8"),
|
|
161
|
+
);
|
|
162
|
+
expect(manifest.explores).toEqual(["model.malloy"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("compileSource rejects a notebook path; a model path still compiles", async () => {
|
|
166
|
+
// /compile appends source to the target MODEL's content for context; a
|
|
167
|
+
// notebook isn't a valid target and must be rejected up front (not left to
|
|
168
|
+
// a confusing downstream parse error).
|
|
169
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
170
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
171
|
+
await writePackageDir(pkgDir, {}, GOOD_MODEL);
|
|
172
|
+
await fs.writeFile(
|
|
173
|
+
path.join(pkgDir, "report.malloynb"),
|
|
174
|
+
`>>>markdown\n# Report\n`,
|
|
175
|
+
);
|
|
176
|
+
await env.addPackage("pkg");
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
env.compileSource("pkg", "report.malloynb", "run: ones"),
|
|
180
|
+
).rejects.toBeInstanceOf(BadRequestError);
|
|
181
|
+
|
|
182
|
+
// The .malloy model still compiles ad-hoc source (no regression).
|
|
183
|
+
const { problems } = await env.compileSource(
|
|
184
|
+
"pkg",
|
|
185
|
+
"model.malloy",
|
|
186
|
+
"run: ones -> { select: x }",
|
|
187
|
+
);
|
|
188
|
+
expect(problems.some((p) => p.severity === "error")).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|