@malloy-publisher/server 0.0.227 → 0.0.229
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/README.docker.md +8 -8
- package/dist/app/api-doc.yaml +84 -1
- package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
- package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-C71GOfVW.js} +1 -1
- package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-CrgCAwLe.js} +1 -1
- package/dist/app/assets/MainPage-BG5__FN3.js +2 -0
- package/dist/app/assets/MaterializationsPage-DE6PnrDR.js +1 -0
- package/dist/app/assets/ModelPage-CcBjcbLm.js +1 -0
- package/dist/app/assets/PackagePage-JTy3ztkB.js +1 -0
- package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-Cymbp47a.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-C_nMnHr8.js +1 -0
- package/dist/app/assets/WorkbookPage-CPQu-DQx.js +1 -0
- package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
- package/dist/app/assets/{index-gEWxu09x.js → index-CM2qhQCI.js} +1 -1
- package/dist/app/assets/{index-D6YtyiJ0.js → index-CcuuST2X.js} +1 -1
- package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
- package/dist/app/assets/{index-BioohWQj.js → index-DlWCXghy.js} +1 -1
- package/dist/app/assets/index-DxArlgRD.js +529 -0
- package/dist/app/index.html +1 -1
- package/dist/default-publisher.config.json +7 -7
- package/dist/instrumentation.mjs +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/runtime/publisher.js +5 -0
- package/dist/server.mjs +7675 -899
- package/package.json +4 -2
- package/publisher.config.example.bigquery.json +7 -7
- package/publisher.config.example.duckdb.json +7 -7
- package/publisher.config.json +7 -11
- package/src/config.spec.ts +89 -3
- package/src/config.ts +102 -1
- package/src/controller/materialization.controller.spec.ts +9 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.ts +62 -31
- package/src/default-publisher.config.json +7 -7
- package/src/materialization_metrics.ts +33 -0
- package/src/mcp/handler_utils.spec.ts +108 -0
- package/src/mcp/handler_utils.ts +98 -4
- package/src/mcp/server.protocol.spec.ts +58 -0
- package/src/mcp/server.ts +29 -1
- 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/mcp/tools/compile_tool.spec.ts +207 -0
- package/src/mcp/tools/compile_tool.ts +177 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
- package/src/mcp/tools/execute_query_tool.ts +37 -4
- package/src/mcp/tools/get_context_tool.ts +1 -1
- package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
- package/src/mcp/tools/reload_package_tool.ts +158 -0
- package/src/query_param_utils.ts +11 -0
- package/src/runtime/publisher.js +5 -0
- package/src/server.ts +74 -14
- package/src/service/connection.spec.ts +8 -7
- package/src/service/cron_evaluator.spec.ts +79 -0
- package/src/service/cron_evaluator.ts +105 -0
- package/src/service/environment.ts +188 -19
- package/src/service/environment_store.spec.ts +372 -2
- package/src/service/environment_store.ts +149 -17
- package/src/service/environment_store_anchoring.spec.ts +107 -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 +260 -0
- package/src/service/materialization_scheduler.ts +304 -0
- package/src/service/materialization_service.spec.ts +167 -4
- package/src/service/materialization_service.ts +121 -8
- package/src/service/materialization_test_fixtures.ts +10 -1
- package/src/service/model.spec.ts +92 -0
- package/src/service/model.ts +58 -7
- package/src/service/package.ts +121 -14
- package/src/service/package_quote_bound_tables.spec.ts +171 -0
- package/src/service/package_reload_safety.spec.ts +193 -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 +154 -0
- package/src/storage/duckdb/MaterializationRepository.ts +56 -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/fixtures/query-givens/data/orders.csv +7 -0
- package/tests/fixtures/query-givens/model.malloy +34 -0
- package/tests/fixtures/query-givens/publisher.json +5 -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_wiring.integration.spec.ts +143 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
- package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
- package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
- package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
- package/dist/app/assets/EnvironmentPage-DvOJ7L_b.js +0 -1
- package/dist/app/assets/MainPage-BIe0VwBa.js +0 -2
- package/dist/app/assets/MaterializationsPage-BuZ6UJVx.js +0 -1
- package/dist/app/assets/ModelPage-DsPf-s8B.js +0 -1
- package/dist/app/assets/PackagePage-CEVNAKZa.js +0 -1
- package/dist/app/assets/ThemeEditorPage-DWC_FdNU.js +0 -1
- package/dist/app/assets/WorkbookPage-CGrsFz8p.js +0 -1
- package/dist/app/assets/index-DNUZpnaa.js +0 -527
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloy-publisher/server",
|
|
3
3
|
"description": "Malloy Publisher Server",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.229",
|
|
5
5
|
"main": "dist/server.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"malloy-publisher": "dist/server.mjs"
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"body-parser": "^1.20.2",
|
|
60
60
|
"chokidar": "^4.0.3",
|
|
61
61
|
"cors": "^2.8.5",
|
|
62
|
+
"cron-parser": "^5.6.2",
|
|
62
63
|
"express": "^4.21.0",
|
|
63
64
|
"extract-zip": "^2.0.1",
|
|
64
65
|
"globals": "^15.9.0",
|
|
@@ -69,7 +70,8 @@
|
|
|
69
70
|
"node-cron": "^3.0.3",
|
|
70
71
|
"recursive-readdir": "^2.2.3",
|
|
71
72
|
"simple-git": "^3.28.0",
|
|
72
|
-
"uuid": "^11.0.3"
|
|
73
|
+
"uuid": "^11.0.3",
|
|
74
|
+
"winston": "^3.17.0"
|
|
73
75
|
},
|
|
74
76
|
"devDependencies": {
|
|
75
77
|
"@eslint/compat": "^1.2.7",
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
"frozenConfig": false,
|
|
3
3
|
"environments": [
|
|
4
4
|
{
|
|
5
|
-
"name": "
|
|
5
|
+
"name": "examples",
|
|
6
6
|
"packages": [
|
|
7
7
|
{
|
|
8
|
-
"name": "
|
|
9
|
-
"location": "https://github.com/
|
|
8
|
+
"name": "storefront",
|
|
9
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/storefront"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
"name": "
|
|
13
|
-
"location": "https://github.com/
|
|
12
|
+
"name": "governed-analytics",
|
|
13
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/governed-analytics"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
|
-
"name": "
|
|
17
|
-
"location": "https://github.com/
|
|
16
|
+
"name": "html-data-app",
|
|
17
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/html-data-app"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"name": "bigquery-hackernews",
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
"frozenConfig": false,
|
|
3
3
|
"environments": [
|
|
4
4
|
{
|
|
5
|
-
"name": "
|
|
5
|
+
"name": "examples",
|
|
6
6
|
"packages": [
|
|
7
7
|
{
|
|
8
|
-
"name": "
|
|
9
|
-
"location": "https://github.com/
|
|
8
|
+
"name": "storefront",
|
|
9
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/storefront"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
"name": "
|
|
13
|
-
"location": "https://github.com/
|
|
12
|
+
"name": "governed-analytics",
|
|
13
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/governed-analytics"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
|
-
"name": "
|
|
17
|
-
"location": "https://github.com/
|
|
16
|
+
"name": "html-data-app",
|
|
17
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/html-data-app"
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
20
|
"connections": []
|
package/publisher.config.json
CHANGED
|
@@ -2,23 +2,19 @@
|
|
|
2
2
|
"frozenConfig": false,
|
|
3
3
|
"environments": [
|
|
4
4
|
{
|
|
5
|
-
"name": "
|
|
5
|
+
"name": "examples",
|
|
6
6
|
"packages": [
|
|
7
7
|
{
|
|
8
|
-
"name": "
|
|
9
|
-
"location": "
|
|
8
|
+
"name": "storefront",
|
|
9
|
+
"location": "../../examples/storefront"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
"name": "
|
|
13
|
-
"location": "
|
|
12
|
+
"name": "governed-analytics",
|
|
13
|
+
"location": "../../examples/governed-analytics"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
|
-
"name": "
|
|
17
|
-
"location": "
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"name": "faa-givens-demo",
|
|
21
|
-
"location": "https://github.com/credibledata/malloy-samples/tree/main/faa-givens-demo"
|
|
16
|
+
"name": "html-data-app",
|
|
17
|
+
"location": "../../examples/html-data-app"
|
|
22
18
|
}
|
|
23
19
|
],
|
|
24
20
|
"connections": []
|
package/src/config.spec.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getPublisherConfig,
|
|
6
|
+
getPublisherConfigDir,
|
|
7
|
+
type PublisherConfig,
|
|
8
|
+
} from "./config";
|
|
5
9
|
import { PUBLISHER_CONFIG_NAME } from "./constants";
|
|
6
10
|
|
|
7
11
|
describe("Config Environment Variable Substitution", () => {
|
|
@@ -1040,8 +1044,8 @@ describe("Config path resolution (--config and bundled default)", () => {
|
|
|
1040
1044
|
|
|
1041
1045
|
expect(result.environments.length).toBeGreaterThan(0);
|
|
1042
1046
|
const env = result.environments[0];
|
|
1043
|
-
expect(env.name).toBe("
|
|
1044
|
-
expect(env.packages.some((p) => p.name === "
|
|
1047
|
+
expect(env.name).toBe("examples");
|
|
1048
|
+
expect(env.packages.some((p) => p.name === "storefront")).toBe(true);
|
|
1045
1049
|
expect(env.packages.some((p) => p.name === "bigquery-hackernews")).toBe(
|
|
1046
1050
|
false,
|
|
1047
1051
|
);
|
|
@@ -1408,3 +1412,85 @@ describe("getMaxConcurrentQueries", () => {
|
|
|
1408
1412
|
expect(() => getMaxConcurrentQueries()).toThrow();
|
|
1409
1413
|
});
|
|
1410
1414
|
});
|
|
1415
|
+
|
|
1416
|
+
describe("getPublisherConfigDir", () => {
|
|
1417
|
+
const testRoot = path.join(process.cwd(), "test-temp-config-dir");
|
|
1418
|
+
const elsewhere = path.join(testRoot, "elsewhere");
|
|
1419
|
+
const savedConfigPath = process.env.PUBLISHER_CONFIG_PATH;
|
|
1420
|
+
|
|
1421
|
+
beforeEach(() => {
|
|
1422
|
+
fs.mkdirSync(elsewhere, { recursive: true });
|
|
1423
|
+
// Both knobs feed resolvePublisherConfigPath, and every spec file shares
|
|
1424
|
+
// one process, so clear both rather than inherit whatever ran before.
|
|
1425
|
+
delete process.env.PUBLISHER_CONFIG_PATH;
|
|
1426
|
+
delete process.env.PUBLISHER_USE_BUNDLED_DEFAULT;
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
afterEach(() => {
|
|
1430
|
+
fs.rmSync(testRoot, { recursive: true, force: true });
|
|
1431
|
+
// Clear both, not just the one this describe saves: every spec file shares
|
|
1432
|
+
// one process, so a knob left set here follows whatever runs next.
|
|
1433
|
+
delete process.env.PUBLISHER_USE_BUNDLED_DEFAULT;
|
|
1434
|
+
if (savedConfigPath === undefined) {
|
|
1435
|
+
delete process.env.PUBLISHER_CONFIG_PATH;
|
|
1436
|
+
} else {
|
|
1437
|
+
process.env.PUBLISHER_CONFIG_PATH = savedConfigPath;
|
|
1438
|
+
}
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
it("returns the server root when the config sits there", () => {
|
|
1442
|
+
fs.writeFileSync(
|
|
1443
|
+
path.join(testRoot, PUBLISHER_CONFIG_NAME),
|
|
1444
|
+
JSON.stringify({ environments: [] }),
|
|
1445
|
+
);
|
|
1446
|
+
expect(getPublisherConfigDir(testRoot)).toBe(testRoot);
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it("follows PUBLISHER_CONFIG_PATH to a config outside the server root", () => {
|
|
1450
|
+
// The --config case: a relative package location must anchor next to
|
|
1451
|
+
// the config that declares it, not at whatever cwd the server booted in.
|
|
1452
|
+
const configPath = path.join(elsewhere, PUBLISHER_CONFIG_NAME);
|
|
1453
|
+
fs.writeFileSync(configPath, JSON.stringify({ environments: [] }));
|
|
1454
|
+
process.env.PUBLISHER_CONFIG_PATH = configPath;
|
|
1455
|
+
expect(getPublisherConfigDir(testRoot)).toBe(elsewhere);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
it("returns null when no config resolves, so callers fall back to the server root", () => {
|
|
1459
|
+
expect(getPublisherConfigDir(testRoot)).toBeNull();
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
it("returns an absolute anchor even when --config is relative", () => {
|
|
1463
|
+
// An anchor that is itself relative would re-resolve against the cwd of
|
|
1464
|
+
// whoever reads it, which is the bug this whole change is fixing.
|
|
1465
|
+
const rel = path.relative(
|
|
1466
|
+
process.cwd(),
|
|
1467
|
+
path.join(elsewhere, PUBLISHER_CONFIG_NAME),
|
|
1468
|
+
);
|
|
1469
|
+
fs.writeFileSync(
|
|
1470
|
+
path.join(elsewhere, PUBLISHER_CONFIG_NAME),
|
|
1471
|
+
JSON.stringify({ environments: [] }),
|
|
1472
|
+
);
|
|
1473
|
+
process.env.PUBLISHER_CONFIG_PATH = rel;
|
|
1474
|
+
const anchor = getPublisherConfigDir(testRoot);
|
|
1475
|
+
expect(anchor).not.toBeNull();
|
|
1476
|
+
expect(path.isAbsolute(anchor as string)).toBe(true);
|
|
1477
|
+
expect(anchor).toBe(elsewhere);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it("returns null when --config names a directory instead of a file", () => {
|
|
1481
|
+
// existsSync is true for a directory, so without a file check the anchor
|
|
1482
|
+
// would silently become that directory's PARENT, for a config that never
|
|
1483
|
+
// loads (getPublisherConfig catches the read error and returns empty).
|
|
1484
|
+
process.env.PUBLISHER_CONFIG_PATH = elsewhere;
|
|
1485
|
+
expect(getPublisherConfigDir(testRoot)).toBeNull();
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
it("returns null for the bundled default rather than anchoring inside the install", () => {
|
|
1489
|
+
// Zero-arg `npx @malloy-publisher/server` resolves the config that ships
|
|
1490
|
+
// inside the package. Anchoring a user's relative location under
|
|
1491
|
+
// node_modules would be meaningless, so the caller falls back to the
|
|
1492
|
+
// server root, which is what this did before the anchor moved.
|
|
1493
|
+
process.env.PUBLISHER_USE_BUNDLED_DEFAULT = "true";
|
|
1494
|
+
expect(getPublisherConfigDir(testRoot)).toBeNull();
|
|
1495
|
+
});
|
|
1496
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -71,7 +71,13 @@ function resolvePublisherConfigPath(serverRoot: string): {
|
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
// Home paths are POSIX form only: `~/` expands, while bare `~`, `~user/…`,
|
|
75
|
+
// and Windows-style `~\` are not local paths and are rejected downstream.
|
|
76
|
+
type FilesystemPath =
|
|
77
|
+
| `./${string}`
|
|
78
|
+
| `../${string}`
|
|
79
|
+
| `/${string}`
|
|
80
|
+
| `~/${string}`;
|
|
75
81
|
type GcsPath = `gs://${string}`;
|
|
76
82
|
type ApiConnection = components["schemas"]["Connection"];
|
|
77
83
|
export type Theme = components["schemas"]["Theme"];
|
|
@@ -256,6 +262,72 @@ export const getMemoryGovernorConfig = (): MemoryGovernorConfig | null => {
|
|
|
256
262
|
};
|
|
257
263
|
};
|
|
258
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Tunables for the standalone {@link MaterializationScheduler}. Sourced from
|
|
267
|
+
* environment variables at startup; see {@link getMaterializationSchedulerConfig}.
|
|
268
|
+
*
|
|
269
|
+
* The scheduler fires a package's `materialization.schedule` cron in **standalone**
|
|
270
|
+
* deployments (no control plane). It is **disabled by default** — an orchestrated
|
|
271
|
+
* deployment, whose control plane already drives materialization, never sets the
|
|
272
|
+
* enable flag, so the scheduler is never constructed there.
|
|
273
|
+
*/
|
|
274
|
+
export interface MaterializationSchedulerConfig {
|
|
275
|
+
/** Cadence of the due-schedule sweep, in milliseconds. */
|
|
276
|
+
tickIntervalMs: number;
|
|
277
|
+
/** Max packages fired per tick — a stampede guard for large deployments. */
|
|
278
|
+
maxFiresPerTick: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const DEFAULT_SCHEDULER_INTERVAL_MS = 60_000;
|
|
282
|
+
const MIN_SCHEDULER_INTERVAL_MS = 1_000;
|
|
283
|
+
const DEFAULT_SCHEDULER_MAX_FIRES_PER_TICK = 10;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parse standalone-scheduler settings and return a validated config, or `null`
|
|
287
|
+
* when the feature is disabled. Disabled iff
|
|
288
|
+
* `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` is unset/false — the default — so
|
|
289
|
+
* the scheduler never runs in an orchestrated deployment (where the control
|
|
290
|
+
* plane drives materialization itself).
|
|
291
|
+
*
|
|
292
|
+
* **Never set `PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` on an orchestrated
|
|
293
|
+
* worker.** It is the primary safety guard: a control-plane-loaded package that
|
|
294
|
+
* is serving live has `manifestLocation === null`, so the scheduler's
|
|
295
|
+
* per-package `manifestLocation` skip does not cover it — only this flag being
|
|
296
|
+
* off keeps the standalone scheduler from double-driving the control plane.
|
|
297
|
+
*
|
|
298
|
+
* Throws at startup on malformed input so a typo surfaces loudly rather than
|
|
299
|
+
* silently disabling scheduling.
|
|
300
|
+
*/
|
|
301
|
+
export const getMaterializationSchedulerConfig =
|
|
302
|
+
(): MaterializationSchedulerConfig | null => {
|
|
303
|
+
const enabled =
|
|
304
|
+
parseBoolEnv("PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER") ?? false;
|
|
305
|
+
if (!enabled) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const tickIntervalMs =
|
|
310
|
+
parseIntEnv("PUBLISHER_MATERIALIZATION_SCHEDULER_INTERVAL_MS") ??
|
|
311
|
+
DEFAULT_SCHEDULER_INTERVAL_MS;
|
|
312
|
+
const maxFiresPerTick =
|
|
313
|
+
parseIntEnv(
|
|
314
|
+
"PUBLISHER_MATERIALIZATION_SCHEDULER_MAX_FIRES_PER_TICK",
|
|
315
|
+
) ?? DEFAULT_SCHEDULER_MAX_FIRES_PER_TICK;
|
|
316
|
+
|
|
317
|
+
if (tickIntervalMs < MIN_SCHEDULER_INTERVAL_MS) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`PUBLISHER_MATERIALIZATION_SCHEDULER_INTERVAL_MS must be >= ${MIN_SCHEDULER_INTERVAL_MS} (got ${tickIntervalMs})`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (maxFiresPerTick <= 0) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`PUBLISHER_MATERIALIZATION_SCHEDULER_MAX_FIRES_PER_TICK must be a positive integer (got ${maxFiresPerTick})`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { tickIntervalMs, maxFiresPerTick };
|
|
329
|
+
};
|
|
330
|
+
|
|
259
331
|
/**
|
|
260
332
|
* Resolve the row cap applied to ad-hoc connection SQL queries.
|
|
261
333
|
* Reads `PUBLISHER_MAX_QUERY_ROWS`; falls back to
|
|
@@ -404,6 +476,35 @@ function processConfigValue(value: unknown): unknown {
|
|
|
404
476
|
return value;
|
|
405
477
|
}
|
|
406
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Absolute directory that a relative package `location` is resolved against:
|
|
481
|
+
* the one holding the active config file.
|
|
482
|
+
*
|
|
483
|
+
* Null when there is nothing sensible to anchor to, leaving the caller to pick
|
|
484
|
+
* a base. That covers three cases: no config at all; the bundled default, which
|
|
485
|
+
* lives inside the installed package, where anchoring a user's relative path
|
|
486
|
+
* somewhere under node_modules would be meaningless (and which declares only
|
|
487
|
+
* remote locations of its own); and a `--config` that names a directory rather
|
|
488
|
+
* than a file, which cannot be read as a config, so its parent is not an anchor
|
|
489
|
+
* anyone asked for.
|
|
490
|
+
*/
|
|
491
|
+
export const getPublisherConfigDir = (serverRoot: string): string | null => {
|
|
492
|
+
const resolved = resolvePublisherConfigPath(serverRoot);
|
|
493
|
+
if (!resolved || resolved.isBundledDefault) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
if (!fs.statSync(resolved.path).isFile()) {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
// Resolve: `--config` may be relative, and an anchor that is itself relative
|
|
504
|
+
// would re-resolve against the cwd of whoever reads it.
|
|
505
|
+
return path.resolve(path.dirname(resolved.path));
|
|
506
|
+
};
|
|
507
|
+
|
|
407
508
|
export const getPublisherConfig = (serverRoot: string): PublisherConfig => {
|
|
408
509
|
const resolved = resolvePublisherConfigPath(serverRoot);
|
|
409
510
|
if (!resolved) {
|
|
@@ -36,6 +36,15 @@ describe("MaterializationController.createMaterialization validation", () => {
|
|
|
36
36
|
).toEqual({ forceRefresh: true, sourceNames: ["a", "b"] });
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
it("never forwards a client-supplied trigger (SCHEDULER cannot be forged)", async () => {
|
|
40
|
+
// trigger is service-level-only: the controller must strip it so an API
|
|
41
|
+
// caller cannot mint a run that reads as scheduler-driven. The service
|
|
42
|
+
// then defaults it to ON_DEMAND.
|
|
43
|
+
const parsed = await parse({ forceRefresh: true, trigger: "SCHEDULER" });
|
|
44
|
+
expect(parsed).toEqual({ forceRefresh: true });
|
|
45
|
+
expect("trigger" in (parsed as object)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
39
48
|
it("rejects a non-boolean forceRefresh", async () => {
|
|
40
49
|
const { controller } = build();
|
|
41
50
|
await expect(
|
|
@@ -20,6 +20,11 @@ export class MaterializationController {
|
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Whitelist: only these fields are read off the client body. `trigger` is
|
|
24
|
+
// deliberately NOT accepted here — it is service-level-only, defaulted to
|
|
25
|
+
// `ON_DEMAND` and set to `SCHEDULER` solely by the in-process scheduler
|
|
26
|
+
// (which calls the service directly, not this HTTP path). Do not add
|
|
27
|
+
// `trigger` to this parser, or an API caller could forge a scheduled run.
|
|
23
28
|
private validateCreateBody(body: Record<string, unknown>): {
|
|
24
29
|
forceRefresh?: boolean;
|
|
25
30
|
sourceNames?: string[];
|
|
@@ -201,6 +206,16 @@ export class MaterializationController {
|
|
|
201
206
|
);
|
|
202
207
|
}
|
|
203
208
|
|
|
209
|
+
async listEnvironmentMaterializations(
|
|
210
|
+
environmentName: string,
|
|
211
|
+
options?: { limit?: number; offset?: number },
|
|
212
|
+
) {
|
|
213
|
+
return this.materializationService.listEnvironmentMaterializations(
|
|
214
|
+
environmentName,
|
|
215
|
+
options,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
204
219
|
async getMaterialization(
|
|
205
220
|
environmentName: string,
|
|
206
221
|
packageName: string,
|
|
@@ -5,6 +5,13 @@ import { EnvironmentStore } from "../service/environment_store";
|
|
|
5
5
|
|
|
6
6
|
type ApiPackage = components["schemas"]["Package"];
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Which path a reload took. `in-place` recompiles the tree already on disk and
|
|
10
|
+
* leaves it alone; `reinstalled` re-fetches from the package's install location,
|
|
11
|
+
* which overwrites on-disk edits.
|
|
12
|
+
*/
|
|
13
|
+
export type PackageReloadMode = "in-place" | "reinstalled";
|
|
14
|
+
|
|
8
15
|
/**
|
|
9
16
|
* Everything that is strict-at-publish, joined into one 400 message (or
|
|
10
17
|
* undefined when the package is publishable): invalid explores entries plus
|
|
@@ -50,45 +57,69 @@ export class PackageController {
|
|
|
50
57
|
packageName: string,
|
|
51
58
|
reload: boolean,
|
|
52
59
|
): Promise<ApiPackage> {
|
|
60
|
+
if (reload) {
|
|
61
|
+
return (await this.reloadPackage(environmentName, packageName))
|
|
62
|
+
.metadata;
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
const environment = await this.environmentStore.getEnvironment(
|
|
54
66
|
environmentName,
|
|
55
67
|
false,
|
|
56
68
|
);
|
|
57
|
-
|
|
58
|
-
if (reload) {
|
|
59
|
-
// Resolve the package's source location from the currently-cached
|
|
60
|
-
// metadata WITHOUT triggering a stale-state reload. If a `location`
|
|
61
|
-
// is set, route the reload through `installPackage` so that
|
|
62
|
-
// download-then-load happens atomically; otherwise fall back to an
|
|
63
|
-
// in-place reload of the existing on-disk content.
|
|
64
|
-
let location: string | undefined;
|
|
65
|
-
try {
|
|
66
|
-
const cached = await environment.getPackage(packageName, false);
|
|
67
|
-
location = cached.getPackageMetadata().location;
|
|
68
|
-
} catch {
|
|
69
|
-
// Not previously loaded — nothing to reinstall from.
|
|
70
|
-
}
|
|
71
|
-
if (location) {
|
|
72
|
-
const reinstalled = await environment.installPackage(
|
|
73
|
-
packageName,
|
|
74
|
-
(stagingPath) =>
|
|
75
|
-
this.downloadInto(
|
|
76
|
-
environmentName,
|
|
77
|
-
packageName,
|
|
78
|
-
location,
|
|
79
|
-
stagingPath,
|
|
80
|
-
),
|
|
81
|
-
);
|
|
82
|
-
return reinstalled.getPackageMetadata();
|
|
83
|
-
}
|
|
84
|
-
const _package = await environment.getPackage(packageName, true);
|
|
85
|
-
return _package.getPackageMetadata();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
69
|
const _package = await environment.getPackage(packageName, false);
|
|
89
70
|
return _package.getPackageMetadata();
|
|
90
71
|
}
|
|
91
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Reload a package and report WHICH path ran. The two are not equivalent to
|
|
75
|
+
* anyone holding on-disk edits: an in-place reload recompiles the tree that
|
|
76
|
+
* is already there, while a reinstall re-fetches from the package's install
|
|
77
|
+
* location and overwrites it. A caller that surfaces the reload to a user
|
|
78
|
+
* needs to be able to say which happened, so it is returned rather than
|
|
79
|
+
* inferred. `getPackage` keeps returning bare metadata, so the REST contract
|
|
80
|
+
* is unchanged.
|
|
81
|
+
*/
|
|
82
|
+
public async reloadPackage(
|
|
83
|
+
environmentName: string,
|
|
84
|
+
packageName: string,
|
|
85
|
+
): Promise<{ metadata: ApiPackage; mode: PackageReloadMode }> {
|
|
86
|
+
const environment = await this.environmentStore.getEnvironment(
|
|
87
|
+
environmentName,
|
|
88
|
+
false,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Resolve the package's source location from the currently-cached
|
|
92
|
+
// metadata WITHOUT triggering a stale-state reload. If a `location`
|
|
93
|
+
// is set, route the reload through `installPackage` so that
|
|
94
|
+
// download-then-load happens atomically; otherwise fall back to an
|
|
95
|
+
// in-place reload of the existing on-disk content.
|
|
96
|
+
let location: string | undefined;
|
|
97
|
+
try {
|
|
98
|
+
const cached = await environment.getPackage(packageName, false);
|
|
99
|
+
location = cached.getPackageMetadata().location;
|
|
100
|
+
} catch {
|
|
101
|
+
// Not previously loaded, so there is nothing to reinstall from.
|
|
102
|
+
}
|
|
103
|
+
if (location) {
|
|
104
|
+
const reinstalled = await environment.installPackage(
|
|
105
|
+
packageName,
|
|
106
|
+
(stagingPath) =>
|
|
107
|
+
this.downloadInto(
|
|
108
|
+
environmentName,
|
|
109
|
+
packageName,
|
|
110
|
+
location,
|
|
111
|
+
stagingPath,
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
return {
|
|
115
|
+
metadata: reinstalled.getPackageMetadata(),
|
|
116
|
+
mode: "reinstalled",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const _package = await environment.getPackage(packageName, true);
|
|
120
|
+
return { metadata: _package.getPackageMetadata(), mode: "in-place" };
|
|
121
|
+
}
|
|
122
|
+
|
|
92
123
|
async addPackage(environmentName: string, body: ApiPackage) {
|
|
93
124
|
if (this.environmentStore.publisherConfigIsFrozen) {
|
|
94
125
|
throw new FrozenConfigError();
|
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
"frozenConfig": false,
|
|
3
3
|
"environments": [
|
|
4
4
|
{
|
|
5
|
-
"name": "
|
|
5
|
+
"name": "examples",
|
|
6
6
|
"packages": [
|
|
7
7
|
{
|
|
8
|
-
"name": "
|
|
9
|
-
"location": "https://github.com/
|
|
8
|
+
"name": "storefront",
|
|
9
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/storefront"
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
"name": "
|
|
13
|
-
"location": "https://github.com/
|
|
12
|
+
"name": "governed-analytics",
|
|
13
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/governed-analytics"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
|
-
"name": "
|
|
17
|
-
"location": "https://github.com/
|
|
16
|
+
"name": "html-data-app",
|
|
17
|
+
"location": "https://github.com/malloydata/publisher/tree/main/examples/html-data-app"
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
20
|
"connections": []
|
|
@@ -78,6 +78,13 @@ const manifestBindCounter = lazyCounter(
|
|
|
78
78
|
"publisher_materialization_manifest_bind_total",
|
|
79
79
|
"Manifest bind attempts. Label: outcome ('success'|'failure'|'timeout').",
|
|
80
80
|
);
|
|
81
|
+
const manifestBindDegradedCounter = lazyCounter(
|
|
82
|
+
"publisher_materialization_manifest_bind_degraded_total",
|
|
83
|
+
"Manifest entries bound with an UNQUOTED table path because their connection " +
|
|
84
|
+
"could not be resolved (serve-side bind) or is absent from the build " +
|
|
85
|
+
"(build-side seed). A misconfiguration that breaks the source on a " +
|
|
86
|
+
"case-folding engine (Snowflake); alertable.",
|
|
87
|
+
);
|
|
81
88
|
const sourceBuildDuration = lazyHistogram(
|
|
82
89
|
"publisher_materialization_source_build_duration_ms",
|
|
83
90
|
"Wall-clock duration of building a single persist source.",
|
|
@@ -87,6 +94,22 @@ const dropTablesCounter = lazyCounter(
|
|
|
87
94
|
"publisher_materialization_drop_tables_total",
|
|
88
95
|
"Physical tables dropped on delete. Label: outcome ('success'|'failure').",
|
|
89
96
|
);
|
|
97
|
+
const scheduledFireCounter = lazyCounter(
|
|
98
|
+
"publisher_materialization_scheduled_fires_total",
|
|
99
|
+
"Standalone-scheduler attempts to fire a package's materialization.schedule. " +
|
|
100
|
+
"Label: outcome ('fired'|'conflict'|'error').",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Record a standalone-scheduler fire attempt. `fired` = a SCHEDULER run started;
|
|
105
|
+
* `conflict` = a materialization was already active (this occurrence coalesces
|
|
106
|
+
* into it); `error` = the fire failed unexpectedly.
|
|
107
|
+
*/
|
|
108
|
+
export function recordScheduledFire(
|
|
109
|
+
outcome: "fired" | "conflict" | "error",
|
|
110
|
+
): void {
|
|
111
|
+
scheduledFireCounter().add(1, { outcome });
|
|
112
|
+
}
|
|
90
113
|
|
|
91
114
|
/** Record the outcome and duration of a materialization build. */
|
|
92
115
|
export function recordMaterializationRun(
|
|
@@ -147,6 +170,16 @@ export function recordManifestBind(outcome: ManifestBindOutcome): void {
|
|
|
147
170
|
manifestBindCounter().add(1, { outcome });
|
|
148
171
|
}
|
|
149
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A single manifest entry bound with an unquoted table path because its
|
|
175
|
+
* connection could not be resolved for quoting. Per-entry degradation (the rest
|
|
176
|
+
* of the bind succeeds), distinct from the whole-bind {@link recordManifestBind}
|
|
177
|
+
* outcome — hence its own counter rather than a label on that one.
|
|
178
|
+
*/
|
|
179
|
+
export function recordManifestBindDegraded(): void {
|
|
180
|
+
manifestBindDegradedCounter().add(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
150
183
|
/** Record the wall-clock duration of building one persist source's table. */
|
|
151
184
|
export function recordSourceBuildDuration(durationMs: number): void {
|
|
152
185
|
sourceBuildDuration().record(durationMs);
|