@malloy-publisher/server 0.0.205 → 0.0.207

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 (69) hide show
  1. package/dist/app/api-doc.yaml +394 -395
  2. package/dist/app/assets/{EnvironmentPage-CAge6UHD.js → EnvironmentPage-BScgHmkw.js} +1 -1
  3. package/dist/app/assets/{HomePage-DhTe8qpa.js → HomePage-CGedji_w.js} +1 -1
  4. package/dist/app/assets/{MainPage-CeTxxGex.js → MainPage-DWfF4jXW.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-CpDHB70t.js → MaterializationsPage-B9PDlk8c.js} +1 -1
  6. package/dist/app/assets/{ModelPage-D9sSMb75.js → ModelPage-BiNOgK_e.js} +1 -1
  7. package/dist/app/assets/{PackagePage-LRqQWrFY.js → PackagePage-DAN5V7gu.js} +1 -1
  8. package/dist/app/assets/{RouteError-xT6kuCNw.js → RouteError-CEnIzuKN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-DsIh9svZ.js → WorkbookPage-gA1ceqHP.js} +1 -1
  10. package/dist/app/assets/{core-C2sQrwVu.es-Bjem0hym.js → core-AOmIKwkc.es-Dclu1Fga.js} +1 -1
  11. package/dist/app/assets/{index-BdOZDcce.js → index-DGGe8UpP.js} +1 -1
  12. package/dist/app/assets/{index-RX3QOTde.js → index-DtlPzNxc.js} +127 -127
  13. package/dist/app/assets/{index-DHHAcY5o.js → index-uu6UpHd2.js} +1 -1
  14. package/dist/app/assets/{index.umd-D2WH3D-f.js → index.umd-DDq93YX4.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +18 -8
  17. package/dist/package_load_worker.mjs +19 -2
  18. package/dist/server.mjs +1272 -1299
  19. package/package.json +1 -1
  20. package/src/constants.ts +12 -0
  21. package/src/controller/materialization.controller.ts +79 -35
  22. package/src/controller/package.controller.spec.ts +179 -0
  23. package/src/controller/package.controller.ts +60 -73
  24. package/src/dto/package.dto.ts +16 -1
  25. package/src/errors.spec.ts +12 -0
  26. package/src/errors.ts +18 -0
  27. package/src/health.spec.ts +34 -1
  28. package/src/instrumentation.ts +33 -17
  29. package/src/materialization_metrics.ts +121 -0
  30. package/src/package_load/package_load_pool.ts +7 -1
  31. package/src/package_load/package_load_worker.ts +44 -4
  32. package/src/package_load/protocol.ts +7 -1
  33. package/src/server-old.ts +7 -149
  34. package/src/server.ts +9 -188
  35. package/src/service/authorize_integration.spec.ts +67 -0
  36. package/src/service/environment.ts +270 -12
  37. package/src/service/environment_store.spec.ts +0 -81
  38. package/src/service/environment_store.ts +0 -23
  39. package/src/service/explore_visibility.spec.ts +434 -0
  40. package/src/service/exports_probe.spec.ts +107 -0
  41. package/src/service/manifest_loader.spec.ts +99 -0
  42. package/src/service/manifest_loader.ts +95 -0
  43. package/src/service/materialization_service.spec.ts +584 -500
  44. package/src/service/materialization_service.ts +839 -657
  45. package/src/service/model.ts +419 -15
  46. package/src/service/package.spec.ts +14 -2
  47. package/src/service/package.ts +339 -29
  48. package/src/service/package_rollback.spec.ts +190 -0
  49. package/src/service/package_worker_path.spec.ts +223 -0
  50. package/src/service/query_boundary.spec.ts +470 -0
  51. package/src/storage/DatabaseInterface.ts +35 -57
  52. package/src/storage/StorageManager.mock.ts +0 -9
  53. package/src/storage/StorageManager.ts +7 -290
  54. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  55. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  56. package/src/storage/duckdb/schema.ts +4 -20
  57. package/tests/integration/materialization/manifest_binding.integration.spec.ts +194 -0
  58. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +369 -264
  59. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  60. package/src/controller/manifest.controller.ts +0 -38
  61. package/src/service/manifest_service.spec.ts +0 -206
  62. package/src/service/manifest_service.ts +0 -117
  63. package/src/service/materialized_table_gc.spec.ts +0 -384
  64. package/src/service/materialized_table_gc.ts +0 -231
  65. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  66. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  67. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  68. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  69. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -1,384 +0,0 @@
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
- environmentId: "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
- environmentId: "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
- environmentId: "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
- environmentId: "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
- environmentId: "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
- environmentId: "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("drops regardless of connection dialect — DDL uses table names verbatim", 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
- environmentId: "proj-1",
185
- });
186
-
187
- // GC no longer needs a per-dialect quoter: table names are used
188
- // verbatim, so an unrecognized dialect is no longer a special case.
189
- expect(result.dropped).toHaveLength(1);
190
- expect(result.errors).toHaveLength(0);
191
- expect(ctx2.conn.runSQL.callCount).toBe(2);
192
- });
193
-
194
- it("dryRun lists what would drop without issuing SQL or deleting rows", async () => {
195
- const entries = [
196
- makeEntry(),
197
- makeEntry({ id: "entry-2", tableName: "schema.other" }),
198
- ];
199
-
200
- const result = await dropManifestEntries(entries, {
201
- connections: ctx.connections,
202
- manifestService: ctx.manifestService,
203
- environmentId: "proj-1",
204
- dryRun: true,
205
- });
206
-
207
- expect(result.dropped).toHaveLength(2);
208
- expect(result.errors).toHaveLength(0);
209
- expect(ctx.conn.runSQL.called).toBe(false);
210
- expect((ctx.manifestService.deleteEntry as sinon.SinonStub).called).toBe(
211
- false,
212
- );
213
- });
214
-
215
- it("isolates per-entry failures — other entries still drop", async () => {
216
- const good = makeEntry({ id: "good", tableName: "good_table" });
217
- const bad = makeEntry({
218
- id: "bad",
219
- tableName: "bad_table",
220
- connectionName: "ghost",
221
- });
222
-
223
- const result = await dropManifestEntries([bad, good], {
224
- connections: ctx.connections,
225
- manifestService: ctx.manifestService,
226
- environmentId: "proj-1",
227
- });
228
-
229
- expect(result.dropped).toHaveLength(1);
230
- expect(result.dropped[0].tableName).toBe("good_table");
231
- expect(result.errors).toHaveLength(1);
232
- expect(result.errors[0].tableName).toBe("bad_table");
233
- });
234
-
235
- // When a source is rebuilt with new SQL it gets a new BuildID but keeps
236
- // the same tableName. Without `liveTables`, GC would drop the stale
237
- // (old BuildID) manifest row and then issue `DROP TABLE IF EXISTS
238
- // <tableName>` — obliterating the fresh physical target produced by
239
- // the rebuild. `liveTables` short-circuits the physical DROP when
240
- // another active entry still claims the same (connection, tableName).
241
- describe("liveTables (rebuild safety)", () => {
242
- it("skips the target DROP when another active entry claims the same (connection, tableName)", async () => {
243
- const staleEntry = makeEntry({
244
- id: "stale",
245
- buildId: "old_buildid_000000000000000000000",
246
- tableName: "my_table",
247
- connectionName: "conn",
248
- });
249
- const live = new Set<string>([liveTableKey("conn", "my_table")]);
250
-
251
- const result = await dropManifestEntries([staleEntry], {
252
- connections: ctx.connections,
253
- manifestService: ctx.manifestService,
254
- environmentId: "proj-1",
255
- liveTables: live,
256
- });
257
-
258
- expect(result.dropped).toHaveLength(1);
259
- expect(result.dropped[0].targetDropSkipped).toBe(true);
260
- expect(result.errors).toHaveLength(0);
261
-
262
- expect(
263
- (ctx.manifestService.deleteEntry as sinon.SinonStub).calledOnce,
264
- ).toBe(true);
265
-
266
- // The target DROP must NOT fire; only the staging best-effort drop
267
- // should go through.
268
- expect(ctx.conn.runSQL.callCount).toBe(1);
269
- const only = ctx.conn.runSQL.getCall(0).args[0];
270
- expect(only).toMatch(/^DROP TABLE IF EXISTS /);
271
- expect(only).toContain(`my_table${stagingSuffix(staleEntry.buildId)}`);
272
- expect(only).not.toMatch(/my_table\b(?!_)/);
273
- });
274
-
275
- it("still drops the target when liveTables claims a different connection", async () => {
276
- const staleEntry = makeEntry({ tableName: "my_table" });
277
- // Different connection with the same tableName is not the same
278
- // physical table; the DROP must proceed.
279
- const live = new Set<string>([liveTableKey("other_conn", "my_table")]);
280
-
281
- const result = await dropManifestEntries([staleEntry], {
282
- connections: ctx.connections,
283
- manifestService: ctx.manifestService,
284
- environmentId: "proj-1",
285
- liveTables: live,
286
- });
287
-
288
- expect(result.dropped).toHaveLength(1);
289
- expect(result.dropped[0].targetDropSkipped).toBeUndefined();
290
- expect(ctx.conn.runSQL.callCount).toBe(2);
291
- });
292
-
293
- it("dryRun reports would-skip via targetDropSkipped", async () => {
294
- const staleEntry = makeEntry();
295
- const live = new Set<string>([liveTableKey("conn", "my_table")]);
296
-
297
- const result = await dropManifestEntries([staleEntry], {
298
- connections: ctx.connections,
299
- manifestService: ctx.manifestService,
300
- environmentId: "proj-1",
301
- dryRun: true,
302
- liveTables: live,
303
- });
304
-
305
- expect(result.dropped).toHaveLength(1);
306
- expect(result.dropped[0].targetDropSkipped).toBe(true);
307
- expect(ctx.conn.runSQL.called).toBe(false);
308
- expect(
309
- (ctx.manifestService.deleteEntry as sinon.SinonStub).called,
310
- ).toBe(false);
311
- });
312
- });
313
-
314
- describe("forceDeleteRowOnMissingConnection (teardown)", () => {
315
- it("deletes the manifest row without any DROP when the connection is gone", async () => {
316
- const entry = makeEntry({ connectionName: "ghost" });
317
-
318
- const result = await dropManifestEntries([entry], {
319
- connections: ctx.connections,
320
- manifestService: ctx.manifestService,
321
- environmentId: "proj-1",
322
- forceDeleteRowOnMissingConnection: true,
323
- });
324
-
325
- expect(result.dropped).toHaveLength(1);
326
- expect(result.dropped[0].targetDropSkipped).toBe(true);
327
- expect(result.errors).toHaveLength(0);
328
- expect(
329
- (ctx.manifestService.deleteEntry as sinon.SinonStub).calledOnce,
330
- ).toBe(true);
331
- expect(ctx.conn.runSQL.called).toBe(false);
332
- });
333
-
334
- it("records an error if the manifest-row delete itself fails", async () => {
335
- const entry = makeEntry({ connectionName: "ghost" });
336
- (ctx.manifestService.deleteEntry as sinon.SinonStub).rejects(
337
- new Error("manifest store down"),
338
- );
339
-
340
- const result = await dropManifestEntries([entry], {
341
- connections: ctx.connections,
342
- manifestService: ctx.manifestService,
343
- environmentId: "proj-1",
344
- forceDeleteRowOnMissingConnection: true,
345
- });
346
-
347
- expect(result.dropped).toHaveLength(0);
348
- expect(result.errors).toHaveLength(1);
349
- expect(result.errors[0].error).toContain("manifest store down");
350
- });
351
-
352
- it("falls back to the error path in dryRun (keeps dryRun side-effect-free)", async () => {
353
- const entry = makeEntry({ connectionName: "ghost" });
354
-
355
- const result = await dropManifestEntries([entry], {
356
- connections: ctx.connections,
357
- manifestService: ctx.manifestService,
358
- environmentId: "proj-1",
359
- forceDeleteRowOnMissingConnection: true,
360
- dryRun: true,
361
- });
362
-
363
- // In dryRun the row is NOT deleted; surface the same error signal
364
- // so operators see the connection gap they'd hit on a live run.
365
- expect(result.dropped).toHaveLength(0);
366
- expect(result.errors).toHaveLength(1);
367
- expect(result.errors[0].error).toMatch(/ghost/);
368
- expect(
369
- (ctx.manifestService.deleteEntry as sinon.SinonStub).called,
370
- ).toBe(false);
371
- });
372
- });
373
- });
374
-
375
- describe("stagingSuffix", () => {
376
- it("uses a fixed-length prefix of the BuildID", () => {
377
- const suffix = stagingSuffix(
378
- "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
379
- );
380
- // 12 hex chars + the "_" prefix = 13 chars total.
381
- expect(suffix).toBe("_0123456789ab");
382
- expect(suffix.length).toBe(13);
383
- });
384
- });
@@ -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
- }