@malloy-publisher/server 0.0.188 → 0.0.382-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 (52) hide show
  1. package/dist/app/api-doc.yaml +423 -60
  2. package/dist/app/assets/{HomePage-DsuUvSI_.js → HomePage-Dn3E4CuB.js} +1 -1
  3. package/dist/app/assets/{MainPage-DHWFkEN6.js → MainPage-BzB3yoqi.js} +1 -1
  4. package/dist/app/assets/{ModelPage-DNwcx1nE.js → ModelPage-C9O_sAXT.js} +1 -1
  5. package/dist/app/assets/{PackagePage-DSgz9G2V.js → PackagePage-DcxKEjBX.js} +1 -1
  6. package/dist/app/assets/{ProjectPage-CSdPosLV.js → ProjectPage-BDj307rF.js} +1 -1
  7. package/dist/app/assets/{RouteError-orw1RX8q.js → RouteError-DAShbVCG.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-Bp-BpGjL.js → WorkbookPage-Cs_XYEaB.js} +1 -1
  9. package/dist/app/assets/{core-B4ZYB7aS.es-8Zh0TkSr.js → core-CjeTkq8O.es-BqRc6yhC.js} +1 -1
  10. package/dist/app/assets/{index-BL2TJgTw.js → index-15BOvhp0.js} +4 -4
  11. package/dist/app/assets/{index-BWJkzsfl.js → index-Bb2jqquW.js} +1 -1
  12. package/dist/app/assets/{index-BefdHHMa.js → index-D68X76-7.js} +1 -1
  13. package/dist/app/assets/{index.umd-lY-87l4L.js → index.umd-DGBekgSu.js} +1 -1
  14. package/dist/app/index.html +1 -1
  15. package/dist/instrumentation.js +98 -77
  16. package/dist/server.js +1834 -450
  17. package/package.json +5 -3
  18. package/src/controller/connection.controller.ts +27 -20
  19. package/src/controller/manifest.controller.ts +29 -0
  20. package/src/controller/materialization.controller.ts +125 -0
  21. package/src/controller/model.controller.ts +0 -2
  22. package/src/controller/package.controller.ts +53 -2
  23. package/src/errors.ts +24 -0
  24. package/src/server.ts +196 -5
  25. package/src/service/manifest_service.spec.ts +201 -0
  26. package/src/service/manifest_service.ts +106 -0
  27. package/src/service/materialization_service.spec.ts +648 -0
  28. package/src/service/materialization_service.ts +929 -0
  29. package/src/service/materialized_table_gc.spec.ts +383 -0
  30. package/src/service/materialized_table_gc.ts +279 -0
  31. package/src/service/model.ts +25 -4
  32. package/src/service/package.ts +50 -0
  33. package/src/service/project_store.ts +21 -2
  34. package/src/service/quoting.ts +41 -0
  35. package/src/service/resolve_project.ts +13 -0
  36. package/src/storage/DatabaseInterface.ts +103 -1
  37. package/src/storage/{StorageManager.spec.ts → StorageManager.mock.ts} +9 -0
  38. package/src/storage/StorageManager.ts +119 -1
  39. package/src/storage/duckdb/DuckDBManifestStore.ts +70 -0
  40. package/src/storage/duckdb/DuckDBRepository.ts +99 -9
  41. package/src/storage/duckdb/ManifestRepository.ts +119 -0
  42. package/src/storage/duckdb/MaterializationRepository.ts +249 -0
  43. package/src/storage/duckdb/manifest_store.spec.ts +133 -0
  44. package/src/storage/duckdb/schema.ts +59 -1
  45. package/src/storage/ducklake/DuckLakeManifestStore.ts +146 -0
  46. package/tests/fixtures/persist-test/data/orders.csv +5 -0
  47. package/tests/fixtures/persist-test/persist_test.malloy +11 -0
  48. package/tests/fixtures/persist-test/publisher.json +5 -0
  49. package/tests/fixtures/publisher.config.json +15 -0
  50. package/tests/harness/rest_e2e.ts +68 -0
  51. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +470 -0
  52. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +2 -2
@@ -0,0 +1,383 @@
1
+ import type { Connection } from "@malloydata/malloy";
2
+ import { beforeEach, describe, expect, it } from "bun:test";
3
+ import * as sinon from "sinon";
4
+ import { ManifestEntry } from "../storage/DatabaseInterface";
5
+ import { ManifestService } from "./manifest_service";
6
+ import { stagingSuffix } from "./materialization_service";
7
+ import { dropManifestEntries, liveTableKey } from "./materialized_table_gc";
8
+
9
+ function makeEntry(overrides: Partial<ManifestEntry> = {}): ManifestEntry {
10
+ return {
11
+ id: "entry-1",
12
+ projectId: "proj-1",
13
+ packageName: "pkg",
14
+ buildId: "abcdef1234567890abcdef1234567890",
15
+ tableName: "my_table",
16
+ sourceName: "my_source",
17
+ connectionName: "conn",
18
+ createdAt: new Date(),
19
+ updatedAt: new Date(),
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ interface MockConnection {
25
+ dialectName: string;
26
+ runSQL: sinon.SinonStub;
27
+ }
28
+
29
+ function makeConnection(dialectName = "duckdb"): MockConnection {
30
+ return {
31
+ dialectName,
32
+ runSQL: sinon.stub().resolves(),
33
+ };
34
+ }
35
+
36
+ interface TestCtx {
37
+ connections: Map<string, Connection>;
38
+ manifestService: sinon.SinonStubbedInstance<ManifestService>;
39
+ conn: MockConnection;
40
+ }
41
+
42
+ function makeCtx(dialectName = "duckdb"): TestCtx {
43
+ const conn = makeConnection(dialectName);
44
+ const connections = new Map<string, Connection>();
45
+ connections.set("conn", conn as unknown as Connection);
46
+ const manifestService = {
47
+ deleteEntry: sinon.stub().resolves(),
48
+ listEntries: sinon.stub().resolves([]),
49
+ getManifest: sinon.stub().resolves({ entries: {}, strict: false }),
50
+ writeEntry: sinon.stub().resolves(),
51
+ reloadManifest: sinon.stub().resolves({ entries: {}, strict: false }),
52
+ } as unknown as sinon.SinonStubbedInstance<ManifestService>;
53
+ return { connections, manifestService, conn };
54
+ }
55
+
56
+ describe("dropManifestEntries", () => {
57
+ let ctx: TestCtx;
58
+
59
+ beforeEach(() => {
60
+ ctx = makeCtx();
61
+ });
62
+
63
+ it("deletes the manifest row first, then drops the target and staging companion", async () => {
64
+ const entry = makeEntry();
65
+ const deleteEntry = ctx.manifestService.deleteEntry as sinon.SinonStub;
66
+
67
+ const result = await dropManifestEntries([entry], {
68
+ connections: ctx.connections,
69
+ manifestService: ctx.manifestService,
70
+ projectId: "proj-1",
71
+ });
72
+
73
+ expect(result.dropped).toHaveLength(1);
74
+ expect(result.errors).toHaveLength(0);
75
+ expect(result.dropped[0].tableName).toBe("my_table");
76
+ expect(result.dropped[0].stagingTableName).toBe(
77
+ `my_table${stagingSuffix(entry.buildId)}`,
78
+ );
79
+
80
+ expect(deleteEntry.calledOnceWith("proj-1", entry.id)).toBe(true);
81
+
82
+ expect(ctx.conn.runSQL.callCount).toBe(2);
83
+ const targetSql = ctx.conn.runSQL.getCall(0).args[0];
84
+ const stagingSql = ctx.conn.runSQL.getCall(1).args[0];
85
+ expect(targetSql).toMatch(/^DROP TABLE IF EXISTS /);
86
+ expect(targetSql).toContain("my_table");
87
+ expect(stagingSql).toMatch(/^DROP TABLE IF EXISTS /);
88
+ expect(stagingSql).toContain(`my_table${stagingSuffix(entry.buildId)}`);
89
+
90
+ // Ordering: manifest row must be deleted before any physical DROP, so
91
+ // we never leave a manifest row pointing at a missing table.
92
+ expect(
93
+ deleteEntry.getCall(0).calledBefore(ctx.conn.runSQL.getCall(0)),
94
+ ).toBe(true);
95
+ });
96
+
97
+ it("records an error and skips physical DROPs when manifest delete fails", async () => {
98
+ const entry = makeEntry();
99
+ const deleteEntry = ctx.manifestService.deleteEntry as sinon.SinonStub;
100
+ deleteEntry.rejects(new Error("manifest store down"));
101
+
102
+ const result = await dropManifestEntries([entry], {
103
+ connections: ctx.connections,
104
+ manifestService: ctx.manifestService,
105
+ projectId: "proj-1",
106
+ });
107
+
108
+ expect(result.dropped).toHaveLength(0);
109
+ expect(result.errors).toHaveLength(1);
110
+ expect(result.errors[0].error).toContain("manifest store down");
111
+ // No physical DROP must fire — we still need the table so the source
112
+ // remains queryable while GC retries on a later pass.
113
+ expect(ctx.conn.runSQL.called).toBe(false);
114
+ });
115
+
116
+ it("still counts as dropped when the target DROP fails after manifest delete (orphan warned, not retried)", async () => {
117
+ const entry = makeEntry();
118
+ ctx.conn.runSQL
119
+ .onFirstCall()
120
+ .rejects(new Error("permission denied on relation my_table"));
121
+
122
+ const result = await dropManifestEntries([entry], {
123
+ connections: ctx.connections,
124
+ manifestService: ctx.manifestService,
125
+ projectId: "proj-1",
126
+ });
127
+
128
+ // Manifest row is gone → entry is authoritatively dropped. The
129
+ // physical target becomes an orphan (logged as a warning); we do NOT
130
+ // push to errors because there is nothing the caller can retry —
131
+ // the manifest row no longer exists to drive another GC pass.
132
+ expect(result.dropped).toHaveLength(1);
133
+ expect(result.errors).toHaveLength(0);
134
+ expect(
135
+ (ctx.manifestService.deleteEntry as sinon.SinonStub).calledOnce,
136
+ ).toBe(true);
137
+ // Staging DROP still attempted for completeness.
138
+ expect(ctx.conn.runSQL.callCount).toBe(2);
139
+ });
140
+
141
+ it("still counts as dropped when only the staging DROP fails (best-effort)", async () => {
142
+ const entry = makeEntry();
143
+ ctx.conn.runSQL.onFirstCall().resolves();
144
+ ctx.conn.runSQL.onSecondCall().rejects(new Error("staging locked"));
145
+
146
+ const result = await dropManifestEntries([entry], {
147
+ connections: ctx.connections,
148
+ manifestService: ctx.manifestService,
149
+ projectId: "proj-1",
150
+ });
151
+
152
+ expect(result.dropped).toHaveLength(1);
153
+ expect(result.errors).toHaveLength(0);
154
+ expect(
155
+ (ctx.manifestService.deleteEntry as sinon.SinonStub).calledOnce,
156
+ ).toBe(true);
157
+ });
158
+
159
+ it("skips DROP and records an error when the connection is missing", async () => {
160
+ const entry = makeEntry({ connectionName: "ghost" });
161
+
162
+ const result = await dropManifestEntries([entry], {
163
+ connections: ctx.connections,
164
+ manifestService: ctx.manifestService,
165
+ projectId: "proj-1",
166
+ });
167
+
168
+ expect(result.dropped).toHaveLength(0);
169
+ expect(result.errors).toHaveLength(1);
170
+ expect(result.errors[0].error).toMatch(/Connection 'ghost'/);
171
+ expect(ctx.conn.runSQL.called).toBe(false);
172
+ expect((ctx.manifestService.deleteEntry as sinon.SinonStub).called).toBe(
173
+ false,
174
+ );
175
+ });
176
+
177
+ it("skips DROP and records an error when the dialect is unknown", async () => {
178
+ const ctx2 = makeCtx("martian-sql");
179
+ const entry = makeEntry();
180
+
181
+ const result = await dropManifestEntries([entry], {
182
+ connections: ctx2.connections,
183
+ manifestService: ctx2.manifestService,
184
+ projectId: "proj-1",
185
+ });
186
+
187
+ expect(result.dropped).toHaveLength(0);
188
+ expect(result.errors).toHaveLength(1);
189
+ expect(result.errors[0].error).toMatch(/martian-sql/);
190
+ expect(ctx2.conn.runSQL.called).toBe(false);
191
+ });
192
+
193
+ it("dryRun lists what would drop without issuing SQL or deleting rows", async () => {
194
+ const entries = [
195
+ makeEntry(),
196
+ makeEntry({ id: "entry-2", tableName: "schema.other" }),
197
+ ];
198
+
199
+ const result = await dropManifestEntries(entries, {
200
+ connections: ctx.connections,
201
+ manifestService: ctx.manifestService,
202
+ projectId: "proj-1",
203
+ dryRun: true,
204
+ });
205
+
206
+ expect(result.dropped).toHaveLength(2);
207
+ expect(result.errors).toHaveLength(0);
208
+ expect(ctx.conn.runSQL.called).toBe(false);
209
+ expect((ctx.manifestService.deleteEntry as sinon.SinonStub).called).toBe(
210
+ false,
211
+ );
212
+ });
213
+
214
+ it("isolates per-entry failures — other entries still drop", async () => {
215
+ const good = makeEntry({ id: "good", tableName: "good_table" });
216
+ const bad = makeEntry({
217
+ id: "bad",
218
+ tableName: "bad_table",
219
+ connectionName: "ghost",
220
+ });
221
+
222
+ const result = await dropManifestEntries([bad, good], {
223
+ connections: ctx.connections,
224
+ manifestService: ctx.manifestService,
225
+ projectId: "proj-1",
226
+ });
227
+
228
+ expect(result.dropped).toHaveLength(1);
229
+ expect(result.dropped[0].tableName).toBe("good_table");
230
+ expect(result.errors).toHaveLength(1);
231
+ expect(result.errors[0].tableName).toBe("bad_table");
232
+ });
233
+
234
+ // When a source is rebuilt with new SQL it gets a new BuildID but keeps
235
+ // the same tableName. Without `liveTables`, GC would drop the stale
236
+ // (old BuildID) manifest row and then issue `DROP TABLE IF EXISTS
237
+ // <tableName>` — obliterating the fresh physical target produced by
238
+ // the rebuild. `liveTables` short-circuits the physical DROP when
239
+ // another active entry still claims the same (connection, tableName).
240
+ describe("liveTables (rebuild safety)", () => {
241
+ it("skips the target DROP when another active entry claims the same (connection, tableName)", async () => {
242
+ const staleEntry = makeEntry({
243
+ id: "stale",
244
+ buildId: "old_buildid_000000000000000000000",
245
+ tableName: "my_table",
246
+ connectionName: "conn",
247
+ });
248
+ const live = new Set<string>([liveTableKey("conn", "my_table")]);
249
+
250
+ const result = await dropManifestEntries([staleEntry], {
251
+ connections: ctx.connections,
252
+ manifestService: ctx.manifestService,
253
+ projectId: "proj-1",
254
+ liveTables: live,
255
+ });
256
+
257
+ expect(result.dropped).toHaveLength(1);
258
+ expect(result.dropped[0].targetDropSkipped).toBe(true);
259
+ expect(result.errors).toHaveLength(0);
260
+
261
+ expect(
262
+ (ctx.manifestService.deleteEntry as sinon.SinonStub).calledOnce,
263
+ ).toBe(true);
264
+
265
+ // The target DROP must NOT fire; only the staging best-effort drop
266
+ // should go through.
267
+ expect(ctx.conn.runSQL.callCount).toBe(1);
268
+ const only = ctx.conn.runSQL.getCall(0).args[0];
269
+ expect(only).toMatch(/^DROP TABLE IF EXISTS /);
270
+ expect(only).toContain(`my_table${stagingSuffix(staleEntry.buildId)}`);
271
+ expect(only).not.toMatch(/my_table\b(?!_)/);
272
+ });
273
+
274
+ it("still drops the target when liveTables claims a different connection", async () => {
275
+ const staleEntry = makeEntry({ tableName: "my_table" });
276
+ // Different connection with the same tableName is not the same
277
+ // physical table; the DROP must proceed.
278
+ const live = new Set<string>([liveTableKey("other_conn", "my_table")]);
279
+
280
+ const result = await dropManifestEntries([staleEntry], {
281
+ connections: ctx.connections,
282
+ manifestService: ctx.manifestService,
283
+ projectId: "proj-1",
284
+ liveTables: live,
285
+ });
286
+
287
+ expect(result.dropped).toHaveLength(1);
288
+ expect(result.dropped[0].targetDropSkipped).toBeUndefined();
289
+ expect(ctx.conn.runSQL.callCount).toBe(2);
290
+ });
291
+
292
+ it("dryRun reports would-skip via targetDropSkipped", async () => {
293
+ const staleEntry = makeEntry();
294
+ const live = new Set<string>([liveTableKey("conn", "my_table")]);
295
+
296
+ const result = await dropManifestEntries([staleEntry], {
297
+ connections: ctx.connections,
298
+ manifestService: ctx.manifestService,
299
+ projectId: "proj-1",
300
+ dryRun: true,
301
+ liveTables: live,
302
+ });
303
+
304
+ expect(result.dropped).toHaveLength(1);
305
+ expect(result.dropped[0].targetDropSkipped).toBe(true);
306
+ expect(ctx.conn.runSQL.called).toBe(false);
307
+ expect(
308
+ (ctx.manifestService.deleteEntry as sinon.SinonStub).called,
309
+ ).toBe(false);
310
+ });
311
+ });
312
+
313
+ describe("forceDeleteRowOnMissingConnection (teardown)", () => {
314
+ it("deletes the manifest row without any DROP when the connection is gone", async () => {
315
+ const entry = makeEntry({ connectionName: "ghost" });
316
+
317
+ const result = await dropManifestEntries([entry], {
318
+ connections: ctx.connections,
319
+ manifestService: ctx.manifestService,
320
+ projectId: "proj-1",
321
+ forceDeleteRowOnMissingConnection: true,
322
+ });
323
+
324
+ expect(result.dropped).toHaveLength(1);
325
+ expect(result.dropped[0].targetDropSkipped).toBe(true);
326
+ expect(result.errors).toHaveLength(0);
327
+ expect(
328
+ (ctx.manifestService.deleteEntry as sinon.SinonStub).calledOnce,
329
+ ).toBe(true);
330
+ expect(ctx.conn.runSQL.called).toBe(false);
331
+ });
332
+
333
+ it("records an error if the manifest-row delete itself fails", async () => {
334
+ const entry = makeEntry({ connectionName: "ghost" });
335
+ (ctx.manifestService.deleteEntry as sinon.SinonStub).rejects(
336
+ new Error("manifest store down"),
337
+ );
338
+
339
+ const result = await dropManifestEntries([entry], {
340
+ connections: ctx.connections,
341
+ manifestService: ctx.manifestService,
342
+ projectId: "proj-1",
343
+ forceDeleteRowOnMissingConnection: true,
344
+ });
345
+
346
+ expect(result.dropped).toHaveLength(0);
347
+ expect(result.errors).toHaveLength(1);
348
+ expect(result.errors[0].error).toContain("manifest store down");
349
+ });
350
+
351
+ it("falls back to the error path in dryRun (keeps dryRun side-effect-free)", async () => {
352
+ const entry = makeEntry({ connectionName: "ghost" });
353
+
354
+ const result = await dropManifestEntries([entry], {
355
+ connections: ctx.connections,
356
+ manifestService: ctx.manifestService,
357
+ projectId: "proj-1",
358
+ forceDeleteRowOnMissingConnection: true,
359
+ dryRun: true,
360
+ });
361
+
362
+ // In dryRun the row is NOT deleted; surface the same error signal
363
+ // so operators see the connection gap they'd hit on a live run.
364
+ expect(result.dropped).toHaveLength(0);
365
+ expect(result.errors).toHaveLength(1);
366
+ expect(result.errors[0].error).toMatch(/ghost/);
367
+ expect(
368
+ (ctx.manifestService.deleteEntry as sinon.SinonStub).called,
369
+ ).toBe(false);
370
+ });
371
+ });
372
+ });
373
+
374
+ describe("stagingSuffix", () => {
375
+ it("uses a fixed-length prefix of the BuildID", () => {
376
+ const suffix = stagingSuffix(
377
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
378
+ );
379
+ // 12 hex chars + the "_" prefix = 13 chars total.
380
+ expect(suffix).toBe("_0123456789ab");
381
+ expect(suffix.length).toBe(13);
382
+ });
383
+ });
@@ -0,0 +1,279 @@
1
+ import type { Connection } from "@malloydata/malloy";
2
+ import {
3
+ DatabricksDialect,
4
+ DuckDBDialect,
5
+ MySQLDialect,
6
+ PostgresDialect,
7
+ SnowflakeDialect,
8
+ StandardSQLDialect,
9
+ TrinoDialect,
10
+ } from "@malloydata/malloy";
11
+ import { logger } from "../logger";
12
+ import { ManifestEntry } from "../storage/DatabaseInterface";
13
+ import { ManifestService } from "./manifest_service";
14
+ import { stagingSuffix } from "./materialization_service";
15
+ import { type Quoter, quoteTablePath } from "./quoting";
16
+
17
+ /**
18
+ * Registry of built-in dialects keyed by `Connection.dialectName`. Malloy's
19
+ * internal `getDialect` helper isn't part of the package's public exports,
20
+ * so we assemble our own registry from the exported dialect classes.
21
+ *
22
+ * Note: `presto` (extends `TrinoDialect`) is not re-exported publicly and
23
+ * is niche enough to omit; if/when it ships as a publisher connection type,
24
+ * add it here.
25
+ */
26
+ const DIALECTS: Readonly<Record<string, Quoter>> = Object.freeze({
27
+ duckdb: new DuckDBDialect(),
28
+ standardsql: new StandardSQLDialect(),
29
+ trino: new TrinoDialect(),
30
+ postgres: new PostgresDialect(),
31
+ snowflake: new SnowflakeDialect(),
32
+ mysql: new MySQLDialect(),
33
+ databricks: new DatabricksDialect(),
34
+ });
35
+
36
+ /** Build a stable key for a `(connectionName, tableName)` tuple. */
37
+ export function liveTableKey(
38
+ connectionName: string,
39
+ tableName: string,
40
+ ): string {
41
+ return `${connectionName}::${tableName}`;
42
+ }
43
+
44
+ /** One manifest entry that was successfully GC'd. */
45
+ export interface GcDropped {
46
+ buildId: string;
47
+ tableName: string;
48
+ connectionName: string;
49
+ stagingTableName: string;
50
+ /**
51
+ * True when the physical target was left in place because another
52
+ * active manifest entry still claims this `(connection, tableName)`
53
+ * pair. The stale manifest row was still deleted — only the DROP was
54
+ * suppressed.
55
+ */
56
+ targetDropSkipped?: boolean;
57
+ }
58
+
59
+ /** One manifest entry that could not be GC'd. The row is left in place. */
60
+ export interface GcError {
61
+ buildId: string;
62
+ tableName: string;
63
+ connectionName: string;
64
+ error: string;
65
+ }
66
+
67
+ export interface GcResult {
68
+ dropped: GcDropped[];
69
+ errors: GcError[];
70
+ }
71
+
72
+ export interface GcContext {
73
+ connections: Map<string, Connection>;
74
+ manifestService: ManifestService;
75
+ projectId: string;
76
+ dryRun?: boolean;
77
+ /**
78
+ * Set of `liveTableKey(connectionName, tableName)` tuples that some
79
+ * *active* manifest entry still claims. When a stale entry's target
80
+ * is in this set the physical DROP is skipped (the fresh row needs
81
+ * the table); the stale manifest row is still deleted and the
82
+ * staging companion is still best-effort dropped.
83
+ */
84
+ liveTables?: ReadonlySet<string>;
85
+ /**
86
+ * When true, delete the manifest row even if the row's
87
+ * `connectionName` is no longer registered with the package. Used by
88
+ * `teardownPackage` where retaining rows that point at a vanished
89
+ * connection just makes them un-GC-able.
90
+ */
91
+ forceDeleteRowOnMissingConnection?: boolean;
92
+ }
93
+
94
+ /**
95
+ * Process a single manifest entry for GC. Returns either a `dropped` or
96
+ * `error` result (never both).
97
+ *
98
+ * Ordering invariant: the manifest row is deleted **before** any physical
99
+ * DROP so a failed DROP leaves an orphaned table (recoverable) rather
100
+ * than an orphaned manifest row (causes skipped builds + query failures).
101
+ */
102
+ async function processOneEntry(
103
+ entry: ManifestEntry,
104
+ ctx: GcContext,
105
+ liveTables: ReadonlySet<string>,
106
+ ): Promise<{ dropped?: GcDropped; error?: GcError }> {
107
+ const stagingTableName = `${entry.tableName}${stagingSuffix(entry.buildId)}`;
108
+ const targetIsLive = liveTables.has(
109
+ liveTableKey(entry.connectionName, entry.tableName),
110
+ );
111
+
112
+ const connection = ctx.connections.get(entry.connectionName);
113
+
114
+ // ── Missing connection ────────────────────────────────────────
115
+ if (!connection) {
116
+ if (ctx.forceDeleteRowOnMissingConnection && !ctx.dryRun) {
117
+ try {
118
+ await ctx.manifestService.deleteEntry(ctx.projectId, entry.id);
119
+ logger.warn(
120
+ "GC: deleted manifest row whose connection is gone; physical table (if any) is orphaned",
121
+ {
122
+ manifestEntryId: entry.id,
123
+ tableName: entry.tableName,
124
+ connectionName: entry.connectionName,
125
+ },
126
+ );
127
+ return {
128
+ dropped: {
129
+ buildId: entry.buildId,
130
+ tableName: entry.tableName,
131
+ connectionName: entry.connectionName,
132
+ stagingTableName,
133
+ targetDropSkipped: true,
134
+ },
135
+ };
136
+ } catch (err) {
137
+ return {
138
+ error: {
139
+ buildId: entry.buildId,
140
+ tableName: entry.tableName,
141
+ connectionName: entry.connectionName,
142
+ error: err instanceof Error ? err.message : String(err),
143
+ },
144
+ };
145
+ }
146
+ }
147
+ return {
148
+ error: {
149
+ buildId: entry.buildId,
150
+ tableName: entry.tableName,
151
+ connectionName: entry.connectionName,
152
+ error: `Connection '${entry.connectionName}' is not available`,
153
+ },
154
+ };
155
+ }
156
+
157
+ // ── Unknown dialect ───────────────────────────────────────────
158
+ const dialect = DIALECTS[connection.dialectName];
159
+ if (!dialect) {
160
+ return {
161
+ error: {
162
+ buildId: entry.buildId,
163
+ tableName: entry.tableName,
164
+ connectionName: entry.connectionName,
165
+ error: `No dialect registered for '${connection.dialectName}'`,
166
+ },
167
+ };
168
+ }
169
+
170
+ // ── Dry run ───────────────────────────────────────────────────
171
+ if (ctx.dryRun) {
172
+ return {
173
+ dropped: {
174
+ buildId: entry.buildId,
175
+ tableName: entry.tableName,
176
+ connectionName: entry.connectionName,
177
+ stagingTableName,
178
+ targetDropSkipped: targetIsLive || undefined,
179
+ },
180
+ };
181
+ }
182
+
183
+ // ── Live run: delete manifest row first ───────────────────────
184
+ const quoted = (p: string) => quoteTablePath(p, dialect);
185
+
186
+ try {
187
+ await ctx.manifestService.deleteEntry(ctx.projectId, entry.id);
188
+ } catch (err) {
189
+ const error = err instanceof Error ? err.message : String(err);
190
+ logger.warn("GC: failed to delete manifest row; skipping physical drop", {
191
+ manifestEntryId: entry.id,
192
+ tableName: entry.tableName,
193
+ error,
194
+ });
195
+ return {
196
+ error: {
197
+ buildId: entry.buildId,
198
+ tableName: entry.tableName,
199
+ connectionName: entry.connectionName,
200
+ error,
201
+ },
202
+ };
203
+ }
204
+
205
+ // ── Drop target table (unless still live) ─────────────────────
206
+ if (targetIsLive) {
207
+ logger.info(
208
+ "GC: skipping target DROP; another active manifest entry claims this (connection, tableName)",
209
+ {
210
+ tableName: entry.tableName,
211
+ connectionName: entry.connectionName,
212
+ retiredBuildId: entry.buildId,
213
+ },
214
+ );
215
+ } else {
216
+ try {
217
+ await connection.runSQL(
218
+ `DROP TABLE IF EXISTS ${quoted(entry.tableName)}`,
219
+ );
220
+ } catch (err) {
221
+ logger.warn(
222
+ "GC: deleted manifest row but failed to drop materialized table (orphaned)",
223
+ {
224
+ tableName: entry.tableName,
225
+ connectionName: entry.connectionName,
226
+ error: err instanceof Error ? err.message : String(err),
227
+ },
228
+ );
229
+ }
230
+ }
231
+
232
+ // ── Best-effort drop staging companion ────────────────────────
233
+ try {
234
+ await connection.runSQL(
235
+ `DROP TABLE IF EXISTS ${quoted(stagingTableName)}`,
236
+ );
237
+ } catch (err) {
238
+ logger.warn("GC: failed to drop staging table (best-effort)", {
239
+ stagingTableName,
240
+ connectionName: entry.connectionName,
241
+ error: err instanceof Error ? err.message : String(err),
242
+ });
243
+ }
244
+
245
+ return {
246
+ dropped: {
247
+ buildId: entry.buildId,
248
+ tableName: entry.tableName,
249
+ connectionName: entry.connectionName,
250
+ stagingTableName,
251
+ targetDropSkipped: targetIsLive || undefined,
252
+ },
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Idempotent manifest-row-delete + DROP TABLE for each entry.
258
+ *
259
+ * The manifest row is deleted **before** the physical tables so a failed
260
+ * DROP leaves an orphaned table (recoverable by the next build that
261
+ * targets the same tableName) rather than an orphaned manifest row
262
+ * (causes skipped builds + query failures).
263
+ */
264
+ export async function dropManifestEntries(
265
+ entries: ManifestEntry[],
266
+ ctx: GcContext,
267
+ ): Promise<GcResult> {
268
+ const dropped: GcDropped[] = [];
269
+ const errors: GcError[] = [];
270
+ const liveTables = ctx.liveTables ?? new Set<string>();
271
+
272
+ for (const entry of entries) {
273
+ const result = await processOneEntry(entry, ctx, liveTables);
274
+ if (result.dropped) dropped.push(result.dropped);
275
+ if (result.error) errors.push(result.error);
276
+ }
277
+
278
+ return { dropped, errors };
279
+ }