@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.
Files changed (74) hide show
  1. package/build.ts +7 -3
  2. package/dist/app/api-doc.yaml +505 -52
  3. package/dist/app/assets/HomePage-Dn3E4CuB.js +1 -0
  4. package/dist/app/assets/{MainPage-B53xidTF.js → MainPage-BzB3yoqi.js} +2 -2
  5. package/dist/app/assets/{ModelPage-UMuQe8qY.js → ModelPage-C9O_sAXT.js} +1 -1
  6. package/dist/app/assets/PackagePage-DcxKEjBX.js +1 -0
  7. package/dist/app/assets/ProjectPage-BDj307rF.js +1 -0
  8. package/dist/app/assets/{RouteError-Cv58zNpb.js → RouteError-DAShbVCG.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DZ1StqsX.js → WorkbookPage-Cs_XYEaB.js} +1 -1
  10. package/dist/app/assets/core-CjeTkq8O.es-BqRc6yhC.js +148 -0
  11. package/dist/app/assets/engine-oniguruma-C4vnmooL.es-jdkXmgTr.js +1 -0
  12. package/dist/app/assets/github-light-JYsPkUQd.es-DAi9KRSo.js +1 -0
  13. package/dist/app/assets/index-15BOvhp0.js +456 -0
  14. package/dist/app/assets/{index-DPThhVfX.js → index-Bb2jqquW.js} +1 -1
  15. package/dist/app/assets/{index-M3Zo817E.js → index-D68X76-7.js} +98 -98
  16. package/dist/app/assets/{index.umd-DnfBsVqO.js → index.umd-DGBekgSu.js} +1 -1
  17. package/dist/app/assets/json-71t8ZF9g.es-BQoSv7ci.js +1 -0
  18. package/dist/app/assets/sql-DCkt643-.es-COK4E0Yg.js +1 -0
  19. package/dist/app/assets/typescript-buWNZFwO.es-Dj6nwHGl.js +1 -0
  20. package/dist/app/index.html +1 -1
  21. package/dist/{instrumentation.js → instrumentation.mjs} +10567 -10584
  22. package/dist/{server.js → server.mjs} +16959 -15357
  23. package/package.json +19 -17
  24. package/src/controller/connection.controller.ts +27 -20
  25. package/src/controller/manifest.controller.ts +29 -0
  26. package/src/controller/materialization.controller.ts +125 -0
  27. package/src/controller/model.controller.ts +4 -3
  28. package/src/controller/package.controller.ts +53 -2
  29. package/src/controller/query.controller.ts +5 -0
  30. package/src/errors.ts +24 -0
  31. package/src/mcp/prompts/handlers.ts +1 -1
  32. package/src/mcp/resources/model_resource.ts +12 -9
  33. package/src/mcp/resources/source_resource.ts +7 -6
  34. package/src/mcp/resources/view_resource.ts +0 -1
  35. package/src/mcp/tools/execute_query_tool.ts +9 -0
  36. package/src/server.ts +223 -15
  37. package/src/service/connection.ts +1 -4
  38. package/src/service/filter.spec.ts +447 -0
  39. package/src/service/filter.ts +337 -0
  40. package/src/service/filter_integration.spec.ts +825 -0
  41. package/src/service/manifest_service.spec.ts +201 -0
  42. package/src/service/manifest_service.ts +106 -0
  43. package/src/service/materialization_service.spec.ts +648 -0
  44. package/src/service/materialization_service.ts +929 -0
  45. package/src/service/materialized_table_gc.spec.ts +383 -0
  46. package/src/service/materialized_table_gc.ts +279 -0
  47. package/src/service/model.ts +227 -49
  48. package/src/service/package.ts +50 -0
  49. package/src/service/project_store.ts +21 -2
  50. package/src/service/quoting.ts +41 -0
  51. package/src/service/resolve_project.ts +13 -0
  52. package/src/storage/DatabaseInterface.ts +103 -1
  53. package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
  54. package/src/storage/StorageManager.ts +119 -1
  55. package/src/storage/duckdb/DuckDBConnection.ts +1 -1
  56. package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
  57. package/src/storage/duckdb/DuckDBRepository.ts +99 -9
  58. package/src/storage/duckdb/ManifestRepository.ts +119 -0
  59. package/src/storage/duckdb/MaterializationRepository.ts +249 -0
  60. package/src/storage/duckdb/manifest_store.spec.ts +133 -0
  61. package/src/storage/duckdb/schema.ts +59 -1
  62. package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
  63. package/tests/fixtures/persist-test/data/orders.csv +5 -0
  64. package/tests/fixtures/persist-test/persist_test.malloy +11 -0
  65. package/tests/fixtures/persist-test/publisher.json +5 -0
  66. package/tests/fixtures/publisher.config.json +15 -0
  67. package/tests/harness/rest_e2e.ts +68 -0
  68. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
  69. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
  70. package/tsconfig.json +1 -1
  71. package/dist/app/assets/HomePage-B0C6gwGj.js +0 -1
  72. package/dist/app/assets/PackagePage-BEDvm_je.js +0 -1
  73. package/dist/app/assets/ProjectPage-DzN4P86H.js +0 -1
  74. package/dist/app/assets/index-D-xPyBUA.js +0 -467
@@ -22,7 +22,7 @@ import {
22
22
  MalloySQLParser,
23
23
  MalloySQLStatementType,
24
24
  } from "@malloydata/malloy-sql";
25
- import malloyPackage from "@malloydata/malloy/package.json";
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 = malloyPackage.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(packagePath, modelPath, connections);
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
- sources = Model.getSources(modelPath, modelDef);
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.sources;
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
- runnable = this.modelMaterializer.loadQuery("\n" + query);
373
+ queryString = "\n" + query;
323
374
  } else if (queryName && !query) {
324
- runnable = this.modelMaterializer.loadQuery(
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(cellIndex: number): Promise<{
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 cell.runnable.getPreparedResult()).resultExplore.limit ||
532
- ROW_LIMIT;
533
- const result = await cell.runnable.run({ rowLimit });
534
- const query = (await cell.runnable.getPreparedQuery())._query;
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
- // Re-throw execution errors so the client knows about them
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 runtime = new Runtime({
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
- ): ApiSource[] {
648
- return Object.values(modelDef.contents)
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
- (sourceObj) =>
652
- ({
653
- name: sourceObj.as || sourceObj.name,
654
- annotations: (sourceObj as StructDef).annotation?.blockNotes
655
- ?.filter((note) => note.at.url.includes(modelPath))
656
- .map((note) => note.text),
657
- views: (sourceObj as StructDef).fields
658
- .filter((turtleObj) => turtleObj.type === "turtle")
659
- .filter((turtleObj) =>
660
- // TODO(kjnesbit): Fix non-reduce views. Filter out
661
- // non-reduce views, i.e., indexes. Need to discuss with Will.
662
- (turtleObj as TurtleDef).pipeline
663
- .map((stage) => stage.type)
664
- .every((type) => type == "reduce"),
665
- )
666
- .map(
667
- (turtleObj) =>
668
- ({
669
- name: turtleObj.as || turtleObj.name,
670
- annotations: turtleObj?.annotation?.blockNotes
671
- ?.filter((note) =>
672
- note.at.url.includes(modelPath),
673
- )
674
- .map((note) => note.text),
675
- }) as ApiView,
676
- ),
677
- }) as ApiSource,
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;
@@ -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
- return { id: existingProject.id, name: projectName };
331
+ dbProject = { id: existingProject.id, name: projectName };
331
332
  } else {
332
- return await repository.createProject(projectData);
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
+ }