@malloy-publisher/server 0.0.181 → 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 +505 -52
- package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
- package/dist/app/assets/{MainPage-B53xidTF.js → MainPage-BzB3yoqi.js} +2 -2
- package/dist/app/assets/{ModelPage-UMuQe8qY.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-Cv58zNpb.js → RouteError-DAShbVCG.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DZ1StqsX.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-DPThhVfX.js → index-Bb2jqquW.js} +1 -1
- package/dist/app/assets/{index-M3Zo817E.js → index-D68X76-7.js} +98 -98
- package/dist/app/assets/{index.umd-DnfBsVqO.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} +16959 -15357
- 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 +4 -3
- package/src/controller/package.controller.ts +53 -2
- package/src/controller/query.controller.ts +5 -0
- package/src/errors.ts +24 -0
- package/src/mcp/prompts/handlers.ts +1 -1
- package/src/mcp/resources/model_resource.ts +12 -9
- package/src/mcp/resources/source_resource.ts +7 -6
- package/src/mcp/resources/view_resource.ts +0 -1
- package/src/mcp/tools/execute_query_tool.ts +9 -0
- package/src/server.ts +223 -15
- package/src/service/connection.ts +1 -4
- package/src/service/filter.spec.ts +447 -0
- package/src/service/filter.ts +337 -0
- package/src/service/filter_integration.spec.ts +825 -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 +227 -49
- 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-B0C6gwGj.js +0 -1
- package/dist/app/assets/PackagePage-BEDvm_je.js +0 -1
- package/dist/app/assets/ProjectPage-DzN4P86H.js +0 -1
- package/dist/app/assets/index-D-xPyBUA.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,13 +41,22 @@ 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";
|
|
46
|
+
import {
|
|
47
|
+
buildFilterClause,
|
|
48
|
+
FilterValidationError,
|
|
49
|
+
injectFilterRefinement,
|
|
50
|
+
parseFilters,
|
|
51
|
+
type FilterDefinition,
|
|
52
|
+
type FilterParams,
|
|
53
|
+
} from "./filter";
|
|
45
54
|
|
|
46
55
|
type ApiCompiledModel = components["schemas"]["CompiledModel"];
|
|
47
56
|
type ApiNotebookCell = components["schemas"]["NotebookCell"];
|
|
48
57
|
type ApiRawNotebook = components["schemas"]["RawNotebook"];
|
|
49
|
-
// @ts-expect-error TODO: Fix missing Source type in API
|
|
50
58
|
type ApiSource = components["schemas"]["Source"];
|
|
59
|
+
type ApiFilter = components["schemas"]["Filter"];
|
|
51
60
|
type ApiView = components["schemas"]["View"];
|
|
52
61
|
type ApiQuery = components["schemas"]["Query"];
|
|
53
62
|
export type ApiConnection = components["schemas"]["Connection"];
|
|
@@ -56,7 +65,11 @@ export type PostgresConnection = components["schemas"]["PostgresConnection"];
|
|
|
56
65
|
export type BigqueryConnection = components["schemas"]["BigqueryConnection"];
|
|
57
66
|
export type TrinoConnection = components["schemas"]["TrinoConnection"];
|
|
58
67
|
|
|
59
|
-
const MALLOY_VERSION =
|
|
68
|
+
const MALLOY_VERSION = (
|
|
69
|
+
createRequire(import.meta.url)("@malloydata/malloy/package.json") as {
|
|
70
|
+
version: string;
|
|
71
|
+
}
|
|
72
|
+
).version;
|
|
60
73
|
|
|
61
74
|
export type ModelType = "model" | "notebook";
|
|
62
75
|
|
|
@@ -64,6 +77,8 @@ interface RunnableNotebookCell {
|
|
|
64
77
|
type: "code" | "markdown";
|
|
65
78
|
text: string;
|
|
66
79
|
runnable?: QueryMaterializer;
|
|
80
|
+
/** Retained so we can rebuild the query with filter refinements at execution time. */
|
|
81
|
+
modelMaterializer?: ModelMaterializer;
|
|
67
82
|
newSources?: Malloy.SourceInfo[];
|
|
68
83
|
queryInfo?: Malloy.QueryInfo;
|
|
69
84
|
}
|
|
@@ -81,6 +96,8 @@ export class Model {
|
|
|
81
96
|
private sourceInfos: Malloy.SourceInfo[] | undefined;
|
|
82
97
|
private runnableNotebookCells: RunnableNotebookCell[] | undefined;
|
|
83
98
|
private compilationError: MalloyError | Error | undefined;
|
|
99
|
+
/** Parsed #(filter) definitions keyed by source name. */
|
|
100
|
+
private filterMap: Map<string, FilterDefinition[]>;
|
|
84
101
|
private meter = metrics.getMeter("publisher");
|
|
85
102
|
private queryExecutionHistogram = this.meter.createHistogram(
|
|
86
103
|
"malloy_model_query_duration",
|
|
@@ -103,6 +120,7 @@ export class Model {
|
|
|
103
120
|
sourceInfos: Malloy.SourceInfo[] | undefined,
|
|
104
121
|
runnableNotebookCells: RunnableNotebookCell[] | undefined,
|
|
105
122
|
compilationError: MalloyError | Error | undefined,
|
|
123
|
+
filterMap?: Map<string, FilterDefinition[]>,
|
|
106
124
|
) {
|
|
107
125
|
this.packageName = packageName;
|
|
108
126
|
this.modelPath = modelPath;
|
|
@@ -115,21 +133,47 @@ export class Model {
|
|
|
115
133
|
this.sourceInfos = sourceInfos;
|
|
116
134
|
this.runnableNotebookCells = runnableNotebookCells;
|
|
117
135
|
this.compilationError = compilationError;
|
|
136
|
+
this.filterMap = filterMap ?? new Map();
|
|
118
137
|
this.modelInfo = this.modelDef
|
|
119
138
|
? modelDefToModelInfo(this.modelDef)
|
|
120
139
|
: undefined;
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Get the parsed filter definitions for a given source name.
|
|
144
|
+
* Returns an empty array if no filters are declared.
|
|
145
|
+
*/
|
|
146
|
+
public getFilters(sourceName: string): FilterDefinition[] {
|
|
147
|
+
return this.filterMap.get(sourceName) ?? [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Best-effort extraction of a source name from an ad-hoc Malloy query string.
|
|
152
|
+
* Matches patterns like `run: source_name -> ...` or `source_name -> ...`.
|
|
153
|
+
*/
|
|
154
|
+
private extractSourceName(query?: string): string | undefined {
|
|
155
|
+
if (!query) return undefined;
|
|
156
|
+
const runMatch = query.match(/run\s*:\s*(\w+)\s*->/);
|
|
157
|
+
const arrowMatch = query.match(/^\s*(\w+)\s*->/m);
|
|
158
|
+
return runMatch?.[1] ?? arrowMatch?.[1];
|
|
159
|
+
}
|
|
160
|
+
|
|
123
161
|
public static async create(
|
|
124
162
|
packageName: string,
|
|
125
163
|
packagePath: string,
|
|
126
164
|
modelPath: string,
|
|
127
165
|
connections: Map<string, Connection>,
|
|
166
|
+
options?: { buildManifest?: BuildManifest["entries"] },
|
|
128
167
|
): Promise<Model> {
|
|
129
168
|
// getModelRuntime might throw a ModelNotFoundError. It's the callers responsibility
|
|
130
169
|
// to pass a valid model path or handle the error.
|
|
131
170
|
const { runtime, modelURL, importBaseURL, dataStyles, modelType } =
|
|
132
|
-
await Model.getModelRuntime(
|
|
171
|
+
await Model.getModelRuntime(
|
|
172
|
+
packagePath,
|
|
173
|
+
modelPath,
|
|
174
|
+
connections,
|
|
175
|
+
options,
|
|
176
|
+
);
|
|
133
177
|
|
|
134
178
|
try {
|
|
135
179
|
const { modelMaterializer, runnableNotebookCells } =
|
|
@@ -143,10 +187,13 @@ export class Model {
|
|
|
143
187
|
let modelDef = undefined;
|
|
144
188
|
let sources = undefined;
|
|
145
189
|
let queries = undefined;
|
|
190
|
+
let filterMap: Map<string, FilterDefinition[]> | undefined;
|
|
146
191
|
const sourceInfos: Malloy.SourceInfo[] = [];
|
|
147
192
|
if (modelMaterializer) {
|
|
148
193
|
modelDef = (await modelMaterializer.getModel())._modelDef;
|
|
149
|
-
|
|
194
|
+
const sourceResult = Model.getSources(modelPath, modelDef);
|
|
195
|
+
sources = sourceResult.sources;
|
|
196
|
+
filterMap = sourceResult.filterMap;
|
|
150
197
|
queries = Model.getQueries(modelPath, modelDef);
|
|
151
198
|
|
|
152
199
|
// Collect sourceInfos from imported models first
|
|
@@ -207,6 +254,7 @@ export class Model {
|
|
|
207
254
|
sourceInfos.length > 0 ? sourceInfos : undefined,
|
|
208
255
|
runnableNotebookCells,
|
|
209
256
|
undefined,
|
|
257
|
+
filterMap,
|
|
210
258
|
);
|
|
211
259
|
} catch (error) {
|
|
212
260
|
let computedError = error;
|
|
@@ -254,7 +302,7 @@ export class Model {
|
|
|
254
302
|
}
|
|
255
303
|
|
|
256
304
|
public getQueries(): ApiQuery[] | undefined {
|
|
257
|
-
return this.
|
|
305
|
+
return this.queries;
|
|
258
306
|
}
|
|
259
307
|
|
|
260
308
|
public async getModel(): Promise<ApiCompiledModel> {
|
|
@@ -292,6 +340,8 @@ export class Model {
|
|
|
292
340
|
sourceName?: string,
|
|
293
341
|
queryName?: string,
|
|
294
342
|
query?: string,
|
|
343
|
+
filterParams?: FilterParams,
|
|
344
|
+
bypassFilters?: boolean,
|
|
295
345
|
): Promise<{
|
|
296
346
|
result: Malloy.Result;
|
|
297
347
|
compactResult: QueryData;
|
|
@@ -318,12 +368,11 @@ export class Model {
|
|
|
318
368
|
|
|
319
369
|
// Wrap loadQuery calls in try-catch to handle query parsing errors
|
|
320
370
|
try {
|
|
371
|
+
let queryString: string;
|
|
321
372
|
if (!sourceName && !queryName && query) {
|
|
322
|
-
|
|
373
|
+
queryString = "\n" + query;
|
|
323
374
|
} else if (queryName && !query) {
|
|
324
|
-
|
|
325
|
-
`\nrun: ${sourceName ? sourceName + "->" : ""}${queryName}`,
|
|
326
|
-
);
|
|
375
|
+
queryString = `\nrun: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
327
376
|
} else {
|
|
328
377
|
const endTime = performance.now();
|
|
329
378
|
const executionTime = endTime - startTime;
|
|
@@ -338,11 +387,35 @@ export class Model {
|
|
|
338
387
|
"Invalid query request. (Query AND !sourceName) OR (queryName AND sourceName) must be defined.",
|
|
339
388
|
);
|
|
340
389
|
}
|
|
390
|
+
|
|
391
|
+
// Inject source filter predicates unless bypassed
|
|
392
|
+
if (!bypassFilters) {
|
|
393
|
+
const effectiveSource = sourceName ?? this.extractSourceName(query);
|
|
394
|
+
if (effectiveSource) {
|
|
395
|
+
const filters = this.getFilters(effectiveSource);
|
|
396
|
+
if (filters.length > 0) {
|
|
397
|
+
const filterClause = buildFilterClause(
|
|
398
|
+
filters,
|
|
399
|
+
filterParams ?? {},
|
|
400
|
+
);
|
|
401
|
+
queryString = injectFilterRefinement(
|
|
402
|
+
queryString,
|
|
403
|
+
filterClause,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
runnable = this.modelMaterializer.loadQuery(queryString);
|
|
341
410
|
} catch (error) {
|
|
342
411
|
// Re-throw BadRequestError as-is
|
|
343
412
|
if (error instanceof BadRequestError) {
|
|
344
413
|
throw error;
|
|
345
414
|
}
|
|
415
|
+
// Source filter validation errors are client errors (400)
|
|
416
|
+
if (error instanceof FilterValidationError) {
|
|
417
|
+
throw new BadRequestError(error.message);
|
|
418
|
+
}
|
|
346
419
|
// Re-throw MalloyError as-is (maps to 400)
|
|
347
420
|
if (error instanceof MalloyError) {
|
|
348
421
|
throw error;
|
|
@@ -491,7 +564,11 @@ export class Model {
|
|
|
491
564
|
} as ApiRawNotebook;
|
|
492
565
|
}
|
|
493
566
|
|
|
494
|
-
public async executeNotebookCell(
|
|
567
|
+
public async executeNotebookCell(
|
|
568
|
+
cellIndex: number,
|
|
569
|
+
filterParams?: FilterParams,
|
|
570
|
+
bypassFilters?: boolean,
|
|
571
|
+
): Promise<{
|
|
495
572
|
type: "code" | "markdown";
|
|
496
573
|
text: string;
|
|
497
574
|
queryName?: string;
|
|
@@ -527,18 +604,44 @@ export class Model {
|
|
|
527
604
|
|
|
528
605
|
if (cell.runnable) {
|
|
529
606
|
try {
|
|
607
|
+
let runnableToExecute = cell.runnable;
|
|
608
|
+
|
|
609
|
+
// If filters need to be applied, rebuild the query with a refinement
|
|
610
|
+
if (!bypassFilters && cell.modelMaterializer) {
|
|
611
|
+
const effectiveSource = this.extractSourceName(cell.text);
|
|
612
|
+
if (effectiveSource) {
|
|
613
|
+
const filters = this.getFilters(effectiveSource);
|
|
614
|
+
if (filters.length > 0) {
|
|
615
|
+
const filterClause = buildFilterClause(
|
|
616
|
+
filters,
|
|
617
|
+
filterParams ?? {},
|
|
618
|
+
);
|
|
619
|
+
if (filterClause) {
|
|
620
|
+
const refinedQuery = injectFilterRefinement(
|
|
621
|
+
cell.text,
|
|
622
|
+
filterClause,
|
|
623
|
+
);
|
|
624
|
+
runnableToExecute =
|
|
625
|
+
cell.modelMaterializer.loadQuery(refinedQuery);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
530
631
|
const rowLimit =
|
|
531
|
-
(await
|
|
532
|
-
|
|
533
|
-
const result = await
|
|
534
|
-
const query = (await
|
|
632
|
+
(await runnableToExecute.getPreparedResult()).resultExplore
|
|
633
|
+
.limit || ROW_LIMIT;
|
|
634
|
+
const result = await runnableToExecute.run({ rowLimit });
|
|
635
|
+
const query = (await runnableToExecute.getPreparedQuery())._query;
|
|
535
636
|
queryName = (query as NamedQueryDef).as || query.name;
|
|
536
637
|
queryResult =
|
|
537
638
|
result?._queryResult &&
|
|
538
639
|
this.modelInfo &&
|
|
539
640
|
JSON.stringify(API.util.wrapResult(result));
|
|
540
641
|
} catch (error) {
|
|
541
|
-
|
|
642
|
+
if (error instanceof FilterValidationError) {
|
|
643
|
+
throw new BadRequestError(error.message);
|
|
644
|
+
}
|
|
542
645
|
if (error instanceof MalloyError) {
|
|
543
646
|
throw error;
|
|
544
647
|
}
|
|
@@ -570,6 +673,7 @@ export class Model {
|
|
|
570
673
|
packagePath: string,
|
|
571
674
|
modelPath: string,
|
|
572
675
|
connections: Map<string, Connection>,
|
|
676
|
+
options?: { buildManifest?: BuildManifest["entries"] },
|
|
573
677
|
): Promise<{
|
|
574
678
|
runtime: Runtime;
|
|
575
679
|
modelURL: URL;
|
|
@@ -609,10 +713,23 @@ export class Model {
|
|
|
609
713
|
`SET FILE_SEARCH_PATH='${workingDirectory}';`,
|
|
610
714
|
);
|
|
611
715
|
|
|
612
|
-
const
|
|
716
|
+
const runtimeOptions: {
|
|
717
|
+
urlReader: typeof urlReader;
|
|
718
|
+
connections: FixedConnectionMap;
|
|
719
|
+
buildManifest?: BuildManifest;
|
|
720
|
+
} = {
|
|
613
721
|
urlReader,
|
|
614
722
|
connections: new FixedConnectionMap(connections, "duckdb"),
|
|
615
|
-
}
|
|
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);
|
|
616
733
|
const dataStyles = urlReader.getHackyAccumulatedDataStyles();
|
|
617
734
|
return { runtime, modelURL, importBaseURL, dataStyles, modelType };
|
|
618
735
|
}
|
|
@@ -644,38 +761,98 @@ export class Model {
|
|
|
644
761
|
private static getSources(
|
|
645
762
|
modelPath: string,
|
|
646
763
|
modelDef: ModelDef,
|
|
647
|
-
):
|
|
648
|
-
|
|
764
|
+
): {
|
|
765
|
+
sources: ApiSource[];
|
|
766
|
+
filterMap: Map<string, FilterDefinition[]>;
|
|
767
|
+
} {
|
|
768
|
+
const filterMap = new Map<string, FilterDefinition[]>();
|
|
769
|
+
|
|
770
|
+
const sources = Object.values(modelDef.contents)
|
|
649
771
|
.filter((obj) => isSourceDef(obj))
|
|
650
|
-
.map(
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
772
|
+
.map((sourceObj) => {
|
|
773
|
+
const sourceName = sourceObj.as || sourceObj.name;
|
|
774
|
+
const annotations = (sourceObj as StructDef).annotation?.blockNotes
|
|
775
|
+
?.filter((note) => note.at.url.includes(modelPath))
|
|
776
|
+
.map((note) => note.text);
|
|
777
|
+
|
|
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();
|
|
799
|
+
let filters: ApiFilter[] | undefined;
|
|
800
|
+
if (allAnnotations.length > 0) {
|
|
801
|
+
try {
|
|
802
|
+
const parsed = parseFilters(allAnnotations);
|
|
803
|
+
if (parsed.length > 0) {
|
|
804
|
+
filterMap.set(sourceName, parsed);
|
|
805
|
+
const structFields = (sourceObj as StructDef).fields;
|
|
806
|
+
filters = parsed.map((f) => {
|
|
807
|
+
const field = structFields.find(
|
|
808
|
+
(fd) => (fd.as || fd.name) === f.dimension,
|
|
809
|
+
);
|
|
810
|
+
return {
|
|
811
|
+
name: f.name,
|
|
812
|
+
dimension: f.dimension,
|
|
813
|
+
type: f.type,
|
|
814
|
+
implicit: f.implicit,
|
|
815
|
+
required: f.required,
|
|
816
|
+
dimensionType: field?.type as string | undefined,
|
|
817
|
+
};
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
} catch (err) {
|
|
821
|
+
logger.warn(
|
|
822
|
+
`Failed to parse filter annotations on source "${sourceName}"`,
|
|
823
|
+
{ error: err },
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const views = (sourceObj as StructDef).fields
|
|
829
|
+
.filter((turtleObj) => turtleObj.type === "turtle")
|
|
830
|
+
.filter((turtleObj) =>
|
|
831
|
+
// TODO(kjnesbit): Fix non-reduce views. Filter out
|
|
832
|
+
// non-reduce views, i.e., indexes. Need to discuss with Will.
|
|
833
|
+
(turtleObj as TurtleDef).pipeline
|
|
834
|
+
.map((stage) => stage.type)
|
|
835
|
+
.every((type) => type == "reduce"),
|
|
836
|
+
)
|
|
837
|
+
.map(
|
|
838
|
+
(turtleObj) =>
|
|
839
|
+
({
|
|
840
|
+
name: turtleObj.as || turtleObj.name,
|
|
841
|
+
annotations: turtleObj?.annotation?.blockNotes
|
|
842
|
+
?.filter((note) => note.at.url.includes(modelPath))
|
|
843
|
+
.map((note) => note.text),
|
|
844
|
+
}) as ApiView,
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
name: sourceName,
|
|
849
|
+
annotations,
|
|
850
|
+
views,
|
|
851
|
+
filters,
|
|
852
|
+
} as ApiSource;
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
return { sources, filterMap };
|
|
679
856
|
}
|
|
680
857
|
|
|
681
858
|
static async getModelMaterializer(
|
|
@@ -857,6 +1034,7 @@ export class Model {
|
|
|
857
1034
|
type: "code",
|
|
858
1035
|
text: stmt.text,
|
|
859
1036
|
runnable: runnable,
|
|
1037
|
+
modelMaterializer: localMM,
|
|
860
1038
|
newSources,
|
|
861
1039
|
queryInfo,
|
|
862
1040
|
} as RunnableNotebookCell;
|
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
|
+
}
|