@malloy-publisher/server 0.0.204 → 0.0.206
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 +10 -1
- package/dist/app/api-doc.yaml +494 -397
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
- package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +1703 -1443
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +33 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +66 -0
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/runtime/publisher.js +318 -0
- package/src/server-old.ts +7 -149
- package/src/server.ts +488 -190
- package/src/service/authorize_integration.spec.ts +163 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +142 -34
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +444 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +271 -20
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
- package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
- package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import type { Connection } from "@malloydata/malloy";
|
|
2
|
-
import { logger } from "../logger";
|
|
3
|
-
import { ManifestEntry } from "../storage/DatabaseInterface";
|
|
4
|
-
import { ManifestService } from "./manifest_service";
|
|
5
|
-
import { stagingSuffix } from "./materialization_service";
|
|
6
|
-
|
|
7
|
-
/** Build a stable key for a `(connectionName, tableName)` tuple. */
|
|
8
|
-
export function liveTableKey(
|
|
9
|
-
connectionName: string,
|
|
10
|
-
tableName: string,
|
|
11
|
-
): string {
|
|
12
|
-
return `${connectionName}::${tableName}`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** One manifest entry that was successfully GC'd. */
|
|
16
|
-
export interface GcDropped {
|
|
17
|
-
buildId: string;
|
|
18
|
-
tableName: string;
|
|
19
|
-
connectionName: string;
|
|
20
|
-
stagingTableName: string;
|
|
21
|
-
/**
|
|
22
|
-
* True when the physical target was left in place because another
|
|
23
|
-
* active manifest entry still claims this `(connection, tableName)`
|
|
24
|
-
* pair. The stale manifest row was still deleted — only the DROP was
|
|
25
|
-
* suppressed.
|
|
26
|
-
*/
|
|
27
|
-
targetDropSkipped?: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** One manifest entry that could not be GC'd. The row is left in place. */
|
|
31
|
-
export interface GcError {
|
|
32
|
-
buildId: string;
|
|
33
|
-
tableName: string;
|
|
34
|
-
connectionName: string;
|
|
35
|
-
error: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface GcResult {
|
|
39
|
-
dropped: GcDropped[];
|
|
40
|
-
errors: GcError[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface GcContext {
|
|
44
|
-
connections: Map<string, Connection>;
|
|
45
|
-
manifestService: ManifestService;
|
|
46
|
-
environmentId: string;
|
|
47
|
-
dryRun?: boolean;
|
|
48
|
-
/**
|
|
49
|
-
* Set of `liveTableKey(connectionName, tableName)` tuples that some
|
|
50
|
-
* *active* manifest entry still claims. When a stale entry's target
|
|
51
|
-
* is in this set the physical DROP is skipped (the fresh row needs
|
|
52
|
-
* the table); the stale manifest row is still deleted and the
|
|
53
|
-
* staging companion is still best-effort dropped.
|
|
54
|
-
*/
|
|
55
|
-
liveTables?: ReadonlySet<string>;
|
|
56
|
-
/**
|
|
57
|
-
* When true, delete the manifest row even if the row's
|
|
58
|
-
* `connectionName` is no longer registered with the package. Used by
|
|
59
|
-
* `teardownPackage` where retaining rows that point at a vanished
|
|
60
|
-
* connection just makes them un-GC-able.
|
|
61
|
-
*/
|
|
62
|
-
forceDeleteRowOnMissingConnection?: boolean;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Process a single manifest entry for GC. Returns either a `dropped` or
|
|
67
|
-
* `error` result (never both).
|
|
68
|
-
*
|
|
69
|
-
* Ordering invariant: the manifest row is deleted **before** any physical
|
|
70
|
-
* DROP so a failed DROP leaves an orphaned table (recoverable) rather
|
|
71
|
-
* than an orphaned manifest row (causes skipped builds + query failures).
|
|
72
|
-
*/
|
|
73
|
-
async function processOneEntry(
|
|
74
|
-
entry: ManifestEntry,
|
|
75
|
-
ctx: GcContext,
|
|
76
|
-
liveTables: ReadonlySet<string>,
|
|
77
|
-
): Promise<{ dropped?: GcDropped; error?: GcError }> {
|
|
78
|
-
const stagingTableName = `${entry.tableName}${stagingSuffix(entry.buildId)}`;
|
|
79
|
-
const targetIsLive = liveTables.has(
|
|
80
|
-
liveTableKey(entry.connectionName, entry.tableName),
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const connection = ctx.connections.get(entry.connectionName);
|
|
84
|
-
|
|
85
|
-
// ── Missing connection ────────────────────────────────────────
|
|
86
|
-
if (!connection) {
|
|
87
|
-
if (ctx.forceDeleteRowOnMissingConnection && !ctx.dryRun) {
|
|
88
|
-
try {
|
|
89
|
-
await ctx.manifestService.deleteEntry(ctx.environmentId, entry.id);
|
|
90
|
-
logger.warn(
|
|
91
|
-
"GC: deleted manifest row whose connection is gone; physical table (if any) is orphaned",
|
|
92
|
-
{
|
|
93
|
-
manifestEntryId: entry.id,
|
|
94
|
-
tableName: entry.tableName,
|
|
95
|
-
connectionName: entry.connectionName,
|
|
96
|
-
},
|
|
97
|
-
);
|
|
98
|
-
return {
|
|
99
|
-
dropped: {
|
|
100
|
-
buildId: entry.buildId,
|
|
101
|
-
tableName: entry.tableName,
|
|
102
|
-
connectionName: entry.connectionName,
|
|
103
|
-
stagingTableName,
|
|
104
|
-
targetDropSkipped: true,
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
} catch (err) {
|
|
108
|
-
return {
|
|
109
|
-
error: {
|
|
110
|
-
buildId: entry.buildId,
|
|
111
|
-
tableName: entry.tableName,
|
|
112
|
-
connectionName: entry.connectionName,
|
|
113
|
-
error: err instanceof Error ? err.message : String(err),
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return {
|
|
119
|
-
error: {
|
|
120
|
-
buildId: entry.buildId,
|
|
121
|
-
tableName: entry.tableName,
|
|
122
|
-
connectionName: entry.connectionName,
|
|
123
|
-
error: `Connection '${entry.connectionName}' is not available`,
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── Dry run ───────────────────────────────────────────────────
|
|
129
|
-
if (ctx.dryRun) {
|
|
130
|
-
return {
|
|
131
|
-
dropped: {
|
|
132
|
-
buildId: entry.buildId,
|
|
133
|
-
tableName: entry.tableName,
|
|
134
|
-
connectionName: entry.connectionName,
|
|
135
|
-
stagingTableName,
|
|
136
|
-
targetDropSkipped: targetIsLive || undefined,
|
|
137
|
-
},
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ── Live run: delete manifest row first ───────────────────────
|
|
142
|
-
try {
|
|
143
|
-
await ctx.manifestService.deleteEntry(ctx.environmentId, entry.id);
|
|
144
|
-
} catch (err) {
|
|
145
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
146
|
-
logger.warn("GC: failed to delete manifest row; skipping physical drop", {
|
|
147
|
-
manifestEntryId: entry.id,
|
|
148
|
-
tableName: entry.tableName,
|
|
149
|
-
error,
|
|
150
|
-
});
|
|
151
|
-
return {
|
|
152
|
-
error: {
|
|
153
|
-
buildId: entry.buildId,
|
|
154
|
-
tableName: entry.tableName,
|
|
155
|
-
connectionName: entry.connectionName,
|
|
156
|
-
error,
|
|
157
|
-
},
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── Drop target table (unless still live) ─────────────────────
|
|
162
|
-
if (targetIsLive) {
|
|
163
|
-
logger.info(
|
|
164
|
-
"GC: skipping target DROP; another active manifest entry claims this (connection, tableName)",
|
|
165
|
-
{
|
|
166
|
-
tableName: entry.tableName,
|
|
167
|
-
connectionName: entry.connectionName,
|
|
168
|
-
retiredBuildId: entry.buildId,
|
|
169
|
-
},
|
|
170
|
-
);
|
|
171
|
-
} else {
|
|
172
|
-
try {
|
|
173
|
-
await connection.runSQL(`DROP TABLE IF EXISTS ${entry.tableName}`);
|
|
174
|
-
} catch (err) {
|
|
175
|
-
logger.warn(
|
|
176
|
-
"GC: deleted manifest row but failed to drop materialized table (orphaned)",
|
|
177
|
-
{
|
|
178
|
-
tableName: entry.tableName,
|
|
179
|
-
connectionName: entry.connectionName,
|
|
180
|
-
error: err instanceof Error ? err.message : String(err),
|
|
181
|
-
},
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// ── Best-effort drop staging companion ────────────────────────
|
|
187
|
-
try {
|
|
188
|
-
await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
|
|
189
|
-
} catch (err) {
|
|
190
|
-
logger.warn("GC: failed to drop staging table (best-effort)", {
|
|
191
|
-
stagingTableName,
|
|
192
|
-
connectionName: entry.connectionName,
|
|
193
|
-
error: err instanceof Error ? err.message : String(err),
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return {
|
|
198
|
-
dropped: {
|
|
199
|
-
buildId: entry.buildId,
|
|
200
|
-
tableName: entry.tableName,
|
|
201
|
-
connectionName: entry.connectionName,
|
|
202
|
-
stagingTableName,
|
|
203
|
-
targetDropSkipped: targetIsLive || undefined,
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Idempotent manifest-row-delete + DROP TABLE for each entry.
|
|
210
|
-
*
|
|
211
|
-
* The manifest row is deleted **before** the physical tables so a failed
|
|
212
|
-
* DROP leaves an orphaned table (recoverable by the next build that
|
|
213
|
-
* targets the same tableName) rather than an orphaned manifest row
|
|
214
|
-
* (causes skipped builds + query failures).
|
|
215
|
-
*/
|
|
216
|
-
export async function dropManifestEntries(
|
|
217
|
-
entries: ManifestEntry[],
|
|
218
|
-
ctx: GcContext,
|
|
219
|
-
): Promise<GcResult> {
|
|
220
|
-
const dropped: GcDropped[] = [];
|
|
221
|
-
const errors: GcError[] = [];
|
|
222
|
-
const liveTables = ctx.liveTables ?? new Set<string>();
|
|
223
|
-
|
|
224
|
-
for (const entry of entries) {
|
|
225
|
-
const result = await processOneEntry(entry, ctx, liveTables);
|
|
226
|
-
if (result.dropped) dropped.push(result.dropped);
|
|
227
|
-
if (result.error) errors.push(result.error);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return { dropped, errors };
|
|
231
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
-
environmentId: string,
|
|
26
|
-
packageName: string,
|
|
27
|
-
): Promise<BuildManifest> {
|
|
28
|
-
const entries = await this.repository.listManifestEntries(
|
|
29
|
-
environmentId,
|
|
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
|
-
environmentId: 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
|
-
environmentId,
|
|
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
|
-
environmentId: string,
|
|
66
|
-
packageName: string,
|
|
67
|
-
): Promise<ManifestEntry[]> {
|
|
68
|
-
return this.repository.listManifestEntries(environmentId, packageName);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
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 (environment, 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
|
-
environmentId: string,
|
|
23
|
-
packageName: string,
|
|
24
|
-
): Promise<ManifestEntry[]> {
|
|
25
|
-
const rows = await this.db.all<Record<string, unknown>>(
|
|
26
|
-
"SELECT * FROM build_manifests WHERE environment_id = ? AND package_name = ? ORDER BY created_at DESC",
|
|
27
|
-
[environmentId, 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
|
-
environmentId: 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 environment_id = ? AND package_name = ? AND build_id = ?",
|
|
40
|
-
[environmentId, 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 (environment, 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, environment_id, package_name, build_id, table_name, source_name, connection_name, created_at, updated_at)
|
|
59
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
60
|
-
ON CONFLICT (environment_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.environmentId,
|
|
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 an environment (used on environment deletion). */
|
|
88
|
-
async deleteEntriesByEnvironmentId(environmentId: string): Promise<void> {
|
|
89
|
-
await this.db.run(
|
|
90
|
-
"DELETE FROM build_manifests WHERE environment_id = ?",
|
|
91
|
-
[environmentId],
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Removes all manifest entries for a specific package. */
|
|
96
|
-
async deleteEntriesByPackage(
|
|
97
|
-
environmentId: string,
|
|
98
|
-
packageName: string,
|
|
99
|
-
): Promise<void> {
|
|
100
|
-
await this.db.run(
|
|
101
|
-
"DELETE FROM build_manifests WHERE environment_id = ? AND package_name = ?",
|
|
102
|
-
[environmentId, packageName],
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Maps a raw DB row (snake_case columns) to a {@link ManifestEntry}. */
|
|
107
|
-
private mapToEntry(row: Record<string, unknown>): ManifestEntry {
|
|
108
|
-
return {
|
|
109
|
-
id: row.id as string,
|
|
110
|
-
environmentId: row.environment_id as string,
|
|
111
|
-
packageName: row.package_name as string,
|
|
112
|
-
buildId: row.build_id as string,
|
|
113
|
-
tableName: row.table_name as string,
|
|
114
|
-
sourceName: row.source_name as string,
|
|
115
|
-
connectionName: row.connection_name as string,
|
|
116
|
-
createdAt: new Date(row.created_at as string),
|
|
117
|
-
updatedAt: new Date(row.updated_at as string),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
}
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import * as sinon from "sinon";
|
|
3
|
-
import { ManifestEntry, ResourceRepository } from "../DatabaseInterface";
|
|
4
|
-
import { DuckDBManifestStore } from "./DuckDBManifestStore";
|
|
5
|
-
|
|
6
|
-
function makeEntry(overrides: Partial<ManifestEntry> = {}): ManifestEntry {
|
|
7
|
-
return {
|
|
8
|
-
id: "entry-1",
|
|
9
|
-
environmentId: "proj-1",
|
|
10
|
-
packageName: "pkg",
|
|
11
|
-
buildId: "build-abc",
|
|
12
|
-
tableName: "my_table",
|
|
13
|
-
sourceName: "my_source",
|
|
14
|
-
connectionName: "duckdb",
|
|
15
|
-
createdAt: new Date("2026-04-03"),
|
|
16
|
-
updatedAt: new Date("2026-04-03"),
|
|
17
|
-
...overrides,
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function createMocks() {
|
|
22
|
-
const sandbox = sinon.createSandbox();
|
|
23
|
-
|
|
24
|
-
const repository = {
|
|
25
|
-
listManifestEntries: sandbox.stub(),
|
|
26
|
-
upsertManifestEntry: sandbox.stub(),
|
|
27
|
-
deleteManifestEntry: sandbox.stub(),
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const store = new DuckDBManifestStore(
|
|
31
|
-
repository as unknown as ResourceRepository,
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
return { sandbox, repository, store };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
describe("DuckDBManifestStore", () => {
|
|
38
|
-
let ctx: ReturnType<typeof createMocks>;
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
ctx = createMocks();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("getManifest", () => {
|
|
45
|
-
it("should assemble a BuildManifest from repository entries", async () => {
|
|
46
|
-
ctx.repository.listManifestEntries.resolves([
|
|
47
|
-
makeEntry({ buildId: "b1", tableName: "tbl_a" }),
|
|
48
|
-
makeEntry({ buildId: "b2", tableName: "tbl_b" }),
|
|
49
|
-
]);
|
|
50
|
-
|
|
51
|
-
const manifest = await ctx.store.getManifest("proj-1", "pkg");
|
|
52
|
-
|
|
53
|
-
expect(manifest.strict).toBe(false);
|
|
54
|
-
expect(manifest.entries).toEqual({
|
|
55
|
-
b1: { tableName: "tbl_a" },
|
|
56
|
-
b2: { tableName: "tbl_b" },
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("should return empty entries when no rows exist", async () => {
|
|
61
|
-
ctx.repository.listManifestEntries.resolves([]);
|
|
62
|
-
|
|
63
|
-
const manifest = await ctx.store.getManifest("proj-1", "pkg");
|
|
64
|
-
|
|
65
|
-
expect(manifest.strict).toBe(false);
|
|
66
|
-
expect(manifest.entries).toEqual({});
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe("writeEntry", () => {
|
|
71
|
-
it("should upsert an entry with all fields", async () => {
|
|
72
|
-
ctx.repository.upsertManifestEntry.resolves(makeEntry());
|
|
73
|
-
|
|
74
|
-
await ctx.store.writeEntry(
|
|
75
|
-
"proj-1",
|
|
76
|
-
"pkg",
|
|
77
|
-
"build-abc",
|
|
78
|
-
"tbl",
|
|
79
|
-
"src",
|
|
80
|
-
"conn",
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
expect(ctx.repository.upsertManifestEntry.calledOnce).toBe(true);
|
|
84
|
-
const arg = ctx.repository.upsertManifestEntry.firstCall.args[0];
|
|
85
|
-
expect(arg).toEqual({
|
|
86
|
-
environmentId: "proj-1",
|
|
87
|
-
packageName: "pkg",
|
|
88
|
-
buildId: "build-abc",
|
|
89
|
-
tableName: "tbl",
|
|
90
|
-
sourceName: "src",
|
|
91
|
-
connectionName: "conn",
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe("deleteEntry", () => {
|
|
97
|
-
it("should delegate to repository", async () => {
|
|
98
|
-
ctx.repository.deleteManifestEntry.resolves();
|
|
99
|
-
|
|
100
|
-
await ctx.store.deleteEntry("entry-1");
|
|
101
|
-
|
|
102
|
-
expect(ctx.repository.deleteManifestEntry.calledOnce).toBe(true);
|
|
103
|
-
expect(ctx.repository.deleteManifestEntry.firstCall.args[0]).toBe(
|
|
104
|
-
"entry-1",
|
|
105
|
-
);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe("listEntries", () => {
|
|
110
|
-
it("should return entries from the repository", async () => {
|
|
111
|
-
const entries = [
|
|
112
|
-
makeEntry(),
|
|
113
|
-
makeEntry({ id: "entry-2", buildId: "build-def" }),
|
|
114
|
-
];
|
|
115
|
-
ctx.repository.listManifestEntries.resolves(entries);
|
|
116
|
-
|
|
117
|
-
const result = await ctx.store.listEntries("proj-1", "pkg");
|
|
118
|
-
|
|
119
|
-
expect(result).toEqual(entries);
|
|
120
|
-
expect(
|
|
121
|
-
ctx.repository.listManifestEntries.calledWith("proj-1", "pkg"),
|
|
122
|
-
).toBe(true);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("should return empty array when no entries exist", async () => {
|
|
126
|
-
ctx.repository.listManifestEntries.resolves([]);
|
|
127
|
-
|
|
128
|
-
const result = await ctx.store.listEntries("proj-1", "pkg");
|
|
129
|
-
|
|
130
|
-
expect(result).toEqual([]);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
});
|