@malloy-publisher/server 0.0.204 → 0.0.205

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/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +133 -4
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-CAge6UHD.js} +1 -1
  4. package/dist/app/assets/HomePage-DhTe8qpa.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-CeTxxGex.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-CpDHB70t.js +1 -0
  7. package/dist/app/assets/ModelPage-D9sSMb75.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-LRqQWrFY.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-xT6kuCNw.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-DsIh9svZ.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-C2sQrwVu.es-Bjem0hym.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BdOZDcce.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-DHHAcY5o.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-RX3QOTde.js} +121 -121
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-D2WH3D-f.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/runtime/publisher.js +318 -0
  18. package/dist/server.mjs +567 -194
  19. package/package.json +5 -4
  20. package/scripts/bake-duckdb-extensions.js +104 -0
  21. package/src/controller/watch-mode.controller.ts +176 -46
  22. package/src/errors.spec.ts +21 -0
  23. package/src/mcp/error_messages.spec.ts +35 -0
  24. package/src/mcp/error_messages.ts +14 -1
  25. package/src/mcp/handler_utils.ts +12 -0
  26. package/src/runtime/publisher.js +318 -0
  27. package/src/server.ts +479 -2
  28. package/src/service/authorize_integration.spec.ts +96 -2
  29. package/src/service/compile_authorize.spec.ts +85 -0
  30. package/src/service/environment.ts +63 -5
  31. package/src/service/environment_store.ts +142 -11
  32. package/src/service/model.ts +44 -0
  33. package/src/service/package.ts +17 -6
  34. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  35. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  36. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  37. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  38. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  39. package/tests/fixtures/html-pages-test/data.csv +3 -0
  40. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  41. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  42. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  43. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  44. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  45. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  46. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  47. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  48. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  49. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  50. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  51. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  52. package/tests/unit/duckdb/repositories.test.ts +208 -0
  53. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  54. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  55. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
@@ -1,11 +1,30 @@
1
1
  import { Mutex } from "async-mutex";
2
- import duckdb from "duckdb";
2
+ import {
3
+ DuckDBConnection as NeoConnection,
4
+ DuckDBInstance,
5
+ type DuckDBValue,
6
+ } from "@duckdb/node-api";
3
7
  import * as path from "path";
4
8
  import { DatabaseConnection } from "../DatabaseInterface";
5
9
 
10
+ /**
11
+ * Embedded persistence layer for the publisher's own metadata (environments,
12
+ * packages, connections, materializations, build manifests) in `publisher.db`.
13
+ *
14
+ * This is a plain DAO over a durable, exclusively-owned DuckDB handle -- it is
15
+ * deliberately NOT Malloy's `@malloydata/db-duckdb` connection, which is an
16
+ * analytical query connection (no prepared-statement parameter binding, a
17
+ * `:memory:` primary with ATTACH/DETACH/idle lifecycle, pooled/shared
18
+ * instances, and a poison-pill close). Those semantics are wrong for a
19
+ * source-of-truth store that must hold one handle open for the server's
20
+ * lifetime and run parameterized CRUD.
21
+ *
22
+ * It wraps `@duckdb/node-api` (the same DuckDB engine Malloy pulls in), so the
23
+ * repo carries a single DuckDB engine rather than a second, redundant driver.
24
+ */
6
25
  export class DuckDBConnection implements DatabaseConnection {
7
- private db: duckdb.Database | null = null;
8
- private connection: duckdb.Connection | null = null;
26
+ private instance: DuckDBInstance | null = null;
27
+ private connection: NeoConnection | null = null;
9
28
  private dbPath: string;
10
29
  private mutex: Mutex = new Mutex();
11
30
 
@@ -15,94 +34,47 @@ export class DuckDBConnection implements DatabaseConnection {
15
34
  }
16
35
 
17
36
  async initialize(): Promise<void> {
18
- return new Promise((resolve, reject) => {
19
- this.db = new duckdb.Database(this.dbPath, {}, (err) => {
20
- if (err) {
21
- console.error("Failed to create DuckDB database:", err);
22
- reject(new Error(`Failed to initialize DuckDB: ${err.message}`));
23
- return;
24
- }
25
-
26
- // Connect synchronously
27
- this.connection = (
28
- this.db as duckdb.Database & { connect(): duckdb.Connection }
29
- ).connect();
30
-
31
- if (!this.connection) {
32
- reject(new Error("Failed to create connection object"));
33
- return;
34
- }
35
-
36
- // Verify connection works
37
- this.connection.all("SELECT 42 as answer", (testErr, _rows) => {
38
- if (testErr) {
39
- console.error("Connection test failed:", testErr);
40
- reject(
41
- new Error(
42
- `Failed to verify DuckDB connection: ${testErr.message}`,
43
- ),
44
- );
45
- return;
46
- }
47
-
48
- resolve();
49
- });
50
- });
51
- });
37
+ try {
38
+ this.instance = await DuckDBInstance.create(this.dbPath);
39
+ this.connection = await this.instance.connect();
40
+ // Verify the connection works
41
+ await this.connection.run("SELECT 42 as answer");
42
+ } catch (err) {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ console.error("Failed to create DuckDB database:", err);
45
+ throw new Error(`Failed to initialize DuckDB: ${message}`);
46
+ }
52
47
  }
53
48
 
54
49
  async close(): Promise<void> {
55
- return new Promise((resolve, reject) => {
50
+ try {
56
51
  if (this.connection) {
57
- this.connection.close((err) => {
58
- if (err) {
59
- reject(
60
- new Error(
61
- `Failed to close DuckDB connection: ${err.message}`,
62
- ),
63
- );
64
- return;
65
- }
66
-
67
- if (this.db) {
68
- this.db.close((dbErr) => {
69
- if (dbErr) {
70
- reject(
71
- new Error(
72
- `Failed to close DuckDB: ${dbErr.message}`,
73
- ),
74
- );
75
- return;
76
- }
77
- console.log("DuckDB connection closed");
78
- resolve();
79
- });
80
- } else {
81
- resolve();
82
- }
83
- });
84
- } else {
85
- resolve();
52
+ this.connection.closeSync();
53
+ this.connection = null;
86
54
  }
87
- });
55
+ if (this.instance) {
56
+ this.instance.closeSync();
57
+ this.instance = null;
58
+ }
59
+ console.log("DuckDB connection closed");
60
+ } catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ throw new Error(`Failed to close DuckDB connection: ${message}`);
63
+ }
88
64
  }
89
65
 
90
66
  async isInitialized(): Promise<boolean> {
91
67
  if (!this.connection) return false;
92
68
 
93
69
  return this.mutex.runExclusive(async () => {
94
- return new Promise<boolean>((resolve) => {
95
- this.connection!.all(
70
+ try {
71
+ const reader = await this.connection!.runAndReadAll(
96
72
  "SELECT name FROM sqlite_master WHERE type='table' AND name='environments'",
97
- (err, rows) => {
98
- if (err) {
99
- resolve(false);
100
- return;
101
- }
102
- resolve(rows && rows.length > 0);
103
- },
104
73
  );
105
- });
74
+ return reader.getRowObjectsJS().length > 0;
75
+ } catch {
76
+ return false;
77
+ }
106
78
  });
107
79
  }
108
80
 
@@ -112,26 +84,14 @@ export class DuckDBConnection implements DatabaseConnection {
112
84
  }
113
85
 
114
86
  return this.mutex.runExclusive(async () => {
115
- return new Promise<void>((resolve, reject) => {
116
- const callback = (err: Error | null) => {
117
- if (err) {
118
- reject(
119
- new Error(
120
- `Query execution failed: ${err.message}\nQuery: ${query}`,
121
- ),
122
- );
123
- return;
124
- }
125
- resolve();
126
- };
127
-
128
- // Pass params directly without the params argument if empty
129
- if (params && params.length > 0) {
130
- this.connection!.run(query, ...params, callback);
131
- } else {
132
- this.connection!.run(query, callback);
133
- }
134
- });
87
+ try {
88
+ await this.connection!.run(query, params as DuckDBValue[]);
89
+ } catch (err) {
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ throw new Error(
92
+ `Query execution failed: ${message}\nQuery: ${query}`,
93
+ );
94
+ }
135
95
  });
136
96
  }
137
97
 
@@ -141,25 +101,18 @@ export class DuckDBConnection implements DatabaseConnection {
141
101
  }
142
102
 
143
103
  return this.mutex.runExclusive(async () => {
144
- return new Promise<T[]>((resolve, reject) => {
145
- const callback = (err: Error | null, rows: unknown[]) => {
146
- if (err) {
147
- reject(
148
- new Error(
149
- `Query execution failed: ${err.message}\nQuery: ${query}`,
150
- ),
151
- );
152
- return;
153
- }
154
- resolve((rows || []) as T[]);
155
- };
156
-
157
- if (params && params.length > 0) {
158
- this.connection!.all(query, ...params, callback);
159
- } else {
160
- this.connection!.all(query, callback);
161
- }
162
- });
104
+ try {
105
+ const reader = await this.connection!.runAndReadAll(
106
+ query,
107
+ params as DuckDBValue[],
108
+ );
109
+ return reader.getRowObjectsJS() as T[];
110
+ } catch (err) {
111
+ const message = err instanceof Error ? err.message : String(err);
112
+ throw new Error(
113
+ `Query execution failed: ${message}\nQuery: ${query}`,
114
+ );
115
+ }
163
116
  });
164
117
  }
165
118
 
@@ -167,11 +120,4 @@ export class DuckDBConnection implements DatabaseConnection {
167
120
  const rows = await this.all<T>(query, params);
168
121
  return rows.length > 0 ? rows[0] : null;
169
122
  }
170
-
171
- getConnection(): duckdb.Connection {
172
- if (!this.connection) {
173
- throw new Error("Database not initialized");
174
- }
175
- return this.connection;
176
- }
177
123
  }
@@ -0,0 +1,9 @@
1
+ ##! experimental.givens
2
+
3
+ given:
4
+ ROLE :: string
5
+
6
+ #(authorize) "$ROLE = 'analyst'"
7
+ source: gated is duckdb.sql("SELECT 1 as x") extend { measure: c is count() }
8
+
9
+ source: open_src is duckdb.sql("SELECT 1 as x") extend { measure: c is count() }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "authorize-compile",
3
+ "description": "Fixture: a gated source for the /compile authorize HTTP integration test."
4
+ }
@@ -0,0 +1 @@
1
+ source: nums is duckdb.sql("SELECT 1 as n")
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "html-pages-nopublic",
3
+ "version": "0.0.1",
4
+ "description": "Package with no public/ directory; static file requests return 404."
5
+ }
@@ -0,0 +1,3 @@
1
+ id,name
2
+ 1,alpha
3
+ 2,beta
@@ -0,0 +1,3 @@
1
+ body {
2
+ font-family: system-ui, sans-serif;
3
+ }
@@ -0,0 +1 @@
1
+ { "ok": true }
@@ -0,0 +1,9 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Carrier Dashboard</title>
5
+ </head>
6
+ <body>
7
+ <h1>Hello from the in-package data app</h1>
8
+ </body>
9
+ </html>
@@ -0,0 +1,9 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Second Page</title>
5
+ </head>
6
+ <body>
7
+ <p>A page in a subdirectory.</p>
8
+ </body>
9
+ </html>
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "html-pages-test",
3
+ "version": "1.0.0",
4
+ "description": "Fixture for in-package HTML static-file serving + /pages + /events"
5
+ }
@@ -0,0 +1 @@
1
+ source: report is duckdb.sql("SELECT 1 as n")
@@ -0,0 +1,92 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { type RestE2EEnv, startRestE2E } from "../../harness/rest_e2e";
5
+
6
+ // HTTP end-to-end for the /compile authorize gate: proves AccessDeniedError
7
+ // surfaces as a real 403 body through the express route + internalErrorToHttpError,
8
+ // and that the 403 names only the source (never the gate expression).
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const PROJECT = "authorize-compile-test-project";
12
+ const PKG = "authorize-compile";
13
+ const MODEL = "model.malloy";
14
+
15
+ describe("compile authorize gate (HTTP E2E)", () => {
16
+ let env: (RestE2EEnv & { stop(): Promise<void> }) | null = null;
17
+ let baseUrl: string;
18
+
19
+ beforeAll(async () => {
20
+ env = await startRestE2E();
21
+ baseUrl = env.baseUrl;
22
+ const fixtureDir = path.resolve(
23
+ __dirname,
24
+ "../../fixtures/authorize-compile",
25
+ );
26
+ const createRes = await fetch(`${baseUrl}/api/v0/projects`, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: JSON.stringify({
30
+ name: PROJECT,
31
+ packages: [{ name: PKG, location: fixtureDir }],
32
+ connections: [],
33
+ }),
34
+ });
35
+ if (!createRes.ok) {
36
+ throw new Error(
37
+ `Failed to create test project (${createRes.status}): ${await createRes.text()}`,
38
+ );
39
+ }
40
+ const deadline = Date.now() + 30_000;
41
+ while (Date.now() < deadline) {
42
+ const res = await fetch(
43
+ `${baseUrl}/api/v0/projects/${PROJECT}/packages/${PKG}`,
44
+ );
45
+ if (res.ok) break;
46
+ await new Promise((r) => setTimeout(r, 250));
47
+ }
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await env?.stop();
52
+ });
53
+
54
+ const compile = (body: unknown) =>
55
+ fetch(
56
+ `${baseUrl}/api/v0/environments/${PROJECT}/packages/${PKG}/models/${MODEL}/compile`,
57
+ {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify(body),
61
+ },
62
+ );
63
+
64
+ it("returns 403 with a source-only message when the gate is not satisfied", async () => {
65
+ const res = await compile({
66
+ source: "run: gated -> { aggregate: c }",
67
+ givens: {},
68
+ });
69
+ expect(res.status).toBe(403);
70
+ const json = (await res.json()) as { code: number; message: string };
71
+ expect(json.code).toBe(403);
72
+ expect(json.message).toBe('Access denied for source "gated".');
73
+ // Redaction: the runtime 403 must NOT leak the authorize expression.
74
+ expect(json.message).not.toContain("ROLE");
75
+ expect(json.message).not.toContain("analyst");
76
+ });
77
+
78
+ it("returns 200 when a satisfying given is supplied", async () => {
79
+ const res = await compile({
80
+ source: "run: gated -> { aggregate: c }",
81
+ givens: { ROLE: "analyst" },
82
+ });
83
+ expect(res.status).toBe(200);
84
+ });
85
+
86
+ it("compiles an ungated source without any given", async () => {
87
+ const res = await compile({
88
+ source: "run: open_src -> { aggregate: c }",
89
+ });
90
+ expect(res.status).toBe(200);
91
+ });
92
+ });
@@ -0,0 +1,138 @@
1
+ /// <reference types="bun-types" />
2
+
3
+ // Integration test for the DuckDB storage stack end-to-end:
4
+ // StorageManager -> ResourceRepository -> DuckDBConnection (@duckdb/node-api)
5
+ // -> a real on-disk publisher.db
6
+ //
7
+ // Unlike the repository unit tests (which construct a single repo against a bare
8
+ // connection) this drives the same path the server uses -- StorageManager.initialize()
9
+ // wires the schema + repositories over the migrated connection, and getRepository()
10
+ // is what the service layer calls. It also reopens a second StorageManager against
11
+ // the same file to prove data persists across a restart, which is the real
12
+ // end-to-end guarantee for the migration.
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
15
+ import fs from "fs/promises";
16
+ import os from "os";
17
+ import path from "path";
18
+ import { StorageManager } from "../../../src/storage/StorageManager";
19
+
20
+ describe("DuckDB storage stack (integration)", () => {
21
+ let dbDir: string;
22
+ let dbPath: string;
23
+ let sm: StorageManager;
24
+
25
+ beforeEach(async () => {
26
+ dbDir = await fs.mkdtemp(path.join(os.tmpdir(), "duckdb-storage-int-"));
27
+ dbPath = path.join(dbDir, "publisher.db");
28
+ sm = new StorageManager({ type: "duckdb", duckdb: { path: dbPath } });
29
+ await sm.initialize();
30
+ });
31
+
32
+ afterEach(async () => {
33
+ try {
34
+ await sm.close();
35
+ } catch {
36
+ // ignore
37
+ }
38
+ await fs.rm(dbDir, { recursive: true, force: true });
39
+ });
40
+
41
+ it("initializes the schema and reports ready", () => {
42
+ expect(sm.isInitialized()).toBe(true);
43
+ });
44
+
45
+ it("creates an environment with a package and connection through the repository", async () => {
46
+ const repo = sm.getRepository();
47
+
48
+ const env = await repo.createEnvironment({
49
+ name: "prod",
50
+ path: "/srv/prod",
51
+ metadata: { owner: "platform" },
52
+ });
53
+ expect(env.id).toBeTruthy();
54
+
55
+ const pkg = await repo.createPackage({
56
+ environmentId: env.id,
57
+ name: "sales",
58
+ manifestPath: "/srv/prod/sales/publisher.json",
59
+ });
60
+
61
+ const conn = await repo.createConnection({
62
+ environmentId: env.id,
63
+ name: "warehouse",
64
+ type: "snowflake",
65
+ config: { account: "acct", warehouse: "wh" },
66
+ });
67
+
68
+ // Read everything back through the same repository.
69
+ expect((await repo.getEnvironmentByName("prod"))?.id).toBe(env.id);
70
+ expect((await repo.listPackages(env.id)).map((p) => p.name)).toEqual([
71
+ "sales",
72
+ ]);
73
+ const conns = await repo.listConnections(env.id);
74
+ expect(conns.map((c) => c.name)).toEqual(["warehouse"]);
75
+ expect(conns[0].config).toEqual({ account: "acct", warehouse: "wh" });
76
+ expect(pkg.environmentId).toBe(env.id);
77
+ });
78
+
79
+ it("persists data across a StorageManager restart (reopen the same file)", async () => {
80
+ const repo = sm.getRepository();
81
+ const env = await repo.createEnvironment({
82
+ name: "persisted",
83
+ path: "/srv/persisted",
84
+ metadata: { keep: true },
85
+ });
86
+ await repo.createConnection({
87
+ environmentId: env.id,
88
+ name: "pg",
89
+ type: "postgres",
90
+ config: { host: "db.internal", port: 5432 },
91
+ });
92
+
93
+ // Close the manager (releases the file), then open a fresh one on the
94
+ // same path -- the data must still be there.
95
+ await sm.close();
96
+
97
+ const reopened = new StorageManager({
98
+ type: "duckdb",
99
+ duckdb: { path: dbPath },
100
+ });
101
+ await reopened.initialize();
102
+ try {
103
+ const repo2 = reopened.getRepository();
104
+ const env2 = await repo2.getEnvironmentByName("persisted");
105
+ expect(env2).not.toBeNull();
106
+ expect(env2!.metadata).toEqual({ keep: true });
107
+ expect(env2!.createdAt).toBeInstanceOf(Date);
108
+
109
+ const conns = await repo2.listConnections(env2!.id);
110
+ expect(conns.map((c) => c.name)).toEqual(["pg"]);
111
+ expect(conns[0].config).toEqual({ host: "db.internal", port: 5432 });
112
+ } finally {
113
+ await reopened.close();
114
+ }
115
+
116
+ // Point afterEach's close() at the already-closed original without error.
117
+ sm = reopened;
118
+ });
119
+
120
+ it("deleteEnvironment also removes the environment's connections", async () => {
121
+ const repo = sm.getRepository();
122
+ const env = await repo.createEnvironment({
123
+ name: "to-delete",
124
+ path: "/srv/del",
125
+ });
126
+ await repo.createConnection({
127
+ environmentId: env.id,
128
+ name: "c1",
129
+ type: "duckdb",
130
+ config: {},
131
+ });
132
+
133
+ await repo.deleteEnvironment(env.id);
134
+
135
+ expect(await repo.getEnvironmentById(env.id)).toBeNull();
136
+ expect(await repo.listConnections(env.id)).toEqual([]);
137
+ });
138
+ });