@malloy-publisher/server 0.0.228 → 0.0.230
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 +102 -10
- package/dist/app/assets/EnvironmentPage-wa_EPkwK.js +1 -0
- package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-jnCrupQp.js} +1 -1
- package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-DYbwNULZ.js} +1 -1
- package/dist/app/assets/MainPage-CuJLrPNI.js +2 -0
- package/dist/app/assets/MaterializationsPage-D_67x2ee.js +1 -0
- package/dist/app/assets/ModelPage-D5JtAWqR.js +1 -0
- package/dist/app/assets/PackagePage-BRwtqUSG.js +1 -0
- package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-CBNNrnSD.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-CTCeBneA.js +1 -0
- package/dist/app/assets/WorkbookPage-SN6f1RBm.js +1 -0
- package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +10 -10
- package/dist/app/assets/{index-CdmFub34.js → index-B3Nn8Vm2.js} +459 -446
- package/dist/app/assets/index-BLCx1EdC.js +18 -0
- package/dist/app/assets/index-C_tJstcx.js +529 -0
- package/dist/app/assets/{index-CE9xhdra.js → index-CfmBVB6M.js} +1 -1
- package/dist/app/assets/{index-OEjKNSYb.js → index-DU4r7GdU.js} +573 -413
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +109 -8
- package/dist/server.mjs +7504 -80
- package/package.json +15 -13
- package/scripts/bake-duckdb-extensions.js +5 -2
- package/src/config.spec.ts +87 -1
- package/src/config.ts +142 -1
- package/src/controller/materialization.controller.spec.ts +9 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/ducklake_version.spec.ts +163 -0
- package/src/ducklake_version.ts +153 -0
- package/src/errors.ts +12 -0
- package/src/malloy_pin_prereqs.spec.ts +25 -0
- package/src/materialization_metrics.ts +33 -0
- package/src/mcp/skills/build_skills_bundle.ts +29 -9
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +31 -2
- package/src/query_param_utils.ts +11 -0
- package/src/server.ts +74 -9
- package/src/service/authorize.spec.ts +22 -0
- package/src/service/authorize.ts +267 -10
- package/src/service/authorize_integration.spec.ts +1068 -26
- package/src/service/connection.spec.ts +79 -7
- package/src/service/connection.ts +312 -25
- package/src/service/connection_config.spec.ts +21 -0
- package/src/service/connection_config.ts +15 -1
- package/src/service/cron_evaluator.spec.ts +79 -0
- package/src/service/cron_evaluator.ts +105 -0
- package/src/service/ducklake_lazy_attach.spec.ts +110 -0
- package/src/service/environment.ts +156 -17
- package/src/service/environment_store.spec.ts +569 -14
- package/src/service/environment_store.ts +237 -31
- package/src/service/environment_store_anchoring.spec.ts +107 -0
- package/src/service/extension_fetch_policy.spec.ts +256 -0
- package/src/service/manifest_loader.spec.ts +3 -1
- package/src/service/manifest_loader.ts +9 -5
- package/src/service/materialization_scheduler.spec.ts +289 -0
- package/src/service/materialization_scheduler.ts +304 -0
- package/src/service/materialization_service.spec.ts +286 -5
- package/src/service/materialization_service.ts +121 -8
- package/src/service/materialization_test_fixtures.ts +10 -1
- package/src/service/model.spec.ts +67 -0
- package/src/service/model.ts +656 -31
- package/src/service/package.spec.ts +38 -0
- package/src/service/package.ts +102 -2
- package/src/service/package_quote_bound_tables.spec.ts +171 -0
- package/src/service/persistence_policy.spec.ts +31 -0
- package/src/service/quoting.spec.ts +57 -1
- package/src/service/quoting.ts +33 -0
- package/src/storage/DatabaseInterface.ts +15 -0
- package/src/storage/duckdb/DuckDBRepository.ts +17 -0
- package/src/storage/duckdb/MaterializationRepository.spec.ts +191 -0
- package/src/storage/duckdb/MaterializationRepository.ts +68 -0
- package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
- package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
- package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
- package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
- package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
- package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
- package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
- package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
- package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
- package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
- package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
- package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
- package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
- package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
- package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
- package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
- package/dist/app/assets/index-DDMrjIT3.js +0 -18
- package/dist/app/assets/index-EqslXZ44.js +0 -527
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { promises as fsPromises } from "fs";
|
|
3
4
|
import * as path from "path";
|
|
4
5
|
import * as sinon from "sinon";
|
|
5
6
|
import { components } from "../api";
|
|
@@ -7,10 +8,17 @@ import { isPublisherConfigFrozen } from "../config";
|
|
|
7
8
|
import { TEMP_DIR_PATH } from "../constants";
|
|
8
9
|
import { BadRequestError } from "../errors";
|
|
9
10
|
import { Environment } from "./environment";
|
|
10
|
-
import { EnvironmentStore } from "./environment_store";
|
|
11
|
+
import { EnvironmentStore, resolvePackageLocation } from "./environment_store";
|
|
11
12
|
|
|
12
13
|
type MockData = Record<string, unknown>;
|
|
13
14
|
|
|
15
|
+
// Environments the mock database reports at boot. Empty by default, which makes
|
|
16
|
+
// initialize() fall back to the config file — the path every other test here
|
|
17
|
+
// exercises. Set it to reach the database-restore branch instead, which is the
|
|
18
|
+
// one a real restart against an existing publisher.db takes. Read at call time,
|
|
19
|
+
// so a test can assign it before constructing the store.
|
|
20
|
+
let mockDbEnvironments: unknown[] = [];
|
|
21
|
+
|
|
14
22
|
mock.module("../storage/StorageManager", () => {
|
|
15
23
|
return {
|
|
16
24
|
StorageManager: class MockStorageManager {
|
|
@@ -21,7 +29,8 @@ mock.module("../storage/StorageManager", () => {
|
|
|
21
29
|
getRepository() {
|
|
22
30
|
return {
|
|
23
31
|
// ===== PROJECT METHODS =====
|
|
24
|
-
listEnvironments: async (): Promise<unknown[]> =>
|
|
32
|
+
listEnvironments: async (): Promise<unknown[]> =>
|
|
33
|
+
mockDbEnvironments,
|
|
25
34
|
|
|
26
35
|
getEnvironmentById: async (
|
|
27
36
|
id: string,
|
|
@@ -194,6 +203,10 @@ describe("EnvironmentStore Service", () => {
|
|
|
194
203
|
rmSync(serverRootPath, { recursive: true, force: true });
|
|
195
204
|
}
|
|
196
205
|
mkdirSync(serverRootPath);
|
|
206
|
+
// Default every test back to the config-file boot path. bun runs all specs
|
|
207
|
+
// in one process, so a leaked value would silently switch later tests onto
|
|
208
|
+
// the database-restore branch.
|
|
209
|
+
mockDbEnvironments = [];
|
|
197
210
|
sandbox = sinon.createSandbox();
|
|
198
211
|
|
|
199
212
|
// Mock the configuration to prevent initialization errors
|
|
@@ -212,6 +225,7 @@ describe("EnvironmentStore Service", () => {
|
|
|
212
225
|
rmSync(serverRootPath, { recursive: true, force: true });
|
|
213
226
|
}
|
|
214
227
|
mkdirSync(serverRootPath);
|
|
228
|
+
mockDbEnvironments = [];
|
|
215
229
|
sandbox.restore();
|
|
216
230
|
});
|
|
217
231
|
|
|
@@ -410,6 +424,457 @@ describe("EnvironmentStore Service", () => {
|
|
|
410
424
|
await expect(
|
|
411
425
|
newEnvironmentStore.getEnvironment(invalidProjectName),
|
|
412
426
|
).rejects.toThrow();
|
|
427
|
+
|
|
428
|
+
// The skip is not fatal, so the server still serves. getStatus is the
|
|
429
|
+
// only place that says the environment is missing rather than never
|
|
430
|
+
// configured: without loadErrors the two are indistinguishable.
|
|
431
|
+
const status = await newEnvironmentStore.getStatus();
|
|
432
|
+
expect(status.operationalState).toBe("serving");
|
|
433
|
+
expect(status.environments.map((e) => e.name)).toEqual([
|
|
434
|
+
validProjectName,
|
|
435
|
+
]);
|
|
436
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
437
|
+
expect(status.loadErrors?.[0]?.environment).toBe(invalidProjectName);
|
|
438
|
+
expect(status.loadErrors?.[0]?.package).toBeUndefined();
|
|
439
|
+
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("should omit loadErrors when every environment loads", async () => {
|
|
443
|
+
const projectPath = path.join(serverRootPath, projectName);
|
|
444
|
+
mkdirSync(projectPath, { recursive: true });
|
|
445
|
+
writeFileSync(
|
|
446
|
+
path.join(projectPath, "publisher.json"),
|
|
447
|
+
JSON.stringify({ name: projectName, description: "Test package" }),
|
|
448
|
+
);
|
|
449
|
+
writeFileSync(
|
|
450
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
451
|
+
JSON.stringify({
|
|
452
|
+
frozenConfig: false,
|
|
453
|
+
environments: [
|
|
454
|
+
{
|
|
455
|
+
name: projectName,
|
|
456
|
+
packages: [{ name: projectName, location: projectPath }],
|
|
457
|
+
connections: [],
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
}),
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
464
|
+
await newEnvironmentStore.finishedInitialization;
|
|
465
|
+
|
|
466
|
+
// Absent, not an empty array: a healthy status is unchanged by this
|
|
467
|
+
// field's existence.
|
|
468
|
+
const status = await newEnvironmentStore.getStatus();
|
|
469
|
+
expect(status.operationalState).toBe("serving");
|
|
470
|
+
expect(status.loadErrors).toBeUndefined();
|
|
471
|
+
expect("loadErrors" in status).toBe(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("keeps good packages serving when a sibling's location is bad", async () => {
|
|
475
|
+
// The headline case: one bad location in an environment must not take
|
|
476
|
+
// down its healthy siblings. Two good packages plus one whose location
|
|
477
|
+
// does not exist -> both good ones serve, the bad one is a per-package
|
|
478
|
+
// loadError, and the environment is intact.
|
|
479
|
+
const goodA = path.join(serverRootPath, "good-a");
|
|
480
|
+
const goodB = path.join(serverRootPath, "good-b");
|
|
481
|
+
for (const dir of [goodA, goodB]) {
|
|
482
|
+
mkdirSync(dir, { recursive: true });
|
|
483
|
+
writeFileSync(
|
|
484
|
+
path.join(dir, "publisher.json"),
|
|
485
|
+
JSON.stringify({ name: path.basename(dir) }),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
writeFileSync(
|
|
489
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
490
|
+
JSON.stringify({
|
|
491
|
+
environments: [
|
|
492
|
+
{
|
|
493
|
+
name: projectName,
|
|
494
|
+
packages: [
|
|
495
|
+
{ name: "good-a", location: goodA },
|
|
496
|
+
{ name: "bad", location: "/non/existent/path" },
|
|
497
|
+
{ name: "good-b", location: goodB },
|
|
498
|
+
],
|
|
499
|
+
connections: [],
|
|
500
|
+
},
|
|
501
|
+
],
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
506
|
+
await newEnvironmentStore.finishedInitialization;
|
|
507
|
+
|
|
508
|
+
const status = await newEnvironmentStore.getStatus();
|
|
509
|
+
expect(status.operationalState).toBe("serving");
|
|
510
|
+
expect(status.environments.map((e) => e.name)).toEqual([projectName]);
|
|
511
|
+
|
|
512
|
+
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
513
|
+
const packages = await environment.listPackages();
|
|
514
|
+
expect(packages.map((p) => p.name).sort()).toEqual(["good-a", "good-b"]);
|
|
515
|
+
|
|
516
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
517
|
+
expect(status.loadErrors?.[0]?.environment).toBe(projectName);
|
|
518
|
+
expect(status.loadErrors?.[0]?.package).toBe("bad");
|
|
519
|
+
// The message has to name the location that is actually wrong. The
|
|
520
|
+
// un-mounted package also fails its lazy load on the manifest that was
|
|
521
|
+
// never copied, and reporting that instead points the reader at
|
|
522
|
+
// publisher_data/ for what is really a typo in the config.
|
|
523
|
+
expect(status.loadErrors?.[0]?.message).toContain("/non/existent/path");
|
|
524
|
+
expect(status.loadErrors?.[0]?.message).not.toContain("publisher_data");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("keeps sibling packages serving when one sharing their location fails to extract", async () => {
|
|
528
|
+
// Packages grouped under ONE location share a single download, then each
|
|
529
|
+
// is extracted separately. A failure in one extract must not strand the
|
|
530
|
+
// siblings queued behind it: the group-level catch used to wrap the whole
|
|
531
|
+
// loop, so with three packages from one repo a throw on the second meant
|
|
532
|
+
// the third was never copied even though nothing was wrong with it.
|
|
533
|
+
const shared = path.join(serverRootPath, "shared-src");
|
|
534
|
+
mkdirSync(shared, { recursive: true });
|
|
535
|
+
writeFileSync(
|
|
536
|
+
path.join(shared, "publisher.json"),
|
|
537
|
+
JSON.stringify({ name: "shared" }),
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
writeFileSync(
|
|
541
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
542
|
+
JSON.stringify({
|
|
543
|
+
environments: [
|
|
544
|
+
{
|
|
545
|
+
name: projectName,
|
|
546
|
+
packages: [
|
|
547
|
+
{ name: "first", location: shared },
|
|
548
|
+
{ name: "middle", location: shared },
|
|
549
|
+
{ name: "last", location: shared },
|
|
550
|
+
],
|
|
551
|
+
connections: [],
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
}),
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
// Fail exactly one package's copy. Everything reachable from config is
|
|
558
|
+
// shared across the group (same location, hence the same source path), so
|
|
559
|
+
// the copy is the only per-package step that can realistically fail on
|
|
560
|
+
// its own — the same shape as a GitHub subdirectory that isn't there.
|
|
561
|
+
const realCp = fsPromises.cp;
|
|
562
|
+
const cpStub = sinon.stub(fsPromises, "cp").callsFake((async (
|
|
563
|
+
src: Parameters<typeof realCp>[0],
|
|
564
|
+
dest: Parameters<typeof realCp>[1],
|
|
565
|
+
opts?: Parameters<typeof realCp>[2],
|
|
566
|
+
) => {
|
|
567
|
+
if (String(dest).endsWith(`${path.sep}middle`)) {
|
|
568
|
+
throw new Error("simulated extract failure for middle");
|
|
569
|
+
}
|
|
570
|
+
return realCp(src, dest, opts);
|
|
571
|
+
}) as typeof realCp);
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
575
|
+
await newEnvironmentStore.finishedInitialization;
|
|
576
|
+
|
|
577
|
+
const environment =
|
|
578
|
+
await newEnvironmentStore.getEnvironment(projectName);
|
|
579
|
+
const packages = await environment.listPackages();
|
|
580
|
+
// "last" is the one that regressed: it is queued behind the failure.
|
|
581
|
+
expect(packages.map((p) => p.name).sort()).toEqual(["first", "last"]);
|
|
582
|
+
|
|
583
|
+
const status = await newEnvironmentStore.getStatus();
|
|
584
|
+
expect(status.operationalState).toBe("serving");
|
|
585
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
586
|
+
expect(status.loadErrors?.[0]?.package).toBe("middle");
|
|
587
|
+
expect(status.loadErrors?.[0]?.message).toContain(
|
|
588
|
+
"simulated extract failure",
|
|
589
|
+
);
|
|
590
|
+
} finally {
|
|
591
|
+
cpStub.restore();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("does not delete the environment directory when a package name is rejected", async () => {
|
|
596
|
+
// A package name that fails the allowlist throws as the FIRST statement of
|
|
597
|
+
// the extract, so the per-package catch runs before any path for it
|
|
598
|
+
// exists. safeJoinUnderRoot deliberately allows a name that resolves to
|
|
599
|
+
// the root, so re-deriving the cleanup target from the name would hand it
|
|
600
|
+
// the whole environment directory and take the healthy siblings with it.
|
|
601
|
+
const good = path.join(serverRootPath, "good-src");
|
|
602
|
+
mkdirSync(good, { recursive: true });
|
|
603
|
+
writeFileSync(
|
|
604
|
+
path.join(good, "publisher.json"),
|
|
605
|
+
JSON.stringify({ name: "good" }),
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
writeFileSync(
|
|
609
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
610
|
+
JSON.stringify({
|
|
611
|
+
environments: [
|
|
612
|
+
{
|
|
613
|
+
name: projectName,
|
|
614
|
+
packages: [
|
|
615
|
+
{ name: "good", location: good },
|
|
616
|
+
{ name: ".", location: good },
|
|
617
|
+
],
|
|
618
|
+
connections: [],
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
}),
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
625
|
+
await newEnvironmentStore.finishedInitialization;
|
|
626
|
+
|
|
627
|
+
// Boot pre-validates the names and skips the environment, so reach the
|
|
628
|
+
// lazy path, which passes the config's package names through unchecked.
|
|
629
|
+
await newEnvironmentStore.getEnvironment(projectName).catch(() => {});
|
|
630
|
+
|
|
631
|
+
const envDir = path.join(serverRootPath, "publisher_data", projectName);
|
|
632
|
+
// The environment directory and the healthy sibling must both survive.
|
|
633
|
+
expect(existsSync(envDir)).toBe(true);
|
|
634
|
+
expect(existsSync(path.join(envDir, "good"))).toBe(true);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("should not report an environment that is serving even if its database sync fails", async () => {
|
|
638
|
+
// An environment reaches this.environments before addEnvironmentToDatabase
|
|
639
|
+
// runs, so a throw from that tail is caught by the same handler that
|
|
640
|
+
// records load failures. The environment is live and listed, so reporting
|
|
641
|
+
// it as a load failure would make the status contradict itself.
|
|
642
|
+
const projectPath = path.join(serverRootPath, projectName);
|
|
643
|
+
mkdirSync(projectPath, { recursive: true });
|
|
644
|
+
writeFileSync(
|
|
645
|
+
path.join(projectPath, "publisher.json"),
|
|
646
|
+
JSON.stringify({ name: projectName }),
|
|
647
|
+
);
|
|
648
|
+
writeFileSync(
|
|
649
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
650
|
+
JSON.stringify({
|
|
651
|
+
frozenConfig: false,
|
|
652
|
+
environments: [
|
|
653
|
+
{
|
|
654
|
+
name: projectName,
|
|
655
|
+
packages: [{ name: projectName, location: projectPath }],
|
|
656
|
+
connections: [],
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
// Stub the prototype before constructing: the constructor starts
|
|
663
|
+
// initialize() immediately, so stubbing the instance would race it.
|
|
664
|
+
sandbox
|
|
665
|
+
.stub(EnvironmentStore.prototype, "addEnvironmentToDatabase")
|
|
666
|
+
.rejects(new Error("database write failed"));
|
|
667
|
+
|
|
668
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
669
|
+
await newEnvironmentStore.finishedInitialization;
|
|
670
|
+
|
|
671
|
+
const status = await newEnvironmentStore.getStatus();
|
|
672
|
+
expect(status.operationalState).toBe("serving");
|
|
673
|
+
// Still serving it, so it must not also be reported as not loaded.
|
|
674
|
+
expect(status.environments.map((e) => e.name)).toEqual([projectName]);
|
|
675
|
+
expect(status.loadErrors).toBeUndefined();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("should report an environment that failed to restore from the database", async () => {
|
|
679
|
+
// The database-restore branch, not the config branch: initialize() takes
|
|
680
|
+
// this one whenever INITIALIZE_STORAGE is not "true" and the database
|
|
681
|
+
// already holds environments, which is every restart of a server with a
|
|
682
|
+
// persisted publisher.db. It has its own catch, so a failure recorded only
|
|
683
|
+
// on the config path would be dropped on the path production actually
|
|
684
|
+
// takes.
|
|
685
|
+
const envName = "restored-env";
|
|
686
|
+
const envPath = path.join(serverRootPath, envName);
|
|
687
|
+
|
|
688
|
+
// Exists, so the "files missing" branch is skipped, but is a file rather
|
|
689
|
+
// than a directory, so Environment.create throws. Stands in for any stored
|
|
690
|
+
// path that goes bad between restarts.
|
|
691
|
+
writeFileSync(envPath, "not a directory");
|
|
692
|
+
|
|
693
|
+
writeFileSync(
|
|
694
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
695
|
+
JSON.stringify({
|
|
696
|
+
frozenConfig: false,
|
|
697
|
+
environments: [
|
|
698
|
+
{
|
|
699
|
+
name: envName,
|
|
700
|
+
packages: [{ name: envName, location: envPath }],
|
|
701
|
+
connections: [],
|
|
702
|
+
},
|
|
703
|
+
],
|
|
704
|
+
}),
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
mockDbEnvironments = [
|
|
708
|
+
{
|
|
709
|
+
id: "restored-env-id",
|
|
710
|
+
name: envName,
|
|
711
|
+
path: envPath,
|
|
712
|
+
metadata: {},
|
|
713
|
+
},
|
|
714
|
+
];
|
|
715
|
+
|
|
716
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
717
|
+
await newEnvironmentStore.finishedInitialization;
|
|
718
|
+
|
|
719
|
+
const status = await newEnvironmentStore.getStatus();
|
|
720
|
+
expect(status.operationalState).toBe("serving");
|
|
721
|
+
expect(status.environments.map((e) => e.name)).toEqual([]);
|
|
722
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
723
|
+
expect(status.loadErrors?.[0]?.environment).toBe(envName);
|
|
724
|
+
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it("should report a package that failed to load while its siblings serve", async () => {
|
|
728
|
+
const goodPackageName = "good-package";
|
|
729
|
+
const badPackageName = "bad-package";
|
|
730
|
+
const goodPackagePath = path.join(serverRootPath, goodPackageName);
|
|
731
|
+
const badPackagePath = path.join(serverRootPath, badPackageName);
|
|
732
|
+
|
|
733
|
+
mkdirSync(goodPackagePath, { recursive: true });
|
|
734
|
+
writeFileSync(
|
|
735
|
+
path.join(goodPackagePath, "publisher.json"),
|
|
736
|
+
JSON.stringify({ name: goodPackageName }),
|
|
737
|
+
);
|
|
738
|
+
// The directory exists, so the environment mounts; the package inside it
|
|
739
|
+
// has no manifest, so only that package fails. This is the second, and
|
|
740
|
+
// quieter, failure mode: the environment is present and serving, with a
|
|
741
|
+
// package missing from it.
|
|
742
|
+
mkdirSync(badPackagePath, { recursive: true });
|
|
743
|
+
|
|
744
|
+
writeFileSync(
|
|
745
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
746
|
+
JSON.stringify({
|
|
747
|
+
frozenConfig: false,
|
|
748
|
+
environments: [
|
|
749
|
+
{
|
|
750
|
+
name: projectName,
|
|
751
|
+
packages: [
|
|
752
|
+
{ name: goodPackageName, location: goodPackagePath },
|
|
753
|
+
{ name: badPackageName, location: badPackagePath },
|
|
754
|
+
],
|
|
755
|
+
connections: [],
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
}),
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
762
|
+
await newEnvironmentStore.finishedInitialization;
|
|
763
|
+
|
|
764
|
+
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
765
|
+
const packages = await environment.listPackages();
|
|
766
|
+
expect(packages.map((p) => p.name)).toEqual([goodPackageName]);
|
|
767
|
+
|
|
768
|
+
const status = await newEnvironmentStore.getStatus();
|
|
769
|
+
expect(status.operationalState).toBe("serving");
|
|
770
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
771
|
+
expect(status.loadErrors?.[0]?.environment).toBe(projectName);
|
|
772
|
+
expect(status.loadErrors?.[0]?.package).toBe(badPackageName);
|
|
773
|
+
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("should stop reporting a failed package once it is deleted", async () => {
|
|
777
|
+
const goodPackageName = "good-package";
|
|
778
|
+
const badPackageName = "bad-package";
|
|
779
|
+
const goodPackagePath = path.join(serverRootPath, goodPackageName);
|
|
780
|
+
const badPackagePath = path.join(serverRootPath, badPackageName);
|
|
781
|
+
|
|
782
|
+
mkdirSync(goodPackagePath, { recursive: true });
|
|
783
|
+
writeFileSync(
|
|
784
|
+
path.join(goodPackagePath, "publisher.json"),
|
|
785
|
+
JSON.stringify({ name: goodPackageName }),
|
|
786
|
+
);
|
|
787
|
+
mkdirSync(badPackagePath, { recursive: true });
|
|
788
|
+
|
|
789
|
+
writeFileSync(
|
|
790
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
791
|
+
JSON.stringify({
|
|
792
|
+
frozenConfig: false,
|
|
793
|
+
environments: [
|
|
794
|
+
{
|
|
795
|
+
name: projectName,
|
|
796
|
+
packages: [
|
|
797
|
+
{ name: goodPackageName, location: goodPackagePath },
|
|
798
|
+
{ name: badPackageName, location: badPackagePath },
|
|
799
|
+
],
|
|
800
|
+
connections: [],
|
|
801
|
+
},
|
|
802
|
+
],
|
|
803
|
+
}),
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
807
|
+
await newEnvironmentStore.finishedInitialization;
|
|
808
|
+
|
|
809
|
+
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
810
|
+
expect((await newEnvironmentStore.getStatus()).loadErrors).toHaveLength(
|
|
811
|
+
1,
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
// A package that failed to load was evicted from `packages`, so the
|
|
815
|
+
// delete takes deletePackage's early return. The failure entry has to be
|
|
816
|
+
// cleared anyway: the caller goes on to drop the package's config row,
|
|
817
|
+
// and a loadError naming a package that is no longer configured sends
|
|
818
|
+
// whoever reads /status hunting for something that isn't there.
|
|
819
|
+
await environment.deletePackage(badPackageName);
|
|
820
|
+
|
|
821
|
+
const status = await newEnvironmentStore.getStatus();
|
|
822
|
+
expect(status.operationalState).toBe("serving");
|
|
823
|
+
expect(status.loadErrors).toBeUndefined();
|
|
824
|
+
expect((await environment.listPackages()).map((p) => p.name)).toEqual([
|
|
825
|
+
goodPackageName,
|
|
826
|
+
]);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it("should stop reporting a failed package once it loads", async () => {
|
|
830
|
+
const badPackageName = "bad-package";
|
|
831
|
+
const badPackagePath = path.join(serverRootPath, badPackageName);
|
|
832
|
+
|
|
833
|
+
// No manifest, so it fails to load and lands in loadErrors.
|
|
834
|
+
mkdirSync(badPackagePath, { recursive: true });
|
|
835
|
+
|
|
836
|
+
writeFileSync(
|
|
837
|
+
path.join(serverRootPath, "publisher.config.json"),
|
|
838
|
+
JSON.stringify({
|
|
839
|
+
frozenConfig: false,
|
|
840
|
+
environments: [
|
|
841
|
+
{
|
|
842
|
+
name: projectName,
|
|
843
|
+
packages: [
|
|
844
|
+
{ name: badPackageName, location: badPackagePath },
|
|
845
|
+
],
|
|
846
|
+
connections: [],
|
|
847
|
+
},
|
|
848
|
+
],
|
|
849
|
+
}),
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
853
|
+
await newEnvironmentStore.finishedInitialization;
|
|
854
|
+
|
|
855
|
+
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
856
|
+
expect((await newEnvironmentStore.getStatus()).loadErrors).toHaveLength(
|
|
857
|
+
1,
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
// Fix the package where the environment actually reads it, then re-add
|
|
861
|
+
// it. A package that is serving must not still be reported as failed.
|
|
862
|
+
writeFileSync(
|
|
863
|
+
path.join(
|
|
864
|
+
environment.metadata.location as string,
|
|
865
|
+
badPackageName,
|
|
866
|
+
"publisher.json",
|
|
867
|
+
),
|
|
868
|
+
JSON.stringify({ name: badPackageName }),
|
|
869
|
+
);
|
|
870
|
+
await environment.addPackage(badPackageName);
|
|
871
|
+
|
|
872
|
+
const status = await newEnvironmentStore.getStatus();
|
|
873
|
+
expect(status.operationalState).toBe("serving");
|
|
874
|
+
expect(status.loadErrors).toBeUndefined();
|
|
875
|
+
expect((await environment.listPackages()).map((p) => p.name)).toEqual([
|
|
876
|
+
badPackageName,
|
|
877
|
+
]);
|
|
413
878
|
});
|
|
414
879
|
|
|
415
880
|
it("should handle project updates", async () => {
|
|
@@ -523,8 +988,10 @@ describe("EnvironmentStore Service", () => {
|
|
|
523
988
|
{ timeout: 30000 },
|
|
524
989
|
);
|
|
525
990
|
|
|
526
|
-
it("
|
|
527
|
-
//
|
|
991
|
+
it("isolates a bad package location to that package, keeping the environment", async () => {
|
|
992
|
+
// A package whose location does not exist used to abort the whole
|
|
993
|
+
// environment. It now behaves like a bad manifest: the package is
|
|
994
|
+
// dropped and reported per-package, and the environment still loads.
|
|
528
995
|
const publisherConfigPath = path.join(
|
|
529
996
|
serverRootPath,
|
|
530
997
|
"publisher.config.json",
|
|
@@ -546,10 +1013,20 @@ describe("EnvironmentStore Service", () => {
|
|
|
546
1013
|
}),
|
|
547
1014
|
);
|
|
548
1015
|
|
|
549
|
-
|
|
550
|
-
await
|
|
551
|
-
|
|
552
|
-
|
|
1016
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1017
|
+
await newEnvironmentStore.finishedInitialization;
|
|
1018
|
+
|
|
1019
|
+
// The environment loaded rather than being skipped.
|
|
1020
|
+
const environment = await newEnvironmentStore.getEnvironment(projectName);
|
|
1021
|
+
expect(environment.metadata.name).toBe(projectName);
|
|
1022
|
+
|
|
1023
|
+
// And the failure is reported at the package level, not the env level.
|
|
1024
|
+
const status = await newEnvironmentStore.getStatus();
|
|
1025
|
+
expect(status.operationalState).toBe("serving");
|
|
1026
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
1027
|
+
expect(status.loadErrors?.[0]?.environment).toBe(projectName);
|
|
1028
|
+
expect(status.loadErrors?.[0]?.package).toBe(projectName);
|
|
1029
|
+
expect(status.loadErrors?.[0]?.message).toBeTruthy();
|
|
553
1030
|
});
|
|
554
1031
|
|
|
555
1032
|
it("should handle invalid publisher config", async () => {
|
|
@@ -794,8 +1271,10 @@ describe("Project Service Error Recovery", () => {
|
|
|
794
1271
|
});
|
|
795
1272
|
|
|
796
1273
|
describe("Project Loading Error Recovery", () => {
|
|
797
|
-
it("
|
|
798
|
-
//
|
|
1274
|
+
it("keeps the environment when a package directory is missing, reporting per-package", async () => {
|
|
1275
|
+
// Same isolation as above, for a relative-to-serverRoot missing dir:
|
|
1276
|
+
// the environment loads and the missing package is a per-package
|
|
1277
|
+
// loadError rather than an environment-level skip.
|
|
799
1278
|
const publisherConfigPath = path.join(
|
|
800
1279
|
serverRootPath,
|
|
801
1280
|
"publisher.config.json",
|
|
@@ -820,10 +1299,17 @@ describe("Project Service Error Recovery", () => {
|
|
|
820
1299
|
}),
|
|
821
1300
|
);
|
|
822
1301
|
|
|
823
|
-
|
|
824
|
-
await
|
|
825
|
-
|
|
826
|
-
|
|
1302
|
+
const newEnvironmentStore = new EnvironmentStore(serverRootPath);
|
|
1303
|
+
await newEnvironmentStore.finishedInitialization;
|
|
1304
|
+
|
|
1305
|
+
const environment =
|
|
1306
|
+
await newEnvironmentStore.getEnvironment(projectName);
|
|
1307
|
+
expect(environment.metadata.name).toBe(projectName);
|
|
1308
|
+
|
|
1309
|
+
const status = await newEnvironmentStore.getStatus();
|
|
1310
|
+
expect(status.operationalState).toBe("serving");
|
|
1311
|
+
expect(status.loadErrors).toHaveLength(1);
|
|
1312
|
+
expect(status.loadErrors?.[0]?.package).toBe(projectName);
|
|
827
1313
|
});
|
|
828
1314
|
|
|
829
1315
|
it(
|
|
@@ -1042,3 +1528,72 @@ describe("EnvironmentStore path-injection guards", () => {
|
|
|
1042
1528
|
);
|
|
1043
1529
|
});
|
|
1044
1530
|
});
|
|
1531
|
+
|
|
1532
|
+
describe("resolvePackageLocation", () => {
|
|
1533
|
+
// Built with `path`, not literals: the helper joins with the platform
|
|
1534
|
+
// separator and these specs run on windows-latest too (cross-platform-tests).
|
|
1535
|
+
const HOME = path.join(path.sep, "home", "tester");
|
|
1536
|
+
const CONFIG_DIR = path.join(path.sep, "etc", "publisher");
|
|
1537
|
+
|
|
1538
|
+
it("expands ~/ against the home directory", () => {
|
|
1539
|
+
expect(
|
|
1540
|
+
resolvePackageLocation("~/my-packages/sales", CONFIG_DIR, HOME),
|
|
1541
|
+
).toBe(path.join(HOME, "my-packages", "sales"));
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it("does not anchor an expanded ~/ path at the config dir", () => {
|
|
1545
|
+
// Regression: `~/` satisfies isLocalPath but not path.isAbsolute, so an
|
|
1546
|
+
// unexpanded tilde used to be joined onto the anchor, yielding
|
|
1547
|
+
// `<anchor>/~/my-packages/sales`.
|
|
1548
|
+
const resolved = resolvePackageLocation("~/x", CONFIG_DIR, HOME);
|
|
1549
|
+
expect(resolved).not.toContain("~");
|
|
1550
|
+
expect(resolved.startsWith(CONFIG_DIR)).toBe(false);
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it("returns an absolute location untouched", () => {
|
|
1554
|
+
const absolute = path.join(path.sep, "srv", "packages", "sales");
|
|
1555
|
+
expect(resolvePackageLocation(absolute, CONFIG_DIR, HOME)).toBe(absolute);
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
it("anchors a relative location at the config dir, not the cwd", () => {
|
|
1559
|
+
expect(resolvePackageLocation("./sales", CONFIG_DIR, HOME)).toBe(
|
|
1560
|
+
path.join(CONFIG_DIR, "sales"),
|
|
1561
|
+
);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
it("anchors a ../ location at the config dir", () => {
|
|
1565
|
+
expect(
|
|
1566
|
+
resolvePackageLocation("../examples/storefront", CONFIG_DIR, HOME),
|
|
1567
|
+
).toBe(path.join(CONFIG_DIR, "..", "examples", "storefront"));
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
it("throws when ~/ cannot be expanded because no home directory is set", () => {
|
|
1571
|
+
// An empty home must fail loudly: `path.join("", "x")` would otherwise
|
|
1572
|
+
// yield a relative "x" that silently anchors under the config dir.
|
|
1573
|
+
expect(() => resolvePackageLocation("~/x", CONFIG_DIR, "")).toThrow(
|
|
1574
|
+
/home directory is not set/,
|
|
1575
|
+
);
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
it("never consults the home directory for non-tilde locations", () => {
|
|
1579
|
+
// The same empty home that makes a ~/ location throw must be irrelevant
|
|
1580
|
+
// to ./ and absolute locations; home is resolved lazily, tilde-only.
|
|
1581
|
+
expect(resolvePackageLocation("./sales", CONFIG_DIR, "")).toBe(
|
|
1582
|
+
path.join(CONFIG_DIR, "sales"),
|
|
1583
|
+
);
|
|
1584
|
+
const absolute = path.join(path.sep, "srv", "packages", "sales");
|
|
1585
|
+
expect(resolvePackageLocation(absolute, CONFIG_DIR, "")).toBe(absolute);
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it("expands only the POSIX ~/ prefix; bare ~ and ~user are not home paths", () => {
|
|
1589
|
+
// Neither form satisfies isLocalPath, so in practice they are rejected
|
|
1590
|
+
// upstream as non-local locations; pin here that the resolver itself
|
|
1591
|
+
// never treats them as home-relative either.
|
|
1592
|
+
expect(resolvePackageLocation("~", CONFIG_DIR, HOME)).toBe(
|
|
1593
|
+
path.join(CONFIG_DIR, "~"),
|
|
1594
|
+
);
|
|
1595
|
+
expect(resolvePackageLocation("~user/foo", CONFIG_DIR, HOME)).toBe(
|
|
1596
|
+
path.join(CONFIG_DIR, "~user", "foo"),
|
|
1597
|
+
);
|
|
1598
|
+
});
|
|
1599
|
+
});
|