@malloy-publisher/server 0.0.200 → 0.0.201
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/dist/app/api-doc.yaml +34 -7
- package/dist/app/assets/{EnvironmentPage-CgKNjySu.js → EnvironmentPage-KoP4wt8H.js} +1 -1
- package/dist/app/assets/{HomePage-BPIpMBjW.js → HomePage-HbPwKL84.js} +1 -1
- package/dist/app/assets/MainPage-DfK4zDYO.js +2 -0
- package/dist/app/assets/{ModelPage-C0Uevsw9.js → ModelPage-CUgSwGXg.js} +1 -1
- package/dist/app/assets/{PackagePage-Cu-u9k1g.js → PackagePage-CUDQNL5k.js} +1 -1
- package/dist/app/assets/{RouteError-DVwPh2Ql.js → RouteError-sgmtBdg8.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DW38R2Zv.js → WorkbookPage-tnWmLcrW.js} +1 -1
- package/dist/app/assets/{core-C0vCMRDQ.es-D_ytHhjS.js → core-B3IQNPBD.es-foBNuT8L.js} +1 -1
- package/dist/app/assets/{index-BGdcKsFF.js → index-B5We8x8r.js} +1 -1
- package/dist/app/assets/{index-CTx4v4_3.js → index-KIvi9k3F.js} +1 -1
- package/dist/app/assets/{index-DE6d5jEy.js → index-PNYovl3E.js} +3 -3
- package/dist/app/assets/{index.umd-C1Mi1uRm.js → index.umd-BXcsl2XW.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +74 -8
- package/package.json +1 -1
- package/publisher.config.json +4 -0
- package/src/filter_deprecation.spec.ts +64 -0
- package/src/filter_deprecation.ts +42 -0
- package/src/server.ts +32 -25
- package/src/service/connection.spec.ts +244 -0
- package/src/service/connection.ts +6 -2
- package/src/service/environment.ts +42 -2
- package/src/service/filter_integration.spec.ts +69 -0
- package/src/service/model.ts +15 -2
- package/dist/app/assets/MainPage-CAwb8U82.js +0 -2
package/dist/server.mjs
CHANGED
|
@@ -221210,7 +221210,9 @@ async function isDatabaseAttached(connection, dbName) {
|
|
|
221210
221210
|
try {
|
|
221211
221211
|
const existingDatabases = await connection.runSQL("SHOW DATABASES");
|
|
221212
221212
|
const rows = Array.isArray(existingDatabases) ? existingDatabases : existingDatabases.rows || [];
|
|
221213
|
-
logger.debug(
|
|
221213
|
+
logger.debug("connection.duckdb.databases.queried", {
|
|
221214
|
+
count: rows.length
|
|
221215
|
+
});
|
|
221214
221216
|
return rows.some((row) => Object.values(row).some((value) => typeof value === "string" && value === dbName));
|
|
221215
221217
|
} catch (error) {
|
|
221216
221218
|
logger.warn(`Failed to check existing databases:`, error);
|
|
@@ -231186,6 +231188,13 @@ class Model {
|
|
|
231186
231188
|
this.filterMap = filterMap ?? new Map;
|
|
231187
231189
|
this.givens = givens;
|
|
231188
231190
|
this.modelInfo = modelInfo ?? (this.modelDef ? modelDefToModelInfo(this.modelDef) : undefined);
|
|
231191
|
+
if (this.filterMap.size > 0) {
|
|
231192
|
+
logger.warn(`Model "${packageName}/${modelPath}" uses deprecated #(filter) annotations. Migrate to given: — see https://github.com/malloydata/publisher/blob/main/docs/givens.md`, {
|
|
231193
|
+
packageName,
|
|
231194
|
+
modelPath,
|
|
231195
|
+
filterSourceCount: this.filterMap.size
|
|
231196
|
+
});
|
|
231197
|
+
}
|
|
231189
231198
|
}
|
|
231190
231199
|
getFilters(sourceName) {
|
|
231191
231200
|
return this.filterMap.get(sourceName) ?? [];
|
|
@@ -231454,7 +231463,6 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
231454
231463
|
}
|
|
231455
231464
|
async getNotebookModel() {
|
|
231456
231465
|
const notebookCells = this.runnableNotebookCells.map((cell) => {
|
|
231457
|
-
logger.debug("cell.queryInfo", cell.queryInfo);
|
|
231458
231466
|
return {
|
|
231459
231467
|
type: cell.type,
|
|
231460
231468
|
text: cell.text,
|
|
@@ -231555,7 +231563,6 @@ run: ${sourceName ? sourceName + "->" : ""}${queryName}`;
|
|
|
231555
231563
|
} else {
|
|
231556
231564
|
logger.error("Error message: ", errorMessage);
|
|
231557
231565
|
}
|
|
231558
|
-
logger.debug("Cell content: ", cellIndex, cell.type, cell.text);
|
|
231559
231566
|
throw new BadRequestError(`Cell execution failed: ${errorMessage}`);
|
|
231560
231567
|
}
|
|
231561
231568
|
}
|
|
@@ -232244,7 +232251,10 @@ class Environment {
|
|
|
232244
232251
|
logger.info(`Creating environment with connection configuration`);
|
|
232245
232252
|
const malloyConfig = buildEnvironmentMalloyConfig(connections, environmentPath);
|
|
232246
232253
|
logger.info(`Loaded ${malloyConfig.apiConnections.length} connections for environment ${environmentName}`, {
|
|
232247
|
-
|
|
232254
|
+
connections: malloyConfig.apiConnections.map((c) => ({
|
|
232255
|
+
name: c.name,
|
|
232256
|
+
type: c.type
|
|
232257
|
+
}))
|
|
232248
232258
|
});
|
|
232249
232259
|
const environment = new Environment(environmentName, environmentPath, malloyConfig, malloyConfig.apiConnections);
|
|
232250
232260
|
await Environment.sweepStaleInstallDirs(environmentPath);
|
|
@@ -232481,8 +232491,7 @@ ${source}` : source;
|
|
|
232481
232491
|
}
|
|
232482
232492
|
this.assertCanAdmitNewPackage(packageName, "add a new package", options.allowAdmission === true);
|
|
232483
232493
|
logger.info(`Adding package ${packageName} to environment ${this.environmentName}`, {
|
|
232484
|
-
packagePath
|
|
232485
|
-
malloyConfig: this.malloyConfig.malloyConfig
|
|
232494
|
+
packagePath
|
|
232486
232495
|
});
|
|
232487
232496
|
return this.withPackageLock(packageName, () => this._addPackageLocked(packageName));
|
|
232488
232497
|
}
|
|
@@ -232507,13 +232516,28 @@ ${source}` : source;
|
|
|
232507
232516
|
assertSafePackageName(packageName);
|
|
232508
232517
|
const stagingPath = this.allocateStagingPath(packageName);
|
|
232509
232518
|
await fs6.promises.mkdir(path8.dirname(stagingPath), { recursive: true });
|
|
232519
|
+
logger.debug("install.phase1.download.started", {
|
|
232520
|
+
environmentName: this.environmentName,
|
|
232521
|
+
packageName,
|
|
232522
|
+
stagingPath
|
|
232523
|
+
});
|
|
232524
|
+
const downloadStartedAt = performance.now();
|
|
232510
232525
|
try {
|
|
232511
232526
|
await downloader(stagingPath);
|
|
232512
232527
|
} catch (err) {
|
|
232513
232528
|
await fs6.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
|
|
232514
232529
|
throw err;
|
|
232515
232530
|
}
|
|
232531
|
+
logger.debug("install.phase1.download.completed", {
|
|
232532
|
+
environmentName: this.environmentName,
|
|
232533
|
+
packageName,
|
|
232534
|
+
durationMs: performance.now() - downloadStartedAt
|
|
232535
|
+
});
|
|
232516
232536
|
return this.withPackageLock(packageName, async () => {
|
|
232537
|
+
logger.debug("install.phase2.swap.started", {
|
|
232538
|
+
environmentName: this.environmentName,
|
|
232539
|
+
packageName
|
|
232540
|
+
});
|
|
232517
232541
|
const canonicalPath = safeJoinUnderRoot(this.environmentPath, packageName);
|
|
232518
232542
|
let retiredPath;
|
|
232519
232543
|
const oldPackage = this.packages.get(packageName);
|
|
@@ -232524,17 +232548,29 @@ ${source}` : source;
|
|
|
232524
232548
|
recursive: true
|
|
232525
232549
|
});
|
|
232526
232550
|
await fs6.promises.rename(canonicalPath, retiredPath);
|
|
232551
|
+
logger.debug("install.phase2.retired_old", {
|
|
232552
|
+
environmentName: this.environmentName,
|
|
232553
|
+
packageName,
|
|
232554
|
+
retiredPath
|
|
232555
|
+
});
|
|
232527
232556
|
}
|
|
232528
232557
|
let newPackage;
|
|
232529
232558
|
try {
|
|
232530
232559
|
await fs6.promises.rename(stagingPath, canonicalPath);
|
|
232531
232560
|
this.setPackageStatus(packageName, "loading" /* LOADING */);
|
|
232532
232561
|
newPackage = await Package.create(this.environmentName, packageName, canonicalPath, () => this.malloyConfig.malloyConfig);
|
|
232562
|
+
logger.debug("install.phase2.committed", {
|
|
232563
|
+
environmentName: this.environmentName,
|
|
232564
|
+
packageName,
|
|
232565
|
+
canonicalPath
|
|
232566
|
+
});
|
|
232533
232567
|
} catch (err) {
|
|
232534
232568
|
await fs6.promises.rm(canonicalPath, { recursive: true, force: true }).catch(() => {});
|
|
232569
|
+
let restored = false;
|
|
232535
232570
|
if (retiredPath) {
|
|
232536
232571
|
try {
|
|
232537
232572
|
await fs6.promises.rename(retiredPath, canonicalPath);
|
|
232573
|
+
restored = true;
|
|
232538
232574
|
} catch (restoreErr) {
|
|
232539
232575
|
logger.error("Failed to restore retired package after install rollback", {
|
|
232540
232576
|
error: restoreErr,
|
|
@@ -232545,6 +232581,12 @@ ${source}` : source;
|
|
|
232545
232581
|
}
|
|
232546
232582
|
await fs6.promises.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
|
|
232547
232583
|
this.deletePackageStatus(packageName);
|
|
232584
|
+
logger.debug("install.phase2.rollback", {
|
|
232585
|
+
environmentName: this.environmentName,
|
|
232586
|
+
packageName,
|
|
232587
|
+
restored,
|
|
232588
|
+
errorName: err instanceof Error ? err.name : "Unknown"
|
|
232589
|
+
});
|
|
232548
232590
|
throw err;
|
|
232549
232591
|
}
|
|
232550
232592
|
this.packages.set(packageName, newPackage);
|
|
@@ -232555,6 +232597,11 @@ ${source}` : source;
|
|
|
232555
232597
|
if (retiredPath) {
|
|
232556
232598
|
const pathToClean = retiredPath;
|
|
232557
232599
|
setImmediate(() => {
|
|
232600
|
+
logger.debug("install.phase3.retired_cleanup", {
|
|
232601
|
+
environmentName: this.environmentName,
|
|
232602
|
+
packageName,
|
|
232603
|
+
retiredPath: pathToClean
|
|
232604
|
+
});
|
|
232558
232605
|
fs6.promises.rm(pathToClean, { recursive: true, force: true }).catch((err) => {
|
|
232559
232606
|
logger.warn(`Failed to clean up retired package directory ${pathToClean}`, { error: err });
|
|
232560
232607
|
});
|
|
@@ -233815,6 +233862,15 @@ class WatchModeController {
|
|
|
233815
233862
|
init_errors();
|
|
233816
233863
|
init_logger();
|
|
233817
233864
|
|
|
233865
|
+
// src/filter_deprecation.ts
|
|
233866
|
+
var setFilterDeprecationHeaders = (res, options) => {
|
|
233867
|
+
const hasFilterParams = options.filterParams !== undefined && options.filterParams !== null && !(typeof options.filterParams === "object" && !Array.isArray(options.filterParams) && Object.keys(options.filterParams).length === 0);
|
|
233868
|
+
if (hasFilterParams || options.bypassFilters !== undefined) {
|
|
233869
|
+
res.setHeader("Deprecation", "true");
|
|
233870
|
+
res.setHeader("Link", '<https://github.com/malloydata/publisher/blob/main/docs/givens.md>; rel="deprecation"; type="text/markdown"');
|
|
233871
|
+
}
|
|
233872
|
+
};
|
|
233873
|
+
|
|
233818
233874
|
// src/heap_check.ts
|
|
233819
233875
|
init_logger();
|
|
233820
233876
|
var import_api7 = __toESM(require_src(), 1);
|
|
@@ -239891,7 +239947,12 @@ app.get(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/note
|
|
|
239891
239947
|
return;
|
|
239892
239948
|
}
|
|
239893
239949
|
}
|
|
239894
|
-
|
|
239950
|
+
const result = await modelController.executeNotebookCell(req.params.environmentName, req.params.packageName, notebookPath, cellIndex, filterParams, bypassFilters, givens);
|
|
239951
|
+
setFilterDeprecationHeaders(res, {
|
|
239952
|
+
filterParams,
|
|
239953
|
+
bypassFilters
|
|
239954
|
+
});
|
|
239955
|
+
res.status(200).json(result);
|
|
239895
239956
|
} catch (error) {
|
|
239896
239957
|
logger.error(error);
|
|
239897
239958
|
const { json, status } = internalErrorToHttpError(error);
|
|
@@ -239919,7 +239980,12 @@ app.post(`${API_PREFIX2}/environments/:environmentName/packages/:packageName/mod
|
|
|
239919
239980
|
}
|
|
239920
239981
|
try {
|
|
239921
239982
|
const modelPath = req.params["0"];
|
|
239922
|
-
|
|
239983
|
+
const result = await queryController.getQuery(req.params.environmentName, req.params.packageName, modelPath, req.body.sourceName, req.body.queryName, req.body.query, req.body.compactJson === true, req.body.filterParams ?? req.body.sourceFilters, req.body.bypassFilters === true ? true : undefined, req.body.givens);
|
|
239984
|
+
setFilterDeprecationHeaders(res, {
|
|
239985
|
+
filterParams: req.body.filterParams ?? req.body.sourceFilters,
|
|
239986
|
+
bypassFilters: req.body.bypassFilters === true ? true : undefined
|
|
239987
|
+
});
|
|
239988
|
+
res.status(200).json(result);
|
|
239923
239989
|
} catch (error) {
|
|
239924
239990
|
logger.error(error);
|
|
239925
239991
|
const { json, status } = internalErrorToHttpError(error);
|
package/package.json
CHANGED
package/publisher.config.json
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
{
|
|
16
16
|
"name": "faa",
|
|
17
17
|
"location": "https://github.com/credibledata/malloy-samples/tree/main/faa"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "faa-givens-demo",
|
|
21
|
+
"location": "https://github.com/credibledata/malloy-samples/tree/main/faa-givens-demo"
|
|
18
22
|
}
|
|
19
23
|
],
|
|
20
24
|
"connections": []
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { setFilterDeprecationHeaders } from "./filter_deprecation";
|
|
3
|
+
|
|
4
|
+
const makeRes = () => {
|
|
5
|
+
const headers: Record<string, string> = {};
|
|
6
|
+
const res = {
|
|
7
|
+
headers,
|
|
8
|
+
setHeader(name: string, value: string) {
|
|
9
|
+
headers[name] = value;
|
|
10
|
+
return res;
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
return res;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const DEPRECATION_LINK =
|
|
17
|
+
'<https://github.com/malloydata/publisher/blob/main/docs/givens.md>; rel="deprecation"; type="text/markdown"';
|
|
18
|
+
|
|
19
|
+
describe("setFilterDeprecationHeaders", () => {
|
|
20
|
+
it("does not set headers when neither filterParams nor bypassFilters is supplied", () => {
|
|
21
|
+
const res = makeRes();
|
|
22
|
+
setFilterDeprecationHeaders(res, {});
|
|
23
|
+
expect(res.headers.Deprecation).toBeUndefined();
|
|
24
|
+
expect(res.headers.Link).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not set headers for an explicit empty filterParams object (no-op opt-out)", () => {
|
|
28
|
+
const res = makeRes();
|
|
29
|
+
setFilterDeprecationHeaders(res, { filterParams: {} });
|
|
30
|
+
expect(res.headers.Deprecation).toBeUndefined();
|
|
31
|
+
expect(res.headers.Link).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("sets RFC 8594 headers when filterParams carries values", () => {
|
|
35
|
+
const res = makeRes();
|
|
36
|
+
setFilterDeprecationHeaders(res, {
|
|
37
|
+
filterParams: { region: ["US"] },
|
|
38
|
+
});
|
|
39
|
+
expect(res.headers.Deprecation).toBe("true");
|
|
40
|
+
expect(res.headers.Link).toBe(DEPRECATION_LINK);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("sets RFC 8594 headers when bypassFilters is true", () => {
|
|
44
|
+
const res = makeRes();
|
|
45
|
+
setFilterDeprecationHeaders(res, { bypassFilters: true });
|
|
46
|
+
expect(res.headers.Deprecation).toBe("true");
|
|
47
|
+
expect(res.headers.Link).toBe(DEPRECATION_LINK);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not set headers when bypassFilters is undefined and filterParams is undefined", () => {
|
|
51
|
+
const res = makeRes();
|
|
52
|
+
setFilterDeprecationHeaders(res, {
|
|
53
|
+
filterParams: undefined,
|
|
54
|
+
bypassFilters: undefined,
|
|
55
|
+
});
|
|
56
|
+
expect(res.headers.Deprecation).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does not set headers when filterParams is null", () => {
|
|
60
|
+
const res = makeRes();
|
|
61
|
+
setFilterDeprecationHeaders(res, { filterParams: null });
|
|
62
|
+
expect(res.headers.Deprecation).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structural type the helper needs from an HTTP response. Kept
|
|
3
|
+
* narrower than `express.Response` so tests can pass a tiny stub instead
|
|
4
|
+
* of constructing a full Express response.
|
|
5
|
+
*/
|
|
6
|
+
export interface HeaderSetter {
|
|
7
|
+
setHeader(name: string, value: string): unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Attach RFC 8594 deprecation headers when the request carries any of the
|
|
12
|
+
* legacy `#(filter)` API surface (`filterParams` / `bypassFilters` on POST,
|
|
13
|
+
* `filter_params` / `bypass_filters` on the notebook-cell GET). The
|
|
14
|
+
* complementary operator-facing notice for legacy *models* (independent of
|
|
15
|
+
* whether the caller used the deprecated request fields) ships as a
|
|
16
|
+
* one-time warn log in `Model`'s constructor — see service/model.ts.
|
|
17
|
+
*
|
|
18
|
+
* `filterParams` here is the *parsed* value (after JSON.parse for the GET
|
|
19
|
+
* notebook-cell path), so an empty `{}` is treated as a no-op opt-out — the
|
|
20
|
+
* header only fires when the caller actually supplied filter values.
|
|
21
|
+
*/
|
|
22
|
+
export const setFilterDeprecationHeaders = (
|
|
23
|
+
res: HeaderSetter,
|
|
24
|
+
options: { filterParams?: unknown; bypassFilters?: unknown },
|
|
25
|
+
): void => {
|
|
26
|
+
const hasFilterParams =
|
|
27
|
+
options.filterParams !== undefined &&
|
|
28
|
+
options.filterParams !== null &&
|
|
29
|
+
!(
|
|
30
|
+
typeof options.filterParams === "object" &&
|
|
31
|
+
!Array.isArray(options.filterParams) &&
|
|
32
|
+
Object.keys(options.filterParams as Record<string, unknown>).length ===
|
|
33
|
+
0
|
|
34
|
+
);
|
|
35
|
+
if (hasFilterParams || options.bypassFilters !== undefined) {
|
|
36
|
+
res.setHeader("Deprecation", "true");
|
|
37
|
+
res.setHeader(
|
|
38
|
+
"Link",
|
|
39
|
+
'<https://github.com/malloydata/publisher/blob/main/docs/givens.md>; rel="deprecation"; type="text/markdown"',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
};
|
package/src/server.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import { logger, loggerMiddleware } from "./logger";
|
|
36
36
|
|
|
37
37
|
import { getMemoryGovernorConfig } from "./config";
|
|
38
|
+
import { setFilterDeprecationHeaders } from "./filter_deprecation";
|
|
38
39
|
import { checkHeapConfiguration } from "./heap_check";
|
|
39
40
|
import { queryConcurrency } from "./query_concurrency";
|
|
40
41
|
import { ManifestController } from "./controller/manifest.controller";
|
|
@@ -1098,17 +1099,20 @@ app.get(
|
|
|
1098
1099
|
}
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
givens,
|
|
1110
|
-
),
|
|
1102
|
+
const result = await modelController.executeNotebookCell(
|
|
1103
|
+
req.params.environmentName,
|
|
1104
|
+
req.params.packageName,
|
|
1105
|
+
notebookPath,
|
|
1106
|
+
cellIndex,
|
|
1107
|
+
filterParams,
|
|
1108
|
+
bypassFilters,
|
|
1109
|
+
givens,
|
|
1111
1110
|
);
|
|
1111
|
+
setFilterDeprecationHeaders(res, {
|
|
1112
|
+
filterParams,
|
|
1113
|
+
bypassFilters,
|
|
1114
|
+
});
|
|
1115
|
+
res.status(200).json(result);
|
|
1112
1116
|
} catch (error) {
|
|
1113
1117
|
logger.error(error);
|
|
1114
1118
|
const { json, status } = internalErrorToHttpError(error as Error);
|
|
@@ -1155,22 +1159,25 @@ app.post(
|
|
|
1155
1159
|
try {
|
|
1156
1160
|
// Express stores wildcard matches in params['0']
|
|
1157
1161
|
const modelPath = (req.params as Record<string, string>)["0"];
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
req.body.givens as Record<string, GivenValue> | undefined,
|
|
1172
|
-
),
|
|
1162
|
+
const result = await queryController.getQuery(
|
|
1163
|
+
req.params.environmentName,
|
|
1164
|
+
req.params.packageName,
|
|
1165
|
+
modelPath,
|
|
1166
|
+
req.body.sourceName as string,
|
|
1167
|
+
req.body.queryName as string,
|
|
1168
|
+
req.body.query as string,
|
|
1169
|
+
req.body.compactJson === true,
|
|
1170
|
+
(req.body.filterParams ?? req.body.sourceFilters) as
|
|
1171
|
+
| Record<string, string | string[]>
|
|
1172
|
+
| undefined,
|
|
1173
|
+
req.body.bypassFilters === true ? true : undefined,
|
|
1174
|
+
req.body.givens as Record<string, GivenValue> | undefined,
|
|
1173
1175
|
);
|
|
1176
|
+
setFilterDeprecationHeaders(res, {
|
|
1177
|
+
filterParams: req.body.filterParams ?? req.body.sourceFilters,
|
|
1178
|
+
bypassFilters: req.body.bypassFilters === true ? true : undefined,
|
|
1179
|
+
});
|
|
1180
|
+
res.status(200).json(result);
|
|
1174
1181
|
} catch (error) {
|
|
1175
1182
|
logger.error(error);
|
|
1176
1183
|
const { json, status } = internalErrorToHttpError(error as Error);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
testConnectionConfig,
|
|
10
10
|
} from "./connection";
|
|
11
11
|
import { assembleEnvironmentConnections } from "./connection_config";
|
|
12
|
+
import { EnvironmentStore } from "./environment_store";
|
|
12
13
|
|
|
13
14
|
type ApiConnection = components["schemas"]["Connection"];
|
|
14
15
|
type AttachedDatabase = components["schemas"]["AttachedDatabase"];
|
|
@@ -45,6 +46,21 @@ const hasGCSCredentials = () =>
|
|
|
45
46
|
const readBigQueryServiceAccountJson = async (): Promise<string> =>
|
|
46
47
|
fs.readFile(process.env.GOOGLE_APPLICATION_CREDENTIALS!, "utf-8");
|
|
47
48
|
|
|
49
|
+
// `BIGQUERY_PUBLIC_DATA_*` env vars are populated by the
|
|
50
|
+
// `Setup BigQuery public-data credentials` step in
|
|
51
|
+
// `.github/workflows/connection-integration-tests.yml`. Kept separate
|
|
52
|
+
// from the existing `BIGQUERY_TEST_*` vars (which point at the
|
|
53
|
+
// org-scoped BQ_PRESTO_TRINO_KEY service account) so the two SAs can
|
|
54
|
+
// coexist without one overwriting the other.
|
|
55
|
+
const hasPublicDataBigQueryCredentials = () =>
|
|
56
|
+
!!(
|
|
57
|
+
process.env.BIGQUERY_PUBLIC_DATA_CREDENTIALS &&
|
|
58
|
+
process.env.BIGQUERY_PUBLIC_DATA_PROJECT_ID
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const readPublicDataBigQueryServiceAccountJson = async (): Promise<string> =>
|
|
62
|
+
fs.readFile(process.env.BIGQUERY_PUBLIC_DATA_CREDENTIALS!, "utf-8");
|
|
63
|
+
|
|
48
64
|
describe("connection integration tests", () => {
|
|
49
65
|
const testEnvironmentPath = path.join(
|
|
50
66
|
process.cwd(),
|
|
@@ -672,6 +688,234 @@ describe("connection integration tests", () => {
|
|
|
672
688
|
);
|
|
673
689
|
});
|
|
674
690
|
|
|
691
|
+
describe("BigQuery direct connection (public-data)", () => {
|
|
692
|
+
// Single end-to-end check that a real `bigquery`-typed
|
|
693
|
+
// connection authenticated by the BIGQUERY_PUBLIC_DATA_SA
|
|
694
|
+
// service account can actually round-trip a query against
|
|
695
|
+
// bigquery-public-data. Skips locally if the creds aren't
|
|
696
|
+
// set; runs in CI under .github/workflows/connection-integration-tests.yml.
|
|
697
|
+
//
|
|
698
|
+
// Picked `samples.shakespeare` because (a) it's tiny (~6 MB
|
|
699
|
+
// / ~165k rows) so the BigQuery byte-scanned cost is well
|
|
700
|
+
// under the 1 TB/month free tier even across many PR runs,
|
|
701
|
+
// (b) it's a long-stable public table so the assertion stays
|
|
702
|
+
// valid across years of reruns. If this assertion ever fails
|
|
703
|
+
// the cause is almost certainly auth, not data drift.
|
|
704
|
+
it(
|
|
705
|
+
"should query bigquery-public-data via real BigQuery connection",
|
|
706
|
+
async () => {
|
|
707
|
+
if (!hasPublicDataBigQueryCredentials()) {
|
|
708
|
+
console.log(
|
|
709
|
+
"Skipping: BIGQUERY_PUBLIC_DATA_CREDENTIALS or BIGQUERY_PUBLIC_DATA_PROJECT_ID not configured",
|
|
710
|
+
);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const serviceAccountJson =
|
|
715
|
+
await readPublicDataBigQueryServiceAccountJson();
|
|
716
|
+
|
|
717
|
+
const bqConnection: ApiConnection = {
|
|
718
|
+
name: "bq_public_data",
|
|
719
|
+
type: "bigquery",
|
|
720
|
+
bigqueryConnection: {
|
|
721
|
+
// Billing/auth project (the SA's own project_id);
|
|
722
|
+
// the queried tables live in bigquery-public-data
|
|
723
|
+
// and are referenced explicitly in the SQL below.
|
|
724
|
+
defaultProjectId:
|
|
725
|
+
process.env.BIGQUERY_PUBLIC_DATA_PROJECT_ID!,
|
|
726
|
+
serviceAccountKeyJson: serviceAccountJson,
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const { malloyConnections } = await createEnvironmentConnections(
|
|
731
|
+
[bqConnection],
|
|
732
|
+
testEnvironmentPath,
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const connection = malloyConnections.get("bq_public_data");
|
|
736
|
+
expect(connection).toBeDefined();
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
const result = await connection!.runSQL(
|
|
740
|
+
"SELECT COUNT(*) AS row_count FROM `bigquery-public-data.samples.shakespeare`",
|
|
741
|
+
);
|
|
742
|
+
expect(result.rows.length).toBe(1);
|
|
743
|
+
// Shakespeare has ~165k rows; bound on both sides so
|
|
744
|
+
// we catch both "auth succeeded but query returned
|
|
745
|
+
// nothing" and "got a confusingly large value" failure
|
|
746
|
+
// modes, while staying tolerant of any minor row-count
|
|
747
|
+
// jitter Google might introduce.
|
|
748
|
+
const row = result.rows[0] as Record<string, unknown>;
|
|
749
|
+
const rowCount = Number(row.row_count);
|
|
750
|
+
expect(rowCount).toBeGreaterThan(100_000);
|
|
751
|
+
expect(rowCount).toBeLessThan(200_000);
|
|
752
|
+
} finally {
|
|
753
|
+
// BigQuery driver holds an HTTP/2 client + auth refresh
|
|
754
|
+
// state; close it explicitly so we don't leak across
|
|
755
|
+
// the rest of the test run. (createdConnections is
|
|
756
|
+
// typed for DuckDBConnection, so we can't use the
|
|
757
|
+
// shared cleanup array here.)
|
|
758
|
+
await connection?.close();
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
{ timeout: 60000 },
|
|
762
|
+
);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
describe("BigQuery package end-to-end (bq-hackernews)", () => {
|
|
766
|
+
// Step 2 of Sagar's bun-setup-fixes ask: not just "does the
|
|
767
|
+
// BigQuery driver round-trip a SQL query" (the previous
|
|
768
|
+
// describe block covers that), but "does the publisher's
|
|
769
|
+
// package-loading path successfully use the BigQuery
|
|
770
|
+
// connection on behalf of a Malloy package."
|
|
771
|
+
//
|
|
772
|
+
// Mechanism: stand up a real EnvironmentStore against a
|
|
773
|
+
// temp publisher.config.json that declares both the BQ
|
|
774
|
+
// connection (with the BIGQUERY_PUBLIC_DATA_SA injected via
|
|
775
|
+
// serviceAccountKeyJson) AND the bq-hackernews package
|
|
776
|
+
// (loaded from credibledata/malloy-samples on GitHub).
|
|
777
|
+
// EnvironmentStore.initialize then: clones the malloy-samples
|
|
778
|
+
// repo, extracts the bigquery-hackernews subdirectory, parses
|
|
779
|
+
// the package's Malloy models, and — crucially — introspects
|
|
780
|
+
// their BigQuery table schemas during model compilation. A
|
|
781
|
+
// successful Package.listModels() call therefore proves the
|
|
782
|
+
// entire package-uses-connection path, not just the bare
|
|
783
|
+
// driver auth.
|
|
784
|
+
//
|
|
785
|
+
// Cost: ~50 MB git clone (one time per test run since
|
|
786
|
+
// publisher_data lives under testEnvironmentPath which the
|
|
787
|
+
// afterEach cleans up) + ~6 MB BQ schema scan. Both well
|
|
788
|
+
// under any meaningful free-tier limit. Test budget: 3 min
|
|
789
|
+
// (clone is ~30-60s on GH runners; compile is ~10s).
|
|
790
|
+
it(
|
|
791
|
+
"should load bq-hackernews package and compile its models via real BQ",
|
|
792
|
+
async () => {
|
|
793
|
+
if (!hasPublicDataBigQueryCredentials()) {
|
|
794
|
+
console.log(
|
|
795
|
+
"Skipping: BIGQUERY_PUBLIC_DATA_CREDENTIALS or BIGQUERY_PUBLIC_DATA_PROJECT_ID not configured",
|
|
796
|
+
);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const serviceAccountJson =
|
|
801
|
+
await readPublicDataBigQueryServiceAccountJson();
|
|
802
|
+
|
|
803
|
+
// Each test gets its own serverRoot so publisher_data
|
|
804
|
+
// doesn't leak across tests; afterEach removes
|
|
805
|
+
// testEnvironmentPath which carries the whole tree
|
|
806
|
+
// away.
|
|
807
|
+
const tempServerRoot = path.join(
|
|
808
|
+
testEnvironmentPath,
|
|
809
|
+
"bq-hackernews-pkg-test",
|
|
810
|
+
);
|
|
811
|
+
await fs.mkdir(tempServerRoot, { recursive: true });
|
|
812
|
+
|
|
813
|
+
const config = {
|
|
814
|
+
frozenConfig: false,
|
|
815
|
+
environments: [
|
|
816
|
+
{
|
|
817
|
+
name: "malloy-samples",
|
|
818
|
+
packages: [
|
|
819
|
+
{
|
|
820
|
+
name: "bigquery-hackernews",
|
|
821
|
+
location:
|
|
822
|
+
"https://github.com/credibledata/malloy-samples/tree/main/bigquery-hackernews",
|
|
823
|
+
},
|
|
824
|
+
],
|
|
825
|
+
connections: [
|
|
826
|
+
{
|
|
827
|
+
name: "bigquery",
|
|
828
|
+
type: "bigquery",
|
|
829
|
+
bigqueryConnection: {
|
|
830
|
+
defaultProjectId:
|
|
831
|
+
process.env
|
|
832
|
+
.BIGQUERY_PUBLIC_DATA_PROJECT_ID!,
|
|
833
|
+
serviceAccountKeyJson: serviceAccountJson,
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
],
|
|
837
|
+
},
|
|
838
|
+
],
|
|
839
|
+
};
|
|
840
|
+
await fs.writeFile(
|
|
841
|
+
path.join(tempServerRoot, "publisher.config.json"),
|
|
842
|
+
JSON.stringify(config),
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
// Force the load-from-config init path. Without this,
|
|
846
|
+
// EnvironmentStore.initialize() takes the load-from-DB
|
|
847
|
+
// branch and only falls back to config when the DB is
|
|
848
|
+
// empty (which it is here, by construction, but relying
|
|
849
|
+
// on that is brittle if the test pattern is reused).
|
|
850
|
+
//
|
|
851
|
+
// SAFETY PRECONDITION: this flag is the SAME one users
|
|
852
|
+
// opt into via `--init` / `start:init` to wipe persisted
|
|
853
|
+
// storage and re-initialize from config (see CLAUDE.md
|
|
854
|
+
// and `environment_store.ts:158`). It is destructive on
|
|
855
|
+
// a real serverRoot. We only set it here because
|
|
856
|
+
// `tempServerRoot` is a freshly-created empty directory
|
|
857
|
+
// (path.join(testEnvironmentPath, "bq-hackernews-pkg-test")
|
|
858
|
+
// mkdir'd ~20 lines above), so there is nothing to wipe.
|
|
859
|
+
// DO NOT copy this pattern into a test that points
|
|
860
|
+
// EnvironmentStore at a non-empty or shared serverRoot
|
|
861
|
+
// — it will delete state.
|
|
862
|
+
const previousInitializeStorage = process.env.INITIALIZE_STORAGE;
|
|
863
|
+
process.env.INITIALIZE_STORAGE = "true";
|
|
864
|
+
|
|
865
|
+
try {
|
|
866
|
+
const envStore = new EnvironmentStore(tempServerRoot);
|
|
867
|
+
await envStore.finishedInitialization;
|
|
868
|
+
|
|
869
|
+
// operationalState=serving is the only signal that
|
|
870
|
+
// initialize() actually succeeded. initialize swallows
|
|
871
|
+
// top-level errors and just calls markNotReady() (see
|
|
872
|
+
// environment_store.ts:297-301), so
|
|
873
|
+
// finishedInitialization always resolves regardless of
|
|
874
|
+
// success. By construction (env_store:288-292), serving
|
|
875
|
+
// also implies all configured environments loaded.
|
|
876
|
+
const status = await envStore.getStatus();
|
|
877
|
+
expect(status.operationalState).toBe("serving");
|
|
878
|
+
|
|
879
|
+
const env = await envStore.getEnvironment("malloy-samples");
|
|
880
|
+
const apiPackages = await env.listPackages();
|
|
881
|
+
expect(apiPackages.map((p) => p.name)).toContain(
|
|
882
|
+
"bigquery-hackernews",
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
const apiConnections = env.listApiConnections();
|
|
886
|
+
expect(apiConnections.map((c) => c.name)).toContain(
|
|
887
|
+
"bigquery",
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
// The actual integration assertion. Package.listModels()
|
|
891
|
+
// collects compile errors per-model and returns ALL
|
|
892
|
+
// models in the result (with `error` populated on
|
|
893
|
+
// failures) — so models.length>0 alone would pass even
|
|
894
|
+
// if every BQ schema introspection failed. Filter for
|
|
895
|
+
// models that compiled cleanly: at least one is the
|
|
896
|
+
// real proof that BQ-on-behalf-of-a-package works.
|
|
897
|
+
const pkg = await env.getPackage("bigquery-hackernews");
|
|
898
|
+
const models = await pkg.listModels();
|
|
899
|
+
const okModels = models.filter((m) => !m.error);
|
|
900
|
+
if (okModels.length === 0) {
|
|
901
|
+
console.error(
|
|
902
|
+
"All bq-hackernews model compilations failed:",
|
|
903
|
+
models.map((m) => `${m.path}: ${m.error}`),
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
expect(okModels.length).toBeGreaterThan(0);
|
|
907
|
+
} finally {
|
|
908
|
+
if (previousInitializeStorage === undefined) {
|
|
909
|
+
delete process.env.INITIALIZE_STORAGE;
|
|
910
|
+
} else {
|
|
911
|
+
process.env.INITIALIZE_STORAGE = previousInitializeStorage;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
},
|
|
915
|
+
{ timeout: 180000 },
|
|
916
|
+
);
|
|
917
|
+
});
|
|
918
|
+
|
|
675
919
|
describe("DuckDB with Snowflake attachment", () => {
|
|
676
920
|
it(
|
|
677
921
|
"should create DuckDB connection with attached Snowflake database",
|