@malloy-publisher/server 0.0.194 → 0.0.196-dev

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.
package/dist/server.mjs CHANGED
@@ -220381,6 +220381,7 @@ import fs from "fs/promises";
220381
220381
  import path2 from "path";
220382
220382
 
220383
220383
  // src/service/connection_config.ts
220384
+ import { createPrivateKey } from "crypto";
220384
220385
  import path from "path";
220385
220386
  var PUBLISHER_DUCKDB_API_FIELDS = new Set(["attachedDatabases"]);
220386
220387
  function normalizeSnowflakePrivateKey(privateKey) {
@@ -220399,6 +220400,12 @@ function normalizeSnowflakePrivateKey(privateKey) {
220399
220400
  endRegex: /-----END\s+PRIVATE\s+KEY-----/i,
220400
220401
  beginMarker: "-----BEGIN PRIVATE KEY-----",
220401
220402
  endMarker: "-----END PRIVATE KEY-----"
220403
+ },
220404
+ {
220405
+ beginRegex: /-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i,
220406
+ endRegex: /-----END\s+RSA\s+PRIVATE\s+KEY-----/i,
220407
+ beginMarker: "-----BEGIN RSA PRIVATE KEY-----",
220408
+ endMarker: "-----END RSA PRIVATE KEY-----"
220402
220409
  }
220403
220410
  ];
220404
220411
  for (const pattern of keyPatterns) {
@@ -220425,6 +220432,21 @@ ${pattern.endMarker}
220425
220432
  privateKeyContent += `
220426
220433
  `;
220427
220434
  }
220435
+ if (/-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i.test(privateKeyContent)) {
220436
+ try {
220437
+ privateKeyContent = createPrivateKey({
220438
+ key: privateKeyContent,
220439
+ format: "pem"
220440
+ }).export({ type: "pkcs8", format: "pem" }).toString();
220441
+ } catch (err) {
220442
+ throw new Error(`Failed to convert Snowflake RSA private key (PKCS#1) to PKCS#8: ${err instanceof Error ? err.message : String(err)}`);
220443
+ }
220444
+ if (!privateKeyContent.endsWith(`
220445
+ `)) {
220446
+ privateKeyContent += `
220447
+ `;
220448
+ }
220449
+ }
220428
220450
  return privateKeyContent;
220429
220451
  }
220430
220452
  function validateDuckdbApiSurface(connection) {
@@ -224989,7 +225011,7 @@ class Mutex {
224989
225011
  }
224990
225012
 
224991
225013
  // src/service/project_store.ts
224992
- import crypto3 from "crypto";
225014
+ import crypto4 from "crypto";
224993
225015
  import * as fs7 from "fs";
224994
225016
  import * as path9 from "path";
224995
225017
 
@@ -229231,6 +229253,9 @@ function registerHealthEndpoints(app) {
229231
229253
  });
229232
229254
  }
229233
229255
 
229256
+ // src/storage/StorageManager.ts
229257
+ import * as crypto3 from "crypto";
229258
+
229234
229259
  // src/storage/duckdb/DuckDBConnection.ts
229235
229260
  import duckdb from "duckdb";
229236
229261
  import * as path5 from "path";
@@ -230146,15 +230171,17 @@ async function dropAllTables(db) {
230146
230171
  class DuckLakeManifestStore {
230147
230172
  db;
230148
230173
  table;
230149
- constructor(db, catalogName) {
230174
+ projectName;
230175
+ constructor(db, catalogName, projectName) {
230150
230176
  this.db = db;
230151
230177
  this.table = `${catalogName}.build_manifests`;
230178
+ this.projectName = projectName;
230152
230179
  }
230153
230180
  async bootstrapSchema() {
230154
230181
  await this.db.run(`
230155
230182
  CREATE TABLE IF NOT EXISTS ${this.table} (
230156
230183
  id VARCHAR,
230157
- project_id VARCHAR NOT NULL,
230184
+ project_name VARCHAR NOT NULL,
230158
230185
  package_name VARCHAR NOT NULL,
230159
230186
  build_id VARCHAR NOT NULL,
230160
230187
  table_name VARCHAR NOT NULL,
@@ -230166,8 +230193,8 @@ class DuckLakeManifestStore {
230166
230193
  `);
230167
230194
  logger.info(`DuckLake manifest table bootstrapped: ${this.table}`);
230168
230195
  }
230169
- async getManifest(projectId, packageName) {
230170
- const rows = await this.db.all(`SELECT * FROM ${this.table} WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC`, [projectId, packageName]);
230196
+ async getManifest(_projectId, packageName) {
230197
+ const rows = await this.db.all(`SELECT * FROM ${this.table} WHERE project_name = ? AND package_name = ? ORDER BY created_at DESC`, [this.projectName, packageName]);
230171
230198
  const manifest = { entries: {}, strict: false };
230172
230199
  for (const row of rows) {
230173
230200
  const buildId = row.build_id;
@@ -230179,13 +230206,13 @@ class DuckLakeManifestStore {
230179
230206
  }
230180
230207
  return manifest;
230181
230208
  }
230182
- async writeEntry(projectId, packageName, buildId, tableName, sourceName, connectionName) {
230209
+ async writeEntry(_projectId, packageName, buildId, tableName, sourceName, connectionName) {
230183
230210
  const now = new Date().toISOString();
230184
230211
  const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
230185
- await this.db.run(`INSERT INTO ${this.table} (id, project_id, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
230212
+ await this.db.run(`INSERT INTO ${this.table} (id, project_name, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
230186
230213
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
230187
230214
  id,
230188
- projectId,
230215
+ this.projectName,
230189
230216
  packageName,
230190
230217
  buildId,
230191
230218
  tableName,
@@ -230198,14 +230225,14 @@ class DuckLakeManifestStore {
230198
230225
  async deleteEntry(id) {
230199
230226
  await this.db.run(`DELETE FROM ${this.table} WHERE id = ?`, [id]);
230200
230227
  }
230201
- async listEntries(projectId, packageName) {
230202
- const rows = await this.db.all(`SELECT * FROM ${this.table} WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC`, [projectId, packageName]);
230228
+ async listEntries(_projectId, packageName) {
230229
+ const rows = await this.db.all(`SELECT * FROM ${this.table} WHERE project_name = ? AND package_name = ? ORDER BY created_at DESC`, [this.projectName, packageName]);
230203
230230
  return rows.map(this.mapToEntry);
230204
230231
  }
230205
230232
  mapToEntry(row) {
230206
230233
  return {
230207
230234
  id: row.id,
230208
- projectId: row.project_id,
230235
+ projectId: row.project_name,
230209
230236
  packageName: row.package_name,
230210
230237
  buildId: row.build_id,
230211
230238
  tableName: row.table_name,
@@ -230221,6 +230248,13 @@ class DuckLakeManifestStore {
230221
230248
  function escapeSQL2(value) {
230222
230249
  return value.replace(/'/g, "''");
230223
230250
  }
230251
+ function configKey(c) {
230252
+ return `${c.catalogUrl}|${c.dataPath}`;
230253
+ }
230254
+ function catalogNameForConfig(c) {
230255
+ const hash = crypto3.createHash("sha256").update(configKey(c)).digest("hex").slice(0, 8);
230256
+ return `manifest_lake_${hash}`;
230257
+ }
230224
230258
 
230225
230259
  class StorageManager {
230226
230260
  connection = null;
@@ -230228,7 +230262,7 @@ class StorageManager {
230228
230262
  repository = null;
230229
230263
  defaultManifestStore = null;
230230
230264
  projectManifestStores = new Map;
230231
- attachedCatalogs = new Set;
230265
+ attachedCatalogs = new Map;
230232
230266
  config;
230233
230267
  constructor(config) {
230234
230268
  this.config = config;
@@ -230261,19 +230295,23 @@ class StorageManager {
230261
230295
  this.repository = new DuckDBRepository(connection);
230262
230296
  this.defaultManifestStore = new DuckDBManifestStore(this.repository);
230263
230297
  }
230264
- async initializeDuckLakeForProject(projectId, config) {
230298
+ async initializeDuckLakeForProject(projectId, projectName, config) {
230265
230299
  if (!this.duckDbConnection) {
230266
230300
  throw new Error("Storage not initialized. Call initialize() first.");
230267
230301
  }
230268
- const catalogName = `manifest_lake_${projectId.replace(/[^a-zA-Z0-9_]/g, "_")}`;
230269
- if (!this.attachedCatalogs.has(catalogName)) {
230302
+ const key = configKey(config);
230303
+ let catalogName = this.attachedCatalogs.get(key);
230304
+ if (!catalogName) {
230305
+ catalogName = catalogNameForConfig(config);
230270
230306
  await this.attachDuckLakeCatalog(config, catalogName);
230307
+ this.attachedCatalogs.set(key, catalogName);
230271
230308
  }
230272
- const store = new DuckLakeManifestStore(this.duckDbConnection, catalogName);
230309
+ const store = new DuckLakeManifestStore(this.duckDbConnection, catalogName, projectName);
230273
230310
  await store.bootstrapSchema();
230274
230311
  this.projectManifestStores.set(projectId, store);
230275
230312
  logger.info("DuckLake manifest store initialized for project", {
230276
230313
  projectId,
230314
+ projectName,
230277
230315
  catalogName
230278
230316
  });
230279
230317
  }
@@ -230288,14 +230326,16 @@ class StorageManager {
230288
230326
  const escapedDataPath = escapeSQL2(config.dataPath);
230289
230327
  const isCloudStorage = config.dataPath.startsWith("gs://") || config.dataPath.startsWith("s3://");
230290
230328
  let attachCmd = `ATTACH 'ducklake:${escapedCatalogUrl}' AS ${catalogName}`;
230291
- const attachOpts = [`DATA_PATH '${escapedDataPath}'`];
230329
+ const attachOpts = [
230330
+ `DATA_PATH '${escapedDataPath}'`,
230331
+ "DATA_INLINING_ROW_LIMIT 100000"
230332
+ ];
230292
230333
  if (isCloudStorage) {
230293
230334
  attachOpts.push("OVERRIDE_DATA_PATH true");
230294
230335
  }
230295
230336
  attachCmd += ` (${attachOpts.join(", ")});`;
230296
230337
  logger.info(`Attaching DuckLake manifest catalog: ${attachCmd}`);
230297
230338
  await connection.run(attachCmd);
230298
- this.attachedCatalogs.add(catalogName);
230299
230339
  }
230300
230340
  getRepository() {
230301
230341
  if (!this.repository) {
@@ -230379,8 +230419,8 @@ import {
230379
230419
  MalloySQLParser,
230380
230420
  MalloySQLStatementType
230381
230421
  } from "@malloydata/malloy-sql";
230382
- import { createRequire as createRequire2 } from "module";
230383
230422
  import * as fs4 from "fs/promises";
230423
+ import { createRequire as createRequire2 } from "module";
230384
230424
  import * as path6 from "path";
230385
230425
 
230386
230426
  // src/data_styles.ts
@@ -231010,7 +231050,7 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
231010
231050
  }
231011
231051
  const modelURL = new URL(`file://${fullModelPath}`);
231012
231052
  const baseUrl = new URL(".", modelURL);
231013
- const importBaseURL = new URL(baseUrl.pathname + "/", "file:");
231053
+ const importBaseURL = baseUrl;
231014
231054
  const urlReader = new HackyDataStylesAccumulator(URL_READER);
231015
231055
  const runtime = new Runtime({
231016
231056
  urlReader,
@@ -231556,6 +231596,9 @@ class Project {
231556
231596
  this.metadata.readme = payload.readme;
231557
231597
  await this.writeProjectReadme(payload.readme);
231558
231598
  }
231599
+ if (payload.materializationStorage !== undefined) {
231600
+ this.metadata.materializationStorage = payload.materializationStorage;
231601
+ }
231559
231602
  if (payload.connections) {
231560
231603
  const payloadConnections = payload.connections;
231561
231604
  await this.runConnectionUpdateExclusive(async () => {
@@ -232131,7 +232174,7 @@ class ProjectStore {
232131
232174
  }
232132
232175
  const materializationStorage = project.metadata?.materializationStorage;
232133
232176
  if (materializationStorage?.catalogUrl && materializationStorage?.dataPath) {
232134
- await this.storageManager.initializeDuckLakeForProject(dbProject.id, {
232177
+ await this.storageManager.initializeDuckLakeForProject(dbProject.id, dbProject.name, {
232135
232178
  catalogUrl: materializationStorage.catalogUrl,
232136
232179
  dataPath: materializationStorage.dataPath
232137
232180
  });
@@ -232407,6 +232450,9 @@ class ProjectStore {
232407
232450
  if (!newProject.metadata)
232408
232451
  newProject.metadata = {};
232409
232452
  newProject.metadata.location = absoluteProjectPath;
232453
+ if (project.materializationStorage !== undefined) {
232454
+ newProject.metadata.materializationStorage = project.materializationStorage;
232455
+ }
232410
232456
  this.projects.set(projectName, newProject);
232411
232457
  project?.packages?.forEach((_package) => {
232412
232458
  if (_package.name) {
@@ -232561,7 +232607,7 @@ class ProjectStore {
232561
232607
  });
232562
232608
  }
232563
232609
  for (const [groupedLocation, packagesForLocation] of locationGroups) {
232564
- const locationHash = crypto3.createHash("sha256").update(groupedLocation).digest("hex").substring(0, 16);
232610
+ const locationHash = crypto4.createHash("sha256").update(groupedLocation).digest("hex").substring(0, 16);
232565
232611
  const tempDownloadPath = `${absoluteTargetPath}/.temp_${locationHash}`;
232566
232612
  await fs7.promises.mkdir(tempDownloadPath, { recursive: true });
232567
232613
  logger.info(`Created temporary directory: ${tempDownloadPath}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.194",
4
+ "version": "0.0.196-dev",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "test": "bun run test:unit && bun run test:integration",
18
18
  "test:unit": "bun test --timeout 100000 src",
19
- "test:integration": "bun test --timeout 100000 tests",
19
+ "test:integration": "bun test --timeout 200000 tests --max-workers=1",
20
20
  "build": "bun generate-api-types && bun build:app && NODE_ENV=production bun run build.ts",
21
21
  "build:server-only": "bun generate-api-types && NODE_ENV=production bun run build.ts",
22
22
  "start": "NODE_ENV=production bun run ./dist/server.mjs",
@@ -1,5 +1,9 @@
1
+ import { generateKeyPairSync } from "crypto";
1
2
  import { describe, expect, it } from "bun:test";
2
- import { assembleProjectConnections } from "./connection_config";
3
+ import {
4
+ assembleProjectConnections,
5
+ normalizeSnowflakePrivateKey,
6
+ } from "./connection_config";
3
7
  import { components } from "../api";
4
8
 
5
9
  type ApiConnection = components["schemas"]["Connection"];
@@ -121,3 +125,44 @@ describe("assembleProjectConnections — databricks", () => {
121
125
  );
122
126
  });
123
127
  });
128
+
129
+ describe("normalizeSnowflakePrivateKey", () => {
130
+ const { privateKey: pkcs8Pem } = generateKeyPairSync("rsa", {
131
+ modulusLength: 2048,
132
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
133
+ publicKeyEncoding: { type: "spki", format: "pem" },
134
+ });
135
+ const { privateKey: pkcs1Pem } = generateKeyPairSync("rsa", {
136
+ modulusLength: 2048,
137
+ privateKeyEncoding: { type: "pkcs1", format: "pem" },
138
+ publicKeyEncoding: { type: "pkcs1", format: "pem" },
139
+ });
140
+
141
+ it("passes a multi-line PKCS#8 key through and adds a trailing newline", () => {
142
+ const trimmed = (pkcs8Pem as string).trimEnd();
143
+ const result = normalizeSnowflakePrivateKey(trimmed);
144
+ expect(result).toContain("-----BEGIN PRIVATE KEY-----");
145
+ expect(result.endsWith("\n")).toBe(true);
146
+ });
147
+
148
+ it("converts a multi-line PKCS#1 RSA key to PKCS#8", () => {
149
+ const result = normalizeSnowflakePrivateKey(pkcs1Pem as string);
150
+ expect(result).toContain("-----BEGIN PRIVATE KEY-----");
151
+ expect(result).not.toContain("BEGIN RSA PRIVATE KEY");
152
+ expect(result.endsWith("\n")).toBe(true);
153
+ });
154
+
155
+ it("converts a single-line PKCS#1 RSA key to PKCS#8", () => {
156
+ const singleLine = (pkcs1Pem as string).replace(/\n/g, "");
157
+ const result = normalizeSnowflakePrivateKey(singleLine);
158
+ expect(result).toContain("-----BEGIN PRIVATE KEY-----");
159
+ expect(result).not.toContain("BEGIN RSA PRIVATE KEY");
160
+ });
161
+
162
+ it("reconstructs a single-line PKCS#8 key without conversion", () => {
163
+ const singleLine = (pkcs8Pem as string).replace(/\n/g, "");
164
+ const result = normalizeSnowflakePrivateKey(singleLine);
165
+ expect(result.startsWith("-----BEGIN PRIVATE KEY-----\n")).toBe(true);
166
+ expect(result.endsWith("-----END PRIVATE KEY-----\n")).toBe(true);
167
+ });
168
+ });
@@ -1,3 +1,4 @@
1
+ import { createPrivateKey } from "crypto";
1
2
  import path from "path";
2
3
  import { components } from "../api";
3
4
 
@@ -48,6 +49,12 @@ export function normalizeSnowflakePrivateKey(privateKey: string): string {
48
49
  beginMarker: "-----BEGIN PRIVATE KEY-----",
49
50
  endMarker: "-----END PRIVATE KEY-----",
50
51
  },
52
+ {
53
+ beginRegex: /-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i,
54
+ endRegex: /-----END\s+RSA\s+PRIVATE\s+KEY-----/i,
55
+ beginMarker: "-----BEGIN RSA PRIVATE KEY-----",
56
+ endMarker: "-----END RSA PRIVATE KEY-----",
57
+ },
51
58
  ];
52
59
 
53
60
  for (const pattern of keyPatterns) {
@@ -73,6 +80,28 @@ export function normalizeSnowflakePrivateKey(privateKey: string): string {
73
80
  privateKeyContent += "\n";
74
81
  }
75
82
 
83
+ // Snowflake's Node SDK requires PKCS#8 ("BEGIN PRIVATE KEY"). Convert
84
+ // PKCS#1 ("BEGIN RSA PRIVATE KEY") so users can paste either format.
85
+ if (/-----BEGIN\s+RSA\s+PRIVATE\s+KEY-----/i.test(privateKeyContent)) {
86
+ try {
87
+ privateKeyContent = createPrivateKey({
88
+ key: privateKeyContent,
89
+ format: "pem",
90
+ })
91
+ .export({ type: "pkcs8", format: "pem" })
92
+ .toString();
93
+ } catch (err) {
94
+ throw new Error(
95
+ `Failed to convert Snowflake RSA private key (PKCS#1) to PKCS#8: ${
96
+ err instanceof Error ? err.message : String(err)
97
+ }`,
98
+ );
99
+ }
100
+ if (!privateKeyContent.endsWith("\n")) {
101
+ privateKeyContent += "\n";
102
+ }
103
+ }
104
+
76
105
  return privateKeyContent;
77
106
  }
78
107
 
@@ -22,10 +22,10 @@ import {
22
22
  MalloySQLParser,
23
23
  MalloySQLStatementType,
24
24
  } from "@malloydata/malloy-sql";
25
- import { createRequire } from "module";
26
25
  import { DataStyles } from "@malloydata/render";
27
26
  import { metrics } from "@opentelemetry/api";
28
27
  import * as fs from "fs/promises";
28
+ import { createRequire } from "module";
29
29
  import * as path from "path";
30
30
  import { components } from "../api";
31
31
  import {
@@ -703,7 +703,7 @@ export class Model {
703
703
 
704
704
  const modelURL = new URL(`file://${fullModelPath}`);
705
705
  const baseUrl = new URL(".", modelURL);
706
- const importBaseURL = new URL(baseUrl.pathname + "/", "file:");
706
+ const importBaseURL = baseUrl;
707
707
  const urlReader = new HackyDataStylesAccumulator(URL_READER);
708
708
 
709
709
  // Request runtimes borrow the cached package MalloyConfig. The package
@@ -99,6 +99,10 @@ export class Project {
99
99
  await this.writeProjectReadme(payload.readme);
100
100
  }
101
101
 
102
+ if (payload.materializationStorage !== undefined) {
103
+ this.metadata.materializationStorage = payload.materializationStorage;
104
+ }
105
+
102
106
  // Handle connections update
103
107
  // TODO: Update project connections should have its own API endpoint
104
108
  if (payload.connections) {
@@ -10,6 +10,12 @@ import { ProjectStore } from "./project_store";
10
10
 
11
11
  type MockData = Record<string, unknown>;
12
12
 
13
+ const initializeDuckLakeCalls: Array<{
14
+ projectId: string;
15
+ projectName: string;
16
+ config: { catalogUrl: string; dataPath: string };
17
+ }> = [];
18
+
13
19
  mock.module("../storage/StorageManager", () => {
14
20
  return {
15
21
  StorageManager: class MockStorageManager {
@@ -17,6 +23,14 @@ mock.module("../storage/StorageManager", () => {
17
23
  return;
18
24
  }
19
25
 
26
+ async initializeDuckLakeForProject(
27
+ projectId: string,
28
+ projectName: string,
29
+ config: { catalogUrl: string; dataPath: string },
30
+ ): Promise<void> {
31
+ initializeDuckLakeCalls.push({ projectId, projectName, config });
32
+ }
33
+
20
34
  getRepository() {
21
35
  return {
22
36
  // ===== PROJECT METHODS =====
@@ -467,6 +481,69 @@ describe("ProjectStore Service", () => {
467
481
  expect(readmeContent).toBe("Updated README content");
468
482
  });
469
483
 
484
+ it("should propagate materializationStorage on addProject for new project", async () => {
485
+ writeFileSync(
486
+ path.join(serverRootPath, "publisher.config.json"),
487
+ JSON.stringify({ frozenConfig: false, projects: [] }),
488
+ );
489
+
490
+ await projectStore.finishedInitialization;
491
+
492
+ const materializationStorage = {
493
+ catalogUrl:
494
+ "postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
495
+ dataPath: "gs://test-bucket",
496
+ };
497
+
498
+ initializeDuckLakeCalls.length = 0;
499
+ const project = await projectStore.addProject({
500
+ name: projectName,
501
+ materializationStorage,
502
+ });
503
+
504
+ expect(project.metadata.materializationStorage).toEqual(
505
+ materializationStorage,
506
+ );
507
+ expect(initializeDuckLakeCalls).toHaveLength(1);
508
+ expect(initializeDuckLakeCalls[0].config).toEqual(materializationStorage);
509
+ });
510
+
511
+ it("should propagate materializationStorage on update", async () => {
512
+ const projectPath = path.join(serverRootPath, projectName);
513
+ mkdirSync(projectPath, { recursive: true });
514
+ writeFileSync(
515
+ path.join(projectPath, "publisher.json"),
516
+ JSON.stringify({ name: projectName, description: "Test package" }),
517
+ );
518
+ writeFileSync(
519
+ path.join(serverRootPath, "publisher.config.json"),
520
+ JSON.stringify({
521
+ frozenConfig: false,
522
+ projects: [
523
+ {
524
+ name: projectName,
525
+ packages: [{ name: projectName, location: projectPath }],
526
+ },
527
+ ],
528
+ }),
529
+ );
530
+
531
+ await projectStore.finishedInitialization;
532
+ const project = await projectStore.getProject(projectName);
533
+
534
+ const materializationStorage = {
535
+ catalogUrl:
536
+ "postgres:host=localhost port=5432 dbname=ducklake user=u password=p",
537
+ dataPath: "gs://test-bucket",
538
+ };
539
+
540
+ await project.update({ name: projectName, materializationStorage });
541
+
542
+ expect(project.metadata.materializationStorage).toEqual(
543
+ materializationStorage,
544
+ );
545
+ });
546
+
470
547
  it(
471
548
  "should handle project reload",
472
549
  async () => {
@@ -359,10 +359,14 @@ export class ProjectStore {
359
359
  materializationStorage?.catalogUrl &&
360
360
  materializationStorage?.dataPath
361
361
  ) {
362
- await this.storageManager.initializeDuckLakeForProject(dbProject.id, {
363
- catalogUrl: materializationStorage.catalogUrl,
364
- dataPath: materializationStorage.dataPath,
365
- });
362
+ await this.storageManager.initializeDuckLakeForProject(
363
+ dbProject.id,
364
+ dbProject.name,
365
+ {
366
+ catalogUrl: materializationStorage.catalogUrl,
367
+ dataPath: materializationStorage.dataPath,
368
+ },
369
+ );
366
370
  }
367
371
 
368
372
  return dbProject;
@@ -807,6 +811,10 @@ export class ProjectStore {
807
811
 
808
812
  if (!newProject.metadata) newProject.metadata = {};
809
813
  newProject.metadata.location = absoluteProjectPath;
814
+ if (project.materializationStorage !== undefined) {
815
+ newProject.metadata.materializationStorage =
816
+ project.materializationStorage;
817
+ }
810
818
 
811
819
  this.projects.set(projectName, newProject);
812
820
 
@@ -1,3 +1,4 @@
1
+ import * as crypto from "crypto";
1
2
  import { logger } from "../logger";
2
3
  import {
3
4
  DatabaseConnection,
@@ -42,6 +43,19 @@ function escapeSQL(value: string): string {
42
43
  return value.replace(/'/g, "''");
43
44
  }
44
45
 
46
+ function configKey(c: DuckLakeManifestConfig): string {
47
+ return `${c.catalogUrl}|${c.dataPath}`;
48
+ }
49
+
50
+ function catalogNameForConfig(c: DuckLakeManifestConfig): string {
51
+ const hash = crypto
52
+ .createHash("sha256")
53
+ .update(configKey(c))
54
+ .digest("hex")
55
+ .slice(0, 8);
56
+ return `manifest_lake_${hash}`;
57
+ }
58
+
45
59
  /**
46
60
  * Manages the storage backend (DuckDB, Postgres, etc.) and per-project
47
61
  * manifest stores. Projects without `materializationStorage` config use
@@ -57,8 +71,12 @@ export class StorageManager {
57
71
  /** Per-project DuckLake manifest stores, keyed by projectId. */
58
72
  private projectManifestStores = new Map<string, ManifestStore>();
59
73
 
60
- /** Tracks which catalogs have been attached to avoid duplicate ATTACHes. */
61
- private attachedCatalogs = new Set<string>();
74
+ /**
75
+ * Tracks attached DuckLake catalogs as `configKey -> catalogName`. Each
76
+ * unique materializationStorage config gets its own ATTACHment under a
77
+ * deterministic catalog name, so multiple configs can coexist on one worker.
78
+ */
79
+ private attachedCatalogs = new Map<string, string>();
62
80
 
63
81
  private config: StorageConfig;
64
82
 
@@ -105,32 +123,44 @@ export class StorageManager {
105
123
 
106
124
  /**
107
125
  * Lazily initializes a DuckLake manifest store for a project.
108
- * The DuckLake catalog is attached to the shared DuckDB connection
109
- * and persists for the lifetime of the process.
126
+ *
127
+ * One shared catalog per materializationStorage config: every project
128
+ * pointing at the same (catalogUrl, dataPath) shares one `build_manifests`
129
+ * table inside it, partitioned by `project_id` (set to the project's name
130
+ * so it's stable across worker replicas — required for cross-pod manifest
131
+ * visibility in orchestrated mode). Different configs (e.g. different
132
+ * orgs) attach as separate catalogs under distinct deterministic aliases.
110
133
  */
111
134
  async initializeDuckLakeForProject(
112
135
  projectId: string,
136
+ projectName: string,
113
137
  config: DuckLakeManifestConfig,
114
138
  ): Promise<void> {
115
139
  if (!this.duckDbConnection) {
116
140
  throw new Error("Storage not initialized. Call initialize() first.");
117
141
  }
118
142
 
119
- const catalogName = `manifest_lake_${projectId.replace(/[^a-zA-Z0-9_]/g, "_")}`;
120
-
121
- if (!this.attachedCatalogs.has(catalogName)) {
143
+ 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);
122
149
  await this.attachDuckLakeCatalog(config, catalogName);
150
+ this.attachedCatalogs.set(key, catalogName);
123
151
  }
124
152
 
125
153
  const store = new DuckLakeManifestStore(
126
154
  this.duckDbConnection,
127
155
  catalogName,
156
+ projectName,
128
157
  );
129
158
  await store.bootstrapSchema();
130
159
 
131
160
  this.projectManifestStores.set(projectId, store);
132
161
  logger.info("DuckLake manifest store initialized for project", {
133
162
  projectId,
163
+ projectName,
134
164
  catalogName,
135
165
  });
136
166
  }
@@ -155,7 +185,14 @@ export class StorageManager {
155
185
  config.dataPath.startsWith("s3://");
156
186
 
157
187
  let attachCmd = `ATTACH 'ducklake:${escapedCatalogUrl}' AS ${catalogName}`;
158
- const attachOpts: string[] = [`DATA_PATH '${escapedDataPath}'`];
188
+ const attachOpts: string[] = [
189
+ `DATA_PATH '${escapedDataPath}'`,
190
+ // The manifest table is small relational metadata (one row per build).
191
+ // Set a high inlining limit so writes always land transactionally in
192
+ // the postgres catalog rather than as parquet files in object storage,
193
+ // sidestepping object-storage auth issues entirely for this path.
194
+ "DATA_INLINING_ROW_LIMIT 100000",
195
+ ];
159
196
  if (isCloudStorage) {
160
197
  attachOpts.push("OVERRIDE_DATA_PATH true");
161
198
  }
@@ -163,8 +200,6 @@ export class StorageManager {
163
200
 
164
201
  logger.info(`Attaching DuckLake manifest catalog: ${attachCmd}`);
165
202
  await connection.run(attachCmd);
166
-
167
- this.attachedCatalogs.add(catalogName);
168
203
  }
169
204
 
170
205
  getRepository(): ResourceRepository {
@@ -31,23 +31,32 @@ import { DuckDBConnection } from "../duckdb/DuckDBConnection";
31
31
  */
32
32
  export class DuckLakeManifestStore implements ManifestStore {
33
33
  private readonly table: string;
34
+ private readonly projectName: string;
34
35
 
35
36
  constructor(
36
37
  private db: DuckDBConnection,
37
38
  catalogName: string,
39
+ projectName: string,
38
40
  ) {
39
41
  this.table = `${catalogName}.build_manifests`;
42
+ this.projectName = projectName;
40
43
  }
41
44
 
42
45
  /**
43
46
  * Idempotently creates the `build_manifests` table and indices in the
44
47
  * DuckLake catalog. Safe to call from every worker on startup.
48
+ *
49
+ * Note: this table uses `project_name` for the partition column, unlike
50
+ * the local DuckDB `build_manifests` table (in `storage/duckdb/schema.ts`)
51
+ * which uses `project_id` and FK-references `projects.id`. The two stores
52
+ * partition by different identifiers — local random id vs cross-pod-stable
53
+ * project name — so they intentionally diverge here.
45
54
  */
46
55
  async bootstrapSchema(): Promise<void> {
47
56
  await this.db.run(`
48
57
  CREATE TABLE IF NOT EXISTS ${this.table} (
49
58
  id VARCHAR,
50
- project_id VARCHAR NOT NULL,
59
+ project_name VARCHAR NOT NULL,
51
60
  package_name VARCHAR NOT NULL,
52
61
  build_id VARCHAR NOT NULL,
53
62
  table_name VARCHAR NOT NULL,
@@ -61,12 +70,12 @@ export class DuckLakeManifestStore implements ManifestStore {
61
70
  }
62
71
 
63
72
  async getManifest(
64
- projectId: string,
73
+ _projectId: string,
65
74
  packageName: string,
66
75
  ): Promise<BuildManifest> {
67
76
  const rows = await this.db.all<Record<string, unknown>>(
68
- `SELECT * FROM ${this.table} WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC`,
69
- [projectId, packageName],
77
+ `SELECT * FROM ${this.table} WHERE project_name = ? AND package_name = ? ORDER BY created_at DESC`,
78
+ [this.projectName, packageName],
70
79
  );
71
80
  const manifest: BuildManifest = { entries: {}, strict: false };
72
81
  for (const row of rows) {
@@ -88,7 +97,7 @@ export class DuckLakeManifestStore implements ManifestStore {
88
97
  * {@link getManifest} deduplicates by build_id keeping the newest row.
89
98
  */
90
99
  async writeEntry(
91
- projectId: string,
100
+ _projectId: string,
92
101
  packageName: string,
93
102
  buildId: string,
94
103
  tableName: string,
@@ -99,11 +108,11 @@ export class DuckLakeManifestStore implements ManifestStore {
99
108
  const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
100
109
 
101
110
  await this.db.run(
102
- `INSERT INTO ${this.table} (id, project_id, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
111
+ `INSERT INTO ${this.table} (id, project_name, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
103
112
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
104
113
  [
105
114
  id,
106
- projectId,
115
+ this.projectName,
107
116
  packageName,
108
117
  buildId,
109
118
  tableName,
@@ -120,12 +129,12 @@ export class DuckLakeManifestStore implements ManifestStore {
120
129
  }
121
130
 
122
131
  async listEntries(
123
- projectId: string,
132
+ _projectId: string,
124
133
  packageName: string,
125
134
  ): Promise<ManifestEntry[]> {
126
135
  const rows = await this.db.all<Record<string, unknown>>(
127
- `SELECT * FROM ${this.table} WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC`,
128
- [projectId, packageName],
136
+ `SELECT * FROM ${this.table} WHERE project_name = ? AND package_name = ? ORDER BY created_at DESC`,
137
+ [this.projectName, packageName],
129
138
  );
130
139
  return rows.map(this.mapToEntry);
131
140
  }
@@ -133,7 +142,7 @@ export class DuckLakeManifestStore implements ManifestStore {
133
142
  private mapToEntry(row: Record<string, unknown>): ManifestEntry {
134
143
  return {
135
144
  id: row.id as string,
136
- projectId: row.project_id as string,
145
+ projectId: row.project_name as string,
137
146
  packageName: row.package_name as string,
138
147
  buildId: row.build_id as string,
139
148
  tableName: row.table_name as string,
@@ -5,6 +5,7 @@ import {
5
5
  Request,
6
6
  Result,
7
7
  } from "@modelcontextprotocol/sdk/types.js";
8
+ import { Mutex } from "async-mutex";
8
9
  import http from "http";
9
10
  import { AddressInfo } from "net";
10
11
  import path from "path";
@@ -24,37 +25,24 @@ export interface McpE2ETestEnvironment {
24
25
  originalInitializeStorage: string | undefined;
25
26
  }
26
27
 
27
- // Counter for unique port assignment per test suite
28
+ // Counter for unique port assignment per test suite (incremented only under e2eSetupMutex)
28
29
  let portCounter = 0;
29
30
 
30
- // Mutex to prevent concurrent initialization (since all tests share the same database)
31
- let initializationLock: Promise<void> | null = null;
31
+ // True mutex: the previous Promise-based lock had a TOCTOU race where two beforeAll hooks
32
+ // could both pass the `if (initializationLock)` check and run setup concurrently, sharing
33
+ // one publisher.db / server singleton and causing flaky listResources and port collisions.
34
+ const e2eSetupMutex = new Mutex();
32
35
 
33
36
  /**
34
37
  * Starts the real application server and connects a real MCP client.
35
38
  */
36
39
  export async function setupE2ETestEnvironment(): Promise<McpE2ETestEnvironment> {
37
- // Wait for any ongoing initialization to complete (prevents concurrent DB initialization)
38
- if (initializationLock) {
40
+ return e2eSetupMutex.runExclusive(async () => {
39
41
  console.log(
40
- "[E2E Test Setup] Waiting for previous initialization to complete...",
42
+ "[E2E Test Setup] Acquired setup mutex; starting environment...",
41
43
  );
42
- await initializationLock;
43
- }
44
-
45
- // Create a new lock for this initialization
46
- let resolveLock: () => void;
47
- initializationLock = new Promise((resolve) => {
48
- resolveLock = resolve;
44
+ return setupE2ETestEnvironmentInternal();
49
45
  });
50
-
51
- try {
52
- return await setupE2ETestEnvironmentInternal();
53
- } finally {
54
- // Release the lock
55
- resolveLock!();
56
- initializationLock = null;
57
- }
58
46
  }
59
47
 
60
48
  async function setupE2ETestEnvironmentInternal(): Promise<McpE2ETestEnvironment> {
@@ -129,9 +117,9 @@ async function setupE2ETestEnvironmentInternal(): Promise<McpE2ETestEnvironment>
129
117
 
130
118
  // --- Wait for server to be ready (packages downloaded) ---
131
119
  // Poll the readiness endpoint to ensure initialization completes before tests run
132
- // Note: Reduced timeout to 90s to be under the 100s test timeout
120
+ // Allow >90s: cold CI can spend most of the budget on clone + package load before markReady().
133
121
  console.log("[E2E Test Setup] Waiting for server to be ready...");
134
- const maxWaitTime = 90000; // 90 seconds max wait (under 100s test timeout)
122
+ const maxWaitTime = 150000; // 150 seconds (INITIALIZE_STORAGE + large samples can be slow)
135
123
  const pollInterval = 1000; // Check every second
136
124
  const startTime = Date.now();
137
125
  let isReady = false;
@@ -19,7 +19,7 @@ import {
19
19
  } from "../../harness/mcp_test_setup";
20
20
 
21
21
  // --- Test Suite ---
22
- describe("MCP Tool Handlers (E2E Integration)", () => {
22
+ describe.serial("MCP Tool Handlers (E2E Integration)", () => {
23
23
  let env: McpE2ETestEnvironment | null = null;
24
24
  let mcpClient: Client;
25
25
 
@@ -20,7 +20,8 @@ interface PackageContentEntry {
20
20
  }
21
21
 
22
22
  // --- Test Suite ---
23
- describe("MCP Resource Handlers (E2E Integration)", () => {
23
+ // Serial: one shared MCP client; concurrent it() blocks can interleave StreamableHTTP requests.
24
+ describe.serial("MCP Resource Handlers (E2E Integration)", () => {
24
25
  let env: McpE2ETestEnvironment | null = null;
25
26
  let mcpClient: Client;
26
27
 
@@ -85,17 +86,17 @@ describe("MCP Resource Handlers (E2E Integration)", () => {
85
86
  expect(faaPackageEntry).toBeDefined();
86
87
  expect(faaPackageEntry?.name).toBe("faa");
87
88
 
88
- const firstResource = result.resources[0];
89
- expect(firstResource).toBeDefined();
90
- expect(firstResource.uri).toBeDefined();
91
- expect(typeof firstResource.uri).toBe("string");
92
- expect(firstResource.uri).toMatch(/^malloy:\/\//);
93
- expect(firstResource.name).toBeDefined();
94
- expect(typeof firstResource.name).toBe("string");
95
- expect(firstResource.description).toBeDefined();
96
- expect(typeof firstResource.description).toBe("string");
97
- if (firstResource.mimeType) {
98
- expect(typeof firstResource.mimeType).toBe("string");
89
+ // Assert shape on a known row (order of listResources is not guaranteed).
90
+ const sample = faaPackageEntry!;
91
+ expect(sample.uri).toBeDefined();
92
+ expect(typeof sample.uri).toBe("string");
93
+ expect(sample.uri).toMatch(/^malloy:\/\//);
94
+ expect(sample.name).toBeDefined();
95
+ expect(typeof sample.name).toBe("string");
96
+ expect(sample.description).toBeDefined();
97
+ expect(typeof sample.description).toBe("string");
98
+ if (sample.mimeType) {
99
+ expect(typeof sample.mimeType).toBe("string");
99
100
  }
100
101
  },
101
102
  { timeout: 30000 },
@@ -18,7 +18,7 @@ import {
18
18
  // --- Test Suite ---
19
19
  // Note: These tests assume interaction via a standard MCP client.
20
20
  // Tests for raw HTTP edge cases (non-JSON, non-RPC) are omitted based on this assumption.
21
- describe("MCP Transport Tests (E2E Integration)", () => {
21
+ describe.serial("MCP Transport Tests (E2E Integration)", () => {
22
22
  let env: McpE2ETestEnvironment | null = null;
23
23
  let mcpClient: Client<Request, Notification, Result>; // Convenience variable
24
24