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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ REDIS_ADDR=redis://redis:6379

# --- cubejs ---
CUBESQL_DEBUG_QTRACE=true
CUBESTORE_VERSION=v1.3.23-arm64v8
CUBESTORE_VERSION=v1.6.37

CUBEJS_URL=http://cubejs:4000
CUBEJS_SECRET=cubejsKey
Expand Down
25 changes: 22 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,32 @@ Docker Compose files per environment: `docker-compose.dev.yml`, `docker-compose.

## Key File Locations

- `services/actions/src/rpc/` — 22 RPC handlers (one file per action)
- `services/actions/src/rpc/` — 25 RPC handlers (one file per action), includes `auditDataschemaDelete`, `auditVersionRollback`, `auditLogsRetention`
- `services/cubejs/src/utils/driverFactory.js` — Database driver creation (25+ drivers)
- `services/cubejs/src/utils/checkAuth.js` — JWT verification and security context setup
- `services/cubejs/src/utils/defineUserScope.js` — Branch/version/access resolution
- `services/cubejs/src/utils/buildSecurityContext.js` — Content-hashed context for cache isolation
- `services/cubejs/src/routes/` — 7 REST API endpoints (run-sql, test, get-schema, generate-models, etc.)
- `services/cubejs/src/utils/compilerCacheInvalidator.js` — Branch-scoped compiler-cache eviction (011-model-mgmt-api)
- `services/cubejs/src/utils/referenceScanner.js` — Seven-kind cross-cube reference detector (FR-008)
- `services/cubejs/src/utils/directVerifyAuth.js` — Shared direct-verify helper for branch-scoped routes
- `services/cubejs/src/utils/metaForBranch.js` — Helper that returns raw visibility-filtered metaConfig
- `services/cubejs/src/utils/auditWriter.js` — Best-effort audit-log writer with retry
- `services/cubejs/src/utils/errorCodes.js` — Canonical Model Management API error codes (FR-017)
- `services/cubejs/src/utils/requireOwnerOrAdmin.js` — Owner/admin team-role check
- `services/cubejs/src/utils/mapHasuraErrorCode.js` — Hasura extensions.code → stable error code
- `services/cubejs/src/utils/versionDiff.js` — Per-cube diff adapter over smart-generation/diffModels
- `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)
- `metaSingleCube.js` (GET /api/v1/meta/cube/:cubeName, US4)
- `versionDiff.js` + `versionRollback.js` (POST /api/v1/version/{diff,rollback}, US5)
- `services/hasura/metadata/actions.yaml` — GraphQL action definitions (maps to Actions RPC)
- `services/hasura/metadata/tables.yaml` — Table definitions, relationships, and permissions
- `services/hasura/migrations/` — 96+ SQL migration directories
- `services/hasura/metadata/cron_triggers.yaml` — Cron triggers (now includes `audit_logs_retention_90d`)
- `services/hasura/migrations/` — 97+ SQL migration directories
- `scripts/lint-error-codes.mjs` — Fails CI when FR-017 error-code enum drifts across contracts
- `tests/workflows/model-management/` — StepCI workflows + SC-003 fixtures for all six endpoints

## Release Process

Expand Down Expand Up @@ -273,6 +290,8 @@ These are the target patterns for adapting Synmetrix and client-v2:
- N/A — no database schema changes. Query results are transient. (009-query-output)
- JavaScript (ES modules), Node.js 22+ + Cube.js v1.6.x (CubeJS service), Express 4.18.2, `openai` npm v6.x (NEW), React 18 + Vite + Ant Design 5 (client-v2), URQL (GraphQL client) (010-dynamic-models-ii)
- PostgreSQL via Hasura (versions, dataschemas), ClickHouse (profiling target — read-only) (010-dynamic-models-ii)
- JavaScript (ES modules), Node.js 22.x (already current in cubejs service after 003-update-deps) + `@cubejs-backend/schema-compiler` ^1.6.19 (existing; `prepareCompiler` powers validation), `@cubejs-backend/server-core` ^1.6.19 (existing; exposes `cubejs.compilerCache` LRU-cache), `@cubejs-backend/api-gateway` ^1.6.19 (existing; `getCompilerApi` + `filterVisibleItemsInMeta`), `jose` (existing; FraiOS/WorkOS JWT verification), Express 4.x (existing router). No new dependencies. (011-model-mgmt-api)
- PostgreSQL via Hasura (existing `dataschemas`, `versions`, `branches` tables — one new Hasura delete-permission migration on `dataschemas`). In-memory LRU compiler cache inside the cubejs process (existing). No new tables. (011-model-mgmt-api)

## Recent Changes
- 001-dev-environment: Added TypeScript (ES2022, Node16 modules) — matches + oclif (CLI framework), zx (shell execution)
161 changes: 161 additions & 0 deletions scripts/lint-error-codes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env node

/**
* lint-error-codes — FR-017 guard.
*
* Fails with a non-zero exit code (and a human-readable diff) whenever the
* `ErrorCode` enum drifts across:
* - services/cubejs/src/utils/errorCodes.js (single source of truth)
* - specs/011-model-mgmt-api/contracts/*.yaml (every OpenAPI contract)
*
* Every contract under `contracts/` must contain a top-level `ErrorCode`
* schema with an explicit `enum` array. Each enum must match the exhaustive
* list of values in `errorCodes.js`, so clients generating bindings from any
* single contract receive the complete enum.
*/

import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");

const ERROR_CODES_JS = join(
repoRoot,
"services/cubejs/src/utils/errorCodes.js"
);
const CONTRACTS_DIR = join(
repoRoot,
"specs/011-model-mgmt-api/contracts"
);

async function readErrorCodesJs() {
const text = await readFile(ERROR_CODES_JS, "utf8");
const re = /["']([a-z][a-z0-9_]+)["']/g;
const known = new Set();
let m;
while ((m = re.exec(text))) {
const v = m[1];
if (
/_(?:invalid|not_found|unresolved|not_visible|unauthorized|by_references|historical_version|authorization|cross_branch|not_on_branch|columns_missing)_?/.test(
v
) ||
/_reference$/.test(v) ||
v === "cube_not_found" ||
v === "validate_invalid_mode" ||
v === "validate_target_not_found" ||
v === "validate_unresolved_reference" ||
v === "refresh_branch_not_visible" ||
v === "refresh_unauthorized" ||
v === "delete_blocked_by_references" ||
v === "delete_blocked_historical_version" ||
v === "delete_blocked_authorization" ||
v === "diff_cross_branch" ||
v === "diff_invalid_request" ||
v === "rollback_version_not_on_branch" ||
v === "rollback_blocked_authorization" ||
v === "rollback_invalid_request" ||
v === "rollback_source_columns_missing"
) {
known.add(v);
}
}
return known;
}

async function readContractEnum(path) {
const text = await readFile(path, "utf8");
// Locate the ErrorCode schema's enum list. Permissive regex: we want every
// value inside the first `enum:` block that follows `ErrorCode:`.
const anchor = text.indexOf("ErrorCode:");
if (anchor === -1) {
throw new Error(`${path} has no ErrorCode schema`);
}
const afterAnchor = text.slice(anchor);
// Find the enum block: `enum:` → dashed list → end when we hit a line that
// starts with non-dash non-whitespace content at the same or lower indent.
const enumAnchor = afterAnchor.search(/\n\s*enum:\s*\n/);
if (enumAnchor === -1) {
throw new Error(`${path} ErrorCode.enum block not found`);
}
const afterEnum = afterAnchor.slice(enumAnchor).split("\n");
// Skip the `enum:` line itself.
const values = [];
let inEnum = false;
for (const raw of afterEnum) {
if (!inEnum) {
if (/^\s*enum:\s*$/.test(raw)) inEnum = true;
continue;
}
const trimmed = raw.trim();
if (trimmed === "") continue;
if (trimmed.startsWith("- ")) {
const v = trimmed.slice(2).replace(/['"]/g, "").trim();
if (v) values.push(v);
continue;
}
// First non-dash non-empty line after the list ends the enum block.
break;
}
return new Set(values);
}

function setDiff(a, b) {
const out = [];
for (const v of a) if (!b.has(v)) out.push(v);
return out;
}

async function main() {
const sourceOfTruth = await readErrorCodesJs();
if (sourceOfTruth.size === 0) {
console.error("lint-error-codes: errorCodes.js produced an empty set");
process.exit(1);
}

const entries = (await readdir(CONTRACTS_DIR)).filter((f) =>
f.endsWith(".yaml")
);
if (entries.length === 0) {
console.error(`lint-error-codes: no contracts found in ${CONTRACTS_DIR}`);
process.exit(1);
}

let failed = false;
for (const entry of entries) {
const path = join(CONTRACTS_DIR, entry);
let contractSet;
try {
contractSet = await readContractEnum(path);
} catch (err) {
console.error(`lint-error-codes: ${entry} → ${err.message}`);
failed = true;
continue;
}
const missing = setDiff(sourceOfTruth, contractSet);
const extra = setDiff(contractSet, sourceOfTruth);
if (missing.length || extra.length) {
failed = true;
console.error(`lint-error-codes: ${entry} drift detected`);
if (missing.length) {
console.error(` MISSING (in errorCodes.js, not in ${entry}):`);
for (const v of missing) console.error(` - ${v}`);
}
if (extra.length) {
console.error(` EXTRA (in ${entry}, not in errorCodes.js):`);
for (const v of extra) console.error(` - ${v}`);
}
}
}

if (failed) process.exit(1);
console.log(
`lint-error-codes: OK (${sourceOfTruth.size} codes, ${entries.length} contracts)`
);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
53 changes: 53 additions & 0 deletions services/actions/src/rpc/__tests__/auditDataschemaDelete.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";

let handler;

describe("auditDataschemaDelete RPC", () => {
let originalError;

beforeEach(async () => {
originalError = console.error;
console.error = () => {};
({ default: handler } = await import("../auditDataschemaDelete.js"));
});

afterEach(() => {
console.error = originalError;
});

it("rejects payloads missing event.data.old.id", async () => {
const res = await handler({}, { event: { data: { old: null } } });
assert.equal(res.ok, false);
assert.match(res.error, /no event.data.old payload/);
});

it("falls through to fetchGraphQL and returns a structured error when HASURA is unreachable", async () => {
const prev = process.env.HASURA_ENDPOINT;
process.env.HASURA_ENDPOINT =
"http://127.0.0.1:1/unreachable-audit-test";
try {
const res = await handler(
{ "x-hasura-user-id": "user-1" },
{
event: {
data: {
old: {
id: "00000000-0000-4000-8000-000000000001",
datasource_id: "00000000-0000-4000-8000-000000000010",
version_id: "00000000-0000-4000-8000-000000000020",
user_id: "user-1",
},
},
session_variables: { "x-hasura-user-id": "user-1" },
},
}
);
assert.equal(res.ok, false);
assert.ok(typeof res.error === "string" && res.error.length > 0);
} finally {
if (prev == null) delete process.env.HASURA_ENDPOINT;
else process.env.HASURA_ENDPOINT = prev;
}
});
});
35 changes: 35 additions & 0 deletions services/actions/src/rpc/__tests__/auditLogsRetention.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";

let handler;

describe("auditLogsRetention RPC", () => {
let originalError;

beforeEach(async () => {
originalError = console.error;
console.error = () => {};
({ default: handler } = await import("../auditLogsRetention.js"));
});

afterEach(() => {
console.error = originalError;
});

it("returns a structured error when Hasura is unreachable", async () => {
const prev = process.env.HASURA_ENDPOINT;
process.env.HASURA_ENDPOINT =
"http://127.0.0.1:1/unreachable-retention-test";
try {
const res = await handler();
assert.ok(res);
assert.ok(
typeof res.error === "string" && res.error.length > 0,
"expected structured error on unreachable Hasura"
);
} finally {
if (prev == null) delete process.env.HASURA_ENDPOINT;
else process.env.HASURA_ENDPOINT = prev;
}
});
});
64 changes: 64 additions & 0 deletions services/actions/src/rpc/__tests__/auditVersionRollback.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";

let handler;

describe("auditVersionRollback RPC", () => {
let originalError;

beforeEach(async () => {
originalError = console.error;
console.error = () => {};
({ default: handler } = await import("../auditVersionRollback.js"));
});

afterEach(() => {
console.error = originalError;
});

it("skips non-rollback inserts", async () => {
const res = await handler(
{},
{
event: {
data: {
new: { id: "v-1", origin: "user" },
},
},
}
);
assert.deepEqual(res, { ok: true, skipped: true });
});

it("processes rollback inserts and reports Hasura errors gracefully", async () => {
const prev = process.env.HASURA_ENDPOINT;
process.env.HASURA_ENDPOINT =
"http://127.0.0.1:1/unreachable-rollback-audit";
try {
const res = await handler(
{},
{
event: {
data: {
new: {
id: "00000000-0000-4000-8000-000000000099",
origin: "rollback",
branch_id: "00000000-0000-4000-8000-000000000050",
user_id: "00000000-0000-4000-8000-000000000005",
checksum: "abc",
},
},
session_variables: {
"x-hasura-user-id": "00000000-0000-4000-8000-000000000005",
},
},
}
);
assert.equal(res.ok, false);
assert.ok(typeof res.error === "string");
} finally {
if (prev == null) delete process.env.HASURA_ENDPOINT;
else process.env.HASURA_ENDPOINT = prev;
}
});
});
Loading
Loading