@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
@@ -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
+ }