CI (SIAX Cloud) / security (push) Successful in 41s
Security Scan (SIAX Cloud) / security (push) Successful in 40s
CI (SIAX Cloud) / integration (push) Successful in 52s
CI (SIAX Cloud) / quality (push) Successful in 1m5s
CI (SIAX Cloud) / security (pull_request) Successful in 31s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 29s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
CI (SIAX Cloud) / quality (pull_request) Successful in 53s
Implements the eight BI program prompts that make 0pt the canonical SIAX
analytics capability. Every file is NEW: not one existing file is modified,
and src/analytics/clickhouse-analytics.ts is byte-identical to main.
BI-P002 — the nineteen canonical entities, verbatim, with tenant_id +
project_id on every tenant-owned record. sql/008 gives each one a table.
The four version entities BI-P002 names as immutable are enforced by BEFORE
UPDATE/DELETE triggers, not by a TypeScript `readonly` — proven by direct SQL
against a live Postgres.
BI-P003 — RAW/NORMALIZED/CURATED/SERVING with the read rule enforced:
assertReadableByBiSurface refuses a RAW dataset to a dashboard unless a
recorded exception exists. All eight required declarations are NOT NULL.
BI-P004 — DatasetContract and a fail-closed compatibility gate. Fifteen
breaking-change classes are each tested twice: refused under a minor bump,
accepted under a major one.
BI-P005 — Arrow IPC alongside JSON, with a hand-written FlatBuffers writer so
no npm dependency (or CVE surface) is added. Conformance comes from three
independent implementations, not from self-agreement: pyarrow 25.0.1 and
DuckDB 1.5.5 read identical values from both ClickHouse's Arrow and ours, and
ClickHouse's own Arrow C++ ingests our 20,000-row batch with exact aggregates.
BI-P009 — SiaxAnalyticsEngine with all eight capabilities. The existing
ClickHouseAnalytics is COMPOSED, never replaced. executeQuery (analyst SQL) is
declared and deliberately refused with CAPABILITY_UNAVAILABLE until BI-P017 and
BI-P033 land, rather than opening the raw-SQL hole BI-P011 enumerates.
BI-P010 — four purpose-built tables, not one giant one, and the difference is
measured against the counterfactual: 48.8ms/40,960 rows vs 174.4ms/5,400,000
rows for the same question; 5/660 granules vs 660/1149.
BI-P011 — layered isolation, each layer proven with the layer above removed.
Without the forced predicate the same statement returns 50,050 (both tenants);
with it, t_alpha gets 50. Without a row policy an unrestricted user sees 2
tenants; the restricted user running the IDENTICAL statement sees 1.
BI-P012 — every named limit, fired individually and identified by ClickHouse
error code, plus the half usually skipped: an unrelated tenant is timed while
a real CPU bomb is in flight and must still return the CORRECT answer.
Three defects found by writing tests that can fail:
* `CHECK (array_length(purposes,1) >= 1)` can NEVER fail — array_length is
NULL for '{}' and a NULL CHECK passes. Fixed with coalesce(); the test
that caught it is pinned.
* EXPLAIN ESTIMATE run under the caller's own max_rows_to_read fails with
code 158 exactly when the estimate would be over budget. Planner calls now
use a narrower envelope.
* result_overflow_mode=break returns HTTP 200 with 65,409 of 100,000 rows
and no indication it is short. Every SELECT ships `throw`.
Verified rather than assumed: server7's ClickHouse holds `opt_scraped` (51
tables, 407,793,471 rows, `persons`/`cz_owners` included) — the b0ts plane this
module is prohibited from touching — has no `opt_analytics` database, zero
tenant_id columns and zero row policies. Nothing there was modified; every
proof ran against disposable containers.
Evidence: evidence/bi-p002-p012/{test-transcript.txt,bi-p010-benchmark.json,
bi-p005-arrow-roundtrip.json,server7-clickhouse-probe.md}
Docs: docs/analytics/BI_ANALYTICS_CORE.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
184 lines
8.2 KiB
TypeScript
184 lines
8.2 KiB
TypeScript
/**
|
|
* BI-P005 conformance against an INDEPENDENT Arrow implementation.
|
|
*
|
|
* The unit suite (tests/analytics/arrow-ipc.test.ts) only proves 0pt's
|
|
* encoder agrees with 0pt's decoder — which it would even if both shared the
|
|
* same misreading of the spec. Here, ClickHouse's Arrow C++ implementation is
|
|
* the second opinion, in BOTH directions:
|
|
*
|
|
* ClickHouse -> Arrow -> 0pt decoder (their encoder, our reader)
|
|
* 0pt encoder -> Arrow -> ClickHouse (our encoder, their reader)
|
|
*
|
|
* The second direction is the load-bearing one: ClickHouse INSERTing our
|
|
* bytes and then answering a SELECT with the exact values back is a claim
|
|
* that cannot be satisfied by a subtly wrong buffer layout.
|
|
*
|
|
* The DoD's literal chain (ClickHouse -> Arrow -> DuckDB/Python) additionally
|
|
* ran against real pyarrow + DuckDB; that transcript is committed at
|
|
* evidence/bi-p002-p012/bi-p005-arrow-roundtrip.json.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import {
|
|
arrowStreamSettings,
|
|
clickhouseArrowStreamFormat,
|
|
decodeArrowStream,
|
|
encodeArrowStream,
|
|
} from "@/src/analytics/arrow/ipc";
|
|
|
|
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
|
const CLICKHOUSE_AUTH = process.env.CLICKHOUSE_AUTH;
|
|
const run = CLICKHOUSE_URL ? describe : describe.skip;
|
|
|
|
const DB = `opt_arrow_${Date.now().toString(36)}`;
|
|
|
|
function authHeaders(): Record<string, string> {
|
|
return CLICKHOUSE_AUTH
|
|
? { Authorization: `Basic ${Buffer.from(CLICKHOUSE_AUTH).toString("base64")}` }
|
|
: {};
|
|
}
|
|
|
|
async function chBytes(sql: string, settings: Record<string, string> = {}): Promise<Uint8Array> {
|
|
const url = new URL(CLICKHOUSE_URL ?? "http://skipped.invalid");
|
|
url.searchParams.set("query", sql);
|
|
for (const [k, v] of Object.entries(settings)) url.searchParams.set(k, v);
|
|
const res = await fetch(url.toString(), { method: "POST", headers: authHeaders(), body: "" });
|
|
const buf = new Uint8Array(await res.arrayBuffer());
|
|
if (!res.ok) throw new Error(`CH ${res.status}: ${new TextDecoder().decode(buf).slice(0, 300)}`);
|
|
return buf;
|
|
}
|
|
|
|
async function chText(sql: string, body?: BodyInit): Promise<string> {
|
|
const url = new URL(CLICKHOUSE_URL ?? "http://skipped.invalid");
|
|
url.searchParams.set("query", sql);
|
|
const res = await fetch(url.toString(), { method: "POST", headers: authHeaders(), body: body ?? "" });
|
|
const text = await res.text();
|
|
if (!res.ok) throw new Error(`CH ${res.status}: ${text.slice(0, 400)}`);
|
|
return text;
|
|
}
|
|
|
|
run("BI-P005 Arrow IPC against a LIVE ClickHouse", () => {
|
|
beforeAll(async () => {
|
|
await chText(`CREATE DATABASE IF NOT EXISTS ${DB}`);
|
|
await chText(
|
|
`CREATE TABLE IF NOT EXISTS ${DB}.arrow_roundtrip (
|
|
tenant_id String, n Int64, v Float64, flag Bool, ts DateTime64(3, 'UTC')
|
|
) ENGINE = MergeTree() ORDER BY (tenant_id, n)`,
|
|
);
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
if (CLICKHOUSE_URL) await chText(`DROP DATABASE IF EXISTS ${DB}`);
|
|
}, 60_000);
|
|
|
|
it("THEIR encoder, OUR reader: ClickHouse ArrowStream decodes with the expected schema and row count", async () => {
|
|
const bytes = await chBytes(
|
|
`SELECT toString(t) AS tenant_id, toInt64(n) AS n, toFloat64(v) AS v, b AS flag,
|
|
toDateTime64(ts, 3, 'UTC') AS ts
|
|
FROM (
|
|
SELECT arrayJoin([
|
|
('t_alpha', 1, 1.5, true, '2026-01-01 00:00:00.000'),
|
|
('t_beta', 2, 2.5, false, '2026-06-15 08:09:10.123'),
|
|
('t_gamma', 3, 3.25, true, '2026-09-06 12:34:56.789')
|
|
]) AS tup, tup.1 AS t, tup.2 AS n, tup.3 AS v, tup.4 AS b, tup.5 AS ts
|
|
)
|
|
FORMAT ${clickhouseArrowStreamFormat()}`,
|
|
arrowStreamSettings(),
|
|
);
|
|
const info = decodeArrowStream(bytes);
|
|
expect(info.rowCount).toBe(3);
|
|
expect(info.fields.map((f) => f.name)).toEqual(["tenant_id", "n", "v", "flag", "ts"]);
|
|
// Utf8=5 (NOT Binary=4 — that is what output_format_arrow_string_as_string buys),
|
|
// Int=2, FloatingPoint=3, Bool=6, Timestamp=10.
|
|
expect(info.fields.map((f) => f.typeType)).toEqual([5, 2, 3, 6, 10]);
|
|
});
|
|
|
|
it("WITHOUT output_format_arrow_string_as_string, ClickHouse emits Binary — the semantic loss the setting prevents", async () => {
|
|
const bytes = await chBytes(`SELECT 'x' AS s FORMAT ${clickhouseArrowStreamFormat()}`, {
|
|
output_format_arrow_string_as_string: "0",
|
|
});
|
|
// Arrow Type union: Binary = 4, Utf8 = 5.
|
|
expect(decodeArrowStream(bytes).fields[0].typeType).toBe(4);
|
|
const asText = await chBytes(`SELECT 'x' AS s FORMAT ${clickhouseArrowStreamFormat()}`, arrowStreamSettings());
|
|
expect(decodeArrowStream(asText).fields[0].typeType).toBe(5);
|
|
});
|
|
|
|
it("OUR encoder, THEIR reader: ClickHouse INSERTs our Arrow and returns every value unchanged", async () => {
|
|
const bytes = encodeArrowStream([
|
|
{ name: "tenant_id", type: "utf8", values: ["t_alpha", "t_beta", "t_gamma"] },
|
|
{ name: "n", type: "int64", values: [1, 2, 3] },
|
|
{ name: "v", type: "float64", values: [1.5, 2.5, 3.25] },
|
|
{ name: "flag", type: "bool", values: [true, false, true] },
|
|
{
|
|
name: "ts",
|
|
type: "timestamp_ms",
|
|
values: [
|
|
new Date("2026-01-01T00:00:00.000Z"),
|
|
new Date("2026-06-15T08:09:10.123Z"),
|
|
new Date("2026-09-06T12:34:56.789Z"),
|
|
],
|
|
},
|
|
]);
|
|
await chText(`INSERT INTO ${DB}.arrow_roundtrip FORMAT ArrowStream`, bytes as unknown as BodyInit);
|
|
|
|
const back = await chText(
|
|
`SELECT tenant_id, n, v, flag, toString(ts) AS ts FROM ${DB}.arrow_roundtrip ORDER BY n FORMAT JSON`,
|
|
);
|
|
const rows = (JSON.parse(back) as {
|
|
data: { tenant_id: string; n: string | number; v: number | string; flag: boolean | number; ts: string }[];
|
|
}).data;
|
|
expect(rows).toHaveLength(3);
|
|
expect(rows.map((r) => r.tenant_id)).toEqual(["t_alpha", "t_beta", "t_gamma"]);
|
|
expect(rows.map((r) => Number(r.n))).toEqual([1, 2, 3]);
|
|
expect(rows.map((r) => Number(r.v))).toEqual([1.5, 2.5, 3.25]);
|
|
expect(rows.map((r) => Boolean(Number(r.flag)))).toEqual([true, false, true]);
|
|
expect(rows.map((r) => r.ts)).toEqual([
|
|
"2026-01-01 00:00:00.000",
|
|
"2026-06-15 08:09:10.123",
|
|
"2026-09-06 12:34:56.789",
|
|
]);
|
|
});
|
|
|
|
it("full circle: ClickHouse -> Arrow -> our decoder -> ClickHouse yields identical rows", async () => {
|
|
const exported = await chBytes(
|
|
`SELECT tenant_id, n, v, flag, ts FROM ${DB}.arrow_roundtrip ORDER BY n FORMAT ArrowStream`,
|
|
arrowStreamSettings(),
|
|
);
|
|
expect(decodeArrowStream(exported).rowCount).toBe(3);
|
|
|
|
await chText(`CREATE TABLE IF NOT EXISTS ${DB}.arrow_circle AS ${DB}.arrow_roundtrip`);
|
|
await chText(`INSERT INTO ${DB}.arrow_circle FORMAT ArrowStream`, exported as unknown as BodyInit);
|
|
|
|
const diff = await chText(
|
|
`SELECT count() AS c FROM (
|
|
SELECT * FROM ${DB}.arrow_roundtrip
|
|
EXCEPT
|
|
SELECT * FROM ${DB}.arrow_circle
|
|
) FORMAT JSON`,
|
|
);
|
|
expect(Number((JSON.parse(diff) as { data: { c: number | string }[] }).data[0].c)).toBe(0);
|
|
});
|
|
|
|
it("a large result survives the round trip (batching/offsets are not only correct for three rows)", async () => {
|
|
const n = 20_000;
|
|
const bytes = encodeArrowStream([
|
|
{ name: "tenant_id", type: "utf8", values: Array.from({ length: n }, (_, i) => `t_${i % 7}`) },
|
|
{ name: "n", type: "int64", values: Array.from({ length: n }, (_, i) => i) },
|
|
{ name: "v", type: "float64", values: Array.from({ length: n }, (_, i) => i / 3) },
|
|
{ name: "flag", type: "bool", values: Array.from({ length: n }, (_, i) => i % 2 === 0) },
|
|
{ name: "ts", type: "timestamp_ms", values: Array.from({ length: n }, () => new Date("2026-09-06T00:00:00Z")) },
|
|
]);
|
|
expect(decodeArrowStream(bytes).rowCount).toBe(n);
|
|
|
|
await chText(`CREATE TABLE IF NOT EXISTS ${DB}.arrow_big AS ${DB}.arrow_roundtrip`);
|
|
await chText(`INSERT INTO ${DB}.arrow_big FORMAT ArrowStream`, bytes as unknown as BodyInit);
|
|
const agg = await chText(
|
|
`SELECT count() AS c, sum(n) AS s, uniqExact(tenant_id) AS t, countIf(flag) AS f FROM ${DB}.arrow_big FORMAT JSON`,
|
|
);
|
|
const d = (JSON.parse(agg) as { data: { c: string | number; s: string | number; t: string | number; f: string | number }[] }).data[0];
|
|
expect(Number(d.c)).toBe(n);
|
|
expect(Number(d.s)).toBe((n * (n - 1)) / 2);
|
|
expect(Number(d.t)).toBe(7);
|
|
expect(Number(d.f)).toBe(n / 2);
|
|
}, 60_000);
|
|
});
|