@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.
Files changed (69) hide show
  1. package/dist/app/api-doc.yaml +394 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
  12. package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
  13. package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +18 -8
  17. package/dist/package_load_worker.mjs +19 -2
  18. package/dist/server.mjs +1272 -1299
  19. package/package.json +1 -1
  20. package/src/constants.ts +12 -0
  21. package/src/controller/materialization.controller.ts +79 -35
  22. package/src/controller/package.controller.spec.ts +179 -0
  23. package/src/controller/package.controller.ts +60 -73
  24. package/src/dto/package.dto.ts +16 -1
  25. package/src/errors.spec.ts +12 -0
  26. package/src/errors.ts +18 -0
  27. package/src/health.spec.ts +34 -1
  28. package/src/instrumentation.ts +33 -17
  29. package/src/materialization_metrics.ts +121 -0
  30. package/src/package_load/package_load_pool.ts +7 -1
  31. package/src/package_load/package_load_worker.ts +44 -4
  32. package/src/package_load/protocol.ts +7 -1
  33. package/src/server-old.ts +7 -149
  34. package/src/server.ts +9 -188
  35. package/src/service/authorize_integration.spec.ts +67 -0
  36. package/src/service/environment.ts +270 -12
  37. package/src/service/environment_store.spec.ts +0 -81
  38. package/src/service/environment_store.ts +0 -23
  39. package/src/service/explore_visibility.spec.ts +434 -0
  40. package/src/service/exports_probe.spec.ts +107 -0
  41. package/src/service/manifest_loader.spec.ts +99 -0
  42. package/src/service/manifest_loader.ts +95 -0
  43. package/src/service/materialization_service.spec.ts +584 -500
  44. package/src/service/materialization_service.ts +839 -657
  45. package/src/service/model.ts +419 -15
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +339 -29
  48. package/src/service/package_rollback.spec.ts +190 -0
  49. package/src/service/package_worker_path.spec.ts +223 -0
  50. package/src/service/query_boundary.spec.ts +470 -0
  51. package/src/storage/DatabaseInterface.ts +35 -57
  52. package/src/storage/StorageManager.mock.ts +0 -9
  53. package/src/storage/StorageManager.ts +7 -290
  54. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  55. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  56. package/src/storage/duckdb/schema.ts +4 -20
  57. package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
  59. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  60. package/src/controller/manifest.controller.ts +0 -38
  61. package/src/service/manifest_service.spec.ts +0 -206
  62. package/src/service/manifest_service.ts +0 -117
  63. package/src/service/materialized_table_gc.spec.ts +0 -384
  64. package/src/service/materialized_table_gc.ts +0 -231
  65. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  66. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  67. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  68. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  69. 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
+ });