@malloy-publisher/server 0.0.192 → 0.0.194

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 (44) hide show
  1. package/build.ts +1 -0
  2. package/dist/app/api-doc.yaml +558 -1
  3. package/dist/app/assets/{HomePage-H1OH-VW5.js → HomePage-DbZS0N7G.js} +1 -1
  4. package/dist/app/assets/MainPage-CBuWkbmr.js +2 -0
  5. package/dist/app/assets/{ModelPage-Crau5hgZ.js → ModelPage-Bt37smot.js} +1 -1
  6. package/dist/app/assets/{PackagePage-CbubRhgE.js → PackagePage-DLZe50WG.js} +1 -1
  7. package/dist/app/assets/{ProjectPage-DUlJkYJ4.js → ProjectPage-FQTEPXP4.js} +1 -1
  8. package/dist/app/assets/{RouteError-DrNXNihc.js → RouteError-DefbDO7F.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-CBBv7n5U.js → WorkbookPage-CkAo16ar.js} +1 -1
  10. package/dist/app/assets/{core-Dzx75uJR.es-DwnFZnyO.js → core-BrfQApxh.es-DnvCX4oH.js} +14 -14
  11. package/dist/app/assets/index-5eLCcNmP.css +1 -0
  12. package/dist/app/assets/{index-d5rvmoZ7.js → index-Bu0ub036.js} +119 -119
  13. package/dist/app/assets/index-CkzK3JIl.js +40 -0
  14. package/dist/app/assets/index-CoA6HIGS.js +1742 -0
  15. package/dist/app/assets/{index.umd-CetYIBQY.js → index.umd-B6Ms2PpL.js} +46 -46
  16. package/dist/app/index.html +2 -2
  17. package/dist/server.mjs +1529 -985
  18. package/package.json +11 -10
  19. package/src/config.ts +7 -2
  20. package/src/controller/connection.controller.ts +102 -27
  21. package/src/dto/connection.dto.spec.ts +55 -0
  22. package/src/dto/connection.dto.ts +87 -2
  23. package/src/server.ts +201 -2
  24. package/src/service/connection.spec.ts +250 -4
  25. package/src/service/connection.ts +328 -473
  26. package/src/service/connection_config.spec.ts +123 -0
  27. package/src/service/connection_config.ts +562 -0
  28. package/src/service/connection_service.spec.ts +50 -0
  29. package/src/service/connection_service.ts +125 -32
  30. package/src/service/db_utils.spec.ts +161 -0
  31. package/src/service/db_utils.ts +131 -0
  32. package/src/service/materialization_service.spec.ts +18 -12
  33. package/src/service/materialization_service.ts +54 -7
  34. package/src/service/model.ts +24 -27
  35. package/src/service/package.spec.ts +125 -1
  36. package/src/service/package.ts +86 -44
  37. package/src/service/project.ts +172 -94
  38. package/src/service/project_store.spec.ts +72 -0
  39. package/src/service/project_store.ts +98 -81
  40. package/tests/unit/duckdb/attached_databases.test.ts +1 -19
  41. package/dist/app/assets/MainPage-GL06aMke.js +0 -2
  42. package/dist/app/assets/index-CMlGQMcl.css +0 -1
  43. package/dist/app/assets/index-CzjyS9cx.js +0 -1276
  44. package/dist/app/assets/index-HHdhLUpv.js +0 -676
@@ -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(
@@ -337,6 +337,78 @@ describe("ProjectStore Service", () => {
337
337
  expect(projects.map((p) => p.name)).toContain(projectName2);
338
338
  });
339
339
 
340
+ it("should skip a project with invalid startup connection config", async () => {
341
+ const validProjectName = "valid-project";
342
+ const invalidProjectName = "invalid-motherduck-project";
343
+ const validProjectPath = path.join(serverRootPath, validProjectName);
344
+ const invalidProjectPath = path.join(serverRootPath, invalidProjectName);
345
+
346
+ mkdirSync(validProjectPath, { recursive: true });
347
+ mkdirSync(invalidProjectPath, { recursive: true });
348
+ writeFileSync(
349
+ path.join(validProjectPath, "publisher.json"),
350
+ JSON.stringify({
351
+ name: validProjectName,
352
+ description: "Valid project",
353
+ }),
354
+ );
355
+ writeFileSync(
356
+ path.join(invalidProjectPath, "publisher.json"),
357
+ JSON.stringify({
358
+ name: invalidProjectName,
359
+ description: "Invalid project",
360
+ }),
361
+ );
362
+
363
+ const publisherConfigPath = path.join(
364
+ serverRootPath,
365
+ "publisher.config.json",
366
+ );
367
+ writeFileSync(
368
+ publisherConfigPath,
369
+ JSON.stringify({
370
+ frozenConfig: false,
371
+ projects: [
372
+ {
373
+ name: invalidProjectName,
374
+ packages: [
375
+ {
376
+ name: invalidProjectName,
377
+ location: invalidProjectPath,
378
+ },
379
+ ],
380
+ connections: [
381
+ {
382
+ name: "motherduck",
383
+ type: "motherduck",
384
+ motherduckConnection: {},
385
+ },
386
+ ],
387
+ },
388
+ {
389
+ name: validProjectName,
390
+ packages: [
391
+ {
392
+ name: validProjectName,
393
+ location: validProjectPath,
394
+ },
395
+ ],
396
+ connections: [],
397
+ },
398
+ ],
399
+ }),
400
+ );
401
+
402
+ const newProjectStore = new ProjectStore(serverRootPath);
403
+ await newProjectStore.finishedInitialization;
404
+
405
+ const projects = await newProjectStore.listProjects();
406
+ expect(projects.map((p) => p.name)).toEqual([validProjectName]);
407
+ await expect(
408
+ newProjectStore.getProject(invalidProjectName),
409
+ ).rejects.toThrow();
410
+ });
411
+
340
412
  it("should handle project updates", async () => {
341
413
  // Create a project directory
342
414
  const projectPath = path.join(serverRootPath, projectName);