@malloy-publisher/server 0.0.196 → 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 (55) 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 +334 -165
  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 +118 -0
  26. package/src/config.ts +78 -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.ts +20 -0
  36. package/src/service/connection.spec.ts +6 -4
  37. package/src/service/connection.ts +8 -3
  38. package/src/service/connection_config.ts +2 -2
  39. package/src/service/environment.ts +53 -25
  40. package/src/service/environment_store.spec.ts +19 -0
  41. package/src/service/environment_store.ts +21 -2
  42. package/src/service/package.ts +4 -3
  43. package/src/storage/StorageManager.ts +71 -11
  44. package/src/utils.ts +11 -0
  45. package/tests/unit/duckdb/attached_databases.test.ts +5 -5
  46. package/tests/unit/storage/StorageManager.test.ts +166 -0
  47. package/dist/app/assets/EnvironmentPage-1j6QDWAy.js +0 -1
  48. package/dist/app/assets/HomePage-DMop21VG.js +0 -1
  49. package/dist/app/assets/MainPage-BbE8ETz1.js +0 -2
  50. package/dist/app/assets/ModelPage-D2jvfe3t.js +0 -1
  51. package/dist/app/assets/PackagePage-BbnhGoD3.js +0 -1
  52. package/dist/app/assets/RouteError-D3LGEZ3i.js +0 -1
  53. package/dist/app/assets/WorkbookPage-DttVIj4u.js +0 -1
  54. package/dist/app/assets/index-5K9YjIxF.js +0 -456
  55. package/dist/app/assets/index-DIgzgp69.js +0 -1742
@@ -0,0 +1,226 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { ConnectionAuthError } from "./errors";
3
+ import {
4
+ classifyPgError,
5
+ handlePgAttachError,
6
+ pgConnectTimeoutSeconds,
7
+ redactPgSecrets,
8
+ withPgConnectTimeout,
9
+ } from "./pg_helpers";
10
+
11
+ describe("pgConnectTimeoutSeconds", () => {
12
+ const ORIGINAL_TIMEOUT = process.env.PG_CONNECT_TIMEOUT_SECONDS;
13
+
14
+ afterEach(() => {
15
+ if (ORIGINAL_TIMEOUT === undefined) {
16
+ delete process.env.PG_CONNECT_TIMEOUT_SECONDS;
17
+ } else {
18
+ process.env.PG_CONNECT_TIMEOUT_SECONDS = ORIGINAL_TIMEOUT;
19
+ }
20
+ });
21
+
22
+ it("defaults to 5 when env unset", () => {
23
+ delete process.env.PG_CONNECT_TIMEOUT_SECONDS;
24
+ expect(pgConnectTimeoutSeconds()).toBe(5);
25
+ });
26
+
27
+ it("honors PG_CONNECT_TIMEOUT_SECONDS override", () => {
28
+ process.env.PG_CONNECT_TIMEOUT_SECONDS = "12";
29
+ expect(pgConnectTimeoutSeconds()).toBe(12);
30
+ });
31
+
32
+ it("falls back to 5 when env value is invalid", () => {
33
+ process.env.PG_CONNECT_TIMEOUT_SECONDS = "not-a-number";
34
+ expect(pgConnectTimeoutSeconds()).toBe(5);
35
+ });
36
+
37
+ it("falls back to 5 when env value is zero or negative", () => {
38
+ process.env.PG_CONNECT_TIMEOUT_SECONDS = "0";
39
+ expect(pgConnectTimeoutSeconds()).toBe(5);
40
+ process.env.PG_CONNECT_TIMEOUT_SECONDS = "-3";
41
+ expect(pgConnectTimeoutSeconds()).toBe(5);
42
+ });
43
+ });
44
+
45
+ describe("withPgConnectTimeout", () => {
46
+ it("appends to keyword form when missing", () => {
47
+ expect(withPgConnectTimeout("host=h dbname=d user=u password=p", 5)).toBe(
48
+ "host=h dbname=d user=u password=p connect_timeout=5",
49
+ );
50
+ });
51
+
52
+ it("appends to postgres: keyword form (DuckLake catalogUrl shape)", () => {
53
+ expect(
54
+ withPgConnectTimeout("postgres:host=h user=u password=p dbname=d", 5),
55
+ ).toBe("postgres:host=h user=u password=p dbname=d connect_timeout=5");
56
+ });
57
+
58
+ it("does not override a user-supplied connect_timeout in keyword form", () => {
59
+ expect(withPgConnectTimeout("host=h connect_timeout=30", 99)).toBe(
60
+ "host=h connect_timeout=30",
61
+ );
62
+ });
63
+
64
+ it("appends to URI form with no query", () => {
65
+ expect(withPgConnectTimeout("postgresql://u:p@h:5432/d", 5)).toBe(
66
+ "postgresql://u:p@h:5432/d?connect_timeout=5",
67
+ );
68
+ });
69
+
70
+ it("appends to URI form with existing query", () => {
71
+ expect(
72
+ withPgConnectTimeout("postgresql://u:p@h/d?sslmode=require", 5),
73
+ ).toBe("postgresql://u:p@h/d?sslmode=require&connect_timeout=5");
74
+ });
75
+
76
+ it("appends to URI with bare trailing ?", () => {
77
+ expect(withPgConnectTimeout("postgresql://h/d?", 5)).toBe(
78
+ "postgresql://h/d?connect_timeout=5",
79
+ );
80
+ });
81
+
82
+ it("does not double-append when URI already has connect_timeout (?-style)", () => {
83
+ expect(
84
+ withPgConnectTimeout("postgresql://h/d?connect_timeout=10", 5),
85
+ ).toBe("postgresql://h/d?connect_timeout=10");
86
+ });
87
+
88
+ it("does not double-append when URI already has connect_timeout (&-style)", () => {
89
+ expect(
90
+ withPgConnectTimeout(
91
+ "postgresql://h/d?sslmode=require&connect_timeout=10",
92
+ 5,
93
+ ),
94
+ ).toBe("postgresql://h/d?sslmode=require&connect_timeout=10");
95
+ });
96
+
97
+ it("recognizes postgres:// (alternative scheme) as URI form", () => {
98
+ expect(withPgConnectTimeout("postgres://u@h/d", 5)).toBe(
99
+ "postgres://u@h/d?connect_timeout=5",
100
+ );
101
+ });
102
+ });
103
+
104
+ describe("redactPgSecrets", () => {
105
+ it("redacts bare password values", () => {
106
+ expect(redactPgSecrets("host=h password=hunter2 dbname=d")).toBe(
107
+ "host=h password=*** dbname=d",
108
+ );
109
+ });
110
+
111
+ it("redacts single-quoted password values", () => {
112
+ expect(redactPgSecrets("host=h password='s3 cret' dbname=d")).toBe(
113
+ "host=h password=*** dbname=d",
114
+ );
115
+ });
116
+
117
+ it("leaves non-secret content alone", () => {
118
+ expect(redactPgSecrets("user=alice dbname=billing")).toBe(
119
+ "user=alice dbname=billing",
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("classifyPgError", () => {
125
+ it.each([
126
+ 'password authentication failed for user "alice"',
127
+ "no pg_hba.conf entry for host",
128
+ 'role "alice" does not exist',
129
+ 'database "billing" does not exist',
130
+ "permission denied for relation foo",
131
+ ])("classifies '%s' as auth error", (msg) => {
132
+ const result = classifyPgError(new Error(msg), "PG attach");
133
+ expect(result).toBeInstanceOf(ConnectionAuthError);
134
+ expect(result?.message).toContain("PG attach:");
135
+ });
136
+
137
+ it("returns undefined for unrelated errors", () => {
138
+ expect(
139
+ classifyPgError(
140
+ new Error('relation "users" does not exist'),
141
+ "PG attach",
142
+ ),
143
+ ).toBeUndefined();
144
+ expect(
145
+ classifyPgError(new Error("connection reset by peer"), "PG attach"),
146
+ ).toBeUndefined();
147
+ });
148
+
149
+ it("returns undefined for non-Error values", () => {
150
+ expect(
151
+ classifyPgError("password authentication failed", "ctx"),
152
+ ).toBeUndefined();
153
+ expect(classifyPgError(undefined, "ctx")).toBeUndefined();
154
+ });
155
+
156
+ it("redacts embedded passwords in the wrapped message", () => {
157
+ const result = classifyPgError(
158
+ new Error(
159
+ "password authentication failed: tried host=h password=hunter2",
160
+ ),
161
+ "DuckLake attach",
162
+ );
163
+ expect(result?.message).toContain("password=***");
164
+ expect(result?.message).not.toContain("hunter2");
165
+ });
166
+ });
167
+
168
+ describe("handlePgAttachError", () => {
169
+ it("swallows 'already exists' errors", () => {
170
+ const outcome = handlePgAttachError(
171
+ new Error('database "db_x" already exists'),
172
+ "ctx",
173
+ );
174
+ expect(outcome.action).toBe("swallow");
175
+ });
176
+
177
+ it("swallows 'already attached' errors", () => {
178
+ const outcome = handlePgAttachError(
179
+ new Error("DuckLake catalog db_x is already attached"),
180
+ "ctx",
181
+ );
182
+ expect(outcome.action).toBe("swallow");
183
+ });
184
+
185
+ it("classifies libpq auth failures as ConnectionAuthError", () => {
186
+ const outcome = handlePgAttachError(
187
+ new Error('password authentication failed for user "alice"'),
188
+ "PG attach db_x",
189
+ );
190
+ expect(outcome.action).toBe("throw");
191
+ if (outcome.action === "throw") {
192
+ expect(outcome.error).toBeInstanceOf(ConnectionAuthError);
193
+ expect(outcome.error.message).toContain("PG attach db_x:");
194
+ }
195
+ });
196
+
197
+ it("passes through unrelated Error instances unchanged", () => {
198
+ const original = new Error("network unreachable");
199
+ const outcome = handlePgAttachError(original, "ctx");
200
+ expect(outcome.action).toBe("throw");
201
+ if (outcome.action === "throw") {
202
+ expect(outcome.error).toBe(original);
203
+ expect(outcome.error).not.toBeInstanceOf(ConnectionAuthError);
204
+ }
205
+ });
206
+
207
+ it("wraps non-Error throwables so callers always get an Error", () => {
208
+ const outcome = handlePgAttachError("a string was thrown", "ctx");
209
+ expect(outcome.action).toBe("throw");
210
+ if (outcome.action === "throw") {
211
+ expect(outcome.error).toBeInstanceOf(Error);
212
+ expect(outcome.error.message).toBe("a string was thrown");
213
+ }
214
+ });
215
+
216
+ it("prefers 'already attached' over auth classification when both keywords appear", () => {
217
+ // Defensive: if a future DuckDB version emits a combined message,
218
+ // 'already attached' wins so we don't bubble up a false auth failure
219
+ // on what is actually a benign idempotent re-attach.
220
+ const outcome = handlePgAttachError(
221
+ new Error("already attached; permission denied tail"),
222
+ "ctx",
223
+ );
224
+ expect(outcome.action).toBe("swallow");
225
+ });
226
+ });
@@ -0,0 +1,129 @@
1
+ // Postgres / libpq helpers shared between `service/` (user-facing
2
+ // connections) and `storage/` (materialization-storage catalog). Lives at
3
+ // `src/` root so neither layer takes a dependency on the other — see
4
+ // CLAUDE.md's "Two parallel DuckLake/PG attach paths" note for why this
5
+ // matters.
6
+ import { ConnectionAuthError } from "./errors";
7
+
8
+ // Default Postgres connect_timeout (seconds), used by the materialization
9
+ // storage catalog ATTACH so a slow or wedged libpq handshake fails the
10
+ // caller in seconds instead of stalling the worker until the K8s liveness
11
+ // probe trips.
12
+ //
13
+ // libpq enforces a documented minimum of 2 seconds — values below 2
14
+ // effectively round up to ~2s wall clock.
15
+ export function pgConnectTimeoutSeconds(): number {
16
+ const raw = process.env.PG_CONNECT_TIMEOUT_SECONDS;
17
+ if (!raw) return 5;
18
+ const parsed = Number.parseInt(raw, 10);
19
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
20
+ }
21
+
22
+ // libpq accepts both keyword=value form ("host=h dbname=d") and URI form
23
+ // ("postgresql://u:p@h/d?param=v"). The materialization-storage catalogUrl
24
+ // can also arrive as `postgres:<keyword=value>` (no `//`). We detect URI
25
+ // form (with `//`) so we know whether to append a new parameter using
26
+ // `?`/`&` or a leading space.
27
+ const URI_FORM_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
28
+
29
+ // Match an existing connect_timeout key in either form. URI form uses
30
+ // `?key=` or `&key=`; keyword form uses whitespace separation or start-of-
31
+ // string. Without the `[?&]` alternatives a URI-form user-supplied timeout
32
+ // would be missed and we'd double-append, producing an invalid URL.
33
+ const HAS_CONNECT_TIMEOUT_RE = /[?&\s]connect_timeout=|^connect_timeout=/;
34
+
35
+ // Append `connect_timeout=N` to a libpq-compatible connection string if
36
+ // the caller hasn't already set one. Handles keyword form ("host=h ..."),
37
+ // URI form ("postgresql://..."), and the `postgres:host=h ...` keyword
38
+ // form with a scheme prefix used by DuckLake catalogUrls.
39
+ export function withPgConnectTimeout(
40
+ connectionString: string,
41
+ timeout: number,
42
+ ): string {
43
+ if (HAS_CONNECT_TIMEOUT_RE.test(connectionString)) {
44
+ return connectionString;
45
+ }
46
+ if (URI_FORM_RE.test(connectionString)) {
47
+ // URI form: append as query parameter. `?` if no query string yet,
48
+ // `&` otherwise. A bare trailing `?` (empty query) gets no extra
49
+ // separator. We don't try to handle URL fragments — libpq URIs don't
50
+ // use them.
51
+ if (!connectionString.includes("?")) {
52
+ return `${connectionString}?connect_timeout=${timeout}`;
53
+ }
54
+ if (connectionString.endsWith("?")) {
55
+ return `${connectionString}connect_timeout=${timeout}`;
56
+ }
57
+ return `${connectionString}&connect_timeout=${timeout}`;
58
+ }
59
+ // Keyword=value form (with or without `postgres:` scheme prefix).
60
+ return `${connectionString} connect_timeout=${timeout}`;
61
+ }
62
+
63
+ // Redact libpq `password=...` values from a string before it goes into a
64
+ // log line or HTTP response body. Handles bare and quoted values.
65
+ //
66
+ // Scope: keyword-form `password=` only. Does not touch URL-style
67
+ // `user:pw@host` credentials, AWS keys, GCS secrets, etc.
68
+ export function redactPgSecrets(s: string): string {
69
+ return s.replace(/password=('[^']*'|"[^"]*"|\S+)/gi, "password=***");
70
+ }
71
+
72
+ // Substring-match libpq error patterns that indicate a non-retryable
73
+ // auth/permission failure. Returns a ConnectionAuthError when matched so
74
+ // callers can fast-fail with HTTP 422 (semantically "the supplied creds
75
+ // are bad; don't retry") instead of letting the raw error fall through to
76
+ // a generic 500 that retry loops treat as transient.
77
+ export function classifyPgError(
78
+ error: unknown,
79
+ context: string,
80
+ ): ConnectionAuthError | undefined {
81
+ if (!(error instanceof Error)) return undefined;
82
+ const msg = error.message;
83
+ const patterns = [
84
+ /password authentication failed/i,
85
+ /pg_hba\.conf/i,
86
+ /role ".*" does not exist/i,
87
+ /database ".*" does not exist/i,
88
+ /permission denied/i,
89
+ ];
90
+ if (!patterns.some((p) => p.test(msg))) return undefined;
91
+ return new ConnectionAuthError(`${context}: ${redactPgSecrets(msg)}`);
92
+ }
93
+
94
+ // Outcome of inspecting an error thrown by an `ATTACH` call:
95
+ // - `{ action: "swallow" }`: DuckDB reported the db is already attached
96
+ // (idempotent re-attach); caller should log and continue.
97
+ // - `{ action: "throw", error: ConnectionAuthError }`: classified as a
98
+ // non-retryable auth failure; caller should warn-log and throw it.
99
+ // - `{ action: "throw", error: <original> }`: unrecognized; caller
100
+ // should rethrow as-is to preserve the original cause for diagnosis.
101
+ //
102
+ // Extracted so the decision tree gets a direct unit test without needing
103
+ // to stub DuckDB or run a real ATTACH.
104
+ export type PgAttachErrorOutcome =
105
+ | { action: "swallow" }
106
+ | { action: "throw"; error: Error };
107
+
108
+ export function handlePgAttachError(
109
+ error: unknown,
110
+ context: string,
111
+ ): PgAttachErrorOutcome {
112
+ if (
113
+ error instanceof Error &&
114
+ (error.message.includes("already exists") ||
115
+ error.message.includes("already attached"))
116
+ ) {
117
+ return { action: "swallow" };
118
+ }
119
+ const authErr = classifyPgError(error, context);
120
+ if (authErr) {
121
+ return { action: "throw", error: authErr };
122
+ }
123
+ if (error instanceof Error) {
124
+ return { action: "throw", error };
125
+ }
126
+ // Non-Error thrown values get wrapped so the catch contract stays
127
+ // (always throws an Error).
128
+ return { action: "throw", error: new Error(String(error)) };
129
+ }
package/src/server.ts CHANGED
@@ -51,6 +51,8 @@ export function normalizeQueryArray(value: unknown): string[] | undefined {
51
51
  // Parse command line arguments
52
52
  function parseArgs() {
53
53
  const args = process.argv.slice(2);
54
+ let sawServerRoot = false;
55
+ let sawConfig = false;
54
56
  for (let i = 0; i < args.length; i++) {
55
57
  const arg = args[i];
56
58
  if (arg === "--port" && args[i + 1]) {
@@ -60,8 +62,13 @@ function parseArgs() {
60
62
  process.env.PUBLISHER_HOST = args[i + 1];
61
63
  i++;
62
64
  } else if (arg === "--server_root" && args[i + 1]) {
65
+ sawServerRoot = true;
63
66
  process.env.SERVER_ROOT = args[i + 1];
64
67
  i++;
68
+ } else if (arg === "--config" && args[i + 1]) {
69
+ sawConfig = true;
70
+ process.env.PUBLISHER_CONFIG_PATH = args[i + 1];
71
+ i++;
65
72
  } else if (arg === "--mcp_port" && args[i + 1]) {
66
73
  process.env.MCP_PORT = args[i + 1];
67
74
  i++;
@@ -91,6 +98,9 @@ function parseArgs() {
91
98
  console.log(
92
99
  " --server_root <path> Root directory to serve files from (default: .)",
93
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
+ );
94
104
  console.log(
95
105
  " --mcp_port <number> Port for MCP server (default: 4040)",
96
106
  );
@@ -107,6 +117,16 @@ function parseArgs() {
107
117
  process.exit(0);
108
118
  }
109
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
+ }
110
130
  }
111
131
 
112
132
  // Parse CLI arguments before setting up constants
@@ -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 () => {