@malloy-publisher/server 0.0.187 → 0.0.189-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/app/api-doc.yaml +423 -60
- package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
- package/dist/app/assets/{MainPage-B-RVib7-.js → MainPage-BzB3yoqi.js} +1 -1
- package/dist/app/assets/{ModelPage-Cv7TxfHc.js → ModelPage-C9O_sAXT.js} +1 -1
- package/dist/app/assets/PackagePage-DcxKEjBX.js +1 -0
- package/dist/app/assets/ProjectPage-BDj307rF.js +1 -0
- package/dist/app/assets/{RouteError-B7433Znd.js → RouteError-DAShbVCG.js} +1 -1
- package/dist/app/assets/{WorkbookPage-BBMaiWJS.js → WorkbookPage-Cs_XYEaB.js} +1 -1
- package/dist/app/assets/core-CjeTkq8O.es-BqRc6yhC.js +148 -0
- package/dist/app/assets/engine-oniguruma-C4vnmooL.es-jdkXmgTr.js +1 -0
- package/dist/app/assets/github-light-JYsPkUQd.es-DAi9KRSo.js +1 -0
- package/dist/app/assets/index-15BOvhp0.js +456 -0
- package/dist/app/assets/{index-CpOUcMUD.js → index-Bb2jqquW.js} +1 -1
- package/dist/app/assets/{index-DfdCzzKW.js → index-D68X76-7.js} +1 -1
- package/dist/app/assets/{index.umd-CAdcmnBE.js → index.umd-DGBekgSu.js} +1 -1
- package/dist/app/assets/json-71t8ZF9g.es-BQoSv7ci.js +1 -0
- package/dist/app/assets/sql-DCkt643-.es-COK4E0Yg.js +1 -0
- package/dist/app/assets/typescript-buWNZFwO.es-Dj6nwHGl.js +1 -0
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.js +98 -77
- package/dist/server.js +1834 -450
- package/package.json +5 -3
- package/src/controller/connection.controller.ts +27 -20
- package/src/controller/manifest.controller.ts +29 -0
- package/src/controller/materialization.controller.ts +125 -0
- package/src/controller/model.controller.ts +0 -2
- package/src/controller/package.controller.ts +53 -2
- package/src/errors.ts +24 -0
- package/src/server.ts +196 -5
- package/src/service/manifest_service.spec.ts +201 -0
- package/src/service/manifest_service.ts +106 -0
- package/src/service/materialization_service.spec.ts +648 -0
- package/src/service/materialization_service.ts +929 -0
- package/src/service/materialized_table_gc.spec.ts +383 -0
- package/src/service/materialized_table_gc.ts +279 -0
- package/src/service/model.ts +25 -4
- package/src/service/package.ts +50 -0
- package/src/service/project_store.ts +21 -2
- package/src/service/quoting.ts +41 -0
- package/src/service/resolve_project.ts +13 -0
- package/src/storage/DatabaseInterface.ts +103 -1
- package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
- package/src/storage/StorageManager.ts +119 -1
- package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
- package/src/storage/duckdb/DuckDBRepository.ts +99 -9
- package/src/storage/duckdb/ManifestRepository.ts +119 -0
- package/src/storage/duckdb/MaterializationRepository.ts +249 -0
- package/src/storage/duckdb/manifest_store.spec.ts +133 -0
- package/src/storage/duckdb/schema.ts +59 -1
- package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
- package/tests/fixtures/persist-test/data/orders.csv +5 -0
- package/tests/fixtures/persist-test/persist_test.malloy +11 -0
- package/tests/fixtures/persist-test/publisher.json +5 -0
- package/tests/fixtures/publisher.config.json +15 -0
- package/tests/harness/rest_e2e.ts +68 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
- package/dist/app/assets/HomePage-qyt2wz79.js +0 -1
- package/dist/app/assets/PackagePage-BOnk1rJb.js +0 -1
- package/dist/app/assets/ProjectPage-DFn9Ek1J.js +0 -1
- package/dist/app/assets/index-DjdBB2D6.js +0 -467
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ResourceRepository,
|
|
3
|
-
Project,
|
|
4
|
-
Package,
|
|
5
2
|
Connection,
|
|
3
|
+
ManifestEntry,
|
|
4
|
+
Materialization,
|
|
5
|
+
MaterializationStatus,
|
|
6
|
+
Package,
|
|
7
|
+
Project,
|
|
8
|
+
ResourceRepository,
|
|
6
9
|
} from "../DatabaseInterface";
|
|
10
|
+
import { ConnectionRepository } from "./ConnectionRepository";
|
|
7
11
|
import { DuckDBConnection } from "./DuckDBConnection";
|
|
8
|
-
import {
|
|
12
|
+
import { ManifestRepository } from "./ManifestRepository";
|
|
13
|
+
import { MaterializationRepository } from "./MaterializationRepository";
|
|
9
14
|
import { PackageRepository } from "./PackageRepository";
|
|
10
|
-
import {
|
|
15
|
+
import { ProjectRepository } from "./ProjectRepository";
|
|
11
16
|
|
|
12
17
|
export class DuckDBRepository implements ResourceRepository {
|
|
13
18
|
private projectRepo: ProjectRepository;
|
|
14
19
|
private packageRepo: PackageRepository;
|
|
15
20
|
private connectionRepo: ConnectionRepository;
|
|
21
|
+
private materializationRepo: MaterializationRepository;
|
|
22
|
+
private manifestRepo: ManifestRepository;
|
|
16
23
|
|
|
17
24
|
constructor(public db: DuckDBConnection) {
|
|
18
25
|
this.projectRepo = new ProjectRepository(db);
|
|
19
26
|
this.packageRepo = new PackageRepository(db);
|
|
20
27
|
this.connectionRepo = new ConnectionRepository(db);
|
|
28
|
+
this.materializationRepo = new MaterializationRepository(db);
|
|
29
|
+
this.manifestRepo = new ManifestRepository(db);
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
// ==================== PROJECTS ====================
|
|
@@ -48,11 +57,10 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
async deleteProject(id: string): Promise<void> {
|
|
51
|
-
|
|
60
|
+
await this.manifestRepo.deleteEntriesByProjectId(id);
|
|
61
|
+
await this.materializationRepo.deleteByProjectId(id);
|
|
52
62
|
await this.connectionRepo.deleteConnectionsByProjectId(id);
|
|
53
63
|
await this.packageRepo.deletePackagesByProjectId(id);
|
|
54
|
-
|
|
55
|
-
// Then delete the project
|
|
56
64
|
await this.projectRepo.deleteProject(id);
|
|
57
65
|
}
|
|
58
66
|
|
|
@@ -87,7 +95,18 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
87
95
|
}
|
|
88
96
|
|
|
89
97
|
async deletePackage(id: string): Promise<void> {
|
|
90
|
-
|
|
98
|
+
const pkg = await this.packageRepo.getPackageById(id);
|
|
99
|
+
if (pkg) {
|
|
100
|
+
await this.manifestRepo.deleteEntriesByPackage(
|
|
101
|
+
pkg.projectId,
|
|
102
|
+
pkg.name,
|
|
103
|
+
);
|
|
104
|
+
await this.materializationRepo.deleteByPackage(
|
|
105
|
+
pkg.projectId,
|
|
106
|
+
pkg.name,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
await this.packageRepo.deletePackage(id);
|
|
91
110
|
}
|
|
92
111
|
|
|
93
112
|
async deletePackagesByProjectId(id: string): Promise<void> {
|
|
@@ -131,4 +150,75 @@ export class DuckDBRepository implements ResourceRepository {
|
|
|
131
150
|
async deleteConnectionsByProjectId(id: string): Promise<void> {
|
|
132
151
|
return this.connectionRepo.deleteConnectionsByProjectId(id);
|
|
133
152
|
}
|
|
153
|
+
|
|
154
|
+
// ==================== MATERIALIZATIONS ====================
|
|
155
|
+
|
|
156
|
+
async listMaterializations(
|
|
157
|
+
projectId: string,
|
|
158
|
+
packageName: string,
|
|
159
|
+
options?: { limit?: number; offset?: number },
|
|
160
|
+
): Promise<Materialization[]> {
|
|
161
|
+
return this.materializationRepo.list(projectId, packageName, options);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getMaterializationById(id: string): Promise<Materialization | null> {
|
|
165
|
+
return this.materializationRepo.getById(id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getActiveMaterialization(
|
|
169
|
+
projectId: string,
|
|
170
|
+
packageName: string,
|
|
171
|
+
): Promise<Materialization | null> {
|
|
172
|
+
return this.materializationRepo.getActive(projectId, packageName);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async createMaterialization(
|
|
176
|
+
projectId: string,
|
|
177
|
+
packageName: string,
|
|
178
|
+
status: MaterializationStatus = "PENDING",
|
|
179
|
+
metadata: Record<string, unknown> | null = null,
|
|
180
|
+
): Promise<Materialization> {
|
|
181
|
+
return this.materializationRepo.create(
|
|
182
|
+
projectId,
|
|
183
|
+
packageName,
|
|
184
|
+
status,
|
|
185
|
+
metadata,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async updateMaterialization(
|
|
190
|
+
id: string,
|
|
191
|
+
updates: {
|
|
192
|
+
status?: MaterializationStatus;
|
|
193
|
+
startedAt?: Date;
|
|
194
|
+
completedAt?: Date;
|
|
195
|
+
error?: string | null;
|
|
196
|
+
metadata?: Record<string, unknown> | null;
|
|
197
|
+
},
|
|
198
|
+
): Promise<Materialization> {
|
|
199
|
+
return this.materializationRepo.update(id, updates);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async deleteMaterialization(id: string): Promise<void> {
|
|
203
|
+
return this.materializationRepo.deleteById(id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ==================== BUILD MANIFESTS ====================
|
|
207
|
+
|
|
208
|
+
async listManifestEntries(
|
|
209
|
+
projectId: string,
|
|
210
|
+
packageName: string,
|
|
211
|
+
): Promise<ManifestEntry[]> {
|
|
212
|
+
return this.manifestRepo.listEntries(projectId, packageName);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async upsertManifestEntry(
|
|
216
|
+
entry: Omit<ManifestEntry, "id" | "createdAt" | "updatedAt">,
|
|
217
|
+
): Promise<ManifestEntry> {
|
|
218
|
+
return this.manifestRepo.upsertEntry(entry);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async deleteManifestEntry(id: string): Promise<void> {
|
|
222
|
+
return this.manifestRepo.deleteEntry(id);
|
|
223
|
+
}
|
|
134
224
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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 (project, 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
|
+
projectId: string,
|
|
23
|
+
packageName: string,
|
|
24
|
+
): Promise<ManifestEntry[]> {
|
|
25
|
+
const rows = await this.db.all<Record<string, unknown>>(
|
|
26
|
+
"SELECT * FROM build_manifests WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC",
|
|
27
|
+
[projectId, 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
|
+
projectId: 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 project_id = ? AND package_name = ? AND build_id = ?",
|
|
40
|
+
[projectId, 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 (project, 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, project_id, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
60
|
+
ON CONFLICT (project_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.projectId,
|
|
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 a project (used on project deletion). */
|
|
88
|
+
async deleteEntriesByProjectId(projectId: string): Promise<void> {
|
|
89
|
+
await this.db.run("DELETE FROM build_manifests WHERE project_id = ?", [
|
|
90
|
+
projectId,
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Removes all manifest entries for a specific package. */
|
|
95
|
+
async deleteEntriesByPackage(
|
|
96
|
+
projectId: string,
|
|
97
|
+
packageName: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
await this.db.run(
|
|
100
|
+
"DELETE FROM build_manifests WHERE project_id = ? AND package_name = ?",
|
|
101
|
+
[projectId, packageName],
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Maps a raw DB row (snake_case columns) to a {@link ManifestEntry}. */
|
|
106
|
+
private mapToEntry(row: Record<string, unknown>): ManifestEntry {
|
|
107
|
+
return {
|
|
108
|
+
id: row.id as string,
|
|
109
|
+
projectId: row.project_id as string,
|
|
110
|
+
packageName: row.package_name as string,
|
|
111
|
+
buildId: row.build_id as string,
|
|
112
|
+
tableName: row.table_name as string,
|
|
113
|
+
sourceName: row.source_name as string,
|
|
114
|
+
connectionName: row.connection_name as string,
|
|
115
|
+
createdAt: new Date(row.created_at as string),
|
|
116
|
+
updatedAt: new Date(row.updated_at as string),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { Materialization, MaterializationStatus } from "../DatabaseInterface";
|
|
2
|
+
import { DuckDBConnection } from "./DuckDBConnection";
|
|
3
|
+
|
|
4
|
+
const TERMINAL_STATUSES: ReadonlySet<MaterializationStatus> = new Set([
|
|
5
|
+
"SUCCESS",
|
|
6
|
+
"FAILED",
|
|
7
|
+
"CANCELLED",
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function activeKeyFor(projectId: string, packageName: string): string {
|
|
11
|
+
return `${projectId}|${packageName}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Thrown when an atomic insert loses a race on (project, package) active
|
|
16
|
+
* materialization. Surfaced separately from a generic DB error so the service
|
|
17
|
+
* layer can translate to `MaterializationConflictError`.
|
|
18
|
+
*/
|
|
19
|
+
export class DuplicateActiveMaterializationError extends Error {
|
|
20
|
+
constructor(projectId: string, packageName: string) {
|
|
21
|
+
super(
|
|
22
|
+
`Active materialization already exists for (${projectId}, ${packageName})`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* DuckDB-backed repository for package materializations.
|
|
29
|
+
*
|
|
30
|
+
* A Materialization tracks a single build run for a (project, package) pair
|
|
31
|
+
* through its lifecycle: PENDING -> RUNNING -> SUCCESS | FAILED | CANCELLED.
|
|
32
|
+
*/
|
|
33
|
+
export class MaterializationRepository {
|
|
34
|
+
constructor(private db: DuckDBConnection) {}
|
|
35
|
+
|
|
36
|
+
private generateId(): string {
|
|
37
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private now(): Date {
|
|
41
|
+
return new Date();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async list(
|
|
45
|
+
projectId: string,
|
|
46
|
+
packageName: string,
|
|
47
|
+
options?: { limit?: number; offset?: number },
|
|
48
|
+
): Promise<Materialization[]> {
|
|
49
|
+
let sql =
|
|
50
|
+
"SELECT * FROM materializations WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC";
|
|
51
|
+
const params: unknown[] = [projectId, packageName];
|
|
52
|
+
if (options?.limit !== undefined) {
|
|
53
|
+
sql += " LIMIT ?";
|
|
54
|
+
params.push(options.limit);
|
|
55
|
+
}
|
|
56
|
+
if (options?.offset !== undefined) {
|
|
57
|
+
sql += " OFFSET ?";
|
|
58
|
+
params.push(options.offset);
|
|
59
|
+
}
|
|
60
|
+
const rows = await this.db.all<Record<string, unknown>>(sql, params);
|
|
61
|
+
return rows.map(this.mapRow);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getById(id: string): Promise<Materialization | null> {
|
|
65
|
+
const row = await this.db.get<Record<string, unknown>>(
|
|
66
|
+
"SELECT * FROM materializations WHERE id = ?",
|
|
67
|
+
[id],
|
|
68
|
+
);
|
|
69
|
+
return row ? this.mapRow(row) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getActive(
|
|
73
|
+
projectId: string,
|
|
74
|
+
packageName: string,
|
|
75
|
+
): Promise<Materialization | null> {
|
|
76
|
+
const row = await this.db.get<Record<string, unknown>>(
|
|
77
|
+
"SELECT * FROM materializations WHERE project_id = ? AND package_name = ? AND status IN ('PENDING', 'RUNNING')",
|
|
78
|
+
[projectId, packageName],
|
|
79
|
+
);
|
|
80
|
+
return row ? this.mapRow(row) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async create(
|
|
84
|
+
projectId: string,
|
|
85
|
+
packageName: string,
|
|
86
|
+
status: MaterializationStatus = "PENDING",
|
|
87
|
+
metadata: Record<string, unknown> | null = null,
|
|
88
|
+
): Promise<Materialization> {
|
|
89
|
+
const id = this.generateId();
|
|
90
|
+
const now = this.now();
|
|
91
|
+
const iso = now.toISOString();
|
|
92
|
+
// Set active_key iff the row is in a non-terminal state. The unique
|
|
93
|
+
// index on active_key makes the race-free conditional insert: a second
|
|
94
|
+
// concurrent create on the same (project, package) fails here rather
|
|
95
|
+
// than in a check-then-write window.
|
|
96
|
+
const activeKey = TERMINAL_STATUSES.has(status)
|
|
97
|
+
? null
|
|
98
|
+
: activeKeyFor(projectId, packageName);
|
|
99
|
+
const metadataJson = metadata ? JSON.stringify(metadata) : null;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const rows = await this.db.all<Record<string, unknown>>(
|
|
103
|
+
`INSERT INTO materializations (id, project_id, package_name, status, active_key, metadata, created_at, updated_at)
|
|
104
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
105
|
+
RETURNING *`,
|
|
106
|
+
[
|
|
107
|
+
id,
|
|
108
|
+
projectId,
|
|
109
|
+
packageName,
|
|
110
|
+
status,
|
|
111
|
+
activeKey,
|
|
112
|
+
metadataJson,
|
|
113
|
+
iso,
|
|
114
|
+
iso,
|
|
115
|
+
],
|
|
116
|
+
);
|
|
117
|
+
return this.mapRow(rows[0]);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (isUniqueViolation(err, "idx_materializations_active_key")) {
|
|
120
|
+
throw new DuplicateActiveMaterializationError(
|
|
121
|
+
projectId,
|
|
122
|
+
packageName,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async update(
|
|
130
|
+
id: string,
|
|
131
|
+
updates: {
|
|
132
|
+
status?: MaterializationStatus;
|
|
133
|
+
startedAt?: Date;
|
|
134
|
+
completedAt?: Date;
|
|
135
|
+
error?: string | null;
|
|
136
|
+
metadata?: Record<string, unknown> | null;
|
|
137
|
+
},
|
|
138
|
+
): Promise<Materialization> {
|
|
139
|
+
const now = this.now();
|
|
140
|
+
const setClauses: string[] = [];
|
|
141
|
+
const params: unknown[] = [];
|
|
142
|
+
|
|
143
|
+
if (updates.status !== undefined) {
|
|
144
|
+
setClauses.push(`status = ?`);
|
|
145
|
+
params.push(updates.status);
|
|
146
|
+
// Clear active_key on any transition to a terminal state; set it on
|
|
147
|
+
// any transition to a non-terminal state. The unique index
|
|
148
|
+
// guarantees we can never end up with two active rows for the same
|
|
149
|
+
// (project, package).
|
|
150
|
+
if (TERMINAL_STATUSES.has(updates.status)) {
|
|
151
|
+
setClauses.push(`active_key = NULL`);
|
|
152
|
+
} else {
|
|
153
|
+
setClauses.push(`active_key = project_id || '|' || package_name`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (updates.startedAt !== undefined) {
|
|
157
|
+
setClauses.push(`started_at = ?`);
|
|
158
|
+
params.push(updates.startedAt.toISOString());
|
|
159
|
+
}
|
|
160
|
+
if (updates.completedAt !== undefined) {
|
|
161
|
+
setClauses.push(`completed_at = ?`);
|
|
162
|
+
params.push(updates.completedAt.toISOString());
|
|
163
|
+
}
|
|
164
|
+
if (updates.error !== undefined) {
|
|
165
|
+
setClauses.push(`error = ?`);
|
|
166
|
+
params.push(updates.error);
|
|
167
|
+
}
|
|
168
|
+
if (updates.metadata !== undefined) {
|
|
169
|
+
setClauses.push(`metadata = ?`);
|
|
170
|
+
params.push(
|
|
171
|
+
updates.metadata ? JSON.stringify(updates.metadata) : null,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setClauses.push(`updated_at = ?`);
|
|
176
|
+
params.push(now.toISOString());
|
|
177
|
+
params.push(id);
|
|
178
|
+
|
|
179
|
+
await this.db.run(
|
|
180
|
+
`UPDATE materializations SET ${setClauses.join(", ")} WHERE id = ?`,
|
|
181
|
+
params,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const updated = await this.getById(id);
|
|
185
|
+
if (!updated) {
|
|
186
|
+
throw new Error(`Materialization ${id} not found after update`);
|
|
187
|
+
}
|
|
188
|
+
return updated;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async deleteByProjectId(projectId: string): Promise<void> {
|
|
192
|
+
await this.db.run("DELETE FROM materializations WHERE project_id = ?", [
|
|
193
|
+
projectId,
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async deleteById(id: string): Promise<void> {
|
|
198
|
+
await this.db.run("DELETE FROM materializations WHERE id = ?", [id]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async deleteByPackage(
|
|
202
|
+
projectId: string,
|
|
203
|
+
packageName: string,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
await this.db.run(
|
|
206
|
+
"DELETE FROM materializations WHERE project_id = ? AND package_name = ?",
|
|
207
|
+
[projectId, packageName],
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private mapRow(row: Record<string, unknown>): Materialization {
|
|
212
|
+
let metadata: Record<string, unknown> | null = null;
|
|
213
|
+
if (row.metadata) {
|
|
214
|
+
try {
|
|
215
|
+
metadata = JSON.parse(row.metadata as string);
|
|
216
|
+
} catch {
|
|
217
|
+
metadata = null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
id: row.id as string,
|
|
223
|
+
projectId: row.project_id as string,
|
|
224
|
+
packageName: row.package_name as string,
|
|
225
|
+
status: row.status as MaterializationStatus,
|
|
226
|
+
startedAt: row.started_at ? new Date(row.started_at as string) : null,
|
|
227
|
+
completedAt: row.completed_at
|
|
228
|
+
? new Date(row.completed_at as string)
|
|
229
|
+
: null,
|
|
230
|
+
error: row.error != null ? (row.error as string) : null,
|
|
231
|
+
metadata,
|
|
232
|
+
createdAt: new Date(row.created_at as string),
|
|
233
|
+
updatedAt: new Date(row.updated_at as string),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* DuckDB surfaces unique-constraint violations as plain Errors whose message
|
|
240
|
+
* mentions the violated index. We match on the index name rather than a
|
|
241
|
+
* generic substring so we don't misclassify unrelated constraint errors.
|
|
242
|
+
*/
|
|
243
|
+
function isUniqueViolation(err: unknown, indexName: string): boolean {
|
|
244
|
+
if (!(err instanceof Error)) return false;
|
|
245
|
+
const msg = err.message;
|
|
246
|
+
return (
|
|
247
|
+
msg.includes(indexName) || /duplicate key|unique constraint/i.test(msg)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
projectId: "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
|
+
projectId: "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
|
+
});
|