@malloy-publisher/server 0.0.229 → 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 +18 -9
- package/dist/app/assets/{EnvironmentPage-QOoHiVeJ.js → EnvironmentPage-wa_EPkwK.js} +1 -1
- package/dist/app/assets/{HomePage-C71GOfVW.js → HomePage-jnCrupQp.js} +1 -1
- package/dist/app/assets/{LightMode-CrgCAwLe.js → LightMode-DYbwNULZ.js} +1 -1
- package/dist/app/assets/{MainPage-BG5__FN3.js → MainPage-CuJLrPNI.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-DE6PnrDR.js → MaterializationsPage-D_67x2ee.js} +1 -1
- package/dist/app/assets/{ModelPage-CcBjcbLm.js → ModelPage-D5JtAWqR.js} +1 -1
- package/dist/app/assets/{PackagePage-JTy3ztkB.js → PackagePage-BRwtqUSG.js} +1 -1
- package/dist/app/assets/{RouteError-Cymbp47a.js → RouteError-CBNNrnSD.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-C_nMnHr8.js → ThemeEditorPage-CTCeBneA.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CPQu-DQx.js → WorkbookPage-SN6f1RBm.js} +1 -1
- package/dist/app/assets/{core-Coi3caGs.es-CSOmajHS.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +1 -1
- package/dist/app/assets/{index-DlWCXghy.js → index-B3Nn8Vm2.js} +459 -446
- package/dist/app/assets/index-BLCx1EdC.js +18 -0
- package/dist/app/assets/{index-DxArlgRD.js → index-C_tJstcx.js} +4 -4
- package/dist/app/assets/{index-CcuuST2X.js → index-CfmBVB6M.js} +1 -1
- package/dist/app/assets/{index-CkmABCAw.js → index-DU4r7GdU.js} +435 -422
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +108 -7
- package/dist/server.mjs +575 -49
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +5 -2
- package/src/config.ts +40 -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/mcp/skills/skills_bundle.json +1 -1
- package/src/server.ts +7 -0
- 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 +71 -0
- 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/ducklake_lazy_attach.spec.ts +110 -0
- package/src/service/environment.ts +43 -9
- package/src/service/environment_store.spec.ts +197 -12
- package/src/service/environment_store.ts +88 -14
- package/src/service/extension_fetch_policy.spec.ts +256 -0
- package/src/service/materialization_scheduler.spec.ts +29 -0
- package/src/service/materialization_service.spec.ts +119 -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 +12 -1
- package/src/storage/duckdb/MaterializationRepository.spec.ts +39 -2
- package/src/storage/duckdb/MaterializationRepository.ts +12 -0
- package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
- package/dist/app/assets/index-CM2qhQCI.js +0 -18
|
@@ -0,0 +1,256 @@
|
|
|
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 { getExtensionFetchPolicy } from "../config";
|
|
7
|
+
import {
|
|
8
|
+
applyExtensionSessionSettings,
|
|
9
|
+
buildEnvironmentMalloyConfig,
|
|
10
|
+
} from "./connection";
|
|
11
|
+
|
|
12
|
+
describe("getExtensionFetchPolicy", () => {
|
|
13
|
+
const original = process.env.EXTENSION_FETCH_POLICY;
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (original === undefined) delete process.env.EXTENSION_FETCH_POLICY;
|
|
16
|
+
else process.env.EXTENSION_FETCH_POLICY = original;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("defaults to on-demand when unset or empty", () => {
|
|
20
|
+
delete process.env.EXTENSION_FETCH_POLICY;
|
|
21
|
+
expect(getExtensionFetchPolicy()).toBe("on-demand");
|
|
22
|
+
process.env.EXTENSION_FETCH_POLICY = " ";
|
|
23
|
+
expect(getExtensionFetchPolicy()).toBe("on-demand");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("accepts the two policies case-insensitively", () => {
|
|
27
|
+
process.env.EXTENSION_FETCH_POLICY = "local-only";
|
|
28
|
+
expect(getExtensionFetchPolicy()).toBe("local-only");
|
|
29
|
+
process.env.EXTENSION_FETCH_POLICY = "On-Demand";
|
|
30
|
+
expect(getExtensionFetchPolicy()).toBe("on-demand");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("throws loudly on an unrecognised value", () => {
|
|
34
|
+
process.env.EXTENSION_FETCH_POLICY = "offline";
|
|
35
|
+
expect(() => getExtensionFetchPolicy()).toThrow(
|
|
36
|
+
/Invalid value for EXTENSION_FETCH_POLICY/,
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("EXTENSION_FETCH_POLICY=local-only extension loading", () => {
|
|
42
|
+
const envPath = path.join(process.cwd(), "test-extension-policy");
|
|
43
|
+
const original = process.env.EXTENSION_FETCH_POLICY;
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
47
|
+
});
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
sinon.restore();
|
|
50
|
+
if (original === undefined) delete process.env.EXTENSION_FETCH_POLICY;
|
|
51
|
+
else process.env.EXTENSION_FETCH_POLICY = original;
|
|
52
|
+
await fs.rm(envPath, { recursive: true, force: true }).catch(() => {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("never runs INSTALL and raises a loud, actionable error when a required extension is missing", async () => {
|
|
56
|
+
process.env.EXTENSION_FETCH_POLICY = "local-only";
|
|
57
|
+
|
|
58
|
+
// Session PRAGMAs and metadata probes succeed; the first extension LOAD
|
|
59
|
+
// fails, simulating an extension that was never baked into the image.
|
|
60
|
+
const runSQL = sinon
|
|
61
|
+
.stub(DuckDBConnection.prototype, "runSQL")
|
|
62
|
+
.resolves({ rows: [] } as never);
|
|
63
|
+
runSQL
|
|
64
|
+
.withArgs(sinon.match(/^LOAD ducklake/))
|
|
65
|
+
.rejects(new Error('Extension "ducklake" not found'));
|
|
66
|
+
|
|
67
|
+
const config = buildEnvironmentMalloyConfig(
|
|
68
|
+
[
|
|
69
|
+
{
|
|
70
|
+
name: "ducklake_localonly",
|
|
71
|
+
type: "ducklake",
|
|
72
|
+
ducklakeConnection: {
|
|
73
|
+
catalog: {
|
|
74
|
+
postgresConnection: {
|
|
75
|
+
host: "192.0.2.1",
|
|
76
|
+
port: 5432,
|
|
77
|
+
userName: "nobody",
|
|
78
|
+
password: "nobody",
|
|
79
|
+
databaseName: "catalog",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
storage: {
|
|
83
|
+
bucketUrl: "s3://test-bucket",
|
|
84
|
+
s3Connection: {
|
|
85
|
+
accessKeyId: "test",
|
|
86
|
+
secretAccessKey: "test",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
] as never,
|
|
92
|
+
envPath,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await expect(
|
|
96
|
+
config.malloyConfig.connections.lookupConnection("ducklake_localonly"),
|
|
97
|
+
).rejects.toThrow(/local-only.*ducklake|ducklake.*local-only/s);
|
|
98
|
+
|
|
99
|
+
// local-only must never issue an INSTALL — not even for a missing extension.
|
|
100
|
+
const ranInstall = runSQL
|
|
101
|
+
.getCalls()
|
|
102
|
+
.some((c) => /^\s*INSTALL\s/i.test(String(c.args[0])));
|
|
103
|
+
expect(ranInstall).toBe(false);
|
|
104
|
+
|
|
105
|
+
// And it did pin implicit auto-install off on the session (item 3).
|
|
106
|
+
const setAutoinstallOff = runSQL
|
|
107
|
+
.getCalls()
|
|
108
|
+
.some((c) =>
|
|
109
|
+
/autoinstall_known_extensions\s*=\s*false/i.test(String(c.args[0])),
|
|
110
|
+
);
|
|
111
|
+
expect(setAutoinstallOff).toBe(true);
|
|
112
|
+
|
|
113
|
+
await config.releaseConnections().catch(() => {});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("applyExtensionSessionSettings", () => {
|
|
118
|
+
const original = process.env.EXTENSION_FETCH_POLICY;
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
sinon.restore();
|
|
121
|
+
if (original === undefined) delete process.env.EXTENSION_FETCH_POLICY;
|
|
122
|
+
else process.env.EXTENSION_FETCH_POLICY = original;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Minimal stand-in — the helper only ever calls runSQL.
|
|
126
|
+
const mockConn = (runSQL: sinon.SinonStub) =>
|
|
127
|
+
({ runSQL }) as unknown as DuckDBConnection;
|
|
128
|
+
|
|
129
|
+
it("pins autoinstall off (autoload on) under local-only", async () => {
|
|
130
|
+
process.env.EXTENSION_FETCH_POLICY = "local-only";
|
|
131
|
+
const runSQL = sinon.stub().resolves({ rows: [] });
|
|
132
|
+
await applyExtensionSessionSettings(mockConn(runSQL));
|
|
133
|
+
const sql = runSQL.getCalls().map((c) => String(c.args[0]));
|
|
134
|
+
expect(
|
|
135
|
+
sql.some((s) => /autoinstall_known_extensions\s*=\s*false/i.test(s)),
|
|
136
|
+
).toBe(true);
|
|
137
|
+
expect(
|
|
138
|
+
sql.some((s) => /autoload_known_extensions\s*=\s*true/i.test(s)),
|
|
139
|
+
).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("is a no-op for a generic session under on-demand", async () => {
|
|
143
|
+
delete process.env.EXTENSION_FETCH_POLICY;
|
|
144
|
+
const runSQL = sinon.stub().resolves({ rows: [] });
|
|
145
|
+
await applyExtensionSessionSettings(mockConn(runSQL));
|
|
146
|
+
expect(runSQL.callCount).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("pins a tier session even under on-demand (alwaysDisableAutoinstall)", async () => {
|
|
150
|
+
delete process.env.EXTENSION_FETCH_POLICY;
|
|
151
|
+
const runSQL = sinon.stub().resolves({ rows: [] });
|
|
152
|
+
await applyExtensionSessionSettings(mockConn(runSQL), {
|
|
153
|
+
alwaysDisableAutoinstall: true,
|
|
154
|
+
});
|
|
155
|
+
expect(
|
|
156
|
+
runSQL
|
|
157
|
+
.getCalls()
|
|
158
|
+
.some((c) =>
|
|
159
|
+
/autoinstall_known_extensions\s*=\s*false/i.test(
|
|
160
|
+
String(c.args[0]),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("pins each connection only once (dedup)", async () => {
|
|
167
|
+
process.env.EXTENSION_FETCH_POLICY = "local-only";
|
|
168
|
+
const runSQL = sinon.stub().resolves({ rows: [] });
|
|
169
|
+
const conn = mockConn(runSQL);
|
|
170
|
+
await applyExtensionSessionSettings(conn);
|
|
171
|
+
await applyExtensionSessionSettings(conn);
|
|
172
|
+
expect(runSQL.callCount).toBe(2); // two SETs from the first call only
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("fails closed under local-only when the pin cannot be applied", async () => {
|
|
176
|
+
process.env.EXTENSION_FETCH_POLICY = "local-only";
|
|
177
|
+
const runSQL = sinon.stub().rejects(new Error("pragma unsupported"));
|
|
178
|
+
await expect(
|
|
179
|
+
applyExtensionSessionSettings(mockConn(runSQL)),
|
|
180
|
+
).rejects.toThrow(/EXTENSION_FETCH_POLICY=local-only/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("does NOT fail a tier attach under on-demand when the pin fails", async () => {
|
|
184
|
+
delete process.env.EXTENSION_FETCH_POLICY;
|
|
185
|
+
const runSQL = sinon.stub().rejects(new Error("pragma unsupported"));
|
|
186
|
+
// Resolves (warn-and-continue), does not throw.
|
|
187
|
+
await applyExtensionSessionSettings(mockConn(runSQL), {
|
|
188
|
+
alwaysDisableAutoinstall: true,
|
|
189
|
+
});
|
|
190
|
+
expect(runSQL.callCount).toBeGreaterThan(0);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("EXTENSION_FETCH_POLICY=on-demand (default) — no behaviour change", () => {
|
|
195
|
+
const envPath = path.join(process.cwd(), "test-extension-policy-ondemand");
|
|
196
|
+
const original = process.env.EXTENSION_FETCH_POLICY;
|
|
197
|
+
|
|
198
|
+
beforeEach(async () => {
|
|
199
|
+
await fs.mkdir(envPath, { recursive: true });
|
|
200
|
+
});
|
|
201
|
+
afterEach(async () => {
|
|
202
|
+
sinon.restore();
|
|
203
|
+
if (original === undefined) delete process.env.EXTENSION_FETCH_POLICY;
|
|
204
|
+
else process.env.EXTENSION_FETCH_POLICY = original;
|
|
205
|
+
await fs.rm(envPath, { recursive: true, force: true }).catch(() => {});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("leaves autoinstall at DuckDB's default on a generic attach session", async () => {
|
|
209
|
+
// Default policy: an ordinary DuckDB connection with an attachment must
|
|
210
|
+
// behave exactly as before — Publisher installs the attachment's extension
|
|
211
|
+
// explicitly, but it must NOT disable DuckDB's implicit auto-install (that
|
|
212
|
+
// would regress a user whose query references an un-provisioned extension).
|
|
213
|
+
delete process.env.EXTENSION_FETCH_POLICY;
|
|
214
|
+
|
|
215
|
+
const runSQL = sinon
|
|
216
|
+
.stub(DuckDBConnection.prototype, "runSQL")
|
|
217
|
+
.resolves({ rows: [] } as never);
|
|
218
|
+
|
|
219
|
+
const config = buildEnvironmentMalloyConfig(
|
|
220
|
+
[
|
|
221
|
+
{
|
|
222
|
+
name: "gen_duck",
|
|
223
|
+
type: "duckdb",
|
|
224
|
+
duckdbConnection: {
|
|
225
|
+
attachedDatabases: [
|
|
226
|
+
{
|
|
227
|
+
name: "pg_attached",
|
|
228
|
+
type: "postgres",
|
|
229
|
+
postgresConnection: {
|
|
230
|
+
connectionString: "postgresql://localhost/test",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
] as never,
|
|
237
|
+
envPath,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
await config.malloyConfig.connections.lookupConnection("gen_duck");
|
|
241
|
+
|
|
242
|
+
const issued = runSQL.getCalls().map((c) => String(c.args[0]));
|
|
243
|
+
// The attachment path ran (postgres explicitly installed)...
|
|
244
|
+
expect(issued.some((sql) => /^\s*INSTALL\s+postgres/i.test(sql))).toBe(
|
|
245
|
+
true,
|
|
246
|
+
);
|
|
247
|
+
// ...but implicit auto-install was left untouched — no behaviour change.
|
|
248
|
+
expect(
|
|
249
|
+
issued.some((sql) =>
|
|
250
|
+
/autoinstall_known_extensions\s*=\s*false/i.test(sql),
|
|
251
|
+
),
|
|
252
|
+
).toBe(false);
|
|
253
|
+
|
|
254
|
+
await config.releaseConnections().catch(() => {});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -257,4 +257,33 @@ describe("MaterializationScheduler", () => {
|
|
|
257
257
|
await sched.tick(dueLater + 1);
|
|
258
258
|
expect(service.calls.length).toBe(3);
|
|
259
259
|
});
|
|
260
|
+
|
|
261
|
+
it("prunes arming state when a package unloads, then re-anchors on reload without a stale fire", async () => {
|
|
262
|
+
// A never-fired schedule (fakeService default lastFireAt = null), so a
|
|
263
|
+
// fresh arm anchors from `now` and does not catch up.
|
|
264
|
+
const service = fakeService();
|
|
265
|
+
const p = fakePackage("p", { schedule: "* * * * *" });
|
|
266
|
+
// makeScheduler closes over this array via getLoadedPackages, so mutating
|
|
267
|
+
// it simulates the package leaving and re-entering the loaded set.
|
|
268
|
+
const loaded = [p];
|
|
269
|
+
const sched = makeScheduler(loaded, service);
|
|
270
|
+
|
|
271
|
+
await sched.tick(t0); // arm: nextFire = t0 + 60s
|
|
272
|
+
|
|
273
|
+
// Unload: the package leaves the loaded set, so the sweep prunes its state
|
|
274
|
+
// (tick's not-seen cleanup) instead of leaving a stale nextFire behind.
|
|
275
|
+
loaded.length = 0;
|
|
276
|
+
await sched.tick(dueLater); // past the old nextFire, but the package is gone
|
|
277
|
+
expect(service.calls.length).toBe(0);
|
|
278
|
+
|
|
279
|
+
// Reload: with the state pruned, arm is fresh — anchored from `now`, so the
|
|
280
|
+
// pre-unload nextFire can't resurface as a spurious catch-up.
|
|
281
|
+
loaded.push(p);
|
|
282
|
+
await sched.tick(dueLater + 1); // now < fresh nextFire -> no fire
|
|
283
|
+
expect(service.calls.length).toBe(0);
|
|
284
|
+
|
|
285
|
+
// It is genuinely re-armed (not inert): the next occurrence still fires.
|
|
286
|
+
await sched.tick(dueLater + 1 + 60_001);
|
|
287
|
+
expect(service.calls.length).toBe(1);
|
|
288
|
+
});
|
|
260
289
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Connection as MalloyConnection } from "@malloydata/malloy";
|
|
2
2
|
import { Manifest } from "@malloydata/malloy";
|
|
3
|
-
import { beforeEach, describe, expect, it } from "bun:test";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
4
4
|
import * as sinon from "sinon";
|
|
5
5
|
import {
|
|
6
6
|
BadRequestError,
|
|
@@ -27,6 +27,11 @@ import {
|
|
|
27
27
|
MaterializationService,
|
|
28
28
|
stagingSuffix,
|
|
29
29
|
} from "./materialization_service";
|
|
30
|
+
import { resetMaterializationTelemetryForTesting } from "../materialization_metrics";
|
|
31
|
+
import {
|
|
32
|
+
startMetricsHarness,
|
|
33
|
+
type MetricsHarness,
|
|
34
|
+
} from "../test_helpers/metrics_harness";
|
|
30
35
|
|
|
31
36
|
type MockRepo = sinon.SinonStubbedInstance<ResourceRepository>;
|
|
32
37
|
|
|
@@ -500,6 +505,86 @@ describe("MaterializationService", () => {
|
|
|
500
505
|
});
|
|
501
506
|
});
|
|
502
507
|
|
|
508
|
+
describe("deleteMaterialization telemetry", () => {
|
|
509
|
+
let ctx: ReturnType<typeof createMocks>;
|
|
510
|
+
let harness: MetricsHarness;
|
|
511
|
+
|
|
512
|
+
beforeEach(async () => {
|
|
513
|
+
ctx = createMocks();
|
|
514
|
+
harness = await startMetricsHarness();
|
|
515
|
+
// Drop cached instruments so recordDropTables re-binds to this provider.
|
|
516
|
+
resetMaterializationTelemetryForTesting();
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
afterEach(async () => {
|
|
520
|
+
resetMaterializationTelemetryForTesting();
|
|
521
|
+
await harness.shutdown();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const DROP_COUNTER = "publisher_materialization_drop_tables_total";
|
|
525
|
+
|
|
526
|
+
// A terminal materialization whose manifest names one physical table, so
|
|
527
|
+
// dropMaterializedTables issues exactly one DROP — the emission under test.
|
|
528
|
+
function dropTablesFixture(runSQL: sinon.SinonStub) {
|
|
529
|
+
const getMalloyConnection = sinon
|
|
530
|
+
.stub()
|
|
531
|
+
.resolves({ runSQL, dialectName: "duckdb" });
|
|
532
|
+
(ctx.environmentStore.getEnvironment as sinon.SinonStub).resolves({
|
|
533
|
+
getPackage: sinon.stub().resolves({ getMalloyConnection }),
|
|
534
|
+
withPackageLock: async (_n: string, fn: () => Promise<unknown>) =>
|
|
535
|
+
fn(),
|
|
536
|
+
});
|
|
537
|
+
ctx.repository.getMaterializationById.resolves(
|
|
538
|
+
makeMaterialization({
|
|
539
|
+
status: "MANIFEST_FILE_READY",
|
|
540
|
+
manifest: {
|
|
541
|
+
builtAt: new Date().toISOString(),
|
|
542
|
+
strict: false,
|
|
543
|
+
entries: {
|
|
544
|
+
b1: {
|
|
545
|
+
sourceEntityId: "b1",
|
|
546
|
+
physicalTableName: "orders_mz",
|
|
547
|
+
connectionName: "duckdb",
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
it("meters a successful physical-table drop as outcome=success", async () => {
|
|
556
|
+
dropTablesFixture(sinon.stub().resolves());
|
|
557
|
+
|
|
558
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
|
|
559
|
+
dropTables: true,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
expect(
|
|
563
|
+
await harness.collectCounter(DROP_COUNTER, { outcome: "success" }),
|
|
564
|
+
).toBe(1);
|
|
565
|
+
expect(
|
|
566
|
+
await harness.collectCounter(DROP_COUNTER, { outcome: "failure" }),
|
|
567
|
+
).toBe(0);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("meters a failed drop as outcome=failure and still deletes the record", async () => {
|
|
571
|
+
dropTablesFixture(sinon.stub().rejects(new Error("drop boom")));
|
|
572
|
+
|
|
573
|
+
// The drop is best-effort: a failure is metered but never surfaces to the
|
|
574
|
+
// caller, and the record is still removed.
|
|
575
|
+
await ctx.service.deleteMaterialization("my-env", "pkg", "mat-1", {
|
|
576
|
+
dropTables: true,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(
|
|
580
|
+
await harness.collectCounter(DROP_COUNTER, { outcome: "failure" }),
|
|
581
|
+
).toBe(1);
|
|
582
|
+
expect(ctx.repository.deleteMaterialization.calledOnceWith("mat-1")).toBe(
|
|
583
|
+
true,
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
503
588
|
describe("stagingSuffix", () => {
|
|
504
589
|
it("derives a short, stable suffix from the sourceEntityId", () => {
|
|
505
590
|
expect(stagingSuffix("abcdef1234567890")).toBe("_abcdef123456");
|
|
@@ -1002,6 +1087,39 @@ describe("buildOneSource", () => {
|
|
|
1002
1087
|
);
|
|
1003
1088
|
});
|
|
1004
1089
|
|
|
1090
|
+
it("rethrows the original build error when staging cleanup also fails", async () => {
|
|
1091
|
+
const runSQL = sinon.stub();
|
|
1092
|
+
runSQL.onCall(0).resolves(); // initial staging drop
|
|
1093
|
+
runSQL.onCall(1).rejects(new Error("create boom")); // CREATE TABLE AS
|
|
1094
|
+
runSQL.onCall(2).rejects(new Error("cleanup boom")); // cleanup drop fails
|
|
1095
|
+
// The cleanup failure is swallowed (logged as a physical leak); the
|
|
1096
|
+
// original build error is what propagates, so the run is classified by its
|
|
1097
|
+
// real cause rather than the cleanup noise.
|
|
1098
|
+
await expect(callBuildOneSource({ runSQL }, "orders_v1")).rejects.toThrow(
|
|
1099
|
+
"create boom",
|
|
1100
|
+
);
|
|
1101
|
+
expect(runSQL.callCount).toBe(3);
|
|
1102
|
+
expect(runSQL.lastCall.args[0]).toBe(
|
|
1103
|
+
'DROP TABLE IF EXISTS "orders_v1_abcdef123456"',
|
|
1104
|
+
);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it("drops staging when the build is cancelled mid-run (no _staging orphan)", async () => {
|
|
1108
|
+
// buildOneSource is cancellation-agnostic: a cancel surfaces as a thrown
|
|
1109
|
+
// build SQL and takes the same staging-cleanup path as a failure, so a
|
|
1110
|
+
// cancelled build leaves no orphaned _staging table behind.
|
|
1111
|
+
const runSQL = sinon.stub();
|
|
1112
|
+
runSQL.onCall(0).resolves(); // initial staging drop
|
|
1113
|
+
runSQL.onCall(1).rejects(new Error("Build cancelled")); // aborted mid-CREATE
|
|
1114
|
+
runSQL.onCall(2).resolves(); // cleanup staging drop
|
|
1115
|
+
await expect(callBuildOneSource({ runSQL }, "orders_v1")).rejects.toThrow(
|
|
1116
|
+
"Build cancelled",
|
|
1117
|
+
);
|
|
1118
|
+
expect(runSQL.lastCall.args[0]).toBe(
|
|
1119
|
+
'DROP TABLE IF EXISTS "orders_v1_abcdef123456"',
|
|
1120
|
+
);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1005
1123
|
it("quotes each segment of a container path for a backtick dialect", async () => {
|
|
1006
1124
|
const runSQL = sinon.stub().resolves();
|
|
1007
1125
|
// Container path (dataset.table) on a backtick dialect (BigQuery): each
|
|
@@ -4,6 +4,7 @@ import fs from "fs/promises";
|
|
|
4
4
|
import sinon from "sinon";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
AccessDeniedError,
|
|
7
8
|
BadRequestError,
|
|
8
9
|
ModelNotFoundError,
|
|
9
10
|
PayloadTooLargeError,
|
|
@@ -420,6 +421,11 @@ describe("service/model", () => {
|
|
|
420
421
|
undefined,
|
|
421
422
|
undefined,
|
|
422
423
|
undefined,
|
|
424
|
+
undefined,
|
|
425
|
+
// Model surfaces `region` so filterGivensToModelSurface (see
|
|
426
|
+
// model.ts) forwards it rather than dropping it as unknown.
|
|
427
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
428
|
+
[{ name: "region", type: "string" }] as any,
|
|
423
429
|
);
|
|
424
430
|
|
|
425
431
|
await expect(
|
|
@@ -716,6 +722,11 @@ describe("service/model", () => {
|
|
|
716
722
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
717
723
|
runnableCells as any,
|
|
718
724
|
undefined,
|
|
725
|
+
undefined,
|
|
726
|
+
// Model surfaces `target_code` so filterGivensToModelSurface
|
|
727
|
+
// (see model.ts) forwards it rather than dropping it as unknown.
|
|
728
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
729
|
+
[{ name: "target_code", type: "string" }] as any,
|
|
719
730
|
);
|
|
720
731
|
|
|
721
732
|
await expect(
|
|
@@ -819,6 +830,62 @@ describe("service/model", () => {
|
|
|
819
830
|
});
|
|
820
831
|
});
|
|
821
832
|
|
|
833
|
+
describe("authorize struct-resolution drift invariant", () => {
|
|
834
|
+
// collectAllReachableGates walks struct.fields using Malloy's own
|
|
835
|
+
// isJoined ('join' in sd) / isSourceDef (struct.type is a known source
|
|
836
|
+
// kind) guards. If a future Malloy version adds a new source `type`
|
|
837
|
+
// that isSourceDef doesn't yet recognize, a genuinely-joined field
|
|
838
|
+
// would fail isSourceDef and used to be silently `continue`d past —
|
|
839
|
+
// failing OPEN (the joined source's gate is never found, so it's never
|
|
840
|
+
// evaluated). This confirms the walk instead denies loudly when it
|
|
841
|
+
// finds a field that IS a join but can't be resolved to a walkable
|
|
842
|
+
// SourceDef, rather than silently treating it as "nothing to gate".
|
|
843
|
+
it("denies rather than silently skipping when a joined field doesn't resolve to a walkable SourceDef", async () => {
|
|
844
|
+
const driftedJoinField = {
|
|
845
|
+
name: "mystery_join",
|
|
846
|
+
join: "one", // isJoined: true ('join' in sd)
|
|
847
|
+
// A source `type` isSourceDef doesn't (yet) recognize -- stands
|
|
848
|
+
// in for a future Malloy source kind the duck-typed guard hasn't
|
|
849
|
+
// been updated to cover.
|
|
850
|
+
type: "future_source_kind",
|
|
851
|
+
fields: [],
|
|
852
|
+
};
|
|
853
|
+
const runStruct = {
|
|
854
|
+
type: "table",
|
|
855
|
+
name: "root",
|
|
856
|
+
fields: [driftedJoinField],
|
|
857
|
+
};
|
|
858
|
+
const modelDef = { contents: {}, exports: [], queryList: [] };
|
|
859
|
+
const runnable = {
|
|
860
|
+
getPreparedQuery: sinon.stub().resolves({
|
|
861
|
+
_query: { structRef: runStruct },
|
|
862
|
+
_modelDef: modelDef,
|
|
863
|
+
}),
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const model = new Model(
|
|
867
|
+
packageName,
|
|
868
|
+
mockModelPath,
|
|
869
|
+
{},
|
|
870
|
+
"model",
|
|
871
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
872
|
+
{ loadQuery: sinon.stub() } as any, // modelMaterializer
|
|
873
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
874
|
+
modelDef as any,
|
|
875
|
+
undefined,
|
|
876
|
+
undefined,
|
|
877
|
+
undefined,
|
|
878
|
+
undefined,
|
|
879
|
+
undefined,
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
await expect(
|
|
883
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
884
|
+
model.assertAuthorizedForAllSources(runnable as any, {}),
|
|
885
|
+
).rejects.toThrow(AccessDeniedError);
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
822
889
|
describe("static methods", () => {
|
|
823
890
|
describe("getModelRuntime", () => {
|
|
824
891
|
it("should throw ModelNotFoundError for invalid modelPath", async () => {
|