@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
@@ -2,10 +2,14 @@ import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
3
 
4
4
  import { DuckDBConnection } from "@malloydata/db-duckdb";
5
+ import "@malloydata/db-duckdb/native";
5
6
  import {
6
7
  Connection,
7
8
  ConnectionRuntime,
8
9
  EmptyURLReader,
10
+ FixedConnectionMap,
11
+ contextOverlay,
12
+ MalloyConfig,
9
13
  SourceDef,
10
14
  } from "@malloydata/malloy";
11
15
  import { metrics } from "@opentelemetry/api";
@@ -28,6 +32,14 @@ type ApiNotebook = components["schemas"]["Notebook"];
28
32
  export type ApiPackage = components["schemas"]["Package"];
29
33
  type ApiColumn = components["schemas"]["Column"];
30
34
  type ApiTableDescription = components["schemas"]["TableDescription"];
35
+ // A thunk lets callers pass a live reference to the *current* project
36
+ // MalloyConfig so the package wrapper resolves project connections against the
37
+ // generation that's active at lookup time, not the one that was current when
38
+ // the package was first loaded.
39
+ type PackageConnectionInput =
40
+ | MalloyConfig
41
+ | Map<string, Connection>
42
+ | (() => MalloyConfig);
31
43
 
32
44
  const ENABLE_LIST_MODEL_COMPILATION = true;
33
45
  export class Package {
@@ -37,7 +49,7 @@ export class Package {
37
49
  private databases: ApiDatabase[];
38
50
  private models: Map<string, Model> = new Map();
39
51
  private packagePath: string;
40
- private connections: Map<string, Connection> = new Map();
52
+ private malloyConfig: MalloyConfig;
41
53
  private static meter = metrics.getMeter("publisher");
42
54
  private static packageLoadHistogram = this.meter.createHistogram(
43
55
  "malloy_package_load_duration",
@@ -54,7 +66,7 @@ export class Package {
54
66
  packageMetadata: ApiPackage,
55
67
  databases: ApiDatabase[],
56
68
  models: Map<string, Model>,
57
- connections: Map<string, Connection> = new Map(),
69
+ malloyConfig: MalloyConfig = new MalloyConfig({ connections: {} }),
58
70
  ) {
59
71
  this.projectName = projectName;
60
72
  this.packageName = packageName;
@@ -62,14 +74,14 @@ export class Package {
62
74
  this.packageMetadata = packageMetadata;
63
75
  this.databases = databases;
64
76
  this.models = models;
65
- this.connections = connections;
77
+ this.malloyConfig = malloyConfig;
66
78
  }
67
79
 
68
80
  static async create(
69
81
  projectName: string,
70
82
  packageName: string,
71
83
  packagePath: string,
72
- projectConnections: Map<string, Connection>,
84
+ projectMalloyConfig: PackageConnectionInput,
73
85
  ): Promise<Package> {
74
86
  const startTime = performance.now();
75
87
  await Package.validatePackageManifestExistsOrThrowError(packagePath);
@@ -97,20 +109,17 @@ export class Package {
97
109
  databaseCount: databases.length,
98
110
  duration: formatDuration(databasesTime - packageConfigTime),
99
111
  });
100
- const connections = new Map<string, Connection>(projectConnections);
101
-
102
- // Add a duckdb connection for the package.
103
- const duckdbConnection = new DuckDBConnection(
104
- "duckdb",
105
- ":memory:",
112
+ const malloyConfig = Package.buildPackageMalloyConfig(
106
113
  packagePath,
114
+ typeof projectMalloyConfig === "function"
115
+ ? projectMalloyConfig
116
+ : () => Package.toMalloyConfig(projectMalloyConfig),
107
117
  );
108
- connections.set("duckdb", duckdbConnection);
109
118
 
110
119
  const models = await Package.loadModels(
111
120
  packageName,
112
121
  packagePath,
113
- connections,
122
+ malloyConfig,
114
123
  );
115
124
  const modelsTime = performance.now();
116
125
  logger.info("Models loaded", {
@@ -159,7 +168,7 @@ export class Package {
159
168
  packageConfig,
160
169
  databases,
161
170
  models,
162
- connections,
171
+ malloyConfig,
163
172
  );
164
173
  } catch (error) {
165
174
  logger.error(`Error loading package ${packageName}`, { error });
@@ -190,10 +199,6 @@ export class Package {
190
199
  return this.packageName;
191
200
  }
192
201
 
193
- public getPackagePath(): string {
194
- return this.packagePath;
195
- }
196
-
197
202
  public getPackageMetadata(): ApiPackage {
198
203
  return this.packageMetadata;
199
204
  }
@@ -206,19 +211,24 @@ export class Package {
206
211
  return this.models.get(modelPath);
207
212
  }
208
213
 
214
+ public async getMalloyConnection(
215
+ connectionName: string,
216
+ ): Promise<Connection> {
217
+ return this.malloyConfig.connections.lookupConnection(connectionName);
218
+ }
219
+
220
+ public getMalloyConfig(): MalloyConfig {
221
+ return this.malloyConfig;
222
+ }
223
+
224
+ public getPackagePath(): string {
225
+ return this.packagePath;
226
+ }
227
+
209
228
  public getModelPaths(): string[] {
210
229
  return Array.from(this.models.keys());
211
230
  }
212
231
 
213
- /**
214
- * Recompile every model in the package with the given build manifest
215
- * so queries resolve persist references to materialized tables.
216
- *
217
- * Builds a fresh map off to the side and swaps it in at the end. If any
218
- * recompile fails the whole call rejects before the swap and the live
219
- * `this.models` reference remains untouched — no half-loaded state is
220
- * ever observable to concurrent readers.
221
- */
222
232
  public async reloadAllModels(
223
233
  buildManifest: BuildManifest["entries"],
224
234
  ): Promise<void> {
@@ -228,14 +238,13 @@ export class Package {
228
238
  modelCount: modelPaths.length,
229
239
  manifestEntryCount: Object.keys(buildManifest).length,
230
240
  });
231
-
232
241
  const reloaded = await Promise.all(
233
242
  modelPaths.map((modelPath) =>
234
243
  Model.create(
235
244
  this.packageName,
236
245
  this.packagePath,
237
246
  modelPath,
238
- this.connections,
247
+ this.malloyConfig,
239
248
  { buildManifest },
240
249
  ),
241
250
  ),
@@ -247,20 +256,6 @@ export class Package {
247
256
  this.models = nextModels;
248
257
  }
249
258
 
250
- public getConnections(): Map<string, Connection> {
251
- return this.connections;
252
- }
253
-
254
- public getMalloyConnection(connectionName: string): Connection {
255
- const connection = this.connections.get(connectionName);
256
- if (!connection) {
257
- throw new Error(
258
- `Connection ${connectionName} not found in package ${this.packageName}`,
259
- );
260
- }
261
- return connection;
262
- }
263
-
264
259
  public async getModelFileText(modelPath: string): Promise<string> {
265
260
  const model = this.getModel(modelPath);
266
261
  if (!model) {
@@ -322,17 +317,64 @@ export class Package {
322
317
  private static async loadModels(
323
318
  packageName: string,
324
319
  packagePath: string,
325
- connections: Map<string, Connection>,
320
+ malloyConfig: MalloyConfig,
326
321
  ): Promise<Map<string, Model>> {
327
322
  const modelPaths = await Package.getModelPaths(packagePath);
328
323
  const models = await Promise.all(
329
324
  modelPaths.map((modelPath) =>
330
- Model.create(packageName, packagePath, modelPath, connections),
325
+ Model.create(packageName, packagePath, modelPath, malloyConfig),
331
326
  ),
332
327
  );
333
328
  return new Map(models.map((model) => [model.getPath(), model]));
334
329
  }
335
330
 
331
+ private static buildPackageMalloyConfig(
332
+ packagePath: string,
333
+ getProjectMalloyConfig: () => MalloyConfig,
334
+ ): MalloyConfig {
335
+ const malloyConfig = new MalloyConfig(
336
+ {
337
+ connections: {
338
+ duckdb: {
339
+ is: "duckdb",
340
+ databasePath: ":memory:",
341
+ },
342
+ },
343
+ },
344
+ {
345
+ config: contextOverlay({ rootDirectory: packagePath }),
346
+ },
347
+ );
348
+
349
+ malloyConfig.wrapConnections((base) => ({
350
+ lookupConnection: async (name?: string) => {
351
+ if (!name || name === "duckdb") {
352
+ return base.lookupConnection(name);
353
+ }
354
+ // Resolve against the *current* project MalloyConfig so a
355
+ // connection-generation swap on Project propagates without a
356
+ // package reload.
357
+ return getProjectMalloyConfig().connections.lookupConnection(name);
358
+ },
359
+ }));
360
+
361
+ return malloyConfig;
362
+ }
363
+
364
+ private static toMalloyConfig(
365
+ input: MalloyConfig | Map<string, Connection>,
366
+ ): MalloyConfig {
367
+ if (input instanceof MalloyConfig) {
368
+ return input;
369
+ }
370
+
371
+ const malloyConfig = new MalloyConfig({ connections: {} });
372
+ malloyConfig.wrapConnections(
373
+ () => new FixedConnectionMap(input, "duckdb"),
374
+ );
375
+ return malloyConfig;
376
+ }
377
+
336
378
  private static async getModelPaths(packagePath: string): Promise<string[]> {
337
379
  let files = undefined;
338
380
  try {
@@ -1,6 +1,5 @@
1
1
  import type { LogMessage } from "@malloydata/malloy";
2
- import { FixedConnectionMap, MalloyError, Runtime } from "@malloydata/malloy";
3
- import { BaseConnection } from "@malloydata/malloy/connection";
2
+ import { MalloyError, Runtime } from "@malloydata/malloy";
4
3
  import { Mutex } from "async-mutex";
5
4
  import * as fs from "fs";
6
5
  import * as path from "path";
@@ -14,9 +13,10 @@ import {
14
13
  import { logger } from "../logger";
15
14
  import { URL_READER } from "../utils";
16
15
  import {
17
- createProjectConnections,
16
+ buildProjectMalloyConfig,
18
17
  deleteDuckLakeConnectionFile,
19
18
  InternalConnection,
19
+ ProjectMalloyConfig,
20
20
  } from "./connection";
21
21
  import { ApiConnection } from "./model";
22
22
  import { Package } from "./package";
@@ -35,12 +35,27 @@ interface PackageInfo {
35
35
 
36
36
  type ApiPackage = components["schemas"]["Package"];
37
37
  type ApiProject = components["schemas"]["Project"];
38
+ type RetiredConnectionGeneration = {
39
+ label: string;
40
+ releaseConnections: () => Promise<void>;
41
+ timer?: ReturnType<typeof setTimeout>;
42
+ };
43
+
44
+ const RETIRED_CONNECTION_DRAIN_MS = 30_000;
38
45
 
39
46
  export class Project {
40
47
  private packages: Map<string, Package> = new Map();
48
+ // Lock ordering: connectionMutex (project) MUST be acquired before any
49
+ // packageMutex. Connection updates may invalidate cached package
50
+ // MalloyConfigs and force reloads, so the project lock is the outer one.
51
+ // Never acquire connectionMutex while holding a packageMutex — that's the
52
+ // AB/BA deadlock path.
41
53
  private packageMutexes = new Map<string, Mutex>();
42
54
  private packageStatuses: Map<string, PackageInfo> = new Map();
43
- private malloyConnections: Map<string, BaseConnection>;
55
+ private malloyConfig: ProjectMalloyConfig;
56
+ private connectionMutex = new Mutex();
57
+ private retiredConnectionGenerations =
58
+ new Set<RetiredConnectionGeneration>();
44
59
  private apiConnections: ApiConnection[];
45
60
  private projectPath: string;
46
61
  private projectName: string;
@@ -49,12 +64,12 @@ export class Project {
49
64
  constructor(
50
65
  projectName: string,
51
66
  projectPath: string,
52
- malloyConnections: Map<string, BaseConnection>,
67
+ malloyConfig: ProjectMalloyConfig,
53
68
  apiConnections: InternalConnection[],
54
69
  ) {
55
70
  this.projectName = projectName;
56
71
  this.projectPath = projectPath;
57
- this.malloyConnections = malloyConnections;
72
+ this.malloyConfig = malloyConfig;
58
73
  this.apiConnections = apiConnections;
59
74
  this.metadata = {
60
75
  resource: `${API_PREFIX}/projects/${this.projectName}`,
@@ -87,30 +102,28 @@ export class Project {
87
102
  // Handle connections update
88
103
  // TODO: Update project connections should have its own API endpoint
89
104
  if (payload.connections) {
90
- logger.info(
91
- `Updating ${payload.connections.length} connections for project ${this.projectName}`,
92
- );
93
- const isUpdateConnectionRequest = true;
94
- // Reload connections with full config
95
- const { malloyConnections, apiConnections } =
96
- await createProjectConnections(
97
- payload.connections,
105
+ const payloadConnections = payload.connections;
106
+ await this.runConnectionUpdateExclusive(async () => {
107
+ logger.info(
108
+ `Updating ${payloadConnections.length} connections for project ${this.projectName}`,
109
+ );
110
+ const isUpdateConnectionRequest = true;
111
+ const nextMalloyConfig = buildProjectMalloyConfig(
112
+ payloadConnections,
98
113
  this.projectPath,
99
114
  isUpdateConnectionRequest,
100
115
  );
101
116
 
102
- // Update the project's connection maps
103
- this.malloyConnections = malloyConnections;
104
- this.apiConnections = apiConnections;
117
+ this.updateConnections(nextMalloyConfig);
105
118
 
106
- logger.info(
107
- `Successfully updated connections for project ${this.projectName}`,
108
- {
109
- malloyConnections: malloyConnections.size,
110
- apiConnections: apiConnections.length,
111
- internalConnections: apiConnections.length,
112
- },
113
- );
119
+ logger.info(
120
+ `Successfully updated connections for project ${this.projectName}`,
121
+ {
122
+ apiConnections: this.apiConnections.length,
123
+ internalConnections: this.apiConnections.length,
124
+ },
125
+ );
126
+ });
114
127
  }
115
128
 
116
129
  return this;
@@ -128,22 +141,20 @@ export class Project {
128
141
  }
129
142
 
130
143
  logger.info(`Creating project with connection configuration`);
131
- const { malloyConnections, apiConnections } =
132
- await createProjectConnections(connections, projectPath);
144
+ const malloyConfig = buildProjectMalloyConfig(connections, projectPath);
133
145
 
134
146
  logger.info(
135
- `Loaded ${malloyConnections.size + apiConnections.length} connections for project ${projectName}`,
147
+ `Loaded ${malloyConfig.apiConnections.length} connections for project ${projectName}`,
136
148
  {
137
- malloyConnections,
138
- apiConnections,
149
+ apiConnections: malloyConfig.apiConnections,
139
150
  },
140
151
  );
141
152
 
142
153
  const project = new Project(
143
154
  projectName,
144
155
  projectPath,
145
- malloyConnections,
146
- apiConnections,
156
+ malloyConfig,
157
+ malloyConfig.apiConnections,
147
158
  );
148
159
 
149
160
  return project;
@@ -203,10 +214,14 @@ export class Project {
203
214
  },
204
215
  };
205
216
 
206
- // Initialize Runtime with the project's active connections
217
+ const pkg = await this.getPackage(packageName);
218
+
219
+ // Initialize Runtime with the package's active MalloyConfig so compile
220
+ // checks see the same package-scoped duckdb as execution. This runtime
221
+ // borrows the package config; the package/project lifecycle owns release.
207
222
  const runtime = new Runtime({
208
223
  urlReader: interceptingReader,
209
- connections: new FixedConnectionMap(this.malloyConnections, "duckdb"),
224
+ config: pkg.getMalloyConfig(),
210
225
  });
211
226
 
212
227
  // Attempt to compile
@@ -254,14 +269,66 @@ export class Project {
254
269
  return connection;
255
270
  }
256
271
 
257
- public getMalloyConnection(connectionName: string): BaseConnection {
258
- const connection = this.malloyConnections.get(connectionName);
259
- if (!connection) {
260
- throw new ConnectionNotFoundError(
261
- `Connection ${connectionName} not found`,
272
+ public async getMalloyConnection(connectionName: string) {
273
+ return this.malloyConfig.malloyConfig.connections.lookupConnection(
274
+ connectionName,
275
+ );
276
+ }
277
+
278
+ public getProjectMalloyConfig() {
279
+ return this.malloyConfig.malloyConfig;
280
+ }
281
+
282
+ public async runConnectionUpdateExclusive<T>(
283
+ fn: () => Promise<T>,
284
+ ): Promise<T> {
285
+ return this.connectionMutex.runExclusive(fn);
286
+ }
287
+
288
+ private retireConnectionGeneration(
289
+ label: string,
290
+ releaseConnections: () => Promise<void>,
291
+ ): void {
292
+ const generation: RetiredConnectionGeneration = {
293
+ label,
294
+ releaseConnections,
295
+ };
296
+ generation.timer = setTimeout(() => {
297
+ void this.releaseRetiredConnectionGeneration(generation);
298
+ }, RETIRED_CONNECTION_DRAIN_MS);
299
+ (
300
+ generation.timer as ReturnType<typeof setTimeout> & {
301
+ unref?: () => void;
302
+ }
303
+ ).unref?.();
304
+ this.retiredConnectionGenerations.add(generation);
305
+ }
306
+
307
+ private async releaseRetiredConnectionGeneration(
308
+ generation: RetiredConnectionGeneration,
309
+ ): Promise<void> {
310
+ if (!this.retiredConnectionGenerations.delete(generation)) return;
311
+
312
+ if (generation.timer) {
313
+ clearTimeout(generation.timer);
314
+ }
315
+
316
+ try {
317
+ await generation.releaseConnections();
318
+ } catch (error) {
319
+ logger.error(
320
+ `Error releasing retired connection generation ${generation.label}`,
321
+ { error },
262
322
  );
263
323
  }
264
- return connection;
324
+ }
325
+
326
+ private async releaseAllRetiredConnectionGenerations(): Promise<void> {
327
+ await Promise.all(
328
+ [...this.retiredConnectionGenerations].map((generation) =>
329
+ this.releaseRetiredConnectionGeneration(generation),
330
+ ),
331
+ );
265
332
  }
266
333
 
267
334
  public async listPackages(): Promise<ApiPackage[]> {
@@ -356,8 +423,13 @@ export class Project {
356
423
  this.projectName,
357
424
  packageName,
358
425
  packagePath,
359
- this.malloyConnections,
426
+ () => this.malloyConfig.malloyConfig,
360
427
  );
428
+ if (existingPackage !== undefined && reload) {
429
+ this.retireConnectionGeneration(`package ${packageName}`, () =>
430
+ existingPackage.getMalloyConfig().releaseConnections(),
431
+ );
432
+ }
361
433
  this.packages.set(packageName, _package);
362
434
 
363
435
  // Set package status to serving
@@ -391,7 +463,7 @@ export class Project {
391
463
  `Adding package ${packageName} to project ${this.projectName}`,
392
464
  {
393
465
  packagePath,
394
- malloyConnections: this.malloyConnections,
466
+ malloyConfig: this.malloyConfig.malloyConfig,
395
467
  },
396
468
  );
397
469
  this.setPackageStatus(packageName, PackageStatus.LOADING);
@@ -402,7 +474,7 @@ export class Project {
402
474
  this.projectName,
403
475
  packageName,
404
476
  packagePath,
405
- this.malloyConnections,
477
+ () => this.malloyConfig.malloyConfig,
406
478
  ),
407
479
  );
408
480
  } catch (error) {
@@ -514,6 +586,8 @@ export class Project {
514
586
  this.setPackageStatus(packageName, PackageStatus.UNLOADING);
515
587
  }
516
588
 
589
+ await _package.getMalloyConfig().releaseConnections();
590
+
517
591
  try {
518
592
  await fs.promises.rm(path.join(this.projectPath, packageName), {
519
593
  recursive: true,
@@ -532,33 +606,37 @@ export class Project {
532
606
  }
533
607
 
534
608
  public updateConnections(
535
- malloyConnections: Map<string, BaseConnection>,
536
- apiConnections: ApiConnection[],
609
+ malloyConfig: ProjectMalloyConfig,
610
+ _apiConnections?: ApiConnection[],
611
+ afterPreviousRelease?: () => Promise<void>,
537
612
  ): void {
538
- this.malloyConnections = malloyConnections;
539
- this.apiConnections = apiConnections;
613
+ const previousMalloyConfig = this.malloyConfig;
614
+ this.malloyConfig = malloyConfig;
615
+ this.apiConnections = malloyConfig.apiConnections;
616
+
617
+ if (previousMalloyConfig !== malloyConfig) {
618
+ this.retireConnectionGeneration(
619
+ `project ${this.projectName}`,
620
+ async () => {
621
+ await previousMalloyConfig.releaseConnections();
622
+ await afterPreviousRelease?.();
623
+ },
624
+ );
625
+ } else {
626
+ void afterPreviousRelease?.();
627
+ }
540
628
  }
541
629
 
542
630
  public async deleteConnection(connectionName: string): Promise<void> {
543
- this.malloyConnections.get(connectionName)?.close();
544
- const isDeleted = this.malloyConnections.delete(connectionName);
545
-
546
631
  const index = this.apiConnections.findIndex(
547
632
  (conn) => conn.name === connectionName,
548
633
  );
549
634
 
550
- const connectionType = this.apiConnections[index]?.type;
551
- if (connectionType === "duckdb") {
552
- await this.deleteDuckDBConnection(connectionName);
553
- } else if (connectionType === "ducklake") {
554
- await this.deleteDuckLakeConnection(connectionName);
555
- }
556
-
557
635
  if (index !== -1) {
558
636
  this.apiConnections.splice(index, 1);
559
637
  }
560
638
 
561
- if (isDeleted || index !== -1) {
639
+ if (index !== -1) {
562
640
  logger.info(
563
641
  `Removed connection ${connectionName} from project ${this.projectName}`,
564
642
  );
@@ -569,24 +647,36 @@ export class Project {
569
647
  }
570
648
  }
571
649
 
572
- public closeAllConnections(): void {
573
- // Close all Malloy connections
574
- for (const [connectionName, connection] of this.malloyConnections) {
575
- try {
576
- connection.close();
577
- logger.info(
578
- `Closed connection ${connectionName} for project ${this.projectName}`,
579
- );
580
- } catch (error) {
650
+ public async closeAllConnections(): Promise<void> {
651
+ // Release the package-scoped MalloyConfigs (each holds the package's own
652
+ // sandbox `duckdb` connection) before tearing down the project config
653
+ // they wrap. Without this, hard unload leaks per-package DuckDB handles.
654
+ const packageReleases = await Promise.allSettled(
655
+ Array.from(this.packages.values(), (pkg) =>
656
+ pkg.getMalloyConfig().releaseConnections(),
657
+ ),
658
+ );
659
+ for (const result of packageReleases) {
660
+ if (result.status === "rejected") {
581
661
  logger.error(
582
- `Error closing connection ${connectionName} for project ${this.projectName}`,
583
- { error },
662
+ `Error closing package connections for project ${this.projectName}`,
663
+ { error: result.reason },
584
664
  );
585
665
  }
586
666
  }
667
+ this.packages.clear();
668
+ this.packageStatuses.clear();
669
+
670
+ try {
671
+ await this.malloyConfig.releaseConnections();
672
+ } catch (error) {
673
+ logger.error(
674
+ `Error closing connections for project ${this.projectName}`,
675
+ { error },
676
+ );
677
+ }
678
+ await this.releaseAllRetiredConnectionGenerations();
587
679
 
588
- // Clear connection maps
589
- this.malloyConnections.clear();
590
680
  this.apiConnections = [];
591
681
 
592
682
  logger.info(`Closed all connections for project ${this.projectName}`);
@@ -605,29 +695,17 @@ export class Project {
605
695
  this.projectPath,
606
696
  `${connectionName}.duckdb`,
607
697
  );
608
- fs.promises
609
- .access(duckdbPath)
610
- .then(() => {
611
- fs.promises
612
- .rm(duckdbPath)
613
- .then(() => {
614
- logger.info(
615
- `Removed DuckDB connection file ${connectionName} from project ${this.projectName}`,
616
- );
617
- })
618
- .catch((error) => {
619
- logger.error(
620
- `Failed to remove DuckDB connection file ${connectionName} from project ${this.projectName}`,
621
- { error },
622
- );
623
- });
624
- })
625
- .catch((error) => {
626
- logger.error(
627
- `Failed to remove DuckDB connection file ${connectionName} from project ${this.projectName}`,
628
- { error },
629
- );
630
- });
698
+ try {
699
+ await fs.promises.rm(duckdbPath, { force: true });
700
+ logger.info(
701
+ `Removed DuckDB connection file ${connectionName} from project ${this.projectName}`,
702
+ );
703
+ } catch (error) {
704
+ logger.error(
705
+ `Failed to remove DuckDB connection file ${connectionName} from project ${this.projectName}`,
706
+ { error },
707
+ );
708
+ }
631
709
  }
632
710
 
633
711
  public async deleteDuckLakeConnection(