@malloy-publisher/server 0.0.200 → 0.0.202

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 (28) hide show
  1. package/dist/app/api-doc.yaml +34 -7
  2. package/dist/app/assets/{EnvironmentPage-CgKNjySu.js → EnvironmentPage-CNQYDaxR.js} +1 -1
  3. package/dist/app/assets/HomePage-DBFTIoD8.js +1 -0
  4. package/dist/app/assets/MainPage-B0kNpkxT.js +2 -0
  5. package/dist/app/assets/{ModelPage-C0Uevsw9.js → ModelPage-DcVElc9L.js} +1 -1
  6. package/dist/app/assets/{PackagePage-Cu-u9k1g.js → PackagePage-yAh0TrOV.js} +1 -1
  7. package/dist/app/assets/{RouteError-DVwPh2Ql.js → RouteError-DknUbx_s.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-DW38R2Zv.js → WorkbookPage-CCqc8otA.js} +1 -1
  9. package/dist/app/assets/{core-C0vCMRDQ.es-D_ytHhjS.js → core-B3A61KGJ.es-iOUZ6RJL.js} +10 -10
  10. package/dist/app/assets/index-F_o127LC.js +454 -0
  11. package/dist/app/assets/{index-BGdcKsFF.js → index-QeX_e740.js} +1 -1
  12. package/dist/app/assets/{index-CTx4v4_3.js → index-W0bOLKGl.js} +1 -1
  13. package/dist/app/assets/{index.umd-C1Mi1uRm.js → index.umd-CEDRw4TK.js} +1 -1
  14. package/dist/app/index.html +1 -1
  15. package/dist/server.mjs +74 -8
  16. package/package.json +1 -1
  17. package/publisher.config.json +4 -0
  18. package/src/filter_deprecation.spec.ts +64 -0
  19. package/src/filter_deprecation.ts +42 -0
  20. package/src/server.ts +32 -25
  21. package/src/service/connection.spec.ts +244 -0
  22. package/src/service/connection.ts +6 -2
  23. package/src/service/environment.ts +42 -2
  24. package/src/service/filter_integration.spec.ts +69 -0
  25. package/src/service/model.ts +15 -2
  26. package/dist/app/assets/HomePage-BPIpMBjW.js +0 -1
  27. package/dist/app/assets/MainPage-CAwb8U82.js +0 -2
  28. package/dist/app/assets/index-DE6d5jEy.js +0 -452
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(`Existing databases:`, rows);
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
- apiConnections: malloyConfig.apiConnections
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
- res.status(200).json(await modelController.executeNotebookCell(req.params.environmentName, req.params.packageName, notebookPath, cellIndex, filterParams, bypassFilters, givens));
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
- res.status(200).json(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));
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.200",
4
+ "version": "0.0.202",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -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
- res.status(200).json(
1102
- await modelController.executeNotebookCell(
1103
- req.params.environmentName,
1104
- req.params.packageName,
1105
- notebookPath,
1106
- cellIndex,
1107
- filterParams,
1108
- bypassFilters,
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
- res.status(200).json(
1159
- await queryController.getQuery(
1160
- req.params.environmentName,
1161
- req.params.packageName,
1162
- modelPath,
1163
- req.body.sourceName as string,
1164
- req.body.queryName as string,
1165
- req.body.query as string,
1166
- req.body.compactJson === true,
1167
- (req.body.filterParams ?? req.body.sourceFilters) as
1168
- | Record<string, string | string[]>
1169
- | undefined,
1170
- req.body.bypassFilters === true ? true : undefined,
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",