@malloy-publisher/server 0.0.205 → 0.0.207
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 +394 -395
- package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
- package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
- package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
- package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
- package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
- package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
- package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
- package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
- package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
- package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
- package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/server.mjs +1272 -1299
- package/package.json +1 -1
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +12 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +121 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/server-old.ts +7 -149
- package/src/server.ts +9 -188
- package/src/service/authorize_integration.spec.ts +67 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +0 -23
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +584 -500
- package/src/service/materialization_service.ts +839 -657
- package/src/service/model.ts +419 -15
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +339 -29
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: Query Boundary (`queryableSources`).
|
|
3
|
+
*
|
|
4
|
+
* Drives `Package.create` through the package-load worker pool and verifies the
|
|
5
|
+
* rule "queryable == discoverable": under the default `queryableSources:
|
|
6
|
+
* "declared"`, only the discovery surface (`explores` files + their `export {}`
|
|
7
|
+
* closure) is a valid top-level query target. Everything else still compiles,
|
|
8
|
+
* imports, joins, and extends, but is denied a direct query with a generic 404
|
|
9
|
+
* (`NotQueryableError`). `"all"` decouples the axes (the prior behavior), and a
|
|
10
|
+
* package with no `explores` is unaffected in either mode.
|
|
11
|
+
*
|
|
12
|
+
* Enforcement is two-step (mirroring the authorize gate): an early
|
|
13
|
+
* pre-compilation gate that positively denies what it can resolve (schema-
|
|
14
|
+
* oracle defense), and a compiled backstop that settles everything else
|
|
15
|
+
* against the run target Malloy actually executes (read off the prepared
|
|
16
|
+
* query's structRef — inspecting compiler output, never altering compilation).
|
|
17
|
+
* Ad-hoc derivation over a curated source is admitted via the same alias walk
|
|
18
|
+
* filters use; an unresolvable target fails closed.
|
|
19
|
+
*
|
|
20
|
+
* This is the *what* axis; `#(authorize)` (the *who* axis) is orthogonal and
|
|
21
|
+
* tested in explore_visibility.spec.ts / the authorize specs. Notebooks are
|
|
22
|
+
* always public and never gated.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
afterAll,
|
|
26
|
+
afterEach,
|
|
27
|
+
beforeAll,
|
|
28
|
+
beforeEach,
|
|
29
|
+
describe,
|
|
30
|
+
expect,
|
|
31
|
+
it,
|
|
32
|
+
} from "bun:test";
|
|
33
|
+
import * as fs from "fs";
|
|
34
|
+
import * as os from "os";
|
|
35
|
+
import * as path from "path";
|
|
36
|
+
import {
|
|
37
|
+
PackageLoadPool,
|
|
38
|
+
__setPackageLoadPoolForTests,
|
|
39
|
+
} from "../package_load/package_load_pool";
|
|
40
|
+
import { NotQueryableError } from "../errors";
|
|
41
|
+
import { Package } from "./package";
|
|
42
|
+
|
|
43
|
+
const ORIGINAL_ENV = process.env.PACKAGE_LOAD_WORKERS;
|
|
44
|
+
|
|
45
|
+
describe("Query Boundary (queryableSources) via worker pool", () => {
|
|
46
|
+
let tempDir: string;
|
|
47
|
+
let pool: PackageLoadPool;
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
process.env.PACKAGE_LOAD_WORKERS = "1";
|
|
51
|
+
pool = new PackageLoadPool(1);
|
|
52
|
+
await __setPackageLoadPoolForTests(pool);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(async () => {
|
|
56
|
+
await __setPackageLoadPoolForTests(null);
|
|
57
|
+
if (ORIGINAL_ENV === undefined) delete process.env.PACKAGE_LOAD_WORKERS;
|
|
58
|
+
else process.env.PACKAGE_LOAD_WORKERS = ORIGINAL_ENV;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "publisher-qbound-"));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
if (tempDir) {
|
|
67
|
+
try {
|
|
68
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
69
|
+
} catch {
|
|
70
|
+
/* already gone */
|
|
71
|
+
}
|
|
72
|
+
tempDir = "";
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function makeMalloyConfig(): Promise<{
|
|
77
|
+
malloyConfig: import("@malloydata/malloy").MalloyConfig;
|
|
78
|
+
duckdb: { close: () => Promise<void> };
|
|
79
|
+
}> {
|
|
80
|
+
const { MalloyConfig, FixedConnectionMap } = await import(
|
|
81
|
+
"@malloydata/malloy"
|
|
82
|
+
);
|
|
83
|
+
const { DuckDBConnection } = await import("@malloydata/db-duckdb");
|
|
84
|
+
const duckdb = new DuckDBConnection("duckdb", ":memory:");
|
|
85
|
+
const connections = new FixedConnectionMap(
|
|
86
|
+
new Map([["duckdb", duckdb]]),
|
|
87
|
+
"duckdb",
|
|
88
|
+
);
|
|
89
|
+
const malloyConfig = new MalloyConfig({ connections: {} });
|
|
90
|
+
malloyConfig.wrapConnections(() => connections);
|
|
91
|
+
return { malloyConfig, duckdb };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeManifest(extra: Record<string, unknown> = {}): void {
|
|
95
|
+
fs.writeFileSync(
|
|
96
|
+
path.join(tempDir, "publisher.json"),
|
|
97
|
+
JSON.stringify({ name: "pkg", description: "test package", ...extra }),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// base.malloy (a building block, never an explore) + index.malloy (the
|
|
102
|
+
// explore) which imports/joins it, exports only `customers`, and keeps a
|
|
103
|
+
// local `helper` source it does NOT export. Each runnable source carries a
|
|
104
|
+
// view so it can actually be queried.
|
|
105
|
+
function writeLayeredModels(): void {
|
|
106
|
+
fs.writeFileSync(
|
|
107
|
+
path.join(tempDir, "base.malloy"),
|
|
108
|
+
`source: base_source is duckdb.sql("select 1 as id, 5 as n") extend {
|
|
109
|
+
measure: total_n is n.sum()
|
|
110
|
+
view: v is { aggregate: total_n }
|
|
111
|
+
}`,
|
|
112
|
+
);
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(tempDir, "index.malloy"),
|
|
115
|
+
`import "base.malloy"
|
|
116
|
+
source: helper is duckdb.sql("select 1 as id") extend {
|
|
117
|
+
measure: c is count()
|
|
118
|
+
view: hv is { aggregate: c }
|
|
119
|
+
}
|
|
120
|
+
source: customers is duckdb.sql("select 1 as id, 100 as amt") extend {
|
|
121
|
+
join_one: b is base_source on id = b.id
|
|
122
|
+
measure: total is amt.sum()
|
|
123
|
+
view: v is { aggregate: total }
|
|
124
|
+
}
|
|
125
|
+
export { customers }`,
|
|
126
|
+
);
|
|
127
|
+
fs.writeFileSync(
|
|
128
|
+
path.join(tempDir, "report.malloynb"),
|
|
129
|
+
`>>>markdown\n# Report\nAlways public.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -- default mode ("declared") -----------------------------------------
|
|
134
|
+
|
|
135
|
+
it("declared: exported source in an explores file IS queryable (incl. join-through)", async () => {
|
|
136
|
+
writeManifest({ explores: ["index.malloy"] }); // queryableSources defaults to "declared"
|
|
137
|
+
writeLayeredModels();
|
|
138
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
139
|
+
try {
|
|
140
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
141
|
+
const { result } = await pkg
|
|
142
|
+
.getModel("index.malloy")!
|
|
143
|
+
.getQueryResults("customers", "v", undefined);
|
|
144
|
+
expect(result.data).toBeDefined();
|
|
145
|
+
} finally {
|
|
146
|
+
await duckdb.close();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("declared: a non-explores model file is not queryable (file-level, 404)", async () => {
|
|
151
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
152
|
+
writeLayeredModels();
|
|
153
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
154
|
+
try {
|
|
155
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
156
|
+
// base.malloy is hidden (not an explore) → not a query entry point.
|
|
157
|
+
await expect(
|
|
158
|
+
pkg
|
|
159
|
+
.getModel("base.malloy")!
|
|
160
|
+
.getQueryResults("base_source", "v", undefined),
|
|
161
|
+
).rejects.toBeInstanceOf(NotQueryableError);
|
|
162
|
+
} finally {
|
|
163
|
+
await duckdb.close();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("declared: a non-exported source inside an explores file is not queryable", async () => {
|
|
168
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
169
|
+
writeLayeredModels();
|
|
170
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
171
|
+
try {
|
|
172
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
173
|
+
// `helper` compiles and is joinable, but is not in index's export{}.
|
|
174
|
+
await expect(
|
|
175
|
+
pkg.getModel("index.malloy")!.getQueryResults("helper", "hv"),
|
|
176
|
+
).rejects.toBeInstanceOf(NotQueryableError);
|
|
177
|
+
} finally {
|
|
178
|
+
await duckdb.close();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("declared: ad-hoc query is admitted for a curated source, denied for a hidden one", async () => {
|
|
183
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
184
|
+
writeLayeredModels();
|
|
185
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
186
|
+
try {
|
|
187
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
188
|
+
const model = pkg.getModel("index.malloy")!;
|
|
189
|
+
|
|
190
|
+
const { result } = await model.getQueryResults(
|
|
191
|
+
undefined,
|
|
192
|
+
undefined,
|
|
193
|
+
"run: customers -> { aggregate: total }",
|
|
194
|
+
);
|
|
195
|
+
expect(result.data).toBeDefined();
|
|
196
|
+
|
|
197
|
+
await expect(
|
|
198
|
+
model.getQueryResults(
|
|
199
|
+
undefined,
|
|
200
|
+
undefined,
|
|
201
|
+
"run: helper -> { aggregate: c }",
|
|
202
|
+
),
|
|
203
|
+
).rejects.toBeInstanceOf(NotQueryableError);
|
|
204
|
+
} finally {
|
|
205
|
+
await duckdb.close();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("declared: multi-statement is settled by the COMPILED run target (last statement wins)", async () => {
|
|
210
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
211
|
+
writeLayeredModels();
|
|
212
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
213
|
+
try {
|
|
214
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
215
|
+
const model = pkg.getModel("index.malloy")!;
|
|
216
|
+
|
|
217
|
+
// Curated decoy first, hidden real target last. The early gate only
|
|
218
|
+
// sees the first `run:` (curated → defers); the compiled backstop
|
|
219
|
+
// resolves the LAST statement — the one Malloy actually executes —
|
|
220
|
+
// and denies it. This is the case the structRef read exists for.
|
|
221
|
+
await expect(
|
|
222
|
+
model.getQueryResults(
|
|
223
|
+
undefined,
|
|
224
|
+
undefined,
|
|
225
|
+
"run: customers -> { aggregate: total }\nrun: helper -> { aggregate: c }",
|
|
226
|
+
),
|
|
227
|
+
).rejects.toBeInstanceOf(NotQueryableError);
|
|
228
|
+
|
|
229
|
+
// Hidden target FIRST is positively denied by the early gate, before
|
|
230
|
+
// compilation — its compile errors can't be used as a schema oracle.
|
|
231
|
+
await expect(
|
|
232
|
+
model.getQueryResults(
|
|
233
|
+
undefined,
|
|
234
|
+
undefined,
|
|
235
|
+
"run: helper -> { aggregate: c }\nrun: customers -> { aggregate: total }",
|
|
236
|
+
),
|
|
237
|
+
).rejects.toBeInstanceOf(NotQueryableError);
|
|
238
|
+
|
|
239
|
+
// All-curated multi-statement is legitimate and admitted (no false
|
|
240
|
+
// denial from the old fail-closed-on-shape heuristic).
|
|
241
|
+
const { result } = await model.getQueryResults(
|
|
242
|
+
undefined,
|
|
243
|
+
undefined,
|
|
244
|
+
"run: customers -> { aggregate: total }\nrun: customers -> { group_by: id }",
|
|
245
|
+
);
|
|
246
|
+
expect(result.data).toBeDefined();
|
|
247
|
+
} finally {
|
|
248
|
+
await duckdb.close();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("declared: ad-hoc derivation over a curated source is queryable; over a hidden one is not", async () => {
|
|
253
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
254
|
+
writeLayeredModels();
|
|
255
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
256
|
+
try {
|
|
257
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
258
|
+
const model = pkg.getModel("index.malloy")!;
|
|
259
|
+
|
|
260
|
+
// Composing over a queryable source is itself queryable: the compiled
|
|
261
|
+
// structRef names the ad-hoc alias `x`, and the derivation walk maps
|
|
262
|
+
// x → customers (curated).
|
|
263
|
+
const { result } = await model.getQueryResults(
|
|
264
|
+
undefined,
|
|
265
|
+
undefined,
|
|
266
|
+
"source: x is customers extend { measure: m is count() }\nrun: x -> { aggregate: m }",
|
|
267
|
+
);
|
|
268
|
+
expect(result.data).toBeDefined();
|
|
269
|
+
|
|
270
|
+
// Laundering a hidden source through an ad-hoc alias is denied: the
|
|
271
|
+
// chain y → helper never reaches the curated surface.
|
|
272
|
+
await expect(
|
|
273
|
+
model.getQueryResults(
|
|
274
|
+
undefined,
|
|
275
|
+
undefined,
|
|
276
|
+
"source: y is helper extend { measure: m is count() }\nrun: y -> { aggregate: m }",
|
|
277
|
+
),
|
|
278
|
+
).rejects.toBeInstanceOf(NotQueryableError);
|
|
279
|
+
} finally {
|
|
280
|
+
await duckdb.close();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("declared: an exported named query reading a hidden source is admitted by name", async () => {
|
|
285
|
+
// Exporting a query is the author's deliberate exposure of a result,
|
|
286
|
+
// even when the source it reads stays hidden. The explicit queryName
|
|
287
|
+
// request is cleared by the early gate and the compiled backstop is
|
|
288
|
+
// skipped — re-deriving the underlying (hidden) source must not re-deny
|
|
289
|
+
// the author's chosen entry point.
|
|
290
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
291
|
+
fs.writeFileSync(
|
|
292
|
+
path.join(tempDir, "index.malloy"),
|
|
293
|
+
`source: helper is duckdb.sql("select 1 as id") extend {
|
|
294
|
+
measure: c is count()
|
|
295
|
+
}
|
|
296
|
+
source: customers is duckdb.sql("select 1 as id, 100 as amt") extend {
|
|
297
|
+
measure: total is amt.sum()
|
|
298
|
+
}
|
|
299
|
+
query: helper_stats is helper -> { aggregate: c }
|
|
300
|
+
export { customers, helper_stats }`,
|
|
301
|
+
);
|
|
302
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
303
|
+
try {
|
|
304
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
305
|
+
const model = pkg.getModel("index.malloy")!;
|
|
306
|
+
|
|
307
|
+
const { result } = await model.getQueryResults(
|
|
308
|
+
undefined,
|
|
309
|
+
"helper_stats",
|
|
310
|
+
undefined,
|
|
311
|
+
);
|
|
312
|
+
expect(result.data).toBeDefined();
|
|
313
|
+
|
|
314
|
+
// …while the hidden source itself stays non-queryable directly.
|
|
315
|
+
await expect(
|
|
316
|
+
model.getQueryResults(
|
|
317
|
+
undefined,
|
|
318
|
+
undefined,
|
|
319
|
+
"run: helper -> { aggregate: c }",
|
|
320
|
+
),
|
|
321
|
+
).rejects.toBeInstanceOf(NotQueryableError);
|
|
322
|
+
} finally {
|
|
323
|
+
await duckdb.close();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("declared: a hidden source is not queryable via a view whose name collides with an exported query (regression)", async () => {
|
|
328
|
+
// Guards the early-gate fast-path. An exported top-level query named
|
|
329
|
+
// `stats` must admit the pure named query `run: stats`, but must NOT clear
|
|
330
|
+
// `run: helper->stats` where `helper` is hidden and `stats` is also a view
|
|
331
|
+
// on it. Clearing on the bare queryName (ignoring the source prefix) would
|
|
332
|
+
// skip the compiled backstop and let the hidden source be read through the
|
|
333
|
+
// name collision — so the source-prefixed form is gated on the *source*
|
|
334
|
+
// being curated, not on the query name.
|
|
335
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
336
|
+
fs.writeFileSync(
|
|
337
|
+
path.join(tempDir, "index.malloy"),
|
|
338
|
+
`source: helper is duckdb.sql("select 1 as id") extend {
|
|
339
|
+
measure: c is count()
|
|
340
|
+
view: stats is { aggregate: c }
|
|
341
|
+
}
|
|
342
|
+
source: customers is duckdb.sql("select 1 as id, 100 as amt") extend {
|
|
343
|
+
measure: total is amt.sum()
|
|
344
|
+
}
|
|
345
|
+
query: stats is customers -> { aggregate: total }
|
|
346
|
+
export { customers, stats }`,
|
|
347
|
+
);
|
|
348
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
349
|
+
try {
|
|
350
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
351
|
+
const model = pkg.getModel("index.malloy")!;
|
|
352
|
+
|
|
353
|
+
// The exported top-level query is reachable on its own name.
|
|
354
|
+
const { result } = await model.getQueryResults(
|
|
355
|
+
undefined,
|
|
356
|
+
"stats",
|
|
357
|
+
undefined,
|
|
358
|
+
);
|
|
359
|
+
expect(result.data).toBeDefined();
|
|
360
|
+
|
|
361
|
+
// …but the hidden source is NOT reachable through the colliding view
|
|
362
|
+
// name when explicitly targeted as the run source.
|
|
363
|
+
await expect(
|
|
364
|
+
model.getQueryResults("helper", "stats", undefined),
|
|
365
|
+
).rejects.toBeInstanceOf(NotQueryableError);
|
|
366
|
+
} finally {
|
|
367
|
+
await duckdb.close();
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("declared: ad-hoc derivation over a backtick-quoted exported source is queryable", async () => {
|
|
372
|
+
// A source whose name needs backticks (a hyphen) must still be walkable
|
|
373
|
+
// by the derivation alias map — otherwise composing over it returns a
|
|
374
|
+
// false 404. Guards buildAliasMap's quoted-identifier handling.
|
|
375
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
376
|
+
fs.writeFileSync(
|
|
377
|
+
path.join(tempDir, "index.malloy"),
|
|
378
|
+
`source: \`customer-orders\` is duckdb.sql("select 1 as id, 100 as amt") extend {
|
|
379
|
+
measure: total is amt.sum()
|
|
380
|
+
}
|
|
381
|
+
export { \`customer-orders\` }`,
|
|
382
|
+
);
|
|
383
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
384
|
+
try {
|
|
385
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
386
|
+
const model = pkg.getModel("index.malloy")!;
|
|
387
|
+
|
|
388
|
+
const { result } = await model.getQueryResults(
|
|
389
|
+
undefined,
|
|
390
|
+
undefined,
|
|
391
|
+
"source: x is `customer-orders` extend { measure: m is count() }\nrun: x -> { aggregate: m }",
|
|
392
|
+
);
|
|
393
|
+
expect(result.data).toBeDefined();
|
|
394
|
+
} finally {
|
|
395
|
+
await duckdb.close();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("declared: a notebook is exempt from the boundary (always public)", async () => {
|
|
400
|
+
// The /compile path runs assertQueryBoundaryEarly against the target
|
|
401
|
+
// model; a notebook is never in `explores`, so without an explicit
|
|
402
|
+
// exemption it would 404 — contradicting "notebooks are always public".
|
|
403
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
404
|
+
writeLayeredModels();
|
|
405
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
406
|
+
try {
|
|
407
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
408
|
+
const notebook = pkg.getModel("report.malloynb");
|
|
409
|
+
expect(notebook).toBeDefined();
|
|
410
|
+
// Boundary is inert for notebooks even though it's not an explore.
|
|
411
|
+
expect(
|
|
412
|
+
notebook!.assertQueryBoundaryEarly(undefined, undefined, "run: x"),
|
|
413
|
+
).toBe("cleared");
|
|
414
|
+
expect(() =>
|
|
415
|
+
notebook!.assertQueryBoundaryCompiled("anything", "run: x"),
|
|
416
|
+
).not.toThrow();
|
|
417
|
+
} finally {
|
|
418
|
+
await duckdb.close();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// -- "all" mode (decoupled) --------------------------------------------
|
|
423
|
+
|
|
424
|
+
it("all: explores gates discovery only — hidden file/source stay queryable", async () => {
|
|
425
|
+
writeManifest({ explores: ["index.malloy"], queryableSources: "all" });
|
|
426
|
+
writeLayeredModels();
|
|
427
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
428
|
+
try {
|
|
429
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
430
|
+
|
|
431
|
+
// Listing is still curated to the explore.
|
|
432
|
+
const listed = (await pkg.listModels()).map((m) => m.path);
|
|
433
|
+
expect(listed).toEqual(["index.malloy"]);
|
|
434
|
+
|
|
435
|
+
// …but the non-explores file and the non-exported source are queryable.
|
|
436
|
+
const base = await pkg
|
|
437
|
+
.getModel("base.malloy")!
|
|
438
|
+
.getQueryResults("base_source", "v", undefined);
|
|
439
|
+
expect(base.result.data).toBeDefined();
|
|
440
|
+
const helper = await pkg
|
|
441
|
+
.getModel("index.malloy")!
|
|
442
|
+
.getQueryResults("helper", "hv", undefined);
|
|
443
|
+
expect(helper.result.data).toBeDefined();
|
|
444
|
+
} finally {
|
|
445
|
+
await duckdb.close();
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// -- no explores declared (boundary inert) -----------------------------
|
|
450
|
+
|
|
451
|
+
it("declared default + no explores: everything stays queryable (backward compatible)", async () => {
|
|
452
|
+
writeManifest(); // no explores → no curated surface to enforce
|
|
453
|
+
writeLayeredModels();
|
|
454
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
455
|
+
try {
|
|
456
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
457
|
+
const base = await pkg
|
|
458
|
+
.getModel("base.malloy")!
|
|
459
|
+
.getQueryResults("base_source", "v", undefined);
|
|
460
|
+
expect(base.result.data).toBeDefined();
|
|
461
|
+
// `helper` is non-exported, but with no explores there is no boundary.
|
|
462
|
+
const helper = await pkg
|
|
463
|
+
.getModel("index.malloy")!
|
|
464
|
+
.getQueryResults("helper", "hv", undefined);
|
|
465
|
+
expect(helper.result.data).toBeDefined();
|
|
466
|
+
} finally {
|
|
467
|
+
await duckdb.close();
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { components } from "../api";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Generic database interface for storage abstraction
|
|
3
5
|
* This allows switching between DuckDB, PostgreSQL, MySQL, etc.
|
|
@@ -70,24 +72,9 @@ export interface ResourceRepository {
|
|
|
70
72
|
): Promise<Materialization>;
|
|
71
73
|
updateMaterialization(
|
|
72
74
|
id: string,
|
|
73
|
-
updates:
|
|
74
|
-
status?: MaterializationStatus;
|
|
75
|
-
startedAt?: Date;
|
|
76
|
-
completedAt?: Date;
|
|
77
|
-
error?: string | null;
|
|
78
|
-
metadata?: Record<string, unknown> | null;
|
|
79
|
-
},
|
|
75
|
+
updates: MaterializationUpdate,
|
|
80
76
|
): Promise<Materialization>;
|
|
81
77
|
deleteMaterialization(id: string): Promise<void>;
|
|
82
|
-
// Build Manifests
|
|
83
|
-
listManifestEntries(
|
|
84
|
-
environmentId: string,
|
|
85
|
-
packageName: string,
|
|
86
|
-
): Promise<ManifestEntry[]>;
|
|
87
|
-
upsertManifestEntry(
|
|
88
|
-
entry: Omit<ManifestEntry, "id" | "createdAt" | "updatedAt">,
|
|
89
|
-
): Promise<ManifestEntry>;
|
|
90
|
-
deleteManifestEntry(id: string): Promise<void>;
|
|
91
78
|
}
|
|
92
79
|
|
|
93
80
|
export interface Environment {
|
|
@@ -121,18 +108,30 @@ export interface Connection {
|
|
|
121
108
|
updatedAt: Date;
|
|
122
109
|
}
|
|
123
110
|
|
|
111
|
+
// Wire types for the two-round build protocol, kept in sync with the OpenAPI
|
|
112
|
+
// spec via the generated `api.ts`.
|
|
124
113
|
export type MaterializationStatus =
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
114
|
+
components["schemas"]["MaterializationStatus"];
|
|
115
|
+
export type BuildPlan = components["schemas"]["BuildPlan"];
|
|
116
|
+
export type BuildManifestResult = components["schemas"]["BuildManifest"];
|
|
117
|
+
export type ManifestEntry = components["schemas"]["ManifestEntry"];
|
|
118
|
+
export type BuildInstruction = components["schemas"]["BuildInstruction"];
|
|
119
|
+
export type Realization = components["schemas"]["Realization"];
|
|
130
120
|
|
|
131
121
|
export interface Materialization {
|
|
132
122
|
id: string;
|
|
133
123
|
environmentId: string;
|
|
134
124
|
packageName: string;
|
|
125
|
+
/**
|
|
126
|
+
* Echoes the create-time flag (read from metadata). False (default) =
|
|
127
|
+
* auto-run all phases; true = pause at BUILD_PLAN_READY for Round 2.
|
|
128
|
+
*/
|
|
129
|
+
pauseBetweenPhases: boolean;
|
|
135
130
|
status: MaterializationStatus;
|
|
131
|
+
/** Round 1 output. Null until status >= BUILD_PLAN_READY. */
|
|
132
|
+
buildPlan: BuildPlan | null;
|
|
133
|
+
/** Round 2 output. Null until status = MANIFEST_FILE_READY. */
|
|
134
|
+
manifest: BuildManifestResult | null;
|
|
136
135
|
startedAt: Date | null;
|
|
137
136
|
completedAt: Date | null;
|
|
138
137
|
error: string | null;
|
|
@@ -141,20 +140,23 @@ export interface Materialization {
|
|
|
141
140
|
updatedAt: Date;
|
|
142
141
|
}
|
|
143
142
|
|
|
144
|
-
export interface
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
createdAt: Date;
|
|
153
|
-
updatedAt: Date;
|
|
143
|
+
export interface MaterializationUpdate {
|
|
144
|
+
status?: MaterializationStatus;
|
|
145
|
+
buildPlan?: BuildPlan | null;
|
|
146
|
+
manifest?: BuildManifestResult | null;
|
|
147
|
+
startedAt?: Date;
|
|
148
|
+
completedAt?: Date;
|
|
149
|
+
error?: string | null;
|
|
150
|
+
metadata?: Record<string, unknown> | null;
|
|
154
151
|
}
|
|
155
152
|
|
|
156
|
-
|
|
157
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Malloy-facing build manifest: maps a buildId to the physical table backing
|
|
155
|
+
* that persist source. This is the shape the Malloy runtime consumes when
|
|
156
|
+
* (re)loading models so persist references resolve to materialized tables.
|
|
157
|
+
* Distinct from {@link BuildManifestResult} (the wire/Round 2 output, which
|
|
158
|
+
* also carries CP bookkeeping like materializedTableId and rowCount).
|
|
159
|
+
*/
|
|
158
160
|
export interface BuildManifestEntry {
|
|
159
161
|
tableName: string;
|
|
160
162
|
}
|
|
@@ -163,27 +165,3 @@ export interface BuildManifest {
|
|
|
163
165
|
entries: Record<string, BuildManifestEntry>;
|
|
164
166
|
strict?: boolean;
|
|
165
167
|
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Abstraction for manifest storage. Standalone mode uses DuckDB;
|
|
169
|
-
* orchestrated mode swaps in a DuckLakeManifestStore.
|
|
170
|
-
*/
|
|
171
|
-
export interface ManifestStore {
|
|
172
|
-
getManifest(
|
|
173
|
-
environmentId: string,
|
|
174
|
-
packageName: string,
|
|
175
|
-
): Promise<BuildManifest>;
|
|
176
|
-
writeEntry(
|
|
177
|
-
environmentId: string,
|
|
178
|
-
packageName: string,
|
|
179
|
-
buildId: string,
|
|
180
|
-
tableName: string,
|
|
181
|
-
sourceName: string,
|
|
182
|
-
connectionName: string,
|
|
183
|
-
): Promise<void>;
|
|
184
|
-
deleteEntry(id: string): Promise<void>;
|
|
185
|
-
listEntries(
|
|
186
|
-
environmentId: string,
|
|
187
|
-
packageName: string,
|
|
188
|
-
): Promise<ManifestEntry[]>;
|
|
189
|
-
}
|
|
@@ -47,13 +47,4 @@ export class StorageManager {
|
|
|
47
47
|
deleteConnection: async (): Promise<void> => {},
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
getManifestStore() {
|
|
52
|
-
return {
|
|
53
|
-
getManifest: async () => ({ entries: {}, strict: false }),
|
|
54
|
-
writeEntry: async () => {},
|
|
55
|
-
deleteEntry: async () => {},
|
|
56
|
-
listEntries: async () => [],
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
50
|
}
|