@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
@@ -34,7 +34,7 @@ export interface ResourceRepository {
34
34
  getConnectionById(id: string): Promise<Connection | null>;
35
35
  getConnectionByName(
36
36
  projectId: string,
37
- id: string,
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 { DatabaseConnection, ResourceRepository } from "./DatabaseInterface";
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
 
@@ -1,5 +1,5 @@
1
1
  import { Mutex } from "async-mutex";
2
- import * as duckdb from "duckdb";
2
+ import duckdb from "duckdb";
3
3
  import * as path from "path";
4
4
  import { DatabaseConnection } from "../DatabaseInterface";
5
5
 
@@ -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
+ }
@@ -1,23 +1,32 @@
1
1
  import {
2
- ResourceRepository,
3
- Project,
4
- Package,
5
2
  Connection,
3
+ ManifestEntry,
4
+ Materialization,
5
+ MaterializationStatus,
6
+ Package,
7
+ Project,
8
+ ResourceRepository,
6
9
  } from "../DatabaseInterface";
10
+ import { ConnectionRepository } from "./ConnectionRepository";
7
11
  import { DuckDBConnection } from "./DuckDBConnection";
8
- import { ProjectRepository } from "./ProjectRepository";
12
+ import { ManifestRepository } from "./ManifestRepository";
13
+ import { MaterializationRepository } from "./MaterializationRepository";
9
14
  import { PackageRepository } from "./PackageRepository";
10
- import { ConnectionRepository } from "./ConnectionRepository";
15
+ import { ProjectRepository } from "./ProjectRepository";
11
16
 
12
17
  export class DuckDBRepository implements ResourceRepository {
13
18
  private projectRepo: ProjectRepository;
14
19
  private packageRepo: PackageRepository;
15
20
  private connectionRepo: ConnectionRepository;
21
+ private materializationRepo: MaterializationRepository;
22
+ private manifestRepo: ManifestRepository;
16
23
 
17
24
  constructor(public db: DuckDBConnection) {
18
25
  this.projectRepo = new ProjectRepository(db);
19
26
  this.packageRepo = new PackageRepository(db);
20
27
  this.connectionRepo = new ConnectionRepository(db);
28
+ this.materializationRepo = new MaterializationRepository(db);
29
+ this.manifestRepo = new ManifestRepository(db);
21
30
  }
22
31
 
23
32
  // ==================== PROJECTS ====================
@@ -48,11 +57,10 @@ export class DuckDBRepository implements ResourceRepository {
48
57
  }
49
58
 
50
59
  async deleteProject(id: string): Promise<void> {
51
- // Delete related connections and packages first
60
+ await this.manifestRepo.deleteEntriesByProjectId(id);
61
+ await this.materializationRepo.deleteByProjectId(id);
52
62
  await this.connectionRepo.deleteConnectionsByProjectId(id);
53
63
  await this.packageRepo.deletePackagesByProjectId(id);
54
-
55
- // Then delete the project
56
64
  await this.projectRepo.deleteProject(id);
57
65
  }
58
66
 
@@ -87,7 +95,18 @@ export class DuckDBRepository implements ResourceRepository {
87
95
  }
88
96
 
89
97
  async deletePackage(id: string): Promise<void> {
90
- return this.packageRepo.deletePackage(id);
98
+ const pkg = await this.packageRepo.getPackageById(id);
99
+ if (pkg) {
100
+ await this.manifestRepo.deleteEntriesByPackage(
101
+ pkg.projectId,
102
+ pkg.name,
103
+ );
104
+ await this.materializationRepo.deleteByPackage(
105
+ pkg.projectId,
106
+ pkg.name,
107
+ );
108
+ }
109
+ await this.packageRepo.deletePackage(id);
91
110
  }
92
111
 
93
112
  async deletePackagesByProjectId(id: string): Promise<void> {
@@ -131,4 +150,75 @@ export class DuckDBRepository implements ResourceRepository {
131
150
  async deleteConnectionsByProjectId(id: string): Promise<void> {
132
151
  return this.connectionRepo.deleteConnectionsByProjectId(id);
133
152
  }
153
+
154
+ // ==================== MATERIALIZATIONS ====================
155
+
156
+ async listMaterializations(
157
+ projectId: string,
158
+ packageName: string,
159
+ options?: { limit?: number; offset?: number },
160
+ ): Promise<Materialization[]> {
161
+ return this.materializationRepo.list(projectId, packageName, options);
162
+ }
163
+
164
+ async getMaterializationById(id: string): Promise<Materialization | null> {
165
+ return this.materializationRepo.getById(id);
166
+ }
167
+
168
+ async getActiveMaterialization(
169
+ projectId: string,
170
+ packageName: string,
171
+ ): Promise<Materialization | null> {
172
+ return this.materializationRepo.getActive(projectId, packageName);
173
+ }
174
+
175
+ async createMaterialization(
176
+ projectId: string,
177
+ packageName: string,
178
+ status: MaterializationStatus = "PENDING",
179
+ metadata: Record<string, unknown> | null = null,
180
+ ): Promise<Materialization> {
181
+ return this.materializationRepo.create(
182
+ projectId,
183
+ packageName,
184
+ status,
185
+ metadata,
186
+ );
187
+ }
188
+
189
+ async updateMaterialization(
190
+ id: string,
191
+ updates: {
192
+ status?: MaterializationStatus;
193
+ startedAt?: Date;
194
+ completedAt?: Date;
195
+ error?: string | null;
196
+ metadata?: Record<string, unknown> | null;
197
+ },
198
+ ): Promise<Materialization> {
199
+ return this.materializationRepo.update(id, updates);
200
+ }
201
+
202
+ async deleteMaterialization(id: string): Promise<void> {
203
+ return this.materializationRepo.deleteById(id);
204
+ }
205
+
206
+ // ==================== BUILD MANIFESTS ====================
207
+
208
+ async listManifestEntries(
209
+ projectId: string,
210
+ packageName: string,
211
+ ): Promise<ManifestEntry[]> {
212
+ return this.manifestRepo.listEntries(projectId, packageName);
213
+ }
214
+
215
+ async upsertManifestEntry(
216
+ entry: Omit<ManifestEntry, "id" | "createdAt" | "updatedAt">,
217
+ ): Promise<ManifestEntry> {
218
+ return this.manifestRepo.upsertEntry(entry);
219
+ }
220
+
221
+ async deleteManifestEntry(id: string): Promise<void> {
222
+ return this.manifestRepo.deleteEntry(id);
223
+ }
134
224
  }
@@ -0,0 +1,119 @@
1
+ import { ManifestEntry } from "../DatabaseInterface";
2
+ import { DuckDBConnection } from "./DuckDBConnection";
3
+
4
+ /**
5
+ * Repository for build manifest entries stored in the `build_manifests` table.
6
+ *
7
+ * A build manifest records the materialized table produced by a specific build
8
+ * of a package, linking it back to the Malloy source and connection that
9
+ * generated it. Manifests are keyed by (project, package, build) and enable
10
+ * downstream consumers to discover which physical tables correspond to a given
11
+ * package version.
12
+ */
13
+ export class ManifestRepository {
14
+ constructor(private db: DuckDBConnection) {}
15
+
16
+ private generateId(): string {
17
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
18
+ }
19
+
20
+ /** Returns all manifest entries for a package, most recent first. */
21
+ async listEntries(
22
+ projectId: string,
23
+ packageName: string,
24
+ ): Promise<ManifestEntry[]> {
25
+ const rows = await this.db.all<Record<string, unknown>>(
26
+ "SELECT * FROM build_manifests WHERE project_id = ? AND package_name = ? ORDER BY created_at DESC",
27
+ [projectId, packageName],
28
+ );
29
+ return rows.map(this.mapToEntry);
30
+ }
31
+
32
+ /** Looks up the manifest entry for a specific build, or null if none exists. */
33
+ async getEntryByBuildId(
34
+ projectId: string,
35
+ packageName: string,
36
+ buildId: string,
37
+ ): Promise<ManifestEntry | null> {
38
+ const row = await this.db.get<Record<string, unknown>>(
39
+ "SELECT * FROM build_manifests WHERE project_id = ? AND package_name = ? AND build_id = ?",
40
+ [projectId, packageName, buildId],
41
+ );
42
+ return row ? this.mapToEntry(row) : null;
43
+ }
44
+
45
+ /**
46
+ * Inserts a new manifest entry, or updates the existing one if a row with
47
+ * the same (project, package, build) triple already exists. Uses INSERT ON
48
+ * CONFLICT to avoid the TOCTOU race of SELECT-then-INSERT/UPDATE.
49
+ */
50
+ async upsertEntry(
51
+ entry: Omit<ManifestEntry, "id" | "createdAt" | "updatedAt">,
52
+ ): Promise<ManifestEntry> {
53
+ const id = this.generateId();
54
+ const now = new Date();
55
+ const iso = now.toISOString();
56
+
57
+ const rows = await this.db.all<Record<string, unknown>>(
58
+ `INSERT INTO build_manifests (id, project_id, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
59
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
60
+ ON CONFLICT (project_id, package_name, build_id)
61
+ DO UPDATE SET table_name = EXCLUDED.table_name,
62
+ source_name = EXCLUDED.source_name,
63
+ connection_name = EXCLUDED.connection_name,
64
+ updated_at = EXCLUDED.updated_at
65
+ RETURNING *`,
66
+ [
67
+ id,
68
+ entry.projectId,
69
+ entry.packageName,
70
+ entry.buildId,
71
+ entry.tableName,
72
+ entry.sourceName,
73
+ entry.connectionName,
74
+ iso,
75
+ iso,
76
+ ],
77
+ );
78
+
79
+ return this.mapToEntry(rows[0]);
80
+ }
81
+
82
+ /** Deletes a single manifest entry by ID. */
83
+ async deleteEntry(id: string): Promise<void> {
84
+ await this.db.run("DELETE FROM build_manifests WHERE id = ?", [id]);
85
+ }
86
+
87
+ /** Removes all manifest entries belonging to a project (used on project deletion). */
88
+ async deleteEntriesByProjectId(projectId: string): Promise<void> {
89
+ await this.db.run("DELETE FROM build_manifests WHERE project_id = ?", [
90
+ projectId,
91
+ ]);
92
+ }
93
+
94
+ /** Removes all manifest entries for a specific package. */
95
+ async deleteEntriesByPackage(
96
+ projectId: string,
97
+ packageName: string,
98
+ ): Promise<void> {
99
+ await this.db.run(
100
+ "DELETE FROM build_manifests WHERE project_id = ? AND package_name = ?",
101
+ [projectId, packageName],
102
+ );
103
+ }
104
+
105
+ /** Maps a raw DB row (snake_case columns) to a {@link ManifestEntry}. */
106
+ private mapToEntry(row: Record<string, unknown>): ManifestEntry {
107
+ return {
108
+ id: row.id as string,
109
+ projectId: row.project_id as string,
110
+ packageName: row.package_name as string,
111
+ buildId: row.build_id as string,
112
+ tableName: row.table_name as string,
113
+ sourceName: row.source_name as string,
114
+ connectionName: row.connection_name as string,
115
+ createdAt: new Date(row.created_at as string),
116
+ updatedAt: new Date(row.updated_at as string),
117
+ };
118
+ }
119
+ }