@malloy-publisher/server 0.0.205 → 0.0.207
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/app/api-doc.yaml +394 -395
- package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
- package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
- package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
- package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
- package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
- package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
- package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
- package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
- package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
- package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
- package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/server.mjs +1272 -1299
- package/package.json +1 -1
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +12 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +121 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/server-old.ts +7 -149
- package/src/server.ts +9 -188
- package/src/service/authorize_integration.spec.ts +67 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +0 -23
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +584 -500
- package/src/service/materialization_service.ts +839 -657
- package/src/service/model.ts +419 -15
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +339 -29
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { ManifestEntry } from "../DatabaseInterface";
|
|
2
|
-
import { DuckDBConnection } from "./DuckDBConnection";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Repository for build manifest entries stored in the `build_manifests` table.
|
|
6
|
-
*
|
|
7
|
-
* A build manifest records the materialized table produced by a specific build
|
|
8
|
-
* of a package, linking it back to the Malloy source and connection that
|
|
9
|
-
* generated it. Manifests are keyed by (environment, package, build) and enable
|
|
10
|
-
* downstream consumers to discover which physical tables correspond to a given
|
|
11
|
-
* package version.
|
|
12
|
-
*/
|
|
13
|
-
export class ManifestRepository {
|
|
14
|
-
constructor(private db: DuckDBConnection) {}
|
|
15
|
-
|
|
16
|
-
private generateId(): string {
|
|
17
|
-
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Returns all manifest entries for a package, most recent first. */
|
|
21
|
-
async listEntries(
|
|
22
|
-
environmentId: string,
|
|
23
|
-
packageName: string,
|
|
24
|
-
): Promise<ManifestEntry[]> {
|
|
25
|
-
const rows = await this.db.all<Record<string, unknown>>(
|
|
26
|
-
"SELECT * FROM build_manifests WHERE environment_id = ? AND package_name = ? ORDER BY created_at DESC",
|
|
27
|
-
[environmentId, packageName],
|
|
28
|
-
);
|
|
29
|
-
return rows.map(this.mapToEntry);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Looks up the manifest entry for a specific build, or null if none exists. */
|
|
33
|
-
async getEntryByBuildId(
|
|
34
|
-
environmentId: string,
|
|
35
|
-
packageName: string,
|
|
36
|
-
buildId: string,
|
|
37
|
-
): Promise<ManifestEntry | null> {
|
|
38
|
-
const row = await this.db.get<Record<string, unknown>>(
|
|
39
|
-
"SELECT * FROM build_manifests WHERE environment_id = ? AND package_name = ? AND build_id = ?",
|
|
40
|
-
[environmentId, packageName, buildId],
|
|
41
|
-
);
|
|
42
|
-
return row ? this.mapToEntry(row) : null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Inserts a new manifest entry, or updates the existing one if a row with
|
|
47
|
-
* the same (environment, package, build) triple already exists. Uses INSERT ON
|
|
48
|
-
* CONFLICT to avoid the TOCTOU race of SELECT-then-INSERT/UPDATE.
|
|
49
|
-
*/
|
|
50
|
-
async upsertEntry(
|
|
51
|
-
entry: Omit<ManifestEntry, "id" | "createdAt" | "updatedAt">,
|
|
52
|
-
): Promise<ManifestEntry> {
|
|
53
|
-
const id = this.generateId();
|
|
54
|
-
const now = new Date();
|
|
55
|
-
const iso = now.toISOString();
|
|
56
|
-
|
|
57
|
-
const rows = await this.db.all<Record<string, unknown>>(
|
|
58
|
-
`INSERT INTO build_manifests (id, environment_id, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
|
|
59
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
60
|
-
ON CONFLICT (environment_id, package_name, build_id)
|
|
61
|
-
DO UPDATE SET table_name = EXCLUDED.table_name,
|
|
62
|
-
source_name = EXCLUDED.source_name,
|
|
63
|
-
connection_name = EXCLUDED.connection_name,
|
|
64
|
-
updated_at = EXCLUDED.updated_at
|
|
65
|
-
RETURNING *`,
|
|
66
|
-
[
|
|
67
|
-
id,
|
|
68
|
-
entry.environmentId,
|
|
69
|
-
entry.packageName,
|
|
70
|
-
entry.buildId,
|
|
71
|
-
entry.tableName,
|
|
72
|
-
entry.sourceName,
|
|
73
|
-
entry.connectionName,
|
|
74
|
-
iso,
|
|
75
|
-
iso,
|
|
76
|
-
],
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
return this.mapToEntry(rows[0]);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Deletes a single manifest entry by ID. */
|
|
83
|
-
async deleteEntry(id: string): Promise<void> {
|
|
84
|
-
await this.db.run("DELETE FROM build_manifests WHERE id = ?", [id]);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Removes all manifest entries belonging to an environment (used on environment deletion). */
|
|
88
|
-
async deleteEntriesByEnvironmentId(environmentId: string): Promise<void> {
|
|
89
|
-
await this.db.run(
|
|
90
|
-
"DELETE FROM build_manifests WHERE environment_id = ?",
|
|
91
|
-
[environmentId],
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Removes all manifest entries for a specific package. */
|
|
96
|
-
async deleteEntriesByPackage(
|
|
97
|
-
environmentId: string,
|
|
98
|
-
packageName: string,
|
|
99
|
-
): Promise<void> {
|
|
100
|
-
await this.db.run(
|
|
101
|
-
"DELETE FROM build_manifests WHERE environment_id = ? AND package_name = ?",
|
|
102
|
-
[environmentId, packageName],
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Maps a raw DB row (snake_case columns) to a {@link ManifestEntry}. */
|
|
107
|
-
private mapToEntry(row: Record<string, unknown>): ManifestEntry {
|
|
108
|
-
return {
|
|
109
|
-
id: row.id as string,
|
|
110
|
-
environmentId: row.environment_id as string,
|
|
111
|
-
packageName: row.package_name as string,
|
|
112
|
-
buildId: row.build_id as string,
|
|
113
|
-
tableName: row.table_name as string,
|
|
114
|
-
sourceName: row.source_name as string,
|
|
115
|
-
connectionName: row.connection_name as string,
|
|
116
|
-
createdAt: new Date(row.created_at as string),
|
|
117
|
-
updatedAt: new Date(row.updated_at as string),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
}
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import * as sinon from "sinon";
|
|
3
|
-
import { ManifestEntry, ResourceRepository } from "../DatabaseInterface";
|
|
4
|
-
import { DuckDBManifestStore } from "./DuckDBManifestStore";
|
|
5
|
-
|
|
6
|
-
function makeEntry(overrides: Partial<ManifestEntry> = {}): ManifestEntry {
|
|
7
|
-
return {
|
|
8
|
-
id: "entry-1",
|
|
9
|
-
environmentId: "proj-1",
|
|
10
|
-
packageName: "pkg",
|
|
11
|
-
buildId: "build-abc",
|
|
12
|
-
tableName: "my_table",
|
|
13
|
-
sourceName: "my_source",
|
|
14
|
-
connectionName: "duckdb",
|
|
15
|
-
createdAt: new Date("2026-04-03"),
|
|
16
|
-
updatedAt: new Date("2026-04-03"),
|
|
17
|
-
...overrides,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function createMocks() {
|
|
22
|
-
const sandbox = sinon.createSandbox();
|
|
23
|
-
|
|
24
|
-
const repository = {
|
|
25
|
-
listManifestEntries: sandbox.stub(),
|
|
26
|
-
upsertManifestEntry: sandbox.stub(),
|
|
27
|
-
deleteManifestEntry: sandbox.stub(),
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const store = new DuckDBManifestStore(
|
|
31
|
-
repository as unknown as ResourceRepository,
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
return { sandbox, repository, store };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe("DuckDBManifestStore", () => {
|
|
38
|
-
let ctx: ReturnType<typeof createMocks>;
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
ctx = createMocks();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("getManifest", () => {
|
|
45
|
-
it("should assemble a BuildManifest from repository entries", async () => {
|
|
46
|
-
ctx.repository.listManifestEntries.resolves([
|
|
47
|
-
makeEntry({ buildId: "b1", tableName: "tbl_a" }),
|
|
48
|
-
makeEntry({ buildId: "b2", tableName: "tbl_b" }),
|
|
49
|
-
]);
|
|
50
|
-
|
|
51
|
-
const manifest = await ctx.store.getManifest("proj-1", "pkg");
|
|
52
|
-
|
|
53
|
-
expect(manifest.strict).toBe(false);
|
|
54
|
-
expect(manifest.entries).toEqual({
|
|
55
|
-
b1: { tableName: "tbl_a" },
|
|
56
|
-
b2: { tableName: "tbl_b" },
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("should return empty entries when no rows exist", async () => {
|
|
61
|
-
ctx.repository.listManifestEntries.resolves([]);
|
|
62
|
-
|
|
63
|
-
const manifest = await ctx.store.getManifest("proj-1", "pkg");
|
|
64
|
-
|
|
65
|
-
expect(manifest.strict).toBe(false);
|
|
66
|
-
expect(manifest.entries).toEqual({});
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("writeEntry", () => {
|
|
71
|
-
it("should upsert an entry with all fields", async () => {
|
|
72
|
-
ctx.repository.upsertManifestEntry.resolves(makeEntry());
|
|
73
|
-
|
|
74
|
-
await ctx.store.writeEntry(
|
|
75
|
-
"proj-1",
|
|
76
|
-
"pkg",
|
|
77
|
-
"build-abc",
|
|
78
|
-
"tbl",
|
|
79
|
-
"src",
|
|
80
|
-
"conn",
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
expect(ctx.repository.upsertManifestEntry.calledOnce).toBe(true);
|
|
84
|
-
const arg = ctx.repository.upsertManifestEntry.firstCall.args[0];
|
|
85
|
-
expect(arg).toEqual({
|
|
86
|
-
environmentId: "proj-1",
|
|
87
|
-
packageName: "pkg",
|
|
88
|
-
buildId: "build-abc",
|
|
89
|
-
tableName: "tbl",
|
|
90
|
-
sourceName: "src",
|
|
91
|
-
connectionName: "conn",
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("deleteEntry", () => {
|
|
97
|
-
it("should delegate to repository", async () => {
|
|
98
|
-
ctx.repository.deleteManifestEntry.resolves();
|
|
99
|
-
|
|
100
|
-
await ctx.store.deleteEntry("entry-1");
|
|
101
|
-
|
|
102
|
-
expect(ctx.repository.deleteManifestEntry.calledOnce).toBe(true);
|
|
103
|
-
expect(ctx.repository.deleteManifestEntry.firstCall.args[0]).toBe(
|
|
104
|
-
"entry-1",
|
|
105
|
-
);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe("listEntries", () => {
|
|
110
|
-
it("should return entries from the repository", async () => {
|
|
111
|
-
const entries = [
|
|
112
|
-
makeEntry(),
|
|
113
|
-
makeEntry({ id: "entry-2", buildId: "build-def" }),
|
|
114
|
-
];
|
|
115
|
-
ctx.repository.listManifestEntries.resolves(entries);
|
|
116
|
-
|
|
117
|
-
const result = await ctx.store.listEntries("proj-1", "pkg");
|
|
118
|
-
|
|
119
|
-
expect(result).toEqual(entries);
|
|
120
|
-
expect(
|
|
121
|
-
ctx.repository.listManifestEntries.calledWith("proj-1", "pkg"),
|
|
122
|
-
).toBe(true);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("should return empty array when no entries exist", async () => {
|
|
126
|
-
ctx.repository.listManifestEntries.resolves([]);
|
|
127
|
-
|
|
128
|
-
const result = await ctx.store.listEntries("proj-1", "pkg");
|
|
129
|
-
|
|
130
|
-
expect(result).toEqual([]);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
});
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { logger } from "../../logger";
|
|
2
|
-
import {
|
|
3
|
-
BuildManifest,
|
|
4
|
-
ManifestEntry,
|
|
5
|
-
ManifestStore,
|
|
6
|
-
} from "../DatabaseInterface";
|
|
7
|
-
import { DuckDBConnection } from "../duckdb/DuckDBConnection";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* DuckLake-backed ManifestStore used in orchestrated mode.
|
|
11
|
-
*
|
|
12
|
-
* Reads and writes manifest entries through a DuckLake catalog attached to
|
|
13
|
-
* the publisher's internal DuckDB. All workers sharing the same DuckLake
|
|
14
|
-
* catalog see the same manifest, enabling multi-worker coordination.
|
|
15
|
-
*
|
|
16
|
-
* The catalog is attached by {@link StorageManager} before this store is
|
|
17
|
-
* instantiated. The first worker to call {@link bootstrapSchema} creates the
|
|
18
|
-
* `build_manifests` table idempotently. Schema ownership lives in the
|
|
19
|
-
* publisher so that DDL and code co-evolve in the same repo.
|
|
20
|
-
*
|
|
21
|
-
* **Scope: manifest sync only.** Orchestrated mode only shares manifest
|
|
22
|
-
* state across workers. The active-materialization lock (the unique index
|
|
23
|
-
* on `materializations.active_key`) still lives in each worker's local
|
|
24
|
-
* DuckDB, so two workers can run builds concurrently and race on physical
|
|
25
|
-
* table names, staging table names, and manifest writes. Until a shared
|
|
26
|
-
* build lease lives in the DuckLake catalog, deployments running
|
|
27
|
-
* orchestrated mode must ensure builds are externally single-writer (e.g.,
|
|
28
|
-
* one designated build worker, or an external job scheduler). Other
|
|
29
|
-
* workers should only call `manifest?action=reload` to pick up manifests produced
|
|
30
|
-
* by the build worker.
|
|
31
|
-
*/
|
|
32
|
-
export class DuckLakeManifestStore implements ManifestStore {
|
|
33
|
-
private readonly table: string;
|
|
34
|
-
private readonly environmentName: string;
|
|
35
|
-
|
|
36
|
-
constructor(
|
|
37
|
-
private db: DuckDBConnection,
|
|
38
|
-
catalogName: string,
|
|
39
|
-
environmentName: string,
|
|
40
|
-
) {
|
|
41
|
-
this.table = `${catalogName}.build_manifests`;
|
|
42
|
-
this.environmentName = environmentName;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Idempotently creates the `build_manifests` table and indices in the
|
|
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.
|
|
54
|
-
*/
|
|
55
|
-
async bootstrapSchema(): Promise<void> {
|
|
56
|
-
await this.db.run(`
|
|
57
|
-
CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
58
|
-
id VARCHAR,
|
|
59
|
-
environment_name VARCHAR NOT NULL,
|
|
60
|
-
package_name VARCHAR NOT NULL,
|
|
61
|
-
build_id VARCHAR NOT NULL,
|
|
62
|
-
table_name VARCHAR NOT NULL,
|
|
63
|
-
source_name VARCHAR NOT NULL,
|
|
64
|
-
connection_name VARCHAR NOT NULL,
|
|
65
|
-
created_at TIMESTAMP NOT NULL,
|
|
66
|
-
updated_at TIMESTAMP NOT NULL
|
|
67
|
-
)
|
|
68
|
-
`);
|
|
69
|
-
logger.info(`DuckLake manifest table bootstrapped: ${this.table}`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async getManifest(
|
|
73
|
-
_environmentId: string,
|
|
74
|
-
packageName: string,
|
|
75
|
-
): Promise<BuildManifest> {
|
|
76
|
-
const rows = await this.db.all<Record<string, unknown>>(
|
|
77
|
-
`SELECT * FROM ${this.table} WHERE environment_name = ? AND package_name = ? ORDER BY created_at DESC`,
|
|
78
|
-
[this.environmentName, packageName],
|
|
79
|
-
);
|
|
80
|
-
const manifest: BuildManifest = { entries: {}, strict: false };
|
|
81
|
-
for (const row of rows) {
|
|
82
|
-
const buildId = row.build_id as string;
|
|
83
|
-
// Rows are ordered newest-first; keep only the latest per build_id
|
|
84
|
-
// to handle rare duplicates from cross-worker races.
|
|
85
|
-
if (!manifest.entries[buildId]) {
|
|
86
|
-
manifest.entries[buildId] = {
|
|
87
|
-
tableName: row.table_name as string,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return manifest;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Insert a manifest entry. If a row with the same `build_id` already
|
|
96
|
-
* exists (retry after crash), the duplicate is harmless:
|
|
97
|
-
* {@link getManifest} deduplicates by build_id keeping the newest row.
|
|
98
|
-
*/
|
|
99
|
-
async writeEntry(
|
|
100
|
-
_environmentId: string,
|
|
101
|
-
packageName: string,
|
|
102
|
-
buildId: string,
|
|
103
|
-
tableName: string,
|
|
104
|
-
sourceName: string,
|
|
105
|
-
connectionName: string,
|
|
106
|
-
): Promise<void> {
|
|
107
|
-
const now = new Date().toISOString();
|
|
108
|
-
const id = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
109
|
-
|
|
110
|
-
await this.db.run(
|
|
111
|
-
`INSERT INTO ${this.table} (id, environment_name, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
|
|
112
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
113
|
-
[
|
|
114
|
-
id,
|
|
115
|
-
this.environmentName,
|
|
116
|
-
packageName,
|
|
117
|
-
buildId,
|
|
118
|
-
tableName,
|
|
119
|
-
sourceName,
|
|
120
|
-
connectionName,
|
|
121
|
-
now,
|
|
122
|
-
now,
|
|
123
|
-
],
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async deleteEntry(id: string): Promise<void> {
|
|
128
|
-
await this.db.run(`DELETE FROM ${this.table} WHERE id = ?`, [id]);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async listEntries(
|
|
132
|
-
_environmentId: string,
|
|
133
|
-
packageName: string,
|
|
134
|
-
): Promise<ManifestEntry[]> {
|
|
135
|
-
const rows = await this.db.all<Record<string, unknown>>(
|
|
136
|
-
`SELECT * FROM ${this.table} WHERE environment_name = ? AND package_name = ? ORDER BY created_at DESC`,
|
|
137
|
-
[this.environmentName, packageName],
|
|
138
|
-
);
|
|
139
|
-
return rows.map(this.mapToEntry);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
private mapToEntry(row: Record<string, unknown>): ManifestEntry {
|
|
143
|
-
return {
|
|
144
|
-
id: row.id as string,
|
|
145
|
-
environmentId: row.environment_name as string,
|
|
146
|
-
packageName: row.package_name as string,
|
|
147
|
-
buildId: row.build_id as string,
|
|
148
|
-
tableName: row.table_name as string,
|
|
149
|
-
sourceName: row.source_name as string,
|
|
150
|
-
connectionName: row.connection_name as string,
|
|
151
|
-
createdAt: new Date(row.created_at as string),
|
|
152
|
-
updatedAt: new Date(row.updated_at as string),
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
}
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
// Unit tests for the catch-block wiring in
|
|
2
|
-
// StorageManager.attachDuckLakeCatalog. The pure helpers from
|
|
3
|
-
// `pg_helpers.ts` are tested directly in `pg_helpers.spec.ts`; this file
|
|
4
|
-
// covers the integration — that the helpers are invoked in the right
|
|
5
|
-
// order and the right places inside `initializeDuckLakeForEnvironment`.
|
|
6
|
-
//
|
|
7
|
-
// Stubs DuckDBConnection.run instead of using a real DuckDB so we can
|
|
8
|
-
// inject libpq-style errors at the ATTACH boundary without standing up a
|
|
9
|
-
// real Postgres.
|
|
10
|
-
//
|
|
11
|
-
// Lives under `tests/unit/` (not `src/`) on purpose: the `src/` unit-spec
|
|
12
|
-
// process imports `service/environment_store.spec.ts`, which calls
|
|
13
|
-
// `mock.module("../storage/StorageManager", ...)`. Bun's module mocks
|
|
14
|
-
// persist process-wide across spec files, so a sibling spec in `src/` that
|
|
15
|
-
// `import`s the real StorageManager would get the mock instead. Running
|
|
16
|
-
// here puts us in the separate `test:integration` process with a clean
|
|
17
|
-
// module cache.
|
|
18
|
-
import { describe, expect, it } from "bun:test";
|
|
19
|
-
import { ConnectionAuthError } from "../../../src/errors";
|
|
20
|
-
import { StorageManager } from "../../../src/storage/StorageManager";
|
|
21
|
-
|
|
22
|
-
interface PrivateStorageManager {
|
|
23
|
-
duckDbConnection: { run: (sql: string) => Promise<unknown> } | null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const PG_CONFIG = {
|
|
27
|
-
catalogUrl: "postgres:host=h user=u password=hunter2 dbname=catalog",
|
|
28
|
-
dataPath: "gs://bucket/path",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
function setupWithStubbedConn(runHandler: (sql: string) => Promise<unknown>): {
|
|
32
|
-
sm: StorageManager;
|
|
33
|
-
calls: string[];
|
|
34
|
-
} {
|
|
35
|
-
const sm = new StorageManager({ type: "duckdb" });
|
|
36
|
-
const calls: string[] = [];
|
|
37
|
-
const stub = {
|
|
38
|
-
run: async (sql: string): Promise<unknown> => {
|
|
39
|
-
calls.push(sql);
|
|
40
|
-
return runHandler(sql);
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
(sm as unknown as PrivateStorageManager).duckDbConnection = stub;
|
|
44
|
-
return { sm, calls };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
describe("StorageManager.attachDuckLakeCatalog wiring", () => {
|
|
48
|
-
it("classifies libpq auth failure on ATTACH as ConnectionAuthError", async () => {
|
|
49
|
-
const { sm } = setupWithStubbedConn(async (sql) => {
|
|
50
|
-
if (sql.startsWith("ATTACH")) {
|
|
51
|
-
throw new Error(
|
|
52
|
-
'IO Error: Unable to connect to Postgres at "host=h user=u password=hunter2 ...": FATAL: password authentication failed for user "u"',
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
return undefined;
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
await expect(
|
|
59
|
-
sm.initializeDuckLakeForEnvironment("env-1", "env-name", PG_CONFIG),
|
|
60
|
-
).rejects.toBeInstanceOf(ConnectionAuthError);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("redacts the embedded password in the classified error", async () => {
|
|
64
|
-
const { sm } = setupWithStubbedConn(async (sql) => {
|
|
65
|
-
if (sql.startsWith("ATTACH")) {
|
|
66
|
-
throw new Error(
|
|
67
|
-
"password authentication failed: tried host=h password=hunter2",
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
return undefined;
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
await sm.initializeDuckLakeForEnvironment(
|
|
75
|
-
"env-1",
|
|
76
|
-
"env-name",
|
|
77
|
-
PG_CONFIG,
|
|
78
|
-
);
|
|
79
|
-
throw new Error("expected ATTACH to throw");
|
|
80
|
-
} catch (e) {
|
|
81
|
-
expect(e).toBeInstanceOf(ConnectionAuthError);
|
|
82
|
-
expect((e as Error).message).toContain("password=***");
|
|
83
|
-
expect((e as Error).message).not.toContain("hunter2");
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("injects connect_timeout into PG catalogUrl before ATTACH", async () => {
|
|
88
|
-
const { sm, calls } = setupWithStubbedConn(async () => undefined);
|
|
89
|
-
|
|
90
|
-
await sm.initializeDuckLakeForEnvironment("env-1", "env-name", PG_CONFIG);
|
|
91
|
-
|
|
92
|
-
const attachSql = calls.find((s) => s.startsWith("ATTACH"));
|
|
93
|
-
expect(attachSql).toBeDefined();
|
|
94
|
-
expect(attachSql).toContain("connect_timeout=");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("honors PG_CONNECT_TIMEOUT_SECONDS env override in the emitted SQL", async () => {
|
|
98
|
-
const original = process.env.PG_CONNECT_TIMEOUT_SECONDS;
|
|
99
|
-
process.env.PG_CONNECT_TIMEOUT_SECONDS = "17";
|
|
100
|
-
try {
|
|
101
|
-
const { sm, calls } = setupWithStubbedConn(async () => undefined);
|
|
102
|
-
await sm.initializeDuckLakeForEnvironment(
|
|
103
|
-
"env-1",
|
|
104
|
-
"env-name",
|
|
105
|
-
PG_CONFIG,
|
|
106
|
-
);
|
|
107
|
-
const attachSql = calls.find((s) => s.startsWith("ATTACH"));
|
|
108
|
-
expect(attachSql).toContain("connect_timeout=17");
|
|
109
|
-
} finally {
|
|
110
|
-
if (original === undefined) {
|
|
111
|
-
delete process.env.PG_CONNECT_TIMEOUT_SECONDS;
|
|
112
|
-
} else {
|
|
113
|
-
process.env.PG_CONNECT_TIMEOUT_SECONDS = original;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("leaves a non-PG catalogUrl untouched (no connect_timeout)", async () => {
|
|
119
|
-
const { sm, calls } = setupWithStubbedConn(async () => undefined);
|
|
120
|
-
const sqliteConfig = {
|
|
121
|
-
catalogUrl: "sqlite:/tmp/x.db",
|
|
122
|
-
dataPath: "/tmp/data",
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
await sm.initializeDuckLakeForEnvironment(
|
|
126
|
-
"env-1",
|
|
127
|
-
"env-name",
|
|
128
|
-
sqliteConfig,
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const attachSql = calls.find((s) => s.startsWith("ATTACH"));
|
|
132
|
-
expect(attachSql).toBeDefined();
|
|
133
|
-
expect(attachSql).not.toContain("connect_timeout");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("rethrows non-auth errors from ATTACH unchanged (preserves cause)", async () => {
|
|
137
|
-
const original = new Error("disk I/O error: read failed");
|
|
138
|
-
const { sm } = setupWithStubbedConn(async (sql) => {
|
|
139
|
-
if (sql.startsWith("ATTACH")) throw original;
|
|
140
|
-
return undefined;
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
await expect(
|
|
144
|
-
sm.initializeDuckLakeForEnvironment("env-1", "env-name", PG_CONFIG),
|
|
145
|
-
).rejects.toBe(original);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("does not call connect_timeout injection when catalogUrl lacks postgres: prefix", async () => {
|
|
149
|
-
// Sanity: the isPostgres branch is detected purely by string prefix.
|
|
150
|
-
// A keyword-form string without the prefix shouldn't be misclassified.
|
|
151
|
-
const { sm, calls } = setupWithStubbedConn(async () => undefined);
|
|
152
|
-
const ambiguousConfig = {
|
|
153
|
-
catalogUrl: "host=h dbname=d", // no scheme prefix at all
|
|
154
|
-
dataPath: "/tmp/data",
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
await sm.initializeDuckLakeForEnvironment(
|
|
158
|
-
"env-1",
|
|
159
|
-
"env-name",
|
|
160
|
-
ambiguousConfig,
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
const attachSql = calls.find((s) => s.startsWith("ATTACH"));
|
|
164
|
-
expect(attachSql).not.toContain("connect_timeout");
|
|
165
|
-
});
|
|
166
|
-
});
|