@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluate a 5-field UNIX cron expression, in UTC, for the standalone
|
|
3
|
+
* materialization scheduler. Mirrors the control plane's `CronEvaluator`
|
|
4
|
+
* semantics (5-field, UTC, "strictly after") so a given `materialization.schedule`
|
|
5
|
+
* fires at the same instants whether the publisher runs standalone or the control
|
|
6
|
+
* plane drives it.
|
|
7
|
+
*
|
|
8
|
+
* Thin wrapper over `cron-parser` that pins three policies:
|
|
9
|
+
* - **exactly 5 fields** — `cron-parser` also accepts an optional leading
|
|
10
|
+
* seconds field (6 fields); the manifest contract is 5-field UNIX cron, so a
|
|
11
|
+
* 6-field expression is rejected rather than silently reinterpreted.
|
|
12
|
+
* - **plain UNIX grammar** — `cron-parser` accepts extensions (`L` last,
|
|
13
|
+
* `W` nearest-weekday, `#` nth-weekday, `?` Quartz no-specific) that the
|
|
14
|
+
* control plane's `cron-utils` UNIX parser rejects. Without this pin, an
|
|
15
|
+
* expression like `0 6 L * *` would validate and fire locally but, after
|
|
16
|
+
* publish, silently never arm in production. Month/day names (JAN, MON-FRI)
|
|
17
|
+
* are still allowed.
|
|
18
|
+
* - **UTC** — schedules are absolute, never tied to the server's local zone.
|
|
19
|
+
*/
|
|
20
|
+
import { CronExpressionParser } from "cron-parser";
|
|
21
|
+
|
|
22
|
+
/** Valid 3-letter month and day-of-week names in UNIX cron. */
|
|
23
|
+
const UNIX_CRON_NAMES = new Set([
|
|
24
|
+
"JAN",
|
|
25
|
+
"FEB",
|
|
26
|
+
"MAR",
|
|
27
|
+
"APR",
|
|
28
|
+
"MAY",
|
|
29
|
+
"JUN",
|
|
30
|
+
"JUL",
|
|
31
|
+
"AUG",
|
|
32
|
+
"SEP",
|
|
33
|
+
"OCT",
|
|
34
|
+
"NOV",
|
|
35
|
+
"DEC",
|
|
36
|
+
"SUN",
|
|
37
|
+
"MON",
|
|
38
|
+
"TUE",
|
|
39
|
+
"WED",
|
|
40
|
+
"THU",
|
|
41
|
+
"FRI",
|
|
42
|
+
"SAT",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export class CronEvaluator {
|
|
46
|
+
/** True when `expr` is a syntactically valid 5-field UNIX cron. */
|
|
47
|
+
isValid(expr: string): boolean {
|
|
48
|
+
if (!this.hasFiveFields(expr) || !this.isPlainUnixGrammar(expr)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
CronExpressionParser.parse(expr, { tz: "UTC" });
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The next fire instant strictly after `from` (UTC). Throws when `expr` is
|
|
61
|
+
* not a valid 5-field cron — callers guard with {@link isValid} (the
|
|
62
|
+
* scheduler skips + logs an invalid cron rather than letting it throw in the
|
|
63
|
+
* tick).
|
|
64
|
+
*/
|
|
65
|
+
nextAfter(expr: string, from: Date): Date {
|
|
66
|
+
if (!this.hasFiveFields(expr) || !this.isPlainUnixGrammar(expr)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Expected a 5-field UNIX cron, got: ${JSON.stringify(expr)}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return CronExpressionParser.parse(expr, {
|
|
72
|
+
currentDate: from,
|
|
73
|
+
tz: "UTC",
|
|
74
|
+
})
|
|
75
|
+
.next()
|
|
76
|
+
.toDate();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whitespace-split field count is exactly 5. */
|
|
80
|
+
private hasFiveFields(expr: string): boolean {
|
|
81
|
+
return typeof expr === "string" && expr.trim().split(/\s+/).length === 5;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reject cron-parser / Quartz extensions absent from plain UNIX cron: `#`
|
|
86
|
+
* and `?` (never part of a name), and any alphabetic token that is not a
|
|
87
|
+
* UNIX month/day name — which catches `L`, `W`, `LW`, `L-3`, `15W`, etc.
|
|
88
|
+
* while still allowing named fields like `JUL` or `MON-FRI`.
|
|
89
|
+
*/
|
|
90
|
+
private isPlainUnixGrammar(expr: string): boolean {
|
|
91
|
+
for (const field of expr.trim().split(/\s+/)) {
|
|
92
|
+
if (/[#?]/.test(field)) return false;
|
|
93
|
+
for (const token of field.split(/[,\-/]/)) {
|
|
94
|
+
if (token === "" || token === "*") continue;
|
|
95
|
+
if (
|
|
96
|
+
/[A-Za-z]/.test(token) &&
|
|
97
|
+
!UNIX_CRON_NAMES.has(token.toUpperCase())
|
|
98
|
+
) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import sinon from "sinon";
|
|
6
|
+
import { components } from "../api";
|
|
7
|
+
import { buildEnvironmentMalloyConfig } from "./connection";
|
|
8
|
+
|
|
9
|
+
type ApiConnection = components["schemas"]["Connection"];
|
|
10
|
+
|
|
11
|
+
// A DuckLake connection whose Postgres catalog points at an unroutable host.
|
|
12
|
+
// If anything on the boot path tried to reach the catalog, these tests would
|
|
13
|
+
// hang or fail — which is precisely the regression they guard against.
|
|
14
|
+
const UNREACHABLE_DUCKLAKE: ApiConnection = {
|
|
15
|
+
name: "ducklake_lazy",
|
|
16
|
+
type: "ducklake",
|
|
17
|
+
ducklakeConnection: {
|
|
18
|
+
catalog: {
|
|
19
|
+
postgresConnection: {
|
|
20
|
+
host: "192.0.2.1", // TEST-NET-1: guaranteed non-routable
|
|
21
|
+
port: 5432,
|
|
22
|
+
userName: "nobody",
|
|
23
|
+
password: "nobody",
|
|
24
|
+
databaseName: "catalog",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
storage: {
|
|
28
|
+
bucketUrl: "s3://test-bucket",
|
|
29
|
+
s3Connection: {
|
|
30
|
+
accessKeyId: "test",
|
|
31
|
+
secretAccessKey: "test",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
} as ApiConnection;
|
|
36
|
+
|
|
37
|
+
describe("DuckLake lazy attach", () => {
|
|
38
|
+
const envPath = path.join(process.cwd(), "test-ducklake-lazy-attach");
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
sinon.restore();
|
|
46
|
+
await fs.rm(envPath, { recursive: true, force: true }).catch(() => {});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not attach the catalog while building the environment config (boot path)", async () => {
|
|
50
|
+
// Stub the DuckDB session so no real database or network work happens; the
|
|
51
|
+
// stub also lets us observe whether ANY SQL ran during config build.
|
|
52
|
+
const runSQL = sinon
|
|
53
|
+
.stub(DuckDBConnection.prototype, "runSQL")
|
|
54
|
+
.resolves({ rows: [] } as never);
|
|
55
|
+
|
|
56
|
+
// Building the config is the worker boot path. It must be synchronous and
|
|
57
|
+
// must not open, construct, or touch the DuckLake session at all.
|
|
58
|
+
const config = buildEnvironmentMalloyConfig(
|
|
59
|
+
[UNREACHABLE_DUCKLAKE],
|
|
60
|
+
envPath,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(config.apiConnections.map((c) => c.name)).toContain(
|
|
64
|
+
"ducklake_lazy",
|
|
65
|
+
);
|
|
66
|
+
// The definitive assertion: zero SQL was issued building the config, so
|
|
67
|
+
// the (unreachable) catalog could not have been contacted on boot.
|
|
68
|
+
expect(runSQL.callCount).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("cannot let an unavailable catalog block startup", async () => {
|
|
72
|
+
sinon
|
|
73
|
+
.stub(DuckDBConnection.prototype, "runSQL")
|
|
74
|
+
.resolves({ rows: [] } as never);
|
|
75
|
+
|
|
76
|
+
// Even with an unreachable catalog, constructing the environment config
|
|
77
|
+
// resolves promptly and never awaits the catalog. If attach were on the
|
|
78
|
+
// boot path, this would hang against 192.0.2.1 until a connect timeout.
|
|
79
|
+
const config = buildEnvironmentMalloyConfig(
|
|
80
|
+
[UNREACHABLE_DUCKLAKE],
|
|
81
|
+
envPath,
|
|
82
|
+
);
|
|
83
|
+
expect(config).toBeDefined();
|
|
84
|
+
expect(config.malloyConfig).toBeDefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("attaches the catalog only on first connection lookup (serve path)", async () => {
|
|
88
|
+
const runSQL = sinon
|
|
89
|
+
.stub(DuckDBConnection.prototype, "runSQL")
|
|
90
|
+
.resolves({ rows: [] } as never);
|
|
91
|
+
|
|
92
|
+
const config = buildEnvironmentMalloyConfig(
|
|
93
|
+
[UNREACHABLE_DUCKLAKE],
|
|
94
|
+
envPath,
|
|
95
|
+
);
|
|
96
|
+
expect(runSQL.callCount).toBe(0);
|
|
97
|
+
|
|
98
|
+
// The lazy attach fires here, on the first lookup — not before.
|
|
99
|
+
await config.malloyConfig.connections.lookupConnection("ducklake_lazy");
|
|
100
|
+
|
|
101
|
+
const issued = runSQL.getCalls().map((c) => String(c.args[0]));
|
|
102
|
+
const attachedDuckLake = issued.some((sql) =>
|
|
103
|
+
sql.includes("ducklake:postgres:"),
|
|
104
|
+
);
|
|
105
|
+
expect(runSQL.callCount).toBeGreaterThan(0);
|
|
106
|
+
expect(attachedDuckLake).toBe(true);
|
|
107
|
+
|
|
108
|
+
await config.releaseConnections().catch(() => {});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ServiceUnavailableError,
|
|
22
22
|
} from "../errors";
|
|
23
23
|
import { logger } from "../logger";
|
|
24
|
+
import { redactPgSecrets } from "../pg_helpers";
|
|
24
25
|
import { recordManifestBind } from "../materialization_metrics";
|
|
25
26
|
import {
|
|
26
27
|
assertSafeEnvironmentPath,
|
|
@@ -138,6 +139,27 @@ export class Environment {
|
|
|
138
139
|
// AB/BA deadlock path.
|
|
139
140
|
private packageMutexes = new Map<string, Mutex>();
|
|
140
141
|
private packageStatuses: Map<string, PackageInfo> = new Map();
|
|
142
|
+
/**
|
|
143
|
+
* Configured packages that failed to load, keyed by name, with the reason.
|
|
144
|
+
*
|
|
145
|
+
* A load failure is not fatal: the package is omitted and its siblings serve
|
|
146
|
+
* on. It is also observable exactly once, because the failing load deletes
|
|
147
|
+
* the `packageStatuses` entry that `listPackages` enumerates, so the next
|
|
148
|
+
* listing no longer knows the package was ever configured. That makes this
|
|
149
|
+
* the only lasting record. Read by EnvironmentStore.getStatus.
|
|
150
|
+
*/
|
|
151
|
+
private failedPackages: Map<string, string> = new Map();
|
|
152
|
+
/**
|
|
153
|
+
* Why a configured package never reached the disk, keyed by package name.
|
|
154
|
+
*
|
|
155
|
+
* Separate from {@link failedPackages} because it is the more specific
|
|
156
|
+
* answer and has to win. A package whose location failed to mount is still
|
|
157
|
+
* seeded SERVING by addEnvironment, so its later lazy load fails on the
|
|
158
|
+
* manifest that was never copied and `listPackages` records that instead.
|
|
159
|
+
* Reporting "Package manifest ... does not exist." for what was really a
|
|
160
|
+
* typo'd `location` sends the reader hunting in the wrong place.
|
|
161
|
+
*/
|
|
162
|
+
private mountErrors: Map<string, string> = new Map();
|
|
141
163
|
private malloyConfig: EnvironmentMalloyConfig;
|
|
142
164
|
private connectionMutex = new Mutex();
|
|
143
165
|
private retiredConnectionGenerations =
|
|
@@ -158,6 +180,11 @@ export class Environment {
|
|
|
158
180
|
return this.environmentPath;
|
|
159
181
|
}
|
|
160
182
|
|
|
183
|
+
/** This environment's name (the canonical key used by the API/service). */
|
|
184
|
+
public getEnvironmentName(): string {
|
|
185
|
+
return this.environmentName;
|
|
186
|
+
}
|
|
187
|
+
|
|
161
188
|
constructor(
|
|
162
189
|
environmentName: string,
|
|
163
190
|
environmentPath: string,
|
|
@@ -456,10 +483,15 @@ export class Environment {
|
|
|
456
483
|
);
|
|
457
484
|
}
|
|
458
485
|
|
|
459
|
-
// Authorize backstop (the *who* axis, 403).
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
486
|
+
// Authorize backstop (the *who* axis, 403). NOT guarded by
|
|
487
|
+
// hasAuthorize(): that only inspects top-level modelDef.contents
|
|
488
|
+
// sources, so a gated source reached only via a cross-file/deep
|
|
489
|
+
// join is invisible to it and this backstop would silently never
|
|
490
|
+
// run for such a model — the same bypass assertAuthorizedForRunnable
|
|
491
|
+
// itself closes on the query path (see model.ts
|
|
492
|
+
// assertAuthorizedForAllSources). The own-source probe and joined-
|
|
493
|
+
// gate walk it runs are cheap no-ops for a genuinely ungated model.
|
|
494
|
+
if (queryMaterializer && gateModel) {
|
|
463
495
|
await gateModel.assertAuthorizedForRunnable(
|
|
464
496
|
queryMaterializer,
|
|
465
497
|
givens ?? {},
|
|
@@ -589,6 +621,17 @@ export class Environment {
|
|
|
589
621
|
);
|
|
590
622
|
}
|
|
591
623
|
|
|
624
|
+
/**
|
|
625
|
+
* Snapshot of the packages currently loaded in memory (does not trigger a
|
|
626
|
+
* load or reload). Used by the standalone materialization scheduler to sweep
|
|
627
|
+
* only already-loaded packages — a not-yet-loaded package is simply not
|
|
628
|
+
* scheduled until something else loads it, so the scheduler never forces a
|
|
629
|
+
* load of its own.
|
|
630
|
+
*/
|
|
631
|
+
public getLoadedPackages(): Package[] {
|
|
632
|
+
return [...this.packages.values()];
|
|
633
|
+
}
|
|
634
|
+
|
|
592
635
|
public async listPackages(): Promise<ApiPackage[]> {
|
|
593
636
|
logger.debug("Listing packages", {
|
|
594
637
|
environmentPath: this.environmentPath,
|
|
@@ -613,6 +656,24 @@ export class Environment {
|
|
|
613
656
|
);
|
|
614
657
|
// Directory did not contain a valid package.json file -- therefore, it's not a package.
|
|
615
658
|
// Or it timed out
|
|
659
|
+
// Redact before this reaches getStatus: compiling a model
|
|
660
|
+
// resolves the package's connections, so a Postgres/DuckLake
|
|
661
|
+
// ATTACH failure surfaces here carrying the connection string
|
|
662
|
+
// (connection.ts builds it, and redacts it before logging for
|
|
663
|
+
// the same reason). A log line was the old destination; this
|
|
664
|
+
// one is an HTTP response body.
|
|
665
|
+
//
|
|
666
|
+
// Reduces the exposure, does not remove it: redactPgSecrets
|
|
667
|
+
// only covers keyword-form `password=`, which is what
|
|
668
|
+
// buildPgConnectionString emits. A URL-form connectionString
|
|
669
|
+
// supplied verbatim in config still carries its credentials
|
|
670
|
+
// through. Widen the helper rather than trusting this call.
|
|
671
|
+
this.failedPackages.set(
|
|
672
|
+
packageName,
|
|
673
|
+
redactPgSecrets(
|
|
674
|
+
error instanceof Error ? error.message : String(error),
|
|
675
|
+
),
|
|
676
|
+
);
|
|
616
677
|
return undefined;
|
|
617
678
|
}
|
|
618
679
|
}),
|
|
@@ -876,6 +937,9 @@ export class Environment {
|
|
|
876
937
|
}
|
|
877
938
|
this.packages.set(packageName, _package);
|
|
878
939
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
940
|
+
// It loaded, so any earlier failure is stale. A package that failed at
|
|
941
|
+
// boot can be fixed on disk and reloaded without a restart.
|
|
942
|
+
this.clearPackageLoadFailure(packageName);
|
|
879
943
|
logger.debug(`Successfully loaded package ${packageName}`);
|
|
880
944
|
|
|
881
945
|
return _package;
|
|
@@ -957,6 +1021,10 @@ export class Environment {
|
|
|
957
1021
|
throw error;
|
|
958
1022
|
}
|
|
959
1023
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1024
|
+
// Same reasoning as the load and install paths: it is serving now, so an
|
|
1025
|
+
// earlier boot failure is stale. Without this, a package fixed on disk
|
|
1026
|
+
// and re-added keeps its loadError for the life of the process.
|
|
1027
|
+
this.clearPackageLoadFailure(packageName);
|
|
960
1028
|
return this.packages.get(packageName);
|
|
961
1029
|
}
|
|
962
1030
|
|
|
@@ -1145,6 +1213,8 @@ export class Environment {
|
|
|
1145
1213
|
|
|
1146
1214
|
this.packages.set(packageName, newPackage);
|
|
1147
1215
|
this.setPackageStatus(packageName, PackageStatus.SERVING);
|
|
1216
|
+
// Publishing a fixed package clears the boot failure it replaces.
|
|
1217
|
+
this.clearPackageLoadFailure(packageName);
|
|
1148
1218
|
|
|
1149
1219
|
if (oldPackage) {
|
|
1150
1220
|
this.retireConnectionGeneration(`package ${packageName}`, () =>
|
|
@@ -1312,6 +1382,8 @@ export class Environment {
|
|
|
1312
1382
|
explores?: string[];
|
|
1313
1383
|
queryableSources?: "declared" | "all";
|
|
1314
1384
|
manifestLocation?: string | null;
|
|
1385
|
+
scope?: ApiPackage["scope"];
|
|
1386
|
+
materialization?: ApiPackage["materialization"];
|
|
1315
1387
|
},
|
|
1316
1388
|
): Promise<void> {
|
|
1317
1389
|
const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
@@ -1344,6 +1416,10 @@ export class Environment {
|
|
|
1344
1416
|
...(metadata.manifestLocation !== undefined
|
|
1345
1417
|
? { manifestLocation: metadata.manifestLocation }
|
|
1346
1418
|
: {}),
|
|
1419
|
+
...(metadata.scope !== undefined ? { scope: metadata.scope } : {}),
|
|
1420
|
+
...(metadata.materialization !== undefined
|
|
1421
|
+
? { materialization: metadata.materialization }
|
|
1422
|
+
: {}),
|
|
1347
1423
|
};
|
|
1348
1424
|
|
|
1349
1425
|
// Write back to file
|
|
@@ -1396,6 +1472,29 @@ export class Environment {
|
|
|
1396
1472
|
body.manifestLocation !== undefined
|
|
1397
1473
|
? body.manifestLocation
|
|
1398
1474
|
: existing.manifestLocation;
|
|
1475
|
+
// Persist `scope` and `materialization` (the schedule cron) are
|
|
1476
|
+
// editable via the API — both are writable in the schema. When the
|
|
1477
|
+
// body carries a value, apply it; otherwise preserve the
|
|
1478
|
+
// manifest-derived one (a name/description-only PATCH must not wipe
|
|
1479
|
+
// them, and the control plane must not misread the gap as a removal).
|
|
1480
|
+
// Changing the schedule re-arms the standalone scheduler on its next
|
|
1481
|
+
// tick — no reload needed.
|
|
1482
|
+
//
|
|
1483
|
+
// A *null* scope/materialization is treated the same as omitted
|
|
1484
|
+
// (preserve), NOT a wipe: the control plane's post-build rebind PATCH
|
|
1485
|
+
// carries only name/location/manifestLocation, but a client that
|
|
1486
|
+
// serializes unset fields as explicit null must not thereby trip the
|
|
1487
|
+
// policy gate below (a rejection there fails the orchestrated run) or
|
|
1488
|
+
// reset the persisted policy. `manifestLocation` is deliberately
|
|
1489
|
+
// different — null there means "clear" (revert to live), which the
|
|
1490
|
+
// orchestrator relies on.
|
|
1491
|
+
const scopeProvided = body.scope != null;
|
|
1492
|
+
const materializationProvided = body.materialization != null;
|
|
1493
|
+
const editingPolicy = scopeProvided || materializationProvided;
|
|
1494
|
+
const scope = scopeProvided ? body.scope : existing.scope;
|
|
1495
|
+
const materialization = materializationProvided
|
|
1496
|
+
? body.materialization
|
|
1497
|
+
: existing.materialization;
|
|
1399
1498
|
_package.setPackageMetadata({
|
|
1400
1499
|
name: body.name,
|
|
1401
1500
|
description: body.description,
|
|
@@ -1404,25 +1503,27 @@ export class Environment {
|
|
|
1404
1503
|
explores,
|
|
1405
1504
|
queryableSources,
|
|
1406
1505
|
manifestLocation,
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
// this wipes the in-memory `materialization` until the next reload —
|
|
1410
|
-
// which makes a later getPackage report no schedule and lets the
|
|
1411
|
-
// control plane misread the gap as a schedule removal. It is not a
|
|
1412
|
-
// PATCH-editable field, so always preserve the existing value.
|
|
1413
|
-
materialization: existing.materialization,
|
|
1414
|
-
// Same rationale for the manifest-derived persist `scope`: not
|
|
1415
|
-
// PATCH-editable, so preserve it rather than dropping it to the
|
|
1416
|
-
// default until the next reload.
|
|
1417
|
-
scope: existing.scope,
|
|
1506
|
+
materialization,
|
|
1507
|
+
scope,
|
|
1418
1508
|
});
|
|
1419
1509
|
|
|
1420
1510
|
// Strict-reject, symmetric with the publish path
|
|
1421
1511
|
// (package.controller.addPackage): validate the resulting explores
|
|
1422
1512
|
// against the live model set and restore the prior metadata before
|
|
1423
1513
|
// rejecting, so a bad update neither persists nor mutates the served
|
|
1424
|
-
// surface.
|
|
1425
|
-
|
|
1514
|
+
// surface. When the body edits the persistence policy (scope /
|
|
1515
|
+
// materialization), also enforce the same scope/schedule/freshness/cron
|
|
1516
|
+
// rules a publish enforces — but only then, so a description-only PATCH
|
|
1517
|
+
// on a package with a pre-existing (load-tolerated) policy warning is
|
|
1518
|
+
// not newly rejected. Cron validity is one of these rules
|
|
1519
|
+
// (persistencePolicyWarnings Rule 4), so publish, PATCH, load, and the
|
|
1520
|
+
// scheduler all enforce it identically.
|
|
1521
|
+
const policyMsg = editingPolicy
|
|
1522
|
+
? _package.formatInvalidPersistencePolicy()
|
|
1523
|
+
: "";
|
|
1524
|
+
const invalidMsg = [_package.formatInvalidExplores(), policyMsg]
|
|
1525
|
+
.filter(Boolean)
|
|
1526
|
+
.join("\n");
|
|
1426
1527
|
if (invalidMsg) {
|
|
1427
1528
|
_package.setPackageMetadata(existing);
|
|
1428
1529
|
throw new BadRequestError(invalidMsg);
|
|
@@ -1434,6 +1535,13 @@ export class Environment {
|
|
|
1434
1535
|
explores: normalizedExplores,
|
|
1435
1536
|
queryableSources: body.queryableSources,
|
|
1436
1537
|
manifestLocation: body.manifestLocation,
|
|
1538
|
+
// Only write when explicitly provided (non-null): mirrors the
|
|
1539
|
+
// null-as-absent rule above, so a rebind PATCH neither wipes the
|
|
1540
|
+
// persisted policy nor writes a stray `scope: null`.
|
|
1541
|
+
scope: scopeProvided ? body.scope : undefined,
|
|
1542
|
+
materialization: materializationProvided
|
|
1543
|
+
? body.materialization
|
|
1544
|
+
: undefined,
|
|
1437
1545
|
});
|
|
1438
1546
|
|
|
1439
1547
|
// When the body changes manifestLocation, apply it now so the new
|
|
@@ -1455,6 +1563,29 @@ export class Environment {
|
|
|
1455
1563
|
return this.packageStatuses.get(packageName);
|
|
1456
1564
|
}
|
|
1457
1565
|
|
|
1566
|
+
/**
|
|
1567
|
+
* Record why a configured package's location never mounted, so /status can
|
|
1568
|
+
* name the real cause instead of the missing-manifest fallout it produces.
|
|
1569
|
+
* Called by EnvironmentStore right after the environment is created.
|
|
1570
|
+
*/
|
|
1571
|
+
public setPackageMountError(packageName: string, message: string): void {
|
|
1572
|
+
this.mountErrors.set(packageName, message);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** Forget any recorded failure for a package, whatever its cause. */
|
|
1576
|
+
private clearPackageLoadFailure(packageName: string): void {
|
|
1577
|
+
this.failedPackages.delete(packageName);
|
|
1578
|
+
this.mountErrors.delete(packageName);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/** Packages configured for this environment that did not load, and why. */
|
|
1582
|
+
public getFailedPackages(): ReadonlyMap<string, string> {
|
|
1583
|
+
if (this.mountErrors.size === 0) return this.failedPackages;
|
|
1584
|
+
// Mount errors last, so the specific cause overwrites the generic
|
|
1585
|
+
// manifest error that the un-mounted package produces on its lazy load.
|
|
1586
|
+
return new Map([...this.failedPackages, ...this.mountErrors]);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1458
1589
|
public setPackageStatus(packageName: string, status: PackageStatus): void {
|
|
1459
1590
|
const currentStatus = this.packageStatuses.get(packageName);
|
|
1460
1591
|
this.packageStatuses.set(packageName, {
|
|
@@ -1471,6 +1602,14 @@ export class Environment {
|
|
|
1471
1602
|
public async deletePackage(packageName: string): Promise<void> {
|
|
1472
1603
|
assertSafePackageName(packageName);
|
|
1473
1604
|
return this.withPackageLock(packageName, async () => {
|
|
1605
|
+
// Clear the load failure before the early return, not after it. A
|
|
1606
|
+
// package that failed to load is not in `packages` (the load catch
|
|
1607
|
+
// evicts it), so deleting it takes the early return every time, while
|
|
1608
|
+
// the controller still drops its config row. Leaving the entry would
|
|
1609
|
+
// make getStatus report a loadError for a package that is no longer
|
|
1610
|
+
// configured, which is the one thing that channel must not do.
|
|
1611
|
+
this.clearPackageLoadFailure(packageName);
|
|
1612
|
+
|
|
1474
1613
|
const _package = this.packages.get(packageName);
|
|
1475
1614
|
if (!_package) {
|
|
1476
1615
|
return;
|