@malloy-publisher/server 0.0.204 → 0.0.206
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.ts +10 -1
- package/dist/app/api-doc.yaml +494 -397
- package/dist/app/assets/{EnvironmentPage-CX06cjOF.js → EnvironmentPage-BYwBeC2F.js} +1 -1
- package/dist/app/assets/HomePage-ivu4vdpj.js +1 -0
- package/dist/app/assets/{MainPage-nUJ9YatG.js → MainPage-B2DnHEDU.js} +2 -2
- package/dist/app/assets/MaterializationsPage-BZEuwF9P.js +1 -0
- package/dist/app/assets/ModelPage-Dpu3bfPg.js +1 -0
- package/dist/app/assets/{PackagePage-BaEVdEAG.js → PackagePage-B8PwcRHt.js} +1 -1
- package/dist/app/assets/{RouteError-BShQjZio.js → RouteError-BhbywAeC.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CBn6ZjJW.js → WorkbookPage-C-JXsJG0.js} +1 -1
- package/dist/app/assets/{core-DECXYL4E.es-OaRfXwuQ.js → core-pPlPr7jK.es-CNEOlxKB.js} +1 -1
- package/dist/app/assets/{index-BLfPC1gy.js → index-BHEm8Egc.js} +1 -1
- package/dist/app/assets/{index-Dy3YhAZQ.js → index-BsvDrV14.js} +1 -1
- package/dist/app/assets/{index-DqiJ0bWp.js → index-ChR1fKR2.js} +134 -134
- package/dist/app/assets/{index.umd-DAN9K8yC.js → index.umd-BVLPYNuj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +18 -8
- package/dist/package_load_worker.mjs +19 -2
- package/dist/runtime/publisher.js +318 -0
- package/dist/server.mjs +1703 -1443
- package/package.json +5 -4
- package/scripts/bake-duckdb-extensions.js +104 -0
- package/src/constants.ts +12 -0
- package/src/controller/materialization.controller.ts +79 -35
- package/src/controller/package.controller.spec.ts +179 -0
- package/src/controller/package.controller.ts +60 -73
- package/src/controller/watch-mode.controller.ts +176 -46
- package/src/dto/package.dto.ts +16 -1
- package/src/errors.spec.ts +33 -0
- package/src/errors.ts +18 -0
- package/src/health.spec.ts +34 -1
- package/src/instrumentation.ts +33 -17
- package/src/materialization_metrics.ts +66 -0
- package/src/mcp/error_messages.spec.ts +35 -0
- package/src/mcp/error_messages.ts +14 -1
- package/src/mcp/handler_utils.ts +12 -0
- package/src/package_load/package_load_pool.ts +7 -1
- package/src/package_load/package_load_worker.ts +44 -4
- package/src/package_load/protocol.ts +7 -1
- package/src/runtime/publisher.js +318 -0
- package/src/server-old.ts +7 -149
- package/src/server.ts +488 -190
- package/src/service/authorize_integration.spec.ts +163 -2
- package/src/service/compile_authorize.spec.ts +85 -0
- package/src/service/environment.ts +270 -12
- package/src/service/environment_store.spec.ts +0 -81
- package/src/service/environment_store.ts +142 -34
- package/src/service/explore_visibility.spec.ts +434 -0
- package/src/service/exports_probe.spec.ts +107 -0
- package/src/service/manifest_loader.spec.ts +99 -0
- package/src/service/manifest_loader.ts +95 -0
- package/src/service/materialization_service.spec.ts +324 -512
- package/src/service/materialization_service.ts +816 -656
- package/src/service/model.ts +444 -13
- package/src/service/package.spec.ts +14 -2
- package/src/service/package.ts +271 -20
- package/src/service/package_rollback.spec.ts +190 -0
- package/src/service/package_worker_path.spec.ts +223 -0
- package/src/service/query_boundary.spec.ts +470 -0
- package/src/storage/DatabaseInterface.ts +35 -57
- package/src/storage/StorageManager.mock.ts +0 -9
- package/src/storage/StorageManager.ts +7 -290
- package/src/storage/duckdb/DuckDBConnection.ts +70 -124
- package/src/storage/duckdb/DuckDBRepository.ts +2 -35
- package/src/storage/duckdb/MaterializationRepository.ts +52 -27
- package/src/storage/duckdb/schema.ts +4 -20
- package/tests/fixtures/authorize-compile/model.malloy +9 -0
- package/tests/fixtures/authorize-compile/publisher.json +4 -0
- package/tests/fixtures/html-pages-nopublic/model.malloy +1 -0
- package/tests/fixtures/html-pages-nopublic/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/data.csv +3 -0
- package/tests/fixtures/html-pages-test/public/assets/app.css +3 -0
- package/tests/fixtures/html-pages-test/public/data.json +1 -0
- package/tests/fixtures/html-pages-test/public/index.html +9 -0
- package/tests/fixtures/html-pages-test/public/sub/page2.html +9 -0
- package/tests/fixtures/html-pages-test/publisher.json +5 -0
- package/tests/fixtures/html-pages-test/report.malloy +1 -0
- package/tests/integration/authorize/compile_authorize_http.integration.spec.ts +92 -0
- package/tests/integration/duckdb_storage/duckdb_storage.integration.spec.ts +138 -0
- package/tests/integration/html_pages/html_pages.integration.spec.ts +378 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +175 -0
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +277 -266
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +421 -0
- package/tests/unit/duckdb/attached_databases.test.ts +111 -0
- package/tests/unit/duckdb/duckdb_connection.test.ts +181 -0
- package/tests/unit/duckdb/legacy_schema_migration.test.ts +7 -4
- package/tests/unit/duckdb/repositories.test.ts +208 -0
- package/dist/app/assets/HomePage-CNFt_eUU.js +0 -1
- package/dist/app/assets/MaterializationsPage-B5goxVXW.js +0 -1
- package/dist/app/assets/ModelPage-Ba7Xh4lL.js +0 -1
- package/src/controller/manifest.controller.ts +0 -38
- package/src/service/manifest_service.spec.ts +0 -206
- package/src/service/manifest_service.ts +0 -117
- package/src/service/materialized_table_gc.spec.ts +0 -384
- package/src/service/materialized_table_gc.ts +0 -231
- package/src/storage/duckdb/DuckDBManifestStore.ts +0 -70
- package/src/storage/duckdb/ManifestRepository.ts +0 -120
- package/src/storage/duckdb/manifest_store.spec.ts +0 -133
- package/src/storage/ducklake/DuckLakeManifestStore.ts +0 -155
- package/tests/unit/storage/StorageManager.test.ts +0 -166
|
@@ -1,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
|
-
});
|