@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import sinon from "sinon";
|
|
3
|
+
import type { Connection } from "@malloydata/malloy";
|
|
4
|
+
import { logger } from "../logger";
|
|
5
|
+
import type { FreshnessManifest } from "../storage/DatabaseInterface";
|
|
6
|
+
import { Model } from "./model";
|
|
7
|
+
import { Package } from "./package";
|
|
8
|
+
|
|
9
|
+
// Unit coverage for Package.quoteBoundTableNames — the bind-time step that
|
|
10
|
+
// mirrors the CREATE side's quoting onto manifest table paths (PR #904). The
|
|
11
|
+
// integration test proves quote chars appear in real DuckDB SQL, but DuckDB is
|
|
12
|
+
// not case-folding and never exhibited the bug; these tests pin the actual
|
|
13
|
+
// quoted contract per dialect and exercise every branch (dialect selection,
|
|
14
|
+
// no-connection passthrough, already-quoted passthrough, and the
|
|
15
|
+
// unresolvable-connection fallback) without needing a live warehouse. The
|
|
16
|
+
// quoting itself runs through the real `quoteTablePath`, so this is not a
|
|
17
|
+
// re-implementation of the oracle — it drives the shipping code.
|
|
18
|
+
describe("service/package quoteBoundTableNames", () => {
|
|
19
|
+
afterEach(() => sinon.restore());
|
|
20
|
+
|
|
21
|
+
function makePackage(): Package {
|
|
22
|
+
return new Package(
|
|
23
|
+
"testEnv",
|
|
24
|
+
"testPackage",
|
|
25
|
+
"/tmp/does-not-matter",
|
|
26
|
+
{ name: "testPackage", description: "Test package" },
|
|
27
|
+
[],
|
|
28
|
+
new Map<string, Model>(),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Reach the private method the same way package.spec.ts reaches private
|
|
33
|
+
// statics — this is the smallest seam that drives the real branch logic.
|
|
34
|
+
function quote(
|
|
35
|
+
pkg: Package,
|
|
36
|
+
entries: FreshnessManifest,
|
|
37
|
+
): Promise<FreshnessManifest> {
|
|
38
|
+
// @ts-expect-error Accessing private method for testing
|
|
39
|
+
return pkg.quoteBoundTableNames(entries);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stubDialect(pkg: Package, dialectName: string): void {
|
|
43
|
+
sinon
|
|
44
|
+
.stub(pkg, "getMalloyConnection")
|
|
45
|
+
.resolves({ dialectName } as Connection);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
it("double-quotes each path segment for a case-folding dialect (Snowflake)", async () => {
|
|
49
|
+
const pkg = makePackage();
|
|
50
|
+
stubDialect(pkg, "snowflake");
|
|
51
|
+
|
|
52
|
+
const out = await quote(pkg, {
|
|
53
|
+
s1: { tableName: "schema.order_summary__g0", connectionName: "sf" },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(out.s1.tableName).toBe('"schema"."order_summary__g0"');
|
|
57
|
+
// connectionName and any other fields survive the rewrite.
|
|
58
|
+
expect(out.s1.connectionName).toBe("sf");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("double-quotes for other double-quote dialects (Postgres)", async () => {
|
|
62
|
+
const pkg = makePackage();
|
|
63
|
+
stubDialect(pkg, "postgres");
|
|
64
|
+
|
|
65
|
+
const out = await quote(pkg, {
|
|
66
|
+
s1: { tableName: "public.daily", connectionName: "pg" },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(out.s1.tableName).toBe('"public"."daily"');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("backticks each segment for a backtick dialect (BigQuery)", async () => {
|
|
73
|
+
const pkg = makePackage();
|
|
74
|
+
stubDialect(pkg, "standardsql");
|
|
75
|
+
|
|
76
|
+
const out = await quote(pkg, {
|
|
77
|
+
s1: { tableName: "my-proj.ds.events", connectionName: "bq" },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(out.s1.tableName).toBe("`my-proj`.`ds`.`events`");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("binds verbatim when the producer recorded no connectionName", async () => {
|
|
84
|
+
const pkg = makePackage();
|
|
85
|
+
const spy = sinon.stub(pkg, "getMalloyConnection");
|
|
86
|
+
|
|
87
|
+
const out = await quote(pkg, {
|
|
88
|
+
s1: { tableName: "schema.bare_builder_table" },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(out.s1.tableName).toBe("schema.bare_builder_table");
|
|
92
|
+
// No connection lookup attempted — this is the benign default path.
|
|
93
|
+
expect(spy.called).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("passes an already-quoted name through without double-quoting", async () => {
|
|
97
|
+
const pkg = makePackage();
|
|
98
|
+
const spy = sinon.stub(pkg, "getMalloyConnection");
|
|
99
|
+
|
|
100
|
+
const out = await quote(pkg, {
|
|
101
|
+
s1: {
|
|
102
|
+
tableName: '"schema"."already_canonical"',
|
|
103
|
+
connectionName: "sf",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(out.s1.tableName).toBe('"schema"."already_canonical"');
|
|
108
|
+
expect(spy.called).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("passes an already-backticked name through without re-quoting", async () => {
|
|
112
|
+
const pkg = makePackage();
|
|
113
|
+
const spy = sinon.stub(pkg, "getMalloyConnection");
|
|
114
|
+
|
|
115
|
+
const out = await quote(pkg, {
|
|
116
|
+
s1: { tableName: "`ds`.`events`", connectionName: "bq" },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(out.s1.tableName).toBe("`ds`.`events`");
|
|
120
|
+
expect(spy.called).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("degrades one entry (unquoted) and logs an actionable error when its connection is unresolvable", async () => {
|
|
124
|
+
const pkg = makePackage();
|
|
125
|
+
sinon
|
|
126
|
+
.stub(pkg, "getMalloyConnection")
|
|
127
|
+
.rejects(new Error("no connection named 'gone'"));
|
|
128
|
+
const errSpy = sinon.stub(logger, "error");
|
|
129
|
+
|
|
130
|
+
const out = await quote(pkg, {
|
|
131
|
+
s1: { tableName: "schema.orphaned", connectionName: "gone" },
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// The one entry degrades to the unquoted name rather than throwing out
|
|
135
|
+
// of the loop and failing the whole package bind.
|
|
136
|
+
expect(out.s1.tableName).toBe("schema.orphaned");
|
|
137
|
+
// ...but the misconfiguration is a distinct, loud, actionable signal —
|
|
138
|
+
// not folded silently into the benign no-connection default.
|
|
139
|
+
expect(errSpy.calledOnce).toBe(true);
|
|
140
|
+
// winston's error() is overloaded, so widen the recorded args to assert
|
|
141
|
+
// the (message, context) contract this branch emits.
|
|
142
|
+
const [message, ctx] = errSpy.firstCall.args as unknown as [
|
|
143
|
+
string,
|
|
144
|
+
Record<string, unknown>,
|
|
145
|
+
];
|
|
146
|
+
expect(message).toContain("gone");
|
|
147
|
+
expect(message).toContain("Fix:");
|
|
148
|
+
expect(ctx).toMatchObject({
|
|
149
|
+
sourceEntityId: "s1",
|
|
150
|
+
connectionName: "gone",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("quotes resolvable entries and degrades an unresolvable one in the same bind", async () => {
|
|
155
|
+
const pkg = makePackage();
|
|
156
|
+
const lookup = sinon.stub(pkg, "getMalloyConnection");
|
|
157
|
+
lookup
|
|
158
|
+
.withArgs("sf")
|
|
159
|
+
.resolves({ dialectName: "snowflake" } as Connection);
|
|
160
|
+
lookup.withArgs("gone").rejects(new Error("missing"));
|
|
161
|
+
sinon.stub(logger, "error");
|
|
162
|
+
|
|
163
|
+
const out = await quote(pkg, {
|
|
164
|
+
good: { tableName: "schema.kept", connectionName: "sf" },
|
|
165
|
+
bad: { tableName: "schema.orphaned", connectionName: "gone" },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(out.good.tableName).toBe('"schema"."kept"');
|
|
169
|
+
expect(out.bad.tableName).toBe("schema.orphaned");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
import { Environment, PackageStatus } from "./environment";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression tests for RELOAD data loss.
|
|
10
|
+
*
|
|
11
|
+
* `Package.create`'s failure cleanup was written for a fresh install, where the
|
|
12
|
+
* half-built tree is Publisher's to remove. A reload runs the same code against
|
|
13
|
+
* a directory that already exists and is already serving, so a model that
|
|
14
|
+
* failed to compile deleted the user's package directory and evicted the
|
|
15
|
+
* last-good compiled model, taking the package offline until it was
|
|
16
|
+
* re-provisioned. That is reachable from the reload endpoint and the
|
|
17
|
+
* malloy_reloadPackage MCP tool by any caller that saves a broken model and
|
|
18
|
+
* reloads, which is the ordinary authoring mistake.
|
|
19
|
+
*
|
|
20
|
+
* Directory cleanup is now opt-in and only `installPackage` asks for it, so
|
|
21
|
+
* these two guard the reload path. Run against a real `Environment` and a real
|
|
22
|
+
* `Package.create` over temp dirs, the same way package_rollback.spec.ts does.
|
|
23
|
+
*/
|
|
24
|
+
describe("failed reload does not destroy a serving package", () => {
|
|
25
|
+
let rootDir: string;
|
|
26
|
+
let envPath: string;
|
|
27
|
+
|
|
28
|
+
const GOOD_MODEL = `source: ones is duckdb.sql("SELECT 1 as x")\n`;
|
|
29
|
+
const BROKEN_MODEL = `source: broken is @@@ not valid malloy !!!\n`;
|
|
30
|
+
|
|
31
|
+
async function writePackageDir(dir: string, model: string): Promise<void> {
|
|
32
|
+
await fs.mkdir(dir, { recursive: true });
|
|
33
|
+
await fs.writeFile(
|
|
34
|
+
path.join(dir, "publisher.json"),
|
|
35
|
+
JSON.stringify({ name: "pkg", description: "reload fixture" }),
|
|
36
|
+
);
|
|
37
|
+
await fs.writeFile(path.join(dir, "model.malloy"), model);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function copyDir(src: string, dst: string): Promise<void> {
|
|
41
|
+
await fs.mkdir(dst, { recursive: true });
|
|
42
|
+
await fs.cp(src, dst, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "publisher-reload-"));
|
|
47
|
+
envPath = path.join(rootDir, "env");
|
|
48
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await fs.rm(rootDir, { recursive: true, force: true }).catch(() => {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("keeps the package directory on disk when the reloaded model does not compile", async () => {
|
|
56
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
57
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
58
|
+
await writePackageDir(pkgDir, GOOD_MODEL);
|
|
59
|
+
await env.addPackage("pkg");
|
|
60
|
+
|
|
61
|
+
// The ordinary authoring mistake: save something that does not compile,
|
|
62
|
+
// then reload.
|
|
63
|
+
await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
|
|
64
|
+
await expect(env.getPackage("pkg", true)).rejects.toThrow();
|
|
65
|
+
|
|
66
|
+
// The directory and the file the user was editing both survive.
|
|
67
|
+
const modelText = await fs.readFile(
|
|
68
|
+
path.join(pkgDir, "model.malloy"),
|
|
69
|
+
"utf-8",
|
|
70
|
+
);
|
|
71
|
+
expect(modelText).toBe(BROKEN_MODEL);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps serving the last good model after a failed reload", async () => {
|
|
75
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
76
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
77
|
+
await writePackageDir(pkgDir, GOOD_MODEL);
|
|
78
|
+
await env.addPackage("pkg");
|
|
79
|
+
|
|
80
|
+
const servingBefore = await env.getPackage("pkg", false);
|
|
81
|
+
|
|
82
|
+
await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
|
|
83
|
+
await expect(env.getPackage("pkg", true)).rejects.toThrow();
|
|
84
|
+
|
|
85
|
+
// The package is still loaded and answerable: a failed reload reports the
|
|
86
|
+
// compile error, it does not take the package down. Assert the exact
|
|
87
|
+
// state, not just that something is there: a status stranded at LOADING
|
|
88
|
+
// would satisfy toBeDefined() while listPackages skips it, so the package
|
|
89
|
+
// would answer getPackage and be invisible to listings and discovery.
|
|
90
|
+
const stillServing = await env.getPackage("pkg", false);
|
|
91
|
+
expect(stillServing).toBe(servingBefore);
|
|
92
|
+
expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
|
|
93
|
+
expect((await env.listPackages()).map((p) => p.name)).toContain("pkg");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("keeps a rolled-back reinstall listed and serving", async () => {
|
|
97
|
+
// The other reload path. A package with an install location reloads via
|
|
98
|
+
// installPackage, whose rollback restores the previous tree but used to
|
|
99
|
+
// drop the status while `packages` kept the old package: it answered
|
|
100
|
+
// getPackage but vanished from listPackages and discovery until a
|
|
101
|
+
// restart. Both maps must agree that it is still serving.
|
|
102
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
103
|
+
const goodFixture = path.join(rootDir, "good");
|
|
104
|
+
const brokenFixture = path.join(rootDir, "broken");
|
|
105
|
+
await writePackageDir(goodFixture, GOOD_MODEL);
|
|
106
|
+
await writePackageDir(brokenFixture, BROKEN_MODEL);
|
|
107
|
+
|
|
108
|
+
await env.installPackage("pkg", (stagingPath) =>
|
|
109
|
+
copyDir(goodFixture, stagingPath),
|
|
110
|
+
);
|
|
111
|
+
const servingBefore = await env.getPackage("pkg", false);
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
env.installPackage("pkg", (stagingPath) =>
|
|
115
|
+
copyDir(brokenFixture, stagingPath),
|
|
116
|
+
),
|
|
117
|
+
).rejects.toThrow();
|
|
118
|
+
|
|
119
|
+
expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
|
|
120
|
+
expect((await env.listPackages()).map((p) => p.name)).toContain("pkg");
|
|
121
|
+
expect(await env.getPackage("pkg", false)).toBe(servingBefore);
|
|
122
|
+
// The on-disk half of the same claim: the rejected tree is gone and the
|
|
123
|
+
// user's is back. Without this, nothing pins "your files are left alone".
|
|
124
|
+
expect(
|
|
125
|
+
await fs.readFile(path.join(envPath, "pkg", "model.malloy"), "utf-8"),
|
|
126
|
+
).toBe(GOOD_MODEL);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not advertise a package as serving when the rollback could not restore it", async () => {
|
|
130
|
+
// Keeping the last-good package served is only honest when the rollback
|
|
131
|
+
// actually put its tree back. If nothing was restored, the cached package
|
|
132
|
+
// no longer matches disk, and claiming SERVING would advertise a package
|
|
133
|
+
// over a canonical path that is missing or still holds rejected content.
|
|
134
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
135
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
136
|
+
await writePackageDir(pkgDir, GOOD_MODEL);
|
|
137
|
+
await env.addPackage("pkg");
|
|
138
|
+
expect(env.getPackageStatus("pkg")?.status).toBe(PackageStatus.SERVING);
|
|
139
|
+
|
|
140
|
+
// Record retirements rather than scheduling the real drain, which is a
|
|
141
|
+
// 30s timer this test has no reason to wait on.
|
|
142
|
+
const retiredLabels: string[] = [];
|
|
143
|
+
(
|
|
144
|
+
env as unknown as {
|
|
145
|
+
retireConnectionGeneration: (label: string) => void;
|
|
146
|
+
}
|
|
147
|
+
).retireConnectionGeneration = (label: string) => {
|
|
148
|
+
retiredLabels.push(label);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// The tree disappears from under Publisher, so the reinstall has nothing
|
|
152
|
+
// to retire and the rollback has nothing to restore.
|
|
153
|
+
await fs.rm(pkgDir, { recursive: true, force: true });
|
|
154
|
+
|
|
155
|
+
const brokenFixture = path.join(rootDir, "broken-nofallback");
|
|
156
|
+
await writePackageDir(brokenFixture, BROKEN_MODEL);
|
|
157
|
+
await expect(
|
|
158
|
+
env.installPackage("pkg", (stagingPath) =>
|
|
159
|
+
copyDir(brokenFixture, stagingPath),
|
|
160
|
+
),
|
|
161
|
+
).rejects.toThrow();
|
|
162
|
+
|
|
163
|
+
// Eviction is the honest outcome here, and both maps must agree on it.
|
|
164
|
+
expect(env.getPackageStatus("pkg")).toBeUndefined();
|
|
165
|
+
expect((await env.listPackages()).map((p) => p.name)).not.toContain(
|
|
166
|
+
"pkg",
|
|
167
|
+
);
|
|
168
|
+
// Assert the packages map too, not just the status: asserting only the
|
|
169
|
+
// status leaves the half that actually strands a package untested.
|
|
170
|
+
await expect(env.getPackage("pkg", false)).rejects.toThrow();
|
|
171
|
+
|
|
172
|
+
// Evicting must retire the old package's connections. Once it leaves
|
|
173
|
+
// this.packages, closeAllConnections cannot reach its MalloyConfig, so
|
|
174
|
+
// skipping the retire orphans its native handles for the process's life.
|
|
175
|
+
// The drain is timer-based, so assert it was scheduled, not that it ran.
|
|
176
|
+
expect(retiredLabels).toContain("package pkg");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("recovers on the next reload once the model compiles again", async () => {
|
|
180
|
+
const env = await Environment.create("testEnv", envPath, []);
|
|
181
|
+
const pkgDir = path.join(envPath, "pkg");
|
|
182
|
+
await writePackageDir(pkgDir, GOOD_MODEL);
|
|
183
|
+
await env.addPackage("pkg");
|
|
184
|
+
|
|
185
|
+
await fs.writeFile(path.join(pkgDir, "model.malloy"), BROKEN_MODEL);
|
|
186
|
+
await expect(env.getPackage("pkg", true)).rejects.toThrow();
|
|
187
|
+
|
|
188
|
+
// Because nothing was deleted, fixing the model and reloading is enough.
|
|
189
|
+
await fs.writeFile(path.join(pkgDir, "model.malloy"), GOOD_MODEL);
|
|
190
|
+
const reloaded = await env.getPackage("pkg", true);
|
|
191
|
+
expect(reloaded).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -209,6 +209,37 @@ source: f is duckdb.sql("SELECT 1 as x")
|
|
|
209
209
|
{ timeout: 30000 },
|
|
210
210
|
);
|
|
211
211
|
|
|
212
|
+
// ── Rule 4: the cron must be a valid 5-field UNIX expression ──────────
|
|
213
|
+
|
|
214
|
+
it(
|
|
215
|
+
"rejects an unparseable schedule cron",
|
|
216
|
+
async () => {
|
|
217
|
+
const pkg = await loadPackage(PLAIN_MODEL, {
|
|
218
|
+
scope: "version",
|
|
219
|
+
materialization: { schedule: "not a cron" },
|
|
220
|
+
});
|
|
221
|
+
const joined = pkg.formatInvalidPersistencePolicy();
|
|
222
|
+
expect(joined).toContain("valid");
|
|
223
|
+
expect(joined).toContain("UNIX cron");
|
|
224
|
+
},
|
|
225
|
+
{ timeout: 30000 },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
it(
|
|
229
|
+
"rejects a cron-parser extension the UNIX grammar lacks (L)",
|
|
230
|
+
async () => {
|
|
231
|
+
// Guards config-parity: cron-parser accepts `0 6 L * *` but the control
|
|
232
|
+
// plane's UNIX parser does not, so publish must reject it too rather
|
|
233
|
+
// than let it silently never arm in production.
|
|
234
|
+
const pkg = await loadPackage(PLAIN_MODEL, {
|
|
235
|
+
scope: "version",
|
|
236
|
+
materialization: { schedule: "0 6 L * *" },
|
|
237
|
+
});
|
|
238
|
+
expect(pkg.formatInvalidPersistencePolicy()).toContain("UNIX cron");
|
|
239
|
+
},
|
|
240
|
+
{ timeout: 30000 },
|
|
241
|
+
);
|
|
242
|
+
|
|
212
243
|
// ── inert when nothing is declared ────────────────────────────────────
|
|
213
244
|
|
|
214
245
|
it(
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
bareTableName,
|
|
4
|
+
isQuotedIdentifierPath,
|
|
5
|
+
quoteIdentifier,
|
|
6
|
+
quoteManifestTablePath,
|
|
7
|
+
quoteTablePath,
|
|
8
|
+
} from "./quoting";
|
|
3
9
|
|
|
4
10
|
describe("bareTableName", () => {
|
|
5
11
|
it("returns the segment after the last dot for a qualified name", () => {
|
|
@@ -77,3 +83,53 @@ describe("quoteTablePath", () => {
|
|
|
77
83
|
expect(quoteTablePath("t_ab12_v0", "postgres")).toBe('"t_ab12_v0"');
|
|
78
84
|
});
|
|
79
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("isQuotedIdentifierPath", () => {
|
|
88
|
+
it("is false for a logical, unquoted path (the control-plane form)", () => {
|
|
89
|
+
expect(isQuotedIdentifierPath("schema.order_summary__g0")).toBe(false);
|
|
90
|
+
expect(isQuotedIdentifierPath("order_summary")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("is true once any segment carries a dialect quote char", () => {
|
|
94
|
+
expect(isQuotedIdentifierPath('"schema"."order_summary"')).toBe(true);
|
|
95
|
+
expect(isQuotedIdentifierPath("`ds`.`events`")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// quoteManifestTablePath is the single quoting authority both the serve-side
|
|
100
|
+
// bind and the build-side manifest route through. It runs the real
|
|
101
|
+
// quoteTablePath (not a re-implementation), so this pins the exact per-dialect
|
|
102
|
+
// contract that keeps read (a Malloy FROM) byte-identical to write (the CREATE
|
|
103
|
+
// DDL, which also quotes via quoteTablePath) on every dialect — case-folding or
|
|
104
|
+
// not.
|
|
105
|
+
describe("quoteManifestTablePath", () => {
|
|
106
|
+
it("double-quotes each segment on a case-folding double-quote dialect (Snowflake)", () => {
|
|
107
|
+
expect(
|
|
108
|
+
quoteManifestTablePath("schema.order_summary__g0", "snowflake"),
|
|
109
|
+
).toBe('"schema"."order_summary__g0"');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("double-quotes on Postgres and DuckDB (unchanged for lowercase names, correct for mixed case)", () => {
|
|
113
|
+
expect(quoteManifestTablePath("public.daily", "postgres")).toBe(
|
|
114
|
+
'"public"."daily"',
|
|
115
|
+
);
|
|
116
|
+
expect(quoteManifestTablePath("main.orders_mz", "duckdb")).toBe(
|
|
117
|
+
'"main"."orders_mz"',
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("backticks each segment on BigQuery (standardsql) — required for hyphenated ids", () => {
|
|
122
|
+
expect(quoteManifestTablePath("my-proj.ds.events", "standardsql")).toBe(
|
|
123
|
+
"`my-proj`.`ds`.`events`",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("passes an already-quoted name through unchanged (any dialect)", () => {
|
|
128
|
+
expect(quoteManifestTablePath('"schema"."already"', "snowflake")).toBe(
|
|
129
|
+
'"schema"."already"',
|
|
130
|
+
);
|
|
131
|
+
expect(quoteManifestTablePath("`ds`.`events`", "standardsql")).toBe(
|
|
132
|
+
"`ds`.`events`",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/service/quoting.ts
CHANGED
|
@@ -43,3 +43,36 @@ export function quoteTablePath(tableName: string, dialect: string): string {
|
|
|
43
43
|
.map((segment) => quoteIdentifier(segment, dialect))
|
|
44
44
|
.join(".");
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a table path already carries a dialect quote character, in which case
|
|
49
|
+
* it is treated as canonical SQL that must not be re-quoted. The single
|
|
50
|
+
* definition of "already quoted" shared by every bind site.
|
|
51
|
+
*/
|
|
52
|
+
export function isQuotedIdentifierPath(tableName: string): boolean {
|
|
53
|
+
return tableName.includes('"') || tableName.includes("`");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Quote a physical table path for a manifest entry so a Malloy `FROM` resolves
|
|
58
|
+
* it correctly, unless it is already canonical SQL (passed through unchanged).
|
|
59
|
+
* This is the single quoting authority for the two places a physical name is
|
|
60
|
+
* bound into a `FROM`: the serve-side bind ({@link Package.quoteBoundTableNames})
|
|
61
|
+
* and the build-side manifest that stitches chained persist sources together
|
|
62
|
+
* (materialization_service). Both must mirror the CREATE side's
|
|
63
|
+
* {@link quoteTablePath} so a case-folding engine (Snowflake uppercases
|
|
64
|
+
* unquoted identifiers) can resolve the case-preserved table the builder wrote.
|
|
65
|
+
*
|
|
66
|
+
* Passing an already-quoted name through is safe for the names this handles:
|
|
67
|
+
* control-plane physical names are logical/unquoted (never contain a quote),
|
|
68
|
+
* and a self-assigned `#@ persist name=` value is the author's own canonical
|
|
69
|
+
* SQL (they own quoting it for the dialect).
|
|
70
|
+
*/
|
|
71
|
+
export function quoteManifestTablePath(
|
|
72
|
+
tableName: string,
|
|
73
|
+
dialect: string,
|
|
74
|
+
): string {
|
|
75
|
+
return isQuotedIdentifierPath(tableName)
|
|
76
|
+
? tableName
|
|
77
|
+
: quoteTablePath(tableName, dialect);
|
|
78
|
+
}
|
|
@@ -59,6 +59,14 @@ export interface ResourceRepository {
|
|
|
59
59
|
packageName: string,
|
|
60
60
|
options?: { limit?: number; offset?: number },
|
|
61
61
|
): Promise<Materialization[]>;
|
|
62
|
+
listMaterializationsByEnvironment(
|
|
63
|
+
environmentId: string,
|
|
64
|
+
options?: { limit?: number; offset?: number },
|
|
65
|
+
): Promise<Materialization[]>;
|
|
66
|
+
getLatestScheduledFireAt(
|
|
67
|
+
environmentId: string,
|
|
68
|
+
packageName: string,
|
|
69
|
+
): Promise<Date | null>;
|
|
62
70
|
getMaterializationById(id: string): Promise<Materialization | null>;
|
|
63
71
|
getActiveMaterialization(
|
|
64
72
|
environmentId: string,
|
|
@@ -174,6 +182,13 @@ export interface BuildManifest {
|
|
|
174
182
|
*/
|
|
175
183
|
export interface FreshnessAwareManifestEntry {
|
|
176
184
|
tableName: string;
|
|
185
|
+
/**
|
|
186
|
+
* The connection the physical table lives on, carried from the wire
|
|
187
|
+
* manifest so the bind step can quote `tableName` for that connection's
|
|
188
|
+
* dialect (see Package.quoteBoundTableNames). Absent on entries whose
|
|
189
|
+
* producer didn't record it; those bind verbatim.
|
|
190
|
+
*/
|
|
191
|
+
connectionName?: string;
|
|
177
192
|
dataAsOf?: string;
|
|
178
193
|
freshnessWindowSeconds?: number;
|
|
179
194
|
freshnessFallback?: "live" | "stale_ok" | "fail";
|
|
@@ -153,6 +153,23 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
153
153
|
return this.materializationRepo.list(environmentId, packageName, options);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
async listMaterializationsByEnvironment(
|
|
157
|
+
environmentId: string,
|
|
158
|
+
options?: { limit?: number; offset?: number },
|
|
159
|
+
): Promise<Materialization[]> {
|
|
160
|
+
return this.materializationRepo.listByEnvironment(environmentId, options);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async getLatestScheduledFireAt(
|
|
164
|
+
environmentId: string,
|
|
165
|
+
packageName: string,
|
|
166
|
+
): Promise<Date | null> {
|
|
167
|
+
return this.materializationRepo.getLatestScheduledFireAt(
|
|
168
|
+
environmentId,
|
|
169
|
+
packageName,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
async getMaterializationById(id: string): Promise<Materialization | null> {
|
|
157
174
|
return this.materializationRepo.getById(id);
|
|
158
175
|
}
|