@malloy-publisher/server 0.0.182 → 0.0.183-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/build.ts +7 -3
- package/dist/app/api-doc.yaml +423 -60
- package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
- package/dist/app/assets/{MainPage-DINuSDg0.js → MainPage-BzB3yoqi.js} +1 -1
- package/dist/app/assets/{ModelPage-BMcaV1YQ.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-r14osUo0.js → RouteError-DAShbVCG.js} +1 -1
- package/dist/app/assets/{WorkbookPage-HI39NTWs.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-Dd6uCk_C.js → index-Bb2jqquW.js} +1 -1
- package/dist/app/assets/{index-JqHhhRqY.js → index-D68X76-7.js} +98 -98
- package/dist/app/assets/{index.umd-lwkX_kFe.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 → instrumentation.mjs} +10567 -10584
- package/dist/{server.js → server.mjs} +16642 -15332
- package/package.json +19 -17
- 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/mcp/prompts/handlers.ts +1 -1
- package/src/server.ts +202 -15
- package/src/service/connection.ts +1 -4
- package/src/service/filter.spec.ts +55 -0
- package/src/service/filter.ts +8 -3
- package/src/service/filter_integration.spec.ts +203 -0
- 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 +54 -13
- 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/DuckDBConnection.ts +1 -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/tsconfig.json +1 -1
- package/dist/app/assets/HomePage-or6BbD5P.js +0 -1
- package/dist/app/assets/PackagePage-DXxlQcCj.js +0 -1
- package/dist/app/assets/ProjectPage-vfZc_Kvu.js +0 -1
- package/dist/app/assets/index-Bw1lh09G.js +0 -467
package/src/service/model.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
MalloySQLParser,
|
|
23
23
|
MalloySQLStatementType,
|
|
24
24
|
} from "@malloydata/malloy-sql";
|
|
25
|
-
import
|
|
25
|
+
import { createRequire } from "module";
|
|
26
26
|
import { DataStyles } from "@malloydata/render";
|
|
27
27
|
import { metrics } from "@opentelemetry/api";
|
|
28
28
|
import * as fs from "fs/promises";
|
|
@@ -41,12 +41,13 @@ import {
|
|
|
41
41
|
ModelNotFoundError,
|
|
42
42
|
} from "../errors";
|
|
43
43
|
import { logger } from "../logger";
|
|
44
|
+
import { BuildManifest } from "../storage/DatabaseInterface";
|
|
44
45
|
import { URL_READER } from "../utils";
|
|
45
46
|
import {
|
|
46
47
|
buildFilterClause,
|
|
48
|
+
FilterValidationError,
|
|
47
49
|
injectFilterRefinement,
|
|
48
50
|
parseFilters,
|
|
49
|
-
FilterValidationError,
|
|
50
51
|
type FilterDefinition,
|
|
51
52
|
type FilterParams,
|
|
52
53
|
} from "./filter";
|
|
@@ -64,7 +65,11 @@ export type PostgresConnection = components["schemas"]["PostgresConnection"];
|
|
|
64
65
|
export type BigqueryConnection = components["schemas"]["BigqueryConnection"];
|
|
65
66
|
export type TrinoConnection = components["schemas"]["TrinoConnection"];
|
|
66
67
|
|
|
67
|
-
const MALLOY_VERSION =
|
|
68
|
+
const MALLOY_VERSION = (
|
|
69
|
+
createRequire(import.meta.url)("@malloydata/malloy/package.json") as {
|
|
70
|
+
version: string;
|
|
71
|
+
}
|
|
72
|
+
).version;
|
|
68
73
|
|
|
69
74
|
export type ModelType = "model" | "notebook";
|
|
70
75
|
|
|
@@ -158,11 +163,17 @@ export class Model {
|
|
|
158
163
|
packagePath: string,
|
|
159
164
|
modelPath: string,
|
|
160
165
|
connections: Map<string, Connection>,
|
|
166
|
+
options?: { buildManifest?: BuildManifest["entries"] },
|
|
161
167
|
): Promise<Model> {
|
|
162
168
|
// getModelRuntime might throw a ModelNotFoundError. It's the callers responsibility
|
|
163
169
|
// to pass a valid model path or handle the error.
|
|
164
170
|
const { runtime, modelURL, importBaseURL, dataStyles, modelType } =
|
|
165
|
-
await Model.getModelRuntime(
|
|
171
|
+
await Model.getModelRuntime(
|
|
172
|
+
packagePath,
|
|
173
|
+
modelPath,
|
|
174
|
+
connections,
|
|
175
|
+
options,
|
|
176
|
+
);
|
|
166
177
|
|
|
167
178
|
try {
|
|
168
179
|
const { modelMaterializer, runnableNotebookCells } =
|
|
@@ -291,7 +302,7 @@ export class Model {
|
|
|
291
302
|
}
|
|
292
303
|
|
|
293
304
|
public getQueries(): ApiQuery[] | undefined {
|
|
294
|
-
return this.
|
|
305
|
+
return this.queries;
|
|
295
306
|
}
|
|
296
307
|
|
|
297
308
|
public async getModel(): Promise<ApiCompiledModel> {
|
|
@@ -662,6 +673,7 @@ export class Model {
|
|
|
662
673
|
packagePath: string,
|
|
663
674
|
modelPath: string,
|
|
664
675
|
connections: Map<string, Connection>,
|
|
676
|
+
options?: { buildManifest?: BuildManifest["entries"] },
|
|
665
677
|
): Promise<{
|
|
666
678
|
runtime: Runtime;
|
|
667
679
|
modelURL: URL;
|
|
@@ -701,10 +713,23 @@ export class Model {
|
|
|
701
713
|
`SET FILE_SEARCH_PATH='${workingDirectory}';`,
|
|
702
714
|
);
|
|
703
715
|
|
|
704
|
-
const
|
|
716
|
+
const runtimeOptions: {
|
|
717
|
+
urlReader: typeof urlReader;
|
|
718
|
+
connections: FixedConnectionMap;
|
|
719
|
+
buildManifest?: BuildManifest;
|
|
720
|
+
} = {
|
|
705
721
|
urlReader,
|
|
706
722
|
connections: new FixedConnectionMap(connections, "duckdb"),
|
|
707
|
-
}
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
if (options?.buildManifest) {
|
|
726
|
+
runtimeOptions.buildManifest = {
|
|
727
|
+
entries: options.buildManifest,
|
|
728
|
+
strict: false,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const runtime = new Runtime(runtimeOptions);
|
|
708
733
|
const dataStyles = urlReader.getHackyAccumulatedDataStyles();
|
|
709
734
|
return { runtime, modelURL, importBaseURL, dataStyles, modelType };
|
|
710
735
|
}
|
|
@@ -750,13 +775,29 @@ export class Model {
|
|
|
750
775
|
?.filter((note) => note.at.url.includes(modelPath))
|
|
751
776
|
.map((note) => note.text);
|
|
752
777
|
|
|
753
|
-
// Parse #(filter) from ALL annotations
|
|
754
|
-
// so filters
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
778
|
+
// Parse #(filter) from ALL annotations, traversing the inherits
|
|
779
|
+
// chain so that filters on a base source (e.g. `recalls`) are
|
|
780
|
+
// picked up by an extending source (`manufacturer_recalls is
|
|
781
|
+
// recalls extend {}`). The Malloy compiler stores the base
|
|
782
|
+
// source's annotations in `annotation.inherits`.
|
|
783
|
+
//
|
|
784
|
+
// The chain goes child → parent, so we collect child-first.
|
|
785
|
+
// parseFilters uses "last wins" dedup, so we reverse to put
|
|
786
|
+
// parent annotations first and child annotations last (winning).
|
|
787
|
+
const collectedAnnotations: string[][] = [];
|
|
788
|
+
let curAnnotation: Annotation | undefined = (sourceObj as StructDef)
|
|
789
|
+
.annotation;
|
|
790
|
+
while (curAnnotation) {
|
|
791
|
+
if (curAnnotation.blockNotes) {
|
|
792
|
+
collectedAnnotations.push(
|
|
793
|
+
curAnnotation.blockNotes.map((note) => note.text),
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
curAnnotation = curAnnotation.inherits;
|
|
797
|
+
}
|
|
798
|
+
const allAnnotations = collectedAnnotations.reverse().flat();
|
|
758
799
|
let filters: ApiFilter[] | undefined;
|
|
759
|
-
if (allAnnotations
|
|
800
|
+
if (allAnnotations.length > 0) {
|
|
760
801
|
try {
|
|
761
802
|
const parsed = parseFilters(allAnnotations);
|
|
762
803
|
if (parsed.length > 0) {
|
package/src/service/package.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "../constants";
|
|
20
20
|
import { PackageNotFoundError } from "../errors";
|
|
21
21
|
import { formatDuration, logger } from "../logger";
|
|
22
|
+
import { BuildManifest } from "../storage/DatabaseInterface";
|
|
22
23
|
import { Model } from "./model";
|
|
23
24
|
|
|
24
25
|
type ApiDatabase = components["schemas"]["Database"];
|
|
@@ -189,6 +190,10 @@ export class Package {
|
|
|
189
190
|
return this.packageName;
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
public getPackagePath(): string {
|
|
194
|
+
return this.packagePath;
|
|
195
|
+
}
|
|
196
|
+
|
|
192
197
|
public getPackageMetadata(): ApiPackage {
|
|
193
198
|
return this.packageMetadata;
|
|
194
199
|
}
|
|
@@ -201,6 +206,51 @@ export class Package {
|
|
|
201
206
|
return this.models.get(modelPath);
|
|
202
207
|
}
|
|
203
208
|
|
|
209
|
+
public getModelPaths(): string[] {
|
|
210
|
+
return Array.from(this.models.keys());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Recompile every model in the package with the given build manifest
|
|
215
|
+
* so queries resolve persist references to materialized tables.
|
|
216
|
+
*
|
|
217
|
+
* Builds a fresh map off to the side and swaps it in at the end. If any
|
|
218
|
+
* recompile fails the whole call rejects before the swap and the live
|
|
219
|
+
* `this.models` reference remains untouched — no half-loaded state is
|
|
220
|
+
* ever observable to concurrent readers.
|
|
221
|
+
*/
|
|
222
|
+
public async reloadAllModels(
|
|
223
|
+
buildManifest: BuildManifest["entries"],
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const modelPaths = Array.from(this.models.keys());
|
|
226
|
+
logger.info("Reloading all models with build manifest", {
|
|
227
|
+
packageName: this.packageName,
|
|
228
|
+
modelCount: modelPaths.length,
|
|
229
|
+
manifestEntryCount: Object.keys(buildManifest).length,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const reloaded = await Promise.all(
|
|
233
|
+
modelPaths.map((modelPath) =>
|
|
234
|
+
Model.create(
|
|
235
|
+
this.packageName,
|
|
236
|
+
this.packagePath,
|
|
237
|
+
modelPath,
|
|
238
|
+
this.connections,
|
|
239
|
+
{ buildManifest },
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
const nextModels = new Map<string, Model>();
|
|
244
|
+
for (const model of reloaded) {
|
|
245
|
+
nextModels.set(model.getPath(), model);
|
|
246
|
+
}
|
|
247
|
+
this.models = nextModels;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
public getConnections(): Map<string, Connection> {
|
|
251
|
+
return this.connections;
|
|
252
|
+
}
|
|
253
|
+
|
|
204
254
|
public getMalloyConnection(connectionName: string): Connection {
|
|
205
255
|
const connection = this.connections.get(connectionName);
|
|
206
256
|
if (!connection) {
|
|
@@ -320,6 +320,7 @@ export class ProjectStore {
|
|
|
320
320
|
};
|
|
321
321
|
const existingProject = await repository.getProjectByName(projectName);
|
|
322
322
|
|
|
323
|
+
let dbProject: { id: string; name: string };
|
|
323
324
|
if (existingProject) {
|
|
324
325
|
const updateData = {
|
|
325
326
|
description: projectDescription,
|
|
@@ -327,10 +328,27 @@ export class ProjectStore {
|
|
|
327
328
|
};
|
|
328
329
|
|
|
329
330
|
await repository.updateProject(existingProject.id, updateData);
|
|
330
|
-
|
|
331
|
+
dbProject = { id: existingProject.id, name: projectName };
|
|
331
332
|
} else {
|
|
332
|
-
|
|
333
|
+
dbProject = await repository.createProject(projectData);
|
|
333
334
|
}
|
|
335
|
+
|
|
336
|
+
// Initialize DuckLake manifest storage if configured on the project.
|
|
337
|
+
const materializationStorage = project.metadata
|
|
338
|
+
?.materializationStorage as
|
|
339
|
+
| { catalogUrl?: string; dataPath?: string }
|
|
340
|
+
| undefined;
|
|
341
|
+
if (
|
|
342
|
+
materializationStorage?.catalogUrl &&
|
|
343
|
+
materializationStorage?.dataPath
|
|
344
|
+
) {
|
|
345
|
+
await this.storageManager.initializeDuckLakeForProject(dbProject.id, {
|
|
346
|
+
catalogUrl: materializationStorage.catalogUrl,
|
|
347
|
+
dataPath: materializationStorage.dataPath,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return dbProject;
|
|
334
352
|
}
|
|
335
353
|
|
|
336
354
|
private async addPackages(
|
|
@@ -917,6 +935,7 @@ export class ProjectStore {
|
|
|
917
935
|
private isLocalPath(location: string) {
|
|
918
936
|
return (
|
|
919
937
|
location.startsWith("./") ||
|
|
938
|
+
location.startsWith("../") ||
|
|
920
939
|
location.startsWith("~/") ||
|
|
921
940
|
location.startsWith("/") ||
|
|
922
941
|
path.isAbsolute(location)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal identifier-quoting surface. Every `Dialect` in `@malloydata/malloy`
|
|
3
|
+
* implements this; we accept the duck type so tests can inject a fake without
|
|
4
|
+
* instantiating a full dialect.
|
|
5
|
+
*/
|
|
6
|
+
export interface Quoter {
|
|
7
|
+
quoteTablePath(seg: string): string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Quote a potentially schema-qualified table path (e.g. "schema.table")
|
|
12
|
+
* by quoting each segment individually with the dialect's quoteTablePath.
|
|
13
|
+
*/
|
|
14
|
+
export function quoteTablePath(path: string, dialect: Quoter): string {
|
|
15
|
+
return path
|
|
16
|
+
.split(".")
|
|
17
|
+
.map((seg) => dialect.quoteTablePath(seg))
|
|
18
|
+
.join(".");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Split a possibly schema-qualified table name into its schema prefix
|
|
23
|
+
* (including the trailing dot) and the bare table name.
|
|
24
|
+
*
|
|
25
|
+
* Examples:
|
|
26
|
+
* "my_schema.my_table" -> { schemaPrefix: "my_schema.", bareName: "my_table" }
|
|
27
|
+
* "my_table" -> { schemaPrefix: "", bareName: "my_table" }
|
|
28
|
+
*/
|
|
29
|
+
export function splitTablePath(tableName: string): {
|
|
30
|
+
schemaPrefix: string;
|
|
31
|
+
bareName: string;
|
|
32
|
+
} {
|
|
33
|
+
const lastDot = tableName.lastIndexOf(".");
|
|
34
|
+
if (lastDot >= 0) {
|
|
35
|
+
return {
|
|
36
|
+
schemaPrefix: tableName.substring(0, lastDot + 1),
|
|
37
|
+
bareName: tableName.substring(lastDot + 1),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { schemaPrefix: "", bareName: tableName };
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ProjectNotFoundError } from "../errors";
|
|
2
|
+
import { ResourceRepository } from "../storage/DatabaseInterface";
|
|
3
|
+
|
|
4
|
+
export async function resolveProjectId(
|
|
5
|
+
repository: ResourceRepository,
|
|
6
|
+
projectName: string,
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
const dbProject = await repository.getProjectByName(projectName);
|
|
9
|
+
if (!dbProject) {
|
|
10
|
+
throw new ProjectNotFoundError(`Project '${projectName}' not found`);
|
|
11
|
+
}
|
|
12
|
+
return dbProject.id;
|
|
13
|
+
}
|
|
@@ -34,7 +34,7 @@ export interface ResourceRepository {
|
|
|
34
34
|
getConnectionById(id: string): Promise<Connection | null>;
|
|
35
35
|
getConnectionByName(
|
|
36
36
|
projectId: string,
|
|
37
|
-
|
|
37
|
+
name: string,
|
|
38
38
|
): Promise<Connection | null>;
|
|
39
39
|
createConnection(
|
|
40
40
|
connection: Omit<Connection, "id" | "createdAt" | "updatedAt">,
|
|
@@ -44,6 +44,44 @@ export interface ResourceRepository {
|
|
|
44
44
|
updates: Partial<Connection>,
|
|
45
45
|
): Promise<Connection>;
|
|
46
46
|
deleteConnection(id: string): Promise<void>;
|
|
47
|
+
|
|
48
|
+
// Materializations
|
|
49
|
+
listMaterializations(
|
|
50
|
+
projectId: string,
|
|
51
|
+
packageName: string,
|
|
52
|
+
options?: { limit?: number; offset?: number },
|
|
53
|
+
): Promise<Materialization[]>;
|
|
54
|
+
getMaterializationById(id: string): Promise<Materialization | null>;
|
|
55
|
+
getActiveMaterialization(
|
|
56
|
+
projectId: string,
|
|
57
|
+
packageName: string,
|
|
58
|
+
): Promise<Materialization | null>;
|
|
59
|
+
createMaterialization(
|
|
60
|
+
projectId: string,
|
|
61
|
+
packageName: string,
|
|
62
|
+
status?: MaterializationStatus,
|
|
63
|
+
metadata?: Record<string, unknown> | null,
|
|
64
|
+
): Promise<Materialization>;
|
|
65
|
+
updateMaterialization(
|
|
66
|
+
id: string,
|
|
67
|
+
updates: {
|
|
68
|
+
status?: MaterializationStatus;
|
|
69
|
+
startedAt?: Date;
|
|
70
|
+
completedAt?: Date;
|
|
71
|
+
error?: string | null;
|
|
72
|
+
metadata?: Record<string, unknown> | null;
|
|
73
|
+
},
|
|
74
|
+
): Promise<Materialization>;
|
|
75
|
+
deleteMaterialization(id: string): Promise<void>;
|
|
76
|
+
// Build Manifests
|
|
77
|
+
listManifestEntries(
|
|
78
|
+
projectId: string,
|
|
79
|
+
packageName: string,
|
|
80
|
+
): Promise<ManifestEntry[]>;
|
|
81
|
+
upsertManifestEntry(
|
|
82
|
+
entry: Omit<ManifestEntry, "id" | "createdAt" | "updatedAt">,
|
|
83
|
+
): Promise<ManifestEntry>;
|
|
84
|
+
deleteManifestEntry(id: string): Promise<void>;
|
|
47
85
|
}
|
|
48
86
|
|
|
49
87
|
export interface Project {
|
|
@@ -76,3 +114,67 @@ export interface Connection {
|
|
|
76
114
|
createdAt: Date;
|
|
77
115
|
updatedAt: Date;
|
|
78
116
|
}
|
|
117
|
+
|
|
118
|
+
export type MaterializationStatus =
|
|
119
|
+
| "PENDING"
|
|
120
|
+
| "RUNNING"
|
|
121
|
+
| "SUCCESS"
|
|
122
|
+
| "FAILED"
|
|
123
|
+
| "CANCELLED";
|
|
124
|
+
|
|
125
|
+
export interface Materialization {
|
|
126
|
+
id: string;
|
|
127
|
+
projectId: string;
|
|
128
|
+
packageName: string;
|
|
129
|
+
status: MaterializationStatus;
|
|
130
|
+
startedAt: Date | null;
|
|
131
|
+
completedAt: Date | null;
|
|
132
|
+
error: string | null;
|
|
133
|
+
metadata: Record<string, unknown> | null;
|
|
134
|
+
createdAt: Date;
|
|
135
|
+
updatedAt: Date;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ManifestEntry {
|
|
139
|
+
id: string;
|
|
140
|
+
projectId: string;
|
|
141
|
+
packageName: string;
|
|
142
|
+
buildId: string;
|
|
143
|
+
tableName: string;
|
|
144
|
+
sourceName: string;
|
|
145
|
+
connectionName: string;
|
|
146
|
+
createdAt: Date;
|
|
147
|
+
updatedAt: Date;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ==================== MANIFEST STORE ====================
|
|
151
|
+
|
|
152
|
+
export interface BuildManifestEntry {
|
|
153
|
+
tableName: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface BuildManifest {
|
|
157
|
+
entries: Record<string, BuildManifestEntry>;
|
|
158
|
+
strict?: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Abstraction for manifest storage. Standalone mode uses DuckDB;
|
|
163
|
+
* orchestrated mode swaps in a DuckLakeManifestStore.
|
|
164
|
+
*/
|
|
165
|
+
export interface ManifestStore {
|
|
166
|
+
getManifest(projectId: string, packageName: string): Promise<BuildManifest>;
|
|
167
|
+
writeEntry(
|
|
168
|
+
projectId: string,
|
|
169
|
+
packageName: string,
|
|
170
|
+
buildId: string,
|
|
171
|
+
tableName: string,
|
|
172
|
+
sourceName: string,
|
|
173
|
+
connectionName: string,
|
|
174
|
+
): Promise<void>;
|
|
175
|
+
deleteEntry(id: string): Promise<void>;
|
|
176
|
+
listEntries(
|
|
177
|
+
projectId: string,
|
|
178
|
+
packageName: string,
|
|
179
|
+
): Promise<ManifestEntry[]>;
|
|
180
|
+
}
|
|
@@ -47,4 +47,13 @@ export class StorageManager {
|
|
|
47
47
|
deleteConnection: async (): Promise<void> => {},
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
getManifestStore() {
|
|
52
|
+
return {
|
|
53
|
+
getManifest: async () => ({ entries: {}, strict: false }),
|
|
54
|
+
writeEntry: async () => {},
|
|
55
|
+
deleteEntry: async () => {},
|
|
56
|
+
listEntries: async () => [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
50
59
|
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { logger } from "../logger";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DatabaseConnection,
|
|
4
|
+
ManifestStore,
|
|
5
|
+
ResourceRepository,
|
|
6
|
+
} from "./DatabaseInterface";
|
|
3
7
|
import { DuckDBConnection } from "./duckdb/DuckDBConnection";
|
|
8
|
+
import { DuckDBManifestStore } from "./duckdb/DuckDBManifestStore";
|
|
4
9
|
import { DuckDBRepository } from "./duckdb/DuckDBRepository";
|
|
5
10
|
import { initializeSchema } from "./duckdb/schema";
|
|
11
|
+
import { DuckLakeManifestStore } from "./ducklake/DuckLakeManifestStore";
|
|
6
12
|
|
|
7
13
|
export type StorageType = "duckdb" | "postgres" | "mysql";
|
|
8
14
|
|
|
15
|
+
export interface DuckLakeManifestConfig {
|
|
16
|
+
catalogUrl: string;
|
|
17
|
+
dataPath: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
export interface StorageConfig {
|
|
10
21
|
type: StorageType;
|
|
11
22
|
duckdb?: {
|
|
@@ -27,9 +38,28 @@ export interface StorageConfig {
|
|
|
27
38
|
};
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
function escapeSQL(value: string): string {
|
|
42
|
+
return value.replace(/'/g, "''");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Manages the storage backend (DuckDB, Postgres, etc.) and per-project
|
|
47
|
+
* manifest stores. Projects without `materializationStorage` config use
|
|
48
|
+
* the default DuckDB manifest store. Projects with the config get a
|
|
49
|
+
* DuckLake-backed store attached lazily on first access.
|
|
50
|
+
*/
|
|
30
51
|
export class StorageManager {
|
|
31
52
|
private connection: DatabaseConnection | null = null;
|
|
53
|
+
private duckDbConnection: DuckDBConnection | null = null;
|
|
32
54
|
private repository: ResourceRepository | null = null;
|
|
55
|
+
private defaultManifestStore: ManifestStore | null = null;
|
|
56
|
+
|
|
57
|
+
/** Per-project DuckLake manifest stores, keyed by projectId. */
|
|
58
|
+
private projectManifestStores = new Map<string, ManifestStore>();
|
|
59
|
+
|
|
60
|
+
/** Tracks which catalogs have been attached to avoid duplicate ATTACHes. */
|
|
61
|
+
private attachedCatalogs = new Set<string>();
|
|
62
|
+
|
|
33
63
|
private config: StorageConfig;
|
|
34
64
|
|
|
35
65
|
constructor(config: StorageConfig) {
|
|
@@ -68,7 +98,73 @@ export class StorageManager {
|
|
|
68
98
|
await initializeSchema(connection, reinit);
|
|
69
99
|
|
|
70
100
|
this.connection = connection;
|
|
101
|
+
this.duckDbConnection = connection;
|
|
71
102
|
this.repository = new DuckDBRepository(connection);
|
|
103
|
+
this.defaultManifestStore = new DuckDBManifestStore(this.repository);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 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.
|
|
110
|
+
*/
|
|
111
|
+
async initializeDuckLakeForProject(
|
|
112
|
+
projectId: string,
|
|
113
|
+
config: DuckLakeManifestConfig,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
if (!this.duckDbConnection) {
|
|
116
|
+
throw new Error("Storage not initialized. Call initialize() first.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const catalogName = `manifest_lake_${projectId.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
120
|
+
|
|
121
|
+
if (!this.attachedCatalogs.has(catalogName)) {
|
|
122
|
+
await this.attachDuckLakeCatalog(config, catalogName);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const store = new DuckLakeManifestStore(
|
|
126
|
+
this.duckDbConnection,
|
|
127
|
+
catalogName,
|
|
128
|
+
);
|
|
129
|
+
await store.bootstrapSchema();
|
|
130
|
+
|
|
131
|
+
this.projectManifestStores.set(projectId, store);
|
|
132
|
+
logger.info("DuckLake manifest store initialized for project", {
|
|
133
|
+
projectId,
|
|
134
|
+
catalogName,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async attachDuckLakeCatalog(
|
|
139
|
+
config: DuckLakeManifestConfig,
|
|
140
|
+
catalogName: string,
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const connection = this.duckDbConnection!;
|
|
143
|
+
|
|
144
|
+
await connection.run("INSTALL ducklake; LOAD ducklake;");
|
|
145
|
+
|
|
146
|
+
const isPostgres = config.catalogUrl.startsWith("postgres:");
|
|
147
|
+
if (isPostgres) {
|
|
148
|
+
await connection.run("INSTALL postgres; LOAD postgres;");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const escapedCatalogUrl = escapeSQL(config.catalogUrl);
|
|
152
|
+
const escapedDataPath = escapeSQL(config.dataPath);
|
|
153
|
+
const isCloudStorage =
|
|
154
|
+
config.dataPath.startsWith("gs://") ||
|
|
155
|
+
config.dataPath.startsWith("s3://");
|
|
156
|
+
|
|
157
|
+
let attachCmd = `ATTACH 'ducklake:${escapedCatalogUrl}' AS ${catalogName}`;
|
|
158
|
+
const attachOpts: string[] = [`DATA_PATH '${escapedDataPath}'`];
|
|
159
|
+
if (isCloudStorage) {
|
|
160
|
+
attachOpts.push("OVERRIDE_DATA_PATH true");
|
|
161
|
+
}
|
|
162
|
+
attachCmd += ` (${attachOpts.join(", ")});`;
|
|
163
|
+
|
|
164
|
+
logger.info(`Attaching DuckLake manifest catalog: ${attachCmd}`);
|
|
165
|
+
await connection.run(attachCmd);
|
|
166
|
+
|
|
167
|
+
this.attachedCatalogs.add(catalogName);
|
|
72
168
|
}
|
|
73
169
|
|
|
74
170
|
getRepository(): ResourceRepository {
|
|
@@ -78,11 +174,33 @@ export class StorageManager {
|
|
|
78
174
|
return this.repository;
|
|
79
175
|
}
|
|
80
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Returns the manifest store for a project. If the project has a
|
|
179
|
+
* DuckLake store configured, returns that; otherwise returns the
|
|
180
|
+
* default DuckDB-backed store.
|
|
181
|
+
*/
|
|
182
|
+
getManifestStore(projectId?: string): ManifestStore {
|
|
183
|
+
if (projectId) {
|
|
184
|
+
const projectStore = this.projectManifestStores.get(projectId);
|
|
185
|
+
if (projectStore) {
|
|
186
|
+
return projectStore;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!this.defaultManifestStore) {
|
|
190
|
+
throw new Error("Storage not initialized. Call initialize() first.");
|
|
191
|
+
}
|
|
192
|
+
return this.defaultManifestStore;
|
|
193
|
+
}
|
|
194
|
+
|
|
81
195
|
async close(): Promise<void> {
|
|
82
196
|
if (this.connection) {
|
|
83
197
|
await this.connection.close();
|
|
84
198
|
this.connection = null;
|
|
199
|
+
this.duckDbConnection = null;
|
|
85
200
|
this.repository = null;
|
|
201
|
+
this.defaultManifestStore = null;
|
|
202
|
+
this.projectManifestStores.clear();
|
|
203
|
+
this.attachedCatalogs.clear();
|
|
86
204
|
}
|
|
87
205
|
}
|
|
88
206
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BuildManifest,
|
|
3
|
+
ManifestEntry,
|
|
4
|
+
ManifestStore,
|
|
5
|
+
ResourceRepository,
|
|
6
|
+
} from "../DatabaseInterface";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* DuckDB-backed ManifestStore that delegates to the local ResourceRepository.
|
|
10
|
+
*
|
|
11
|
+
* In standalone mode this is the active store. Orchestrated deployments swap
|
|
12
|
+
* in a DuckLake-backed implementation that reads/writes manifests through the
|
|
13
|
+
* shared catalog instead of the local DuckDB build_manifests table.
|
|
14
|
+
*/
|
|
15
|
+
export class DuckDBManifestStore implements ManifestStore {
|
|
16
|
+
constructor(private repository: ResourceRepository) {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Assembles a {@link BuildManifest} by folding all manifest rows for the
|
|
20
|
+
* given package into a build-ID-keyed map. `strict: false` lets the Malloy
|
|
21
|
+
* runtime fall back to executing the underlying query when a persist
|
|
22
|
+
* reference has no manifest entry (e.g. before the first materialization).
|
|
23
|
+
*/
|
|
24
|
+
async getManifest(
|
|
25
|
+
projectId: string,
|
|
26
|
+
packageName: string,
|
|
27
|
+
): Promise<BuildManifest> {
|
|
28
|
+
const entries = await this.repository.listManifestEntries(
|
|
29
|
+
projectId,
|
|
30
|
+
packageName,
|
|
31
|
+
);
|
|
32
|
+
const manifest: BuildManifest = {
|
|
33
|
+
entries: {},
|
|
34
|
+
strict: false,
|
|
35
|
+
};
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
manifest.entries[entry.buildId] = { tableName: entry.tableName };
|
|
38
|
+
}
|
|
39
|
+
return manifest;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async writeEntry(
|
|
43
|
+
projectId: string,
|
|
44
|
+
packageName: string,
|
|
45
|
+
buildId: string,
|
|
46
|
+
tableName: string,
|
|
47
|
+
sourceName: string,
|
|
48
|
+
connectionName: string,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
await this.repository.upsertManifestEntry({
|
|
51
|
+
projectId,
|
|
52
|
+
packageName,
|
|
53
|
+
buildId,
|
|
54
|
+
tableName,
|
|
55
|
+
sourceName,
|
|
56
|
+
connectionName,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async deleteEntry(id: string): Promise<void> {
|
|
61
|
+
await this.repository.deleteManifestEntry(id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async listEntries(
|
|
65
|
+
projectId: string,
|
|
66
|
+
packageName: string,
|
|
67
|
+
): Promise<ManifestEntry[]> {
|
|
68
|
+
return this.repository.listManifestEntries(projectId, packageName);
|
|
69
|
+
}
|
|
70
|
+
}
|