@malloy-publisher/server 0.0.197-dev → 0.0.197

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 (60) hide show
  1. package/README.docker.md +88 -20
  2. package/README.md +15 -0
  3. package/build.ts +16 -0
  4. package/dist/app/api-doc.yaml +20 -3
  5. package/dist/app/assets/EnvironmentPage-BVkQH_xQ.js +1 -0
  6. package/dist/app/assets/HomePage-BgH9UkjK.js +1 -0
  7. package/dist/app/assets/MainPage-DiBxABem.js +2 -0
  8. package/dist/app/assets/ModelPage-oS70fj83.js +1 -0
  9. package/dist/app/assets/PackagePage-F_qLDAdv.js +1 -0
  10. package/dist/app/assets/RouteError-WqpffppN.js +1 -0
  11. package/dist/app/assets/WorkbookPage-_YmC-ebR.js +1 -0
  12. package/dist/app/assets/{core-w79IMXAG.es-Bd0UlzOL.js → core-B8L9xCYT.es-BcRLJTnC.js} +14 -14
  13. package/dist/app/assets/index-BMViiwtJ.js +451 -0
  14. package/dist/app/assets/{index-C513UodQ.js → index-C3XPaTaS.js} +15 -15
  15. package/dist/app/assets/index-rg8Ok8nl.js +1803 -0
  16. package/dist/app/assets/{index.umd-BMeMPq_9.js → index.umd-CCAfKkxY.js} +1 -1
  17. package/dist/app/index.html +2 -3
  18. package/dist/default-publisher.config.json +23 -0
  19. package/dist/instrumentation.mjs +1 -3
  20. package/dist/server.mjs +958 -177
  21. package/package.json +11 -12
  22. package/publisher.config.example.bigquery.json +33 -0
  23. package/publisher.config.example.duckdb.json +23 -0
  24. package/publisher.config.json +1 -11
  25. package/src/config.spec.ts +225 -0
  26. package/src/config.ts +96 -2
  27. package/src/controller/connection.controller.ts +1 -1
  28. package/src/default-publisher.config.json +23 -0
  29. package/src/errors.spec.ts +42 -0
  30. package/src/errors.ts +8 -0
  31. package/src/health.ts +26 -0
  32. package/src/logger.ts +1 -3
  33. package/src/pg_helpers.spec.ts +226 -0
  34. package/src/pg_helpers.ts +129 -0
  35. package/src/server-old.ts +1119 -0
  36. package/src/server.ts +36 -0
  37. package/src/service/connection.spec.ts +6 -4
  38. package/src/service/connection.ts +8 -3
  39. package/src/service/connection_config.ts +2 -2
  40. package/src/service/environment.ts +53 -25
  41. package/src/service/environment_store.spec.ts +19 -0
  42. package/src/service/environment_store.ts +21 -2
  43. package/src/service/package.ts +4 -3
  44. package/src/storage/StorageManager.ts +71 -11
  45. package/src/storage/duckdb/schema.ts +41 -0
  46. package/src/utils.ts +11 -0
  47. package/tests/harness/rest_e2e.ts +2 -2
  48. package/tests/integration/legacy_routes/legacy_routes.integration.spec.ts +259 -0
  49. package/tests/unit/duckdb/attached_databases.test.ts +5 -5
  50. package/tests/unit/duckdb/legacy_schema_migration.test.ts +194 -0
  51. package/tests/unit/storage/StorageManager.test.ts +166 -0
  52. package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
  53. package/dist/app/assets/HomePage-DMop21VG.js +0 -1
  54. package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
  55. package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
  56. package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
  57. package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
  58. package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
  59. package/dist/app/assets/index-5K9YjIxF.js +0 -456
  60. package/dist/app/assets/index-DIgzgp69.js +0 -1742
package/src/server.ts CHANGED
@@ -36,6 +36,7 @@ import { logger, loggerMiddleware } from "./logger";
36
36
  import { ManifestController } from "./controller/manifest.controller";
37
37
  import { MaterializationController } from "./controller/materialization.controller";
38
38
  import { initializeMcpServer } from "./mcp/server";
39
+ import { registerLegacyRoutes } from "./server-old";
39
40
  import { EnvironmentStore } from "./service/environment_store";
40
41
  import { ManifestService } from "./service/manifest_service";
41
42
  import { MaterializationService } from "./service/materialization_service";
@@ -50,6 +51,8 @@ export function normalizeQueryArray(value: unknown): string[] | undefined {
50
51
  // Parse command line arguments
51
52
  function parseArgs() {
52
53
  const args = process.argv.slice(2);
54
+ let sawServerRoot = false;
55
+ let sawConfig = false;
53
56
  for (let i = 0; i < args.length; i++) {
54
57
  const arg = args[i];
55
58
  if (arg === "--port" && args[i + 1]) {
@@ -59,8 +62,13 @@ function parseArgs() {
59
62
  process.env.PUBLISHER_HOST = args[i + 1];
60
63
  i++;
61
64
  } else if (arg === "--server_root" && args[i + 1]) {
65
+ sawServerRoot = true;
62
66
  process.env.SERVER_ROOT = args[i + 1];
63
67
  i++;
68
+ } else if (arg === "--config" && args[i + 1]) {
69
+ sawConfig = true;
70
+ process.env.PUBLISHER_CONFIG_PATH = args[i + 1];
71
+ i++;
64
72
  } else if (arg === "--mcp_port" && args[i + 1]) {
65
73
  process.env.MCP_PORT = args[i + 1];
66
74
  i++;
@@ -90,6 +98,9 @@ function parseArgs() {
90
98
  console.log(
91
99
  " --server_root <path> Root directory to serve files from (default: .)",
92
100
  );
101
+ console.log(
102
+ " --config <path> Path to publisher.config.json (default: <server_root>/publisher.config.json; falls back to bundled DuckDB-only sample config if missing)",
103
+ );
93
104
  console.log(
94
105
  " --mcp_port <number> Port for MCP server (default: 4040)",
95
106
  );
@@ -106,6 +117,16 @@ function parseArgs() {
106
117
  process.exit(0);
107
118
  }
108
119
  }
120
+ // Zero-config invocation (`npx @malloy-publisher/server`) opts in to
121
+ // the bundled DuckDB-only sample config so the Quick Start works
122
+ // without any flags. Any explicit --server_root or --config disables
123
+ // this — the user told us where to look. Skip in NODE_ENV=test so
124
+ // specs that import this module for utility helpers (e.g.
125
+ // db_utils.spec.ts -> normalizeQueryArray) don't get the bundled
126
+ // default leaked into their EnvironmentStore construction.
127
+ if (!sawServerRoot && !sawConfig && process.env.NODE_ENV !== "test") {
128
+ process.env.PUBLISHER_USE_BUNDLED_DEFAULT = "true";
129
+ }
109
130
  }
110
131
 
111
132
  // Parse CLI arguments before setting up constants
@@ -1364,6 +1385,21 @@ app.post(
1364
1385
  },
1365
1386
  );
1366
1387
 
1388
+ // Register legacy `/projects/...` routes for backwards compatibility with
1389
+ // clients that haven't migrated to `/environments/...` yet. Must be added
1390
+ // before the SPA catch-all below.
1391
+ registerLegacyRoutes(app, {
1392
+ environmentStore,
1393
+ connectionController,
1394
+ modelController,
1395
+ packageController,
1396
+ databaseController,
1397
+ queryController,
1398
+ compileController,
1399
+ materializationController,
1400
+ manifestController,
1401
+ });
1402
+
1367
1403
  // Modify the catch-all route to only serve index.html in production
1368
1404
  if (!isDevelopment) {
1369
1405
  app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));
@@ -1129,10 +1129,14 @@ describe("connection integration tests", () => {
1129
1129
  ],
1130
1130
  testEnvironmentPath,
1131
1131
  ),
1132
- ).rejects.toThrow(/cannot be 'duckdb'/);
1132
+ ).rejects.toThrow(/'duckdb' is reserved/);
1133
1133
  });
1134
1134
 
1135
1135
  it("should reject DuckDB connections with no attachments", async () => {
1136
+ // Env-level DuckDB connections must declare at least one
1137
+ // attached foreign database; the empty-array case is operator
1138
+ // confusion (the per-package "duckdb" sandbox already covers
1139
+ // the plain-in-memory use case).
1136
1140
  await expect(
1137
1141
  createEnvironmentConnections(
1138
1142
  [
@@ -1144,9 +1148,7 @@ describe("connection integration tests", () => {
1144
1148
  ],
1145
1149
  testEnvironmentPath,
1146
1150
  ),
1147
- ).rejects.toThrow(
1148
- "DuckDB connection must have at least one attached database",
1149
- );
1151
+ ).rejects.toThrow(/has no attached databases/);
1150
1152
  });
1151
1153
 
1152
1154
  it("should reject unsupported DuckDB connector fields", async () => {
@@ -25,6 +25,7 @@ import fs from "fs/promises";
25
25
  import path from "path";
26
26
  import { components } from "../api";
27
27
  import { logAxiosError, logger } from "../logger";
28
+ import { redactPgSecrets } from "../pg_helpers";
28
29
  import {
29
30
  assembleEnvironmentConnections,
30
31
  CoreConnectionEntry,
@@ -365,13 +366,17 @@ async function attachDuckLake(
365
366
  const pgConnString: string = buildPgConnectionString(pg);
366
367
  // Attach DuckLake with Postgres catalog and cloud storage data path in READ_ONLY mode
367
368
  // The client manages metadata - we only read from the catalogs
368
- logger.info(`pgConnString: ${pgConnString}`);
369
+ logger.info(`pgConnString: ${redactPgSecrets(pgConnString)}`);
369
370
  const escapedPgConnString = escapeSQL(pgConnString);
370
- logger.info(`Final escaped connection string: ${escapedPgConnString}`);
371
+ logger.info(
372
+ `Final escaped connection string: ${redactPgSecrets(escapedPgConnString)}`,
373
+ );
371
374
  const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
372
375
  logger.info(`escapedBucketUrl: ${escapedBucketUrl}`);
373
376
  const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true, READ_ONLY true);`;
374
- logger.info(`Attaching DuckLake database using command: ${attachCommand}`);
377
+ logger.info(
378
+ `Attaching DuckLake database using command: ${redactPgSecrets(attachCommand)}`,
379
+ );
375
380
  try {
376
381
  await connection.runSQL(attachCommand);
377
382
  logger.info(
@@ -272,7 +272,7 @@ function validateConnectionShape(connection: ApiConnection): void {
272
272
  connection.duckdbConnection.attachedDatabases ?? [];
273
273
  if (attached.length === 0) {
274
274
  throw new Error(
275
- "DuckDB connection must have at least one attached database",
275
+ `DuckDB connection "${connection.name}" has no attached databases. Add at least one foreign database (BigQuery, Snowflake, Postgres, GCS, S3, Azure) to attachedDatabases, or remove this connection entirely — each package already gets a per-package DuckDB sandbox named "duckdb" automatically.`,
276
276
  );
277
277
  }
278
278
  }
@@ -359,7 +359,7 @@ export function assembleEnvironmentConnections(
359
359
 
360
360
  if (connection.name === "duckdb") {
361
361
  throw new Error(
362
- "DuckDB connection name cannot be 'duckdb'; it is reserved for Publisher package sandboxes.",
362
+ "Connection name 'duckdb' is reserved for per-package sandboxes. Choose a different name for environment-level DuckDB connections (e.g. 'shared_duckdb').",
363
363
  );
364
364
  }
365
365
 
@@ -389,6 +389,16 @@ export class Environment {
389
389
  }
390
390
  }
391
391
 
392
+ /** One mutex per package name; never replace after create (avoids parallel loads). */
393
+ private getOrCreatePackageMutex(packageName: string): Mutex {
394
+ let packageMutex = this.packageMutexes.get(packageName);
395
+ if (packageMutex === undefined) {
396
+ packageMutex = new Mutex();
397
+ this.packageMutexes.set(packageName, packageMutex);
398
+ }
399
+ return packageMutex;
400
+ }
401
+
392
402
  public async getPackage(
393
403
  packageName: string,
394
404
  reload: boolean = false,
@@ -399,25 +409,23 @@ export class Environment {
399
409
  return _package;
400
410
  }
401
411
 
402
- // We need to acquire the mutex to prevent a thundering herd of requests from creating the
403
- // package multiple times.
404
- let packageMutex = this.packageMutexes.get(packageName);
405
- if (packageMutex?.isLocked()) {
412
+ // Serialize load per package name so concurrent callers share one Mutex and
413
+ // failed loads cannot rm the tree while another load is still scanning it.
414
+ const packageMutex = this.getOrCreatePackageMutex(packageName);
415
+
416
+ if (packageMutex.isLocked()) {
406
417
  logger.debug(
407
418
  `Package ${packageName} is being loaded, waiting for unlock...`,
408
419
  );
409
420
  await packageMutex.waitForUnlock();
410
421
  logger.debug(`Package ${packageName} unlocked`);
411
422
  const existingPackage = this.packages.get(packageName);
412
- if (existingPackage) {
423
+ if (existingPackage !== undefined && !reload) {
413
424
  logger.debug(`Package ${packageName} loaded by another request`);
414
425
  return existingPackage;
415
426
  }
416
- // If package still doesn't exist after unlock, it might have failed to load
417
- // Continue to try loading it ourselves
427
+ // Reload, or prior load failed continue under the same mutex.
418
428
  }
419
- packageMutex = new Mutex();
420
- this.packageMutexes.set(packageName, packageMutex);
421
429
 
422
430
  return packageMutex.runExclusive(async () => {
423
431
  // Double-check after acquiring mutex
@@ -479,24 +487,44 @@ export class Environment {
479
487
  malloyConfig: this.malloyConfig.malloyConfig,
480
488
  },
481
489
  );
482
- this.setPackageStatus(packageName, PackageStatus.LOADING);
483
- try {
484
- this.packages.set(
485
- packageName,
486
- await Package.create(
487
- this.environmentName,
488
- packageName,
489
- packagePath,
490
- () => this.malloyConfig.malloyConfig,
491
- ),
490
+
491
+ const packageMutex = this.getOrCreatePackageMutex(packageName);
492
+ if (packageMutex.isLocked()) {
493
+ logger.debug(
494
+ `Package ${packageName} is being loaded, waiting before addPackage...`,
492
495
  );
493
- } catch (error) {
494
- logger.error("Error adding package", { error });
495
- this.deletePackageStatus(packageName);
496
- throw error;
496
+ await packageMutex.waitForUnlock();
497
+ const alreadyLoaded = this.packages.get(packageName);
498
+ if (alreadyLoaded !== undefined) {
499
+ return alreadyLoaded;
500
+ }
497
501
  }
498
- this.setPackageStatus(packageName, PackageStatus.SERVING);
499
- return this.packages.get(packageName);
502
+
503
+ return packageMutex.runExclusive(async () => {
504
+ const existingPackage = this.packages.get(packageName);
505
+ if (existingPackage !== undefined) {
506
+ return existingPackage;
507
+ }
508
+
509
+ this.setPackageStatus(packageName, PackageStatus.LOADING);
510
+ try {
511
+ this.packages.set(
512
+ packageName,
513
+ await Package.create(
514
+ this.environmentName,
515
+ packageName,
516
+ packagePath,
517
+ () => this.malloyConfig.malloyConfig,
518
+ ),
519
+ );
520
+ } catch (error) {
521
+ logger.error("Error adding package", { error });
522
+ this.deletePackageStatus(packageName);
523
+ throw error;
524
+ }
525
+ this.setPackageStatus(packageName, PackageStatus.SERVING);
526
+ return this.packages.get(packageName);
527
+ });
500
528
  }
501
529
 
502
530
  private async writePackageManifest(
@@ -355,6 +355,15 @@ describe("EnvironmentStore Service", () => {
355
355
  expect(projects.length).toBe(2);
356
356
  expect(projects.map((p) => p.name)).toContain(projectName1);
357
357
  expect(projects.map((p) => p.name)).toContain(projectName2);
358
+
359
+ // All envs initialized cleanly → status is "serving" (not
360
+ // "degraded") and there's no failedEnvironments key on the
361
+ // response. This is the happy-path companion to the
362
+ // "should skip a project with invalid startup connection config"
363
+ // test which exercises the degraded path.
364
+ const status = await newEnvironmentStore.getStatus();
365
+ expect(status.operationalState).toBe("serving");
366
+ expect(status.failedEnvironments).toBeUndefined();
358
367
  });
359
368
 
360
369
  it("should skip a project with invalid startup connection config", async () => {
@@ -427,6 +436,16 @@ describe("EnvironmentStore Service", () => {
427
436
  await expect(
428
437
  newEnvironmentStore.getEnvironment(invalidProjectName),
429
438
  ).rejects.toThrow();
439
+
440
+ // The skipped environment should surface in the status response
441
+ // so external callers (CI smoke tests, dashboards) can tell the
442
+ // server is only partially serving.
443
+ const status = await newEnvironmentStore.getStatus();
444
+ expect(status.operationalState).toBe("degraded");
445
+ expect(status.failedEnvironments).toBeDefined();
446
+ expect(status.failedEnvironments?.map((f) => f.name)).toContain(
447
+ invalidProjectName,
448
+ );
430
449
  });
431
450
 
432
451
  it("should handle project updates", async () => {
@@ -25,7 +25,12 @@ import {
25
25
  FrozenConfigError,
26
26
  PackageNotFoundError,
27
27
  } from "../errors";
28
- import { getOperationalState, markNotReady, markReady } from "../health";
28
+ import {
29
+ getOperationalState,
30
+ markDegraded,
31
+ markNotReady,
32
+ markReady,
33
+ } from "../health";
29
34
  import { formatDuration, logger } from "../logger";
30
35
  import { Connection } from "../storage/DatabaseInterface";
31
36
  import { StorageConfig, StorageManager } from "../storage/StorageManager";
@@ -96,6 +101,7 @@ export class EnvironmentStore {
96
101
  public publisherConfigIsFrozen: boolean;
97
102
  public finishedInitialization: Promise<void>;
98
103
  private isInitialized: boolean = false;
104
+ private failedEnvironments: Array<{ name: string; error: string }> = [];
99
105
  public storageManager: StorageManager;
100
106
  private s3Client = new S3({
101
107
  followRegionRedirects: true,
@@ -142,6 +148,10 @@ export class EnvironmentStore {
142
148
  `Error initializing environment${label}; skipping environment`,
143
149
  this.extractErrorDataFromError(error),
144
150
  );
151
+ this.failedEnvironments.push({
152
+ name: environmentName ?? "<unknown>",
153
+ error: error instanceof Error ? error.message : String(error),
154
+ });
145
155
  }
146
156
 
147
157
  private async initialize() {
@@ -275,7 +285,11 @@ export class EnvironmentStore {
275
285
  }
276
286
 
277
287
  this.isInitialized = true;
278
- markReady();
288
+ if (this.failedEnvironments.length > 0) {
289
+ markDegraded();
290
+ } else {
291
+ markReady();
292
+ }
279
293
  const initializationDuration = performance.now() - initialTime;
280
294
  logger.info(
281
295
  `Environment store successfully initialized in ${formatDuration(initializationDuration)}`,
@@ -689,6 +703,11 @@ export class EnvironmentStore {
689
703
  frozenConfig: isPublisherConfigFrozen(this.serverRootPath),
690
704
  operationalState:
691
705
  getOperationalState() as components["schemas"]["ServerStatus"]["operationalState"],
706
+ ...(this.failedEnvironments.length > 0 && {
707
+ failedEnvironments: [
708
+ ...this.failedEnvironments,
709
+ ] as components["schemas"]["ServerStatus"]["failedEnvironments"],
710
+ }),
692
711
  };
693
712
 
694
713
  const environments = await this.listEnvironments(true);
@@ -24,6 +24,7 @@ import {
24
24
  import { PackageNotFoundError } from "../errors";
25
25
  import { formatDuration, logger } from "../logger";
26
26
  import { BuildManifest } from "../storage/DatabaseInterface";
27
+ import { ignoreDotfiles } from "../utils";
27
28
  import { Model } from "./model";
28
29
 
29
30
  type ApiDatabase = components["schemas"]["Database"];
@@ -42,6 +43,7 @@ type PackageConnectionInput =
42
43
  | (() => MalloyConfig);
43
44
 
44
45
  const ENABLE_LIST_MODEL_COMPILATION = true;
46
+
45
47
  export class Package {
46
48
  private environmentName: string;
47
49
  private packageName: string;
@@ -380,7 +382,7 @@ export class Package {
380
382
  private static async getModelPaths(packagePath: string): Promise<string[]> {
381
383
  let files = undefined;
382
384
  try {
383
- files = await recursive(packagePath);
385
+ files = await recursive(packagePath, [ignoreDotfiles]);
384
386
  } catch (error) {
385
387
  logger.error(error);
386
388
  throw new PackageNotFoundError(
@@ -449,8 +451,7 @@ export class Package {
449
451
  private static async getDatabasePaths(
450
452
  packagePath: string,
451
453
  ): Promise<string[]> {
452
- let files = undefined;
453
- files = await recursive(packagePath);
454
+ const files = await recursive(packagePath, [ignoreDotfiles]);
454
455
  return files
455
456
  .map((fullPath: string) => {
456
457
  return path.relative(packagePath, fullPath).replace(/\\/g, "/");
@@ -1,5 +1,13 @@
1
+ import { Mutex } from "async-mutex";
1
2
  import * as crypto from "crypto";
3
+ import { ConnectionAuthError } from "../errors";
2
4
  import { logger } from "../logger";
5
+ import {
6
+ handlePgAttachError,
7
+ pgConnectTimeoutSeconds,
8
+ redactPgSecrets,
9
+ withPgConnectTimeout,
10
+ } from "../pg_helpers";
3
11
  import {
4
12
  DatabaseConnection,
5
13
  ManifestStore,
@@ -78,6 +86,13 @@ export class StorageManager {
78
86
  */
79
87
  private attachedCatalogs = new Map<string, string>();
80
88
 
89
+ // Serializes DuckLake catalog attaches. Concurrent POST /environments calls
90
+ // hitting the same DuckDB connection would otherwise race on extension
91
+ // autoload (httpfs/azure/etc.), where multiple connections download the
92
+ // extension to `.tmp-<uuid>` files in parallel; only one wins the rename
93
+ // and the rest crash with "Could not remove file ... No such file or directory".
94
+ private duckLakeAttachMutex: Mutex = new Mutex();
95
+
81
96
  private config: StorageConfig;
82
97
 
83
98
  constructor(config: StorageConfig) {
@@ -141,14 +156,18 @@ export class StorageManager {
141
156
  }
142
157
 
143
158
  const key = configKey(config);
144
- let catalogName = this.attachedCatalogs.get(key);
145
- if (!catalogName) {
146
- // Catalog name derived from the config so multiple configs can coexist as
147
- // separate ATTACHments without colliding on the name.
148
- catalogName = catalogNameForConfig(config);
149
- await this.attachDuckLakeCatalog(config, catalogName);
150
- this.attachedCatalogs.set(key, catalogName);
151
- }
159
+ const catalogName = await this.duckLakeAttachMutex.runExclusive(
160
+ async () => {
161
+ const existing = this.attachedCatalogs.get(key);
162
+ if (existing) return existing;
163
+ // Catalog name derived from the config so multiple configs can coexist as
164
+ // separate ATTACHments without colliding on the name.
165
+ const name = catalogNameForConfig(config);
166
+ await this.attachDuckLakeCatalog(config, name);
167
+ this.attachedCatalogs.set(key, name);
168
+ return name;
169
+ },
170
+ );
152
171
 
153
172
  const store = new DuckLakeManifestStore(
154
173
  this.duckDbConnection,
@@ -178,12 +197,31 @@ export class StorageManager {
178
197
  await connection.run("INSTALL postgres; LOAD postgres;");
179
198
  }
180
199
 
181
- const escapedCatalogUrl = escapeSQL(config.catalogUrl);
200
+ // For PG-backed catalogs, inject connect_timeout so a wedged libpq
201
+ // handshake fails the caller in seconds rather than hanging the
202
+ // worker until the K8s liveness probe trips (the 2026-05 incident).
203
+ // Non-PG catalogs (e.g. SQLite, MySQL) pass through unchanged.
204
+ const catalogUrl = isPostgres
205
+ ? withPgConnectTimeout(config.catalogUrl, pgConnectTimeoutSeconds())
206
+ : config.catalogUrl;
207
+
208
+ const escapedCatalogUrl = escapeSQL(catalogUrl);
182
209
  const escapedDataPath = escapeSQL(config.dataPath);
183
210
  const isCloudStorage =
184
211
  config.dataPath.startsWith("gs://") ||
185
212
  config.dataPath.startsWith("s3://");
186
213
 
214
+ // Pre-install httpfs explicitly so the ATTACH below doesn't trigger
215
+ // DuckDB's autoloader. The autoloader downloads extensions to
216
+ // `<ext>.tmp-<uuid>` and races when multiple connections within the
217
+ // same process hit it concurrently — losers fail with
218
+ // "Could not remove file ... No such file or directory" on cleanup
219
+ // of their .tmp file. INSTALL/LOAD here is idempotent and serialized
220
+ // by the caller's mutex.
221
+ if (isCloudStorage) {
222
+ await connection.run("INSTALL httpfs; LOAD httpfs;");
223
+ }
224
+
187
225
  let attachCmd = `ATTACH 'ducklake:${escapedCatalogUrl}' AS ${catalogName}`;
188
226
  const attachOpts: string[] = [
189
227
  `DATA_PATH '${escapedDataPath}'`,
@@ -193,13 +231,35 @@ export class StorageManager {
193
231
  // sidestepping object-storage auth issues entirely for this path.
194
232
  "DATA_INLINING_ROW_LIMIT 100000",
195
233
  ];
234
+
196
235
  if (isCloudStorage) {
197
236
  attachOpts.push("OVERRIDE_DATA_PATH true");
198
237
  }
199
238
  attachCmd += ` (${attachOpts.join(", ")});`;
200
239
 
201
- logger.info(`Attaching DuckLake manifest catalog: ${attachCmd}`);
202
- await connection.run(attachCmd);
240
+ logger.info(
241
+ `Attaching DuckLake manifest catalog: ${redactPgSecrets(attachCmd)}`,
242
+ );
243
+ try {
244
+ await connection.run(attachCmd);
245
+ } catch (error) {
246
+ const outcome = handlePgAttachError(
247
+ error,
248
+ `DuckLake catalog credentials rejected for ${catalogName}`,
249
+ );
250
+ if (outcome.action === "swallow") {
251
+ logger.info(
252
+ `DuckLake catalog ${catalogName} is already attached, skipping`,
253
+ );
254
+ return;
255
+ }
256
+ if (outcome.error instanceof ConnectionAuthError) {
257
+ logger.warn("DuckLake catalog credentials rejected", {
258
+ catalogName,
259
+ });
260
+ }
261
+ throw outcome.error;
262
+ }
203
263
  }
204
264
 
205
265
  getRepository(): ResourceRepository {
@@ -17,6 +17,15 @@ export async function initializeSchema(
17
17
  );
18
18
  await dropAllTables(db);
19
19
  } else {
20
+ // TODO: Remove this during projects cleanup
21
+ // If a pre-rename `projects` schema is on disk, the new
22
+ // CREATE TABLE IF NOT EXISTS pass below would silently leave child
23
+ // tables on the old `project_id` column and the first query against
24
+ // `environment_id` would crash. Drop the legacy tables (with a loud
25
+ // warning) so the fresh schema can be created cleanly. This is
26
+ // destructive — operators upgrading should re-create their environments
27
+ // and packages via the API after the upgrade.
28
+ await dropLegacyProjectSchema(db);
20
29
  logger.info("Creating database schema for the first time...");
21
30
  }
22
31
 
@@ -125,6 +134,38 @@ export async function initializeSchema(
125
134
  );
126
135
  }
127
136
 
137
+ // TODO: Remove this during projects cleanup
138
+ // Tables in the pre-rename schema, listed children-first so DROP order
139
+ // satisfies foreign-key dependencies on the legacy `projects` table.
140
+ const LEGACY_TABLES_DROP_ORDER = [
141
+ "build_manifests",
142
+ "materializations",
143
+ "packages",
144
+ "connections",
145
+ "projects",
146
+ ] as const;
147
+
148
+ async function dropLegacyProjectSchema(db: DuckDBConnection): Promise<void> {
149
+ const legacy = await db.all<{ name: string }>(
150
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='projects'",
151
+ );
152
+ if (!legacy || legacy.length === 0) {
153
+ return;
154
+ }
155
+
156
+ logger.warn(
157
+ "Detected legacy 'projects' schema. Dropping legacy tables; existing environments/packages/connections/materializations data will be lost. Re-create them via the API after upgrade.",
158
+ );
159
+
160
+ for (const table of LEGACY_TABLES_DROP_ORDER) {
161
+ try {
162
+ await db.run(`DROP TABLE IF EXISTS ${table}`);
163
+ } catch (err) {
164
+ logger.warn(`Failed to drop legacy table ${table}:`, err);
165
+ }
166
+ }
167
+ }
168
+
128
169
  async function dropAllTables(db: DuckDBConnection): Promise<void> {
129
170
  const tables = [
130
171
  "build_manifests",
package/src/utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { URLReader } from "@malloydata/malloy";
2
2
  import * as fs from "fs";
3
+ import * as path from "path";
3
4
  import { fileURLToPath } from "url";
4
5
 
5
6
  export const URL_READER: URLReader = {
@@ -11,3 +12,13 @@ export const URL_READER: URLReader = {
11
12
  return fs.promises.readFile(path, "utf8");
12
13
  },
13
14
  };
15
+
16
+ /**
17
+ * Skip dotfiles/dotdirs (.vscode, .git, .DS_Store, etc.) when walking a
18
+ * package tree. These come from editors/VCS, never contain Malloy models
19
+ * or databases, and have been a source of spurious ENOENTs when their
20
+ * contents disappear mid-scan.
21
+ */
22
+ export function ignoreDotfiles(file: string): boolean {
23
+ return path.basename(file).startsWith(".");
24
+ }
@@ -12,8 +12,8 @@ export interface RestE2EEnv {
12
12
  * reuses the cached Express app and binds on an OS-assigned port
13
13
  * to avoid collisions.
14
14
  *
15
- * Callers are responsible for creating any test-specific projects
16
- * via the REST API (POST /api/v0/projects) and cleaning them up.
15
+ * Callers are responsible for creating any test-specific environments
16
+ * via the REST API (POST /api/v0/environments) and cleaning them up.
17
17
  */
18
18
  export async function startRestE2E(): Promise<
19
19
  RestE2EEnv & { stop(): Promise<void> }