@fabricorg/databricks-testkit 0.1.0 → 0.2.0

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/index.cjs CHANGED
@@ -500,7 +500,7 @@ function looselyEqual(a, b) {
500
500
 
501
501
  // src/mock-client.ts
502
502
  function createMockDatabricksClient() {
503
- const queues = { GET: [], POST: [] };
503
+ const queues = { GET: [], POST: [], DELETE: [] };
504
504
  const calls = [];
505
505
  function take(method, path) {
506
506
  const index = queues[method].findIndex((q) => path.startsWith(q.pathPrefix));
@@ -524,6 +524,12 @@ function createMockDatabricksClient() {
524
524
  if (queued.error) throw queued.error;
525
525
  return queued.response;
526
526
  },
527
+ async request(method, path, body, query) {
528
+ calls.push({ method, path, body, query });
529
+ const queued = take(method, path);
530
+ if (queued.error) throw queued.error;
531
+ return queued.response;
532
+ },
527
533
  queue(method, pathPrefix, response) {
528
534
  queues[method].push({ pathPrefix, response });
529
535
  return client;
@@ -692,6 +698,26 @@ function sqlRoundTripCheck() {
692
698
  }
693
699
  };
694
700
  }
701
+ function restrictedPrincipalCheck() {
702
+ return {
703
+ id: "restricted-principal",
704
+ description: "Live checks use the designated least-privilege service principal",
705
+ configured: (env) => Boolean(env.DBX_TEST_RESTRICTED_PRINCIPAL_ID),
706
+ async run({ env }) {
707
+ const expected = env.DBX_TEST_RESTRICTED_PRINCIPAL_ID?.toLowerCase();
708
+ const actual = env.DATABRICKS_CLIENT_ID?.toLowerCase();
709
+ if (!expected || !actual) {
710
+ throw new Error(
711
+ "Restricted-principal check requires DBX_TEST_RESTRICTED_PRINCIPAL_ID and DATABRICKS_CLIENT_ID"
712
+ );
713
+ }
714
+ if (actual !== expected) {
715
+ throw new Error(`Live suite is using principal '${actual}', expected '${expected}'`);
716
+ }
717
+ return { principalId: actual };
718
+ }
719
+ };
720
+ }
695
721
  function assertDeltaContract(table, rows, columns) {
696
722
  for (const expected of columns) {
697
723
  const actual = rows.find((row) => String(row.col_name ?? row.column_name) === expected.name);
@@ -783,17 +809,21 @@ function notebookSubmitCheck() {
783
809
  return {
784
810
  id: "notebook",
785
811
  description: "Databricks notebook submit run reaches SUCCESS",
786
- configured: (env) => Boolean(env.DBX_TEST_NOTEBOOK_PATH && env.DBX_TEST_CLUSTER_ID),
812
+ configured: (env) => Boolean(
813
+ env.DBX_TEST_NOTEBOOK_PATH && (env.DBX_TEST_CLUSTER_ID || env.DBX_TEST_NOTEBOOK_SERVERLESS === "1")
814
+ ),
787
815
  async run({ env, client }) {
788
- if (!env.DBX_TEST_NOTEBOOK_PATH || !env.DBX_TEST_CLUSTER_ID) {
789
- throw new Error("Notebook submit requires DBX_TEST_NOTEBOOK_PATH and DBX_TEST_CLUSTER_ID");
816
+ if (!env.DBX_TEST_NOTEBOOK_PATH || !env.DBX_TEST_CLUSTER_ID && env.DBX_TEST_NOTEBOOK_SERVERLESS !== "1") {
817
+ throw new Error(
818
+ "Notebook submit requires DBX_TEST_NOTEBOOK_PATH and either DBX_TEST_CLUSTER_ID or DBX_TEST_NOTEBOOK_SERVERLESS=1"
819
+ );
790
820
  }
791
821
  const submitted = await client.post("/api/2.1/jobs/runs/submit", {
792
822
  run_name: `fabric-experiments-notebook-${Date.now()}`,
793
823
  tasks: [
794
824
  {
795
825
  task_key: "notebook",
796
- existing_cluster_id: env.DBX_TEST_CLUSTER_ID,
826
+ ...env.DBX_TEST_CLUSTER_ID ? { existing_cluster_id: env.DBX_TEST_CLUSTER_ID } : {},
797
827
  notebook_task: { notebook_path: env.DBX_TEST_NOTEBOOK_PATH }
798
828
  }
799
829
  ]
@@ -813,29 +843,59 @@ function dbtBuildCheck() {
813
843
  return {
814
844
  id: "dbt",
815
845
  description: "Databricks dbt task reaches SUCCESS",
816
- configured: (env) => Boolean(env.DBX_TEST_DBT_GIT_URL && env.DATABRICKS_WAREHOUSE_ID),
846
+ configured: (env) => Boolean(
847
+ (env.DBX_TEST_DBT_GIT_URL || env.DBX_TEST_DBT_PROJECT_DIR) && env.DATABRICKS_WAREHOUSE_ID
848
+ ),
817
849
  async run({ env, client }) {
818
- if (!env.DBX_TEST_DBT_GIT_URL || !env.DATABRICKS_WAREHOUSE_ID) {
819
- throw new Error("dbt build requires DBX_TEST_DBT_GIT_URL and DATABRICKS_WAREHOUSE_ID");
850
+ if (!env.DBX_TEST_DBT_GIT_URL && !env.DBX_TEST_DBT_PROJECT_DIR || !env.DATABRICKS_WAREHOUSE_ID) {
851
+ throw new Error(
852
+ "dbt build requires DBX_TEST_DBT_GIT_URL or DBX_TEST_DBT_PROJECT_DIR, plus DATABRICKS_WAREHOUSE_ID"
853
+ );
820
854
  }
821
- const submitted = await client.post("/api/2.1/jobs/runs/submit", {
855
+ const sourceVars = JSON.stringify({
856
+ source_catalog: env.DBX_TEST_DBT_SOURCE_CATALOG ?? env.DBX_TEST_CATALOG,
857
+ source_schema: env.DBX_TEST_DBT_SOURCE_SCHEMA
858
+ });
859
+ const serverless = env.DBX_TEST_DBT_SERVERLESS === "1";
860
+ const request = {
822
861
  run_name: `fabric-experiments-dbt-${Date.now()}`,
823
- git_source: {
824
- git_url: env.DBX_TEST_DBT_GIT_URL,
825
- git_provider: env.DBX_TEST_DBT_GIT_PROVIDER ?? "gitHub",
826
- git_branch: env.DBX_TEST_DBT_GIT_BRANCH ?? "main"
827
- },
862
+ ...env.DBX_TEST_DBT_GIT_URL ? {
863
+ git_source: {
864
+ git_url: env.DBX_TEST_DBT_GIT_URL,
865
+ git_provider: env.DBX_TEST_DBT_GIT_PROVIDER ?? "gitHub",
866
+ git_branch: env.DBX_TEST_DBT_GIT_BRANCH ?? "main"
867
+ }
868
+ } : {},
828
869
  tasks: [
829
870
  {
830
871
  task_key: "dbt_build",
872
+ ...serverless ? { environment_key: "dbt" } : {},
831
873
  dbt_task: {
832
- commands: ["dbt deps", "dbt build"],
874
+ commands: ["dbt deps", `dbt build --vars '${sourceVars}'`],
833
875
  warehouse_id: env.DATABRICKS_WAREHOUSE_ID,
876
+ ...env.DBX_TEST_CATALOG ? { catalog: env.DBX_TEST_CATALOG } : {},
877
+ ...env.DBX_TEST_SCHEMA ? { schema: env.DBX_TEST_SCHEMA } : {},
878
+ ...env.DBX_TEST_DBT_GIT_URL ? {} : { source: "WORKSPACE" },
834
879
  ...env.DBX_TEST_DBT_PROJECT_DIR ? { project_directory: env.DBX_TEST_DBT_PROJECT_DIR } : {}
835
880
  }
836
881
  }
837
- ]
838
- });
882
+ ],
883
+ ...serverless ? {
884
+ environments: [
885
+ {
886
+ environment_key: "dbt",
887
+ spec: {
888
+ environment_version: "2",
889
+ dependencies: ["dbt-databricks>=1.8,<2"]
890
+ }
891
+ }
892
+ ]
893
+ } : {}
894
+ };
895
+ const submitted = await client.post(
896
+ "/api/2.1/jobs/runs/submit",
897
+ request
898
+ );
839
899
  if (!submitted.run_id) throw new Error("dbt submit returned no run_id");
840
900
  const run = await databricks.waitForDatabricksRun(client, submitted.run_id, {
841
901
  timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
@@ -843,7 +903,10 @@ function dbtBuildCheck() {
843
903
  });
844
904
  const result = run.state?.result_state;
845
905
  if (result !== "SUCCESS") throw new Error(`dbt run ended in ${result ?? "UNKNOWN"}`);
846
- return { runId: submitted.run_id, gitUrl: env.DBX_TEST_DBT_GIT_URL };
906
+ return {
907
+ runId: submitted.run_id,
908
+ source: env.DBX_TEST_DBT_GIT_URL ?? env.DBX_TEST_DBT_PROJECT_DIR
909
+ };
847
910
  }
848
911
  };
849
912
  }
@@ -922,6 +985,99 @@ function lakebaseRoundTripCheck(options = {}) {
922
985
  };
923
986
  }
924
987
 
988
+ // src/lakebase-branch.ts
989
+ init_workspace_context();
990
+ var DEFAULT_POLL_INTERVAL_MS = 2e3;
991
+ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
992
+ function resolveLakebaseProject(env, explicit) {
993
+ const project = explicit ?? env.DBX_TEST_LAKEBASE_PROJECT;
994
+ if (!project || !/^projects\/[a-z0-9][a-z0-9-]{0,62}$/.test(project)) {
995
+ throw new Error(
996
+ `Lakebase project must look like projects/fabric-experiments; got '${project ?? ""}'`
997
+ );
998
+ }
999
+ return project;
1000
+ }
1001
+ function toLakebaseBranchId(environmentName) {
1002
+ const normalized = environmentName.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
1003
+ const prefixed = normalized.startsWith("fx-") ? normalized : `fx-${normalized}`;
1004
+ const trimmed = prefixed.slice(0, 63).replace(/-+$/g, "");
1005
+ if (!/^fx-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(trimmed)) {
1006
+ throw new Error(`Cannot derive a safe Lakebase branch ID from '${environmentName}'`);
1007
+ }
1008
+ return trimmed;
1009
+ }
1010
+ async function createEphemeralLakebaseBranch(options) {
1011
+ const env = options.env ?? process.env;
1012
+ const client = options.client ?? createClientFromEnv(env);
1013
+ const project = resolveLakebaseProject(env, options.project);
1014
+ const branchId = toLakebaseBranchId(options.branchId);
1015
+ const ttlSeconds = Math.max(60, Math.ceil(options.ttlMs / 1e3));
1016
+ const operation = await client.post(
1017
+ `/api/2.0/postgres/${project}/branches`,
1018
+ { spec: { ttl: `${ttlSeconds}s` } },
1019
+ { branch_id: branchId, replace_existing: false }
1020
+ );
1021
+ const branch = await waitForOperation(client, operation, options);
1022
+ const branchName = branch?.name ?? `${project}/branches/${branchId}`;
1023
+ const [endpointResult, databaseResult] = await Promise.all([
1024
+ client.get(`/api/2.0/postgres/${branchName}/endpoints`),
1025
+ client.get(`/api/2.0/postgres/${branchName}/databases`)
1026
+ ]);
1027
+ const endpoint = endpointResult.endpoints?.find((item) => item.endpoint_id === "primary") ?? endpointResult.endpoints?.[0];
1028
+ const database = databaseResult.databases?.[0];
1029
+ const host = endpoint?.status?.hosts?.host;
1030
+ const postgresDatabase = database?.status?.postgres_database;
1031
+ if (!endpoint?.name || !host || !database?.name || !postgresDatabase) {
1032
+ throw new Error(`Lakebase branch '${branchName}' did not expose an endpoint and database`);
1033
+ }
1034
+ const createdAt = branch?.create_time ?? (options.now?.() ?? /* @__PURE__ */ new Date()).toISOString();
1035
+ return {
1036
+ project,
1037
+ branch: branchName,
1038
+ branchId,
1039
+ endpoint: endpoint.name,
1040
+ host,
1041
+ databaseResource: database.name,
1042
+ database: postgresDatabase,
1043
+ createdAt,
1044
+ expiresAt: new Date(Date.parse(createdAt) + ttlSeconds * 1e3).toISOString()
1045
+ };
1046
+ }
1047
+ async function deleteEphemeralLakebaseBranch(branch, options = {}) {
1048
+ if (!/^projects\/[a-z0-9-]+\/branches\/fx-[a-z0-9-]+$/.test(branch.branch)) {
1049
+ throw new Error(`Refusing to delete non-ephemeral Lakebase branch '${branch.branch}'`);
1050
+ }
1051
+ const env = options.env ?? process.env;
1052
+ const client = options.client ?? createClientFromEnv(env);
1053
+ const operation = await client.request(
1054
+ "DELETE",
1055
+ `/api/2.0/postgres/${branch.branch}`,
1056
+ void 0,
1057
+ { purge: true }
1058
+ );
1059
+ await waitForOperation(client, operation, options);
1060
+ }
1061
+ async function waitForOperation(client, initial, options) {
1062
+ let operation = initial;
1063
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
1064
+ while (!operation.done) {
1065
+ if (!operation.name) throw new Error("Lakebase operation did not return an operation name");
1066
+ if (Date.now() >= deadline) throw new Error(`Lakebase operation '${operation.name}' timed out`);
1067
+ await delay(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
1068
+ operation = await client.get(`/api/2.0/postgres/${operation.name}`);
1069
+ }
1070
+ if (operation.error) {
1071
+ throw new Error(
1072
+ `Lakebase operation failed${operation.error.code ? ` (${operation.error.code})` : ""}: ${operation.error.message ?? "unknown error"}`
1073
+ );
1074
+ }
1075
+ return operation.response;
1076
+ }
1077
+ function delay(ms) {
1078
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1079
+ }
1080
+
925
1081
  // src/databricks-http.ts
926
1082
  init_workspace_context();
927
1083
  async function databricksFetch(env, pathOrUrl, init = {}, fetchImpl = fetch) {
@@ -1115,6 +1271,7 @@ exports.autoLoaderIngestionCheck = autoLoaderIngestionCheck;
1115
1271
  exports.compareToGolden = compareToGolden;
1116
1272
  exports.createClientFromEnv = createClientFromEnv;
1117
1273
  exports.createDuckDbContext = createDuckDbContext;
1274
+ exports.createEphemeralLakebaseBranch = createEphemeralLakebaseBranch;
1118
1275
  exports.createExecutionContext = createExecutionContext;
1119
1276
  exports.createLakebaseTestProvider = createLakebaseTestProvider;
1120
1277
  exports.createMockDatabricksClient = createMockDatabricksClient;
@@ -1122,6 +1279,7 @@ exports.createWorkspaceContext = createWorkspaceContext;
1122
1279
  exports.customLiveCheck = customLiveCheck;
1123
1280
  exports.dbtBuildCheck = dbtBuildCheck;
1124
1281
  exports.defineLiveSuite = defineLiveSuite;
1282
+ exports.deleteEphemeralLakebaseBranch = deleteEphemeralLakebaseBranch;
1125
1283
  exports.deleteVolumeFile = deleteVolumeFile;
1126
1284
  exports.deltaContractCheck = deltaContractCheck;
1127
1285
  exports.ensureVolumeDirectory = ensureVolumeDirectory;
@@ -1138,11 +1296,14 @@ exports.parseStatementRows = parseStatementRows;
1138
1296
  exports.pipelineRefreshCheck = pipelineRefreshCheck;
1139
1297
  exports.renderJUnit = renderJUnit;
1140
1298
  exports.resolveFixtureRows = resolveFixtureRows;
1299
+ exports.resolveLakebaseProject = resolveLakebaseProject;
1141
1300
  exports.resolveProfile = resolveProfile;
1142
1301
  exports.resolveTokenProvider = resolveTokenProvider;
1302
+ exports.restrictedPrincipalCheck = restrictedPrincipalCheck;
1143
1303
  exports.runLiveSuite = runLiveSuite;
1144
1304
  exports.secretScopeCheck = secretScopeCheck;
1145
1305
  exports.sqlRoundTripCheck = sqlRoundTripCheck;
1306
+ exports.toLakebaseBranchId = toLakebaseBranchId;
1146
1307
  exports.toNamedParameters = toNamedParameters;
1147
1308
  exports.unityCatalogAccessCheck = unityCatalogAccessCheck;
1148
1309
  exports.uploadVolumeFile = uploadVolumeFile;