@malloy-publisher/server 0.0.205 → 0.0.206
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 +363 -395
- package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-ivu4vdpj.js} +1 -1
- package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-BZEuwF9P.js} +1 -1
- package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-Dpu3bfPg.js} +1 -1
- package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BdOZDcce.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-DHHAcY5o.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-RX3QOTde.js → index-ChR1fKR2.js} +127 -127
- package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-BVLPYNuj.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 +1151 -1264
- 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 +66 -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 +212 -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 +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +400 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +254 -14
- 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 +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- 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,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: Explore Visibility.
|
|
3
|
+
*
|
|
4
|
+
* Drives `Package.create` through the package-load worker pool (same harness as
|
|
5
|
+
* package_worker_path.spec.ts) and verifies, end-to-end, the listing rule:
|
|
6
|
+
*
|
|
7
|
+
* - `explores` in publisher.json hides non-entry .malloy models from
|
|
8
|
+
* `listModels()` while they still compile for import/join resolution.
|
|
9
|
+
* - Within an explore, only the re-export closure (`export { … }`) is
|
|
10
|
+
* listed as sources — imported helpers stay out.
|
|
11
|
+
* - A query that joins through a hidden module still resolves.
|
|
12
|
+
* - Notebooks are always listed regardless of `explores`.
|
|
13
|
+
* - Absent/empty `explores` → every model is listed with the full source set
|
|
14
|
+
* (backward compatible; no within-file `export {}` curation).
|
|
15
|
+
* - Within-file curation applies only when `explores` is declared.
|
|
16
|
+
* - A bogus `explores` path is fail-safe at load (warn + hide, no throw);
|
|
17
|
+
* the publish path rejects it (package.controller.spec.ts).
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
afterAll,
|
|
21
|
+
afterEach,
|
|
22
|
+
beforeAll,
|
|
23
|
+
beforeEach,
|
|
24
|
+
describe,
|
|
25
|
+
expect,
|
|
26
|
+
it,
|
|
27
|
+
} from "bun:test";
|
|
28
|
+
import * as fs from "fs";
|
|
29
|
+
import * as os from "os";
|
|
30
|
+
import * as path from "path";
|
|
31
|
+
import {
|
|
32
|
+
PackageLoadPool,
|
|
33
|
+
__setPackageLoadPoolForTests,
|
|
34
|
+
} from "../package_load/package_load_pool";
|
|
35
|
+
import { Package } from "./package";
|
|
36
|
+
|
|
37
|
+
const ORIGINAL_ENV = process.env.PACKAGE_LOAD_WORKERS;
|
|
38
|
+
|
|
39
|
+
describe("Explore Visibility via worker pool", () => {
|
|
40
|
+
let tempDir: string;
|
|
41
|
+
let pool: PackageLoadPool;
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
process.env.PACKAGE_LOAD_WORKERS = "1";
|
|
45
|
+
pool = new PackageLoadPool(1);
|
|
46
|
+
await __setPackageLoadPoolForTests(pool);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(async () => {
|
|
50
|
+
await __setPackageLoadPoolForTests(null);
|
|
51
|
+
if (ORIGINAL_ENV === undefined) delete process.env.PACKAGE_LOAD_WORKERS;
|
|
52
|
+
else process.env.PACKAGE_LOAD_WORKERS = ORIGINAL_ENV;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "publisher-explores-"));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
if (tempDir) {
|
|
61
|
+
try {
|
|
62
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
63
|
+
} catch {
|
|
64
|
+
/* already gone */
|
|
65
|
+
}
|
|
66
|
+
tempDir = "";
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
async function makeMalloyConfig(): Promise<{
|
|
71
|
+
malloyConfig: import("@malloydata/malloy").MalloyConfig;
|
|
72
|
+
duckdb: { close: () => Promise<void> };
|
|
73
|
+
}> {
|
|
74
|
+
const { MalloyConfig, FixedConnectionMap } = await import(
|
|
75
|
+
"@malloydata/malloy"
|
|
76
|
+
);
|
|
77
|
+
const { DuckDBConnection } = await import("@malloydata/db-duckdb");
|
|
78
|
+
const duckdb = new DuckDBConnection("duckdb", ":memory:");
|
|
79
|
+
const connections = new FixedConnectionMap(
|
|
80
|
+
new Map([["duckdb", duckdb]]),
|
|
81
|
+
"duckdb",
|
|
82
|
+
);
|
|
83
|
+
const malloyConfig = new MalloyConfig({ connections: {} });
|
|
84
|
+
malloyConfig.wrapConnections(() => connections);
|
|
85
|
+
return { malloyConfig, duckdb };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeManifest(extra: Record<string, unknown> = {}): void {
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
path.join(tempDir, "publisher.json"),
|
|
91
|
+
JSON.stringify({ name: "pkg", description: "test package", ...extra }),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A base module (never an explore) plus a curated index that imports it,
|
|
96
|
+
// joins through it, and re-exports only `customers`. `helper` is a second
|
|
97
|
+
// local source the index does NOT export — used to prove within-file curation.
|
|
98
|
+
function writeLayeredModels(): void {
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
path.join(tempDir, "base.malloy"),
|
|
101
|
+
`source: base_source is duckdb.sql("select 1 as id, 'x' as label")`,
|
|
102
|
+
);
|
|
103
|
+
fs.writeFileSync(
|
|
104
|
+
path.join(tempDir, "index.malloy"),
|
|
105
|
+
`import "base.malloy"
|
|
106
|
+
source: helper is duckdb.sql("select 1 as id")
|
|
107
|
+
source: customers is duckdb.sql("select 1 as id, 100 as amt") extend {
|
|
108
|
+
join_one: b is base_source on id = b.id
|
|
109
|
+
measure: total is amt.sum()
|
|
110
|
+
view: v is { aggregate: total }
|
|
111
|
+
}
|
|
112
|
+
export { customers }`,
|
|
113
|
+
);
|
|
114
|
+
fs.writeFileSync(
|
|
115
|
+
path.join(tempDir, "report.malloynb"),
|
|
116
|
+
`>>>markdown\n# Sales Report\nA report that is always public.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function listedModelPaths(pkg: Package): Promise<string[]> {
|
|
121
|
+
return (await pkg.listModels()).map((m) => m.path as string).sort();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
it("hides non-entry models, curates exports, keeps join-through, lists notebooks", async () => {
|
|
125
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
126
|
+
writeLayeredModels();
|
|
127
|
+
|
|
128
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
129
|
+
try {
|
|
130
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
131
|
+
|
|
132
|
+
// Listing surface: only the explore, not base.malloy.
|
|
133
|
+
expect(await listedModelPaths(pkg)).toEqual(["index.malloy"]);
|
|
134
|
+
|
|
135
|
+
// Within-file curation: only the re-exported `customers`, not the
|
|
136
|
+
// un-exported `helper`, and not the imported `base_source`.
|
|
137
|
+
const apiModel = (await pkg.getModel("index.malloy")!.getModel()) as {
|
|
138
|
+
sources?: { name?: string }[];
|
|
139
|
+
};
|
|
140
|
+
expect((apiModel.sources ?? []).map((s) => s.name).sort()).toEqual([
|
|
141
|
+
"customers",
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
// The hidden base module still resolves: index.malloy compiled (above)
|
|
145
|
+
// and a query that joins through base_source executes.
|
|
146
|
+
const { result } = await pkg
|
|
147
|
+
.getModel("index.malloy")!
|
|
148
|
+
.getQueryResults("customers", "v", undefined);
|
|
149
|
+
expect(result.data).toBeDefined();
|
|
150
|
+
|
|
151
|
+
// Notebooks are always public regardless of explores.
|
|
152
|
+
expect((await pkg.listNotebooks()).map((n) => n.path)).toEqual([
|
|
153
|
+
"report.malloynb",
|
|
154
|
+
]);
|
|
155
|
+
} finally {
|
|
156
|
+
await duckdb.close();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("keeps enforcing a hidden source's authorize gate (curation ≠ access)", async () => {
|
|
161
|
+
// base_source is hidden from index.malloy's listing (not re-exported),
|
|
162
|
+
// but it carries its own #(authorize) gate. Curation must not drop that
|
|
163
|
+
// gate: getAuthorize reads the COMPLETE source list, not the curated view.
|
|
164
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
165
|
+
fs.writeFileSync(
|
|
166
|
+
path.join(tempDir, "base.malloy"),
|
|
167
|
+
`#(authorize) "1 = 1"
|
|
168
|
+
source: base_source is duckdb.sql("select 1 as id")`,
|
|
169
|
+
);
|
|
170
|
+
fs.writeFileSync(
|
|
171
|
+
path.join(tempDir, "index.malloy"),
|
|
172
|
+
`import "base.malloy"
|
|
173
|
+
source: customers is duckdb.sql("select 1 as id, 100 as amt") extend {
|
|
174
|
+
measure: total is amt.sum()
|
|
175
|
+
}
|
|
176
|
+
export { customers }`,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
180
|
+
try {
|
|
181
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
182
|
+
const model = pkg.getModel("index.malloy")!;
|
|
183
|
+
|
|
184
|
+
// Discovery: base_source is hidden, customers is listed.
|
|
185
|
+
const apiModel = (await model.getModel()) as {
|
|
186
|
+
sources?: { name?: string }[];
|
|
187
|
+
};
|
|
188
|
+
expect((apiModel.sources ?? []).map((s) => s.name).sort()).toEqual([
|
|
189
|
+
"customers",
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
// Enforcement: the hidden source's gate is still in force.
|
|
193
|
+
expect(model.getAuthorize("base_source")).toEqual(["1 = 1"]);
|
|
194
|
+
} finally {
|
|
195
|
+
await duckdb.close();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("lists every model and full sources when explores is absent (backward compatible)", async () => {
|
|
200
|
+
writeManifest();
|
|
201
|
+
writeLayeredModels();
|
|
202
|
+
|
|
203
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
204
|
+
try {
|
|
205
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
206
|
+
expect(await listedModelPaths(pkg)).toEqual([
|
|
207
|
+
"base.malloy",
|
|
208
|
+
"index.malloy",
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
// No `explores` ⇒ export{} curation off; non-exported `helper` listed.
|
|
212
|
+
const apiModel = (await pkg.getModel("index.malloy")!.getModel()) as {
|
|
213
|
+
sources?: { name?: string }[];
|
|
214
|
+
};
|
|
215
|
+
const names = (apiModel.sources ?? []).map((s) => s.name).sort();
|
|
216
|
+
expect(names).toContain("customers");
|
|
217
|
+
expect(names).toContain("helper");
|
|
218
|
+
} finally {
|
|
219
|
+
await duckdb.close();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("does not curate by export{} without explores; curates once explores is declared", async () => {
|
|
224
|
+
writeManifest(); // no explores
|
|
225
|
+
fs.writeFileSync(
|
|
226
|
+
path.join(tempDir, "model.malloy"),
|
|
227
|
+
`source: public_orders is duckdb.sql("select 1 as id")
|
|
228
|
+
source: internal_scratch is duckdb.sql("select 1 as id")
|
|
229
|
+
export { public_orders }`,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
233
|
+
try {
|
|
234
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
235
|
+
const api = (await pkg.getModel("model.malloy")!.getModel()) as {
|
|
236
|
+
sources?: { name?: string }[];
|
|
237
|
+
modelInfo?: string;
|
|
238
|
+
};
|
|
239
|
+
let sourceNames = (api.sources ?? []).map((s) => s.name).sort();
|
|
240
|
+
expect(sourceNames).toEqual(["internal_scratch", "public_orders"]);
|
|
241
|
+
|
|
242
|
+
// Opt in via explores ⇒ export closure only, aligned with modelInfo.
|
|
243
|
+
pkg.setPackageMetadata({
|
|
244
|
+
...pkg.getPackageMetadata(),
|
|
245
|
+
explores: ["model.malloy"],
|
|
246
|
+
});
|
|
247
|
+
const curated = (await pkg.getModel("model.malloy")!.getModel()) as {
|
|
248
|
+
sources?: { name?: string }[];
|
|
249
|
+
modelInfo?: string;
|
|
250
|
+
};
|
|
251
|
+
sourceNames = (curated.sources ?? []).map((s) => s.name).sort();
|
|
252
|
+
expect(sourceNames).toEqual(["public_orders"]);
|
|
253
|
+
const mi = JSON.parse(curated.modelInfo ?? "{}");
|
|
254
|
+
const infoNames = (mi.entries ?? [])
|
|
255
|
+
.filter((e: { kind: string }) => e.kind === "source")
|
|
256
|
+
.map((e: { name: string }) => e.name)
|
|
257
|
+
.sort();
|
|
258
|
+
expect(sourceNames).toEqual(infoNames);
|
|
259
|
+
} finally {
|
|
260
|
+
await duckdb.close();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("import-only barrel lists imported sources without explores; empty once explores lists it", async () => {
|
|
265
|
+
writeManifest(); // no explores
|
|
266
|
+
fs.writeFileSync(
|
|
267
|
+
path.join(tempDir, "base.malloy"),
|
|
268
|
+
`source: base_source is duckdb.sql("select 1 as id, 100 as amt") extend {
|
|
269
|
+
measure: total is amt.sum()
|
|
270
|
+
view: v is { aggregate: total }
|
|
271
|
+
}`,
|
|
272
|
+
);
|
|
273
|
+
fs.writeFileSync(
|
|
274
|
+
path.join(tempDir, "consumer.malloy"),
|
|
275
|
+
`import "base.malloy"
|
|
276
|
+
run: base_source -> v`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
280
|
+
try {
|
|
281
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
282
|
+
const api = (await pkg.getModel("consumer.malloy")!.getModel()) as {
|
|
283
|
+
sources?: { name?: string }[];
|
|
284
|
+
};
|
|
285
|
+
expect((api.sources ?? []).map((s) => s.name).sort()).toEqual([
|
|
286
|
+
"base_source",
|
|
287
|
+
]);
|
|
288
|
+
expect(pkg.emptyDiscoveryWarnings()).toEqual([]);
|
|
289
|
+
|
|
290
|
+
pkg.setPackageMetadata({
|
|
291
|
+
...pkg.getPackageMetadata(),
|
|
292
|
+
explores: ["consumer.malloy"],
|
|
293
|
+
queryableSources: "all",
|
|
294
|
+
});
|
|
295
|
+
const curated = (await pkg
|
|
296
|
+
.getModel("consumer.malloy")!
|
|
297
|
+
.getModel()) as {
|
|
298
|
+
sources?: { name?: string }[];
|
|
299
|
+
modelInfo?: string;
|
|
300
|
+
};
|
|
301
|
+
const sourceNames = (curated.sources ?? []).map((s) => s.name).sort();
|
|
302
|
+
expect(sourceNames).toEqual([]);
|
|
303
|
+
const mi = JSON.parse(curated.modelInfo ?? "{}");
|
|
304
|
+
const infoNames = (mi.entries ?? [])
|
|
305
|
+
.filter((e: { kind: string }) => e.kind === "source")
|
|
306
|
+
.map((e: { name: string }) => e.name)
|
|
307
|
+
.sort();
|
|
308
|
+
expect(sourceNames).toEqual(infoNames);
|
|
309
|
+
|
|
310
|
+
// Soft migration: queryableSources "all" keeps hidden files queryable.
|
|
311
|
+
const { result } = await pkg
|
|
312
|
+
.getModel("base.malloy")!
|
|
313
|
+
.getQueryResults("base_source", "v", undefined);
|
|
314
|
+
expect(result.data).toBeDefined();
|
|
315
|
+
} finally {
|
|
316
|
+
await duckdb.close();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("direct getModel on a hidden file succeeds and uses package-wide curation", async () => {
|
|
321
|
+
writeManifest({ explores: ["index.malloy"] });
|
|
322
|
+
fs.writeFileSync(
|
|
323
|
+
path.join(tempDir, "base.malloy"),
|
|
324
|
+
`source: pub is duckdb.sql("select 1 as id")
|
|
325
|
+
source: hidden is duckdb.sql("select 2 as id")
|
|
326
|
+
export { pub }`,
|
|
327
|
+
);
|
|
328
|
+
fs.writeFileSync(
|
|
329
|
+
path.join(tempDir, "index.malloy"),
|
|
330
|
+
`import "base.malloy"
|
|
331
|
+
source: customers is duckdb.sql("select 1 as id")
|
|
332
|
+
export { customers }`,
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
336
|
+
try {
|
|
337
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
338
|
+
expect(await listedModelPaths(pkg)).toEqual(["index.malloy"]);
|
|
339
|
+
|
|
340
|
+
const hidden = (await pkg.getModel("base.malloy")!.getModel()) as {
|
|
341
|
+
sources?: { name?: string }[];
|
|
342
|
+
};
|
|
343
|
+
expect((hidden.sources ?? []).map((s) => s.name)).toEqual(["pub"]);
|
|
344
|
+
} finally {
|
|
345
|
+
await duckdb.close();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("warns for a LISTED import-only model (blank page), not for a hidden one", async () => {
|
|
350
|
+
writeManifest({ explores: ["consumer.malloy"] });
|
|
351
|
+
fs.writeFileSync(
|
|
352
|
+
path.join(tempDir, "base.malloy"),
|
|
353
|
+
`source: base_source is duckdb.sql("select 1 as id")`,
|
|
354
|
+
);
|
|
355
|
+
fs.writeFileSync(
|
|
356
|
+
path.join(tempDir, "consumer.malloy"),
|
|
357
|
+
`import "base.malloy"\nrun: base_source -> { group_by: id }`,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
361
|
+
try {
|
|
362
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
363
|
+
const warnings = pkg.emptyDiscoveryWarnings();
|
|
364
|
+
expect(warnings.length).toBe(1);
|
|
365
|
+
expect(warnings[0]).toBe(
|
|
366
|
+
`Model "consumer.malloy" is listed but exposes nothing: it only ` +
|
|
367
|
+
`imports other files and re-exports none of their sources. ` +
|
|
368
|
+
`Add e.g. 'export { source_name }' to surface sources on ` +
|
|
369
|
+
`this model.`,
|
|
370
|
+
);
|
|
371
|
+
expect(pkg.formatInvalidExplores()).toBe("");
|
|
372
|
+
|
|
373
|
+
fs.writeFileSync(
|
|
374
|
+
path.join(tempDir, "consumer.malloy"),
|
|
375
|
+
`import "base.malloy"\nexport { base_source }`,
|
|
376
|
+
);
|
|
377
|
+
await pkg.reloadAllModels({});
|
|
378
|
+
expect(pkg.emptyDiscoveryWarnings()).toEqual([]);
|
|
379
|
+
|
|
380
|
+
fs.writeFileSync(
|
|
381
|
+
path.join(tempDir, "consumer.malloy"),
|
|
382
|
+
`import "base.malloy"\nrun: base_source -> { group_by: id }`,
|
|
383
|
+
);
|
|
384
|
+
writeManifest({ explores: ["base.malloy"] });
|
|
385
|
+
await pkg.reloadAllModels({});
|
|
386
|
+
expect(pkg.emptyDiscoveryWarnings()).toEqual([]);
|
|
387
|
+
} finally {
|
|
388
|
+
await duckdb.close();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("load is fail-safe on an unknown explores path: warns, hides, does not throw", async () => {
|
|
393
|
+
writeManifest({ explores: ["does-not-exist.malloy"] });
|
|
394
|
+
writeLayeredModels();
|
|
395
|
+
|
|
396
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
397
|
+
try {
|
|
398
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
399
|
+
expect(await listedModelPaths(pkg)).toEqual([]);
|
|
400
|
+
expect(pkg.formatInvalidExplores()).toMatch(
|
|
401
|
+
/does-not-exist\.malloy.*not found/s,
|
|
402
|
+
);
|
|
403
|
+
const warnings = pkg.getPackageMetadata().exploresWarnings ?? [];
|
|
404
|
+
expect(warnings.length).toBe(1);
|
|
405
|
+
expect(warnings[0]).toBe(
|
|
406
|
+
`Invalid explores entry 'does-not-exist.malloy' in ` +
|
|
407
|
+
`publisher.json: file not found in the package. Fix: list a ` +
|
|
408
|
+
`.malloy file relative to the package root ` +
|
|
409
|
+
`(e.g. "index.malloy").`,
|
|
410
|
+
);
|
|
411
|
+
} finally {
|
|
412
|
+
await duckdb.close();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("flags a notebook listed as an explore; valid entries still resolve", async () => {
|
|
417
|
+
writeManifest({ explores: ["index.malloy", "report.malloynb"] });
|
|
418
|
+
writeLayeredModels();
|
|
419
|
+
|
|
420
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
421
|
+
try {
|
|
422
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
423
|
+
expect(await listedModelPaths(pkg)).toEqual(["index.malloy"]);
|
|
424
|
+
expect(pkg.getInvalidExplores().map((p) => p.entry)).toEqual([
|
|
425
|
+
"report.malloynb",
|
|
426
|
+
]);
|
|
427
|
+
expect(pkg.formatInvalidExplores()).toMatch(
|
|
428
|
+
/report\.malloynb.*notebooks are always public/s,
|
|
429
|
+
);
|
|
430
|
+
} finally {
|
|
431
|
+
await duckdb.close();
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Contract test for the Malloy property explore visibility relies on when
|
|
2
|
+
// `explores` is declared: `ModelDef.exports`. The within-file curation in
|
|
3
|
+
// service/model.ts lists only the names in `exports`, so if a Malloy upgrade
|
|
4
|
+
// changes this behavior the curation silently breaks — pin it here against
|
|
5
|
+
// the real compiler (not a re-implementation). See explore_visibility.spec.ts
|
|
6
|
+
// for the end-to-end wiring through Package/Model.
|
|
7
|
+
import { DuckDBConnection } from "@malloydata/db-duckdb";
|
|
8
|
+
import {
|
|
9
|
+
FixedConnectionMap,
|
|
10
|
+
InMemoryURLReader,
|
|
11
|
+
Runtime,
|
|
12
|
+
} from "@malloydata/malloy";
|
|
13
|
+
import { beforeAll, describe, expect, it } from "bun:test";
|
|
14
|
+
|
|
15
|
+
const ROOT = "file:///probe/";
|
|
16
|
+
let connections: FixedConnectionMap;
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
const duckdb = new DuckDBConnection("duckdb", ":memory:");
|
|
20
|
+
connections = new FixedConnectionMap(
|
|
21
|
+
new Map([["duckdb", duckdb]]),
|
|
22
|
+
"duckdb",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function modelDefOf(
|
|
27
|
+
files: Record<string, string>,
|
|
28
|
+
entry: string,
|
|
29
|
+
): Promise<{ exports: string[]; contents: string[] }> {
|
|
30
|
+
const urlReader = new InMemoryURLReader(
|
|
31
|
+
new Map(
|
|
32
|
+
Object.entries(files).map(([name, text]) => [`${ROOT}${name}`, text]),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
const runtime = new Runtime({ urlReader, connections });
|
|
36
|
+
const model = await runtime
|
|
37
|
+
.loadModel(new URL(`${ROOT}${entry}`), { importBaseURL: new URL(ROOT) })
|
|
38
|
+
.getModel();
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
+
const def = (model as any)._modelDef;
|
|
41
|
+
return {
|
|
42
|
+
exports: [...(def.exports as string[])],
|
|
43
|
+
contents: Object.keys(def.contents),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("Malloy ModelDef.exports contract (curation depends on this)", () => {
|
|
48
|
+
it("no export{}: exports = all locally-declared top-level sources", async () => {
|
|
49
|
+
const { exports } = await modelDefOf(
|
|
50
|
+
{
|
|
51
|
+
"m.malloy": `source: a is duckdb.sql("select 1 as x")
|
|
52
|
+
source: b is duckdb.sql("select 2 as y")`,
|
|
53
|
+
},
|
|
54
|
+
"m.malloy",
|
|
55
|
+
);
|
|
56
|
+
expect(exports.sort()).toEqual(["a", "b"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("export{a}: exports narrows to the listed name", async () => {
|
|
60
|
+
const { exports } = await modelDefOf(
|
|
61
|
+
{
|
|
62
|
+
"m.malloy": `source: a is duckdb.sql("select 1 as x")
|
|
63
|
+
source: b is duckdb.sql("select 2 as y")
|
|
64
|
+
export { a }`,
|
|
65
|
+
},
|
|
66
|
+
"m.malloy",
|
|
67
|
+
);
|
|
68
|
+
expect(exports).toEqual(["a"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("imported sources are in contents but NOT in exports unless re-exported", async () => {
|
|
72
|
+
const { exports, contents } = await modelDefOf(
|
|
73
|
+
{
|
|
74
|
+
"base.malloy": `source: base_source is duckdb.sql("select 1 as x")`,
|
|
75
|
+
"index.malloy": `import "base.malloy"
|
|
76
|
+
source: customers is base_source extend { dimension: two is 2 }
|
|
77
|
+
export { customers }`,
|
|
78
|
+
},
|
|
79
|
+
"index.malloy",
|
|
80
|
+
);
|
|
81
|
+
// The imported base_source is resolvable (in contents) — so joins work —
|
|
82
|
+
// but it is not part of the re-export closure, so it must not be listed.
|
|
83
|
+
expect(contents).toContain("base_source");
|
|
84
|
+
expect(exports).toEqual(["customers"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("import without export{}: imported name is excluded from exports", async () => {
|
|
88
|
+
const { exports } = await modelDefOf(
|
|
89
|
+
{
|
|
90
|
+
"base.malloy": `source: base_source is duckdb.sql("select 1 as x")`,
|
|
91
|
+
"user.malloy": `import "base.malloy"
|
|
92
|
+
source: local_one is duckdb.sql("select 2 as y")`,
|
|
93
|
+
},
|
|
94
|
+
"user.malloy",
|
|
95
|
+
);
|
|
96
|
+
// Even with no explicit export{}, `exports` lists only locally-declared
|
|
97
|
+
// names — the imported `base_source` is omitted. This is the raw Malloy
|
|
98
|
+
// `exports` behavior (mirrored by modelDefToModelInfo's modelInfo/
|
|
99
|
+
// sourceInfos) that publisher's discovery curation relies on when it is
|
|
100
|
+
// active: a model that declares `explores` lists only this export
|
|
101
|
+
// closure (Model.curateForDiscovery), while every source stays
|
|
102
|
+
// resolvable for joins and fully queryable. (Without `explores`, curation
|
|
103
|
+
// is off and publisher lists the uncurated source set — see
|
|
104
|
+
// explore_visibility.spec.)
|
|
105
|
+
expect(exports).toEqual(["local_one"]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
4
|
+
import * as fs from "fs/promises";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { pathToFileURL } from "url";
|
|
8
|
+
import { fetchManifestEntries } from "./manifest_loader";
|
|
9
|
+
|
|
10
|
+
describe("fetchManifestEntries", () => {
|
|
11
|
+
let dir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), "manifest-loader-"));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function writeManifest(contents: unknown): Promise<string> {
|
|
22
|
+
const file = path.join(dir, "manifest.json");
|
|
23
|
+
await fs.writeFile(file, JSON.stringify(contents), "utf8");
|
|
24
|
+
return file;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it("maps physicalTableName to the Malloy runtime tableName", async () => {
|
|
28
|
+
const file = await writeManifest({
|
|
29
|
+
builtAt: new Date().toISOString(),
|
|
30
|
+
strict: false,
|
|
31
|
+
entries: {
|
|
32
|
+
b1: { buildId: "b1", physicalTableName: "schema.orders_mz" },
|
|
33
|
+
b2: {
|
|
34
|
+
buildId: "b2",
|
|
35
|
+
physicalTableName: "schema.daily_mz",
|
|
36
|
+
connectionName: "bq",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const entries = await fetchManifestEntries(file);
|
|
42
|
+
|
|
43
|
+
expect(entries).toEqual({
|
|
44
|
+
b1: { tableName: "schema.orders_mz" },
|
|
45
|
+
b2: { tableName: "schema.daily_mz" },
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("reads via a file:// URI", async () => {
|
|
50
|
+
const file = await writeManifest({
|
|
51
|
+
entries: { b1: { buildId: "b1", physicalTableName: "t1" } },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const entries = await fetchManifestEntries(pathToFileURL(file).href);
|
|
55
|
+
|
|
56
|
+
expect(entries).toEqual({ b1: { tableName: "t1" } });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("skips entries without a physicalTableName", async () => {
|
|
60
|
+
const file = await writeManifest({
|
|
61
|
+
entries: {
|
|
62
|
+
good: { buildId: "good", physicalTableName: "t1" },
|
|
63
|
+
bad: { buildId: "bad" },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const entries = await fetchManifestEntries(file);
|
|
68
|
+
|
|
69
|
+
expect(entries).toEqual({ good: { tableName: "t1" } });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns an empty map when there are no entries", async () => {
|
|
73
|
+
const file = await writeManifest({ entries: {} });
|
|
74
|
+
expect(await fetchManifestEntries(file)).toEqual({});
|
|
75
|
+
|
|
76
|
+
const file2 = await writeManifest({});
|
|
77
|
+
expect(await fetchManifestEntries(file2)).toEqual({});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("throws on malformed JSON", async () => {
|
|
81
|
+
const file = path.join(dir, "bad.json");
|
|
82
|
+
await fs.writeFile(file, "{ not json", "utf8");
|
|
83
|
+
await expect(fetchManifestEntries(file)).rejects.toThrow(
|
|
84
|
+
/Failed to parse build manifest/,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("throws when the file does not exist", async () => {
|
|
89
|
+
await expect(
|
|
90
|
+
fetchManifestEntries(path.join(dir, "missing.json")),
|
|
91
|
+
).rejects.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects a malformed gs:// URI without a key", async () => {
|
|
95
|
+
await expect(fetchManifestEntries("gs://bucket-only")).rejects.toThrow(
|
|
96
|
+
/Malformed manifest URI/,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|