@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.
Files changed (100) hide show
  1. package/build.ts +10 -1
  2. package/dist/app/api-doc.yaml +494 -397
  3. package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
  4. package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
  5. package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
  6. package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
  7. package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
  8. package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
  9. package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
  10. package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
  11. package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
  12. package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
  13. package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
  14. package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
  15. package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
  16. package/dist/app/index.html +1 -1
  17. package/dist/instrumentation.mjs +18 -8
  18. package/dist/package_load_worker.mjs +19 -2
  19. package/dist/runtime/publisher.js +318 -0
  20. package/dist/server.mjs +1703 -1443
  21. package/package.json +5 -4
  22. package/scripts/bake-duckdb-extensions.js +104 -0
  23. package/src/constants.ts +12 -0
  24. package/src/controller/materialization.controller.ts +79 -35
  25. package/src/controller/package.controller.spec.ts +179 -0
  26. package/src/controller/package.controller.ts +60 -73
  27. package/src/controller/watch-mode.controller.ts +176 -46
  28. package/src/dto/package.dto.ts +16 -1
  29. package/src/errors.spec.ts +33 -0
  30. package/src/errors.ts +18 -0
  31. package/src/health.spec.ts +34 -1
  32. package/src/instrumentation.ts +33 -17
  33. package/src/materialization_metrics.ts +66 -0
  34. package/src/mcp/error_messages.spec.ts +35 -0
  35. package/src/mcp/error_messages.ts +14 -1
  36. package/src/mcp/handler_utils.ts +12 -0
  37. package/src/package_load/package_load_pool.ts +7 -1
  38. package/src/package_load/package_load_worker.ts +44 -4
  39. package/src/package_load/protocol.ts +7 -1
  40. package/src/runtime/publisher.js +318 -0
  41. package/src/server-old.ts +7 -149
  42. package/src/server.ts +488 -190
  43. package/src/service/authorize_integration.spec.ts +163 -2
  44. package/src/service/compile_authorize.spec.ts +85 -0
  45. package/src/service/environment.ts +270 -12
  46. package/src/service/environment_store.spec.ts +0 -81
  47. package/src/service/environment_store.ts +142 -34
  48. package/src/service/explore_visibility.spec.ts +434 -0
  49. package/src/service/exports_probe.spec.ts +107 -0
  50. package/src/service/manifest_loader.spec.ts +99 -0
  51. package/src/service/manifest_loader.ts +95 -0
  52. package/src/service/materialization_service.spec.ts +324 -512
  53. package/src/service/materialization_service.ts +816 -656
  54. package/src/service/model.ts +444 -13
  55. package/src/service/package.spec.ts +14 -2
  56. package/src/service/package.ts +271 -20
  57. package/src/service/package_rollback.spec.ts +190 -0
  58. package/src/service/package_worker_path.spec.ts +223 -0
  59. package/src/service/query_boundary.spec.ts +470 -0
  60. package/src/storage/DatabaseInterface.ts +35 -57
  61. package/src/storage/StorageManager.mock.ts +0 -9
  62. package/src/storage/StorageManager.ts +7 -290
  63. package/src/storage/duckdb/DuckDBConnection.ts +70 -124
  64. package/src/storage/duckdb/DuckDBRepository.ts +2 -35
  65. package/src/storage/duckdb/MaterializationRepository.ts +52 -27
  66. package/src/storage/duckdb/schema.ts +4 -20
  67. package/tests/fixtures/authorize-compile/model.malloy +9 -0
  68. package/tests/fixtures/authorize-compile/publisher.json +4 -0
  69. package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
  70. package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
  71. package/tests/fixtures/html-pages-test/data.csv +3 -0
  72. package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
  73. package/tests/fixtures/html-pages-test/public/data.json +1 -0
  74. package/tests/fixtures/html-pages-test/public/index.html +9 -0
  75. package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
  76. package/tests/fixtures/html-pages-test/publisher.json +5 -0
  77. package/tests/fixtures/html-pages-test/report.malloy +1 -0
  78. package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
  79. package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
  80. package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
  81. package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
  82. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
  83. package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
  84. package/tests/unit/duckdb/attached_databases.test.ts +111 -0
  85. package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
  86. package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
  87. package/tests/unit/duckdb/repositories.test.ts +208 -0
  88. package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
  89. package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
  90. package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
  91. package/src/controller/manifest.controller.ts +0 -38
  92. package/src/service/manifest_service.spec.ts +0 -206
  93. package/src/service/manifest_service.ts +0 -117
  94. package/src/service/materialized_table_gc.spec.ts +0 -384
  95. package/src/service/materialized_table_gc.ts +0 -231
  96. package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
  97. package/src/storage/duckdb/ManifestRepository.ts +0 -120
  98. package/src/storage/duckdb/manifest_store.spec.ts +0 -133
  99. package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
  100. package/tests/unit/storage/StorageManager.test.ts +0 -166
@@ -1,117 +0,0 @@
1
- import { logger } from "../logger";
2
- import {
3
- BuildManifest,
4
- ManifestEntry,
5
- ManifestStore,
6
- } from "../storage/DatabaseInterface";
7
- import { EnvironmentStore } from "./environment_store";
8
-
9
- /**
10
- * Manages build manifests that map source names to materialized table names.
11
- *
12
- * A build manifest is produced during materialization: each entry records which
13
- * DuckDB table backs a given Malloy source.
14
- *
15
- * Two-phase lifecycle:
16
- * 1. **Persist** – `writeEntry` stores individual entries to the DB as they
17
- * are produced during a build.
18
- * 2. **Reload** – `reloadManifest` reads the manifest from the DB and
19
- * recompiles all models in the package so the Malloy Runtime resolves
20
- * persist references to the materialized tables.
21
- *
22
- * All manifest operations delegate to the active {@link ManifestStore}, which
23
- * is either the local DuckDB store (standalone) or DuckLake (orchestrated).
24
- */
25
- export class ManifestService {
26
- constructor(private environmentStore: EnvironmentStore) {}
27
-
28
- private manifestStoreFor(environmentId: string): ManifestStore {
29
- return this.environmentStore.storageManager.getManifestStore(
30
- environmentId,
31
- );
32
- }
33
-
34
- async getManifest(
35
- environmentId: string,
36
- packageName: string,
37
- ): Promise<BuildManifest> {
38
- return this.manifestStoreFor(environmentId).getManifest(
39
- environmentId,
40
- packageName,
41
- );
42
- }
43
-
44
- async writeEntry(
45
- environmentId: string,
46
- packageName: string,
47
- buildId: string,
48
- tableName: string,
49
- sourceName: string,
50
- connectionName: string,
51
- ): Promise<void> {
52
- await this.manifestStoreFor(environmentId).writeEntry(
53
- environmentId,
54
- packageName,
55
- buildId,
56
- tableName,
57
- sourceName,
58
- connectionName,
59
- );
60
- }
61
-
62
- async deleteEntry(environmentId: string, entryId: string): Promise<void> {
63
- await this.manifestStoreFor(environmentId).deleteEntry(entryId);
64
- }
65
-
66
- /**
67
- * Read the manifest from storage and reload it onto this worker's
68
- * package so subsequent queries resolve persist references to
69
- * materialized tables. This is what `POST .../manifest?action=reload` calls
70
- * (used by orchestrated workers that need to pick up manifest state
71
- * produced elsewhere). Despite the name, nothing is loaded *into*
72
- * storage — the worker pulls the manifest down and recompiles.
73
- */
74
- async reloadManifest(
75
- environmentId: string,
76
- packageName: string,
77
- environmentName: string,
78
- ): Promise<BuildManifest> {
79
- const manifest = await this.getManifest(environmentId, packageName);
80
-
81
- const environment = await this.environmentStore.getEnvironment(
82
- environmentName,
83
- false,
84
- );
85
- // Ensure the package is loaded, then reload its models under the
86
- // per-package mutex so the disk reads are serialized against
87
- // installPackage / deletePackage.
88
- await environment.getPackage(packageName, false);
89
- await environment.reloadAllModelsForPackage(
90
- packageName,
91
- manifest.entries,
92
- );
93
-
94
- logger.info("Reloaded manifest and recompiled models", {
95
- environmentId,
96
- packageName,
97
- entryCount: Object.keys(manifest.entries).length,
98
- });
99
-
100
- return manifest;
101
- }
102
-
103
- /**
104
- * Get the manifest entries from the active store for inspection.
105
- * Routes through ManifestStore so DuckLake mode reads the shared
106
- * catalog instead of the local DuckDB table.
107
- */
108
- async listEntries(
109
- environmentId: string,
110
- packageName: string,
111
- ): Promise<ManifestEntry[]> {
112
- return this.manifestStoreFor(environmentId).listEntries(
113
- environmentId,
114
- packageName,
115
- );
116
- }
117
- }
@@ -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
- });