@malloy-publisher/server 0.0.178 → 0.0.180-dev-v1

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 +1 -1
  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-JYvDXOkC.js → MainPage-BzB3yoqi.js} +2 -2
  5. package/dist/app/assets/{ModelPage-TEQrhaqq.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-DnSZEzkT.js → RouteError-DAShbVCG.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DjQ8u5DD.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--80Q7qw1.js → index-Bb2jqquW.js} +1 -1
  15. package/dist/app/assets/{index-CZ4G_NMp.js → index-D68X76-7.js} +168 -166
  16. package/dist/app/assets/index.umd-DGBekgSu.js +1145 -0
  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 +10567 -10584
  22. package/dist/server.js +16973 -15367
  23. package/package.json +14 -12
  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/resources/model_resource.ts +12 -9
  32. package/src/mcp/resources/source_resource.ts +7 -6
  33. package/src/mcp/resources/view_resource.ts +0 -1
  34. package/src/mcp/tools/execute_query_tool.ts +9 -0
  35. package/src/server.ts +217 -5
  36. package/src/service/connection.ts +1 -4
  37. package/src/service/db_utils.spec.ts +4 -2
  38. package/src/service/db_utils.ts +6 -2
  39. package/src/service/filter.spec.ts +447 -0
  40. package/src/service/filter.ts +337 -0
  41. package/src/service/filter_integration.spec.ts +825 -0
  42. package/src/service/manifest_service.spec.ts +201 -0
  43. package/src/service/manifest_service.ts +106 -0
  44. package/src/service/materialization_service.spec.ts +648 -0
  45. package/src/service/materialization_service.ts +929 -0
  46. package/src/service/materialized_table_gc.spec.ts +383 -0
  47. package/src/service/materialized_table_gc.ts +279 -0
  48. package/src/service/model.ts +221 -47
  49. package/src/service/package.ts +50 -0
  50. package/src/service/project_store.ts +21 -2
  51. package/src/service/quoting.ts +41 -0
  52. package/src/service/resolve_project.ts +13 -0
  53. package/src/storage/DatabaseInterface.ts +103 -1
  54. package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
  55. package/src/storage/StorageManager.ts +119 -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/dist/app/assets/HomePage-CwUkFsA8.js +0 -1
  71. package/dist/app/assets/PackagePage-CgE-izLw.js +0 -1
  72. package/dist/app/assets/ProjectPage-PiMPpFX8.js +0 -1
  73. package/dist/app/assets/index-BJUsHnGO.js +0 -467
  74. package/dist/app/assets/index.umd-Cf-wqh-R.js +0 -1145
@@ -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
+ injectFilterRefinement,
49
+ parseFilters,
50
+ FilterValidationError,
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"];
@@ -64,6 +73,8 @@ interface RunnableNotebookCell {
64
73
  type: "code" | "markdown";
65
74
  text: string;
66
75
  runnable?: QueryMaterializer;
76
+ /** Retained so we can rebuild the query with filter refinements at execution time. */
77
+ modelMaterializer?: ModelMaterializer;
67
78
  newSources?: Malloy.SourceInfo[];
68
79
  queryInfo?: Malloy.QueryInfo;
69
80
  }
@@ -81,6 +92,8 @@ export class Model {
81
92
  private sourceInfos: Malloy.SourceInfo[] | undefined;
82
93
  private runnableNotebookCells: RunnableNotebookCell[] | undefined;
83
94
  private compilationError: MalloyError | Error | undefined;
95
+ /** Parsed #(filter) definitions keyed by source name. */
96
+ private filterMap: Map<string, FilterDefinition[]>;
84
97
  private meter = metrics.getMeter("publisher");
85
98
  private queryExecutionHistogram = this.meter.createHistogram(
86
99
  "malloy_model_query_duration",
@@ -103,6 +116,7 @@ export class Model {
103
116
  sourceInfos: Malloy.SourceInfo[] | undefined,
104
117
  runnableNotebookCells: RunnableNotebookCell[] | undefined,
105
118
  compilationError: MalloyError | Error | undefined,
119
+ filterMap?: Map<string, FilterDefinition[]>,
106
120
  ) {
107
121
  this.packageName = packageName;
108
122
  this.modelPath = modelPath;
@@ -115,21 +129,47 @@ export class Model {
115
129
  this.sourceInfos = sourceInfos;
116
130
  this.runnableNotebookCells = runnableNotebookCells;
117
131
  this.compilationError = compilationError;
132
+ this.filterMap = filterMap ?? new Map();
118
133
  this.modelInfo = this.modelDef
119
134
  ? modelDefToModelInfo(this.modelDef)
120
135
  : undefined;
121
136
  }
122
137
 
138
+ /**
139
+ * Get the parsed filter definitions for a given source name.
140
+ * Returns an empty array if no filters are declared.
141
+ */
142
+ public getFilters(sourceName: string): FilterDefinition[] {
143
+ return this.filterMap.get(sourceName) ?? [];
144
+ }
145
+
146
+ /**
147
+ * Best-effort extraction of a source name from an ad-hoc Malloy query string.
148
+ * Matches patterns like `run: source_name -> ...` or `source_name -> ...`.
149
+ */
150
+ private extractSourceName(query?: string): string | undefined {
151
+ if (!query) return undefined;
152
+ const runMatch = query.match(/run\s*:\s*(\w+)\s*->/);
153
+ const arrowMatch = query.match(/^\s*(\w+)\s*->/m);
154
+ return runMatch?.[1] ?? arrowMatch?.[1];
155
+ }
156
+
123
157
  public static async create(
124
158
  packageName: string,
125
159
  packagePath: string,
126
160
  modelPath: string,
127
161
  connections: Map<string, Connection>,
162
+ options?: { buildManifest?: BuildManifest["entries"] },
128
163
  ): Promise<Model> {
129
164
  // getModelRuntime might throw a ModelNotFoundError. It's the callers responsibility
130
165
  // to pass a valid model path or handle the error.
131
166
  const { runtime, modelURL, importBaseURL, dataStyles, modelType } =
132
- await Model.getModelRuntime(packagePath, modelPath, connections);
167
+ await Model.getModelRuntime(
168
+ packagePath,
169
+ modelPath,
170
+ connections,
171
+ options,
172
+ );
133
173
 
134
174
  try {
135
175
  const { modelMaterializer, runnableNotebookCells } =
@@ -143,10 +183,13 @@ export class Model {
143
183
  let modelDef = undefined;
144
184
  let sources = undefined;
145
185
  let queries = undefined;
186
+ let filterMap: Map<string, FilterDefinition[]> | undefined;
146
187
  const sourceInfos: Malloy.SourceInfo[] = [];
147
188
  if (modelMaterializer) {
148
189
  modelDef = (await modelMaterializer.getModel())._modelDef;
149
- sources = Model.getSources(modelPath, modelDef);
190
+ const sourceResult = Model.getSources(modelPath, modelDef);
191
+ sources = sourceResult.sources;
192
+ filterMap = sourceResult.filterMap;
150
193
  queries = Model.getQueries(modelPath, modelDef);
151
194
 
152
195
  // Collect sourceInfos from imported models first
@@ -207,6 +250,7 @@ export class Model {
207
250
  sourceInfos.length > 0 ? sourceInfos : undefined,
208
251
  runnableNotebookCells,
209
252
  undefined,
253
+ filterMap,
210
254
  );
211
255
  } catch (error) {
212
256
  let computedError = error;
@@ -254,7 +298,7 @@ export class Model {
254
298
  }
255
299
 
256
300
  public getQueries(): ApiQuery[] | undefined {
257
- return this.sources;
301
+ return this.queries;
258
302
  }
259
303
 
260
304
  public async getModel(): Promise<ApiCompiledModel> {
@@ -292,6 +336,8 @@ export class Model {
292
336
  sourceName?: string,
293
337
  queryName?: string,
294
338
  query?: string,
339
+ filterParams?: FilterParams,
340
+ bypassFilters?: boolean,
295
341
  ): Promise<{
296
342
  result: Malloy.Result;
297
343
  compactResult: QueryData;
@@ -318,12 +364,11 @@ export class Model {
318
364
 
319
365
  // Wrap loadQuery calls in try-catch to handle query parsing errors
320
366
  try {
367
+ let queryString: string;
321
368
  if (!sourceName && !queryName && query) {
322
- runnable = this.modelMaterializer.loadQuery("\n" + query);
369
+ queryString = "\n" + query;
323
370
  } else if (queryName && !query) {
324
- runnable = this.modelMaterializer.loadQuery(
325
- `\nrun: ${sourceName ? sourceName + "->" : ""}${queryName}`,
326
- );
371
+ queryString = `\nrun: ${sourceName ? sourceName + "->" : ""}${queryName}`;
327
372
  } else {
328
373
  const endTime = performance.now();
329
374
  const executionTime = endTime - startTime;
@@ -338,11 +383,35 @@ export class Model {
338
383
  "Invalid query request. (Query AND !sourceName) OR (queryName AND sourceName) must be defined.",
339
384
  );
340
385
  }
386
+
387
+ // Inject source filter predicates unless bypassed
388
+ if (!bypassFilters) {
389
+ const effectiveSource = sourceName ?? this.extractSourceName(query);
390
+ if (effectiveSource) {
391
+ const filters = this.getFilters(effectiveSource);
392
+ if (filters.length > 0) {
393
+ const filterClause = buildFilterClause(
394
+ filters,
395
+ filterParams ?? {},
396
+ );
397
+ queryString = injectFilterRefinement(
398
+ queryString,
399
+ filterClause,
400
+ );
401
+ }
402
+ }
403
+ }
404
+
405
+ runnable = this.modelMaterializer.loadQuery(queryString);
341
406
  } catch (error) {
342
407
  // Re-throw BadRequestError as-is
343
408
  if (error instanceof BadRequestError) {
344
409
  throw error;
345
410
  }
411
+ // Source filter validation errors are client errors (400)
412
+ if (error instanceof FilterValidationError) {
413
+ throw new BadRequestError(error.message);
414
+ }
346
415
  // Re-throw MalloyError as-is (maps to 400)
347
416
  if (error instanceof MalloyError) {
348
417
  throw error;
@@ -491,7 +560,11 @@ export class Model {
491
560
  } as ApiRawNotebook;
492
561
  }
493
562
 
494
- public async executeNotebookCell(cellIndex: number): Promise<{
563
+ public async executeNotebookCell(
564
+ cellIndex: number,
565
+ filterParams?: FilterParams,
566
+ bypassFilters?: boolean,
567
+ ): Promise<{
495
568
  type: "code" | "markdown";
496
569
  text: string;
497
570
  queryName?: string;
@@ -527,18 +600,44 @@ export class Model {
527
600
 
528
601
  if (cell.runnable) {
529
602
  try {
603
+ let runnableToExecute = cell.runnable;
604
+
605
+ // If filters need to be applied, rebuild the query with a refinement
606
+ if (!bypassFilters && cell.modelMaterializer) {
607
+ const effectiveSource = this.extractSourceName(cell.text);
608
+ if (effectiveSource) {
609
+ const filters = this.getFilters(effectiveSource);
610
+ if (filters.length > 0) {
611
+ const filterClause = buildFilterClause(
612
+ filters,
613
+ filterParams ?? {},
614
+ );
615
+ if (filterClause) {
616
+ const refinedQuery = injectFilterRefinement(
617
+ cell.text,
618
+ filterClause,
619
+ );
620
+ runnableToExecute =
621
+ cell.modelMaterializer.loadQuery(refinedQuery);
622
+ }
623
+ }
624
+ }
625
+ }
626
+
530
627
  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;
628
+ (await runnableToExecute.getPreparedResult()).resultExplore
629
+ .limit || ROW_LIMIT;
630
+ const result = await runnableToExecute.run({ rowLimit });
631
+ const query = (await runnableToExecute.getPreparedQuery())._query;
535
632
  queryName = (query as NamedQueryDef).as || query.name;
536
633
  queryResult =
537
634
  result?._queryResult &&
538
635
  this.modelInfo &&
539
636
  JSON.stringify(API.util.wrapResult(result));
540
637
  } catch (error) {
541
- // Re-throw execution errors so the client knows about them
638
+ if (error instanceof FilterValidationError) {
639
+ throw new BadRequestError(error.message);
640
+ }
542
641
  if (error instanceof MalloyError) {
543
642
  throw error;
544
643
  }
@@ -570,6 +669,7 @@ export class Model {
570
669
  packagePath: string,
571
670
  modelPath: string,
572
671
  connections: Map<string, Connection>,
672
+ options?: { buildManifest?: BuildManifest["entries"] },
573
673
  ): Promise<{
574
674
  runtime: Runtime;
575
675
  modelURL: URL;
@@ -609,10 +709,23 @@ export class Model {
609
709
  `SET FILE_SEARCH_PATH='${workingDirectory}';`,
610
710
  );
611
711
 
612
- const runtime = new Runtime({
712
+ const runtimeOptions: {
713
+ urlReader: typeof urlReader;
714
+ connections: FixedConnectionMap;
715
+ buildManifest?: BuildManifest;
716
+ } = {
613
717
  urlReader,
614
718
  connections: new FixedConnectionMap(connections, "duckdb"),
615
- });
719
+ };
720
+
721
+ if (options?.buildManifest) {
722
+ runtimeOptions.buildManifest = {
723
+ entries: options.buildManifest,
724
+ strict: false,
725
+ };
726
+ }
727
+
728
+ const runtime = new Runtime(runtimeOptions);
616
729
  const dataStyles = urlReader.getHackyAccumulatedDataStyles();
617
730
  return { runtime, modelURL, importBaseURL, dataStyles, modelType };
618
731
  }
@@ -644,38 +757,98 @@ export class Model {
644
757
  private static getSources(
645
758
  modelPath: string,
646
759
  modelDef: ModelDef,
647
- ): ApiSource[] {
648
- return Object.values(modelDef.contents)
760
+ ): {
761
+ sources: ApiSource[];
762
+ filterMap: Map<string, FilterDefinition[]>;
763
+ } {
764
+ const filterMap = new Map<string, FilterDefinition[]>();
765
+
766
+ const sources = Object.values(modelDef.contents)
649
767
  .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
- );
768
+ .map((sourceObj) => {
769
+ const sourceName = sourceObj.as || sourceObj.name;
770
+ const annotations = (sourceObj as StructDef).annotation?.blockNotes
771
+ ?.filter((note) => note.at.url.includes(modelPath))
772
+ .map((note) => note.text);
773
+
774
+ // Parse #(filter) from ALL annotations, traversing the inherits
775
+ // chain so that filters on a base source (e.g. `recalls`) are
776
+ // picked up by an extending source (`manufacturer_recalls is
777
+ // recalls extend {}`). The Malloy compiler stores the base
778
+ // source's annotations in `annotation.inherits`.
779
+ //
780
+ // The chain goes child → parent, so we collect child-first.
781
+ // parseFilters uses "last wins" dedup, so we reverse to put
782
+ // parent annotations first and child annotations last (winning).
783
+ const collectedAnnotations: string[][] = [];
784
+ let curAnnotation: Annotation | undefined = (sourceObj as StructDef)
785
+ .annotation;
786
+ while (curAnnotation) {
787
+ if (curAnnotation.blockNotes) {
788
+ collectedAnnotations.push(
789
+ curAnnotation.blockNotes.map((note) => note.text),
790
+ );
791
+ }
792
+ curAnnotation = curAnnotation.inherits;
793
+ }
794
+ const allAnnotations = collectedAnnotations.reverse().flat();
795
+ let filters: ApiFilter[] | undefined;
796
+ if (allAnnotations.length > 0) {
797
+ try {
798
+ const parsed = parseFilters(allAnnotations);
799
+ if (parsed.length > 0) {
800
+ filterMap.set(sourceName, parsed);
801
+ const structFields = (sourceObj as StructDef).fields;
802
+ filters = parsed.map((f) => {
803
+ const field = structFields.find(
804
+ (fd) => (fd.as || fd.name) === f.dimension,
805
+ );
806
+ return {
807
+ name: f.name,
808
+ dimension: f.dimension,
809
+ type: f.type,
810
+ implicit: f.implicit,
811
+ required: f.required,
812
+ dimensionType: field?.type as string | undefined,
813
+ };
814
+ });
815
+ }
816
+ } catch (err) {
817
+ logger.warn(
818
+ `Failed to parse filter annotations on source "${sourceName}"`,
819
+ { error: err },
820
+ );
821
+ }
822
+ }
823
+
824
+ const views = (sourceObj as StructDef).fields
825
+ .filter((turtleObj) => turtleObj.type === "turtle")
826
+ .filter((turtleObj) =>
827
+ // TODO(kjnesbit): Fix non-reduce views. Filter out
828
+ // non-reduce views, i.e., indexes. Need to discuss with Will.
829
+ (turtleObj as TurtleDef).pipeline
830
+ .map((stage) => stage.type)
831
+ .every((type) => type == "reduce"),
832
+ )
833
+ .map(
834
+ (turtleObj) =>
835
+ ({
836
+ name: turtleObj.as || turtleObj.name,
837
+ annotations: turtleObj?.annotation?.blockNotes
838
+ ?.filter((note) => note.at.url.includes(modelPath))
839
+ .map((note) => note.text),
840
+ }) as ApiView,
841
+ );
842
+
843
+ return {
844
+ name: sourceName,
845
+ annotations,
846
+ views,
847
+ filters,
848
+ } as ApiSource;
849
+ });
850
+
851
+ return { sources, filterMap };
679
852
  }
680
853
 
681
854
  static async getModelMaterializer(
@@ -857,6 +1030,7 @@ export class Model {
857
1030
  type: "code",
858
1031
  text: stmt.text,
859
1032
  runnable: runnable,
1033
+ modelMaterializer: localMM,
860
1034
  newSources,
861
1035
  queryInfo,
862
1036
  } 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
+ }