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