@malloy-publisher/server 0.0.192 → 0.0.193

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 (34) hide show
  1. package/dist/app/api-doc.yaml +522 -1
  2. package/dist/app/assets/{HomePage-H1OH-VW5.js → HomePage-Di9MU3lS.js} +1 -1
  3. package/dist/app/assets/{MainPage-GL06aMke.js → MainPage-yZQo2HSL.js} +1 -1
  4. package/dist/app/assets/{ModelPage-Crau5hgZ.js → ModelPage-Dx2mHWeT.js} +1 -1
  5. package/dist/app/assets/{PackagePage-CbubRhgE.js → PackagePage-Q386Py9t.js} +1 -1
  6. package/dist/app/assets/{ProjectPage-DUlJkYJ4.js → ProjectPage-WR7wPQB-.js} +1 -1
  7. package/dist/app/assets/{RouteError-DrNXNihc.js → RouteError-stRGU4aW.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-CBBv7n5U.js → WorkbookPage-D3iX0djH.js} +1 -1
  9. package/dist/app/assets/{core-Dzx75uJR.es-DwnFZnyO.js → core-QH4HZQVz.es-CqlQLZdl.js} +1 -1
  10. package/dist/app/assets/{index-d5rvmoZ7.js → index-CVHzPJwN.js} +119 -119
  11. package/dist/app/assets/{index-CzjyS9cx.js → index-DavAceYD.js} +50 -50
  12. package/dist/app/assets/{index-HHdhLUpv.js → index-Y3Y-VRna.js} +1 -1
  13. package/dist/app/assets/{index.umd-CetYIBQY.js → index.umd-Bp8OIhfV.js} +46 -46
  14. package/dist/app/index.html +1 -1
  15. package/dist/server.mjs +1389 -984
  16. package/package.json +10 -10
  17. package/src/controller/connection.controller.ts +102 -27
  18. package/src/dto/connection.dto.spec.ts +4 -0
  19. package/src/dto/connection.dto.ts +46 -2
  20. package/src/server.ts +201 -2
  21. package/src/service/connection.spec.ts +250 -4
  22. package/src/service/connection.ts +326 -473
  23. package/src/service/connection_config.ts +514 -0
  24. package/src/service/connection_service.spec.ts +50 -0
  25. package/src/service/connection_service.ts +125 -32
  26. package/src/service/materialization_service.spec.ts +18 -12
  27. package/src/service/materialization_service.ts +54 -7
  28. package/src/service/model.ts +24 -27
  29. package/src/service/package.spec.ts +125 -1
  30. package/src/service/package.ts +86 -44
  31. package/src/service/project.ts +172 -94
  32. package/src/service/project_store.spec.ts +72 -0
  33. package/src/service/project_store.ts +98 -81
  34. package/tests/unit/duckdb/attached_databases.test.ts +1 -19
@@ -1,10 +1,66 @@
1
1
  import { components } from "../api";
2
2
  import { ConnectionNotFoundError, FrozenConfigError } from "../errors";
3
3
  import { logger } from "../logger";
4
- import { createProjectConnections } from "./connection";
4
+ import { buildProjectMalloyConfig } from "./connection";
5
5
  import { ProjectStore } from "./project_store";
6
6
 
7
7
  type ApiConnection = components["schemas"]["Connection"];
8
+ type ReleaseCallback = () => Promise<void>;
9
+ type ConnectionUpdateProject = {
10
+ runConnectionUpdateExclusive?: <T>(fn: () => Promise<T>) => Promise<T>;
11
+ updateConnections?: (
12
+ nextMalloyConfig: ReturnType<typeof buildProjectMalloyConfig>,
13
+ apiConnections?: ApiConnection[],
14
+ afterPreviousRelease?: ReleaseCallback,
15
+ ) => void;
16
+ deleteConnection?: (connectionName: string) => Promise<void>;
17
+ deleteDuckDBConnection?: (connectionName: string) => Promise<void>;
18
+ deleteDuckLakeConnection?: (connectionName: string) => Promise<void>;
19
+ };
20
+
21
+ async function runProjectConnectionUpdate<T>(
22
+ project: ConnectionUpdateProject,
23
+ fn: () => Promise<T>,
24
+ ): Promise<T> {
25
+ if (project.runConnectionUpdateExclusive) {
26
+ return project.runConnectionUpdateExclusive(fn);
27
+ }
28
+ return fn();
29
+ }
30
+
31
+ function updateProjectConnections(
32
+ project: ConnectionUpdateProject,
33
+ nextMalloyConfig: ReturnType<typeof buildProjectMalloyConfig>,
34
+ afterPreviousRelease?: ReleaseCallback,
35
+ ): void {
36
+ project.updateConnections?.(
37
+ nextMalloyConfig,
38
+ nextMalloyConfig.apiConnections,
39
+ afterPreviousRelease,
40
+ );
41
+ }
42
+
43
+ function buildDeletedConnectionCleanup(
44
+ project: ConnectionUpdateProject,
45
+ deletedConnection: ApiConnection,
46
+ connectionName: string,
47
+ ): ReleaseCallback | undefined {
48
+ if (
49
+ deletedConnection.type === "duckdb" &&
50
+ typeof project.deleteDuckDBConnection === "function"
51
+ ) {
52
+ return () => project.deleteDuckDBConnection!(connectionName);
53
+ }
54
+
55
+ if (
56
+ deletedConnection.type === "ducklake" &&
57
+ typeof project.deleteDuckLakeConnection === "function"
58
+ ) {
59
+ return () => project.deleteDuckLakeConnection!(connectionName);
60
+ }
61
+
62
+ return undefined;
63
+ }
8
64
 
9
65
  export class ConnectionService {
10
66
  private projectStore: ProjectStore;
@@ -74,21 +130,21 @@ export class ConnectionService {
74
130
 
75
131
  // Update in-memory connections
76
132
  const project = await this.projectStore.getProject(projectName, false);
77
- const existingConnections = project.listApiConnections();
78
-
79
- const { malloyConnections, apiConnections } =
80
- await createProjectConnections(
133
+ await runProjectConnectionUpdate(project, async () => {
134
+ const existingConnections = project.listApiConnections();
135
+ const nextMalloyConfig = buildProjectMalloyConfig(
81
136
  [...existingConnections, connection],
82
137
  project.metadata.location || "",
83
138
  );
84
139
 
85
- project.updateConnections(malloyConnections, apiConnections);
140
+ await this.projectStore.addConnection(
141
+ connection,
142
+ dbProject.id,
143
+ repository,
144
+ );
86
145
 
87
- await this.projectStore.addConnection(
88
- connection,
89
- dbProject.id,
90
- repository,
91
- );
146
+ updateProjectConnections(project, nextMalloyConfig);
147
+ });
92
148
 
93
149
  logger.info(
94
150
  `Successfully added connection "${connection.name}" to project "${projectName}"`,
@@ -117,31 +173,36 @@ export class ConnectionService {
117
173
 
118
174
  // Update in-memory connections
119
175
  const project = await this.projectStore.getProject(projectName, false);
120
- const existingConnections = project.listApiConnections();
176
+ await runProjectConnectionUpdate(project, async () => {
177
+ const existingConnections = project.listApiConnections();
121
178
 
122
- const updatedConnection = {
123
- ...dbConnection.config,
124
- ...connection,
125
- name: connectionName,
126
- };
179
+ const updatedConnection = {
180
+ ...dbConnection.config,
181
+ ...connection,
182
+ name: connectionName,
183
+ };
127
184
 
128
- const updatedConnections = existingConnections.map((conn) =>
129
- conn.name === connectionName ? updatedConnection : conn,
130
- );
185
+ const updatedConnections = existingConnections.map((conn) =>
186
+ conn.name === connectionName ? updatedConnection : conn,
187
+ );
131
188
 
132
- const { malloyConnections, apiConnections } =
133
- await createProjectConnections(
189
+ // Pass isUpdateConnectionRequest=true so the DuckLake wrapper
190
+ // re-attaches against the updated catalog/storage settings instead
191
+ // of trusting the prior generation's persisted attach state.
192
+ const nextMalloyConfig = buildProjectMalloyConfig(
134
193
  updatedConnections,
135
194
  project.metadata.location || "",
195
+ true,
136
196
  );
137
197
 
138
- project.updateConnections(malloyConnections, apiConnections);
198
+ await this.projectStore.updateConnection(
199
+ updatedConnection,
200
+ dbProject.id,
201
+ repository,
202
+ );
139
203
 
140
- await this.projectStore.updateConnection(
141
- updatedConnection,
142
- dbProject.id,
143
- repository,
144
- );
204
+ updateProjectConnections(project, nextMalloyConfig);
205
+ });
145
206
 
146
207
  logger.info(
147
208
  `Successfully updated connection "${connectionName}" in project "${projectName}"`,
@@ -169,10 +230,42 @@ export class ConnectionService {
169
230
 
170
231
  // Update in-memory connections
171
232
  const project = await this.projectStore.getProject(projectName, false);
172
- await project.deleteConnection(connectionName);
173
-
174
- // Delete from database
175
- await repository.deleteConnection(dbConnection.id);
233
+ await runProjectConnectionUpdate(project, async () => {
234
+ if (typeof project.listApiConnections !== "function") {
235
+ if (typeof project.deleteConnection === "function") {
236
+ await project.deleteConnection(connectionName);
237
+ }
238
+ await repository.deleteConnection(dbConnection.id);
239
+ return;
240
+ }
241
+
242
+ const deletedConnection =
243
+ "getApiConnection" in project &&
244
+ typeof project.getApiConnection === "function"
245
+ ? project.getApiConnection(connectionName)
246
+ : dbConnection.config;
247
+ const updatedConnections = project
248
+ .listApiConnections()
249
+ .filter((connection) => connection.name !== connectionName);
250
+ const nextMalloyConfig = buildProjectMalloyConfig(
251
+ updatedConnections,
252
+ project.metadata.location || "",
253
+ );
254
+ const deleteConnectionFilesAfterRelease =
255
+ buildDeletedConnectionCleanup(
256
+ project,
257
+ deletedConnection,
258
+ connectionName,
259
+ );
260
+
261
+ await repository.deleteConnection(dbConnection.id);
262
+
263
+ updateProjectConnections(
264
+ project,
265
+ nextMalloyConfig,
266
+ deleteConnectionFilesAfterRelease,
267
+ );
268
+ });
176
269
 
177
270
  logger.info(
178
271
  `Successfully deleted connection "${connectionName}" from project "${projectName}"`,
@@ -480,10 +480,12 @@ describe("MaterializationService", () => {
480
480
  dialectName: "duckdb",
481
481
  runSQL,
482
482
  } as unknown as Connection;
483
- const connections = new Map<string, Connection>([
484
- ["conn", connection],
485
- ]);
486
- const pkg = { getConnections: () => connections };
483
+ const pkg = {
484
+ getMalloyConnection: async (name: string): Promise<Connection> => {
485
+ if (name === "conn") return connection;
486
+ throw new Error(`unknown connection: ${name}`);
487
+ },
488
+ };
487
489
  (ctx.projectStore.getProject as sinon.SinonStub).resolves({
488
490
  getPackage: sinon.stub().resolves(pkg),
489
491
  });
@@ -535,10 +537,12 @@ describe("MaterializationService", () => {
535
537
  // a vanished "ghost_conn", which used to be impossible to tear down.
536
538
  // `teardownPackage` must force-delete the row anyway so teardown
537
539
  // can complete.
538
- const connections = new Map<string, Connection>([
539
- ["live_conn", livingConn],
540
- ]);
541
- const pkg = { getConnections: () => connections };
540
+ const pkg = {
541
+ getMalloyConnection: async (name: string): Promise<Connection> => {
542
+ if (name === "live_conn") return livingConn;
543
+ throw new Error(`unknown connection: ${name}`);
544
+ },
545
+ };
542
546
  (ctx.projectStore.getProject as sinon.SinonStub).resolves({
543
547
  getPackage: sinon.stub().resolves(pkg),
544
548
  });
@@ -576,10 +580,12 @@ describe("MaterializationService", () => {
576
580
  dialectName: "duckdb",
577
581
  runSQL,
578
582
  } as unknown as Connection;
579
- const connections = new Map<string, Connection>([
580
- ["conn", connection],
581
- ]);
582
- const pkg = { getConnections: () => connections };
583
+ const pkg = {
584
+ getMalloyConnection: async (name: string): Promise<Connection> => {
585
+ if (name === "conn") return connection;
586
+ throw new Error(`unknown connection: ${name}`);
587
+ },
588
+ };
583
589
  (ctx.projectStore.getProject as sinon.SinonStub).resolves({
584
590
  getPackage: sinon.stub().resolves(pkg),
585
591
  });
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  BuildGraph,
3
3
  Connection as MalloyConnection,
4
+ MalloyConfig,
4
5
  PersistSource,
5
6
  } from "@malloydata/malloy";
6
7
  import { Manifest } from "@malloydata/malloy";
@@ -46,6 +47,34 @@ export function stagingSuffix(buildId: string): string {
46
47
  return `_${buildId.substring(0, STAGING_BUILD_ID_LEN)}`;
47
48
  }
48
49
 
50
+ /**
51
+ * Resolve a Map<name, Connection> for just the names a materialization
52
+ * step is about to touch. The package's MalloyConfig caches each lookup,
53
+ * so subsequent calls with overlapping names are cheap. A failed lookup
54
+ * is logged and the name is omitted from the result — downstream code
55
+ * (`forceDeleteRowOnMissingConnection` in teardown, or the explicit
56
+ * "connection X not found" check in build) handles a missing entry.
57
+ */
58
+ async function resolvePackageConnections(
59
+ pkg: { getMalloyConnection(name: string): Promise<MalloyConnection> },
60
+ names: Iterable<string>,
61
+ ): Promise<Map<string, MalloyConnection>> {
62
+ const map = new Map<string, MalloyConnection>();
63
+ const seen = new Set<string>();
64
+ for (const name of names) {
65
+ if (!name || seen.has(name)) continue;
66
+ seen.add(name);
67
+ try {
68
+ map.set(name, await pkg.getMalloyConnection(name));
69
+ } catch (err) {
70
+ logger.warn(`Failed to resolve connection ${name}`, {
71
+ error: err instanceof Error ? err.message : String(err),
72
+ });
73
+ }
74
+ }
75
+ return map;
76
+ }
77
+
49
78
  /**
50
79
  * Build a stable key for a `(connectionName, tableName)` pair.
51
80
  * Used to check whether a persist target was created by a previous build.
@@ -494,13 +523,17 @@ export class MaterializationService {
494
523
 
495
524
  const project = await this.projectStore.getProject(projectName, false);
496
525
  const pkg = await project.getPackage(packageName, false);
497
- const connections = pkg.getConnections();
498
526
 
499
527
  const entries = await this.manifestService.listEntries(
500
528
  projectId,
501
529
  packageName,
502
530
  );
503
531
 
532
+ const connections = await resolvePackageConnections(
533
+ pkg,
534
+ entries.map((e) => e.connectionName),
535
+ );
536
+
504
537
  // `forceDeleteRowOnMissingConnection`: teardown is the one place
505
538
  // where we'd rather lose the manifest row than leave it pointing at
506
539
  // a vanished connection. We also deliberately omit `liveTables`:
@@ -560,7 +593,9 @@ export class MaterializationService {
560
593
  );
561
594
 
562
595
  // ── STEP 2: COMPILE & PLAN ─────────────────────────────────────
563
- const { graphs, sources, connectionDigests } =
596
+ // `connections` is built lazily from the connection names the plan
597
+ // actually targets — no upfront ATTACH on every project connection.
598
+ const { graphs, sources, connectionDigests, connections } =
564
599
  await this.compilePackageBuildPlan(pkg, signal);
565
600
 
566
601
  if (graphs.length === 0) {
@@ -569,7 +604,6 @@ export class MaterializationService {
569
604
  }
570
605
 
571
606
  // ── STEP 3: BUILD ──────────────────────────────────────────────
572
- const connections = pkg.getConnections();
573
607
  let sourcesBuilt = 0;
574
608
  let sourcesSkipped = 0;
575
609
  const sourceResults: Record<string, unknown>[] = [];
@@ -646,13 +680,15 @@ export class MaterializationService {
646
680
  pkg: {
647
681
  getModelPaths(): string[];
648
682
  getPackagePath(): string;
649
- getConnections(): Map<string, MalloyConnection>;
683
+ getMalloyConfig(): MalloyConfig;
684
+ getMalloyConnection(name: string): Promise<MalloyConnection>;
650
685
  },
651
686
  signal: AbortSignal,
652
687
  ): Promise<{
653
688
  graphs: BuildGraph[];
654
689
  sources: Record<string, PersistSource>;
655
690
  connectionDigests: Record<string, string>;
691
+ connections: Map<string, MalloyConnection>;
656
692
  }> {
657
693
  const modelPaths = pkg.getModelPaths();
658
694
  const allGraphs: BuildGraph[] = [];
@@ -665,7 +701,7 @@ export class MaterializationService {
665
701
  await Model.getModelRuntime(
666
702
  pkg.getPackagePath(),
667
703
  modelPath,
668
- pkg.getConnections(),
704
+ pkg.getMalloyConfig(),
669
705
  );
670
706
 
671
707
  const modelMaterializer = runtime.loadModel(modelURL, {
@@ -730,7 +766,13 @@ export class MaterializationService {
730
766
  tableOwners.set(key, sourceID);
731
767
  }
732
768
 
733
- const connections = pkg.getConnections();
769
+ // Resolve only the connections this build plan actually targets;
770
+ // the package's MalloyConfig caches each lookup so the build phase
771
+ // sees the same Connection instance and avoids re-resolving.
772
+ const connections = await resolvePackageConnections(
773
+ pkg,
774
+ allGraphs.map((g) => g.connectionName),
775
+ );
734
776
  const connectionDigests: Record<string, string> = {};
735
777
  for (const graph of allGraphs) {
736
778
  const conn = connections.get(graph.connectionName);
@@ -739,7 +781,12 @@ export class MaterializationService {
739
781
  }
740
782
  }
741
783
 
742
- return { graphs: allGraphs, sources: allSources, connectionDigests };
784
+ return {
785
+ graphs: allGraphs,
786
+ sources: allSources,
787
+ connectionDigests,
788
+ connections,
789
+ };
743
790
  }
744
791
 
745
792
  /**
@@ -1,10 +1,10 @@
1
- import { DuckDBConnection } from "@malloydata/db-duckdb";
2
1
  import {
3
2
  Annotation,
4
3
  API,
5
4
  Connection,
6
5
  FixedConnectionMap,
7
6
  isSourceDef,
7
+ MalloyConfig,
8
8
  MalloyError,
9
9
  ModelDef,
10
10
  modelDefToModelInfo,
@@ -27,7 +27,6 @@ import { DataStyles } from "@malloydata/render";
27
27
  import { metrics } from "@opentelemetry/api";
28
28
  import * as fs from "fs/promises";
29
29
  import * as path from "path";
30
- import { fileURLToPath } from "url";
31
30
  import { components } from "../api";
32
31
  import {
33
32
  MODEL_FILE_SUFFIX,
@@ -72,6 +71,7 @@ const MALLOY_VERSION = (
72
71
  ).version;
73
72
 
74
73
  export type ModelType = "model" | "notebook";
74
+ type ModelConnectionInput = MalloyConfig | Map<string, Connection>;
75
75
 
76
76
  interface RunnableNotebookCell {
77
77
  type: "code" | "markdown";
@@ -162,7 +162,7 @@ export class Model {
162
162
  packageName: string,
163
163
  packagePath: string,
164
164
  modelPath: string,
165
- connections: Map<string, Connection>,
165
+ malloyConfig: ModelConnectionInput,
166
166
  options?: { buildManifest?: BuildManifest["entries"] },
167
167
  ): Promise<Model> {
168
168
  // getModelRuntime might throw a ModelNotFoundError. It's the callers responsibility
@@ -171,7 +171,7 @@ export class Model {
171
171
  await Model.getModelRuntime(
172
172
  packagePath,
173
173
  modelPath,
174
- connections,
174
+ malloyConfig,
175
175
  options,
176
176
  );
177
177
 
@@ -672,7 +672,7 @@ export class Model {
672
672
  static async getModelRuntime(
673
673
  packagePath: string,
674
674
  modelPath: string,
675
- connections: Map<string, Connection>,
675
+ malloyConfig: ModelConnectionInput,
676
676
  options?: { buildManifest?: BuildManifest["entries"] },
677
677
  ): Promise<{
678
678
  runtime: Runtime;
@@ -703,35 +703,32 @@ export class Model {
703
703
 
704
704
  const modelURL = new URL(`file://${fullModelPath}`);
705
705
  const baseUrl = new URL(".", modelURL);
706
- const fileUrl = new URL(baseUrl.pathname, "file:");
707
- const workingDirectory = fileURLToPath(fileUrl);
708
706
  const importBaseURL = new URL(baseUrl.pathname + "/", "file:");
709
707
  const urlReader = new HackyDataStylesAccumulator(URL_READER);
710
708
 
711
- const duckdbConnection = connections.get("duckdb") as DuckDBConnection;
712
- await duckdbConnection.runSQL(
713
- `SET FILE_SEARCH_PATH='${workingDirectory}';`,
714
- );
715
-
716
- const runtimeOptions: {
717
- urlReader: typeof urlReader;
718
- connections: FixedConnectionMap;
719
- buildManifest?: BuildManifest;
720
- } = {
709
+ // Request runtimes borrow the cached package MalloyConfig. The package
710
+ // owns release; callers must not release this runtime per request.
711
+ const runtime = new Runtime({
721
712
  urlReader,
722
- connections: new FixedConnectionMap(connections, "duckdb"),
723
- };
713
+ config: Model.toMalloyConfig(malloyConfig),
714
+ buildManifest: options?.buildManifest
715
+ ? { entries: options.buildManifest, strict: false }
716
+ : undefined,
717
+ });
718
+ const dataStyles = urlReader.getHackyAccumulatedDataStyles();
719
+ return { runtime, modelURL, importBaseURL, dataStyles, modelType };
720
+ }
724
721
 
725
- if (options?.buildManifest) {
726
- runtimeOptions.buildManifest = {
727
- entries: options.buildManifest,
728
- strict: false,
729
- };
722
+ private static toMalloyConfig(input: ModelConnectionInput): MalloyConfig {
723
+ if (input instanceof MalloyConfig) {
724
+ return input;
730
725
  }
731
726
 
732
- const runtime = new Runtime(runtimeOptions);
733
- const dataStyles = urlReader.getHackyAccumulatedDataStyles();
734
- return { runtime, modelURL, importBaseURL, dataStyles, modelType };
727
+ const malloyConfig = new MalloyConfig({ connections: {} });
728
+ malloyConfig.wrapConnections(
729
+ () => new FixedConnectionMap(input, "duckdb"),
730
+ );
731
+ return malloyConfig;
735
732
  }
736
733
 
737
734
  private static getQueries(
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
2
  import { Stats } from "fs";
3
3
  import fs from "fs/promises";
4
- import { join } from "path";
4
+ import { join, resolve } from "path";
5
5
  import sinon from "sinon";
6
6
  import { PackageNotFoundError } from "../errors";
7
7
  import { Model } from "./model";
@@ -155,6 +155,130 @@ describe("service/package", () => {
155
155
  },
156
156
  { timeout: 20000 },
157
157
  );
158
+
159
+ it(
160
+ "uses package-root-relative DuckDB file paths for nested models",
161
+ async () => {
162
+ await fs.mkdir(join(testPackageDirectory, "data"), {
163
+ recursive: true,
164
+ });
165
+ await fs.mkdir(join(testPackageDirectory, "models"), {
166
+ recursive: true,
167
+ });
168
+ await fs.writeFile(
169
+ join(testPackageDirectory, "data", "root.csv"),
170
+ "name,value\nalpha,1\nbeta,2\n",
171
+ );
172
+ await fs.writeFile(
173
+ join(testPackageDirectory, "models", "root_read.malloy"),
174
+ [
175
+ "source: rows is duckdb.table('data/root.csv')",
176
+ "query: row_count is rows -> { aggregate: c is count() }",
177
+ ].join("\n"),
178
+ );
179
+
180
+ const absolutePackageDirectory = resolve(testPackageDirectory);
181
+ const packageInstance = await Package.create(
182
+ "testProject",
183
+ "testPackage",
184
+ absolutePackageDirectory,
185
+ new Map(),
186
+ );
187
+ try {
188
+ const rootModel = packageInstance.getModel(
189
+ "models/root_read.malloy",
190
+ );
191
+ expect(rootModel).toBeDefined();
192
+ const rootResults = await rootModel!.getQueryResults(
193
+ undefined,
194
+ "row_count",
195
+ );
196
+ expect(
197
+ (rootResults.compactResult as { c: number }[])[0]?.c,
198
+ ).toBe(2);
199
+ } finally {
200
+ await packageInstance.getMalloyConfig().releaseConnections();
201
+ }
202
+ },
203
+ { timeout: 20000 },
204
+ );
205
+
206
+ it(
207
+ "does not resolve DuckDB file paths relative to the model directory",
208
+ async () => {
209
+ await fs.mkdir(join(testPackageDirectory, "models"), {
210
+ recursive: true,
211
+ });
212
+ await fs.writeFile(
213
+ join(testPackageDirectory, "models", "sibling.csv"),
214
+ "name,value\nnested,3\n",
215
+ );
216
+ await fs.writeFile(
217
+ join(testPackageDirectory, "models", "sibling_read.malloy"),
218
+ [
219
+ "source: rows is duckdb.table('./sibling.csv')",
220
+ "query: row_count is rows -> { aggregate: c is count() }",
221
+ ].join("\n"),
222
+ );
223
+
224
+ await expect(
225
+ Package.create(
226
+ "testProject",
227
+ "testPackage",
228
+ resolve(testPackageDirectory),
229
+ new Map(),
230
+ ),
231
+ ).rejects.toThrow();
232
+ },
233
+ { timeout: 20000 },
234
+ );
235
+
236
+ it(
237
+ "does not treat package-root paths as filesystem isolation",
238
+ async () => {
239
+ const outsidePath = resolve(
240
+ testPackageDirectory,
241
+ "..",
242
+ "outside-package.csv",
243
+ );
244
+ await fs.mkdir(join(testPackageDirectory, "models"), {
245
+ recursive: true,
246
+ });
247
+ await fs.writeFile(outsidePath, "name,value\noutside,9\n");
248
+ await fs.writeFile(
249
+ join(testPackageDirectory, "models", "outside_read.malloy"),
250
+ [
251
+ "source: rows is duckdb.table('../outside-package.csv')",
252
+ "query: row_count is rows -> { aggregate: c is count() }",
253
+ ].join("\n"),
254
+ );
255
+
256
+ let packageInstance: Package | undefined;
257
+ try {
258
+ packageInstance = await Package.create(
259
+ "testProject",
260
+ "testPackage",
261
+ resolve(testPackageDirectory),
262
+ new Map(),
263
+ );
264
+ const outsideModel = packageInstance.getModel(
265
+ "models/outside_read.malloy",
266
+ );
267
+ expect(outsideModel).toBeDefined();
268
+ const outsideResults = await outsideModel!.getQueryResults(
269
+ undefined,
270
+ "row_count",
271
+ );
272
+ expect(
273
+ (outsideResults.compactResult as { c: number }[])[0]?.c,
274
+ ).toBe(1);
275
+ } finally {
276
+ await packageInstance?.getMalloyConfig().releaseConnections();
277
+ await fs.rm(outsidePath, { force: true });
278
+ }
279
+ },
280
+ { timeout: 20000 },
281
+ );
158
282
  });
159
283
 
160
284
  describe("listModels", () => {