@malloy-publisher/server 0.0.382-dev → 0.2.1

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 (175) hide show
  1. package/README.docker.md +145 -20
  2. package/README.md +65 -12
  3. package/dist/app/api-doc.html +5 -0
  4. package/dist/app/api-doc.yaml +3929 -534
  5. package/dist/app/assets/EnvironmentPage-CHq5BF4l.js +1 -0
  6. package/dist/app/assets/HomePage-CIp-pJCa.js +1 -0
  7. package/dist/app/assets/LightMode-mHHPjEeA.js +1 -0
  8. package/dist/app/assets/MainPage-CylxO5Yu.js +2 -0
  9. package/dist/app/assets/MaterializationsPage-a0vjqxib.js +1 -0
  10. package/dist/app/assets/ModelPage-Cz-MrSZQ.js +1 -0
  11. package/dist/app/assets/PackagePage-Cdg5D7AN.js +1 -0
  12. package/dist/app/assets/RouteError-B5SIyn6G.js +1 -0
  13. package/dist/app/assets/ThemeEditorPage-B62rRc-y.js +1 -0
  14. package/dist/app/assets/WorkbookPage-45cG1Gx6.js +1 -0
  15. package/dist/app/assets/{core-CjeTkq8O.es-BqRc6yhC.js → core-DyU47reh.es-DY9b3WjS.js} +1 -1
  16. package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
  17. package/dist/app/assets/index-5eLCcNmP.css +1 -0
  18. package/dist/app/assets/index-C5ClsnS7.js +1774 -0
  19. package/dist/app/assets/index-DF9tVqwN.js +1 -0
  20. package/dist/app/assets/index-DSoCUF-f.js +2725 -0
  21. package/dist/app/assets/index-DgVRz2wm.js +2 -0
  22. package/dist/app/assets/index-P-pF8nU5.js +632 -0
  23. package/dist/app/assets/index-w_0OQJgZ.js +23 -0
  24. package/dist/app/index.html +8 -4
  25. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  26. package/dist/default-publisher.config.json +23 -0
  27. package/dist/{instrumentation.js → instrumentation.mjs} +10631 -10573
  28. package/dist/package_load_worker.mjs +15219 -0
  29. package/dist/runtime/publisher.js +331 -0
  30. package/dist/{server.js → server.mjs} +141276 -74705
  31. package/dist/sshcrypto-vd2k5hq9.node +0 -0
  32. package/package.json +41 -28
  33. package/publisher.config.example.bigquery.json +33 -0
  34. package/publisher.config.example.duckdb.json +23 -0
  35. package/.eslintrc.json +0 -44
  36. package/.prettierignore +0 -1
  37. package/.prettierrc +0 -4
  38. package/build.ts +0 -38
  39. package/dist/app/assets/HomePage-Dn3E4CuB.js +0 -1
  40. package/dist/app/assets/MainPage-BzB3yoqi.js +0 -2
  41. package/dist/app/assets/ModelPage-C9O_sAXT.js +0 -1
  42. package/dist/app/assets/PackagePage-DcxKEjBX.js +0 -1
  43. package/dist/app/assets/ProjectPage-BDj307rF.js +0 -1
  44. package/dist/app/assets/RouteError-DAShbVCG.js +0 -1
  45. package/dist/app/assets/WorkbookPage-Cs_XYEaB.js +0 -1
  46. package/dist/app/assets/index-15BOvhp0.js +0 -456
  47. package/dist/app/assets/index-Bb2jqquW.js +0 -676
  48. package/dist/app/assets/index-CMlGQMcl.css +0 -1
  49. package/dist/app/assets/index-D68X76-7.js +0 -1276
  50. package/dist/app/assets/index.umd-DGBekgSu.js +0 -1145
  51. package/dxt/malloy_bridge.py +0 -354
  52. package/dxt/manifest.json +0 -22
  53. package/eslint.config.mjs +0 -76
  54. package/k6-tests/README.md +0 -144
  55. package/k6-tests/bun.lock +0 -797
  56. package/k6-tests/load-test/load-test-crud-connections.ts +0 -263
  57. package/k6-tests/load-test/load-test-crud-packages.ts +0 -461
  58. package/k6-tests/load-test/load-test-crud-projects.ts +0 -160
  59. package/k6-tests/load-test/load-test-crud.ts +0 -142
  60. package/k6-tests/load-test/load-test.ts +0 -784
  61. package/k6-tests/package.json +0 -27
  62. package/k6-tests/scripts/run-with-otel.sh +0 -50
  63. package/k6-tests/smoke-test/smoke-test.ts +0 -158
  64. package/k6-tests/tsconfig.json +0 -29
  65. package/k6-tests/utils/client_factory.ts +0 -128
  66. package/k6-tests/utils/common.ts +0 -489
  67. package/k6-tests/utils/logger.ts +0 -62
  68. package/k6-tests/utils/types.d.ts +0 -35
  69. package/malloy_mcp.dxt +0 -0
  70. package/publisher.config.json +0 -33
  71. package/src/config.spec.ts +0 -850
  72. package/src/config.ts +0 -305
  73. package/src/constants.ts +0 -10
  74. package/src/controller/compile.controller.ts +0 -35
  75. package/src/controller/connection.controller.ts +0 -515
  76. package/src/controller/database.controller.ts +0 -21
  77. package/src/controller/manifest.controller.ts +0 -29
  78. package/src/controller/materialization.controller.ts +0 -125
  79. package/src/controller/model.controller.ts +0 -107
  80. package/src/controller/package.controller.ts +0 -182
  81. package/src/controller/query.controller.ts +0 -60
  82. package/src/controller/watch-mode.controller.ts +0 -98
  83. package/src/data_styles.ts +0 -71
  84. package/src/dto/connection.dto.spec.ts +0 -131
  85. package/src/dto/connection.dto.ts +0 -174
  86. package/src/dto/index.ts +0 -2
  87. package/src/dto/package.dto.spec.ts +0 -42
  88. package/src/dto/package.dto.ts +0 -12
  89. package/src/dto/validate.spec.ts +0 -76
  90. package/src/dto/validate.ts +0 -31
  91. package/src/errors.ts +0 -116
  92. package/src/health.ts +0 -189
  93. package/src/instrumentation.ts +0 -148
  94. package/src/logger.ts +0 -154
  95. package/src/mcp/error_messages.ts +0 -199
  96. package/src/mcp/handler_utils.ts +0 -239
  97. package/src/mcp/mcp_constants.ts +0 -22
  98. package/src/mcp/prompts/handlers.ts +0 -84
  99. package/src/mcp/prompts/index.ts +0 -11
  100. package/src/mcp/prompts/prompt_definitions.ts +0 -160
  101. package/src/mcp/prompts/prompt_service.ts +0 -67
  102. package/src/mcp/prompts/utils.ts +0 -62
  103. package/src/mcp/resource_metadata.ts +0 -47
  104. package/src/mcp/resources/model_resource.ts +0 -153
  105. package/src/mcp/resources/notebook_resource.ts +0 -137
  106. package/src/mcp/resources/package_resource.ts +0 -370
  107. package/src/mcp/resources/project_resource.ts +0 -184
  108. package/src/mcp/resources/query_resource.ts +0 -117
  109. package/src/mcp/resources/source_resource.ts +0 -141
  110. package/src/mcp/resources/view_resource.ts +0 -136
  111. package/src/mcp/server.ts +0 -62
  112. package/src/mcp/tools/discovery_tools.ts +0 -258
  113. package/src/mcp/tools/execute_query_tool.ts +0 -265
  114. package/src/server.ts +0 -1195
  115. package/src/service/connection.spec.ts +0 -1331
  116. package/src/service/connection.ts +0 -1511
  117. package/src/service/connection_service.spec.ts +0 -641
  118. package/src/service/connection_service.ts +0 -181
  119. package/src/service/db_utils.spec.ts +0 -714
  120. package/src/service/db_utils.ts +0 -1227
  121. package/src/service/filter.spec.ts +0 -447
  122. package/src/service/filter.ts +0 -337
  123. package/src/service/filter_integration.spec.ts +0 -825
  124. package/src/service/gcs_s3_utils.ts +0 -427
  125. package/src/service/manifest_service.spec.ts +0 -201
  126. package/src/service/manifest_service.ts +0 -106
  127. package/src/service/materialization_service.spec.ts +0 -648
  128. package/src/service/materialization_service.ts +0 -929
  129. package/src/service/materialized_table_gc.spec.ts +0 -383
  130. package/src/service/materialized_table_gc.ts +0 -279
  131. package/src/service/model.spec.ts +0 -257
  132. package/src/service/model.ts +0 -1069
  133. package/src/service/package.spec.ts +0 -236
  134. package/src/service/package.ts +0 -463
  135. package/src/service/project.ts +0 -681
  136. package/src/service/project_compile.spec.ts +0 -197
  137. package/src/service/project_store.spec.ts +0 -859
  138. package/src/service/project_store.ts +0 -1531
  139. package/src/service/quoting.ts +0 -41
  140. package/src/service/resolve_project.ts +0 -13
  141. package/src/storage/BaseRepository.ts +0 -31
  142. package/src/storage/DatabaseInterface.ts +0 -180
  143. package/src/storage/StorageManager.mock.ts +0 -59
  144. package/src/storage/StorageManager.ts +0 -210
  145. package/src/storage/duckdb/ConnectionRepository.ts +0 -141
  146. package/src/storage/duckdb/DuckDBConnection.ts +0 -177
  147. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  148. package/src/storage/duckdb/DuckDBRepository.ts +0 -224
  149. package/src/storage/duckdb/ManifestRepository.ts +0 -119
  150. package/src/storage/duckdb/MaterializationRepository.ts +0 -249
  151. package/src/storage/duckdb/PackageRepository.ts +0 -135
  152. package/src/storage/duckdb/ProjectRepository.ts +0 -148
  153. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  154. package/src/storage/duckdb/schema.ts +0 -147
  155. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -146
  156. package/src/utils.ts +0 -13
  157. package/tests/fixtures/persist-test/data/orders.csv +0 -5
  158. package/tests/fixtures/persist-test/persist_test.malloy +0 -11
  159. package/tests/fixtures/persist-test/publisher.json +0 -5
  160. package/tests/fixtures/publisher.config.json +0 -15
  161. package/tests/harness/e2e.ts +0 -96
  162. package/tests/harness/mcp_test_setup.ts +0 -344
  163. package/tests/harness/mocks.ts +0 -37
  164. package/tests/harness/rest_e2e.ts +0 -68
  165. package/tests/harness/uris.ts +0 -31
  166. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +0 -470
  167. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +0 -434
  168. package/tests/integration/mcp/mcp_resource.integration.spec.ts +0 -663
  169. package/tests/integration/mcp/mcp_transport.integration.spec.ts +0 -122
  170. package/tests/integration/mcp/setup.spec.ts +0 -5
  171. package/tests/unit/duckdb/attached_databases.test.ts +0 -1132
  172. package/tests/unit/ducklake/ducklake.test.ts +0 -950
  173. package/tests/unit/mcp/prompt_definitions.test.ts +0 -102
  174. package/tests/unit/mcp/prompt_happy.test.ts +0 -51
  175. package/tsconfig.json +0 -32
@@ -1,383 +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
- 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
- });
@@ -1,279 +0,0 @@
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
- }