Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Docker Compose files per environment: `docker-compose.dev.yml`, `docker-compose.
- `services/cubejs/src/routes/` — 13 REST API endpoints, now including:
- `validateInBranch.js` (POST /api/v1/validate-in-branch, US1)
- `refreshCompiler.js` (POST /api/v1/internal/refresh-compiler, US2)
- `deleteDataschema.js` (DELETE /api/v1/dataschema/:id, US3)
- `deleteDataschema.js` (DELETE /api/v1/dataschema/:id, US3) + `updateDataschema.js` (PUT, same path) — share guards in `utils/modelWriteGuards.js`; every server-side version write (these, rollback, createDataSchema) goes through `commitVersionFiles` in dataSourceHelpers
- `metaSingleCube.js` (GET /api/v1/meta/cube/:cubeName, US4)
- `versionDiff.js` + `versionRollback.js` (POST /api/v1/version/{diff,rollback}, US5)
- `services/cubejs/src/routes/reconcileTeam.js` — POST /api/v1/internal/reconcile-team: per-team default-models worker (013 — probe→generate→merge→validate→publish, system-user only)
Expand Down
6 changes: 5 additions & 1 deletion services/actions/src/rpc/auditVersionRollback.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ export default async (session, input) => {
target_id: row.id,
outcome: "success",
error_code: null,
payload: { origin: row.origin, checksum: row.checksum },
payload: {
origin: row.origin,
checksum: row.checksum,
source_version_id: row.source_version_id ?? null,
},
});
const id = res?.data?.insert_audit_logs_one?.id;
return { ok: true, auditLogId: id };
Expand Down
28 changes: 28 additions & 0 deletions services/actions/src/utils/__tests__/cubeCache.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";

describe("cubeCache", () => {
it("sends the admin secret cubejs requires on invalidate-cache", async () => {
process.env.HASURA_GRAPHQL_ADMIN_SECRET = "s3cret";
const calls = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, init) => {
calls.push({ url, init });
return { ok: true };
};
try {
const { invalidateUserCache, invalidateAllUserCaches, invalidateRulesCache } =
await import("../cubeCache.js");
invalidateUserCache("u-1");
invalidateAllUserCaches();
invalidateRulesCache();
} finally {
globalThis.fetch = originalFetch;
}
assert.equal(calls.length, 3);
for (const { url, init } of calls) {
assert.match(url, /\/api\/v1\/internal\/invalidate-cache$/);
assert.equal(init.headers["x-hasura-admin-secret"], "s3cret");
}
});
});
12 changes: 9 additions & 3 deletions services/actions/src/utils/cubeCache.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
const CUBEJS_URL = process.env.CUBEJS_URL || "http://cubejs:4000";

// cubejs rejects /internal/invalidate-cache without the shared admin secret.
const headers = {
"Content-Type": "application/json",
"x-hasura-admin-secret": process.env.HASURA_GRAPHQL_ADMIN_SECRET || "",
};

/**
* Invalidate CubeJS caches after admin mutations.
* Fire-and-forget — never blocks the caller.
*/
export function invalidateUserCache(userId) {
fetch(`${CUBEJS_URL}/api/v1/internal/invalidate-cache`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers,
body: JSON.stringify({ type: "user", userId }),
}).catch(() => {});
}

export function invalidateAllUserCaches() {
fetch(`${CUBEJS_URL}/api/v1/internal/invalidate-cache`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers,
body: JSON.stringify({ type: "user" }),
}).catch(() => {});
}

export function invalidateRulesCache() {
fetch(`${CUBEJS_URL}/api/v1/internal/invalidate-cache`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers,
body: JSON.stringify({ type: "rules" }),
}).catch(() => {});
}
10 changes: 10 additions & 0 deletions services/cubejs/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ FROM node:22.14.0-bullseye@sha256:1a956cf7ae435192dcdd18b8aba7349d649f0947795eac

ARG DATABRICKS_JDBC_URL=https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/jdbc/2.6.32/DatabricksJDBC42-2.6.32.1054.zip

# Debian 11 (bullseye) LTS ended 2026-08-31. deb.debian.org has since purged
# the bullseye-security pool but still serves its index, so any install that
# resolves to a security update (libssl1.1 1.1.1w-0+deb11u8, ...) 404s. Read
# that suite from snapshot.debian.org's frozen copy of its final state (index
# dated 2026-08-31 21:13 UTC); its Valid-Until has passed, hence the flag.
# ponytail: stop-gap on an EOL base with no further security updates; move to
# a bookworm base (OpenSSL 3) to get updates again.
RUN sed -i 's#^deb http://deb.debian.org/debian-security bullseye-security main$#deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20260903T000000Z bullseye-security main#' /etc/apt/sources.list \
&& grep -q '^deb \[check-valid-until=no\] http://snapshot.debian.org/archive/debian-security/' /etc/apt/sources.list

RUN DEBIAN_FRONTEND=noninteractive \
&& apt-get update \
&& apt-get install -y --no-install-recommends rxvt-unicode libssl1.1 \
Expand Down
64 changes: 50 additions & 14 deletions services/cubejs/scripts/patchCubeYamlCompiler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,47 @@ const PATCHED = ` else if (typeof obj === 'string') {
let code = obj;
if (!CubeValidator_1.nonStringFields.has(propertyPath[propertyPath.length - 1])) {`;

const CLICKHOUSE_ORIGINAL = ` templates.types.timestamp = 'DATETIME';
delete templates.types.time;`;

const CLICKHOUSE_PATCHED = ` templates.types.timestamp = 'DATETIME';
// ClickHouse type names are case-sensitive. The base 'STRING' type is
// what Tesseract CASTs multi-column primary keys to for count measures
// without sql, and ClickHouse rejects it ("Unknown data type
// family: STRING").
templates.types.string = 'String';
delete templates.types.time;`;

const occurrences = (source, value) => source.split(value).length - 1;

export function patchCompilerSource(source) {
if (source.includes(PATCHED)) return { source, changed: false };
if (occurrences(source, ORIGINAL) !== 1) {
function applyPatch(source, original, patched, label) {
if (source.includes(patched)) return { source, changed: false };
if (occurrences(source, original) !== 1) {
throw new Error(
"Refusing to patch Cube YAML compiler: expected source anchor was not found exactly once",
`Refusing to patch Cube ${label}: expected source anchor was not found exactly once`,
);
}
return { source: source.replace(ORIGINAL, PATCHED), changed: true };
return { source: source.replace(original, patched), changed: true };
}

export function patchCompilerSource(source) {
return applyPatch(source, ORIGINAL, PATCHED, "YAML compiler");
}

export function patchClickHouseQuerySource(source) {
return applyPatch(
source,
CLICKHOUSE_ORIGINAL,
CLICKHOUSE_PATCHED,
"ClickHouse query adapter",
);
}

async function patchFile(path, patch) {
const current = await readFile(path, "utf8");
const result = patch(current);
if (result.changed) await writeFile(path, result.source, "utf8");
return result.changed;
}

export async function patchInstalledCompiler() {
Expand All @@ -43,14 +74,19 @@ export async function patchInstalledCompiler() {
);
}

const compilerPath = resolve(
dirname(packageJsonPath),
"dist/src/compiler/YamlCompiler.js",
const root = dirname(packageJsonPath);
const compilerPath = resolve(root, "dist/src/compiler/YamlCompiler.js");
const clickHousePath = resolve(root, "dist/src/adapter/ClickHouseQuery.js");
const yamlChanged = await patchFile(compilerPath, patchCompilerSource);
const clickHouseChanged = await patchFile(
clickHousePath,
patchClickHouseQuerySource,
);
const current = await readFile(compilerPath, "utf8");
const result = patchCompilerSource(current);
if (result.changed) await writeFile(compilerPath, result.source, "utf8");
return { compilerPath, changed: result.changed };
return {
compilerPath,
clickHousePath,
changed: yamlChanged || clickHouseChanged,
};
}

if (
Expand All @@ -60,7 +96,7 @@ if (
const result = await patchInstalledCompiler();
console.log(
result.changed
? `Patched Cube ${SUPPORTED_VERSION} YAML metadata handling`
: `Cube ${SUPPORTED_VERSION} YAML metadata patch already applied`,
? `Patched Cube ${SUPPORTED_VERSION} YAML metadata handling + ClickHouse string type`
: `Cube ${SUPPORTED_VERSION} YAML metadata + ClickHouse string type patches already applied`,
);
}
58 changes: 57 additions & 1 deletion services/cubejs/src/__tests__/cube17Regression.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { prepareCompiler } from "@cubejs-backend/schema-compiler";

import { escapeCSVField } from "../utils/csvSerializer.js";
import { validateFormat } from "../utils/formatValidator.js";
import { patchCompilerSource } from "../../scripts/patchCubeYamlCompiler.mjs";
import {
patchClickHouseQuerySource,
patchCompilerSource,
} from "../../scripts/patchCubeYamlCompiler.mjs";

const require = createRequire(import.meta.url);
const runtimeVersion = require("@cubejs-backend/server-core/package.json").version;
Expand Down Expand Up @@ -102,6 +105,59 @@ after`;
);
});

it("guards the ClickHouse string-type patch against upstream source drift", () => {
const source = `before
templates.types.timestamp = 'DATETIME';
delete templates.types.time;
after`;
const first = patchClickHouseQuerySource(source);
assert.equal(first.changed, true);
assert.match(first.source, /templates\.types\.string = 'String';/);
assert.deepEqual(patchClickHouseQuerySource(first.source), {
source: first.source,
changed: false,
});
assert.throws(
() => patchClickHouseQuerySource("unexpected adapter source"),
/expected source anchor was not found exactly once/,
);
});

it("casts composite-key count measures to ClickHouse String (Tesseract)", async () => {
const { ClickHouseQuery } = require(
"@cubejs-backend/schema-compiler/dist/src/adapter/ClickHouseQuery.js",
);
const content = `cubes:
- name: points
sql_table: points
dimensions:
- name: series_gid
sql: series_gid
type: string
primary_key: true
- name: ts
sql: ts
type: time
primary_key: true
measures:
- name: count
type: count
`;
const compilers = prepareCompiler(
{ dataSchemaFiles: async () => [{ fileName: "points.yml", content }] },
{ adapter: "clickhouse" },
);
await compilers.compiler.compile();
const [sql] = new ClickHouseQuery(compilers, {
measures: ["points.count"],
timezone: "UTC",
useNativeSqlPlanner: true,
}).buildSqlAndParams();

assert.match(sql, / AS String\)/);
assert.doesNotMatch(sql, / AS STRING\)/);
});

it("does not embed a comparison result in the test source", async () => {
const source = await readFile(new URL(import.meta.url), "utf8");
assert.doesNotMatch(source, /pass(?:ed)?\s*[:=]\s*(?:true|yes)/i);
Expand Down
Loading
Loading