feat(analytics): BI-P002..BI-P012 — 0pt analytics core (domain model, tiers, contracts, Arrow, ClickHouse engine, tenant isolation, resource governance) #34
@@ -0,0 +1,271 @@
|
||||
# 0pt Analytics Core — BI-P002 … BI-P012
|
||||
|
||||
Status of this document: it describes what **is in this repository and has been
|
||||
run**, and states separately what has **not** been deployed. Nothing here is a
|
||||
plan written in the present tense.
|
||||
|
||||
---
|
||||
|
||||
## 0. The ownership boundary (normative, not negotiable)
|
||||
|
||||
```
|
||||
SIAX / 0pt owns the analytics engine, the query compiler, the tenant forcing,
|
||||
the resource governance and the semantic/contract layer.
|
||||
|
||||
Product repos (VehIQ and the rest) own DOMAIN MEANING — what a row means,
|
||||
which metric a dealer cares about, how a screen reads.
|
||||
```
|
||||
|
||||
Concretely, and enforced rather than asserted:
|
||||
|
||||
* `BIApplicationBinding` (BI-P002 entity 19) has **no credential field**, in
|
||||
TypeScript or in `sql/008_analytics_domain_model.sql`. A bound BI
|
||||
application authenticates to the 0pt Analytics API with a CL0UD identity and
|
||||
receives compiled, tenant-forced results. It never learns a ClickHouse DSN,
|
||||
user, or password. Two tests assert the absence of such a column — one over
|
||||
the migration text, one over `information_schema.columns` on a live
|
||||
Postgres — so adding one later is a test failure.
|
||||
* `AnalyticsIdentity` (BI-P009) is a **separate parameter** from the request,
|
||||
never a field on it. This estate has already shipped a live cross-tenant
|
||||
hole by reading a tenant out of a request body (INF0 Wave 2); the type
|
||||
shape is what stops that recurring here.
|
||||
|
||||
---
|
||||
|
||||
## 1. What each prompt produced
|
||||
|
||||
| Prompt | Deliverable | Where |
|
||||
|---|---|---|
|
||||
| BI-P002 | 19 canonical entities + migrations + tenant-scoped persistence + restore drill | `src/analytics/domain/entities.ts`, `sql/008_analytics_domain_model.sql`, `src/analytics/repositories/analytics-domain-repository.ts` |
|
||||
| BI-P003 | RAW / NORMALIZED / CURATED / SERVING, with the BI-surface read rule enforced | `src/analytics/domain/tiers.ts` |
|
||||
| BI-P004 | `DatasetContract` + a fail-closed compatibility gate | `src/analytics/domain/contract.ts` |
|
||||
| BI-P005 | Arrow IPC encode/decode + ClickHouse pass-through | `src/analytics/arrow/ipc.ts`, `src/analytics/arrow/flatbuffer-builder.ts` |
|
||||
| BI-P009 | `SiaxAnalyticsEngine` interface + ClickHouse provider, existing implementation **preserved** | `src/analytics/engine/siax-analytics-engine.ts`, `src/analytics/engine/clickhouse-engine.ts` |
|
||||
| BI-P010 | Four purpose-built ClickHouse tables + rollup MV, benchmarked | `src/analytics/engine/schema.ts`, `scripts/bi-p010-benchmark.mjs` |
|
||||
| BI-P011 | Layered tenant isolation: verified identity, forced compiler predicate, restricted users, row policies | `src/analytics/engine/query-compiler.ts` |
|
||||
| BI-P012 | Every named ClickHouse limit + CL0UD quota + per-tenant admission control | `src/analytics/engine/resource-governance.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 2. BI-P009 — what "preserve" means here
|
||||
|
||||
`src/analytics/clickhouse-analytics.ts` is **byte-identical to main**. The new
|
||||
engine composes it:
|
||||
|
||||
* `health()` delegates to it;
|
||||
* the pre-existing `opt_metric_events` path is reachable through
|
||||
`executeLegacyMetric()`, still using its own compiler;
|
||||
* everything else uses the new compiler for the BI-P010 tables.
|
||||
|
||||
"Do not regress the existing forced tenant predicate" is enforced by
|
||||
`tests/analytics/tenant-predicate-regression.test.ts`, which asserts the exact
|
||||
string `tenant_id = {tenant:String}` on the URL the adapter builds. It has been
|
||||
run with the forcing deliberately deleted from both compilers and goes RED
|
||||
(2 of 6 tests fail); the transcript is
|
||||
`evidence/bi-p002-p012/test-transcript.txt` §1–§2.
|
||||
|
||||
The eight capabilities are all implemented, with one **deliberate refusal**:
|
||||
`executeQuery` (analyst SQL) throws `CAPABILITY_UNAVAILABLE` until BI-P017
|
||||
(SQL dialect contract) and BI-P033 (analytics authorization) exist. Shipping a
|
||||
raw-SQL surface first would create exactly the "raw SQL attempt" hole BI-P011
|
||||
enumerates. It is declared and refused, not silently missing.
|
||||
|
||||
---
|
||||
|
||||
## 3. BI-P010 — why four tables, measured
|
||||
|
||||
BI-P010 warns against "one giant unbounded table for every analytics use
|
||||
case". `scripts/bi-p010-benchmark.mjs` builds both fixtures and the
|
||||
counterfactual, and times them. Measured on a disposable
|
||||
`clickhouse/clickhouse-server:24.8` (full transcript:
|
||||
`evidence/bi-p002-p012/bi-p010-benchmark.json`):
|
||||
|
||||
| Question | Purpose-built | One giant table |
|
||||
|---|---|---|
|
||||
| 90-day metric total, one tenant | **48.8 ms p50**, 40 960 rows read | 174.4 ms p50, **5 400 000** rows read |
|
||||
| Vehicle point lookup by key, one tenant | **49.3 ms p50**, 8 192 rows read | 236.6 ms p50, **7 401 152** rows read |
|
||||
| Avg asking price by make over 2M vehicles | **389.6 ms p50** | 680.8 ms p50 |
|
||||
| Granules read for the metric total | **5 / 660** | 660 / 1 149 |
|
||||
|
||||
Fixtures: 5 400 000 SaaS events (200 tenants × 12 metrics × 90 days) and
|
||||
4 000 000 vehicle projections (2M for the tenant under test + 2M decoys owned
|
||||
by 50 other tenants).
|
||||
|
||||
**Honest finding that does not flatter the design:** at this scale the
|
||||
`AggregatingMergeTree` rollup (37.98 ms p50, 21 416 rows read) was *not* faster
|
||||
in wall time than scanning raw events (32.55 ms p50, 40 960 rows read). It
|
||||
reads about half the rows, and its value is the long TTL — raw events expire
|
||||
in 30 days, the rollup keeps three years — so the rollup earns its keep on
|
||||
retention and on scale beyond this fixture, not on latency at 5.4M rows. Do
|
||||
not quote it as a speed win.
|
||||
|
||||
---
|
||||
|
||||
## 4. BI-P011 — the isolation proof
|
||||
|
||||
Four layers, and each one is tested with the layer above it *removed*:
|
||||
|
||||
1. **Verified CL0UD identity.** `AnalyticsIdentity` is not part of the
|
||||
request; a blank tenant or principal is `UNAUTHENTICATED`, never a wildcard.
|
||||
2. **The 0pt query compiler forces the tenant.** Every compiled statement
|
||||
contains `tenant_id = {tenant:String}` as its first WHERE condition, with
|
||||
the value bound as an HTTP parameter — the tenant string never appears in
|
||||
the SQL text.
|
||||
3. **Restricted database users + row policies.**
|
||||
`restrictedTenantUserDdl()` renders a per-tenant ClickHouse user with
|
||||
`readonly = 1` and a `CREATE ROW POLICY ... USING tenant_id = '<tenant>'`
|
||||
on every analytics table.
|
||||
4. **Separate database/cluster tier** for very-high-isolation customers:
|
||||
supported by construction — the engine takes a `database` and a `baseUrl`
|
||||
per instance — but **not yet operated**, because there is no deployed
|
||||
ClickHouse for 0pt analytics at all (see §7).
|
||||
|
||||
The proof, run against a disposable ClickHouse with 50 rows for `t_alpha`
|
||||
(value 1 each) and 50 for `t_beta` (value 1000 each):
|
||||
|
||||
* **hole, layer 2 removed:** the same statement minus the forced predicate
|
||||
returns **50 050** — both tenants in one number;
|
||||
* **closed, layer 2 present:** `t_alpha` gets **50**, `t_beta` gets **50 000**;
|
||||
* **hole, layer 3 absent:** an unrestricted user issuing a predicate-free
|
||||
`uniqExact(tenant_id)` sees **2** tenants;
|
||||
* **closed, layer 3 present:** the restricted user issuing the *identical*
|
||||
statement sees **1**, and `sum(value)` returns 50 rather than 50 050.
|
||||
|
||||
Adversarial cases from BI-P011's list — tenant A querying B's dataset, forged
|
||||
dataset IDs, raw SQL, SQL injection, dimension identifier injection, filter
|
||||
injection — are covered in
|
||||
`tests/analytics/query-compiler-injection.test.ts` (compiler layer) and
|
||||
`tests/integration/analytics-tenant-isolation.integration.test.ts` (live).
|
||||
|
||||
Dimension names are **resolved through a per-tenant allow-list**
|
||||
(`DimensionDefinition`), never escaped and never spliced. A tenant that has
|
||||
declared no dimensions may reference none — fail closed.
|
||||
|
||||
---
|
||||
|
||||
## 5. BI-P012 — governance, and the partial-result trap
|
||||
|
||||
Every setting BI-P012 names is applied. Five ride on the statement itself
|
||||
(`max_execution_time`, `max_result_rows`, `max_rows_to_read`,
|
||||
`max_bytes_to_read`, `max_memory_usage`) under `readonly = 1`, so the query
|
||||
text cannot re-raise them; `max_concurrent_queries` is a per-user quota plus a
|
||||
local admission gate.
|
||||
|
||||
Overflow modes are **`throw`, never `break`**. That is not a style preference.
|
||||
Measured on ClickHouse 24.8: with `result_overflow_mode = break` and
|
||||
`max_result_rows = 10`, a 100 000-row query returns **HTTP 200 with 65 409
|
||||
rows** and nothing in the payload says it is short. A truncated answer that
|
||||
looks complete is the analytics form of a false green.
|
||||
|
||||
Query-bomb outcomes, each fired individually and identified by ClickHouse
|
||||
error code: 158 `TOO_MANY_ROWS`, 307 `TOO_MANY_BYTES`, 159 `TIMEOUT_EXCEEDED`,
|
||||
396 `TOO_MANY_ROWS_OR_BYTES`, 241 `MEMORY_LIMIT_EXCEEDED`.
|
||||
|
||||
The second half of the DoD — "without degrading unrelated tenant beyond
|
||||
declared SLO" — is timed, not assumed: an unrelated tenant's interactive query
|
||||
is run five times *while* an 8-second CPU bomb is in flight, must still return
|
||||
the **correct** answer, and must stay under a declared 5000 ms ceiling (measured ~30 ms on an idle
|
||||
server; the ceiling is set wide for a shared CI runner, and a genuinely
|
||||
starved tenant would take seconds or fail).
|
||||
|
||||
Two measured details worth keeping:
|
||||
|
||||
* `EXPLAIN ESTIMATE` must **not** run under the caller's own
|
||||
`max_rows_to_read`. It fails with code 158 exactly when the estimate would
|
||||
be over budget — i.e. in the only case the estimate matters. Planner calls
|
||||
use `planningSettings()`, a narrower envelope, instead.
|
||||
* `SELECT count() FROM numbers(2e10)` is *not* a query bomb — ClickHouse
|
||||
answers it in about 5 seconds because it can skip the values. The bomb in
|
||||
the test hashes every row, which cannot be short-circuited.
|
||||
|
||||
---
|
||||
|
||||
## 6. BI-P005 — Arrow, and why it is not self-certified
|
||||
|
||||
Two paths:
|
||||
|
||||
* **pass-through** — ClickHouse emits Arrow IPC itself (`FORMAT ArrowStream`);
|
||||
0pt streams those bytes unchanged. `output_format_arrow_string_as_string=1`
|
||||
is required, or String columns arrive as Arrow **Binary** and both pyarrow
|
||||
and DuckDB read them as bytes: silent semantic loss.
|
||||
* **encoded** — for results 0pt assembles itself, `encodeArrowStream()` writes
|
||||
the IPC stream, including a small hand-written FlatBuffers writer (no new
|
||||
npm dependency, no new supply-chain surface).
|
||||
|
||||
A hand-rolled encoder is only worth having if somebody else's parser agrees
|
||||
with it, so conformance is established by three independent implementations:
|
||||
|
||||
* **pyarrow 25.0.1** and **DuckDB 1.5.5** decode both ClickHouse's bytes and
|
||||
0pt's bytes to *identical* values and types
|
||||
(`evidence/bi-p002-p012/bi-p005-arrow-roundtrip.json`, verdict `PASS`);
|
||||
* **ClickHouse's own Arrow C++** ingests 0pt-encoded Arrow — including a
|
||||
20 000-row batch whose aggregates come back exact — in
|
||||
`tests/integration/analytics-arrow-clickhouse.integration.test.ts`, which
|
||||
runs on every push.
|
||||
|
||||
Supported types and their caveats are the table in `ARROW_TYPE_SUPPORT`;
|
||||
`DateTime64(6|9)` and `UInt64` above 2^63 are deliberately **outside** the
|
||||
supported set rather than silently lossy.
|
||||
|
||||
Arrow is also not an escape hatch from BI-P012:
|
||||
`assertArrowWithinResultBudget()` decodes the row count and refuses a payload
|
||||
over the caller's `max_result_rows`.
|
||||
|
||||
---
|
||||
|
||||
## 7. What is NOT done — verified, not assumed
|
||||
|
||||
* **There is no deployed ClickHouse for 0pt analytics.** Probed 2026-09-06 on
|
||||
server7, the only ClickHouse in the estate: databases are
|
||||
`opt_scraped`, `default`, `system`, `INFORMATION_SCHEMA`,
|
||||
`information_schema`. There is no `opt_analytics` database, so
|
||||
`ClickHouseAnalytics` — the "existing working" provider — has never been
|
||||
run against a production instance. Everything in this document was proven
|
||||
against disposable containers.
|
||||
* **server7's ClickHouse must not be used for this.** It holds
|
||||
`opt_scraped` — 51 tables, 407 793 471 rows, including `persons` and
|
||||
`cz_owners`. That is the b0ts scraping plane, which
|
||||
`src/analytics/clickhouse-analytics.ts`'s own header already prohibits this
|
||||
module from touching (Ratsit/person-PII legal case, written DPIA
|
||||
prohibition on quarantined market data). It also has **zero** columns named
|
||||
`tenant_id` or `project_id` anywhere on the server and **zero** row
|
||||
policies, so it could not satisfy BI-P011 even if it were legally
|
||||
available. Nothing on that server was modified by this work.
|
||||
* **BI-P011 layer 5** (separate database/cluster tier per high-isolation
|
||||
customer) is supported by construction but not operated.
|
||||
* **`executeQuery`** is refused pending BI-P017/BI-P033 (§2).
|
||||
* The domain repository covers `AnalyticsDataset`, `DatasetVersion`,
|
||||
`MetricDefinition`, `MetricVersion` and `DimensionDefinition`. The other
|
||||
fourteen entities have tables, constraints and types but no repository
|
||||
methods yet; their consumers (dashboards, reports, quality runs, lineage)
|
||||
arrive with BI-P025 … BI-P035.
|
||||
|
||||
---
|
||||
|
||||
## 8. Running the proofs yourself
|
||||
|
||||
```bash
|
||||
# unit suites (no infrastructure)
|
||||
pnpm vitest run tests/analytics
|
||||
|
||||
# live ClickHouse suites
|
||||
docker run -d --name opt-bi-ch -p 18123:8123 \
|
||||
-e CLICKHOUSE_USER=opt_admin -e CLICKHOUSE_PASSWORD=<throwaway> \
|
||||
-e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 clickhouse/clickhouse-server:24.8
|
||||
CLICKHOUSE_URL=http://localhost:18123 CLICKHOUSE_AUTH=opt_admin:<throwaway> \
|
||||
pnpm vitest run tests/integration
|
||||
|
||||
# BI-P002 restore drill
|
||||
docker run -d --name opt-bi-pg -p 15455:5432 \
|
||||
-e POSTGRES_USER=opt -e POSTGRES_PASSWORD=<throwaway> -e POSTGRES_DB=opt_bi_test \
|
||||
postgres:16-alpine
|
||||
ANALYTICS_TEST_DATABASE_URL=postgres://opt:<throwaway>@localhost:15455/opt_bi_test \
|
||||
pnpm vitest run tests/analytics/analytics-domain-postgres.realinfra.test.ts
|
||||
|
||||
# BI-P010 benchmark and BI-P005 external roundtrip (rewrite the evidence files)
|
||||
CLICKHOUSE_URL=... CLICKHOUSE_AUTH=... node scripts/bi-p010-benchmark.mjs
|
||||
CLICKHOUSE_URL=... CLICKHOUSE_AUTH=... node scripts/bi-p005-arrow-roundtrip.mjs
|
||||
```
|
||||
|
||||
Both scripts CREATE and DROP a database. Never point either at server7 or at
|
||||
any ClickHouse holding b0ts or quarantined market data.
|
||||
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"generated_at": "2026-09-06T20:53:00.409Z",
|
||||
"clickhouse_version": "24.8.14.39",
|
||||
"query": "SELECT toString(t) AS tenant_id, toInt64(n) AS n, toFloat64(v) AS v, b AS flag,\n toDateTime64(ts, 3, 'UTC') AS ts\nFROM (\n SELECT arrayJoin([\n ('t_alpha', 1, 1.5, true, '2026-01-01 00:00:00.000'),\n ('t_beta', 2, 2.5, false, '2026-06-15 08:09:10.123'),\n ('t_gamma', 3, 3.25, true, '2026-09-06 12:34:56.789')\n ]) AS tup, tup.1 AS t, tup.2 AS n, tup.3 AS v, tup.4 AS b, tup.5 AS ts\n)",
|
||||
"directions": {
|
||||
"clickhouse -> arrow -> pyarrow/duckdb": {
|
||||
"pyarrow_version": "25.0.1",
|
||||
"duckdb_version": "1.5.5",
|
||||
"schema": [
|
||||
{
|
||||
"name": "tenant_id",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "n",
|
||||
"type": "int64"
|
||||
},
|
||||
{
|
||||
"name": "v",
|
||||
"type": "double"
|
||||
},
|
||||
{
|
||||
"name": "flag",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"name": "ts",
|
||||
"type": "timestamp[ms, tz=UTC]"
|
||||
}
|
||||
],
|
||||
"num_rows": 3,
|
||||
"pyarrow_values": {
|
||||
"tenant_id": [
|
||||
"t_alpha",
|
||||
"t_beta",
|
||||
"t_gamma"
|
||||
],
|
||||
"n": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"v": [
|
||||
1.5,
|
||||
2.5,
|
||||
3.25
|
||||
],
|
||||
"flag": [
|
||||
true,
|
||||
false,
|
||||
true
|
||||
],
|
||||
"ts": [
|
||||
"2026-01-01 00:00:00+00:00",
|
||||
"2026-06-15 08:09:10.123000+00:00",
|
||||
"2026-09-06 12:34:56.789000+00:00"
|
||||
]
|
||||
},
|
||||
"duckdb_types": [
|
||||
[
|
||||
"tenant_id",
|
||||
"VARCHAR"
|
||||
],
|
||||
[
|
||||
"n",
|
||||
"BIGINT"
|
||||
],
|
||||
[
|
||||
"v",
|
||||
"DOUBLE"
|
||||
],
|
||||
[
|
||||
"flag",
|
||||
"BOOLEAN"
|
||||
],
|
||||
[
|
||||
"ts",
|
||||
"TIMESTAMP WITH TIME ZONE"
|
||||
]
|
||||
],
|
||||
"duckdb_rows": [
|
||||
[
|
||||
"t_alpha",
|
||||
"1",
|
||||
"1.5",
|
||||
"True",
|
||||
"2026-01-01 00:00:00+00:00"
|
||||
],
|
||||
[
|
||||
"t_beta",
|
||||
"2",
|
||||
"2.5",
|
||||
"False",
|
||||
"2026-06-15 08:09:10.123000+00:00"
|
||||
],
|
||||
[
|
||||
"t_gamma",
|
||||
"3",
|
||||
"3.25",
|
||||
"True",
|
||||
"2026-09-06 12:34:56.789000+00:00"
|
||||
]
|
||||
]
|
||||
},
|
||||
"0pt encoder -> arrow -> pyarrow/duckdb": {
|
||||
"pyarrow_version": "25.0.1",
|
||||
"duckdb_version": "1.5.5",
|
||||
"schema": [
|
||||
{
|
||||
"name": "tenant_id",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "n",
|
||||
"type": "int64"
|
||||
},
|
||||
{
|
||||
"name": "v",
|
||||
"type": "double"
|
||||
},
|
||||
{
|
||||
"name": "flag",
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"name": "ts",
|
||||
"type": "timestamp[ms, tz=UTC]"
|
||||
}
|
||||
],
|
||||
"num_rows": 3,
|
||||
"pyarrow_values": {
|
||||
"tenant_id": [
|
||||
"t_alpha",
|
||||
"t_beta",
|
||||
"t_gamma"
|
||||
],
|
||||
"n": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"v": [
|
||||
1.5,
|
||||
2.5,
|
||||
3.25
|
||||
],
|
||||
"flag": [
|
||||
true,
|
||||
false,
|
||||
true
|
||||
],
|
||||
"ts": [
|
||||
"2026-01-01 00:00:00+00:00",
|
||||
"2026-06-15 08:09:10.123000+00:00",
|
||||
"2026-09-06 12:34:56.789000+00:00"
|
||||
]
|
||||
},
|
||||
"duckdb_types": [
|
||||
[
|
||||
"tenant_id",
|
||||
"VARCHAR"
|
||||
],
|
||||
[
|
||||
"n",
|
||||
"BIGINT"
|
||||
],
|
||||
[
|
||||
"v",
|
||||
"DOUBLE"
|
||||
],
|
||||
[
|
||||
"flag",
|
||||
"BOOLEAN"
|
||||
],
|
||||
[
|
||||
"ts",
|
||||
"TIMESTAMP WITH TIME ZONE"
|
||||
]
|
||||
],
|
||||
"duckdb_rows": [
|
||||
[
|
||||
"t_alpha",
|
||||
"1",
|
||||
"1.5",
|
||||
"True",
|
||||
"2026-01-01 00:00:00+00:00"
|
||||
],
|
||||
[
|
||||
"t_beta",
|
||||
"2",
|
||||
"2.5",
|
||||
"False",
|
||||
"2026-06-15 08:09:10.123000+00:00"
|
||||
],
|
||||
[
|
||||
"t_gamma",
|
||||
"3",
|
||||
"3.25",
|
||||
"True",
|
||||
"2026-09-06 12:34:56.789000+00:00"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"byte_sizes": {
|
||||
"clickhouse_encoded": 968,
|
||||
"opt_encoded": 864
|
||||
},
|
||||
"verdict": "PASS — pyarrow and DuckDB read identical values and types from BOTH encoders"
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"generated_at": "2026-09-06T20:44:16.335Z",
|
||||
"clickhouse_version": "24.8.14.39",
|
||||
"database": "opt_bench_mtqa56j3",
|
||||
"fixtures": {
|
||||
"saas": {
|
||||
"events": 5400000,
|
||||
"tenants": 200,
|
||||
"metrics": 12,
|
||||
"days": 90,
|
||||
"seed_ms": 22912
|
||||
},
|
||||
"vehiq": {
|
||||
"vehicles_under_test": 2000000,
|
||||
"decoy_vehicles": 2000000,
|
||||
"seed_ms": 31960
|
||||
},
|
||||
"on_disk": [
|
||||
{
|
||||
"table": "giant_everything",
|
||||
"rows": 9400000,
|
||||
"bytes": 84015753
|
||||
},
|
||||
{
|
||||
"table": "opt_analytics_event",
|
||||
"rows": 5400000,
|
||||
"bytes": 60158827
|
||||
},
|
||||
{
|
||||
"table": "opt_dataset_projection",
|
||||
"rows": 4000000,
|
||||
"bytes": 48936260
|
||||
},
|
||||
{
|
||||
"table": "opt_metric_daily",
|
||||
"rows": 37800,
|
||||
"bytes": 86181
|
||||
}
|
||||
]
|
||||
},
|
||||
"measurements": [
|
||||
{
|
||||
"label": "SaaS dashboard panel: 90-day metric total for ONE tenant (purpose-built event table)",
|
||||
"runs": 12,
|
||||
"p50_ms": 48.8,
|
||||
"p95_ms": 72.24,
|
||||
"min_ms": 31.36,
|
||||
"max_ms": 72.24,
|
||||
"rows_read": 40960,
|
||||
"bytes_read": 75216
|
||||
},
|
||||
{
|
||||
"label": "SAME question against ONE GIANT TABLE (time-leading sort key)",
|
||||
"runs": 12,
|
||||
"p50_ms": 174.38,
|
||||
"p95_ms": 320.61,
|
||||
"min_ms": 142.41,
|
||||
"max_ms": 320.61,
|
||||
"rows_read": 5400000,
|
||||
"bytes_read": 165330000
|
||||
},
|
||||
{
|
||||
"label": "SaaS time series: daily series for ONE tenant from the AggregatingMergeTree rollup",
|
||||
"runs": 12,
|
||||
"p50_ms": 37.98,
|
||||
"p95_ms": 60.34,
|
||||
"min_ms": 22.25,
|
||||
"max_ms": 60.34,
|
||||
"rows_read": 21416,
|
||||
"bytes_read": 42832
|
||||
},
|
||||
{
|
||||
"label": "SAME time series computed from raw events instead of the rollup",
|
||||
"runs": 12,
|
||||
"p50_ms": 32.55,
|
||||
"p95_ms": 85.96,
|
||||
"min_ms": 22.03,
|
||||
"max_ms": 85.96,
|
||||
"rows_read": 40960,
|
||||
"bytes_read": 75216
|
||||
},
|
||||
{
|
||||
"label": "SaaS group-by: one tenant sliced by a dimension",
|
||||
"runs": 12,
|
||||
"p50_ms": 44.45,
|
||||
"p95_ms": 317.18,
|
||||
"min_ms": 22.92,
|
||||
"max_ms": 317.18,
|
||||
"rows_read": 40960,
|
||||
"bytes_read": 75216
|
||||
},
|
||||
{
|
||||
"label": "VehIQ point lookup: one vehicle by key, tenant-scoped (purpose-built projection)",
|
||||
"runs": 12,
|
||||
"p50_ms": 49.25,
|
||||
"p95_ms": 89.99,
|
||||
"min_ms": 28.31,
|
||||
"max_ms": 89.99,
|
||||
"rows_read": 8192,
|
||||
"bytes_read": 761856
|
||||
},
|
||||
{
|
||||
"label": "SAME point lookup against ONE GIANT TABLE",
|
||||
"runs": 12,
|
||||
"p50_ms": 236.6,
|
||||
"p95_ms": 1227.93,
|
||||
"min_ms": 174.21,
|
||||
"max_ms": 1227.93,
|
||||
"rows_read": 7401152,
|
||||
"bytes_read": 196760496
|
||||
},
|
||||
{
|
||||
"label": "VehIQ aggregate: average asking price by make for ONE tenant across 2M vehicles",
|
||||
"runs": 8,
|
||||
"p50_ms": 389.57,
|
||||
"p95_ms": 16748.19,
|
||||
"min_ms": 189.51,
|
||||
"max_ms": 16748.19,
|
||||
"rows_read": 2000000,
|
||||
"bytes_read": 256400000
|
||||
},
|
||||
{
|
||||
"label": "SAME aggregate against ONE GIANT TABLE",
|
||||
"runs": 8,
|
||||
"p50_ms": 680.82,
|
||||
"p95_ms": 756.4,
|
||||
"min_ms": 410.57,
|
||||
"max_ms": 756.4,
|
||||
"rows_read": 7401152,
|
||||
"bytes_read": 447184368
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"Fixtures are synthetic and generated in-server with numbers(); no production or b0ts data was read or written.",
|
||||
"Each timing is the p50/p95 of repeated runs after one warm-up, measured client-side over HTTP on the same host.",
|
||||
"The 'ONE GIANT TABLE' rows are the counterfactual BI-P010 warns against, measured rather than asserted."
|
||||
],
|
||||
"index_pruning": {
|
||||
"purpose_built_event_table": {
|
||||
"read": 5,
|
||||
"total": 660
|
||||
},
|
||||
"one_giant_table": {
|
||||
"read": 660,
|
||||
"total": 1149
|
||||
},
|
||||
"note": "Granules read/total for the SAME question. The tenant-leading ORDER BY is what turns the forced tenant predicate (BI-P011) into a primary-key range scan instead of a full scan."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# Live probe: the only ClickHouse in the estate (2026-09-06)
|
||||
|
||||
Read-only inspection over the tailnet (`root@100.100.215.20`, siax-server7).
|
||||
Nothing was written, altered or deleted. Only server metadata
|
||||
(`system.databases`, `system.tables`, `system.columns`, `system.users`,
|
||||
`system.row_policies`) was read — **no table content, no person data**.
|
||||
|
||||
## What is there
|
||||
|
||||
```
|
||||
version() 26.7.1.1278
|
||||
process clickhouse-server (native, not a container)
|
||||
listening 127.0.0.1:8123/9000 and 10.100.0.5:8123/9000
|
||||
databases INFORMATION_SCHEMA, default, information_schema,
|
||||
opt_scraped, system
|
||||
users default, b0ts, b0ts_writer, b0ts_reader
|
||||
row policies 0
|
||||
settings profiles default, readonly
|
||||
```
|
||||
|
||||
`opt_scraped`: 51 tables, 407 793 471 rows in total. Largest by row count:
|
||||
|
||||
```
|
||||
vehicles ReplacingMergeTree 104 759 599
|
||||
cz_owners MergeTree 90 979 084
|
||||
vehicle_details_v2 ReplacingMergeTree 50 987 449
|
||||
cz_vehicles_full MergeTree 38 222 160
|
||||
vehicle_history MergeTree 28 421 783
|
||||
vehicle_price_history MergeTree 20 475 073
|
||||
...
|
||||
persons ReplacingMergeTree 9 457
|
||||
persons_meta ReplacingMergeTree 9 457
|
||||
```
|
||||
|
||||
## Three findings that change what can be claimed
|
||||
|
||||
1. **There is no `opt_analytics` database anywhere on this server.** The
|
||||
`ClickHouseAnalytics` capability in this repo — described upstream as "an
|
||||
existing working implementation" — has never been run against a deployed
|
||||
instance. It works (it is exercised against real ClickHouse in CI and in
|
||||
this branch's evidence), but it is not in production.
|
||||
|
||||
2. **This server is the b0ts scraping plane, and this module is prohibited
|
||||
from touching it.** `src/analytics/clickhouse-analytics.ts`'s own header
|
||||
states the boundary: never the b0ts ClickHouse pipelines
|
||||
(Ratsit / person-PII, separate legal case) and never quarantined
|
||||
`market='sverige'`/`'norge'` data (written DPIA prohibition). `opt_scraped`
|
||||
holds exactly that, `persons`/`cz_owners` included. The briefing note that
|
||||
"ClickHouse runs on server7 and holds 40M+ real Swedish vehicle rows" is
|
||||
accurate about the server and, on inspection, describes the plane 0pt
|
||||
analytics may not use.
|
||||
|
||||
3. **It could not satisfy BI-P011 even if it were available.** There is not a
|
||||
single column named `tenant_id` or `project_id` anywhere on the server, and
|
||||
there are zero row policies. There is nothing to force a predicate on.
|
||||
|
||||
## Consequence for BI-P009 … BI-P012
|
||||
|
||||
The canonical OLAP provider needs a **new, separate** ClickHouse for
|
||||
`opt_analytics` — its own instance or at minimum its own database with
|
||||
restricted users — before any of this reaches production. Every proof in this
|
||||
branch was therefore run against disposable
|
||||
`clickhouse/clickhouse-server:24.8` containers created and torn down by the
|
||||
test/benchmark scripts.
|
||||
@@ -0,0 +1,109 @@
|
||||
BI-P002..BI-P012 — measured test transcript
|
||||
===========================================
|
||||
host: Linux 6.8.0-139-generic x86_64 node: v24.18.0 date(UTC): 2026-09-06T20:53:23Z
|
||||
clickhouse under test: clickhouse/clickhouse-server:24.8 (disposable container, created and torn down by this run)
|
||||
postgres under test: postgres:16-alpine (disposable container)
|
||||
|
||||
### 1. BI-P009 regression guard WITH the forced tenant predicate REMOVED (must be RED)
|
||||
× BI-P009 regression guard: the forced tenant predicate > PRESERVED ClickHouseAnalytics still emits tenant_id = {tenant:String} on the wire 163ms
|
||||
✓ BI-P009 regression guard: the forced tenant predicate > PRESERVED ClickHouseAnalytics still refuses a blank tenant_id 36ms
|
||||
✓ BI-P009 regression guard: the forced tenant predicate > PRESERVED ClickHouseAnalytics still refuses a hostile group_by identifier 1ms
|
||||
× BI-P009 regression guard: the forced tenant predicate > every statement the NEW compiler produces contains the forced predicate 34ms
|
||||
✓ BI-P009 regression guard: the forced tenant predicate > the NEW compiler fails closed on a blank/absent tenant instead of emitting an unfiltered query 33ms
|
||||
✓ BI-P009 regression guard: the forced tenant predicate > the constant itself is the predicate, so deleting the forcing cannot pass silently 1ms
|
||||
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
|
||||
Test Files 1 failed (1)
|
||||
Tests 2 failed | 4 passed (6)
|
||||
|
||||
### 2. The same suite with the forcing RESTORED (must be GREEN)
|
||||
✓ tests/analytics/tenant-predicate-regression.test.ts (6 tests) 85ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 6 passed (6)
|
||||
(working tree restored: 0 lines of diff in the preserved file)
|
||||
|
||||
### 3. BI-P011 tenant isolation against a LIVE disposable ClickHouse (hole shown OPEN, then CLOSED)
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > HOLE, layer 2 REMOVED: the same query WITHOUT the forced predicate returns t_beta's rows to t_alpha 49ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > CLOSED, layer 2 PRESENT: the compiled query returns t_alpha's 50 and never t_beta's 50000 60ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > tenant A querying tenant B's dataset id gets ZERO rows, not B's rows 23ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > a forged dataset id yields an empty result, never an error that leaks existence 22ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > a tenant with no data at all sees zero, not everyone's aggregate 24ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > group-by results carry only this tenant's dimension values 66ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > SQL/dimension/filter injection through the live path changes nothing 74ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > raw SQL attempt: the analyst-SQL surface is refused, not silently executed 1ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > HOLE, layer 3 ABSENT: an unrestricted user with no predicate sees every tenant 42ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > CLOSED, layer 3 PRESENT: the restricted user runs the SAME predicate-free statement and sees ONE tenant 200ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > the restricted user cannot write, and cannot reach a table it was not granted 90ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > every analytics table's sorting key LEADS with tenant_id (what makes the predicate cheap) 143ms
|
||||
✓ tests/integration/analytics-tenant-isolation.integration.test.ts > BI-P011 tenant isolation against a LIVE ClickHouse > the tenant value never appears in the statement text the server receives 149ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 13 passed (13)
|
||||
|
||||
### 4. BI-P009/BI-P010 provider + schema against the same LIVE ClickHouse
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > health() reports the live server's version 30ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > BI-P010: four purpose-built tables exist, NOT one giant table 12ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > BI-P010: the tables use different engines and different sort keys — the split is real 30ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > BI-P010: TTL and LowCardinality are actually applied, not just written in a comment 22ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > BI-P010: the materialized view actually populates the rollup 92ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > executeMetric returns grouped rows keyed by the CALLER-FACING dimension name 48ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > estimateQuery gets a real EXPLAIN ESTIMATE from the server and compares it to the budget 15ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > estimateQuery REFUSES a query whose estimate exceeds a tightened quota 11ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > explain() proves the forced predicate reaches the PRIMARY KEY, pruning parts 15ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > cancelQuery issues a real KILL QUERY the server accepts 33ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > ingestProjection writes the SERVING tier with the identity's tenant, ignoring any row-level claim 33ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > refreshProjection makes a re-ingest idempotent (ReplacingMergeTree + OPTIMIZE FINAL) 58ms
|
||||
✓ tests/integration/analytics-engine-clickhouse.integration.test.ts > BI-P009/BI-P010 ClickHouse provider against a LIVE server > the PRESERVED ClickHouseAnalytics path still ingests and answers through the engine 35ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 13 passed (13)
|
||||
|
||||
### 5. BI-P012 resource governance / query bomb against the same LIVE ClickHouse
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > max_rows_to_read terminates a read bomb (CH code 158 TOO_MANY_ROWS) 60ms
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > max_bytes_to_read terminates a scan bomb (CH code 307 TOO_MANY_BYTES) 26ms
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > max_execution_time terminates a slow bomb (CH code 159 TIMEOUT_EXCEEDED) 1014ms
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > max_result_rows terminates a result bomb and does NOT return a partial result 422ms
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > max_memory_usage terminates a memory bomb (CH code 241 MEMORY_LIMIT_EXCEEDED) 84ms
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > readonly=1 means the query text cannot re-raise its own limits 37ms
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > readonly=1 blocks writes through the query surface 26ms
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > an unrelated tenant stays within its declared SLO WHILE a query bomb is in flight 8163ms
|
||||
✓ tests/integration/analytics-resource-governance.integration.test.ts > BI-P012 resource governance against a LIVE ClickHouse > the per-tenant concurrency gate refuses the N+1st in-flight query for THAT tenant only 220ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 9 passed (9)
|
||||
|
||||
### 6. BI-P005 Arrow interop with ClickHouse's own Arrow C++ implementation
|
||||
✓ tests/integration/analytics-arrow-clickhouse.integration.test.ts > BI-P005 Arrow IPC against a LIVE ClickHouse > THEIR encoder, OUR reader: ClickHouse ArrowStream decodes with the expected schema and row count 106ms
|
||||
✓ tests/integration/analytics-arrow-clickhouse.integration.test.ts > BI-P005 Arrow IPC against a LIVE ClickHouse > WITHOUT output_format_arrow_string_as_string, ClickHouse emits Binary — the semantic loss the setting prevents 42ms
|
||||
✓ tests/integration/analytics-arrow-clickhouse.integration.test.ts > BI-P005 Arrow IPC against a LIVE ClickHouse > OUR encoder, THEIR reader: ClickHouse INSERTs our Arrow and returns every value unchanged 76ms
|
||||
✓ tests/integration/analytics-arrow-clickhouse.integration.test.ts > BI-P005 Arrow IPC against a LIVE ClickHouse > full circle: ClickHouse -> Arrow -> our decoder -> ClickHouse yields identical rows 281ms
|
||||
✓ tests/integration/analytics-arrow-clickhouse.integration.test.ts > BI-P005 Arrow IPC against a LIVE ClickHouse > a large result survives the round trip (batching/offsets are not only correct for three rows) 690ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 5 passed (5)
|
||||
|
||||
### 7. BI-P002 domain model against a LIVE disposable Postgres (restore drill + immutability)
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > restore drill: sql/008 applies cleanly on top of the replayed chain 6ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > all nineteen entity tables really exist after the restore 125ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > replaying sql/008 a second time is a no-op, not an error 253ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > persists a dataset and reads it back through the repository 27ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > TENANT ISOLATION: t_beta cannot read t_alpha's dataset, even with the exact id 7ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > TENANT ISOLATION: a version cannot be minted against another tenant's dataset 4ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > dataset versions are monotonic and carry the contract hash 136ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > IMMUTABILITY is enforced by the DATABASE: UPDATE and DELETE on a version row are refused 27ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > all four immutable entities refuse an UPDATE, not just the one that was tested first 638ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > a metric version after the first must state WHY the number changed meaning 123ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > a DatasetGrant without a purpose is refused by the DATABASE (purpose limitation) 134ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > a BIApplicationBinding without a purpose is refused too, and carries no credential column 40ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > the dimension catalog is tenant-scoped and FAILS CLOSED for a tenant with none 111ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > a dimension whose physical field is not identifier-safe is refused by the DATABASE 3ms
|
||||
✓ tests/analytics/analytics-domain-postgres.realinfra.test.ts > BI-P002 analytics domain model against a LIVE Postgres > the repository refuses to act without a verified tenant scope 1ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 15 passed (15)
|
||||
|
||||
### 8. Repository gates (the same commands .gitea/workflows/ci.yml runs)
|
||||
TYPECHECK_EXIT=0
|
||||
Test Files 54 passed | 9 skipped (63)
|
||||
Tests 463 passed | 80 skipped (543)
|
||||
TEST_EXIT=0
|
||||
BUILD_EXIT=0
|
||||
lint (ADVISORY in CI, continue-on-error): 12 pre-existing errors in tests/p0/* — ZERO in any file this branch adds.
|
||||
|
||||
### 9. Scope check
|
||||
src/analytics/clickhouse-analytics.ts diff vs main: 0 lines (0 = preserved untouched)
|
||||
.gitea/workflows diff vs main: 0 lines (0 = no protected file touched)
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BI-P005 DoD, executed literally:
|
||||
*
|
||||
* "Same query result roundtrips ClickHouse -> Arrow -> DuckDB/Python
|
||||
* without semantic loss for supported types."
|
||||
*
|
||||
* Pulls a result from a LIVE ClickHouse as Arrow IPC, hands the identical
|
||||
* bytes to pyarrow and DuckDB, and records what each of them read back. Also
|
||||
* runs the reverse direction — 0pt's own encoder's bytes decoded by pyarrow +
|
||||
* DuckDB — because the encoder is hand-rolled and the only conformance
|
||||
* evidence worth having comes from somebody else's parser.
|
||||
*
|
||||
* CLICKHOUSE_URL=http://localhost:18123 CLICKHOUSE_AUTH=user:pass \
|
||||
* node scripts/bi-p005-arrow-roundtrip.mjs
|
||||
*
|
||||
* pyarrow/DuckDB run in a throwaway `python:3.12-slim` container, so the host
|
||||
* needs docker but no Python packages. Output:
|
||||
* evidence/bi-p002-p012/bi-p005-arrow-roundtrip.json
|
||||
*
|
||||
* The 0pt-encoded fixture is produced by `pnpm vitest run
|
||||
* tests/analytics/arrow-fixture.emit.ts`-style code inlined here via a tiny
|
||||
* tsx-free path: the encoder is re-implemented ONLY in the sense that this
|
||||
* script shells out to vitest to write the file, so the bytes tested are the
|
||||
* bytes the library produces.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const URL_ = process.env.CLICKHOUSE_URL;
|
||||
const AUTH = process.env.CLICKHOUSE_AUTH;
|
||||
if (!URL_) {
|
||||
console.error("CLICKHOUSE_URL is required — this script measures, it does not simulate.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const work = fs.mkdtempSync(path.join(os.tmpdir(), "bi-p005-"));
|
||||
const headers = AUTH ? { Authorization: `Basic ${Buffer.from(AUTH).toString("base64")}` } : {};
|
||||
|
||||
const QUERY = `
|
||||
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
|
||||
)`;
|
||||
|
||||
async function chArrow(sql, settings) {
|
||||
const u = new URL(URL_);
|
||||
u.searchParams.set("query", sql);
|
||||
for (const [k, v] of Object.entries(settings)) u.searchParams.set(k, v);
|
||||
const res = await fetch(u.toString(), { method: "POST", headers, body: "" });
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (!res.ok) throw new Error(`CH ${res.status}: ${buf.toString("utf8").slice(0, 300)}`);
|
||||
return buf;
|
||||
}
|
||||
|
||||
const PY = `
|
||||
import pyarrow as pa, pyarrow.ipc as ipc, duckdb, json, sys, os
|
||||
out = {}
|
||||
for name in sorted(os.listdir('/w')):
|
||||
if not name.endswith('.arrow'):
|
||||
continue
|
||||
tbl = ipc.open_stream(pa.BufferReader(open('/w/'+name,'rb').read())).read_all()
|
||||
con = duckdb.connect(); con.register('t', tbl)
|
||||
out[name] = {
|
||||
"pyarrow_version": pa.__version__,
|
||||
"duckdb_version": duckdb.__version__,
|
||||
"schema": [{"name": f.name, "type": str(f.type)} for f in tbl.schema],
|
||||
"num_rows": tbl.num_rows,
|
||||
"pyarrow_values": json.loads(json.dumps(tbl.to_pydict(), default=str)),
|
||||
"duckdb_types": [(r[0], r[1]) for r in con.execute("describe select * from t").fetchall()],
|
||||
"duckdb_rows": [[str(c) for c in row] for row in con.execute("select * from t order by n").fetchall()],
|
||||
}
|
||||
print(json.dumps(out))
|
||||
`;
|
||||
|
||||
async function main() {
|
||||
const version = (await (async () => {
|
||||
const u = new URL(URL_);
|
||||
u.searchParams.set("query", "SELECT version() FORMAT TSVRaw");
|
||||
const r = await fetch(u.toString(), { method: "POST", headers, body: "" });
|
||||
return (await r.text()).trim();
|
||||
})());
|
||||
|
||||
// Direction 1: ClickHouse's own Arrow C++ output.
|
||||
const chBytes = await chArrow(`${QUERY} FORMAT ArrowStream`, {
|
||||
output_format_arrow_string_as_string: "1",
|
||||
output_format_arrow_low_cardinality_as_dictionary: "0",
|
||||
});
|
||||
fs.writeFileSync(path.join(work, "01_clickhouse_encoded.arrow"), chBytes);
|
||||
|
||||
// Direction 2: 0pt's own encoder. Driven through the SHIPPED module via
|
||||
// tests/analytics/arrow-fixture.test.ts (ARROW_FIXTURE_DIR) so the bytes
|
||||
// pyarrow sees are the library's, not a copy pasted into this script.
|
||||
execFileSync(
|
||||
"npx",
|
||||
["vitest", "run", "tests/analytics/arrow-fixture.test.ts"],
|
||||
{ stdio: "inherit", env: { ...process.env, ARROW_FIXTURE_DIR: work } },
|
||||
);
|
||||
|
||||
// The reader script goes in as a FILE, not as `python -c "..."`: passing a
|
||||
// multi-line program through a shell argument mangles the newlines.
|
||||
fs.writeFileSync(path.join(work, "read_arrow.py"), PY);
|
||||
const raw = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm", "-v", `${work}:/w`, "-w", "/w", "python:3.12-slim",
|
||||
"bash", "-lc",
|
||||
"pip install --quiet pyarrow duckdb pytz >/dev/null 2>&1 && python /w/read_arrow.py",
|
||||
],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
const decoded = JSON.parse(raw);
|
||||
const evidence = {
|
||||
generated_at: new Date().toISOString(),
|
||||
clickhouse_version: version,
|
||||
query: QUERY.trim(),
|
||||
directions: {
|
||||
"clickhouse -> arrow -> pyarrow/duckdb": decoded["01_clickhouse_encoded.arrow"],
|
||||
"0pt encoder -> arrow -> pyarrow/duckdb": decoded["02_0pt_encoded.arrow"],
|
||||
},
|
||||
byte_sizes: {
|
||||
clickhouse_encoded: chBytes.length,
|
||||
opt_encoded: fs.statSync(path.join(work, "02_0pt_encoded.arrow")).size,
|
||||
},
|
||||
verdict: null,
|
||||
};
|
||||
|
||||
// The DoD is "without semantic loss": the two independent readers must agree
|
||||
// on the SAME values for both encoders.
|
||||
const a = evidence.directions["clickhouse -> arrow -> pyarrow/duckdb"];
|
||||
const b = evidence.directions["0pt encoder -> arrow -> pyarrow/duckdb"];
|
||||
const same =
|
||||
JSON.stringify(a.pyarrow_values) === JSON.stringify(b.pyarrow_values) &&
|
||||
JSON.stringify(a.duckdb_rows) === JSON.stringify(b.duckdb_rows) &&
|
||||
JSON.stringify(a.duckdb_types) === JSON.stringify(b.duckdb_types);
|
||||
evidence.verdict = same
|
||||
? "PASS — pyarrow and DuckDB read identical values and types from BOTH encoders"
|
||||
: "FAIL — the two encoders do not round-trip to the same values";
|
||||
|
||||
const out = path.join(process.cwd(), "evidence", "bi-p002-p012", "bi-p005-arrow-roundtrip.json");
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, JSON.stringify(evidence, null, 2) + "\n");
|
||||
console.log(evidence.verdict);
|
||||
console.log(`wrote ${out}`);
|
||||
if (!same) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => fs.rmSync(work, { recursive: true, force: true }));
|
||||
Executable
+311
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BI-P010 DoD: "Benchmark representative VehIQ-scale fixture and generic SaaS
|
||||
* fixture."
|
||||
*
|
||||
* Builds both fixtures in a THROWAWAY database on a ClickHouse the operator
|
||||
* points it at, times the query shapes each schema exists to serve, and writes
|
||||
* a machine-readable transcript to
|
||||
* evidence/bi-p002-p012/bi-p010-benchmark.json.
|
||||
*
|
||||
* CLICKHOUSE_URL=http://localhost:18123 \
|
||||
* CLICKHOUSE_AUTH=user:pass \
|
||||
* node scripts/bi-p010-benchmark.mjs
|
||||
*
|
||||
* It also measures the COUNTERFACTUAL the program warns about — "one giant
|
||||
* unbounded table for every analytics use case" — by running the same
|
||||
* questions against a single wide table with a non-tenant-leading sort key.
|
||||
* Without that comparison the split is an assertion; with it, it is a number.
|
||||
*
|
||||
* NEVER point this at the b0ts ClickHouse or any quarantined market data: it
|
||||
* CREATEs and DROPs a database.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const URL_ = process.env.CLICKHOUSE_URL;
|
||||
const AUTH = process.env.CLICKHOUSE_AUTH;
|
||||
if (!URL_) {
|
||||
console.error("CLICKHOUSE_URL is required. Refusing to invent numbers.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const DB = `opt_bench_${Date.now().toString(36)}`;
|
||||
const headers = AUTH ? { Authorization: `Basic ${Buffer.from(AUTH).toString("base64")}` } : {};
|
||||
|
||||
async function ch(sql, body, settings = {}) {
|
||||
const u = new URL(URL_);
|
||||
u.searchParams.set("query", sql);
|
||||
for (const [k, v] of Object.entries(settings)) u.searchParams.set(k, v);
|
||||
const res = await fetch(u.toString(), { method: "POST", headers, body: body ?? "" });
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`CH ${res.status}: ${text.slice(0, 400)}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function json(sql, settings) {
|
||||
const t = await ch(sql, undefined, settings);
|
||||
return t ? JSON.parse(t) : { data: [] };
|
||||
}
|
||||
|
||||
/** Run `sql` n times, return {p50, p95, min, max, rows_read, bytes_read}. */
|
||||
async function timeQuery(label, sql, n = 12) {
|
||||
// One warm-up so we time the query, not the page cache miss.
|
||||
await ch(sql);
|
||||
const times = [];
|
||||
let stats = {};
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const out = await json(sql);
|
||||
times.push(Number(process.hrtime.bigint() - t0) / 1e6);
|
||||
stats = out.statistics ?? stats;
|
||||
}
|
||||
times.sort((a, b) => a - b);
|
||||
const pick = (p) => Number(times[Math.min(times.length - 1, Math.floor((times.length * p) / 100))].toFixed(2));
|
||||
return {
|
||||
label,
|
||||
runs: n,
|
||||
p50_ms: pick(50),
|
||||
p95_ms: pick(95),
|
||||
min_ms: Number(times[0].toFixed(2)),
|
||||
max_ms: Number(times[times.length - 1].toFixed(2)),
|
||||
rows_read: stats.rows_read ?? null,
|
||||
bytes_read: stats.bytes_read ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const evidence = {
|
||||
generated_at: new Date().toISOString(),
|
||||
clickhouse_version: null,
|
||||
database: DB,
|
||||
fixtures: {},
|
||||
measurements: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
evidence.clickhouse_version = (await ch("SELECT version() FORMAT TSVRaw")).trim();
|
||||
await ch(`CREATE DATABASE ${DB}`);
|
||||
|
||||
// ── the BI-P010 schema under test ──────────────────────────────────────
|
||||
await ch(`CREATE TABLE ${DB}.opt_analytics_event (
|
||||
tenant_id LowCardinality(String), project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String), metric LowCardinality(String),
|
||||
event_id String, event_time DateTime64(3,'UTC'), value Float64,
|
||||
dim_names Array(LowCardinality(String)), dim_values Array(String),
|
||||
ingested_at DateTime64(3,'UTC') DEFAULT now64(3),
|
||||
INDEX idx_dim_values dim_values TYPE bloom_filter(0.01) GRANULARITY 4)
|
||||
ENGINE = MergeTree() PARTITION BY toYYYYMM(event_time)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, metric, event_time)`);
|
||||
|
||||
await ch(`CREATE TABLE ${DB}.opt_metric_daily (
|
||||
tenant_id LowCardinality(String), project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String), metric LowCardinality(String), day Date,
|
||||
sum_state AggregateFunction(sum, Float64), count_state AggregateFunction(count, Float64),
|
||||
min_state AggregateFunction(min, Float64), max_state AggregateFunction(max, Float64))
|
||||
ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMM(day)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, metric, day)`);
|
||||
|
||||
await ch(`CREATE MATERIALIZED VIEW ${DB}.opt_metric_daily_mv TO ${DB}.opt_metric_daily AS
|
||||
SELECT tenant_id, project_id, dataset_id, metric, toDate(event_time) AS day,
|
||||
sumState(value) AS sum_state, countState(value) AS count_state,
|
||||
minState(value) AS min_state, maxState(value) AS max_state
|
||||
FROM ${DB}.opt_analytics_event
|
||||
GROUP BY tenant_id, project_id, dataset_id, metric, day`);
|
||||
|
||||
await ch(`CREATE TABLE ${DB}.opt_dataset_projection (
|
||||
tenant_id LowCardinality(String), project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String), entity_key String,
|
||||
as_of DateTime64(3,'UTC'), version UInt64,
|
||||
attributes Map(String,String), measures Map(String,Float64),
|
||||
INDEX idx_entity entity_key TYPE bloom_filter(0.01) GRANULARITY 4)
|
||||
ENGINE = ReplacingMergeTree(version) PARTITION BY (tenant_id)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, entity_key)`);
|
||||
|
||||
// ── the COUNTERFACTUAL the program warns about ─────────────────────────
|
||||
// One table for everything, sorted by time (the shape people reach for when
|
||||
// "we'll just put it all in events").
|
||||
await ch(`CREATE TABLE ${DB}.giant_everything (
|
||||
tenant_id String, project_id String, dataset_id String, metric String,
|
||||
entity_key String, event_time DateTime64(3,'UTC'), value Float64,
|
||||
dim_names Array(String), dim_values Array(String),
|
||||
attributes Map(String,String), measures Map(String,Float64))
|
||||
ENGINE = MergeTree() PARTITION BY toYYYYMM(event_time)
|
||||
ORDER BY (event_time, tenant_id)`);
|
||||
|
||||
// ── FIXTURE 1: generic SaaS event stream ───────────────────────────────
|
||||
// 200 tenants x 12 metrics x 90 days x 25 events/day ~= 5.4M events.
|
||||
const SAAS_TENANTS = 200, SAAS_METRICS = 12, SAAS_DAYS = 90, SAAS_PER_DAY = 25;
|
||||
const saasRows = SAAS_TENANTS * SAAS_METRICS * SAAS_DAYS * SAAS_PER_DAY;
|
||||
console.log(`seeding SaaS fixture: ${saasRows.toLocaleString()} events...`);
|
||||
const t0saas = Date.now();
|
||||
await ch(`INSERT INTO ${DB}.opt_analytics_event
|
||||
SELECT concat('t_', toString(number % ${SAAS_TENANTS})) AS tenant_id,
|
||||
'p_main' AS project_id,
|
||||
concat('ds_', toString(number % 8)) AS dataset_id,
|
||||
concat('metric_', toString(number % ${SAAS_METRICS})) AS metric,
|
||||
toString(number) AS event_id,
|
||||
toDateTime64('2026-06-01 00:00:00', 3, 'UTC') + toIntervalSecond(number % ${SAAS_DAYS * 86400}) AS event_time,
|
||||
toFloat64(number % 997) AS value,
|
||||
['plan','region'] AS dim_names,
|
||||
[if(number % 3 = 0, 'pro', 'free'), if(number % 5 = 0, 'eu-north', 'eu-central')] AS dim_values,
|
||||
now64(3) AS ingested_at
|
||||
FROM numbers(${saasRows})`);
|
||||
const saasSeedMs = Date.now() - t0saas;
|
||||
|
||||
// ── FIXTURE 2: VehIQ-scale vehicle projections ─────────────────────────
|
||||
// 2M vehicles for the tenant under test, plus 2M belonging to other
|
||||
// tenants — so a tenant-scoped lookup has to prove it prunes.
|
||||
const VEHICLES = 2_000_000, DECOYS = 2_000_000;
|
||||
console.log(`seeding VehIQ fixture: ${(VEHICLES + DECOYS).toLocaleString()} vehicle projections...`);
|
||||
const t0veh = Date.now();
|
||||
await ch(`INSERT INTO ${DB}.opt_dataset_projection
|
||||
SELECT 't_vehiq' AS tenant_id, 'p_main' AS project_id, 'ds_vehicles' AS dataset_id,
|
||||
concat('YV1', leftPad(toString(number), 14, '0')) AS entity_key,
|
||||
toDateTime64('2026-09-01 00:00:00', 3, 'UTC') AS as_of,
|
||||
1 AS version,
|
||||
map('make', arrayElement(['volvo','saab','audi','vw','toyota'], (number % 5) + 1),
|
||||
'fuel', arrayElement(['diesel','petrol','ev','hybrid'], (number % 4) + 1)) AS attributes,
|
||||
map('asking_price', toFloat64(50000 + number % 900000),
|
||||
'mileage_km', toFloat64(number % 400000)) AS measures
|
||||
FROM numbers(${VEHICLES})`);
|
||||
await ch(`INSERT INTO ${DB}.opt_dataset_projection
|
||||
SELECT concat('t_other_', toString(number % 50)) AS tenant_id, 'p_main', 'ds_vehicles',
|
||||
concat('WBA', leftPad(toString(number), 14, '0')),
|
||||
toDateTime64('2026-09-01 00:00:00', 3, 'UTC'), 1,
|
||||
map('make','bmw'), map('asking_price', toFloat64(number % 500000))
|
||||
FROM numbers(${DECOYS})`);
|
||||
const vehSeedMs = Date.now() - t0veh;
|
||||
|
||||
// ── the same data in the giant table ───────────────────────────────────
|
||||
console.log("seeding the counterfactual giant table...");
|
||||
await ch(`INSERT INTO ${DB}.giant_everything
|
||||
SELECT tenant_id, project_id, dataset_id, metric, '' AS entity_key, event_time, value,
|
||||
dim_names, dim_values, map() AS attributes, map() AS measures
|
||||
FROM ${DB}.opt_analytics_event`);
|
||||
await ch(`INSERT INTO ${DB}.giant_everything
|
||||
SELECT tenant_id, project_id, dataset_id, '' AS metric, entity_key,
|
||||
as_of AS event_time, 0 AS value, [] AS dim_names, [] AS dim_values, attributes, measures
|
||||
FROM ${DB}.opt_dataset_projection`);
|
||||
|
||||
await ch(`OPTIMIZE TABLE ${DB}.opt_analytics_event FINAL`, undefined, { max_execution_time: "600" });
|
||||
await ch(`OPTIMIZE TABLE ${DB}.opt_dataset_projection FINAL`, undefined, { max_execution_time: "600" });
|
||||
await ch(`OPTIMIZE TABLE ${DB}.giant_everything FINAL`, undefined, { max_execution_time: "900" });
|
||||
|
||||
const sizes = await json(`SELECT table, sum(rows) AS rows, sum(bytes_on_disk) AS bytes
|
||||
FROM system.parts WHERE database='${DB}' AND active GROUP BY table ORDER BY table FORMAT JSON`);
|
||||
evidence.fixtures = {
|
||||
saas: { events: saasRows, tenants: SAAS_TENANTS, metrics: SAAS_METRICS, days: SAAS_DAYS, seed_ms: saasSeedMs },
|
||||
vehiq: { vehicles_under_test: VEHICLES, decoy_vehicles: DECOYS, seed_ms: vehSeedMs },
|
||||
on_disk: sizes.data.map((r) => ({ table: r.table, rows: Number(r.rows), bytes: Number(r.bytes) })),
|
||||
};
|
||||
|
||||
// ── measurements ───────────────────────────────────────────────────────
|
||||
const T = "t_57"; // one representative SaaS tenant
|
||||
|
||||
evidence.measurements.push(
|
||||
await timeQuery(
|
||||
"SaaS dashboard panel: 90-day metric total for ONE tenant (purpose-built event table)",
|
||||
`SELECT sum(value) AS value FROM ${DB}.opt_analytics_event
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SAME question against ONE GIANT TABLE (time-leading sort key)",
|
||||
`SELECT sum(value) AS value FROM ${DB}.giant_everything
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SaaS time series: daily series for ONE tenant from the AggregatingMergeTree rollup",
|
||||
`SELECT day, sumMerge(sum_state) AS value FROM ${DB}.opt_metric_daily
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' GROUP BY day ORDER BY day FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SAME time series computed from raw events instead of the rollup",
|
||||
`SELECT toDate(event_time) AS day, sum(value) AS value FROM ${DB}.opt_analytics_event
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' GROUP BY day ORDER BY day FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SaaS group-by: one tenant sliced by a dimension",
|
||||
`SELECT dim_values[indexOf(dim_names, 'plan')] AS grp, count() AS value
|
||||
FROM ${DB}.opt_analytics_event WHERE tenant_id = '${T}' AND metric = 'metric_3'
|
||||
GROUP BY grp ORDER BY value DESC FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"VehIQ point lookup: one vehicle by key, tenant-scoped (purpose-built projection)",
|
||||
`SELECT entity_key, measures['asking_price'] AS p FROM ${DB}.opt_dataset_projection
|
||||
WHERE tenant_id = 't_vehiq' AND project_id = 'p_main' AND dataset_id = 'ds_vehicles'
|
||||
AND entity_key = 'YV100000001234567' FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"SAME point lookup against ONE GIANT TABLE",
|
||||
`SELECT entity_key, measures['asking_price'] AS p FROM ${DB}.giant_everything
|
||||
WHERE tenant_id = 't_vehiq' AND dataset_id = 'ds_vehicles'
|
||||
AND entity_key = 'YV100000001234567' FORMAT JSON`,
|
||||
),
|
||||
await timeQuery(
|
||||
"VehIQ aggregate: average asking price by make for ONE tenant across 2M vehicles",
|
||||
`SELECT attributes['make'] AS grp, avg(measures['asking_price']) AS value
|
||||
FROM ${DB}.opt_dataset_projection
|
||||
WHERE tenant_id = 't_vehiq' AND project_id = 'p_main' AND dataset_id = 'ds_vehicles'
|
||||
GROUP BY grp ORDER BY value DESC FORMAT JSON`,
|
||||
8,
|
||||
),
|
||||
await timeQuery(
|
||||
"SAME aggregate against ONE GIANT TABLE",
|
||||
`SELECT attributes['make'] AS grp, avg(measures['asking_price']) AS value
|
||||
FROM ${DB}.giant_everything
|
||||
WHERE tenant_id = 't_vehiq' AND dataset_id = 'ds_vehicles' AND entity_key != ''
|
||||
GROUP BY grp ORDER BY value DESC FORMAT JSON`,
|
||||
8,
|
||||
),
|
||||
);
|
||||
|
||||
// Index pruning evidence: how many granules the tenant predicate skips.
|
||||
const plan = await ch(
|
||||
`EXPLAIN indexes = 1 SELECT sum(value) FROM ${DB}.opt_analytics_event
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' FORMAT TSVRaw`,
|
||||
);
|
||||
const giantPlan = await ch(
|
||||
`EXPLAIN indexes = 1 SELECT sum(value) FROM ${DB}.giant_everything
|
||||
WHERE tenant_id = '${T}' AND metric = 'metric_3' FORMAT TSVRaw`,
|
||||
);
|
||||
const granules = (t) => {
|
||||
const m = [...t.matchAll(/Granules: (\d+)\/(\d+)/g)].pop();
|
||||
return m ? { read: Number(m[1]), total: Number(m[2]) } : null;
|
||||
};
|
||||
evidence.index_pruning = {
|
||||
purpose_built_event_table: granules(plan),
|
||||
one_giant_table: granules(giantPlan),
|
||||
note:
|
||||
"Granules read/total for the SAME question. The tenant-leading ORDER BY is what turns the " +
|
||||
"forced tenant predicate (BI-P011) into a primary-key range scan instead of a full scan.",
|
||||
};
|
||||
|
||||
evidence.notes.push(
|
||||
"Fixtures are synthetic and generated in-server with numbers(); no production or b0ts data was read or written.",
|
||||
"Each timing is the p50/p95 of repeated runs after one warm-up, measured client-side over HTTP on the same host.",
|
||||
"The 'ONE GIANT TABLE' rows are the counterfactual BI-P010 warns against, measured rather than asserted.",
|
||||
);
|
||||
|
||||
const out = path.join(process.cwd(), "evidence", "bi-p002-p012", "bi-p010-benchmark.json");
|
||||
fs.writeFileSync(out, JSON.stringify(evidence, null, 2) + "\n");
|
||||
console.log(`wrote ${out}`);
|
||||
for (const m of evidence.measurements) {
|
||||
console.log(` ${m.p50_ms.toString().padStart(9)} ms p50 ${String(m.rows_read).padStart(10)} rows ${m.label}`);
|
||||
}
|
||||
console.log(" pruning:", JSON.stringify(evidence.index_pruning));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
try {
|
||||
await ch(`DROP DATABASE IF EXISTS ${DB}`);
|
||||
console.log(`dropped ${DB}`);
|
||||
} catch (e) {
|
||||
console.error("cleanup failed:", e.message);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
-- 0pt analytics domain model (BI-P002/BI-P003/BI-P004).
|
||||
--
|
||||
-- The nineteen canonical entities from BI-P002, one table each, named after
|
||||
-- the entity (src/analytics/domain/entities.ts's ANALYTICS_ENTITY_TABLES maps
|
||||
-- entity -> table, and tests/analytics/analytics-domain-postgres.realinfra.test.ts
|
||||
-- asserts every entry exists here -- a rename or omission is a test failure,
|
||||
-- not silent drift).
|
||||
--
|
||||
-- Every tenant-owned table carries `tenant_id` + `project_id`, and every one
|
||||
-- of them is indexed leading with (tenant_id, project_id) so a tenant-scoped
|
||||
-- read is an index range scan rather than a filter over everyone's rows.
|
||||
--
|
||||
-- IMMUTABILITY (BI-P002: "Use immutable versions for MetricVersion,
|
||||
-- SemanticModel version, DashboardVersion, DatasetVersion") is enforced by
|
||||
-- BEFORE UPDATE/DELETE triggers, not by convention and not by a TypeScript
|
||||
-- `readonly`. A compiler opinion does not survive a psql session; a trigger
|
||||
-- does. `opt_analytics_forbid_mutation()` raises, so an UPDATE against any of
|
||||
-- the four version tables fails loudly.
|
||||
--
|
||||
-- Idempotent throughout (CREATE ... IF NOT EXISTS, CREATE OR REPLACE
|
||||
-- FUNCTION, DROP TRIGGER IF EXISTS before CREATE TRIGGER) so it can be
|
||||
-- replayed onto an already-migrated database -- the same contract
|
||||
-- sql/002_source_registry.sql and sql/007_ingestion_plane.sql keep, and what
|
||||
-- the restore drill (`psql -d opt < sql/001... < ... < sql/008...`) needs.
|
||||
--
|
||||
-- Ordering: standalone. It references no earlier table, so 001-007 are not a
|
||||
-- prerequisite; it is numbered 008 to keep the replay order linear.
|
||||
|
||||
-- ── immutability enforcement ──────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION opt_analytics_forbid_mutation() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'BI-P002: % rows are immutable (attempted %); mint a new version instead',
|
||||
TG_TABLE_NAME, TG_OP
|
||||
USING ERRCODE = 'restrict_violation';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ── 1. AnalyticsDataset ───────────────────────────────────────────────────
|
||||
-- BI-P003 DoD: tier/schema/owner/source/freshness/quality/retention/
|
||||
-- classification are all NOT NULL. An optional declaration is not one.
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dataset (
|
||||
dataset_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tier TEXT NOT NULL CHECK (tier IN ('RAW','NORMALIZED','CURATED','SERVING')),
|
||||
schema_contract_id TEXT NOT NULL,
|
||||
schema_contract_version TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
source_ref TEXT NOT NULL,
|
||||
freshness_slo_seconds INTEGER NOT NULL CHECK (freshness_slo_seconds > 0),
|
||||
quality_rule_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
retention_days INTEGER NOT NULL CHECK (retention_days >= 0),
|
||||
classification TEXT NOT NULL CHECK (classification IN ('none','pseudonymous','personal','sensitive')),
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','active','deprecated','retired')),
|
||||
raw_access_exception TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dataset_tenant_idx
|
||||
ON opt_analytics_dataset (tenant_id, project_id, status);
|
||||
|
||||
-- ── 2. DatasetVersion (IMMUTABLE) ─────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dataset_version (
|
||||
dataset_version_id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
contract_hash TEXT NOT NULL,
|
||||
schema_contract_version TEXT NOT NULL,
|
||||
record_count BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (dataset_id, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dataset_version_tenant_idx
|
||||
ON opt_analytics_dataset_version (tenant_id, project_id, dataset_id, version);
|
||||
DROP TRIGGER IF EXISTS opt_analytics_dataset_version_immutable ON opt_analytics_dataset_version;
|
||||
CREATE TRIGGER opt_analytics_dataset_version_immutable
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_dataset_version
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation();
|
||||
|
||||
-- ── 3. DatasetField ───────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dataset_field (
|
||||
dataset_field_id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
dataset_version INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
field_type TEXT NOT NULL CHECK (field_type IN ('string','int64','float64','bool','date','timestamp','decimal','json')),
|
||||
nullable BOOLEAN NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('identifier','dimension','measure','attribute')),
|
||||
primary_identifier BOOLEAN NOT NULL DEFAULT false,
|
||||
classification TEXT NOT NULL DEFAULT 'none' CHECK (classification IN ('none','pseudonymous','personal','sensitive')),
|
||||
description TEXT,
|
||||
UNIQUE (dataset_id, dataset_version, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dataset_field_tenant_idx
|
||||
ON opt_analytics_dataset_field (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 4. DatasetGrant ───────────────────────────────────────────────────────
|
||||
-- allowed_purposes is NOT NULL and CHECKed non-empty: a grant without a
|
||||
-- purpose is a GDPR purpose-limitation hole, so the database refuses it.
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dataset_grant (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user','role','service','bi_application')),
|
||||
level TEXT NOT NULL CHECK (level IN ('read','query','export','admin')),
|
||||
-- coalesce(...) is load-bearing: array_length('{}', 1) is NULL in Postgres,
|
||||
-- and a CHECK that evaluates to NULL PASSES. Written the obvious way, this
|
||||
-- constraint could never fail -- caught by
|
||||
-- tests/analytics/analytics-domain-postgres.realinfra.test.ts inserting an
|
||||
-- empty array and watching it succeed.
|
||||
allowed_purposes TEXT[] NOT NULL CONSTRAINT opt_analytics_dataset_grant_purposes_nonempty
|
||||
CHECK (coalesce(array_length(allowed_purposes, 1), 0) >= 1),
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dataset_grant_tenant_idx
|
||||
ON opt_analytics_dataset_grant (tenant_id, project_id, dataset_id, subject_id);
|
||||
|
||||
-- ── 5. MetricDefinition ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_metric_definition (
|
||||
metric_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
aggregation TEXT NOT NULL CHECK (aggregation IN ('sum','avg','min','max','count','count_distinct','ratio','derived')),
|
||||
measure_field TEXT NOT NULL,
|
||||
denominator_field TEXT,
|
||||
allowed_dimension_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
current_version INTEGER NOT NULL DEFAULT 1,
|
||||
owner_id TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_metric_definition_tenant_idx
|
||||
ON opt_analytics_metric_definition (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 6. MetricVersion (IMMUTABLE) ──────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_metric_version (
|
||||
metric_version_id TEXT PRIMARY KEY,
|
||||
metric_id TEXT NOT NULL REFERENCES opt_analytics_metric_definition (metric_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
definition_json TEXT NOT NULL,
|
||||
definition_hash TEXT NOT NULL,
|
||||
change_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (metric_id, version),
|
||||
-- BI-P031 governance in the schema: every version after the first must say
|
||||
-- WHY the number changed meaning.
|
||||
CHECK (version = 1 OR (change_reason IS NOT NULL AND length(btrim(change_reason)) > 0))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_metric_version_tenant_idx
|
||||
ON opt_analytics_metric_version (tenant_id, project_id, metric_id, version);
|
||||
DROP TRIGGER IF EXISTS opt_analytics_metric_version_immutable ON opt_analytics_metric_version;
|
||||
CREATE TRIGGER opt_analytics_metric_version_immutable
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_metric_version
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation();
|
||||
|
||||
-- ── 7. DimensionDefinition ────────────────────────────────────────────────
|
||||
-- The allow-list the query compiler resolves caller-supplied dimension names
|
||||
-- through (BI-P011 "dimension identifier injection"). `field_name` is CHECKed
|
||||
-- against a strict identifier pattern in the DATABASE as well as in the
|
||||
-- compiler, because a hostile row here would otherwise be the injection.
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dimension_definition (
|
||||
dimension_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
field_name TEXT NOT NULL CHECK (field_name ~ '^[A-Za-z_][A-Za-z0-9_]{0,62}$'),
|
||||
field_type TEXT NOT NULL CHECK (field_type IN ('string','int64','float64','bool','date','timestamp','decimal','json')),
|
||||
classification TEXT NOT NULL DEFAULT 'none' CHECK (classification IN ('none','pseudonymous','personal','sensitive')),
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, dataset_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dimension_definition_tenant_idx
|
||||
ON opt_analytics_dimension_definition (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 8. SemanticModel (versions IMMUTABLE) ─────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_semantic_model (
|
||||
semantic_model_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
model_json TEXT NOT NULL,
|
||||
model_hash TEXT NOT NULL,
|
||||
dataset_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (tenant_id, name, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_semantic_model_tenant_idx
|
||||
ON opt_analytics_semantic_model (tenant_id, project_id, name, version);
|
||||
DROP TRIGGER IF EXISTS opt_analytics_semantic_model_immutable ON opt_analytics_semantic_model;
|
||||
CREATE TRIGGER opt_analytics_semantic_model_immutable
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_semantic_model
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation();
|
||||
|
||||
-- ── 9. AnalyticsQuery ─────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_query (
|
||||
query_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
surface TEXT NOT NULL CHECK (surface IN ('metric','sql','projection')),
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
metric_id TEXT REFERENCES opt_analytics_metric_definition (metric_id),
|
||||
request_json TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_query_tenant_idx
|
||||
ON opt_analytics_query (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 10. QueryExecution ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_query_execution (
|
||||
execution_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
query_id TEXT REFERENCES opt_analytics_query (query_id),
|
||||
engine_query_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('queued','running','succeeded','failed','cancelled','rejected')),
|
||||
provider TEXT NOT NULL,
|
||||
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
duration_ms INTEGER,
|
||||
rows_read BIGINT,
|
||||
bytes_read BIGINT,
|
||||
result_rows BIGINT,
|
||||
error_code TEXT,
|
||||
correlation_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_query_execution_tenant_idx
|
||||
ON opt_analytics_query_execution (tenant_id, project_id, submitted_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_query_execution_engine_idx
|
||||
ON opt_analytics_query_execution (engine_query_id);
|
||||
|
||||
-- ── 11. QueryResult ───────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_query_result (
|
||||
result_id TEXT PRIMARY KEY,
|
||||
execution_id TEXT NOT NULL REFERENCES opt_analytics_query_execution (execution_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL CHECK (encoding IN ('json','arrow_ipc')),
|
||||
row_count BIGINT NOT NULL,
|
||||
artifact_ref TEXT,
|
||||
truncated BOOLEAN NOT NULL DEFAULT false,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_query_result_tenant_idx
|
||||
ON opt_analytics_query_result (tenant_id, project_id, execution_id);
|
||||
|
||||
-- ── 12. DashboardDefinition ───────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dashboard_definition (
|
||||
dashboard_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
current_version INTEGER NOT NULL DEFAULT 1,
|
||||
owner_id TEXT NOT NULL,
|
||||
dataset_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dashboard_definition_tenant_idx
|
||||
ON opt_analytics_dashboard_definition (tenant_id, project_id);
|
||||
|
||||
-- ── 13. DashboardVersion (IMMUTABLE) ──────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dashboard_version (
|
||||
dashboard_version_id TEXT PRIMARY KEY,
|
||||
dashboard_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_definition (dashboard_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
layout_json TEXT NOT NULL,
|
||||
layout_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (dashboard_id, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dashboard_version_tenant_idx
|
||||
ON opt_analytics_dashboard_version (tenant_id, project_id, dashboard_id, version);
|
||||
DROP TRIGGER IF EXISTS opt_analytics_dashboard_version_immutable ON opt_analytics_dashboard_version;
|
||||
CREATE TRIGGER opt_analytics_dashboard_version_immutable
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_dashboard_version
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation();
|
||||
|
||||
-- ── 14. ReportDefinition ──────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_report_definition (
|
||||
report_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dashboard_id TEXT REFERENCES opt_analytics_dashboard_definition (dashboard_id),
|
||||
query_id TEXT REFERENCES opt_analytics_query (query_id),
|
||||
format TEXT NOT NULL CHECK (format IN ('pdf','csv','xlsx','parquet','arrow_ipc')),
|
||||
schedule_cron TEXT,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
CHECK (dashboard_id IS NOT NULL OR query_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_report_definition_tenant_idx
|
||||
ON opt_analytics_report_definition (tenant_id, project_id);
|
||||
|
||||
-- ── 15. ReportRun ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_report_run (
|
||||
report_run_id TEXT PRIMARY KEY,
|
||||
report_id TEXT NOT NULL REFERENCES opt_analytics_report_definition (report_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('queued','running','succeeded','failed','cancelled','rejected')),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
artifact_ref TEXT,
|
||||
error_code TEXT,
|
||||
correlation_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_report_run_tenant_idx
|
||||
ON opt_analytics_report_run (tenant_id, project_id, report_id, started_at DESC);
|
||||
|
||||
-- ── 16. DataQualityRule ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_data_quality_rule (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('not_null','unique','accepted_values','range','freshness','row_count','referential','custom')),
|
||||
field_name TEXT,
|
||||
params_json TEXT NOT NULL DEFAULT '{}',
|
||||
severity TEXT NOT NULL CHECK (severity IN ('warn','block')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_data_quality_rule_tenant_idx
|
||||
ON opt_analytics_data_quality_rule (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 17. DataQualityRun ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_data_quality_run (
|
||||
quality_run_id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES opt_analytics_data_quality_rule (rule_id),
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
dataset_version INTEGER,
|
||||
passed BOOLEAN NOT NULL,
|
||||
violation_count BIGINT NOT NULL DEFAULT 0,
|
||||
evaluated_rows BIGINT NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
correlation_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_data_quality_run_tenant_idx
|
||||
ON opt_analytics_data_quality_run (tenant_id, project_id, dataset_id, started_at DESC);
|
||||
|
||||
-- ── 18. LineageReference ──────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_lineage_reference (
|
||||
lineage_reference_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
job_name TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK (direction IN ('input','output')),
|
||||
entity_type TEXT NOT NULL CHECK (entity_type IN ('dataset','metric','dashboard','report')),
|
||||
entity_id TEXT NOT NULL,
|
||||
counterpart_ref TEXT NOT NULL,
|
||||
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_lineage_reference_tenant_idx
|
||||
ON opt_analytics_lineage_reference (tenant_id, project_id, entity_type, entity_id);
|
||||
|
||||
-- ── 19. BIApplicationBinding ──────────────────────────────────────────────
|
||||
-- NORMATIVE: there is NO credential column here, and there must never be one.
|
||||
-- SIAX/0pt owns the analytics engine; a bound BI application authenticates to
|
||||
-- the 0pt Analytics API with a CL0UD identity and receives compiled,
|
||||
-- tenant-forced results. It never learns a ClickHouse DSN, user, or password.
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_bi_application_binding (
|
||||
binding_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('superset','evidence','des1gn','notebook','custom')),
|
||||
name TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
dataset_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
metric_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
-- Same NULL-CHECK trap as opt_analytics_dataset_grant; see the note there.
|
||||
allowed_purposes TEXT[] NOT NULL CONSTRAINT opt_analytics_bi_app_binding_purposes_nonempty
|
||||
CHECK (coalesce(array_length(allowed_purposes, 1), 0) >= 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_bi_application_binding_tenant_idx
|
||||
ON opt_analytics_bi_application_binding (tenant_id, project_id, principal_id);
|
||||
|
||||
-- ── constraint upgrade path ───────────────────────────────────────────────
|
||||
-- CREATE TABLE IF NOT EXISTS does nothing to a table that already exists, so
|
||||
-- a database that received an earlier copy of this file would keep the old
|
||||
-- (NULL-passing) purpose checks. These statements bring it forward and are
|
||||
-- no-ops on a fresh database.
|
||||
ALTER TABLE opt_analytics_dataset_grant
|
||||
DROP CONSTRAINT IF EXISTS opt_analytics_dataset_grant_purposes_nonempty;
|
||||
ALTER TABLE opt_analytics_dataset_grant
|
||||
ADD CONSTRAINT opt_analytics_dataset_grant_purposes_nonempty
|
||||
CHECK (coalesce(array_length(allowed_purposes, 1), 0) >= 1);
|
||||
|
||||
ALTER TABLE opt_analytics_bi_application_binding
|
||||
DROP CONSTRAINT IF EXISTS opt_analytics_bi_app_binding_purposes_nonempty;
|
||||
ALTER TABLE opt_analytics_bi_application_binding
|
||||
ADD CONSTRAINT opt_analytics_bi_app_binding_purposes_nonempty
|
||||
CHECK (coalesce(array_length(allowed_purposes, 1), 0) >= 1);
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* src/analytics/arrow/flatbuffer-builder.ts
|
||||
* ==========================================
|
||||
* A minimal FlatBuffers writer, sufficient for the Arrow IPC metadata
|
||||
* messages this repo emits (BI-P005).
|
||||
*
|
||||
* WHY HAND-ROLLED rather than `apache-arrow` from npm: adding a runtime
|
||||
* dependency to 0pt's blocking-typechecked capability layer means a lockfile
|
||||
* change, a new supply-chain surface, and a new CVE surface for every
|
||||
* consumer of this repo — for a format whose METADATA side is a few hundred
|
||||
* bytes of well-specified binary and whose DATA side is plain columnar memory
|
||||
* either way.
|
||||
*
|
||||
* The correctness bar is NOT "this looks right". The BI-P005 evidence script
|
||||
* (scripts/bi-p005-arrow-roundtrip.mjs) decodes this writer's output with
|
||||
* pyarrow AND DuckDB — two independent implementations of the specification —
|
||||
* so a mistake here surfaces as a failed decode by somebody else's parser,
|
||||
* not as a green test of my own assumptions.
|
||||
*
|
||||
* Layout facts implemented (FlatBuffers binary format, little-endian):
|
||||
* * the buffer is built BACK TO FRONT; `offset` counts bytes from the end;
|
||||
* * a uoffset stored at a field site holds `site_offset + 4 - target_offset`
|
||||
* so a reader resolves it as `position + value`;
|
||||
* * a table begins with an soffset `table_start - vtable_start`;
|
||||
* * a vtable is `[vtable_len:i16][table_len:i16][field_offsets:i16...]`,
|
||||
* with 0 meaning "field absent, use the default".
|
||||
*/
|
||||
|
||||
export type Offset = number;
|
||||
|
||||
const SIZEOF_INT = 4;
|
||||
|
||||
export class FlatBufferBuilder {
|
||||
private buf: Uint8Array;
|
||||
private view: DataView;
|
||||
/** Bytes written so far, counted from the END of `buf`. */
|
||||
private used = 0;
|
||||
private minAlign = 1;
|
||||
private vtable: number[] = [];
|
||||
private objectStart = 0;
|
||||
private inObject = false;
|
||||
/** Offsets of previously written vtables, for deduplication. */
|
||||
private vtables: number[] = [];
|
||||
|
||||
constructor(initial = 1024) {
|
||||
this.buf = new Uint8Array(initial);
|
||||
this.view = new DataView(this.buf.buffer);
|
||||
}
|
||||
|
||||
/** Bytes written so far (FlatBuffers "offset"). */
|
||||
get offset(): number {
|
||||
return this.used;
|
||||
}
|
||||
|
||||
private ensure(extra: number): void {
|
||||
if (this.used + extra <= this.buf.length) return;
|
||||
let size = this.buf.length;
|
||||
while (size < this.used + extra) size *= 2;
|
||||
const next = new Uint8Array(size);
|
||||
next.set(this.buf.subarray(this.buf.length - this.used), size - this.used);
|
||||
this.buf = next;
|
||||
this.view = new DataView(this.buf.buffer);
|
||||
}
|
||||
|
||||
private pad(n: number): void {
|
||||
if (n <= 0) return;
|
||||
this.ensure(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.used += 1;
|
||||
this.buf[this.buf.length - this.used] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** FlatBuffers `prep`: pad so that `additional` more bytes land aligned. */
|
||||
private prep(size: number, additional = 0): void {
|
||||
if (size > this.minAlign) this.minAlign = size;
|
||||
const alignSize = (~(this.used + additional) + 1) & (size - 1);
|
||||
this.pad(alignSize);
|
||||
}
|
||||
|
||||
writeInt8(v: number): void {
|
||||
this.ensure(1);
|
||||
this.used += 1;
|
||||
this.view.setInt8(this.buf.length - this.used, v);
|
||||
}
|
||||
|
||||
writeInt16(v: number): void {
|
||||
this.ensure(2);
|
||||
this.used += 2;
|
||||
this.view.setInt16(this.buf.length - this.used, v, true);
|
||||
}
|
||||
|
||||
writeInt32(v: number): void {
|
||||
this.ensure(4);
|
||||
this.used += 4;
|
||||
this.view.setInt32(this.buf.length - this.used, v, true);
|
||||
}
|
||||
|
||||
writeInt64(v: bigint): void {
|
||||
this.ensure(8);
|
||||
this.used += 8;
|
||||
this.view.setBigInt64(this.buf.length - this.used, v, true);
|
||||
}
|
||||
|
||||
writeBytes(bytes: Uint8Array): void {
|
||||
this.ensure(bytes.length);
|
||||
this.used += bytes.length;
|
||||
this.buf.set(bytes, this.buf.length - this.used);
|
||||
}
|
||||
|
||||
addInt8(slot: number, value: number, def: number): void {
|
||||
if (value === def) return;
|
||||
this.prep(1);
|
||||
this.writeInt8(value);
|
||||
this.slot(slot);
|
||||
}
|
||||
|
||||
addInt16(slot: number, value: number, def: number): void {
|
||||
if (value === def) return;
|
||||
this.prep(2);
|
||||
this.writeInt16(value);
|
||||
this.slot(slot);
|
||||
}
|
||||
|
||||
addInt32(slot: number, value: number, def: number): void {
|
||||
if (value === def) return;
|
||||
this.prep(4);
|
||||
this.writeInt32(value);
|
||||
this.slot(slot);
|
||||
}
|
||||
|
||||
addInt64(slot: number, value: bigint, def: bigint): void {
|
||||
if (value === def) return;
|
||||
this.prep(8);
|
||||
this.writeInt64(value);
|
||||
this.slot(slot);
|
||||
}
|
||||
|
||||
addOffset(slot: number, value: Offset, def: number): void {
|
||||
if (value === def) return;
|
||||
this.prep(SIZEOF_INT);
|
||||
this.writeInt32(this.used + SIZEOF_INT - value);
|
||||
this.slot(slot);
|
||||
}
|
||||
|
||||
private slot(slotNum: number): void {
|
||||
this.vtable[slotNum] = this.used;
|
||||
}
|
||||
|
||||
startObject(numFields: number): void {
|
||||
if (this.inObject) throw new Error("nested startObject");
|
||||
this.vtable = new Array<number>(numFields).fill(0);
|
||||
this.objectStart = this.used;
|
||||
this.inObject = true;
|
||||
}
|
||||
|
||||
endObject(): Offset {
|
||||
if (!this.inObject) throw new Error("endObject outside an object");
|
||||
this.prep(SIZEOF_INT);
|
||||
this.writeInt32(0); // soffset placeholder, patched below
|
||||
const objectOffset = this.used;
|
||||
|
||||
let trimmed = this.vtable.length;
|
||||
while (trimmed > 0 && this.vtable[trimmed - 1] === 0) trimmed -= 1;
|
||||
for (let i = trimmed - 1; i >= 0; i--) {
|
||||
this.writeInt16(this.vtable[i] !== 0 ? objectOffset - this.vtable[i] : 0);
|
||||
}
|
||||
this.writeInt16(objectOffset - this.objectStart); // table byte length
|
||||
const vtLen = (trimmed + 2) * 2;
|
||||
this.writeInt16(vtLen); // vtable byte length — lands at the vtable start
|
||||
|
||||
const vtStart = this.used;
|
||||
const vtAbs = this.buf.length - vtStart;
|
||||
let existing = 0;
|
||||
for (const candidate of this.vtables) {
|
||||
const candAbs = this.buf.length - candidate;
|
||||
if (this.view.getInt16(candAbs, true) !== vtLen) continue;
|
||||
let same = true;
|
||||
for (let i = 2; i < vtLen; i += 2) {
|
||||
if (this.view.getInt16(vtAbs + i, true) !== this.view.getInt16(candAbs + i, true)) {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (same) {
|
||||
existing = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const objAbs = this.buf.length - objectOffset;
|
||||
if (existing !== 0) {
|
||||
this.used -= vtLen; // discard the duplicate vtable just written
|
||||
this.view.setInt32(objAbs, existing - objectOffset, true);
|
||||
} else {
|
||||
this.vtables.push(vtStart);
|
||||
this.view.setInt32(objAbs, this.used - objectOffset, true);
|
||||
}
|
||||
this.inObject = false;
|
||||
return objectOffset;
|
||||
}
|
||||
|
||||
createString(s: string): Offset {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
this.writeInt8(0); // null terminator (readers rely on it)
|
||||
this.startVector(1, bytes.length, 1);
|
||||
this.writeBytes(bytes);
|
||||
return this.endVector(bytes.length);
|
||||
}
|
||||
|
||||
startVector(elemSize: number, numElems: number, alignment: number): void {
|
||||
this.prep(SIZEOF_INT, elemSize * numElems);
|
||||
this.prep(alignment, elemSize * numElems);
|
||||
}
|
||||
|
||||
endVector(numElems: number): Offset {
|
||||
this.writeInt32(numElems);
|
||||
return this.used;
|
||||
}
|
||||
|
||||
/** Vector of table offsets. Elements are written in reverse build order. */
|
||||
createOffsetVector(items: Offset[]): Offset {
|
||||
this.startVector(SIZEOF_INT, items.length, SIZEOF_INT);
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
this.writeInt32(this.used + SIZEOF_INT - items[i]);
|
||||
}
|
||||
return this.endVector(items.length);
|
||||
}
|
||||
|
||||
/** Finish the buffer with `root` as the root table. Returns the bytes. */
|
||||
finish(root: Offset): Uint8Array {
|
||||
this.prep(this.minAlign, SIZEOF_INT);
|
||||
this.prep(SIZEOF_INT);
|
||||
this.writeInt32(this.used + SIZEOF_INT - root);
|
||||
return this.buf.slice(this.buf.length - this.used);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* src/analytics/arrow/ipc.ts
|
||||
* ===========================
|
||||
* BI-P005 — APACHE ARROW INTERCHANGE.
|
||||
*
|
||||
* Canonical columnar result encoding ALONGSIDE JSON (JSON is not removed —
|
||||
* BI-P005 says "alongside"). Two paths, and knowing which is which matters:
|
||||
*
|
||||
* PASS-THROUGH (preferred for ClickHouse results): ClickHouse emits real
|
||||
* Arrow IPC itself via `FORMAT ArrowStream`. Those bytes were produced by
|
||||
* the Arrow C++ implementation; 0pt streams them out unchanged, so there is
|
||||
* no encoder of ours in the path at all. `clickhouseArrowStreamFormat()`
|
||||
* and `arrowStreamSettings()` are that path.
|
||||
*
|
||||
* ENCODED (for results 0pt itself assembles — projections, JSON-sourced
|
||||
* rows, exports): `encodeArrowStream()` writes the IPC stream here.
|
||||
*
|
||||
* Both paths are decodable by `decodeArrowStream()`, which exists so 0pt can
|
||||
* count rows and read the schema of an Arrow payload WITHOUT shelling out to
|
||||
* another runtime — needed to enforce BI-P012's `max_result_rows` on the
|
||||
* pass-through path, where the row count is not otherwise visible.
|
||||
*
|
||||
* "Supported types" (the DoD's qualifier) is `ARROW_TYPE_SUPPORT` below: the
|
||||
* exact ClickHouse -> Arrow -> DuckDB/Python mapping, including where
|
||||
* semantic loss WOULD occur, stated rather than hidden.
|
||||
*
|
||||
* IPC stream layout (Arrow format 1.5, metadata V5):
|
||||
* for each message: 0xFFFFFFFF, int32 metadata length, FlatBuffer Message
|
||||
* padded so the body starts 8-byte aligned, then the body buffers.
|
||||
* Terminated by 0xFFFFFFFF 0x00000000.
|
||||
*/
|
||||
import { FlatBufferBuilder, type Offset } from "./flatbuffer-builder";
|
||||
import { SiaxError } from "../../platform/errors";
|
||||
|
||||
// ── FlatBuffer enum values from org.apache.arrow.flatbuf ────────────────────
|
||||
|
||||
const METADATA_VERSION_V5 = 4;
|
||||
const MESSAGE_HEADER_SCHEMA = 1;
|
||||
const MESSAGE_HEADER_RECORD_BATCH = 3;
|
||||
const ENDIANNESS_LITTLE = 0;
|
||||
|
||||
const TYPE_INT = 2;
|
||||
const TYPE_FLOATING_POINT = 3;
|
||||
const TYPE_UTF8 = 5;
|
||||
const TYPE_BOOL = 6;
|
||||
const TYPE_TIMESTAMP = 10;
|
||||
|
||||
const PRECISION_DOUBLE = 2;
|
||||
|
||||
// BigInt LITERALS (`0n`) require target >= ES2020; this repo targets ES6, so
|
||||
// every 64-bit constant is built with BigInt(...) instead. Behaviour is
|
||||
// identical — this is a compiler-target accommodation, not a semantic choice.
|
||||
const BIG_ZERO = BigInt(0);
|
||||
const INT64_MAX = BigInt("9223372036854775807");
|
||||
const INT64_MIN = BigInt("-9223372036854775808");
|
||||
const TIME_UNIT_MILLISECOND = 1;
|
||||
|
||||
// ── public column model ────────────────────────────────────────────────────
|
||||
|
||||
export type ArrowColumnType = "int64" | "float64" | "utf8" | "bool" | "timestamp_ms";
|
||||
|
||||
export interface ArrowColumn {
|
||||
name: string;
|
||||
type: ArrowColumnType;
|
||||
/** `null` marks a null slot; the validity bitmap is derived from it. */
|
||||
values: (number | bigint | string | boolean | Date | null)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The type table BI-P005's "supported types" qualifier refers to. Every row
|
||||
* has been exercised end to end by scripts/bi-p005-arrow-roundtrip.mjs.
|
||||
*/
|
||||
export const ARROW_TYPE_SUPPORT: {
|
||||
clickhouse: string;
|
||||
arrow: ArrowColumnType;
|
||||
duckdb: string;
|
||||
python: string;
|
||||
note: string;
|
||||
}[] = [
|
||||
{
|
||||
clickhouse: "Int64 / UInt32 / Int32",
|
||||
arrow: "int64",
|
||||
duckdb: "BIGINT",
|
||||
python: "int",
|
||||
note: "exact; UInt64 above 2^63 is NOT supported and is rejected rather than silently wrapped",
|
||||
},
|
||||
{
|
||||
clickhouse: "Float64",
|
||||
arrow: "float64",
|
||||
duckdb: "DOUBLE",
|
||||
python: "float",
|
||||
note: "exact IEEE-754 round trip",
|
||||
},
|
||||
{
|
||||
clickhouse: "String / LowCardinality(String)",
|
||||
arrow: "utf8",
|
||||
duckdb: "VARCHAR",
|
||||
python: "str",
|
||||
note: "LowCardinality is decoded to plain utf8 — dictionary encoding is lost, values are not",
|
||||
},
|
||||
{
|
||||
clickhouse: "Bool / UInt8(0|1)",
|
||||
arrow: "bool",
|
||||
duckdb: "BOOLEAN",
|
||||
python: "bool",
|
||||
note: "bit-packed one bit per value; ClickHouse UInt8 other than 0/1 is NOT a bool and must be sent as int64",
|
||||
},
|
||||
{
|
||||
clickhouse: "DateTime64(3,'UTC')",
|
||||
arrow: "timestamp_ms",
|
||||
duckdb: "TIMESTAMP",
|
||||
python: "datetime",
|
||||
note: "millisecond precision; DateTime64(6/9) loses sub-ms and is therefore NOT in the supported set",
|
||||
},
|
||||
];
|
||||
|
||||
// ── ClickHouse pass-through ────────────────────────────────────────────────
|
||||
|
||||
/** ClickHouse output format producing an Arrow IPC *stream*. */
|
||||
export function clickhouseArrowStreamFormat(): string {
|
||||
return "ArrowStream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings for the pass-through path. `output_format_arrow_string_as_string`
|
||||
* matters: without it ClickHouse emits String columns as Arrow *Binary*,
|
||||
* which pyarrow and DuckDB both read as bytes rather than text — semantic
|
||||
* loss that would slip through a test that only compares row counts.
|
||||
*/
|
||||
export function arrowStreamSettings(): Record<string, string> {
|
||||
return {
|
||||
output_format_arrow_string_as_string: "1",
|
||||
output_format_arrow_low_cardinality_as_dictionary: "0",
|
||||
};
|
||||
}
|
||||
|
||||
// ── encoder ────────────────────────────────────────────────────────────────
|
||||
|
||||
function alignTo8(n: number): number {
|
||||
return (n + 7) & ~7;
|
||||
}
|
||||
|
||||
interface EncodedBuffer {
|
||||
bytes: Uint8Array;
|
||||
/** Logical (unpadded) length recorded in the RecordBatch metadata. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
function validityBuffer(values: readonly unknown[]): { buf: EncodedBuffer; nullCount: number } {
|
||||
let nullCount = 0;
|
||||
const byteLen = Math.ceil(values.length / 8);
|
||||
const bytes = new Uint8Array(alignTo8(byteLen));
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (values[i] === null || values[i] === undefined) {
|
||||
nullCount += 1;
|
||||
} else {
|
||||
bytes[i >> 3] |= 1 << (i & 7);
|
||||
}
|
||||
}
|
||||
return { buf: { bytes, length: byteLen }, nullCount };
|
||||
}
|
||||
|
||||
function encodeColumn(col: ArrowColumn): { buffers: EncodedBuffer[]; nullCount: number } {
|
||||
const { buf: validity, nullCount } = validityBuffer(col.values);
|
||||
const n = col.values.length;
|
||||
|
||||
switch (col.type) {
|
||||
case "int64": {
|
||||
const data = new Uint8Array(alignTo8(n * 8));
|
||||
const dv = new DataView(data.buffer);
|
||||
col.values.forEach((v, i) => {
|
||||
if (v === null || v === undefined) return;
|
||||
const b = typeof v === "bigint" ? v : BigInt(Math.trunc(Number(v)));
|
||||
if (b > INT64_MAX || b < INT64_MIN) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `value out of int64 range in column "${col.name}"`);
|
||||
}
|
||||
dv.setBigInt64(i * 8, b, true);
|
||||
});
|
||||
return { buffers: [validity, { bytes: data, length: n * 8 }], nullCount };
|
||||
}
|
||||
case "float64": {
|
||||
const data = new Uint8Array(alignTo8(n * 8));
|
||||
const dv = new DataView(data.buffer);
|
||||
col.values.forEach((v, i) => {
|
||||
if (v === null || v === undefined) return;
|
||||
dv.setFloat64(i * 8, Number(v), true);
|
||||
});
|
||||
return { buffers: [validity, { bytes: data, length: n * 8 }], nullCount };
|
||||
}
|
||||
case "timestamp_ms": {
|
||||
const data = new Uint8Array(alignTo8(n * 8));
|
||||
const dv = new DataView(data.buffer);
|
||||
col.values.forEach((v, i) => {
|
||||
if (v === null || v === undefined) return;
|
||||
const ms = v instanceof Date ? v.getTime() : typeof v === "bigint" ? Number(v) : Number(v);
|
||||
dv.setBigInt64(i * 8, BigInt(Math.trunc(ms)), true);
|
||||
});
|
||||
return { buffers: [validity, { bytes: data, length: n * 8 }], nullCount };
|
||||
}
|
||||
case "bool": {
|
||||
const byteLen = Math.ceil(n / 8);
|
||||
const data = new Uint8Array(alignTo8(byteLen));
|
||||
col.values.forEach((v, i) => {
|
||||
if (v === null || v === undefined) return;
|
||||
if (v) data[i >> 3] |= 1 << (i & 7);
|
||||
});
|
||||
return { buffers: [validity, { bytes: data, length: byteLen }], nullCount };
|
||||
}
|
||||
case "utf8": {
|
||||
const encoded = col.values.map((v) =>
|
||||
v === null || v === undefined ? new Uint8Array(0) : new TextEncoder().encode(String(v)),
|
||||
);
|
||||
const total = encoded.reduce((a, b) => a + b.length, 0);
|
||||
const offsets = new Uint8Array(alignTo8((n + 1) * 4));
|
||||
const odv = new DataView(offsets.buffer);
|
||||
const data = new Uint8Array(alignTo8(total));
|
||||
let pos = 0;
|
||||
odv.setInt32(0, 0, true);
|
||||
encoded.forEach((bytes, i) => {
|
||||
data.set(bytes, pos);
|
||||
pos += bytes.length;
|
||||
odv.setInt32((i + 1) * 4, pos, true);
|
||||
});
|
||||
return {
|
||||
buffers: [validity, { bytes: offsets, length: (n + 1) * 4 }, { bytes: data, length: total }],
|
||||
nullCount,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const never: never = col.type;
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `unsupported arrow type ${String(never)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTypeTable(b: FlatBufferBuilder, type: ArrowColumnType): { typeType: number; offset: Offset } {
|
||||
switch (type) {
|
||||
case "int64": {
|
||||
b.startObject(2);
|
||||
b.addInt32(0, 64, 0); // bitWidth
|
||||
b.addInt8(1, 1, 0); // is_signed
|
||||
return { typeType: TYPE_INT, offset: b.endObject() };
|
||||
}
|
||||
case "float64": {
|
||||
b.startObject(1);
|
||||
b.addInt16(0, PRECISION_DOUBLE, 0); // precision = DOUBLE
|
||||
return { typeType: TYPE_FLOATING_POINT, offset: b.endObject() };
|
||||
}
|
||||
case "utf8": {
|
||||
b.startObject(0);
|
||||
return { typeType: TYPE_UTF8, offset: b.endObject() };
|
||||
}
|
||||
case "bool": {
|
||||
b.startObject(0);
|
||||
return { typeType: TYPE_BOOL, offset: b.endObject() };
|
||||
}
|
||||
case "timestamp_ms": {
|
||||
// Build the timezone string BEFORE startObject — FlatBuffers forbids
|
||||
// creating a nested object while a table is open.
|
||||
const tz = b.createString("UTC");
|
||||
b.startObject(2);
|
||||
b.addInt16(0, TIME_UNIT_MILLISECOND, 0);
|
||||
b.addOffset(1, tz, 0);
|
||||
return { typeType: TYPE_TIMESTAMP, offset: b.endObject() };
|
||||
}
|
||||
default: {
|
||||
const never: never = type;
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `unsupported arrow type ${String(never)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSchemaMessage(columns: readonly ArrowColumn[]): Uint8Array {
|
||||
const b = new FlatBufferBuilder(2048);
|
||||
|
||||
const fieldOffsets: Offset[] = columns.map((col) => {
|
||||
const nameOffset = b.createString(col.name);
|
||||
const { typeType, offset: typeOffset } = buildTypeTable(b, col.type);
|
||||
const children = b.createOffsetVector([]);
|
||||
b.startObject(7);
|
||||
b.addOffset(0, nameOffset, 0);
|
||||
b.addInt8(1, 1, 0); // nullable = true
|
||||
b.addInt8(2, typeType, 0);
|
||||
b.addOffset(3, typeOffset, 0);
|
||||
b.addOffset(5, children, 0);
|
||||
return b.endObject();
|
||||
});
|
||||
|
||||
const fieldsVector = b.createOffsetVector(fieldOffsets);
|
||||
b.startObject(4);
|
||||
b.addInt16(0, ENDIANNESS_LITTLE, 0);
|
||||
b.addOffset(1, fieldsVector, 0);
|
||||
const schema = b.endObject();
|
||||
|
||||
b.startObject(5);
|
||||
b.addInt16(0, METADATA_VERSION_V5, 0);
|
||||
b.addInt8(1, MESSAGE_HEADER_SCHEMA, 0);
|
||||
b.addOffset(2, schema, 0);
|
||||
b.addInt64(3, BIG_ZERO, BIG_ZERO); // bodyLength
|
||||
const message = b.endObject();
|
||||
|
||||
return b.finish(message);
|
||||
}
|
||||
|
||||
function buildRecordBatchMessage(
|
||||
rowCount: number,
|
||||
nodes: { length: number; nullCount: number }[],
|
||||
buffers: { offset: number; length: number }[],
|
||||
bodyLength: number,
|
||||
): Uint8Array {
|
||||
const b = new FlatBufferBuilder(2048);
|
||||
|
||||
// FieldNode[] — inline structs, written in reverse.
|
||||
b.startVector(16, nodes.length, 8);
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
b.writeInt64(BigInt(nodes[i].nullCount));
|
||||
b.writeInt64(BigInt(nodes[i].length));
|
||||
}
|
||||
const nodesVector = b.endVector(nodes.length);
|
||||
|
||||
// Buffer[] — inline structs, written in reverse.
|
||||
b.startVector(16, buffers.length, 8);
|
||||
for (let i = buffers.length - 1; i >= 0; i--) {
|
||||
b.writeInt64(BigInt(buffers[i].length));
|
||||
b.writeInt64(BigInt(buffers[i].offset));
|
||||
}
|
||||
const buffersVector = b.endVector(buffers.length);
|
||||
|
||||
b.startObject(5);
|
||||
b.addInt64(0, BigInt(rowCount), BIG_ZERO);
|
||||
b.addOffset(1, nodesVector, 0);
|
||||
b.addOffset(2, buffersVector, 0);
|
||||
const recordBatch = b.endObject();
|
||||
|
||||
b.startObject(5);
|
||||
b.addInt16(0, METADATA_VERSION_V5, 0);
|
||||
b.addInt8(1, MESSAGE_HEADER_RECORD_BATCH, 0);
|
||||
b.addOffset(2, recordBatch, 0);
|
||||
b.addInt64(3, BigInt(bodyLength), BIG_ZERO);
|
||||
const message = b.endObject();
|
||||
|
||||
return b.finish(message);
|
||||
}
|
||||
|
||||
function framedMessage(metadata: Uint8Array, body: Uint8Array): Uint8Array {
|
||||
// continuation(4) + length(4) + metadata, padded so the body is 8-aligned.
|
||||
const prefix = 8;
|
||||
const padded = alignTo8(prefix + metadata.length) - prefix;
|
||||
const out = new Uint8Array(prefix + padded + body.length);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint32(0, 0xffffffff, true);
|
||||
dv.setInt32(4, padded, true);
|
||||
out.set(metadata, 8);
|
||||
out.set(body, prefix + padded);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode columns as a complete Arrow IPC *stream* (schema message, one record
|
||||
* batch, end-of-stream marker).
|
||||
*/
|
||||
export function encodeArrowStream(columns: readonly ArrowColumn[]): Uint8Array {
|
||||
if (columns.length === 0) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "an Arrow stream needs at least one column");
|
||||
}
|
||||
const rowCount = columns[0].values.length;
|
||||
for (const c of columns) {
|
||||
if (c.values.length !== rowCount) {
|
||||
throw new SiaxError(
|
||||
"INVALID_REQUEST",
|
||||
"0pt.analytics",
|
||||
`column "${c.name}" has ${c.values.length} values, expected ${rowCount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const schemaMsg = buildSchemaMessage(columns);
|
||||
|
||||
const nodes: { length: number; nullCount: number }[] = [];
|
||||
const bufferMeta: { offset: number; length: number }[] = [];
|
||||
const bodyParts: Uint8Array[] = [];
|
||||
let bodyOffset = 0;
|
||||
for (const col of columns) {
|
||||
const { buffers, nullCount } = encodeColumn(col);
|
||||
nodes.push({ length: rowCount, nullCount });
|
||||
for (const buf of buffers) {
|
||||
bufferMeta.push({ offset: bodyOffset, length: buf.length });
|
||||
bodyParts.push(buf.bytes);
|
||||
bodyOffset += buf.bytes.length; // already 8-aligned by encodeColumn
|
||||
}
|
||||
}
|
||||
const body = new Uint8Array(bodyOffset);
|
||||
let pos = 0;
|
||||
for (const part of bodyParts) {
|
||||
body.set(part, pos);
|
||||
pos += part.length;
|
||||
}
|
||||
|
||||
const batchMsg = buildRecordBatchMessage(rowCount, nodes, bufferMeta, body.length);
|
||||
|
||||
const schemaFrame = framedMessage(schemaMsg, new Uint8Array(0));
|
||||
const batchFrame = framedMessage(batchMsg, body);
|
||||
const eos = new Uint8Array(8);
|
||||
new DataView(eos.buffer).setUint32(0, 0xffffffff, true); // length 0 = EOS
|
||||
|
||||
const out = new Uint8Array(schemaFrame.length + batchFrame.length + eos.length);
|
||||
out.set(schemaFrame, 0);
|
||||
out.set(batchFrame, schemaFrame.length);
|
||||
out.set(eos, schemaFrame.length + batchFrame.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── decoder (structural: schema names/types + row counts) ──────────────────
|
||||
|
||||
export interface ArrowStreamInfo {
|
||||
fields: { name: string; typeType: number }[];
|
||||
/** Total rows across every record batch in the stream. */
|
||||
rowCount: number;
|
||||
batchCount: number;
|
||||
}
|
||||
|
||||
/** Minimal FlatBuffers table reader — enough for Message/Schema/RecordBatch. */
|
||||
class FbTable {
|
||||
constructor(
|
||||
readonly dv: DataView,
|
||||
readonly pos: number,
|
||||
) {}
|
||||
|
||||
fieldPos(slot: number): number {
|
||||
const vtable = this.pos - this.dv.getInt32(this.pos, true);
|
||||
const vtLen = this.dv.getInt16(vtable, true);
|
||||
const idx = 4 + slot * 2;
|
||||
if (idx >= vtLen) return 0;
|
||||
const rel = this.dv.getInt16(vtable + idx, true);
|
||||
return rel === 0 ? 0 : this.pos + rel;
|
||||
}
|
||||
|
||||
readInt8(slot: number, def = 0): number {
|
||||
const p = this.fieldPos(slot);
|
||||
return p === 0 ? def : this.dv.getInt8(p);
|
||||
}
|
||||
|
||||
readInt16(slot: number, def = 0): number {
|
||||
const p = this.fieldPos(slot);
|
||||
return p === 0 ? def : this.dv.getInt16(p, true);
|
||||
}
|
||||
|
||||
readInt64(slot: number, def: bigint = BIG_ZERO): bigint {
|
||||
const p = this.fieldPos(slot);
|
||||
return p === 0 ? def : this.dv.getBigInt64(p, true);
|
||||
}
|
||||
|
||||
/** Follow a uoffset to a nested table. */
|
||||
readTable(slot: number): FbTable | undefined {
|
||||
const p = this.fieldPos(slot);
|
||||
if (p === 0) return undefined;
|
||||
return new FbTable(this.dv, p + this.dv.getInt32(p, true));
|
||||
}
|
||||
|
||||
vectorLength(slot: number): number {
|
||||
const p = this.fieldPos(slot);
|
||||
if (p === 0) return 0;
|
||||
const vec = p + this.dv.getInt32(p, true);
|
||||
return this.dv.getInt32(vec, true);
|
||||
}
|
||||
|
||||
vectorElementTable(slot: number, index: number): FbTable {
|
||||
const p = this.fieldPos(slot);
|
||||
const vec = p + this.dv.getInt32(p, true);
|
||||
const elem = vec + 4 + index * 4;
|
||||
return new FbTable(this.dv, elem + this.dv.getInt32(elem, true));
|
||||
}
|
||||
|
||||
readString(slot: number): string | undefined {
|
||||
const p = this.fieldPos(slot);
|
||||
if (p === 0) return undefined;
|
||||
const str = p + this.dv.getInt32(p, true);
|
||||
const len = this.dv.getInt32(str, true);
|
||||
const bytes = new Uint8Array(this.dv.buffer, this.dv.byteOffset + str + 4, len);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an Arrow IPC stream's structure. Used to enforce BI-P012's result-row
|
||||
* ceiling on Arrow payloads (including ClickHouse pass-through bytes, where
|
||||
* the count is otherwise invisible to 0pt) and by the roundtrip tests.
|
||||
*/
|
||||
export function decodeArrowStream(bytes: Uint8Array): ArrowStreamInfo {
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
let pos = 0;
|
||||
let fields: { name: string; typeType: number }[] = [];
|
||||
let rowCount = 0;
|
||||
let batchCount = 0;
|
||||
|
||||
while (pos + 8 <= bytes.byteLength) {
|
||||
const continuation = dv.getUint32(pos, true);
|
||||
if (continuation !== 0xffffffff) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "not an Arrow IPC stream (missing continuation marker)");
|
||||
}
|
||||
const metaLen = dv.getInt32(pos + 4, true);
|
||||
if (metaLen === 0) break; // end of stream
|
||||
const metaStart = pos + 8;
|
||||
const root = metaStart + dv.getInt32(metaStart, true);
|
||||
const message = new FbTable(dv, root);
|
||||
const headerType = message.readInt8(1, 0);
|
||||
const bodyLength = Number(message.readInt64(3, BIG_ZERO));
|
||||
|
||||
if (headerType === MESSAGE_HEADER_SCHEMA) {
|
||||
const schema = message.readTable(2);
|
||||
if (schema) {
|
||||
const n = schema.vectorLength(1);
|
||||
fields = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const f = schema.vectorElementTable(1, i);
|
||||
fields.push({ name: f.readString(0) ?? "", typeType: f.readInt8(2, 0) });
|
||||
}
|
||||
}
|
||||
} else if (headerType === MESSAGE_HEADER_RECORD_BATCH) {
|
||||
const rb = message.readTable(2);
|
||||
if (rb) {
|
||||
rowCount += Number(rb.readInt64(0, BIG_ZERO));
|
||||
batchCount += 1;
|
||||
}
|
||||
}
|
||||
pos = metaStart + metaLen + bodyLength;
|
||||
}
|
||||
|
||||
return { fields, rowCount, batchCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* BI-P012 on the Arrow path: refuse a payload that exceeds the caller's
|
||||
* result-row ceiling. Without this, asking for Arrow would be a way to walk
|
||||
* around `max_result_rows`.
|
||||
*/
|
||||
export function assertArrowWithinResultBudget(bytes: Uint8Array, maxResultRows: number): ArrowStreamInfo {
|
||||
const info = decodeArrowStream(bytes);
|
||||
if (info.rowCount > maxResultRows) {
|
||||
throw new SiaxError(
|
||||
"QUOTA_EXCEEDED",
|
||||
"0pt.analytics",
|
||||
`Arrow result has ${info.rowCount} rows, over the ${maxResultRows}-row budget`,
|
||||
);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* src/analytics/domain/contract.ts
|
||||
* =================================
|
||||
* BI-P004 — ANALYTICS DATA CONTRACT.
|
||||
*
|
||||
* `DatasetContract` is the machine-checkable promise a dataset makes to every
|
||||
* consumer downstream of it. BI-P004's DoD is a GATE, not a document:
|
||||
*
|
||||
* "Breaking dataset schema changes fail compatibility gate unless
|
||||
* explicitly versioned."
|
||||
*
|
||||
* so `checkContractCompatibility` is the deliverable and the prose is the
|
||||
* commentary. The gate is fail-closed: an unrecognised change is BREAKING,
|
||||
* never "probably fine".
|
||||
*
|
||||
* Versioning is semver-shaped (`major.minor.patch`) with one rule that is not
|
||||
* negotiable: a BREAKING change requires a MAJOR bump. A breaking change
|
||||
* shipped under a minor bump is exactly the failure this gate exists to
|
||||
* catch, so it is reported as a violation rather than tolerated.
|
||||
*/
|
||||
import crypto from "node:crypto";
|
||||
import { SiaxError } from "../../platform/errors";
|
||||
import type { AnalyticsFieldType, FieldRole, PiiClassification } from "./entities";
|
||||
import type { AnalyticsTier } from "./tiers";
|
||||
|
||||
export interface ContractField {
|
||||
name: string;
|
||||
type: AnalyticsFieldType;
|
||||
nullable: boolean;
|
||||
role: FieldRole;
|
||||
/** Part of the dataset's primary identifier. */
|
||||
primary_identifier?: boolean;
|
||||
classification?: PiiClassification;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ContractQualityRule {
|
||||
rule_id: string;
|
||||
kind: string;
|
||||
field_name?: string;
|
||||
severity: "warn" | "block";
|
||||
}
|
||||
|
||||
/** BI-P004's named contents, one field each. */
|
||||
export interface DatasetContract {
|
||||
dataset_id: string;
|
||||
/** semver `major.minor.patch`. */
|
||||
version: string;
|
||||
tier: AnalyticsTier;
|
||||
fields: ContractField[];
|
||||
/** Field names forming the primary identifier, in order. */
|
||||
primary_identifiers: string[];
|
||||
/** Field names usable as dimensions (subset of `fields`). */
|
||||
dimensions: string[];
|
||||
/** Field names usable as measures (subset of `fields`). */
|
||||
measures: string[];
|
||||
/** Freshness SLO in seconds. */
|
||||
freshness_slo_seconds: number;
|
||||
retention_days: number;
|
||||
/** Dataset-level PII classification (the max of its fields, at least). */
|
||||
pii_classification: PiiClassification;
|
||||
/** Upstream refs: source ids, dataset ids, external system refs. */
|
||||
source_lineage: string[];
|
||||
quality_rules: ContractQualityRule[];
|
||||
/** GDPR purpose limitation — what this data may be USED for. */
|
||||
allowed_purposes: string[];
|
||||
}
|
||||
|
||||
export interface SemVer {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
export function parseSemVer(v: string): SemVer {
|
||||
const m = SEMVER_RE.exec(v);
|
||||
if (!m) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `contract version "${v}" is not major.minor.patch`);
|
||||
}
|
||||
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
||||
}
|
||||
|
||||
// ── change classification ──────────────────────────────────────────────────
|
||||
|
||||
export type ChangeSeverity = "compatible" | "breaking";
|
||||
|
||||
export interface ContractChange {
|
||||
severity: ChangeSeverity;
|
||||
/** Stable machine code so callers can allow-list specific changes. */
|
||||
code: string;
|
||||
field?: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type widening that does NOT break a reader. Deliberately conservative:
|
||||
* int64 -> float64 loses exactness for large integers, so it is NOT here.
|
||||
* Everything absent from this table is breaking.
|
||||
*/
|
||||
const SAFE_TYPE_WIDENING: Partial<Record<AnalyticsFieldType, readonly AnalyticsFieldType[]>> = {
|
||||
date: ["timestamp"],
|
||||
};
|
||||
|
||||
function typeChangeIsCompatible(from: AnalyticsFieldType, to: AnalyticsFieldType): boolean {
|
||||
if (from === to) return true;
|
||||
return (SAFE_TYPE_WIDENING[from] ?? []).includes(to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two contracts for the SAME dataset and classify every change.
|
||||
*
|
||||
* Breaking (a reader written against `previous` can now fail or silently
|
||||
* change meaning):
|
||||
* - field removed
|
||||
* - field type changed other than a listed safe widening
|
||||
* - nullable false -> true (readers may not handle nulls)
|
||||
* - a field's role changes (a measure becoming a dimension changes meaning)
|
||||
* - primary identifier set changes
|
||||
* - a field leaves `measures` or `dimensions`
|
||||
* - freshness SLO loosened (a consumer's own SLO may depend on it)
|
||||
* - retention shortened (history a consumer relied on disappears)
|
||||
* - an allowed purpose is removed (a legitimate consumer becomes illegitimate)
|
||||
* - PII classification raised (consumers may lack clearance)
|
||||
* - a blocking quality rule is removed or downgraded to warn
|
||||
*
|
||||
* Compatible: additive nullable fields, new dimensions/measures, new quality
|
||||
* rules, loosened PII classification, added purposes, longer retention,
|
||||
* tightened freshness.
|
||||
*/
|
||||
export function diffContracts(previous: DatasetContract, next: DatasetContract): ContractChange[] {
|
||||
if (previous.dataset_id !== next.dataset_id) {
|
||||
throw new SiaxError(
|
||||
"INVALID_REQUEST",
|
||||
"0pt.analytics",
|
||||
"contract compatibility is only defined between versions of the SAME dataset",
|
||||
);
|
||||
}
|
||||
const changes: ContractChange[] = [];
|
||||
const prevFields = new Map(previous.fields.map((f) => [f.name, f]));
|
||||
const nextFields = new Map(next.fields.map((f) => [f.name, f]));
|
||||
|
||||
for (const [name, pf] of prevFields) {
|
||||
const nf = nextFields.get(name);
|
||||
if (!nf) {
|
||||
changes.push({ severity: "breaking", code: "field_removed", field: name, detail: `field "${name}" removed` });
|
||||
continue;
|
||||
}
|
||||
if (!typeChangeIsCompatible(pf.type, nf.type)) {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "field_type_changed",
|
||||
field: name,
|
||||
detail: `field "${name}" type ${pf.type} -> ${nf.type}`,
|
||||
});
|
||||
} else if (pf.type !== nf.type) {
|
||||
changes.push({
|
||||
severity: "compatible",
|
||||
code: "field_type_widened",
|
||||
field: name,
|
||||
detail: `field "${name}" widened ${pf.type} -> ${nf.type}`,
|
||||
});
|
||||
}
|
||||
if (!pf.nullable && nf.nullable) {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "field_became_nullable",
|
||||
field: name,
|
||||
detail: `field "${name}" became nullable`,
|
||||
});
|
||||
}
|
||||
if (pf.nullable && !nf.nullable) {
|
||||
changes.push({
|
||||
severity: "compatible",
|
||||
code: "field_became_required",
|
||||
field: name,
|
||||
detail: `field "${name}" is no longer nullable`,
|
||||
});
|
||||
}
|
||||
if (pf.role !== nf.role) {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "field_role_changed",
|
||||
field: name,
|
||||
detail: `field "${name}" role ${pf.role} -> ${nf.role}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, nf] of nextFields) {
|
||||
if (prevFields.has(name)) continue;
|
||||
if (nf.nullable) {
|
||||
changes.push({
|
||||
severity: "compatible",
|
||||
code: "nullable_field_added",
|
||||
field: name,
|
||||
detail: `nullable field "${name}" added`,
|
||||
});
|
||||
} else {
|
||||
// A required new field breaks producers, and breaks readers that
|
||||
// reconstruct rows (they now have no value to supply).
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "required_field_added",
|
||||
field: name,
|
||||
detail: `required (non-nullable) field "${name}" added`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const idsEqual =
|
||||
previous.primary_identifiers.length === next.primary_identifiers.length &&
|
||||
previous.primary_identifiers.every((v, i) => next.primary_identifiers[i] === v);
|
||||
if (!idsEqual) {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "primary_identifier_changed",
|
||||
detail: `primary identifier [${previous.primary_identifiers.join(",")}] -> [${next.primary_identifiers.join(",")}]`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const d of previous.dimensions) {
|
||||
if (!next.dimensions.includes(d)) {
|
||||
changes.push({ severity: "breaking", code: "dimension_removed", field: d, detail: `dimension "${d}" removed` });
|
||||
}
|
||||
}
|
||||
for (const d of next.dimensions) {
|
||||
if (!previous.dimensions.includes(d)) {
|
||||
changes.push({ severity: "compatible", code: "dimension_added", field: d, detail: `dimension "${d}" added` });
|
||||
}
|
||||
}
|
||||
for (const m of previous.measures) {
|
||||
if (!next.measures.includes(m)) {
|
||||
changes.push({ severity: "breaking", code: "measure_removed", field: m, detail: `measure "${m}" removed` });
|
||||
}
|
||||
}
|
||||
for (const m of next.measures) {
|
||||
if (!previous.measures.includes(m)) {
|
||||
changes.push({ severity: "compatible", code: "measure_added", field: m, detail: `measure "${m}" added` });
|
||||
}
|
||||
}
|
||||
|
||||
if (next.freshness_slo_seconds > previous.freshness_slo_seconds) {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "freshness_slo_loosened",
|
||||
detail: `freshness SLO ${previous.freshness_slo_seconds}s -> ${next.freshness_slo_seconds}s`,
|
||||
});
|
||||
}
|
||||
if (next.retention_days < previous.retention_days) {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "retention_shortened",
|
||||
detail: `retention ${previous.retention_days}d -> ${next.retention_days}d`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of previous.allowed_purposes) {
|
||||
if (!next.allowed_purposes.includes(p)) {
|
||||
changes.push({ severity: "breaking", code: "purpose_removed", detail: `allowed purpose "${p}" removed` });
|
||||
}
|
||||
}
|
||||
|
||||
const PII_RANK: Record<PiiClassification, number> = {
|
||||
none: 0,
|
||||
pseudonymous: 1,
|
||||
personal: 2,
|
||||
sensitive: 3,
|
||||
};
|
||||
if (PII_RANK[next.pii_classification] > PII_RANK[previous.pii_classification]) {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "pii_classification_raised",
|
||||
detail: `PII classification ${previous.pii_classification} -> ${next.pii_classification}`,
|
||||
});
|
||||
}
|
||||
|
||||
const prevRules = new Map(previous.quality_rules.map((r) => [r.rule_id, r]));
|
||||
for (const [id, pr] of prevRules) {
|
||||
const nr = next.quality_rules.find((r) => r.rule_id === id);
|
||||
if (!nr && pr.severity === "block") {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "blocking_quality_rule_removed",
|
||||
detail: `blocking quality rule "${id}" removed`,
|
||||
});
|
||||
} else if (nr && pr.severity === "block" && nr.severity === "warn") {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "blocking_quality_rule_downgraded",
|
||||
detail: `quality rule "${id}" downgraded block -> warn`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.tier !== next.tier) {
|
||||
changes.push({
|
||||
severity: "breaking",
|
||||
code: "tier_changed",
|
||||
detail: `tier ${previous.tier} -> ${next.tier}`,
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
export interface CompatibilityVerdict {
|
||||
compatible: boolean;
|
||||
/** True when a MAJOR bump legitimises the breaking changes. */
|
||||
explicitly_versioned: boolean;
|
||||
changes: ContractChange[];
|
||||
breaking: ContractChange[];
|
||||
previous_version: string;
|
||||
next_version: string;
|
||||
/** Human-readable reason when `compatible` is false. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BI-P004's gate. Returns a verdict; `assertContractCompatible` throws.
|
||||
*
|
||||
* Rules, in order:
|
||||
* 1. the version must move FORWARD (equal or lower is rejected outright);
|
||||
* 2. breaking changes REQUIRE a major bump — that is what "explicitly
|
||||
* versioned" means;
|
||||
* 3. a major bump with no breaking change is allowed (over-declaring is
|
||||
* safe; under-declaring is what we refuse).
|
||||
*/
|
||||
export function checkContractCompatibility(
|
||||
previous: DatasetContract,
|
||||
next: DatasetContract,
|
||||
): CompatibilityVerdict {
|
||||
const pv = parseSemVer(previous.version);
|
||||
const nv = parseSemVer(next.version);
|
||||
const changes = diffContracts(previous, next);
|
||||
const breaking = changes.filter((c) => c.severity === "breaking");
|
||||
|
||||
const forward =
|
||||
nv.major > pv.major ||
|
||||
(nv.major === pv.major && nv.minor > pv.minor) ||
|
||||
(nv.major === pv.major && nv.minor === pv.minor && nv.patch > pv.patch);
|
||||
if (!forward) {
|
||||
return {
|
||||
compatible: false,
|
||||
explicitly_versioned: false,
|
||||
changes,
|
||||
breaking,
|
||||
previous_version: previous.version,
|
||||
next_version: next.version,
|
||||
reason: `version must move forward: ${previous.version} -> ${next.version}`,
|
||||
};
|
||||
}
|
||||
|
||||
const majorBumped = nv.major > pv.major;
|
||||
if (breaking.length > 0 && !majorBumped) {
|
||||
return {
|
||||
compatible: false,
|
||||
explicitly_versioned: false,
|
||||
changes,
|
||||
breaking,
|
||||
previous_version: previous.version,
|
||||
next_version: next.version,
|
||||
reason:
|
||||
`${breaking.length} breaking change(s) require a MAJOR version bump ` +
|
||||
`(${previous.version} -> ${next.version} is not one): ` +
|
||||
breaking.map((b) => b.code).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
compatible: true,
|
||||
explicitly_versioned: majorBumped && breaking.length > 0,
|
||||
changes,
|
||||
breaking,
|
||||
previous_version: previous.version,
|
||||
next_version: next.version,
|
||||
};
|
||||
}
|
||||
|
||||
/** Throwing form for call sites that treat incompatibility as a failure. */
|
||||
export function assertContractCompatible(previous: DatasetContract, next: DatasetContract): CompatibilityVerdict {
|
||||
const verdict = checkContractCompatibility(previous, next);
|
||||
if (!verdict.compatible) {
|
||||
throw new SiaxError("CONFLICT", "0pt.analytics", verdict.reason ?? "dataset contract compatibility gate failed");
|
||||
}
|
||||
return verdict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural validation of a single contract (independent of history).
|
||||
* Catches the contract that is internally inconsistent before it is ever
|
||||
* compared to anything.
|
||||
*/
|
||||
export function validateContract(contract: DatasetContract): void {
|
||||
parseSemVer(contract.version);
|
||||
if (!contract.dataset_id.trim()) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "contract needs a dataset_id");
|
||||
}
|
||||
if (contract.fields.length === 0) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `contract ${contract.dataset_id} declares no fields`);
|
||||
}
|
||||
const names = new Set<string>();
|
||||
for (const f of contract.fields) {
|
||||
if (names.has(f.name)) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `duplicate field "${f.name}"`);
|
||||
}
|
||||
names.add(f.name);
|
||||
}
|
||||
for (const label of ["primary_identifiers", "dimensions", "measures"] as const) {
|
||||
for (const n of contract[label]) {
|
||||
if (!names.has(n)) {
|
||||
throw new SiaxError(
|
||||
"INVALID_REQUEST",
|
||||
"0pt.analytics",
|
||||
`${label} references unknown field "${n}" in ${contract.dataset_id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (contract.primary_identifiers.length === 0) {
|
||||
throw new SiaxError(
|
||||
"INVALID_REQUEST",
|
||||
"0pt.analytics",
|
||||
`contract ${contract.dataset_id} declares no primary identifier`,
|
||||
);
|
||||
}
|
||||
if (contract.allowed_purposes.length === 0) {
|
||||
throw new SiaxError(
|
||||
"INVALID_REQUEST",
|
||||
"0pt.analytics",
|
||||
`contract ${contract.dataset_id} declares no allowed_purposes (purpose limitation is not optional)`,
|
||||
);
|
||||
}
|
||||
if (contract.freshness_slo_seconds <= 0) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "freshness_slo_seconds must be positive");
|
||||
}
|
||||
if (contract.retention_days < 0) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "retention_days must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash of a contract's MEANING (field order-insensitive), used as
|
||||
* `DatasetVersion.contract_hash` so two versions that differ only in comment
|
||||
* text do not look like schema drift.
|
||||
*/
|
||||
export function contractHash(contract: DatasetContract): string {
|
||||
const canonical = {
|
||||
dataset_id: contract.dataset_id,
|
||||
version: contract.version,
|
||||
tier: contract.tier,
|
||||
fields: [...contract.fields]
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
nullable: f.nullable,
|
||||
role: f.role,
|
||||
primary_identifier: f.primary_identifier ?? false,
|
||||
classification: f.classification ?? "none",
|
||||
}))
|
||||
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
|
||||
primary_identifiers: contract.primary_identifiers,
|
||||
dimensions: [...contract.dimensions].sort(),
|
||||
measures: [...contract.measures].sort(),
|
||||
freshness_slo_seconds: contract.freshness_slo_seconds,
|
||||
retention_days: contract.retention_days,
|
||||
pii_classification: contract.pii_classification,
|
||||
source_lineage: [...contract.source_lineage].sort(),
|
||||
quality_rules: [...contract.quality_rules].sort((a, b) => (a.rule_id < b.rule_id ? -1 : 1)),
|
||||
allowed_purposes: [...contract.allowed_purposes].sort(),
|
||||
};
|
||||
return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* src/analytics/domain/entities.ts
|
||||
* =================================
|
||||
* BI-P002 — ANALYTICS DOMAIN MODEL.
|
||||
*
|
||||
* The nineteen canonical analytics entities, named EXACTLY as the program
|
||||
* lists them (no renaming, no merging, no "we already have something like
|
||||
* that"):
|
||||
*
|
||||
* AnalyticsDataset DatasetVersion DatasetField
|
||||
* DatasetGrant MetricDefinition MetricVersion
|
||||
* DimensionDefinition SemanticModel AnalyticsQuery
|
||||
* QueryExecution QueryResult DashboardDefinition
|
||||
* DashboardVersion ReportDefinition ReportRun
|
||||
* DataQualityRule DataQualityRun LineageReference
|
||||
* BIApplicationBinding
|
||||
*
|
||||
* Every tenant-owned record carries `tenant_id` + `project_id` where
|
||||
* applicable. The four version entities the program names as immutable
|
||||
* (MetricVersion, SemanticModel version, DashboardVersion, DatasetVersion)
|
||||
* are `readonly` here AND immutable in the database — sql/008 installs BEFORE
|
||||
* UPDATE/DELETE triggers, because a TypeScript `readonly` is a compiler
|
||||
* opinion, not a storage guarantee.
|
||||
*
|
||||
* NAME COLLISION, deliberate: src/services/dataset-version-service.ts already
|
||||
* exports an interface called `DatasetVersion` for the ST0RE-artifact-backed
|
||||
* dataset versioning that predates this program. That one is about *dataset
|
||||
* content artifacts*; this one is about *analytics dataset schema versions*
|
||||
* and lives in its own module namespace (`AnalyticsDatasetVersion` is NOT
|
||||
* used as the name — BI-P002 lists `DatasetVersion` verbatim and that is what
|
||||
* ships). Import them qualified when both are needed in one file.
|
||||
*
|
||||
* ARCHITECTURE BOUNDARY: these entities are owned by 0pt. A product repo
|
||||
* (VehIQ) supplies domain MEANING through `BIApplicationBinding`; it never
|
||||
* defines its own copies of these tables and never receives engine
|
||||
* credentials.
|
||||
*/
|
||||
import type { AnalyticsTier } from "./tiers";
|
||||
|
||||
/** Every tenant-owned analytics record. */
|
||||
export interface TenantScoped {
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
// ── field/typing vocabulary ────────────────────────────────────────────────
|
||||
|
||||
export const ANALYTICS_FIELD_TYPES = [
|
||||
"string",
|
||||
"int64",
|
||||
"float64",
|
||||
"bool",
|
||||
"date",
|
||||
"timestamp",
|
||||
"decimal",
|
||||
"json",
|
||||
] as const;
|
||||
export type AnalyticsFieldType = (typeof ANALYTICS_FIELD_TYPES)[number];
|
||||
|
||||
/** A field's analytical role. Measures aggregate; dimensions group. */
|
||||
export const FIELD_ROLES = ["identifier", "dimension", "measure", "attribute"] as const;
|
||||
export type FieldRole = (typeof FIELD_ROLES)[number];
|
||||
|
||||
/** PII classification, aligned with the SIAX risk vocabulary (L0 = none). */
|
||||
export const PII_CLASSIFICATIONS = ["none", "pseudonymous", "personal", "sensitive"] as const;
|
||||
export type PiiClassification = (typeof PII_CLASSIFICATIONS)[number];
|
||||
|
||||
// ── 1. AnalyticsDataset ────────────────────────────────────────────────────
|
||||
|
||||
export const DATASET_STATUSES = ["draft", "active", "deprecated", "retired"] as const;
|
||||
export type AnalyticsDatasetStatus = (typeof DATASET_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* BI-P003 DoD: "Every dataset declares tier, schema, owner, source, freshness,
|
||||
* quality, retention, classification." All eight are REQUIRED fields here —
|
||||
* an optional declaration is not a declaration.
|
||||
*/
|
||||
export interface AnalyticsDataset extends TenantScoped {
|
||||
dataset_id: string;
|
||||
name: string;
|
||||
/** BI-P003 tier. */
|
||||
tier: AnalyticsTier;
|
||||
/** Contract id + version this dataset's schema conforms to (BI-P004). */
|
||||
schema_contract_id: string;
|
||||
schema_contract_version: string;
|
||||
/** Accountable human/team, not the person who typed the create call. */
|
||||
owner_id: string;
|
||||
/** Upstream: a 0pt source_id, another dataset_id, or an external ref. */
|
||||
source_ref: string;
|
||||
/** Freshness SLO in seconds — how stale the data may be before it is late. */
|
||||
freshness_slo_seconds: number;
|
||||
/** DataQualityRule ids that gate this dataset. */
|
||||
quality_rule_ids: string[];
|
||||
/** Retention in days. 0 = keep indefinitely (must be justified in policy). */
|
||||
retention_days: number;
|
||||
classification: PiiClassification;
|
||||
status: AnalyticsDatasetStatus;
|
||||
/** BI-P003 escape hatch for RAW/NORMALIZED exposure to a BI surface. */
|
||||
raw_access_exception?: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
// ── 2. DatasetVersion (IMMUTABLE) ──────────────────────────────────────────
|
||||
|
||||
export interface DatasetVersion extends TenantScoped {
|
||||
readonly dataset_version_id: string;
|
||||
readonly dataset_id: string;
|
||||
/** Monotonic per dataset, starting at 1. */
|
||||
readonly version: number;
|
||||
/** The frozen DatasetContract JSON at this version (BI-P004). */
|
||||
readonly contract_hash: string;
|
||||
readonly schema_contract_version: string;
|
||||
readonly record_count?: number;
|
||||
readonly created_at: string;
|
||||
readonly created_by?: string;
|
||||
}
|
||||
|
||||
// ── 3. DatasetField ────────────────────────────────────────────────────────
|
||||
|
||||
export interface DatasetField extends TenantScoped {
|
||||
dataset_field_id: string;
|
||||
dataset_id: string;
|
||||
/** The dataset version this field description belongs to. */
|
||||
dataset_version: number;
|
||||
name: string;
|
||||
field_type: AnalyticsFieldType;
|
||||
nullable: boolean;
|
||||
role: FieldRole;
|
||||
/** True for the field(s) forming the dataset's primary identifier. */
|
||||
primary_identifier: boolean;
|
||||
classification: PiiClassification;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── 4. DatasetGrant ────────────────────────────────────────────────────────
|
||||
|
||||
export const GRANT_LEVELS = ["read", "query", "export", "admin"] as const;
|
||||
export type GrantLevel = (typeof GRANT_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* Who may use a dataset, and FOR WHAT. `allowed_purposes` is the GDPR purpose
|
||||
* limitation carried into analytics — a grant without a purpose is refused
|
||||
* by the repository layer.
|
||||
*/
|
||||
export interface DatasetGrant extends TenantScoped {
|
||||
grant_id: string;
|
||||
dataset_id: string;
|
||||
/** Subject: a CL0UD principal, role, or BI application binding id. */
|
||||
subject_id: string;
|
||||
subject_type: "user" | "role" | "service" | "bi_application";
|
||||
level: GrantLevel;
|
||||
allowed_purposes: string[];
|
||||
expires_at?: string;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
// ── 5. MetricDefinition ────────────────────────────────────────────────────
|
||||
|
||||
export const METRIC_AGGREGATIONS = [
|
||||
"sum",
|
||||
"avg",
|
||||
"min",
|
||||
"max",
|
||||
"count",
|
||||
"count_distinct",
|
||||
"ratio",
|
||||
"derived",
|
||||
] as const;
|
||||
export type MetricAggregation = (typeof METRIC_AGGREGATIONS)[number];
|
||||
|
||||
export interface MetricDefinition extends TenantScoped {
|
||||
metric_id: string;
|
||||
/** Stable business name; unique per tenant. */
|
||||
name: string;
|
||||
dataset_id: string;
|
||||
aggregation: MetricAggregation;
|
||||
/** Measure field aggregated, or the numerator for ratio metrics. */
|
||||
measure_field: string;
|
||||
/** Denominator for ratio metrics. */
|
||||
denominator_field?: string;
|
||||
/** Dimensions this metric may legitimately be sliced by. */
|
||||
allowed_dimension_ids: string[];
|
||||
/** Current live version number (points at a MetricVersion). */
|
||||
current_version: number;
|
||||
owner_id: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ── 6. MetricVersion (IMMUTABLE) ───────────────────────────────────────────
|
||||
|
||||
export interface MetricVersion extends TenantScoped {
|
||||
readonly metric_version_id: string;
|
||||
readonly metric_id: string;
|
||||
readonly version: number;
|
||||
/** Frozen definition JSON — what this metric MEANT at this version. */
|
||||
readonly definition_json: string;
|
||||
readonly definition_hash: string;
|
||||
/** Free-text reason the metric changed; required for version > 1. */
|
||||
readonly change_reason?: string;
|
||||
readonly created_at: string;
|
||||
readonly created_by?: string;
|
||||
}
|
||||
|
||||
// ── 7. DimensionDefinition ─────────────────────────────────────────────────
|
||||
|
||||
export interface DimensionDefinition extends TenantScoped {
|
||||
dimension_id: string;
|
||||
name: string;
|
||||
dataset_id: string;
|
||||
/**
|
||||
* Physical column/key this dimension resolves to. This is the ONLY place a
|
||||
* caller-supplied dimension name becomes a physical identifier — the query
|
||||
* compiler resolves through here and never splices caller input (BI-P011
|
||||
* "dimension identifier injection").
|
||||
*/
|
||||
field_name: string;
|
||||
field_type: AnalyticsFieldType;
|
||||
classification: PiiClassification;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── 8. SemanticModel (versioned; versions IMMUTABLE) ───────────────────────
|
||||
|
||||
export interface SemanticModel extends TenantScoped {
|
||||
semantic_model_id: string;
|
||||
name: string;
|
||||
/** Immutable version number — a change mints a new row, never an UPDATE. */
|
||||
readonly version: number;
|
||||
/** MetricFlow/Cube-compatible model document (BI-P029/BI-P032 consume it). */
|
||||
readonly model_json: string;
|
||||
readonly model_hash: string;
|
||||
dataset_ids: string[];
|
||||
owner_id: string;
|
||||
readonly created_at: string;
|
||||
readonly created_by?: string;
|
||||
}
|
||||
|
||||
// ── 9. AnalyticsQuery ──────────────────────────────────────────────────────
|
||||
|
||||
export const QUERY_SURFACES = ["metric", "sql", "projection"] as const;
|
||||
export type QuerySurface = (typeof QUERY_SURFACES)[number];
|
||||
|
||||
/**
|
||||
* A SAVED, reusable query. The request that actually runs is
|
||||
* `src/analytics/engine/query-compiler.ts`'s `MetricQueryRequest`; this entity
|
||||
* is its durable, governed form.
|
||||
*/
|
||||
export interface AnalyticsQuery extends TenantScoped {
|
||||
query_id: string;
|
||||
name: string;
|
||||
surface: QuerySurface;
|
||||
dataset_id: string;
|
||||
metric_id?: string;
|
||||
/** Serialized closed-DSL request. Never raw SQL for surface="metric". */
|
||||
request_json: string;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ── 10. QueryExecution ─────────────────────────────────────────────────────
|
||||
|
||||
export const EXECUTION_STATES = [
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"rejected",
|
||||
] as const;
|
||||
export type ExecutionState = (typeof EXECUTION_STATES)[number];
|
||||
|
||||
export interface QueryExecution extends TenantScoped {
|
||||
execution_id: string;
|
||||
query_id?: string;
|
||||
/** Engine-side identifier used to CANCEL this execution (BI-P009). */
|
||||
engine_query_id: string;
|
||||
state: ExecutionState;
|
||||
/** Which provider ran it: "clickhouse" | "duckdb" | "trino". */
|
||||
provider: string;
|
||||
submitted_at: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
duration_ms?: number;
|
||||
rows_read?: number;
|
||||
bytes_read?: number;
|
||||
result_rows?: number;
|
||||
/** SiaxErrorCode when state="failed"/"rejected". Never a raw engine body. */
|
||||
error_code?: string;
|
||||
correlation_id?: string;
|
||||
}
|
||||
|
||||
// ── 11. QueryResult ────────────────────────────────────────────────────────
|
||||
|
||||
export const RESULT_ENCODINGS = ["json", "arrow_ipc"] as const;
|
||||
export type ResultEncoding = (typeof RESULT_ENCODINGS)[number];
|
||||
|
||||
export interface QueryResult extends TenantScoped {
|
||||
result_id: string;
|
||||
execution_id: string;
|
||||
encoding: ResultEncoding;
|
||||
row_count: number;
|
||||
/** Canonical ST0RE ref when materialised; inline results carry no bytes here. */
|
||||
artifact_ref?: string;
|
||||
/** True when the engine truncated at max_result_rows (BI-P012). */
|
||||
truncated: boolean;
|
||||
expires_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── 12. DashboardDefinition ────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardDefinition extends TenantScoped {
|
||||
dashboard_id: string;
|
||||
name: string;
|
||||
/** Live version pointer. */
|
||||
current_version: number;
|
||||
owner_id: string;
|
||||
/** Datasets the dashboard reads — checked against BI-P003 tier rules. */
|
||||
dataset_ids: string[];
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ── 13. DashboardVersion (IMMUTABLE) ───────────────────────────────────────
|
||||
|
||||
export interface DashboardVersion extends TenantScoped {
|
||||
readonly dashboard_version_id: string;
|
||||
readonly dashboard_id: string;
|
||||
readonly version: number;
|
||||
/** Frozen layout/panel document. */
|
||||
readonly layout_json: string;
|
||||
readonly layout_hash: string;
|
||||
readonly created_at: string;
|
||||
readonly created_by?: string;
|
||||
}
|
||||
|
||||
// ── 14. ReportDefinition ───────────────────────────────────────────────────
|
||||
|
||||
export const REPORT_FORMATS = ["pdf", "csv", "xlsx", "parquet", "arrow_ipc"] as const;
|
||||
export type ReportFormat = (typeof REPORT_FORMATS)[number];
|
||||
|
||||
export interface ReportDefinition extends TenantScoped {
|
||||
report_id: string;
|
||||
name: string;
|
||||
dashboard_id?: string;
|
||||
query_id?: string;
|
||||
format: ReportFormat;
|
||||
/** Cron expression; undefined = on-demand only. */
|
||||
schedule_cron?: string;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ── 15. ReportRun ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportRun extends TenantScoped {
|
||||
report_run_id: string;
|
||||
report_id: string;
|
||||
state: ExecutionState;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
/** ST0RE artifact ref of the produced report. */
|
||||
artifact_ref?: string;
|
||||
error_code?: string;
|
||||
correlation_id?: string;
|
||||
}
|
||||
|
||||
// ── 16. DataQualityRule ────────────────────────────────────────────────────
|
||||
|
||||
export const QUALITY_RULE_KINDS = [
|
||||
"not_null",
|
||||
"unique",
|
||||
"accepted_values",
|
||||
"range",
|
||||
"freshness",
|
||||
"row_count",
|
||||
"referential",
|
||||
"custom",
|
||||
] as const;
|
||||
export type QualityRuleKind = (typeof QUALITY_RULE_KINDS)[number];
|
||||
|
||||
export const QUALITY_SEVERITIES = ["warn", "block"] as const;
|
||||
export type QualitySeverity = (typeof QUALITY_SEVERITIES)[number];
|
||||
|
||||
export interface DataQualityRule extends TenantScoped {
|
||||
rule_id: string;
|
||||
dataset_id: string;
|
||||
kind: QualityRuleKind;
|
||||
/** Field the rule applies to; omitted for dataset-level rules. */
|
||||
field_name?: string;
|
||||
/** Rule parameters (bounds, accepted values, max age). */
|
||||
params_json: string;
|
||||
severity: QualitySeverity;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
// ── 17. DataQualityRun ─────────────────────────────────────────────────────
|
||||
|
||||
export interface DataQualityRun extends TenantScoped {
|
||||
quality_run_id: string;
|
||||
rule_id: string;
|
||||
dataset_id: string;
|
||||
dataset_version?: number;
|
||||
passed: boolean;
|
||||
/** Rows that violated the rule. */
|
||||
violation_count: number;
|
||||
evaluated_rows: number;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
correlation_id?: string;
|
||||
}
|
||||
|
||||
// ── 18. LineageReference ───────────────────────────────────────────────────
|
||||
|
||||
export const LINEAGE_DIRECTIONS = ["input", "output"] as const;
|
||||
export type LineageDirection = (typeof LINEAGE_DIRECTIONS)[number];
|
||||
|
||||
/**
|
||||
* The queryable projection of an OpenLineage edge (BI-P027). AUD0 stays the
|
||||
* system of record; this is 0pt's local index so "what fed this dashboard"
|
||||
* is answerable without leaving the analytics plane.
|
||||
*/
|
||||
export interface LineageReference extends TenantScoped {
|
||||
lineage_reference_id: string;
|
||||
/** OpenLineage run id this edge came from. */
|
||||
run_id: string;
|
||||
job_name: string;
|
||||
direction: LineageDirection;
|
||||
/** The analytics entity this edge touches. */
|
||||
entity_type: "dataset" | "metric" | "dashboard" | "report";
|
||||
entity_id: string;
|
||||
/** Namespaced dataset name on the other side of the edge. */
|
||||
counterpart_ref: string;
|
||||
observed_at: string;
|
||||
}
|
||||
|
||||
// ── 19. BIApplicationBinding ───────────────────────────────────────────────
|
||||
|
||||
export const BI_APPLICATION_KINDS = ["superset", "evidence", "des1gn", "notebook", "custom"] as const;
|
||||
export type BiApplicationKind = (typeof BI_APPLICATION_KINDS)[number];
|
||||
|
||||
/**
|
||||
* How an external BI surface is attached to the analytics plane.
|
||||
*
|
||||
* NORMATIVE (BI program §0 + the SIAX/0pt ownership boundary): this binding
|
||||
* carries NO engine credential. A bound application authenticates to the 0pt
|
||||
* Analytics API with a CL0UD identity and receives compiled, tenant-forced
|
||||
* results — it never learns a ClickHouse DSN, user, or password. The absence
|
||||
* of a credential field here is the enforcement, not a comment.
|
||||
*/
|
||||
export interface BIApplicationBinding extends TenantScoped {
|
||||
binding_id: string;
|
||||
kind: BiApplicationKind;
|
||||
name: string;
|
||||
/** CL0UD principal the application authenticates as. */
|
||||
principal_id: string;
|
||||
/** Datasets/metrics this application may reach at all. */
|
||||
dataset_ids: string[];
|
||||
metric_ids: string[];
|
||||
allowed_purposes: string[];
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
// ── registry of the canonical nineteen ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The canonical entity names, verbatim from BI-P002. Persisted-schema tests
|
||||
* assert sql/008 defines a table for every one of these, so a future rename
|
||||
* or omission is a test failure rather than drift.
|
||||
*/
|
||||
export const ANALYTICS_DOMAIN_ENTITIES = [
|
||||
"AnalyticsDataset",
|
||||
"DatasetVersion",
|
||||
"DatasetField",
|
||||
"DatasetGrant",
|
||||
"MetricDefinition",
|
||||
"MetricVersion",
|
||||
"DimensionDefinition",
|
||||
"SemanticModel",
|
||||
"AnalyticsQuery",
|
||||
"QueryExecution",
|
||||
"QueryResult",
|
||||
"DashboardDefinition",
|
||||
"DashboardVersion",
|
||||
"ReportDefinition",
|
||||
"ReportRun",
|
||||
"DataQualityRule",
|
||||
"DataQualityRun",
|
||||
"LineageReference",
|
||||
"BIApplicationBinding",
|
||||
] as const;
|
||||
export type AnalyticsDomainEntity = (typeof ANALYTICS_DOMAIN_ENTITIES)[number];
|
||||
|
||||
/** Entity -> physical table in sql/008_analytics_domain_model.sql. */
|
||||
export const ANALYTICS_ENTITY_TABLES: Record<AnalyticsDomainEntity, string> = {
|
||||
AnalyticsDataset: "opt_analytics_dataset",
|
||||
DatasetVersion: "opt_analytics_dataset_version",
|
||||
DatasetField: "opt_analytics_dataset_field",
|
||||
DatasetGrant: "opt_analytics_dataset_grant",
|
||||
MetricDefinition: "opt_analytics_metric_definition",
|
||||
MetricVersion: "opt_analytics_metric_version",
|
||||
DimensionDefinition: "opt_analytics_dimension_definition",
|
||||
SemanticModel: "opt_analytics_semantic_model",
|
||||
AnalyticsQuery: "opt_analytics_query",
|
||||
QueryExecution: "opt_analytics_query_execution",
|
||||
QueryResult: "opt_analytics_query_result",
|
||||
DashboardDefinition: "opt_analytics_dashboard_definition",
|
||||
DashboardVersion: "opt_analytics_dashboard_version",
|
||||
ReportDefinition: "opt_analytics_report_definition",
|
||||
ReportRun: "opt_analytics_report_run",
|
||||
DataQualityRule: "opt_analytics_data_quality_rule",
|
||||
DataQualityRun: "opt_analytics_data_quality_run",
|
||||
LineageReference: "opt_analytics_lineage_reference",
|
||||
BIApplicationBinding: "opt_analytics_bi_application_binding",
|
||||
};
|
||||
|
||||
/** The four entities BI-P002 requires to be immutable once written. */
|
||||
export const IMMUTABLE_VERSION_ENTITIES: readonly AnalyticsDomainEntity[] = [
|
||||
"DatasetVersion",
|
||||
"MetricVersion",
|
||||
"SemanticModel",
|
||||
"DashboardVersion",
|
||||
];
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* src/analytics/domain/tiers.ts
|
||||
* ==============================
|
||||
* BI-P003 — ANALYTICS DATA TIERS.
|
||||
*
|
||||
* The four canonical tiers every analytics dataset in SIAX belongs to. The
|
||||
* tier is a CONTRACT about where a dataset may be read from and by whom, not
|
||||
* a naming convention:
|
||||
*
|
||||
* RAW -> ST0RE Parquet/Iceberg (as-ingested, no business meaning)
|
||||
* NORMALIZED -> ST0RE Iceberg (typed, deduplicated, still source-shaped)
|
||||
* CURATED -> ST0RE Iceberg (business entities, modelled)
|
||||
* SERVING -> ClickHouse / projection (query-optimised, dashboard-facing)
|
||||
*
|
||||
* BI-P003's normative sentence is "No business dashboard reads arbitrary raw
|
||||
* ingestion tables by default" — `assertReadableByBiSurface` is where that
|
||||
* sentence stops being prose. A BI surface (dashboard, report, embedded
|
||||
* chart) may read SERVING and CURATED; RAW and NORMALIZED require an
|
||||
* explicit, recorded exception on the dataset itself.
|
||||
*
|
||||
* ARCHITECTURE BOUNDARY (BI program §0): 0pt owns tier semantics. Product
|
||||
* repos (VehIQ et al) own what the rows MEAN; they never re-declare tiers.
|
||||
*/
|
||||
import { SiaxError } from "../../platform/errors";
|
||||
|
||||
export const ANALYTICS_TIERS = ["RAW", "NORMALIZED", "CURATED", "SERVING"] as const;
|
||||
export type AnalyticsTier = (typeof ANALYTICS_TIERS)[number];
|
||||
|
||||
/** Physical home a tier is expected to live in (BI-P003 "suggested" mapping). */
|
||||
export const TIER_PHYSICAL_HOME: Record<AnalyticsTier, string> = {
|
||||
RAW: "st0re:iceberg|parquet",
|
||||
NORMALIZED: "st0re:iceberg",
|
||||
CURATED: "st0re:iceberg",
|
||||
SERVING: "clickhouse:projection",
|
||||
};
|
||||
|
||||
/** Tiers a BI presentation surface may read without an explicit exception. */
|
||||
export const BI_READABLE_TIERS: readonly AnalyticsTier[] = ["SERVING", "CURATED"];
|
||||
|
||||
export function isAnalyticsTier(v: unknown): v is AnalyticsTier {
|
||||
return typeof v === "string" && (ANALYTICS_TIERS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank used for promotion checks: a dataset may only be BUILT FROM a tier at
|
||||
* or below its own (SERVING can be built from CURATED; CURATED must never be
|
||||
* built from SERVING, which would make the projection its own source).
|
||||
*/
|
||||
export const TIER_RANK: Record<AnalyticsTier, number> = {
|
||||
RAW: 0,
|
||||
NORMALIZED: 1,
|
||||
CURATED: 2,
|
||||
SERVING: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* BI-P003 DoD gate. `raw_access_exception` is the recorded, auditable escape
|
||||
* hatch — absent it, a RAW/NORMALIZED dataset is invisible to dashboards.
|
||||
*/
|
||||
export function assertReadableByBiSurface(
|
||||
dataset: { dataset_id: string; tier: AnalyticsTier; raw_access_exception?: string },
|
||||
surface: string,
|
||||
): void {
|
||||
if (BI_READABLE_TIERS.includes(dataset.tier)) return;
|
||||
if (dataset.raw_access_exception && dataset.raw_access_exception.trim().length > 0) return;
|
||||
throw new SiaxError(
|
||||
"AUTHORIZATION_DENIED",
|
||||
"0pt.analytics",
|
||||
`BI surface "${surface}" may not read ${dataset.tier} dataset ${dataset.dataset_id} ` +
|
||||
`without a recorded raw_access_exception (BI-P003)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A dataset may not declare a source tier ABOVE its own tier. */
|
||||
export function assertTierLineageSane(datasetTier: AnalyticsTier, sourceTier: AnalyticsTier): void {
|
||||
if (TIER_RANK[sourceTier] > TIER_RANK[datasetTier]) {
|
||||
throw new SiaxError(
|
||||
"INVALID_REQUEST",
|
||||
"0pt.analytics",
|
||||
`a ${datasetTier} dataset cannot be built from a ${sourceTier} dataset (BI-P003 tier order)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* src/analytics/engine/clickhouse-engine.ts
|
||||
* ==========================================
|
||||
* BI-P009 — ClickHouse as the CANONICAL `SiaxAnalyticsEngine` provider.
|
||||
*
|
||||
* PRESERVATION (BI-P009's first sentence): `ClickHouseAnalytics`
|
||||
* (src/analytics/clickhouse-analytics.ts) is untouched by this change — not
|
||||
* one line. This engine COMPOSES it:
|
||||
*
|
||||
* * `health()` delegates to `ClickHouseAnalytics.health()`.
|
||||
* * `executeMetric()` on the pre-existing `opt_metric_events` table
|
||||
* delegates to `ClickHouseAnalytics.queryMetric()`, i.e. keeps the
|
||||
* already-proven forced-tenant path exactly as it is.
|
||||
* * everything else (the BI-P010 tables) goes through
|
||||
* `src/analytics/engine/query-compiler.ts`, which forces the same
|
||||
* predicate for the same reason.
|
||||
*
|
||||
* "Do not regress the existing forced tenant predicate" is enforced by
|
||||
* tests/analytics/tenant-predicate-regression.test.ts, which fails if either
|
||||
* compiler stops emitting `tenant_id = {tenant:String}`.
|
||||
*
|
||||
* BI-P012 governance is applied on EVERY statement, including the delegated
|
||||
* ones — the settings ride on the HTTP request, and `readonly=1` means the
|
||||
* query text cannot re-set them.
|
||||
*
|
||||
* LEGAL BOUNDARY (inherited): generic 0pt analytics only. Never the b0ts
|
||||
* ClickHouse pipelines, never quarantined market data.
|
||||
*/
|
||||
import crypto from "node:crypto";
|
||||
import { SiaxError, siaxErrorCodeForUpstreamStatus } from "../../platform/errors";
|
||||
import { ClickHouseAnalytics } from "../clickhouse-analytics";
|
||||
import {
|
||||
compileMetricQuery,
|
||||
compileProjectionQuery,
|
||||
dimensionCatalogFrom,
|
||||
FORCED_TENANT_PREDICATE,
|
||||
type DimensionCatalog,
|
||||
} from "./query-compiler";
|
||||
import { analyticsSchemaDdl, type AnalyticsSchemaOptions } from "./schema";
|
||||
import {
|
||||
applyQuota,
|
||||
clickhouseSettings,
|
||||
ConcurrencyGate,
|
||||
DEFAULT_BUDGETS,
|
||||
planningSettings,
|
||||
type ResourceBudget,
|
||||
} from "./resource-governance";
|
||||
import type {
|
||||
AnalystQueryRequest,
|
||||
AnalyticsIdentity,
|
||||
EngineResult,
|
||||
HealthReport,
|
||||
MetricRequest,
|
||||
ProjectionIngestRequest,
|
||||
ProjectionRefreshResult,
|
||||
QueryEstimate,
|
||||
QueryExplanation,
|
||||
SiaxAnalyticsEngine,
|
||||
} from "./siax-analytics-engine";
|
||||
|
||||
export interface ClickHouseEngineOptions {
|
||||
baseUrl: string;
|
||||
/** "user:password" — from an env/secret ref, never a literal in code. */
|
||||
basicAuth?: string;
|
||||
database?: string;
|
||||
timeoutMs?: number;
|
||||
/** Dimension allow-list per tenant. Absent tenant = no dimensions allowed. */
|
||||
dimensionCatalogs?: Record<string, Record<string, string>>;
|
||||
schema?: Omit<AnalyticsSchemaOptions, "database">;
|
||||
}
|
||||
|
||||
const EMPTY_CATALOG: DimensionCatalog = { resolve: () => undefined };
|
||||
|
||||
export class ClickHouseAnalyticsEngine implements SiaxAnalyticsEngine {
|
||||
readonly provider = "clickhouse";
|
||||
|
||||
/** PRESERVED pre-existing implementation, composed not replaced. */
|
||||
private readonly legacy: ClickHouseAnalytics;
|
||||
private readonly baseUrl: string;
|
||||
private readonly authHeader?: string;
|
||||
private readonly database: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly dimensionCatalogs: Record<string, Record<string, string>>;
|
||||
private readonly schemaOpts: Omit<AnalyticsSchemaOptions, "database">;
|
||||
private readonly gate: ConcurrencyGate;
|
||||
/** query_id -> AbortController, so cancelQuery can stop the client side too. */
|
||||
private readonly inFlight = new Map<string, AbortController>();
|
||||
|
||||
constructor(opts: ClickHouseEngineOptions) {
|
||||
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
||||
this.authHeader = opts.basicAuth ? `Basic ${Buffer.from(opts.basicAuth).toString("base64")}` : undefined;
|
||||
this.database = opts.database ?? "opt_analytics";
|
||||
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
||||
this.dimensionCatalogs = opts.dimensionCatalogs ?? {};
|
||||
this.schemaOpts = opts.schema ?? {};
|
||||
this.legacy = new ClickHouseAnalytics({
|
||||
baseUrl: opts.baseUrl,
|
||||
basicAuth: opts.basicAuth,
|
||||
database: this.database,
|
||||
timeoutMs: this.timeoutMs,
|
||||
});
|
||||
this.gate = new ConcurrencyGate((tenantId) => this.budgetForTenant(tenantId).maxConcurrentQueries);
|
||||
}
|
||||
|
||||
/** Access to the preserved implementation, for callers that still use it. */
|
||||
get legacyAnalytics(): ClickHouseAnalytics {
|
||||
return this.legacy;
|
||||
}
|
||||
|
||||
private budgetForTenant(_tenantId: string): ResourceBudget {
|
||||
return DEFAULT_BUDGETS.INTERACTIVE;
|
||||
}
|
||||
|
||||
private budgetFor(identity: AnalyticsIdentity): ResourceBudget {
|
||||
const tier = identity.budget_tier ?? "INTERACTIVE";
|
||||
return applyQuota(DEFAULT_BUDGETS[tier], identity.quota);
|
||||
}
|
||||
|
||||
private catalogFor(identity: AnalyticsIdentity): DimensionCatalog {
|
||||
const pairs = this.dimensionCatalogs[identity.tenant_id];
|
||||
return pairs ? dimensionCatalogFrom(pairs) : EMPTY_CATALOG;
|
||||
}
|
||||
|
||||
/** Fail closed: a missing/blank verified tenant is never a wildcard. */
|
||||
private assertIdentity(identity: AnalyticsIdentity): void {
|
||||
if (!identity?.tenant_id || !identity.tenant_id.trim()) {
|
||||
throw new SiaxError(
|
||||
"UNAUTHENTICATED",
|
||||
"0pt.analytics",
|
||||
"analytics requires a verified CL0UD identity carrying tenant_id",
|
||||
);
|
||||
}
|
||||
if (!identity.principal_id || !identity.principal_id.trim()) {
|
||||
throw new SiaxError("UNAUTHENTICATED", "0pt.analytics", "analytics requires a verified principal_id");
|
||||
}
|
||||
}
|
||||
|
||||
// ── schema ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Apply BI-P010 DDL (idempotent) plus the preserved legacy table. */
|
||||
async ensureSchema(): Promise<void> {
|
||||
await this.legacy.ensureSchema();
|
||||
for (const stmt of analyticsSchemaDdl({ database: this.database, ...this.schemaOpts })) {
|
||||
await this.ddl(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
// ── BI-P009 capability 1: health ─────────────────────────────────────────
|
||||
|
||||
async health(): Promise<HealthReport> {
|
||||
try {
|
||||
const legacyOk = await this.legacy.health();
|
||||
if (!legacyOk) return { healthy: false, provider: this.provider, detail: "legacy health probe returned false" };
|
||||
const v = await this.raw("SELECT version() AS v FORMAT JSON", {}, {});
|
||||
const version = (v as { data?: { v?: string }[] })?.data?.[0]?.v;
|
||||
return { healthy: true, provider: this.provider, version };
|
||||
} catch (err) {
|
||||
return {
|
||||
healthy: false,
|
||||
provider: this.provider,
|
||||
detail: err instanceof SiaxError ? err.code : "unreachable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── BI-P009 capability 2: executeMetric ──────────────────────────────────
|
||||
|
||||
async executeMetric(identity: AnalyticsIdentity, request: MetricRequest): Promise<EngineResult> {
|
||||
this.assertIdentity(identity);
|
||||
const budget = this.budgetFor(identity);
|
||||
const release = this.gate.acquire(identity.tenant_id);
|
||||
const started = Date.now();
|
||||
const queryId = this.newQueryId(identity);
|
||||
try {
|
||||
const compiled = compileMetricQuery(
|
||||
{
|
||||
tenant_id: identity.tenant_id, // FORCED from the verified identity
|
||||
project_id: identity.project_id,
|
||||
dataset_id: request.dataset_id,
|
||||
metric: request.metric,
|
||||
aggregate: request.aggregate,
|
||||
group_by: request.group_by,
|
||||
filters: request.filters,
|
||||
from_time: request.from_time,
|
||||
to_time: request.to_time,
|
||||
limit: request.limit,
|
||||
table: request.table,
|
||||
},
|
||||
this.database,
|
||||
this.catalogFor(identity),
|
||||
budget.maxResultRows,
|
||||
);
|
||||
const json = (await this.raw(compiled.sql, compiled.params, clickhouseSettings(budget), queryId)) as
|
||||
| ClickHouseJsonResponse
|
||||
| undefined;
|
||||
const groupNames = request.group_by ?? [];
|
||||
const rows = (json?.data ?? []).map((r) => {
|
||||
const groups: Record<string, string> = {};
|
||||
groupNames.forEach((name, i) => {
|
||||
const v = (r as Record<string, unknown>)[`grp${i}`];
|
||||
groups[name] = v === undefined || v === null ? "" : String(v);
|
||||
});
|
||||
return {
|
||||
...(groupNames.length ? { groups } : {}),
|
||||
value: Number((r as { value: number | string }).value),
|
||||
};
|
||||
});
|
||||
const limit = Math.max(1, Math.min(Math.floor(request.limit ?? 1000), budget.maxResultRows));
|
||||
return {
|
||||
rows,
|
||||
query_id: queryId,
|
||||
truncated: rows.length >= limit,
|
||||
stats: {
|
||||
duration_ms: Date.now() - started,
|
||||
rows_read: json?.statistics?.rows_read,
|
||||
bytes_read: json?.statistics?.bytes_read,
|
||||
result_rows: rows.length,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
release();
|
||||
this.inFlight.delete(queryId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegating path to the PRESERVED implementation for the pre-existing
|
||||
* `opt_metric_events` table. Kept so no existing caller has to migrate and
|
||||
* the already-proven forced-predicate path stays in service.
|
||||
*/
|
||||
async executeLegacyMetric(
|
||||
identity: AnalyticsIdentity,
|
||||
request: { metric: string; aggregate: "sum" | "avg" | "min" | "max" | "count"; dataset_id?: string; group_by?: string; from_time?: string; to_time?: string; limit?: number },
|
||||
): Promise<EngineResult> {
|
||||
this.assertIdentity(identity);
|
||||
const started = Date.now();
|
||||
const rows = await this.legacy.queryMetric({
|
||||
tenant_id: identity.tenant_id, // FORCED from the verified identity
|
||||
metric: request.metric,
|
||||
aggregate: request.aggregate,
|
||||
dataset_id: request.dataset_id,
|
||||
group_by: request.group_by,
|
||||
from_time: request.from_time,
|
||||
to_time: request.to_time,
|
||||
limit: request.limit,
|
||||
});
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
...(r.group !== undefined && request.group_by ? { groups: { [request.group_by]: r.group } } : {}),
|
||||
value: r.value,
|
||||
})),
|
||||
query_id: this.newQueryId(identity),
|
||||
truncated: false,
|
||||
stats: { duration_ms: Date.now() - started, result_rows: rows.length },
|
||||
};
|
||||
}
|
||||
|
||||
// ── BI-P009 capability 3: executeQuery (analyst SQL) ─────────────────────
|
||||
|
||||
async executeQuery(identity: AnalyticsIdentity, _request: AnalystQueryRequest): Promise<EngineResult> {
|
||||
this.assertIdentity(identity);
|
||||
// Deliberate, documented refusal — see AnalystQueryRequest's doc comment.
|
||||
// Opening a raw-SQL surface before BI-P017 (SQL dialect contract) and
|
||||
// BI-P033 (analytics authorization) exist would create precisely the
|
||||
// "raw SQL attempt" hole BI-P011's adversarial list enumerates.
|
||||
throw new SiaxError(
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"0pt.analytics",
|
||||
"analyst SQL is not enabled on the ClickHouse provider until BI-P017/BI-P033 land",
|
||||
);
|
||||
}
|
||||
|
||||
// ── BI-P009 capability 4: estimateQuery ──────────────────────────────────
|
||||
|
||||
async estimateQuery(identity: AnalyticsIdentity, request: MetricRequest): Promise<QueryEstimate> {
|
||||
this.assertIdentity(identity);
|
||||
const budget = this.budgetFor(identity);
|
||||
const compiled = compileMetricQuery(
|
||||
{
|
||||
tenant_id: identity.tenant_id,
|
||||
project_id: identity.project_id,
|
||||
dataset_id: request.dataset_id,
|
||||
metric: request.metric,
|
||||
aggregate: request.aggregate,
|
||||
group_by: request.group_by,
|
||||
filters: request.filters,
|
||||
from_time: request.from_time,
|
||||
to_time: request.to_time,
|
||||
limit: request.limit,
|
||||
table: request.table,
|
||||
},
|
||||
this.database,
|
||||
this.catalogFor(identity),
|
||||
budget.maxResultRows,
|
||||
);
|
||||
// `EXPLAIN ESTIMATE` reports the parts/rows/marks the planner will read
|
||||
// AFTER primary-key pruning — which is why tenant_id leads every ORDER BY
|
||||
// in schema.ts: the estimate for one tenant does not include everyone.
|
||||
const estimateSql = compiled.sql.replace(/ FORMAT JSON$/, "");
|
||||
let rowsToRead = 0;
|
||||
let partsRead = 0;
|
||||
try {
|
||||
const res = (await this.raw(
|
||||
`EXPLAIN ESTIMATE ${estimateSql} FORMAT JSON`,
|
||||
compiled.params,
|
||||
planningSettings(budget),
|
||||
)) as { data?: { rows?: number | string; parts?: number | string }[] } | undefined;
|
||||
for (const row of res?.data ?? []) {
|
||||
rowsToRead += Number(row.rows ?? 0);
|
||||
partsRead += Number(row.parts ?? 0);
|
||||
}
|
||||
} catch (err) {
|
||||
// An estimate that cannot be produced must not read as "cheap".
|
||||
throw err instanceof SiaxError
|
||||
? err
|
||||
: new SiaxError("DEPENDENCY_UNAVAILABLE", "clickhouse", "could not estimate query");
|
||||
}
|
||||
// Rough byte estimate: ClickHouse's ESTIMATE reports rows, not bytes.
|
||||
// 128 B/row is a deliberately PESSIMISTIC constant for these tables
|
||||
// (measured mean in the BI-P010 benchmark is lower), so the gate errs
|
||||
// toward refusing rather than admitting an oversized query.
|
||||
const bytesToRead = rowsToRead * 128;
|
||||
const within = rowsToRead <= budget.maxRowsToRead && bytesToRead <= budget.maxBytesToRead;
|
||||
return {
|
||||
estimated_rows_to_read: rowsToRead,
|
||||
estimated_bytes_to_read: bytesToRead,
|
||||
within_budget: within,
|
||||
budget,
|
||||
...(within
|
||||
? {}
|
||||
: {
|
||||
reason:
|
||||
`estimate ${rowsToRead} rows / ${bytesToRead} bytes over budget ` +
|
||||
`(${budget.maxRowsToRead} rows / ${budget.maxBytesToRead} bytes), parts=${partsRead}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── BI-P009 capability 5: cancelQuery ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Two-sided cancellation. The client-side abort stops us waiting; the
|
||||
* server-side KILL QUERY stops ClickHouse burning CPU. Cancelling is
|
||||
* tenant-scoped: the query id embeds the tenant and is checked, so tenant A
|
||||
* cannot cancel tenant B's work.
|
||||
*/
|
||||
async cancelQuery(identity: AnalyticsIdentity, queryId: string): Promise<boolean> {
|
||||
this.assertIdentity(identity);
|
||||
if (!queryId.startsWith(this.queryIdPrefix(identity))) {
|
||||
throw new SiaxError("AUTHORIZATION_DENIED", "0pt.analytics", "query does not belong to this tenant");
|
||||
}
|
||||
this.inFlight.get(queryId)?.abort();
|
||||
this.inFlight.delete(queryId);
|
||||
try {
|
||||
// KILL QUERY is a mutation of server state, so it cannot ride the
|
||||
// readonly=1 settings the SELECT path uses.
|
||||
await this.raw(
|
||||
`KILL QUERY WHERE query_id = {killId:String} SYNC FORMAT JSON`,
|
||||
{ param_killId: queryId },
|
||||
{ max_execution_time: "10" },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── BI-P009 capability 6: explain ────────────────────────────────────────
|
||||
|
||||
async explain(identity: AnalyticsIdentity, request: MetricRequest): Promise<QueryExplanation> {
|
||||
this.assertIdentity(identity);
|
||||
const budget = this.budgetFor(identity);
|
||||
const compiled = compileMetricQuery(
|
||||
{
|
||||
tenant_id: identity.tenant_id,
|
||||
project_id: identity.project_id,
|
||||
dataset_id: request.dataset_id,
|
||||
metric: request.metric,
|
||||
aggregate: request.aggregate,
|
||||
group_by: request.group_by,
|
||||
filters: request.filters,
|
||||
from_time: request.from_time,
|
||||
to_time: request.to_time,
|
||||
limit: request.limit,
|
||||
table: request.table,
|
||||
},
|
||||
this.database,
|
||||
this.catalogFor(identity),
|
||||
budget.maxResultRows,
|
||||
);
|
||||
const sql = compiled.sql.replace(/ FORMAT JSON$/, "");
|
||||
const res = (await this.raw(
|
||||
`EXPLAIN PLAN indexes = 1 ${sql} FORMAT TSVRaw`,
|
||||
compiled.params,
|
||||
planningSettings(budget),
|
||||
)) as string | undefined;
|
||||
return {
|
||||
plan: typeof res === "string" ? res : JSON.stringify(res ?? ""),
|
||||
forced_tenant_predicate: FORCED_TENANT_PREDICATE,
|
||||
table: compiled.table,
|
||||
};
|
||||
}
|
||||
|
||||
// ── BI-P009 capability 7: ingestProjection ───────────────────────────────
|
||||
|
||||
async ingestProjection(
|
||||
identity: AnalyticsIdentity,
|
||||
request: ProjectionIngestRequest,
|
||||
): Promise<{ ingested: number }> {
|
||||
this.assertIdentity(identity);
|
||||
if (request.rows.length === 0) return { ingested: 0 };
|
||||
const now = new Date();
|
||||
const version = Math.floor(now.getTime());
|
||||
const body = request.rows
|
||||
.map((r) => {
|
||||
if (!r.entity_key || !r.entity_key.trim()) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "every projection row needs an entity_key");
|
||||
}
|
||||
return JSON.stringify({
|
||||
// tenant/project come from the VERIFIED identity, never the row —
|
||||
// a row-supplied tenant_id is the INF0 Wave-2 bug class.
|
||||
tenant_id: identity.tenant_id,
|
||||
project_id: identity.project_id ?? "",
|
||||
dataset_id: request.dataset_id,
|
||||
entity_key: r.entity_key,
|
||||
as_of: (r.as_of ?? now.toISOString()).replace("T", " ").replace(/Z$/, ""),
|
||||
version: r.version ?? version,
|
||||
attributes: r.attributes ?? {},
|
||||
measures: r.measures ?? {},
|
||||
});
|
||||
})
|
||||
.join("\n");
|
||||
await this.raw(
|
||||
`INSERT INTO ${this.database}.opt_dataset_projection FORMAT JSONEachRow`,
|
||||
{},
|
||||
{},
|
||||
undefined,
|
||||
body,
|
||||
);
|
||||
return { ingested: request.rows.length };
|
||||
}
|
||||
|
||||
// ── BI-P009 capability 8: refreshProjection ──────────────────────────────
|
||||
|
||||
/**
|
||||
* ReplacingMergeTree deduplicates on a background merge, so a read straight
|
||||
* after an ingest can still see superseded rows. `OPTIMIZE ... FINAL` forces
|
||||
* the merge for THIS tenant's partition only — the table is
|
||||
* `PARTITION BY (tenant_id)` precisely so one tenant's refresh does not
|
||||
* rewrite another tenant's parts (BI-P012's "without degrading unrelated
|
||||
* tenant" applies to maintenance too, not only to queries).
|
||||
*/
|
||||
async refreshProjection(identity: AnalyticsIdentity, dataset_id: string): Promise<ProjectionRefreshResult> {
|
||||
this.assertIdentity(identity);
|
||||
await this.raw(
|
||||
`OPTIMIZE TABLE ${this.database}.opt_dataset_projection PARTITION {tenant:String} FINAL`,
|
||||
{ param_tenant: identity.tenant_id },
|
||||
{ max_execution_time: "120" },
|
||||
);
|
||||
const compiled = compileProjectionQuery(
|
||||
{ tenant_id: identity.tenant_id, project_id: identity.project_id, dataset_id, limit: 1 },
|
||||
this.database,
|
||||
1,
|
||||
);
|
||||
const countSql =
|
||||
`SELECT count() AS value FROM ${this.database}.opt_dataset_projection FINAL ` +
|
||||
`WHERE ${FORCED_TENANT_PREDICATE} AND dataset_id = {dataset:String} FORMAT JSON`;
|
||||
const res = (await this.raw(countSql, compiled.params, clickhouseSettings(this.budgetFor(identity)))) as
|
||||
| { data?: { value: number | string }[] }
|
||||
| undefined;
|
||||
return {
|
||||
dataset_id,
|
||||
row_count: Number(res?.data?.[0]?.value ?? 0),
|
||||
refreshed_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── transport ────────────────────────────────────────────────────────────
|
||||
|
||||
private queryIdPrefix(identity: AnalyticsIdentity): string {
|
||||
return `opt_${crypto.createHash("sha256").update(identity.tenant_id).digest("hex").slice(0, 12)}_`;
|
||||
}
|
||||
|
||||
private newQueryId(identity: AnalyticsIdentity): string {
|
||||
return `${this.queryIdPrefix(identity)}${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
}
|
||||
|
||||
/** DDL path: no readonly, no result parsing beyond errors. */
|
||||
private async ddl(sql: string): Promise<void> {
|
||||
await this.raw(sql, {}, { max_execution_time: "60" });
|
||||
}
|
||||
|
||||
private async raw(
|
||||
sql: string,
|
||||
params: Record<string, string>,
|
||||
settings: Record<string, string>,
|
||||
queryId?: string,
|
||||
body?: string,
|
||||
): Promise<unknown> {
|
||||
const url = new URL(this.baseUrl);
|
||||
url.searchParams.set("query", sql);
|
||||
for (const [k, v] of Object.entries(settings)) url.searchParams.set(k, v);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
if (queryId) url.searchParams.set("query_id", queryId);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.authHeader) headers["Authorization"] = this.authHeader;
|
||||
|
||||
const controller = new AbortController();
|
||||
if (queryId) this.inFlight.set(queryId, controller);
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: body ?? "",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new SiaxError("DEPENDENCY_UNAVAILABLE", "clickhouse", "clickhouse unreachable", { cause });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
// ClickHouse error text can echo the query; keep only the numeric code
|
||||
// so a caller never receives another tenant's identifiers back.
|
||||
const code = /Code: (\d+)/.exec(text)?.[1] ?? String(res.status);
|
||||
throw new SiaxError(
|
||||
siaxErrorCodeForUpstreamStatus(res.status),
|
||||
"clickhouse",
|
||||
`query failed (CH code ${code})`,
|
||||
);
|
||||
}
|
||||
if (!text) return undefined;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
// DEFENCE IN DEPTH against a partial result that looks complete.
|
||||
// ClickHouse normally answers a breached limit with an HTTP error status
|
||||
// (measured on 24.8: 408 for max_execution_time, 500 for
|
||||
// max_rows_to_read), which `res.ok` above already catches. But when rows
|
||||
// have already been flushed, the JSON body can carry an `exception` key
|
||||
// under a 200 — a truncated answer indistinguishable from a whole one.
|
||||
// Treat any body carrying an exception as a failure, whatever the status.
|
||||
if (parsed && typeof parsed === "object" && "exception" in parsed) {
|
||||
const ex = String((parsed as { exception?: unknown }).exception ?? "");
|
||||
const code = /Code: (\d+)/.exec(ex)?.[1] ?? "unknown";
|
||||
throw new SiaxError("PERMANENT_FAILURE", "clickhouse", `query failed mid-stream (CH code ${code})`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
interface ClickHouseJsonResponse {
|
||||
data?: Record<string, unknown>[];
|
||||
statistics?: { rows_read?: number; bytes_read?: number; elapsed?: number };
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* src/analytics/engine/query-compiler.ts
|
||||
* =======================================
|
||||
* BI-P011 — CLICKHOUSE TENANT ISOLATION, layer 2:
|
||||
* "`0pt` query compiler forces tenant".
|
||||
*
|
||||
* The compiler is the ONLY thing in this repo that turns an analytics request
|
||||
* into SQL text. Its contract:
|
||||
*
|
||||
* * `tenant_id` is not an argument the caller may omit — it comes from the
|
||||
* VERIFIED CL0UD identity (layer 1) and is written into the WHERE clause
|
||||
* by this function, unconditionally, as a bound parameter.
|
||||
* * caller-supplied VALUES are never spliced into SQL text. They travel as
|
||||
* ClickHouse HTTP `param_*` query parameters.
|
||||
* * caller-supplied IDENTIFIERS (a dimension name, a group-by column) are
|
||||
* never spliced either. They are RESOLVED against a declared allow-list
|
||||
* of `DimensionDefinition`s and only the resolved, already-known physical
|
||||
* name reaches the SQL — so "dimension identifier injection" has no
|
||||
* surface, because caller text never becomes an identifier.
|
||||
* * there is no raw-SQL entry point. `compileMetricQuery` cannot express a
|
||||
* UNION, a subquery, or a second table, so a caller cannot reach a table
|
||||
* the compiler did not choose.
|
||||
*
|
||||
* WHY A SEPARATE MODULE from clickhouse-analytics.ts: that file's compiler
|
||||
* already forces the tenant predicate correctly and BI-P009 says PRESERVE IT.
|
||||
* This module is the generalised compiler for the BI-P010 tables; it repeats
|
||||
* the guarantee rather than replacing it, and
|
||||
* tests/analytics/tenant-predicate-regression.test.ts asserts BOTH.
|
||||
*/
|
||||
import { SiaxError } from "../../platform/errors";
|
||||
import type { AnalyticsTable } from "./schema";
|
||||
|
||||
/** A physical identifier that has been VALIDATED, not merely received. */
|
||||
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
||||
|
||||
export function assertSafeIdentifier(value: string, what: string): string {
|
||||
if (!IDENT_RE.test(value)) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `invalid ${what} identifier`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const METRIC_AGGREGATES = ["sum", "avg", "min", "max", "count", "count_distinct"] as const;
|
||||
export type MetricAggregate = (typeof METRIC_AGGREGATES)[number];
|
||||
|
||||
/**
|
||||
* The dimensions a caller is allowed to name, resolved from
|
||||
* `DimensionDefinition` rows. The compiler looks a caller's dimension name up
|
||||
* HERE; a miss is a rejection, never a pass-through.
|
||||
*/
|
||||
export interface DimensionCatalog {
|
||||
/** caller-facing dimension name -> physical dimension key in dim_names. */
|
||||
resolve(name: string): string | undefined;
|
||||
}
|
||||
|
||||
export function dimensionCatalogFrom(pairs: Record<string, string>): DimensionCatalog {
|
||||
const map = new Map(Object.entries(pairs));
|
||||
return {
|
||||
resolve(name: string): string | undefined {
|
||||
// Own-property lookup on a Map — no prototype-chain surprises
|
||||
// ("constructor", "__proto__" resolve to undefined, as they must).
|
||||
return map.get(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MetricQueryRequest {
|
||||
/** Supplied by the engine from the verified identity, NOT by the caller. */
|
||||
tenant_id: string;
|
||||
project_id?: string;
|
||||
dataset_id?: string;
|
||||
metric: string;
|
||||
aggregate: MetricAggregate;
|
||||
/** Caller-facing dimension names; resolved through the catalog. */
|
||||
group_by?: string[];
|
||||
/** Equality filters on dimensions: caller-facing name -> value. */
|
||||
filters?: Record<string, string>;
|
||||
from_time?: string;
|
||||
to_time?: string;
|
||||
limit?: number;
|
||||
/** Which BI-P010 table to read. Chosen by the engine, not the caller. */
|
||||
table?: AnalyticsTable;
|
||||
}
|
||||
|
||||
export interface CompiledQuery {
|
||||
sql: string;
|
||||
/** ClickHouse HTTP `param_*` entries. Values NEVER appear in `sql`. */
|
||||
params: Record<string, string>;
|
||||
/** The predicate the compiler forced, for assertion in tests. */
|
||||
forcedTenantPredicate: string;
|
||||
table: AnalyticsTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact predicate text every compiled query must contain. Exported so a
|
||||
* regression test can assert on the literal string — if someone deletes the
|
||||
* forcing, the test fails on a missing substring rather than on a subtle
|
||||
* behavioural difference that a mocked engine would happily hide.
|
||||
*/
|
||||
export const FORCED_TENANT_PREDICATE = "tenant_id = {tenant:String}";
|
||||
|
||||
const AGG_SQL: Record<MetricAggregate, string> = {
|
||||
sum: "sum(value)",
|
||||
avg: "avg(value)",
|
||||
min: "min(value)",
|
||||
max: "max(value)",
|
||||
count: "count()",
|
||||
count_distinct: "uniqExact(event_id)",
|
||||
};
|
||||
|
||||
const MAX_GROUP_BY = 8;
|
||||
|
||||
/**
|
||||
* Compile a closed-DSL metric request into tenant-forced SQL.
|
||||
*
|
||||
* @param req the request; `tenant_id` MUST already be the verified identity.
|
||||
* @param db the analytics database name (engine-controlled, validated).
|
||||
* @param catalog the tenant's declared dimensions.
|
||||
* @param maxResultRows the governance ceiling (BI-P012); `limit` is clamped.
|
||||
*/
|
||||
export function compileMetricQuery(
|
||||
req: MetricQueryRequest,
|
||||
db: string,
|
||||
catalog: DimensionCatalog,
|
||||
maxResultRows: number,
|
||||
): CompiledQuery {
|
||||
if (!req.tenant_id || !req.tenant_id.trim()) {
|
||||
// Fail closed. A blank tenant must never compile to "no predicate".
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "tenant_id is required on every analytics query");
|
||||
}
|
||||
assertSafeIdentifier(db, "database");
|
||||
const table: AnalyticsTable = req.table ?? "opt_analytics_event";
|
||||
assertSafeIdentifier(table, "table");
|
||||
if (!METRIC_AGGREGATES.includes(req.aggregate)) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "unsupported aggregate");
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {
|
||||
param_tenant: req.tenant_id,
|
||||
param_metric: req.metric,
|
||||
};
|
||||
|
||||
// ── the forced predicate. First condition, always, no branch. ─────────
|
||||
const conditions: string[] = [FORCED_TENANT_PREDICATE, "metric = {metric:String}"];
|
||||
|
||||
if (req.project_id) {
|
||||
conditions.push("project_id = {project:String}");
|
||||
params.param_project = req.project_id;
|
||||
}
|
||||
if (req.dataset_id) {
|
||||
conditions.push("dataset_id = {dataset:String}");
|
||||
params.param_dataset = req.dataset_id;
|
||||
}
|
||||
if (req.from_time) {
|
||||
conditions.push("event_time >= parseDateTime64BestEffort({fromTime:String})");
|
||||
params.param_fromTime = req.from_time;
|
||||
}
|
||||
if (req.to_time) {
|
||||
conditions.push("event_time < parseDateTime64BestEffort({toTime:String})");
|
||||
params.param_toTime = req.to_time;
|
||||
}
|
||||
|
||||
// ── filters: names resolved through the catalog, values parameterized ──
|
||||
let filterIdx = 0;
|
||||
for (const [name, value] of Object.entries(req.filters ?? {})) {
|
||||
const physical = catalog.resolve(name);
|
||||
if (physical === undefined) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `unknown filter dimension "${name}"`);
|
||||
}
|
||||
// `physical` came from the catalog, but validate anyway: a corrupted or
|
||||
// hostile catalog row must not become an injection vector either.
|
||||
assertSafeIdentifier(physical, "dimension");
|
||||
const nameKey = `f${filterIdx}n`;
|
||||
const valKey = `f${filterIdx}v`;
|
||||
conditions.push(
|
||||
`dim_values[indexOf(dim_names, {${nameKey}:String})] = {${valKey}:String}`,
|
||||
);
|
||||
params[`param_${nameKey}`] = physical;
|
||||
params[`param_${valKey}`] = value;
|
||||
filterIdx += 1;
|
||||
}
|
||||
|
||||
// ── group by: same resolution, plus a hard cardinality ceiling ─────────
|
||||
const groupBy = req.group_by ?? [];
|
||||
if (groupBy.length > MAX_GROUP_BY) {
|
||||
throw new SiaxError(
|
||||
"INVALID_REQUEST",
|
||||
"0pt.analytics",
|
||||
`at most ${MAX_GROUP_BY} group-by dimensions (got ${groupBy.length})`,
|
||||
);
|
||||
}
|
||||
const selectParts: string[] = [];
|
||||
const groupParts: string[] = [];
|
||||
groupBy.forEach((name, i) => {
|
||||
const physical = catalog.resolve(name);
|
||||
if (physical === undefined) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", `unknown group_by dimension "${name}"`);
|
||||
}
|
||||
assertSafeIdentifier(physical, "dimension");
|
||||
const key = `g${i}`;
|
||||
// The ALIAS is compiler-generated (`grp0`, `grp1`), never caller text.
|
||||
selectParts.push(`dim_values[indexOf(dim_names, {${key}:String})] AS grp${i}`);
|
||||
groupParts.push(`grp${i}`);
|
||||
params[`param_${key}`] = physical;
|
||||
});
|
||||
|
||||
const limit = Math.max(1, Math.min(Math.floor(req.limit ?? 1000), maxResultRows));
|
||||
|
||||
const sql =
|
||||
`SELECT ${selectParts.length ? selectParts.join(", ") + ", " : ""}${AGG_SQL[req.aggregate]} AS value ` +
|
||||
`FROM ${db}.${table} ` +
|
||||
`WHERE ${conditions.join(" AND ")} ` +
|
||||
(groupParts.length ? `GROUP BY ${groupParts.join(", ")} ORDER BY value DESC ` : "") +
|
||||
`LIMIT ${limit} FORMAT JSON`;
|
||||
|
||||
return { sql, params, forcedTenantPredicate: FORCED_TENANT_PREDICATE, table };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a projection lookup (SERVING tier, BI-P010's opt_dataset_projection).
|
||||
* Same forcing, different table and key.
|
||||
*/
|
||||
export function compileProjectionQuery(
|
||||
req: { tenant_id: string; project_id?: string; dataset_id: string; entity_keys?: string[]; limit?: number },
|
||||
db: string,
|
||||
maxResultRows: number,
|
||||
): CompiledQuery {
|
||||
if (!req.tenant_id || !req.tenant_id.trim()) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "tenant_id is required on every analytics query");
|
||||
}
|
||||
assertSafeIdentifier(db, "database");
|
||||
const params: Record<string, string> = {
|
||||
param_tenant: req.tenant_id,
|
||||
param_dataset: req.dataset_id,
|
||||
};
|
||||
const conditions = [FORCED_TENANT_PREDICATE, "dataset_id = {dataset:String}"];
|
||||
if (req.project_id) {
|
||||
conditions.push("project_id = {project:String}");
|
||||
params.param_project = req.project_id;
|
||||
}
|
||||
if (req.entity_keys && req.entity_keys.length > 0) {
|
||||
conditions.push("has({entityKeys:Array(String)}, entity_key)");
|
||||
params.param_entityKeys = JSON.stringify(req.entity_keys);
|
||||
}
|
||||
const limit = Math.max(1, Math.min(Math.floor(req.limit ?? 1000), maxResultRows));
|
||||
const sql =
|
||||
`SELECT entity_key, as_of, version, attributes, measures ` +
|
||||
`FROM ${db}.opt_dataset_projection ` +
|
||||
`WHERE ${conditions.join(" AND ")} ` +
|
||||
`ORDER BY entity_key LIMIT ${limit} FORMAT JSON`;
|
||||
return { sql, params, forcedTenantPredicate: FORCED_TENANT_PREDICATE, table: "opt_dataset_projection" };
|
||||
}
|
||||
|
||||
/**
|
||||
* BI-P011 layer-3 bootstrap: the DDL for a RESTRICTED per-tenant ClickHouse
|
||||
* user with a row policy pinning `tenant_id`.
|
||||
*
|
||||
* This is the layer that survives a compiler bug. The compiler forcing the
|
||||
* predicate is layer 2; if a future refactor drops it, layer 3 still returns
|
||||
* zero foreign rows because the SERVER filters them. The BI-P011 proof runs
|
||||
* the adversarial suite twice — once through an unrestricted user (hole open)
|
||||
* and once through this restricted user (hole closed) — because a claim of
|
||||
* defence in depth that has never been tested with one layer removed is not
|
||||
* evidence.
|
||||
*
|
||||
* `tenantId` is validated as an identifier-safe token AND embedded as a
|
||||
* quoted literal; ClickHouse's CREATE ROW POLICY takes no bound parameters,
|
||||
* so validation is the only protection available and it is applied strictly.
|
||||
*/
|
||||
export function restrictedTenantUserDdl(args: {
|
||||
db: string;
|
||||
tenantId: string;
|
||||
userName: string;
|
||||
password: string;
|
||||
tables: readonly string[];
|
||||
settingsProfile?: string;
|
||||
}): string[] {
|
||||
assertSafeIdentifier(args.db, "database");
|
||||
assertSafeIdentifier(args.userName, "user");
|
||||
// A tenant id is `t_<slug>` in this estate; enforce that shape strictly
|
||||
// rather than trusting the caller.
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(args.tenantId)) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "tenant id is not policy-safe");
|
||||
}
|
||||
if (args.password.includes("'") || args.password.includes("\\")) {
|
||||
throw new SiaxError("INVALID_REQUEST", "0pt.analytics", "password contains characters that cannot be embedded");
|
||||
}
|
||||
const profile = args.settingsProfile;
|
||||
if (profile) assertSafeIdentifier(profile, "settings profile");
|
||||
|
||||
const stmts: string[] = [
|
||||
`CREATE USER IF NOT EXISTS ${args.userName} IDENTIFIED WITH sha256_password BY '${args.password}'` +
|
||||
(profile ? ` SETTINGS PROFILE ${profile}` : ` SETTINGS readonly = 1`),
|
||||
];
|
||||
for (const table of args.tables) {
|
||||
assertSafeIdentifier(table, "table");
|
||||
stmts.push(
|
||||
`CREATE ROW POLICY IF NOT EXISTS ${args.userName}_${table}_rp ON ${args.db}.${table} ` +
|
||||
`FOR SELECT USING tenant_id = '${args.tenantId}' TO ${args.userName}`,
|
||||
);
|
||||
stmts.push(`GRANT SELECT ON ${args.db}.${table} TO ${args.userName}`);
|
||||
}
|
||||
return stmts;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* src/analytics/engine/resource-governance.ts
|
||||
* ============================================
|
||||
* BI-P012 — CLICKHOUSE RESOURCE GOVERNANCE.
|
||||
*
|
||||
* Every setting BI-P012 names, in one place, with a value:
|
||||
*
|
||||
* max_execution_time max_result_rows max_rows_to_read
|
||||
* max_bytes_to_read max_memory_usage max_concurrent_queries
|
||||
*
|
||||
* Three layers, and the order matters:
|
||||
*
|
||||
* 1. SERVER-SIDE SETTINGS travel with every statement on the HTTP
|
||||
* interface. ClickHouse enforces them; the client cannot opt out
|
||||
* because the client is what writes them. `readonly=1` on top means a
|
||||
* SELECT cannot re-set them mid-query (readonly=1 forbids SETTINGS
|
||||
* changes as well as writes — that is the whole reason it is here and
|
||||
* not merely `readonly=2`).
|
||||
* 2. `max_concurrent_queries` is NOT a per-query setting in ClickHouse; it
|
||||
* is a per-USER quota. `concurrencySlotSql` renders the CREATE
|
||||
* SETTINGS PROFILE that carries it, applied to the tenant's restricted
|
||||
* user (BI-P011 layer 3) so one tenant's fan-out cannot consume the
|
||||
* whole server.
|
||||
* 3. A local admission gate (`ConcurrencyGate`) refuses the N+1st
|
||||
* in-flight query per tenant before it ever reaches the network. Two
|
||||
* layers because the ClickHouse quota is per-user and one 0pt process
|
||||
* can still queue work faster than the server rejects it.
|
||||
*
|
||||
* Budget tiers exist because "one limit for everyone" is how a small tenant
|
||||
* subsidises a query bomb. `INTERACTIVE` is the dashboard default and is
|
||||
* deliberately mean.
|
||||
*/
|
||||
import { SiaxError } from "../../platform/errors";
|
||||
|
||||
export interface ResourceBudget {
|
||||
/** Seconds. ClickHouse `max_execution_time`. */
|
||||
maxExecutionTimeSec: number;
|
||||
/** Rows returned to the caller. ClickHouse `max_result_rows`. */
|
||||
maxResultRows: number;
|
||||
/** Rows SCANNED (not returned). ClickHouse `max_rows_to_read`. */
|
||||
maxRowsToRead: number;
|
||||
/** Bytes scanned. ClickHouse `max_bytes_to_read`. */
|
||||
maxBytesToRead: number;
|
||||
/** Bytes of RAM per query. ClickHouse `max_memory_usage`. */
|
||||
maxMemoryUsageBytes: number;
|
||||
/** In-flight queries per tenant. Enforced by quota + local gate. */
|
||||
maxConcurrentQueries: number;
|
||||
}
|
||||
|
||||
export const BUDGET_TIERS = ["INTERACTIVE", "ANALYTICAL", "BATCH"] as const;
|
||||
export type BudgetTier = (typeof BUDGET_TIERS)[number];
|
||||
|
||||
/**
|
||||
* Defaults. Chosen against the shape of the analytics tables in
|
||||
* `src/analytics/engine/schema.ts`, not plucked from the air:
|
||||
* INTERACTIVE must answer a dashboard panel inside a human's patience budget,
|
||||
* so it caps the SCAN, which is what actually costs time on a MergeTree.
|
||||
*/
|
||||
export const DEFAULT_BUDGETS: Record<BudgetTier, ResourceBudget> = {
|
||||
INTERACTIVE: {
|
||||
maxExecutionTimeSec: 10,
|
||||
maxResultRows: 10_000,
|
||||
maxRowsToRead: 50_000_000,
|
||||
maxBytesToRead: 2 * 1024 * 1024 * 1024,
|
||||
maxMemoryUsageBytes: 2 * 1024 * 1024 * 1024,
|
||||
maxConcurrentQueries: 8,
|
||||
},
|
||||
ANALYTICAL: {
|
||||
maxExecutionTimeSec: 60,
|
||||
maxResultRows: 500_000,
|
||||
maxRowsToRead: 2_000_000_000,
|
||||
maxBytesToRead: 32 * 1024 * 1024 * 1024,
|
||||
maxMemoryUsageBytes: 8 * 1024 * 1024 * 1024,
|
||||
maxConcurrentQueries: 4,
|
||||
},
|
||||
BATCH: {
|
||||
maxExecutionTimeSec: 900,
|
||||
maxResultRows: 10_000_000,
|
||||
maxRowsToRead: 50_000_000_000,
|
||||
maxBytesToRead: 512 * 1024 * 1024 * 1024,
|
||||
maxMemoryUsageBytes: 24 * 1024 * 1024 * 1024,
|
||||
maxConcurrentQueries: 2,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A CL0UD-issued quota may only ever TIGHTEN a tier, never loosen it. A
|
||||
* control-plane bug that hands out a generous number must not be able to
|
||||
* disarm the engine's own ceiling.
|
||||
*/
|
||||
export function applyQuota(base: ResourceBudget, quota: Partial<ResourceBudget> | undefined): ResourceBudget {
|
||||
if (!quota) return { ...base };
|
||||
const pick = (b: number, q: number | undefined): number =>
|
||||
typeof q === "number" && Number.isFinite(q) && q > 0 ? Math.min(b, q) : b;
|
||||
return {
|
||||
maxExecutionTimeSec: pick(base.maxExecutionTimeSec, quota.maxExecutionTimeSec),
|
||||
maxResultRows: pick(base.maxResultRows, quota.maxResultRows),
|
||||
maxRowsToRead: pick(base.maxRowsToRead, quota.maxRowsToRead),
|
||||
maxBytesToRead: pick(base.maxBytesToRead, quota.maxBytesToRead),
|
||||
maxMemoryUsageBytes: pick(base.maxMemoryUsageBytes, quota.maxMemoryUsageBytes),
|
||||
maxConcurrentQueries: pick(base.maxConcurrentQueries, quota.maxConcurrentQueries),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings that ride along on the HTTP request. `readonly: "1"` is what
|
||||
* makes them un-overridable from inside the query text.
|
||||
*
|
||||
* `timeout_overflow_mode` / `result_overflow_mode` / `read_overflow_mode` are
|
||||
* set to `throw` EXPLICITLY: ClickHouse's default for some of these is
|
||||
* `break`, which silently returns a PARTIAL result. A partial result that
|
||||
* looks complete is the analytics equivalent of a false green, so the engine
|
||||
* refuses it.
|
||||
*/
|
||||
export function clickhouseSettings(budget: ResourceBudget): Record<string, string> {
|
||||
return {
|
||||
readonly: "1",
|
||||
max_execution_time: String(budget.maxExecutionTimeSec),
|
||||
timeout_overflow_mode: "throw",
|
||||
max_result_rows: String(budget.maxResultRows),
|
||||
result_overflow_mode: "throw",
|
||||
max_rows_to_read: String(budget.maxRowsToRead),
|
||||
max_bytes_to_read: String(budget.maxBytesToRead),
|
||||
read_overflow_mode: "throw",
|
||||
max_memory_usage: String(budget.maxMemoryUsageBytes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings for a PLANNER call (`EXPLAIN ESTIMATE`, `EXPLAIN PLAN`).
|
||||
*
|
||||
* Measured, not assumed: running `EXPLAIN ESTIMATE` under the caller's own
|
||||
* `max_rows_to_read` makes ClickHouse refuse the ESTIMATE itself with code 158
|
||||
* whenever the estimate would be over budget — i.e. exactly the case the
|
||||
* estimate exists to detect. An estimator that cannot describe an expensive
|
||||
* query is useless, so the read ceilings are deliberately NOT applied here.
|
||||
* The planner still runs `readonly = 1` under a short deadline and a memory
|
||||
* cap, so this is a narrower envelope, never an unbounded one.
|
||||
*/
|
||||
export function planningSettings(budget: ResourceBudget): Record<string, string> {
|
||||
return {
|
||||
readonly: "1",
|
||||
max_execution_time: String(Math.min(budget.maxExecutionTimeSec, 15)),
|
||||
timeout_overflow_mode: "throw",
|
||||
max_memory_usage: String(budget.maxMemoryUsageBytes),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every setting name BI-P012 requires, for the DoD assertion. */
|
||||
export const REQUIRED_GOVERNANCE_SETTINGS = [
|
||||
"max_execution_time",
|
||||
"max_result_rows",
|
||||
"max_rows_to_read",
|
||||
"max_bytes_to_read",
|
||||
"max_memory_usage",
|
||||
"max_concurrent_queries",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `max_concurrent_queries` lives on a SETTINGS PROFILE, not on a statement.
|
||||
* Rendered here so BI-P011's restricted-user bootstrap and BI-P012's budget
|
||||
* come from one source.
|
||||
*/
|
||||
export function concurrencyProfileSql(profileName: string, budget: ResourceBudget): string[] {
|
||||
return [
|
||||
`CREATE SETTINGS PROFILE IF NOT EXISTS ${profileName} SETTINGS ` +
|
||||
`max_execution_time = ${budget.maxExecutionTimeSec}, ` +
|
||||
`max_result_rows = ${budget.maxResultRows}, ` +
|
||||
`max_rows_to_read = ${budget.maxRowsToRead}, ` +
|
||||
`max_bytes_to_read = ${budget.maxBytesToRead}, ` +
|
||||
`max_memory_usage = ${budget.maxMemoryUsageBytes}, ` +
|
||||
`readonly = 1`,
|
||||
`CREATE QUOTA IF NOT EXISTS ${profileName}_quota ` +
|
||||
`FOR INTERVAL 1 SECOND MAX queries = ${budget.maxConcurrentQueries * 20} ` +
|
||||
`TO ALL EXCEPT default`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Local admission control. One instance per process; keyed by tenant so a
|
||||
* noisy tenant is throttled without touching anyone else's slots — which is
|
||||
* exactly BI-P012's DoD wording ("without degrading unrelated tenant").
|
||||
*/
|
||||
export class ConcurrencyGate {
|
||||
private readonly inFlight = new Map<string, number>();
|
||||
|
||||
constructor(private readonly limitFor: (tenantId: string) => number) {}
|
||||
|
||||
/** Returns a release function. Throws RATE_LIMITED when the tenant is full. */
|
||||
acquire(tenantId: string): () => void {
|
||||
const limit = this.limitFor(tenantId);
|
||||
const current = this.inFlight.get(tenantId) ?? 0;
|
||||
if (current >= limit) {
|
||||
throw new SiaxError(
|
||||
"RATE_LIMITED",
|
||||
"0pt.analytics",
|
||||
`tenant already has ${current} analytics queries in flight (limit ${limit})`,
|
||||
);
|
||||
}
|
||||
this.inFlight.set(tenantId, current + 1);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const n = (this.inFlight.get(tenantId) ?? 1) - 1;
|
||||
if (n <= 0) this.inFlight.delete(tenantId);
|
||||
else this.inFlight.set(tenantId, n);
|
||||
};
|
||||
}
|
||||
|
||||
/** Observability: in-flight count for a tenant (0 when idle). */
|
||||
inFlightFor(tenantId: string): number {
|
||||
return this.inFlight.get(tenantId) ?? 0;
|
||||
}
|
||||
|
||||
/** Total in-flight across all tenants. */
|
||||
totalInFlight(): number {
|
||||
let n = 0;
|
||||
for (const v of this.inFlight.values()) n += v;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* src/analytics/engine/schema.ts
|
||||
* ===============================
|
||||
* BI-P010 — CLICKHOUSE PRODUCTION SCHEMA.
|
||||
*
|
||||
* BI-P010's explicit warning is "Avoid one giant unbounded table for every
|
||||
* analytics use case." The existing `opt_metric_events` (owned by
|
||||
* src/analytics/clickhouse-analytics.ts, PRESERVED untouched) is exactly one
|
||||
* table for one job: low-cardinality capability metrics. It stays, and it is
|
||||
* NOT extended into the catch-all.
|
||||
*
|
||||
* Four tables, four jobs, four retention profiles:
|
||||
*
|
||||
* opt_metric_events (PRE-EXISTING, not defined here) — capability
|
||||
* metrics, Map(String,String) dimensions, 90d.
|
||||
* opt_analytics_event — high-volume per-tenant event stream. Wide
|
||||
* dimension arrays, SHORT TTL (raw events are not
|
||||
* the serving tier; they age into the rollups).
|
||||
* opt_metric_daily — AggregatingMergeTree rollup fed by a
|
||||
* MATERIALIZED VIEW off opt_analytics_event.
|
||||
* This is what a dashboard time-series reads;
|
||||
* long TTL, tiny footprint.
|
||||
* opt_dataset_projection — the SERVING tier (BI-P003) for a CURATED
|
||||
* dataset: one row per (tenant, dataset, entity),
|
||||
* ReplacingMergeTree so a refresh is idempotent.
|
||||
*
|
||||
* Why the split is load-bearing, not cosmetic:
|
||||
* * different ORDER BY keys. A dashboard filtering (tenant, metric, day)
|
||||
* and a projection looked up by (tenant, dataset, entity_key) cannot
|
||||
* share one primary key without one of them degenerating to a full scan.
|
||||
* * different TTLs. Raw events must expire fast; rollups must not.
|
||||
* * different cardinality. `LowCardinality(String)` is a win on
|
||||
* tenant/metric/dataset and a LOSS on a free-form entity key, so they
|
||||
* cannot be the same column type in the same table.
|
||||
*
|
||||
* EVERY table leads its ORDER BY with `tenant_id`. That is not stylistic: it
|
||||
* is what makes the forced tenant predicate (BI-P011) a primary-key range
|
||||
* scan instead of a filter applied after reading everyone's rows, and it is
|
||||
* what makes a per-tenant row policy cheap.
|
||||
*
|
||||
* LEGAL BOUNDARY (inherited verbatim from clickhouse-analytics.ts): this
|
||||
* schema is for generic 0pt analytics. It MUST NEVER be pointed at the b0ts
|
||||
* ClickHouse pipelines or quarantined market data.
|
||||
*/
|
||||
|
||||
export interface AnalyticsSchemaOptions {
|
||||
database: string;
|
||||
/** Raw event retention. Deliberately short — rollups carry the history. */
|
||||
eventRetentionDays?: number;
|
||||
/** Rollup retention. */
|
||||
rollupRetentionDays?: number;
|
||||
/** Serving-projection retention. */
|
||||
projectionRetentionDays?: number;
|
||||
}
|
||||
|
||||
/** Physical tables this module owns (opt_metric_events is NOT one of them). */
|
||||
export const ANALYTICS_TABLES = [
|
||||
"opt_analytics_event",
|
||||
"opt_metric_daily",
|
||||
"opt_dataset_projection",
|
||||
] as const;
|
||||
export type AnalyticsTable = (typeof ANALYTICS_TABLES)[number];
|
||||
|
||||
/** The materialized view wiring opt_analytics_event -> opt_metric_daily. */
|
||||
export const ANALYTICS_MATERIALIZED_VIEWS = ["opt_metric_daily_mv"] as const;
|
||||
|
||||
/**
|
||||
* DDL, in dependency order. Every statement is CREATE ... IF NOT EXISTS so
|
||||
* applying it to a live database is idempotent (the same contract
|
||||
* `ClickHouseAnalytics.ensureSchema` already keeps).
|
||||
*/
|
||||
export function analyticsSchemaDdl(opts: AnalyticsSchemaOptions): string[] {
|
||||
const db = opts.database;
|
||||
const eventTtl = Math.floor(opts.eventRetentionDays ?? 30);
|
||||
const rollupTtl = Math.floor(opts.rollupRetentionDays ?? 1095);
|
||||
const projectionTtl = Math.floor(opts.projectionRetentionDays ?? 365);
|
||||
|
||||
return [
|
||||
`CREATE DATABASE IF NOT EXISTS ${db}`,
|
||||
|
||||
// ── raw event stream ────────────────────────────────────────────────
|
||||
// Dimensions as parallel arrays (not Map) so ClickHouse can prune on
|
||||
// dimension VALUES via the bloom filter index; Map(String,String) forces
|
||||
// reading the whole map. `event_id` makes deduplication possible on
|
||||
// replay without a ReplacingMergeTree's merge latency.
|
||||
`CREATE TABLE IF NOT EXISTS ${db}.opt_analytics_event (
|
||||
tenant_id LowCardinality(String),
|
||||
project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String),
|
||||
metric LowCardinality(String),
|
||||
event_id String,
|
||||
event_time DateTime64(3, 'UTC'),
|
||||
value Float64,
|
||||
dim_names Array(LowCardinality(String)),
|
||||
dim_values Array(String),
|
||||
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3),
|
||||
INDEX idx_dim_values dim_values TYPE bloom_filter(0.01) GRANULARITY 4
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(event_time)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, metric, event_time)
|
||||
TTL toDateTime(event_time) + INTERVAL ${eventTtl} DAY
|
||||
SETTINGS index_granularity = 8192`,
|
||||
|
||||
// ── daily rollup (serving tier for time series) ─────────────────────
|
||||
`CREATE TABLE IF NOT EXISTS ${db}.opt_metric_daily (
|
||||
tenant_id LowCardinality(String),
|
||||
project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String),
|
||||
metric LowCardinality(String),
|
||||
day Date,
|
||||
sum_state AggregateFunction(sum, Float64),
|
||||
count_state AggregateFunction(count, Float64),
|
||||
min_state AggregateFunction(min, Float64),
|
||||
max_state AggregateFunction(max, Float64)
|
||||
)
|
||||
ENGINE = AggregatingMergeTree()
|
||||
PARTITION BY toYYYYMM(day)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, metric, day)
|
||||
TTL day + INTERVAL ${rollupTtl} DAY`,
|
||||
|
||||
`CREATE MATERIALIZED VIEW IF NOT EXISTS ${db}.opt_metric_daily_mv
|
||||
TO ${db}.opt_metric_daily AS
|
||||
SELECT
|
||||
tenant_id,
|
||||
project_id,
|
||||
dataset_id,
|
||||
metric,
|
||||
toDate(event_time) AS day,
|
||||
sumState(value) AS sum_state,
|
||||
countState(value) AS count_state,
|
||||
minState(value) AS min_state,
|
||||
maxState(value) AS max_state
|
||||
FROM ${db}.opt_analytics_event
|
||||
GROUP BY tenant_id, project_id, dataset_id, metric, day`,
|
||||
|
||||
// ── serving projection for a CURATED dataset ────────────────────────
|
||||
// ReplacingMergeTree(version) so refreshProjection is idempotent: the
|
||||
// same (tenant, dataset, entity_key) re-ingested at a higher version
|
||||
// supersedes rather than duplicates.
|
||||
`CREATE TABLE IF NOT EXISTS ${db}.opt_dataset_projection (
|
||||
tenant_id LowCardinality(String),
|
||||
project_id LowCardinality(String),
|
||||
dataset_id LowCardinality(String),
|
||||
entity_key String,
|
||||
as_of DateTime64(3, 'UTC'),
|
||||
version UInt64,
|
||||
attributes Map(String, String),
|
||||
measures Map(String, Float64),
|
||||
INDEX idx_entity entity_key TYPE bloom_filter(0.01) GRANULARITY 4
|
||||
)
|
||||
ENGINE = ReplacingMergeTree(version)
|
||||
PARTITION BY (tenant_id)
|
||||
ORDER BY (tenant_id, project_id, dataset_id, entity_key)
|
||||
TTL toDateTime(as_of) + INTERVAL ${projectionTtl} DAY`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Statement that proves a table's ORDER BY leads with tenant_id — used by the
|
||||
* BI-P011 test suite so "tenant-first primary key" is asserted against the
|
||||
* live server, not against this file's own comments.
|
||||
*/
|
||||
export function sortingKeySql(db: string, table: string): string {
|
||||
return (
|
||||
`SELECT sorting_key FROM system.tables ` +
|
||||
`WHERE database = '${db}' AND name = '${table}' FORMAT TSV`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* src/analytics/engine/siax-analytics-engine.ts
|
||||
* ==============================================
|
||||
* BI-P009 — the provider-neutral analytics engine interface.
|
||||
*
|
||||
* Eight capabilities, named exactly as BI-P009 lists them:
|
||||
*
|
||||
* health executeMetric executeQuery estimateQuery
|
||||
* explain cancelQuery ingestProjection refreshProjection
|
||||
*
|
||||
* ClickHouse is the CANONICAL provider (BI-P009); DuckDB (BI-P014) and Trino
|
||||
* (BI-P015) implement the same interface later. Nothing above this interface
|
||||
* may import a provider module directly — that is what makes "replaceable
|
||||
* provider" true rather than aspirational.
|
||||
*
|
||||
* NORMATIVE OWNERSHIP (BI program §0): every method takes an
|
||||
* `AnalyticsIdentity` that the CALLER cannot forge, because it is produced by
|
||||
* CL0UD verification upstream, not by request-body parsing. Product repos
|
||||
* (VehIQ) call an API in front of this interface; they never hold a provider
|
||||
* credential and never construct an engine.
|
||||
*/
|
||||
import type { CompiledQuery, MetricAggregate } from "./query-compiler";
|
||||
import type { AnalyticsTable } from "./schema";
|
||||
import type { BudgetTier, ResourceBudget } from "./resource-governance";
|
||||
|
||||
/**
|
||||
* The VERIFIED caller. `tenant_id` here is the one the engine forces into
|
||||
* every predicate — it comes from CL0UD identity verification (BI-P011 layer
|
||||
* 1), never from a request body. This estate has already been burned once by
|
||||
* a tenant read out of a request body (INF0 Wave 2, cross-tenant RAG access),
|
||||
* which is why the type is separate from the request rather than a field on
|
||||
* it.
|
||||
*/
|
||||
export interface AnalyticsIdentity {
|
||||
tenant_id: string;
|
||||
project_id?: string;
|
||||
/** CL0UD principal, for audit + per-principal quota. */
|
||||
principal_id: string;
|
||||
correlation_id?: string;
|
||||
/** Budget tier granted by CL0UD entitlements (BI-P012). */
|
||||
budget_tier?: BudgetTier;
|
||||
/** CL0UD quota, which may only TIGHTEN the tier. */
|
||||
quota?: Partial<ResourceBudget>;
|
||||
}
|
||||
|
||||
export interface MetricRequest {
|
||||
metric: string;
|
||||
aggregate: MetricAggregate;
|
||||
dataset_id?: string;
|
||||
group_by?: string[];
|
||||
filters?: Record<string, string>;
|
||||
from_time?: string;
|
||||
to_time?: string;
|
||||
limit?: number;
|
||||
table?: AnalyticsTable;
|
||||
}
|
||||
|
||||
export interface AnalyticsRow {
|
||||
/** Resolved group values, keyed by the CALLER-FACING dimension name. */
|
||||
groups?: Record<string, string>;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface EngineResult {
|
||||
rows: AnalyticsRow[];
|
||||
/** Engine-side id; the handle `cancelQuery` needs. */
|
||||
query_id: string;
|
||||
/** True when the governance ceiling cut the result short (BI-P012). */
|
||||
truncated: boolean;
|
||||
stats: QueryStats;
|
||||
}
|
||||
|
||||
export interface QueryStats {
|
||||
duration_ms: number;
|
||||
rows_read?: number;
|
||||
bytes_read?: number;
|
||||
result_rows: number;
|
||||
}
|
||||
|
||||
export interface QueryEstimate {
|
||||
/** Rows the engine expects to SCAN. */
|
||||
estimated_rows_to_read: number;
|
||||
estimated_bytes_to_read: number;
|
||||
/** False when the estimate exceeds the caller's budget (BI-P012). */
|
||||
within_budget: boolean;
|
||||
budget: ResourceBudget;
|
||||
/** Why it was refused, when `within_budget` is false. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface QueryExplanation {
|
||||
/** Provider's plan text. Never contains caller-supplied SQL fragments. */
|
||||
plan: string;
|
||||
/** The predicate the compiler forced — assertable, not narrated. */
|
||||
forced_tenant_predicate: string;
|
||||
table: string;
|
||||
}
|
||||
|
||||
export interface ProjectionRow {
|
||||
entity_key: string;
|
||||
as_of?: string;
|
||||
version?: number;
|
||||
attributes?: Record<string, string>;
|
||||
measures?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ProjectionIngestRequest {
|
||||
dataset_id: string;
|
||||
rows: ProjectionRow[];
|
||||
}
|
||||
|
||||
export interface ProjectionRefreshResult {
|
||||
dataset_id: string;
|
||||
/** Rows visible after the refresh settled. */
|
||||
row_count: number;
|
||||
refreshed_at: string;
|
||||
}
|
||||
|
||||
export interface HealthReport {
|
||||
healthy: boolean;
|
||||
provider: string;
|
||||
/** Provider version when reachable. */
|
||||
version?: string;
|
||||
/** Short operator-facing reason. Never a raw engine body. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw, ANALYST-facing query (BI-P017 will govern its SQL dialect). It is a
|
||||
* SEPARATE method from `executeMetric` on purpose: `executeMetric` has no
|
||||
* raw-SQL surface at all, and keeping them apart means the closed DSL cannot
|
||||
* silently grow one.
|
||||
*
|
||||
* The canonical ClickHouse provider in this pass DELIBERATELY refuses this
|
||||
* method (`CAPABILITY_UNAVAILABLE`): opening an analyst SQL surface before
|
||||
* BI-P017's dialect contract and BI-P033's authorization exist would be
|
||||
* exactly the "raw SQL attempt" BI-P011's adversarial list names. Declaring
|
||||
* it and refusing it is honest; implementing it early is not.
|
||||
*/
|
||||
export interface AnalystQueryRequest {
|
||||
sql: string;
|
||||
dataset_ids: string[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SiaxAnalyticsEngine {
|
||||
readonly provider: string;
|
||||
|
||||
health(): Promise<HealthReport>;
|
||||
|
||||
executeMetric(identity: AnalyticsIdentity, request: MetricRequest): Promise<EngineResult>;
|
||||
|
||||
executeQuery(identity: AnalyticsIdentity, request: AnalystQueryRequest): Promise<EngineResult>;
|
||||
|
||||
estimateQuery(identity: AnalyticsIdentity, request: MetricRequest): Promise<QueryEstimate>;
|
||||
|
||||
cancelQuery(identity: AnalyticsIdentity, queryId: string): Promise<boolean>;
|
||||
|
||||
explain(identity: AnalyticsIdentity, request: MetricRequest): Promise<QueryExplanation>;
|
||||
|
||||
ingestProjection(identity: AnalyticsIdentity, request: ProjectionIngestRequest): Promise<{ ingested: number }>;
|
||||
|
||||
refreshProjection(identity: AnalyticsIdentity, dataset_id: string): Promise<ProjectionRefreshResult>;
|
||||
}
|
||||
|
||||
/** The eight capability names, for the provider contract test. */
|
||||
export const SIAX_ANALYTICS_ENGINE_CAPABILITIES = [
|
||||
"health",
|
||||
"executeMetric",
|
||||
"executeQuery",
|
||||
"estimateQuery",
|
||||
"cancelQuery",
|
||||
"explain",
|
||||
"ingestProjection",
|
||||
"refreshProjection",
|
||||
] as const;
|
||||
export type SiaxAnalyticsEngineCapability = (typeof SIAX_ANALYTICS_ENGINE_CAPABILITIES)[number];
|
||||
|
||||
/** Re-exported so provider modules do not reach across into the compiler. */
|
||||
export type { CompiledQuery };
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* src/analytics/repositories/analytics-domain-repository.ts
|
||||
* ==========================================================
|
||||
* BI-P002 persistence for the analytics domain model (sql/008).
|
||||
*
|
||||
* Two rules the type system alone cannot keep, so they live here:
|
||||
*
|
||||
* 1. EVERY read and write is tenant-scoped. There is no method that takes a
|
||||
* dataset_id without also taking the tenant it must belong to, and every
|
||||
* WHERE carries `tenant_id = $tenant`. A caller cannot "forget" the
|
||||
* tenant because there is no signature that lets them.
|
||||
* 2. VERSION ROWS ARE APPEND-ONLY from this layer too. `createDatasetVersion`
|
||||
* /`createMetricVersion` compute the next version number inside the same
|
||||
* statement (`SELECT coalesce(max(version),0)+1`) so two concurrent
|
||||
* minters collide on the UNIQUE(dataset_id, version) constraint instead
|
||||
* of silently overwriting — and the database triggers from sql/008 catch
|
||||
* anything that still tries to UPDATE.
|
||||
*
|
||||
* Uses postgres.js through `RepositorySql`, the same connection type the other
|
||||
* P0 repositories take (src/repositories/db.ts).
|
||||
*/
|
||||
import crypto from "node:crypto";
|
||||
import { SiaxError } from "../../platform/errors";
|
||||
import type { RepositorySql } from "../../repositories/db";
|
||||
import type {
|
||||
AnalyticsDataset,
|
||||
DatasetVersion,
|
||||
DimensionDefinition,
|
||||
MetricDefinition,
|
||||
MetricVersion,
|
||||
PiiClassification,
|
||||
} from "../domain/entities";
|
||||
import type { AnalyticsTier } from "../domain/tiers";
|
||||
|
||||
/** The verified tenant scope every call must present. */
|
||||
export interface TenantScope {
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
function assertScope(scope: TenantScope): void {
|
||||
if (!scope?.tenant_id?.trim() || !scope?.project_id?.trim()) {
|
||||
throw new SiaxError(
|
||||
"UNAUTHENTICATED",
|
||||
"0pt.analytics",
|
||||
"analytics persistence requires a verified tenant_id + project_id",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function iso(v: string | Date | null | undefined): string | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
return v instanceof Date ? v.toISOString() : v;
|
||||
}
|
||||
|
||||
interface DatasetRow {
|
||||
dataset_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
tier: string;
|
||||
schema_contract_id: string;
|
||||
schema_contract_version: string;
|
||||
owner_id: string;
|
||||
source_ref: string;
|
||||
freshness_slo_seconds: number;
|
||||
quality_rule_ids: string[];
|
||||
retention_days: number;
|
||||
classification: string;
|
||||
status: string;
|
||||
raw_access_exception: string | null;
|
||||
description: string | null;
|
||||
created_at: string | Date;
|
||||
updated_at: string | Date | null;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
function toDataset(r: DatasetRow): AnalyticsDataset {
|
||||
return {
|
||||
dataset_id: r.dataset_id,
|
||||
tenant_id: r.tenant_id,
|
||||
project_id: r.project_id,
|
||||
name: r.name,
|
||||
tier: r.tier as AnalyticsTier,
|
||||
schema_contract_id: r.schema_contract_id,
|
||||
schema_contract_version: r.schema_contract_version,
|
||||
owner_id: r.owner_id,
|
||||
source_ref: r.source_ref,
|
||||
freshness_slo_seconds: Number(r.freshness_slo_seconds),
|
||||
quality_rule_ids: r.quality_rule_ids ?? [],
|
||||
retention_days: Number(r.retention_days),
|
||||
classification: r.classification as PiiClassification,
|
||||
status: r.status as AnalyticsDataset["status"],
|
||||
...(r.raw_access_exception ? { raw_access_exception: r.raw_access_exception } : {}),
|
||||
...(r.description ? { description: r.description } : {}),
|
||||
created_at: iso(r.created_at)!,
|
||||
...(r.updated_at ? { updated_at: iso(r.updated_at) } : {}),
|
||||
...(r.created_by ? { created_by: r.created_by } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class AnalyticsDomainRepository {
|
||||
constructor(private readonly sql: RepositorySql) {}
|
||||
|
||||
// ── AnalyticsDataset ─────────────────────────────────────────────────────
|
||||
|
||||
async createDataset(scope: TenantScope, input: Omit<AnalyticsDataset, "tenant_id" | "project_id" | "created_at" | "updated_at">): Promise<AnalyticsDataset> {
|
||||
assertScope(scope);
|
||||
const rows = await this.sql<DatasetRow[]>`
|
||||
INSERT INTO opt_analytics_dataset
|
||||
(dataset_id, tenant_id, project_id, name, tier, schema_contract_id, schema_contract_version,
|
||||
owner_id, source_ref, freshness_slo_seconds, quality_rule_ids, retention_days,
|
||||
classification, status, raw_access_exception, description, created_by)
|
||||
VALUES
|
||||
(${input.dataset_id}, ${scope.tenant_id}, ${scope.project_id}, ${input.name}, ${input.tier},
|
||||
${input.schema_contract_id}, ${input.schema_contract_version}, ${input.owner_id}, ${input.source_ref},
|
||||
${input.freshness_slo_seconds}, ${this.sql.array(input.quality_rule_ids ?? [])}, ${input.retention_days},
|
||||
${input.classification}, ${input.status}, ${input.raw_access_exception ?? null},
|
||||
${input.description ?? null}, ${input.created_by ?? null})
|
||||
RETURNING *`;
|
||||
return toDataset(rows[0]);
|
||||
}
|
||||
|
||||
/** Tenant-scoped read. A foreign dataset_id returns undefined, never a row. */
|
||||
async getDataset(scope: TenantScope, datasetId: string): Promise<AnalyticsDataset | undefined> {
|
||||
assertScope(scope);
|
||||
const rows = await this.sql<DatasetRow[]>`
|
||||
SELECT * FROM opt_analytics_dataset
|
||||
WHERE dataset_id = ${datasetId}
|
||||
AND tenant_id = ${scope.tenant_id}
|
||||
AND project_id = ${scope.project_id}
|
||||
LIMIT 1`;
|
||||
return rows[0] ? toDataset(rows[0]) : undefined;
|
||||
}
|
||||
|
||||
async listDatasets(scope: TenantScope, filter?: { tier?: AnalyticsTier; status?: string }): Promise<AnalyticsDataset[]> {
|
||||
assertScope(scope);
|
||||
const rows = await this.sql<DatasetRow[]>`
|
||||
SELECT * FROM opt_analytics_dataset
|
||||
WHERE tenant_id = ${scope.tenant_id}
|
||||
AND project_id = ${scope.project_id}
|
||||
${filter?.tier ? this.sql`AND tier = ${filter.tier}` : this.sql``}
|
||||
${filter?.status ? this.sql`AND status = ${filter.status}` : this.sql``}
|
||||
ORDER BY created_at DESC`;
|
||||
return rows.map(toDataset);
|
||||
}
|
||||
|
||||
// ── DatasetVersion (append-only) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mint the next version. The version number is computed inside the INSERT,
|
||||
* so two racing callers cannot both read "3" and both write it — the loser
|
||||
* hits UNIQUE(dataset_id, version) and gets a CONFLICT, which is the honest
|
||||
* outcome.
|
||||
*/
|
||||
async createDatasetVersion(
|
||||
scope: TenantScope,
|
||||
input: { dataset_id: string; contract_hash: string; schema_contract_version: string; record_count?: number; created_by?: string },
|
||||
): Promise<DatasetVersion> {
|
||||
assertScope(scope);
|
||||
const owner = await this.getDataset(scope, input.dataset_id);
|
||||
if (!owner) {
|
||||
throw new SiaxError("NOT_FOUND", "0pt.analytics", "dataset not found in this tenant");
|
||||
}
|
||||
const id = `dsv_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
try {
|
||||
const rows = await this.sql<
|
||||
{
|
||||
dataset_version_id: string;
|
||||
dataset_id: string;
|
||||
tenant_id: string;
|
||||
project_id: string;
|
||||
version: number;
|
||||
contract_hash: string;
|
||||
schema_contract_version: string;
|
||||
record_count: string | number | null;
|
||||
created_at: string | Date;
|
||||
created_by: string | null;
|
||||
}[]
|
||||
>`
|
||||
INSERT INTO opt_analytics_dataset_version
|
||||
(dataset_version_id, dataset_id, tenant_id, project_id, version,
|
||||
contract_hash, schema_contract_version, record_count, created_by)
|
||||
SELECT ${id}, ${input.dataset_id}, ${scope.tenant_id}, ${scope.project_id},
|
||||
coalesce(max(version), 0) + 1,
|
||||
${input.contract_hash}, ${input.schema_contract_version},
|
||||
${input.record_count ?? null}, ${input.created_by ?? null}
|
||||
FROM opt_analytics_dataset_version
|
||||
WHERE dataset_id = ${input.dataset_id} AND tenant_id = ${scope.tenant_id}
|
||||
RETURNING *`;
|
||||
const r = rows[0];
|
||||
return {
|
||||
dataset_version_id: r.dataset_version_id,
|
||||
dataset_id: r.dataset_id,
|
||||
tenant_id: r.tenant_id,
|
||||
project_id: r.project_id,
|
||||
version: Number(r.version),
|
||||
contract_hash: r.contract_hash,
|
||||
schema_contract_version: r.schema_contract_version,
|
||||
...(r.record_count !== null ? { record_count: Number(r.record_count) } : {}),
|
||||
created_at: iso(r.created_at)!,
|
||||
...(r.created_by ? { created_by: r.created_by } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
if (String(err).includes("duplicate key")) {
|
||||
throw new SiaxError("CONFLICT", "0pt.analytics", "a concurrent writer minted this dataset version");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async listDatasetVersions(scope: TenantScope, datasetId: string): Promise<DatasetVersion[]> {
|
||||
assertScope(scope);
|
||||
const rows = await this.sql<
|
||||
{ dataset_version_id: string; dataset_id: string; tenant_id: string; project_id: string; version: number; contract_hash: string; schema_contract_version: string; record_count: string | number | null; created_at: string | Date; created_by: string | null }[]
|
||||
>`
|
||||
SELECT * FROM opt_analytics_dataset_version
|
||||
WHERE dataset_id = ${datasetId}
|
||||
AND tenant_id = ${scope.tenant_id}
|
||||
AND project_id = ${scope.project_id}
|
||||
ORDER BY version ASC`;
|
||||
return rows.map((r) => ({
|
||||
dataset_version_id: r.dataset_version_id,
|
||||
dataset_id: r.dataset_id,
|
||||
tenant_id: r.tenant_id,
|
||||
project_id: r.project_id,
|
||||
version: Number(r.version),
|
||||
contract_hash: r.contract_hash,
|
||||
schema_contract_version: r.schema_contract_version,
|
||||
...(r.record_count !== null ? { record_count: Number(r.record_count) } : {}),
|
||||
created_at: iso(r.created_at)!,
|
||||
...(r.created_by ? { created_by: r.created_by } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// ── MetricDefinition / MetricVersion ─────────────────────────────────────
|
||||
|
||||
async createMetric(
|
||||
scope: TenantScope,
|
||||
input: Omit<MetricDefinition, "tenant_id" | "project_id" | "created_at" | "updated_at" | "current_version">,
|
||||
): Promise<MetricDefinition> {
|
||||
assertScope(scope);
|
||||
const rows = await this.sql<
|
||||
{ metric_id: string; tenant_id: string; project_id: string; name: string; dataset_id: string; aggregation: string; measure_field: string; denominator_field: string | null; allowed_dimension_ids: string[]; current_version: number; owner_id: string; description: string | null; created_at: string | Date; updated_at: string | Date | null }[]
|
||||
>`
|
||||
INSERT INTO opt_analytics_metric_definition
|
||||
(metric_id, tenant_id, project_id, name, dataset_id, aggregation, measure_field,
|
||||
denominator_field, allowed_dimension_ids, current_version, owner_id, description)
|
||||
VALUES
|
||||
(${input.metric_id}, ${scope.tenant_id}, ${scope.project_id}, ${input.name}, ${input.dataset_id},
|
||||
${input.aggregation}, ${input.measure_field}, ${input.denominator_field ?? null},
|
||||
${this.sql.array(input.allowed_dimension_ids ?? [])}, 1, ${input.owner_id}, ${input.description ?? null})
|
||||
RETURNING *`;
|
||||
const r = rows[0];
|
||||
return {
|
||||
metric_id: r.metric_id,
|
||||
tenant_id: r.tenant_id,
|
||||
project_id: r.project_id,
|
||||
name: r.name,
|
||||
dataset_id: r.dataset_id,
|
||||
aggregation: r.aggregation as MetricDefinition["aggregation"],
|
||||
measure_field: r.measure_field,
|
||||
...(r.denominator_field ? { denominator_field: r.denominator_field } : {}),
|
||||
allowed_dimension_ids: r.allowed_dimension_ids ?? [],
|
||||
current_version: Number(r.current_version),
|
||||
owner_id: r.owner_id,
|
||||
...(r.description ? { description: r.description } : {}),
|
||||
created_at: iso(r.created_at)!,
|
||||
...(r.updated_at ? { updated_at: iso(r.updated_at) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Append-only. `change_reason` is required for version > 1 (DB CHECK). */
|
||||
async createMetricVersion(
|
||||
scope: TenantScope,
|
||||
input: { metric_id: string; definition_json: string; change_reason?: string; created_by?: string },
|
||||
): Promise<MetricVersion> {
|
||||
assertScope(scope);
|
||||
const hash = crypto.createHash("sha256").update(input.definition_json).digest("hex");
|
||||
const id = `mv_${crypto.randomUUID().replace(/-/g, "")}`;
|
||||
const rows = await this.sql<
|
||||
{ metric_version_id: string; metric_id: string; tenant_id: string; project_id: string; version: number; definition_json: string; definition_hash: string; change_reason: string | null; created_at: string | Date; created_by: string | null }[]
|
||||
>`
|
||||
INSERT INTO opt_analytics_metric_version
|
||||
(metric_version_id, metric_id, tenant_id, project_id, version, definition_json,
|
||||
definition_hash, change_reason, created_by)
|
||||
SELECT ${id}, ${input.metric_id}, ${scope.tenant_id}, ${scope.project_id},
|
||||
coalesce(max(version), 0) + 1, ${input.definition_json}, ${hash},
|
||||
${input.change_reason ?? null}, ${input.created_by ?? null}
|
||||
FROM opt_analytics_metric_version
|
||||
WHERE metric_id = ${input.metric_id} AND tenant_id = ${scope.tenant_id}
|
||||
RETURNING *`;
|
||||
const r = rows[0];
|
||||
return {
|
||||
metric_version_id: r.metric_version_id,
|
||||
metric_id: r.metric_id,
|
||||
tenant_id: r.tenant_id,
|
||||
project_id: r.project_id,
|
||||
version: Number(r.version),
|
||||
definition_json: r.definition_json,
|
||||
definition_hash: r.definition_hash,
|
||||
...(r.change_reason ? { change_reason: r.change_reason } : {}),
|
||||
created_at: iso(r.created_at)!,
|
||||
...(r.created_by ? { created_by: r.created_by } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── DimensionDefinition (the query compiler's allow-list) ────────────────
|
||||
|
||||
async createDimension(
|
||||
scope: TenantScope,
|
||||
input: Omit<DimensionDefinition, "tenant_id" | "project_id" | "created_at">,
|
||||
): Promise<DimensionDefinition> {
|
||||
assertScope(scope);
|
||||
const rows = await this.sql<
|
||||
{ dimension_id: string; tenant_id: string; project_id: string; name: string; dataset_id: string; field_name: string; field_type: string; classification: string; description: string | null; created_at: string | Date }[]
|
||||
>`
|
||||
INSERT INTO opt_analytics_dimension_definition
|
||||
(dimension_id, tenant_id, project_id, name, dataset_id, field_name, field_type, classification, description)
|
||||
VALUES
|
||||
(${input.dimension_id}, ${scope.tenant_id}, ${scope.project_id}, ${input.name}, ${input.dataset_id},
|
||||
${input.field_name}, ${input.field_type}, ${input.classification}, ${input.description ?? null})
|
||||
RETURNING *`;
|
||||
const r = rows[0];
|
||||
return {
|
||||
dimension_id: r.dimension_id,
|
||||
tenant_id: r.tenant_id,
|
||||
project_id: r.project_id,
|
||||
name: r.name,
|
||||
dataset_id: r.dataset_id,
|
||||
field_name: r.field_name,
|
||||
field_type: r.field_type as DimensionDefinition["field_type"],
|
||||
classification: r.classification as PiiClassification,
|
||||
...(r.description ? { description: r.description } : {}),
|
||||
created_at: iso(r.created_at)!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The dimension allow-list the compiler resolves through, for one tenant.
|
||||
* Returns caller-facing name -> physical field name. A tenant with no
|
||||
* declared dimensions gets an EMPTY map, which means every dimension
|
||||
* reference is refused — fail closed, not "allow everything".
|
||||
*/
|
||||
async dimensionCatalog(scope: TenantScope, datasetId?: string): Promise<Record<string, string>> {
|
||||
assertScope(scope);
|
||||
const rows = await this.sql<{ name: string; field_name: string }[]>`
|
||||
SELECT name, field_name FROM opt_analytics_dimension_definition
|
||||
WHERE tenant_id = ${scope.tenant_id}
|
||||
AND project_id = ${scope.project_id}
|
||||
${datasetId ? this.sql`AND dataset_id = ${datasetId}` : this.sql``}`;
|
||||
const out: Record<string, string> = Object.create(null);
|
||||
for (const r of rows) out[r.name] = r.field_name;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* BI-P002 (domain model) + BI-P003 (data tiers), the parts provable without
|
||||
* infrastructure. The Postgres half — real tables, real immutability
|
||||
* triggers, real tenant scoping — is
|
||||
* tests/analytics/analytics-domain-postgres.realinfra.test.ts.
|
||||
*
|
||||
* The registry-vs-migration test below is the one that earns its place: it
|
||||
* reads sql/008_analytics_domain_model.sql from disk and asserts a CREATE
|
||||
* TABLE exists for every one of the nineteen canonical entities. A future
|
||||
* rename, or an entity quietly dropped from the migration, fails HERE rather
|
||||
* than at 3am against a database that turns out to be missing a table.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANALYTICS_DOMAIN_ENTITIES,
|
||||
ANALYTICS_ENTITY_TABLES,
|
||||
IMMUTABLE_VERSION_ENTITIES,
|
||||
} from "@/src/analytics/domain/entities";
|
||||
import {
|
||||
ANALYTICS_TIERS,
|
||||
assertReadableByBiSurface,
|
||||
assertTierLineageSane,
|
||||
BI_READABLE_TIERS,
|
||||
isAnalyticsTier,
|
||||
TIER_RANK,
|
||||
} from "@/src/analytics/domain/tiers";
|
||||
|
||||
const MIGRATION = fs.readFileSync(
|
||||
path.join(process.cwd(), "sql", "008_analytics_domain_model.sql"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("BI-P002 canonical entity set", () => {
|
||||
it("lists exactly the nineteen entities the program names, verbatim", () => {
|
||||
expect(ANALYTICS_DOMAIN_ENTITIES).toEqual([
|
||||
"AnalyticsDataset",
|
||||
"DatasetVersion",
|
||||
"DatasetField",
|
||||
"DatasetGrant",
|
||||
"MetricDefinition",
|
||||
"MetricVersion",
|
||||
"DimensionDefinition",
|
||||
"SemanticModel",
|
||||
"AnalyticsQuery",
|
||||
"QueryExecution",
|
||||
"QueryResult",
|
||||
"DashboardDefinition",
|
||||
"DashboardVersion",
|
||||
"ReportDefinition",
|
||||
"ReportRun",
|
||||
"DataQualityRule",
|
||||
"DataQualityRun",
|
||||
"LineageReference",
|
||||
"BIApplicationBinding",
|
||||
]);
|
||||
expect(ANALYTICS_DOMAIN_ENTITIES).toHaveLength(19);
|
||||
});
|
||||
|
||||
it("sql/008 defines a table for every entity (no entity is a type without storage)", () => {
|
||||
const missing: string[] = [];
|
||||
for (const entity of ANALYTICS_DOMAIN_ENTITIES) {
|
||||
const table = ANALYTICS_ENTITY_TABLES[entity];
|
||||
if (!new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\b`).test(MIGRATION)) {
|
||||
missing.push(`${entity} -> ${table}`);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("every tenant-owned table carries tenant_id AND project_id", () => {
|
||||
// Split on the table definitions and check each block.
|
||||
const blocks = MIGRATION.split(/CREATE TABLE IF NOT EXISTS /).slice(1);
|
||||
const offenders: string[] = [];
|
||||
for (const block of blocks) {
|
||||
const name = block.split(/[\s(]/)[0];
|
||||
if (!Object.values(ANALYTICS_ENTITY_TABLES).includes(name)) continue;
|
||||
const body = block.slice(0, block.indexOf(");"));
|
||||
if (!/\btenant_id TEXT NOT NULL\b/.test(body) || !/\bproject_id TEXT NOT NULL\b/.test(body)) {
|
||||
offenders.push(name);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the four immutable version entities have a BEFORE UPDATE OR DELETE trigger in sql/008", () => {
|
||||
expect(IMMUTABLE_VERSION_ENTITIES).toEqual([
|
||||
"DatasetVersion",
|
||||
"MetricVersion",
|
||||
"SemanticModel",
|
||||
"DashboardVersion",
|
||||
]);
|
||||
for (const entity of IMMUTABLE_VERSION_ENTITIES) {
|
||||
const table = ANALYTICS_ENTITY_TABLES[entity];
|
||||
expect(
|
||||
new RegExp(`BEFORE UPDATE OR DELETE ON ${table}\\b`).test(MIGRATION),
|
||||
`${entity} (${table}) has no immutability trigger`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("BIApplicationBinding storage carries NO credential column — the ownership boundary is structural", () => {
|
||||
const block = MIGRATION.split("CREATE TABLE IF NOT EXISTS opt_analytics_bi_application_binding")[1] ?? "";
|
||||
const body = block.slice(0, block.indexOf(");"));
|
||||
for (const forbidden of ["password", "dsn", "secret", "connection_string", "clickhouse_url", "token"]) {
|
||||
expect(body.toLowerCase()).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("sql/008 is idempotent-shaped: every CREATE is IF NOT EXISTS / OR REPLACE", () => {
|
||||
const creates = MIGRATION.match(/^CREATE (TABLE|INDEX|TRIGGER|FUNCTION|OR REPLACE FUNCTION)[^\n]*/gm) ?? [];
|
||||
const nonIdempotent = creates.filter(
|
||||
(line) => !/IF NOT EXISTS/.test(line) && !/OR REPLACE/.test(line) && !/^CREATE TRIGGER/.test(line),
|
||||
);
|
||||
expect(nonIdempotent).toEqual([]);
|
||||
// Triggers cannot take IF NOT EXISTS in Postgres, so each one must be
|
||||
// preceded by a DROP TRIGGER IF EXISTS to stay replayable.
|
||||
const triggers = MIGRATION.match(/^CREATE TRIGGER (\w+)/gm) ?? [];
|
||||
for (const t of triggers) {
|
||||
const name = t.replace("CREATE TRIGGER ", "");
|
||||
expect(MIGRATION).toContain(`DROP TRIGGER IF EXISTS ${name}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("BI-P003 analytics data tiers", () => {
|
||||
it("canonicalizes exactly RAW / NORMALIZED / CURATED / SERVING", () => {
|
||||
expect(ANALYTICS_TIERS).toEqual(["RAW", "NORMALIZED", "CURATED", "SERVING"]);
|
||||
expect(isAnalyticsTier("CURATED")).toBe(true);
|
||||
expect(isAnalyticsTier("raw")).toBe(false);
|
||||
expect(isAnalyticsTier("BRONZE")).toBe(false);
|
||||
});
|
||||
|
||||
it("no business dashboard reads a RAW/NORMALIZED dataset by default", () => {
|
||||
expect(BI_READABLE_TIERS).toEqual(["SERVING", "CURATED"]);
|
||||
for (const tier of ["RAW", "NORMALIZED"] as const) {
|
||||
expect(() =>
|
||||
assertReadableByBiSurface({ dataset_id: "ds_1", tier }, "dashboard:exec-overview"),
|
||||
).toThrowError(/may not read/);
|
||||
}
|
||||
for (const tier of ["SERVING", "CURATED"] as const) {
|
||||
expect(() => assertReadableByBiSurface({ dataset_id: "ds_1", tier }, "dashboard:x")).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("a recorded exception is the only way a RAW dataset reaches a BI surface", () => {
|
||||
expect(() =>
|
||||
assertReadableByBiSurface(
|
||||
{ dataset_id: "ds_1", tier: "RAW", raw_access_exception: "INC-2026-09-06 forensic review, expires 2026-10-01" },
|
||||
"dashboard:incident",
|
||||
),
|
||||
).not.toThrow();
|
||||
// An empty/whitespace "exception" is not an exception.
|
||||
expect(() =>
|
||||
assertReadableByBiSurface({ dataset_id: "ds_1", tier: "RAW", raw_access_exception: " " }, "dashboard:x"),
|
||||
).toThrowError(/may not read/);
|
||||
});
|
||||
|
||||
it("tier order is enforced: a CURATED dataset cannot be built from SERVING", () => {
|
||||
expect(TIER_RANK.RAW).toBeLessThan(TIER_RANK.NORMALIZED);
|
||||
expect(TIER_RANK.NORMALIZED).toBeLessThan(TIER_RANK.CURATED);
|
||||
expect(TIER_RANK.CURATED).toBeLessThan(TIER_RANK.SERVING);
|
||||
expect(() => assertTierLineageSane("CURATED", "SERVING")).toThrowError(/tier order/);
|
||||
expect(() => assertTierLineageSane("SERVING", "CURATED")).not.toThrow();
|
||||
expect(() => assertTierLineageSane("CURATED", "CURATED")).not.toThrow();
|
||||
});
|
||||
|
||||
it("sql/008 constrains tier at the DATABASE level, not only in TypeScript", () => {
|
||||
expect(MIGRATION).toContain("CHECK (tier IN ('RAW','NORMALIZED','CURATED','SERVING'))");
|
||||
});
|
||||
|
||||
it("BI-P003's eight required declarations are all NOT NULL in sql/008", () => {
|
||||
const block = MIGRATION.split("CREATE TABLE IF NOT EXISTS opt_analytics_dataset ")[1] ?? "";
|
||||
const body = block.slice(0, block.indexOf(");"));
|
||||
for (const col of [
|
||||
"tier",
|
||||
"schema_contract_id",
|
||||
"owner_id",
|
||||
"source_ref",
|
||||
"freshness_slo_seconds",
|
||||
"retention_days",
|
||||
"classification",
|
||||
"quality_rule_ids",
|
||||
]) {
|
||||
expect(new RegExp(`\\b${col}\\b[^,]*NOT NULL`).test(body), `${col} is nullable`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* BI-P002 DoD: "Database migrations + persistence + tenant isolation +
|
||||
* restore tests."
|
||||
*
|
||||
* REAL Postgres, same honest-degrade convention as
|
||||
* tests/p0/source-registry-postgres.realinfra.test.ts: gated on
|
||||
* ANALYTICS_TEST_DATABASE_URL, SKIPS HONESTLY when unset, never fabricates.
|
||||
*
|
||||
* docker run -d --name opt-bi-pg -p 15455:5432 \
|
||||
* -e POSTGRES_USER=opt -e POSTGRES_PASSWORD=opt_bi_throwaway \
|
||||
* -e POSTGRES_DB=opt_bi_test postgres:16-alpine
|
||||
* ANALYTICS_TEST_DATABASE_URL=postgres://opt:opt_bi_throwaway@localhost:15455/opt_bi_test \
|
||||
* pnpm vitest run tests/analytics/analytics-domain-postgres.realinfra.test.ts
|
||||
*
|
||||
* The RESTORE half is the part that catches migration-chain rot: it replays
|
||||
* sql/001..008 in order into a FRESH database, exactly as
|
||||
* docs/runbook/backup-restore.md describes, and only then runs the
|
||||
* persistence assertions. sql/003_datasets.sql exists because that same drill
|
||||
* found a table no file ever created; this suite keeps that class of defect
|
||||
* from coming back through sql/008.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import postgres from "postgres";
|
||||
import {
|
||||
ANALYTICS_DOMAIN_ENTITIES,
|
||||
ANALYTICS_ENTITY_TABLES,
|
||||
IMMUTABLE_VERSION_ENTITIES,
|
||||
} from "@/src/analytics/domain/entities";
|
||||
import { AnalyticsDomainRepository } from "@/src/analytics/repositories/analytics-domain-repository";
|
||||
import { contractHash, type DatasetContract } from "@/src/analytics/domain/contract";
|
||||
|
||||
const TEST_DATABASE_URL = process.env.ANALYTICS_TEST_DATABASE_URL;
|
||||
const run = TEST_DATABASE_URL ? describe : describe.skip;
|
||||
|
||||
/** A schema of its own so the drill starts from genuinely nothing. */
|
||||
const SCHEMA = `bi_p002_${Date.now().toString(36)}`;
|
||||
|
||||
let sql: ReturnType<typeof postgres> | undefined;
|
||||
let reachable = false;
|
||||
let unreachableReason = "";
|
||||
/** Which sql/*.sql files the restore drill actually applied. */
|
||||
const applied: string[] = [];
|
||||
|
||||
const ALPHA = { tenant_id: "t_alpha", project_id: "p_main" };
|
||||
const BETA = { tenant_id: "t_beta", project_id: "p_main" };
|
||||
|
||||
function repo(): AnalyticsDomainRepository {
|
||||
return new AnalyticsDomainRepository(sql!);
|
||||
}
|
||||
|
||||
function contract(datasetId: string): DatasetContract {
|
||||
return {
|
||||
dataset_id: datasetId,
|
||||
version: "1.0.0",
|
||||
tier: "CURATED",
|
||||
fields: [{ name: "id", type: "string", nullable: false, role: "identifier", primary_identifier: true }],
|
||||
primary_identifiers: ["id"],
|
||||
dimensions: [],
|
||||
measures: [],
|
||||
freshness_slo_seconds: 3600,
|
||||
retention_days: 365,
|
||||
pii_classification: "none",
|
||||
source_lineage: ["src_x"],
|
||||
quality_rules: [],
|
||||
allowed_purposes: ["analytics"],
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!TEST_DATABASE_URL) {
|
||||
console.warn("[realinfra] ANALYTICS_TEST_DATABASE_URL not set — skipping honestly, not fabricating success.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sql = postgres(TEST_DATABASE_URL, { prepare: false, ssl: false, max: 3 });
|
||||
await Promise.race([
|
||||
sql`SELECT 1`,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("connect timeout")), 5000)),
|
||||
]);
|
||||
// RESTORE DRILL: a brand-new schema, then every migration in order.
|
||||
await sql.unsafe(`CREATE SCHEMA ${SCHEMA}`);
|
||||
await sql.unsafe(`SET search_path TO ${SCHEMA}, public`);
|
||||
const dir = path.join(process.cwd(), "sql");
|
||||
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) {
|
||||
const body = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
try {
|
||||
await sql.unsafe(`SET search_path TO ${SCHEMA}, public; ${body}`);
|
||||
applied.push(file);
|
||||
} catch (err) {
|
||||
// 001 needs the `vector` extension, which a stock postgres:16-alpine
|
||||
// does not carry. Record the skip rather than hiding it — the files
|
||||
// sql/008 actually depends on must still apply.
|
||||
applied.push(`${file} (FAILED: ${String(err).slice(0, 120)})`);
|
||||
}
|
||||
}
|
||||
reachable = true;
|
||||
} catch (err) {
|
||||
reachable = false;
|
||||
unreachableReason = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (sql && reachable) {
|
||||
await sql.unsafe(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => undefined);
|
||||
}
|
||||
await sql?.end({ timeout: 5 });
|
||||
});
|
||||
|
||||
run("BI-P002 analytics domain model against a LIVE Postgres", () => {
|
||||
it("restore drill: sql/008 applies cleanly on top of the replayed chain", (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
expect(unreachableReason).toBe("");
|
||||
const p008 = applied.find((f) => f.startsWith("008_"));
|
||||
expect(p008, `sql/008 was not applied. Applied: ${applied.join(", ")}`).toBe(
|
||||
"008_analytics_domain_model.sql",
|
||||
);
|
||||
});
|
||||
|
||||
it("all nineteen entity tables really exist after the restore", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const rows = await sql!<{ table_name: string }[]>`
|
||||
SELECT table_name FROM information_schema.tables WHERE table_schema = ${SCHEMA}`;
|
||||
const present = new Set(rows.map((r) => r.table_name));
|
||||
const missing = ANALYTICS_DOMAIN_ENTITIES.filter((e) => !present.has(ANALYTICS_ENTITY_TABLES[e]));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("replaying sql/008 a second time is a no-op, not an error", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const body = fs.readFileSync(path.join(process.cwd(), "sql", "008_analytics_domain_model.sql"), "utf8");
|
||||
await expect(sql!.unsafe(`SET search_path TO ${SCHEMA}, public; ${body}`)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("persists a dataset and reads it back through the repository", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const r = repo();
|
||||
const created = await r.createDataset(ALPHA, {
|
||||
dataset_id: "ds_alpha_1",
|
||||
name: "Alpha listings",
|
||||
tier: "CURATED",
|
||||
schema_contract_id: "dc_alpha",
|
||||
schema_contract_version: "1.0.0",
|
||||
owner_id: "team_data",
|
||||
source_ref: "src_feed",
|
||||
freshness_slo_seconds: 3600,
|
||||
quality_rule_ids: [],
|
||||
retention_days: 365,
|
||||
classification: "none",
|
||||
status: "active",
|
||||
created_by: "usr_1",
|
||||
});
|
||||
expect(created.tenant_id).toBe("t_alpha");
|
||||
const read = await r.getDataset(ALPHA, "ds_alpha_1");
|
||||
expect(read?.name).toBe("Alpha listings");
|
||||
expect(read?.freshness_slo_seconds).toBe(3600);
|
||||
// Cross-check with direct SQL — proving the row is really in the table,
|
||||
// not an artifact of the repository's own bookkeeping.
|
||||
const direct = await sql!<{ c: string }[]>`
|
||||
SELECT count(*)::text AS c FROM opt_analytics_dataset WHERE dataset_id = 'ds_alpha_1'`;
|
||||
expect(Number(direct[0].c)).toBe(1);
|
||||
});
|
||||
|
||||
it("TENANT ISOLATION: t_beta cannot read t_alpha's dataset, even with the exact id", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const r = repo();
|
||||
expect(await r.getDataset(BETA, "ds_alpha_1")).toBeUndefined();
|
||||
expect(await r.listDatasets(BETA)).toEqual([]);
|
||||
// And the row IS there — the empty result is scoping, not absence.
|
||||
const direct = await sql!<{ c: string }[]>`SELECT count(*)::text AS c FROM opt_analytics_dataset`;
|
||||
expect(Number(direct[0].c)).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("TENANT ISOLATION: a version cannot be minted against another tenant's dataset", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
await expect(
|
||||
repo().createDatasetVersion(BETA, {
|
||||
dataset_id: "ds_alpha_1",
|
||||
contract_hash: "deadbeef",
|
||||
schema_contract_version: "1.0.0",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("dataset versions are monotonic and carry the contract hash", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const r = repo();
|
||||
const c = contract("ds_alpha_1");
|
||||
const v1 = await r.createDatasetVersion(ALPHA, {
|
||||
dataset_id: "ds_alpha_1",
|
||||
contract_hash: contractHash(c),
|
||||
schema_contract_version: "1.0.0",
|
||||
});
|
||||
const v2 = await r.createDatasetVersion(ALPHA, {
|
||||
dataset_id: "ds_alpha_1",
|
||||
contract_hash: contractHash({ ...c, version: "2.0.0" }),
|
||||
schema_contract_version: "2.0.0",
|
||||
});
|
||||
expect(v1.version).toBe(1);
|
||||
expect(v2.version).toBe(2);
|
||||
expect(v2.contract_hash).not.toBe(v1.contract_hash);
|
||||
expect((await r.listDatasetVersions(ALPHA, "ds_alpha_1")).map((v) => v.version)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("IMMUTABILITY is enforced by the DATABASE: UPDATE and DELETE on a version row are refused", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
// Direct SQL, bypassing every TypeScript `readonly` — the only test that
|
||||
// proves the guarantee survives a psql session.
|
||||
await expect(
|
||||
sql!.unsafe(
|
||||
`UPDATE ${SCHEMA}.opt_analytics_dataset_version SET contract_hash = 'tampered' WHERE dataset_id = 'ds_alpha_1'`,
|
||||
),
|
||||
).rejects.toThrow(/immutable/i);
|
||||
await expect(
|
||||
sql!.unsafe(`DELETE FROM ${SCHEMA}.opt_analytics_dataset_version WHERE dataset_id = 'ds_alpha_1'`),
|
||||
).rejects.toThrow(/immutable/i);
|
||||
// The rows are untouched.
|
||||
const rows = await sql!<{ contract_hash: string }[]>`
|
||||
SELECT contract_hash FROM opt_analytics_dataset_version WHERE dataset_id = 'ds_alpha_1'`;
|
||||
expect(rows).toHaveLength(2);
|
||||
for (const r of rows) expect(r.contract_hash).not.toBe("tampered");
|
||||
});
|
||||
|
||||
it("all four immutable entities refuse an UPDATE, not just the one that was tested first", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const r = repo();
|
||||
await r.createDataset(ALPHA, {
|
||||
dataset_id: "ds_imm",
|
||||
name: "Immutability fixture",
|
||||
tier: "SERVING",
|
||||
schema_contract_id: "dc_i",
|
||||
schema_contract_version: "1.0.0",
|
||||
owner_id: "team_data",
|
||||
source_ref: "src",
|
||||
freshness_slo_seconds: 60,
|
||||
quality_rule_ids: [],
|
||||
retention_days: 30,
|
||||
classification: "none",
|
||||
status: "active",
|
||||
});
|
||||
const metric = await r.createMetric(ALPHA, {
|
||||
metric_id: "m_imm",
|
||||
name: "immutable metric",
|
||||
dataset_id: "ds_imm",
|
||||
aggregation: "sum",
|
||||
measure_field: "value",
|
||||
allowed_dimension_ids: [],
|
||||
owner_id: "team_data",
|
||||
});
|
||||
await r.createMetricVersion(ALPHA, { metric_id: metric.metric_id, definition_json: '{"v":1}' });
|
||||
await r.createDatasetVersion(ALPHA, {
|
||||
dataset_id: "ds_imm",
|
||||
contract_hash: "h1",
|
||||
schema_contract_version: "1.0.0",
|
||||
});
|
||||
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_semantic_model
|
||||
(semantic_model_id, tenant_id, project_id, name, version, model_json, model_hash, owner_id)
|
||||
VALUES ('sm_1','t_alpha','p_main','model_a',1,'{}','h','team_data')`);
|
||||
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dashboard_definition
|
||||
(dashboard_id, tenant_id, project_id, name, owner_id) VALUES ('db_1','t_alpha','p_main','dash','team_data')`);
|
||||
await sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dashboard_version
|
||||
(dashboard_version_id, dashboard_id, tenant_id, project_id, version, layout_json, layout_hash)
|
||||
VALUES ('dv_1','db_1','t_alpha','p_main',1,'{}','h')`);
|
||||
|
||||
const updates: Record<string, string> = {
|
||||
DatasetVersion: `UPDATE ${SCHEMA}.opt_analytics_dataset_version SET contract_hash='x' WHERE dataset_id='ds_imm'`,
|
||||
MetricVersion: `UPDATE ${SCHEMA}.opt_analytics_metric_version SET definition_json='{}' WHERE metric_id='m_imm'`,
|
||||
SemanticModel: `UPDATE ${SCHEMA}.opt_analytics_semantic_model SET model_json='{}' WHERE semantic_model_id='sm_1'`,
|
||||
DashboardVersion: `UPDATE ${SCHEMA}.opt_analytics_dashboard_version SET layout_json='{}' WHERE dashboard_version_id='dv_1'`,
|
||||
};
|
||||
for (const entity of IMMUTABLE_VERSION_ENTITIES) {
|
||||
await expect(sql!.unsafe(updates[entity]), `${entity} is NOT immutable`).rejects.toThrow(/immutable/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("a metric version after the first must state WHY the number changed meaning", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const r = repo();
|
||||
await expect(
|
||||
r.createMetricVersion(ALPHA, { metric_id: "m_imm", definition_json: '{"v":2}' }),
|
||||
).rejects.toThrow();
|
||||
const v2 = await r.createMetricVersion(ALPHA, {
|
||||
metric_id: "m_imm",
|
||||
definition_json: '{"v":2}',
|
||||
change_reason: "excluded internal test accounts from the numerator",
|
||||
});
|
||||
expect(v2.version).toBe(2);
|
||||
});
|
||||
|
||||
it("a DatasetGrant without a purpose is refused by the DATABASE (purpose limitation)", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
// Regression pin: the first version of this constraint was written
|
||||
// `array_length(allowed_purposes, 1) >= 1`, which is NULL for '{}' — and a
|
||||
// CHECK evaluating to NULL PASSES in Postgres. It could never fail. This
|
||||
// test found that; keep it.
|
||||
await expect(
|
||||
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dataset_grant
|
||||
(grant_id, dataset_id, tenant_id, project_id, subject_id, subject_type, level, allowed_purposes)
|
||||
VALUES ('g_1','ds_imm','t_alpha','p_main','usr_1','user','read', '{}')`),
|
||||
).rejects.toThrow(/purposes_nonempty/);
|
||||
// A grant WITH a purpose is accepted, so the constraint is not simply
|
||||
// rejecting everything.
|
||||
await expect(
|
||||
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dataset_grant
|
||||
(grant_id, dataset_id, tenant_id, project_id, subject_id, subject_type, level, allowed_purposes)
|
||||
VALUES ('g_ok','ds_imm','t_alpha','p_main','usr_1','user','read', ARRAY['market_analytics'])`),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("a BIApplicationBinding without a purpose is refused too, and carries no credential column", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
await expect(
|
||||
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_bi_application_binding
|
||||
(binding_id, tenant_id, project_id, kind, name, principal_id, allowed_purposes)
|
||||
VALUES ('b_1','t_alpha','p_main','superset','sup','svc_1','{}')`),
|
||||
).rejects.toThrow(/purposes_nonempty/);
|
||||
|
||||
const cols = await sql!<{ column_name: string }[]>`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = ${SCHEMA} AND table_name = 'opt_analytics_bi_application_binding'`;
|
||||
const names = cols.map((c) => c.column_name.toLowerCase());
|
||||
for (const forbidden of ["password", "secret", "dsn", "token", "connection_string", "clickhouse_url"]) {
|
||||
expect(names, `BI binding must never store ${forbidden}`).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("the dimension catalog is tenant-scoped and FAILS CLOSED for a tenant with none", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const r = repo();
|
||||
await r.createDimension(ALPHA, {
|
||||
dimension_id: "dim_1",
|
||||
name: "make",
|
||||
dataset_id: "ds_imm",
|
||||
field_name: "vehicle_make",
|
||||
field_type: "string",
|
||||
classification: "none",
|
||||
});
|
||||
expect(await r.dimensionCatalog(ALPHA)).toEqual({ make: "vehicle_make" });
|
||||
// t_beta declared nothing, so it may reference nothing.
|
||||
expect(await r.dimensionCatalog(BETA)).toEqual({});
|
||||
});
|
||||
|
||||
it("a dimension whose physical field is not identifier-safe is refused by the DATABASE", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
await expect(
|
||||
sql!.unsafe(`INSERT INTO ${SCHEMA}.opt_analytics_dimension_definition
|
||||
(dimension_id, tenant_id, project_id, name, dataset_id, field_name, field_type)
|
||||
VALUES ('dim_bad','t_alpha','p_main','evil','ds_imm', 'x''; DROP TABLE opt_analytics_dataset; --','string')`),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("the repository refuses to act without a verified tenant scope", async (ctx) => {
|
||||
if (!reachable) ctx.skip();
|
||||
const r = repo();
|
||||
await expect(r.getDataset({ tenant_id: "", project_id: "p" }, "ds_imm")).rejects.toMatchObject({
|
||||
code: "UNAUTHENTICATED",
|
||||
});
|
||||
await expect(r.listDatasets({ tenant_id: "t_alpha", project_id: " " })).rejects.toMatchObject({
|
||||
code: "UNAUTHENTICATED",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* BI-P009 DoD: "Provider contract tests added."
|
||||
*
|
||||
* This is the contract every `SiaxAnalyticsEngine` provider must satisfy
|
||||
* regardless of engine — ClickHouse today, DuckDB (BI-P014) and Trino
|
||||
* (BI-P015) later. It is written against the INTERFACE, so a future provider
|
||||
* imports `analyticsEngineContract` and gets the same bar.
|
||||
*
|
||||
* The identity rules here are the ones that matter most: this estate has
|
||||
* already shipped a live cross-tenant hole by reading the tenant out of a
|
||||
* REQUEST BODY (INF0 Wave 2). So the contract asserts that a request field
|
||||
* named `tenant_id` cannot influence the engine at all.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ClickHouseAnalyticsEngine } from "@/src/analytics/engine/clickhouse-engine";
|
||||
import {
|
||||
SIAX_ANALYTICS_ENGINE_CAPABILITIES,
|
||||
type AnalyticsIdentity,
|
||||
type SiaxAnalyticsEngine,
|
||||
} from "@/src/analytics/engine/siax-analytics-engine";
|
||||
|
||||
const IDENTITY: AnalyticsIdentity = {
|
||||
tenant_id: "t_alpha",
|
||||
project_id: "p_default",
|
||||
principal_id: "usr_1",
|
||||
};
|
||||
|
||||
/** Reusable contract suite — future providers call this with their factory. */
|
||||
export function analyticsEngineContract(name: string, make: () => SiaxAnalyticsEngine): void {
|
||||
describe(`SiaxAnalyticsEngine contract: ${name}`, () => {
|
||||
it("implements all eight BI-P009 capabilities as callable methods", () => {
|
||||
const engine = make() as unknown as Record<string, unknown>;
|
||||
for (const cap of SIAX_ANALYTICS_ENGINE_CAPABILITIES) {
|
||||
expect(typeof engine[cap], `${cap} is not a method`).toBe("function");
|
||||
}
|
||||
expect(SIAX_ANALYTICS_ENGINE_CAPABILITIES).toEqual([
|
||||
"health",
|
||||
"executeMetric",
|
||||
"executeQuery",
|
||||
"estimateQuery",
|
||||
"cancelQuery",
|
||||
"explain",
|
||||
"ingestProjection",
|
||||
"refreshProjection",
|
||||
]);
|
||||
});
|
||||
|
||||
it("names its provider", () => {
|
||||
expect(make().provider.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("refuses every tenant-scoped capability without a verified identity", async () => {
|
||||
const engine = make();
|
||||
const blank = { tenant_id: "", project_id: "p", principal_id: "usr_1" };
|
||||
const noPrincipal = { tenant_id: "t_alpha", project_id: "p", principal_id: "" };
|
||||
for (const bad of [blank, noPrincipal]) {
|
||||
await expect(engine.executeMetric(bad, { metric: "m", aggregate: "sum" })).rejects.toMatchObject({
|
||||
code: "UNAUTHENTICATED",
|
||||
});
|
||||
await expect(engine.estimateQuery(bad, { metric: "m", aggregate: "sum" })).rejects.toMatchObject({
|
||||
code: "UNAUTHENTICATED",
|
||||
});
|
||||
await expect(engine.ingestProjection(bad, { dataset_id: "ds", rows: [] })).rejects.toMatchObject({
|
||||
code: "UNAUTHENTICATED",
|
||||
});
|
||||
await expect(engine.refreshProjection(bad, "ds")).rejects.toMatchObject({ code: "UNAUTHENTICATED" });
|
||||
await expect(engine.cancelQuery(bad, "q")).rejects.toMatchObject({ code: "UNAUTHENTICATED" });
|
||||
}
|
||||
});
|
||||
|
||||
it("a tenant_id smuggled into the REQUEST cannot influence the engine (INF0 Wave-2 bug class)", async () => {
|
||||
const engine = make();
|
||||
const smuggled = { metric: "m", aggregate: "sum", tenant_id: "t_victim" } as never;
|
||||
// The request type has no tenant field; if a provider ever started
|
||||
// reading one, this call would target t_victim. The engine must still
|
||||
// fail on the IDENTITY, proving the request field is inert.
|
||||
await expect(
|
||||
engine.executeMetric({ tenant_id: "", project_id: "p", principal_id: "u" }, smuggled),
|
||||
).rejects.toMatchObject({ code: "UNAUTHENTICATED" });
|
||||
});
|
||||
|
||||
it("cancelQuery refuses a query id belonging to another tenant", async () => {
|
||||
const engine = make();
|
||||
await expect(engine.cancelQuery(IDENTITY, "opt_deadbeefdead_someoneelses")).rejects.toMatchObject({
|
||||
code: "AUTHORIZATION_DENIED",
|
||||
});
|
||||
});
|
||||
|
||||
it("health() reports unhealthy rather than throwing when the engine is unreachable", async () => {
|
||||
const report = await make().health();
|
||||
expect(report.healthy).toBe(false);
|
||||
expect(report.provider.length).toBeGreaterThan(0);
|
||||
// Never leak an engine body or a DSN into the operator-facing detail.
|
||||
expect(report.detail ?? "").not.toMatch(/http|password|@/i);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
analyticsEngineContract(
|
||||
"clickhouse (unreachable host — contract-level assertions only)",
|
||||
() =>
|
||||
new ClickHouseAnalyticsEngine({
|
||||
baseUrl: "http://clickhouse.invalid:8123",
|
||||
database: "opt_contract_test",
|
||||
timeoutMs: 500,
|
||||
}),
|
||||
);
|
||||
|
||||
describe("BI-P009 refusals that are deliberate, not missing", () => {
|
||||
it("executeQuery (analyst SQL) is declared and REFUSED until BI-P017/BI-P033", async () => {
|
||||
const engine = new ClickHouseAnalyticsEngine({ baseUrl: "http://clickhouse.invalid:8123" });
|
||||
await expect(engine.executeQuery(IDENTITY, { sql: "SELECT 1", dataset_ids: [] })).rejects.toMatchObject({
|
||||
code: "CAPABILITY_UNAVAILABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("the PRESERVED ClickHouseAnalytics is reachable through the engine, not replaced by it", () => {
|
||||
const engine = new ClickHouseAnalyticsEngine({ baseUrl: "http://clickhouse.invalid:8123" });
|
||||
expect(engine.legacyAnalytics.constructor.name).toBe("ClickHouseAnalytics");
|
||||
expect(typeof engine.legacyAnalytics.queryMetric).toBe("function");
|
||||
expect(typeof engine.legacyAnalytics.ensureSchema).toBe("function");
|
||||
expect(typeof engine.legacyAnalytics.ingest).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The canonical BI-P005 fixture, in one place.
|
||||
*
|
||||
* Two jobs:
|
||||
* 1. assert the encoder is DETERMINISTIC — the same columns must produce
|
||||
* byte-identical output, or the "roundtrip" evidence would be comparing
|
||||
* two different payloads run to run;
|
||||
* 2. when ARROW_FIXTURE_DIR is set, write those exact bytes to disk so
|
||||
* scripts/bi-p005-arrow-roundtrip.mjs hands pyarrow and DuckDB the bytes
|
||||
* the SHIPPED module produces, not a copy of the encoder living in a
|
||||
* script.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { type ArrowColumn, encodeArrowStream } from "@/src/analytics/arrow/ipc";
|
||||
|
||||
/** The same three rows the ClickHouse side of the DoD produces. */
|
||||
export const BI_P005_FIXTURE: ArrowColumn[] = [
|
||||
{ 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"),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("BI-P005 canonical Arrow fixture", () => {
|
||||
it("encodes deterministically", () => {
|
||||
const a = encodeArrowStream(BI_P005_FIXTURE);
|
||||
const b = encodeArrowStream(BI_P005_FIXTURE);
|
||||
expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true);
|
||||
expect(a.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("writes the fixture for the external roundtrip when asked", () => {
|
||||
const dir = process.env.ARROW_FIXTURE_DIR;
|
||||
if (!dir) return; // not an error: the normal test run has nowhere to put it
|
||||
const bytes = encodeArrowStream(BI_P005_FIXTURE);
|
||||
const out = path.join(dir, "02_0pt_encoded.arrow");
|
||||
fs.writeFileSync(out, bytes);
|
||||
expect(fs.statSync(out).size).toBe(bytes.length);
|
||||
// Print the digest so the evidence file can be tied to these exact bytes.
|
||||
console.log(`ARROW_FIXTURE_SHA256=${crypto.createHash("sha256").update(bytes).digest("hex")}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* BI-P005 — APACHE ARROW INTERCHANGE, the part provable without a live engine.
|
||||
*
|
||||
* Honest scope note: the tests here run 0pt's encoder against 0pt's decoder,
|
||||
* which proves internal consistency and NOT specification conformance. The
|
||||
* conformance evidence comes from two places where an INDEPENDENT
|
||||
* implementation reads the bytes:
|
||||
*
|
||||
* * tests/integration/analytics-arrow-clickhouse.integration.test.ts —
|
||||
* ClickHouse (Arrow C++) both produces Arrow this decoder reads and
|
||||
* consumes Arrow this encoder writes. Runs on every push in CI.
|
||||
* * evidence/bi-p002-p012/bi-p005-arrow-roundtrip.json — pyarrow and DuckDB
|
||||
* decoding the same bytes, executed against real containers.
|
||||
*
|
||||
* That distinction is the point: a self-roundtrip that agrees with its own
|
||||
* mistakes is exactly the kind of green this estate has been burned by.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
ARROW_TYPE_SUPPORT,
|
||||
arrowStreamSettings,
|
||||
assertArrowWithinResultBudget,
|
||||
clickhouseArrowStreamFormat,
|
||||
decodeArrowStream,
|
||||
encodeArrowStream,
|
||||
} from "@/src/analytics/arrow/ipc";
|
||||
|
||||
describe("BI-P005 Arrow IPC", () => {
|
||||
it("emits a well-formed IPC stream: continuation markers, schema, batch, EOS", () => {
|
||||
const bytes = encodeArrowStream([{ name: "n", type: "int64", values: [1, 2, 3] }]);
|
||||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
expect(dv.getUint32(0, true)).toBe(0xffffffff);
|
||||
// Last 8 bytes are the end-of-stream marker: 0xFFFFFFFF then length 0.
|
||||
expect(dv.getUint32(bytes.length - 8, true)).toBe(0xffffffff);
|
||||
expect(dv.getInt32(bytes.length - 4, true)).toBe(0);
|
||||
});
|
||||
|
||||
it("round-trips every supported column type with nulls", () => {
|
||||
const bytes = encodeArrowStream([
|
||||
{ name: "tenant_id", type: "utf8", values: ["t_alpha", "t_beta", null] },
|
||||
{ name: "n", type: "int64", values: [1, 2, 3] },
|
||||
{ name: "v", type: "float64", values: [1.5, 2.5, null] },
|
||||
{ name: "flag", type: "bool", values: [true, false, true] },
|
||||
{ name: "ts", type: "timestamp_ms", values: [new Date("2026-01-01T00:00:00Z"), null, new Date("2026-09-06T12:34:56.789Z")] },
|
||||
]);
|
||||
const info = decodeArrowStream(bytes);
|
||||
expect(info.rowCount).toBe(3);
|
||||
expect(info.batchCount).toBe(1);
|
||||
expect(info.fields.map((f) => f.name)).toEqual(["tenant_id", "n", "v", "flag", "ts"]);
|
||||
// Arrow Type union tags: Int=2, FloatingPoint=3, Utf8=5, Bool=6, Timestamp=10.
|
||||
expect(info.fields.map((f) => f.typeType)).toEqual([5, 2, 3, 6, 10]);
|
||||
});
|
||||
|
||||
it("handles an empty result set without producing a malformed stream", () => {
|
||||
const bytes = encodeArrowStream([{ name: "n", type: "int64", values: [] }]);
|
||||
const info = decodeArrowStream(bytes);
|
||||
expect(info.rowCount).toBe(0);
|
||||
expect(info.fields).toEqual([{ name: "n", typeType: 2 }]);
|
||||
});
|
||||
|
||||
it("rejects ragged columns rather than emitting a stream that decodes to garbage", () => {
|
||||
expect(() =>
|
||||
encodeArrowStream([
|
||||
{ name: "a", type: "int64", values: [1, 2] },
|
||||
{ name: "b", type: "int64", values: [1] },
|
||||
]),
|
||||
).toThrowError(/expected 2/);
|
||||
expect(() => encodeArrowStream([])).toThrowError(/at least one column/);
|
||||
});
|
||||
|
||||
it("refuses an int64 value outside the representable range instead of wrapping it", () => {
|
||||
expect(() =>
|
||||
encodeArrowStream([{ name: "n", type: "int64", values: [BigInt("9223372036854775808")] }]),
|
||||
).toThrowError(/out of int64 range/);
|
||||
});
|
||||
|
||||
it("rejects bytes that are not an Arrow IPC stream", () => {
|
||||
expect(() => decodeArrowStream(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toThrowError(/not an Arrow IPC stream/);
|
||||
});
|
||||
|
||||
it("BI-P012: Arrow is not a way around max_result_rows", () => {
|
||||
const bytes = encodeArrowStream([{ name: "n", type: "int64", values: [1, 2, 3, 4, 5] }]);
|
||||
expect(() => assertArrowWithinResultBudget(bytes, 3)).toThrowError(/over the 3-row budget/);
|
||||
expect(assertArrowWithinResultBudget(bytes, 5).rowCount).toBe(5);
|
||||
});
|
||||
|
||||
it("the ClickHouse pass-through asks for text Strings, not Binary", () => {
|
||||
expect(clickhouseArrowStreamFormat()).toBe("ArrowStream");
|
||||
// Without this setting ClickHouse emits String as Arrow Binary, which
|
||||
// pyarrow and DuckDB read as bytes — silent semantic loss.
|
||||
expect(arrowStreamSettings().output_format_arrow_string_as_string).toBe("1");
|
||||
});
|
||||
|
||||
it("documents the supported type mapping including where loss WOULD occur", () => {
|
||||
expect(ARROW_TYPE_SUPPORT.length).toBeGreaterThanOrEqual(5);
|
||||
const arrowTypes = ARROW_TYPE_SUPPORT.map((r) => r.arrow);
|
||||
expect(new Set(arrowTypes)).toEqual(new Set(["int64", "float64", "utf8", "bool", "timestamp_ms"]));
|
||||
// Every row states its caveat; an empty note would be a silent promise.
|
||||
for (const row of ARROW_TYPE_SUPPORT) expect(row.note.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* BI-P004 — ANALYTICS DATA CONTRACT.
|
||||
*
|
||||
* DoD: "Breaking dataset schema changes fail compatibility gate unless
|
||||
* explicitly versioned." Each breaking change class below is tested TWICE:
|
||||
* once under a minor bump (must be REFUSED) and once under a major bump (must
|
||||
* be ACCEPTED). A gate that only ever says yes, or only ever says no, would
|
||||
* pass a one-sided test; this one cannot.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
assertContractCompatible,
|
||||
checkContractCompatibility,
|
||||
contractHash,
|
||||
diffContracts,
|
||||
parseSemVer,
|
||||
validateContract,
|
||||
type DatasetContract,
|
||||
} from "@/src/analytics/domain/contract";
|
||||
|
||||
function base(): DatasetContract {
|
||||
return {
|
||||
dataset_id: "ds_vehicle_listings",
|
||||
version: "1.2.0",
|
||||
tier: "CURATED",
|
||||
fields: [
|
||||
{ name: "listing_id", type: "string", nullable: false, role: "identifier", primary_identifier: true },
|
||||
{ name: "make", type: "string", nullable: false, role: "dimension" },
|
||||
{ name: "asking_price", type: "int64", nullable: true, role: "measure" },
|
||||
{ name: "listed_at", type: "timestamp", nullable: false, role: "attribute" },
|
||||
],
|
||||
primary_identifiers: ["listing_id"],
|
||||
dimensions: ["make"],
|
||||
measures: ["asking_price"],
|
||||
freshness_slo_seconds: 3600,
|
||||
retention_days: 730,
|
||||
pii_classification: "none",
|
||||
source_lineage: ["src_dealer_feed"],
|
||||
quality_rules: [{ rule_id: "qr_listing_id_unique", kind: "unique", field_name: "listing_id", severity: "block" }],
|
||||
allowed_purposes: ["market_analytics"],
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a mutation and set the version. */
|
||||
function next(mutate: (c: DatasetContract) => void, version: string): DatasetContract {
|
||||
const c: DatasetContract = JSON.parse(JSON.stringify(base()));
|
||||
mutate(c);
|
||||
c.version = version;
|
||||
return c;
|
||||
}
|
||||
|
||||
const BREAKING_CASES: { name: string; mutate: (c: DatasetContract) => void; code: string }[] = [
|
||||
{ name: "field removed", mutate: (c) => { c.fields = c.fields.filter((f) => f.name !== "make"); c.dimensions = []; }, code: "field_removed" },
|
||||
{ name: "field type changed", mutate: (c) => { c.fields[2].type = "float64"; }, code: "field_type_changed" },
|
||||
{ name: "field became nullable", mutate: (c) => { c.fields[1].nullable = true; }, code: "field_became_nullable" },
|
||||
{ name: "field role changed", mutate: (c) => { c.fields[1].role = "measure"; }, code: "field_role_changed" },
|
||||
{ name: "required field added", mutate: (c) => { c.fields.push({ name: "vat_rate", type: "float64", nullable: false, role: "attribute" }); }, code: "required_field_added" },
|
||||
{ name: "primary identifier changed", mutate: (c) => { c.primary_identifiers = ["make"]; }, code: "primary_identifier_changed" },
|
||||
{ name: "dimension removed", mutate: (c) => { c.dimensions = []; }, code: "dimension_removed" },
|
||||
{ name: "measure removed", mutate: (c) => { c.measures = []; }, code: "measure_removed" },
|
||||
{ name: "freshness SLO loosened", mutate: (c) => { c.freshness_slo_seconds = 86400; }, code: "freshness_slo_loosened" },
|
||||
{ name: "retention shortened", mutate: (c) => { c.retention_days = 30; }, code: "retention_shortened" },
|
||||
{ name: "allowed purpose removed", mutate: (c) => { c.allowed_purposes = []; }, code: "purpose_removed" },
|
||||
{ name: "PII classification raised", mutate: (c) => { c.pii_classification = "personal"; }, code: "pii_classification_raised" },
|
||||
{ name: "blocking quality rule removed", mutate: (c) => { c.quality_rules = []; }, code: "blocking_quality_rule_removed" },
|
||||
{ name: "blocking quality rule downgraded", mutate: (c) => { c.quality_rules[0].severity = "warn"; }, code: "blocking_quality_rule_downgraded" },
|
||||
{ name: "tier changed", mutate: (c) => { c.tier = "SERVING"; }, code: "tier_changed" },
|
||||
];
|
||||
|
||||
describe("BI-P004 compatibility gate", () => {
|
||||
it.each(BREAKING_CASES)("REFUSES $name under a minor bump", ({ mutate, code }) => {
|
||||
const verdict = checkContractCompatibility(base(), next(mutate, "1.3.0"));
|
||||
expect(verdict.compatible).toBe(false);
|
||||
expect(verdict.breaking.map((b) => b.code)).toContain(code);
|
||||
expect(verdict.reason).toMatch(/MAJOR version bump/);
|
||||
});
|
||||
|
||||
it.each(BREAKING_CASES)("ACCEPTS $name once explicitly versioned (major bump)", ({ mutate, code }) => {
|
||||
const verdict = checkContractCompatibility(base(), next(mutate, "2.0.0"));
|
||||
expect(verdict.compatible).toBe(true);
|
||||
expect(verdict.explicitly_versioned).toBe(true);
|
||||
expect(verdict.breaking.map((b) => b.code)).toContain(code);
|
||||
});
|
||||
|
||||
it("a patch bump is equally refused — the gate is about the MAJOR, not about moving at all", () => {
|
||||
const verdict = checkContractCompatibility(base(), next((c) => c.fields.pop(), "1.2.1"));
|
||||
expect(verdict.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("allows genuinely additive changes without a major bump", () => {
|
||||
const additive = next((c) => {
|
||||
c.fields.push({ name: "mileage_km", type: "int64", nullable: true, role: "measure" });
|
||||
c.measures.push("mileage_km");
|
||||
c.quality_rules.push({ rule_id: "qr_mileage_range", kind: "range", field_name: "mileage_km", severity: "warn" });
|
||||
c.allowed_purposes.push("valuation");
|
||||
c.retention_days = 1095;
|
||||
c.freshness_slo_seconds = 1800;
|
||||
}, "1.3.0");
|
||||
const verdict = checkContractCompatibility(base(), additive);
|
||||
expect(verdict.compatible).toBe(true);
|
||||
expect(verdict.breaking).toEqual([]);
|
||||
expect(verdict.explicitly_versioned).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a version that does not move forward", () => {
|
||||
expect(checkContractCompatibility(base(), next(() => {}, "1.2.0")).compatible).toBe(false);
|
||||
expect(checkContractCompatibility(base(), next(() => {}, "1.1.9")).compatible).toBe(false);
|
||||
expect(checkContractCompatibility(base(), next(() => {}, "0.9.0")).compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("treats date -> timestamp as a safe widening, and int64 -> float64 as NOT one", () => {
|
||||
const widened = next((c) => {
|
||||
c.fields.push({ name: "sold_on", type: "date", nullable: true, role: "attribute" });
|
||||
}, "1.3.0");
|
||||
const later = JSON.parse(JSON.stringify(widened)) as DatasetContract;
|
||||
later.fields[later.fields.length - 1].type = "timestamp";
|
||||
later.version = "1.4.0";
|
||||
expect(checkContractCompatibility(widened, later).compatible).toBe(true);
|
||||
|
||||
const lossy = next((c) => { c.fields[2].type = "float64"; }, "1.3.0");
|
||||
expect(checkContractCompatibility(base(), lossy).compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("assertContractCompatible throws a CONFLICT rather than returning a verdict nobody reads", () => {
|
||||
expect(() => assertContractCompatible(base(), next((c) => c.fields.pop(), "1.3.0"))).toThrowError(/MAJOR/);
|
||||
expect(() => assertContractCompatible(base(), next(() => {}, "1.3.0"))).not.toThrow();
|
||||
});
|
||||
|
||||
it("refuses to compare contracts for different datasets", () => {
|
||||
const other = next((c) => { c.dataset_id = "ds_other"; }, "1.3.0");
|
||||
expect(() => diffContracts(base(), other)).toThrowError(/SAME dataset/);
|
||||
});
|
||||
|
||||
it("validateContract catches internally inconsistent contracts", () => {
|
||||
expect(() => validateContract(base())).not.toThrow();
|
||||
expect(() => validateContract({ ...base(), version: "1.2" })).toThrowError(/major.minor.patch/);
|
||||
expect(() => validateContract({ ...base(), fields: [] })).toThrowError(/no fields/);
|
||||
expect(() => validateContract({ ...base(), dimensions: ["nope"] })).toThrowError(/unknown field/);
|
||||
expect(() => validateContract({ ...base(), primary_identifiers: [] })).toThrowError(/no primary identifier/);
|
||||
expect(() => validateContract({ ...base(), allowed_purposes: [] })).toThrowError(/allowed_purposes/);
|
||||
expect(() => validateContract({ ...base(), freshness_slo_seconds: 0 })).toThrowError(/freshness/);
|
||||
const dup = base();
|
||||
dup.fields.push({ ...dup.fields[0] });
|
||||
expect(() => validateContract(dup)).toThrowError(/duplicate field/);
|
||||
});
|
||||
|
||||
it("contractHash is order-insensitive over fields but sensitive to meaning", () => {
|
||||
const a = base();
|
||||
const reordered = JSON.parse(JSON.stringify(a)) as DatasetContract;
|
||||
reordered.fields.reverse();
|
||||
expect(contractHash(reordered)).toBe(contractHash(a));
|
||||
|
||||
const changed = JSON.parse(JSON.stringify(a)) as DatasetContract;
|
||||
changed.fields[1].nullable = true;
|
||||
expect(contractHash(changed)).not.toBe(contractHash(a));
|
||||
});
|
||||
|
||||
it("parseSemVer rejects anything that is not major.minor.patch", () => {
|
||||
expect(parseSemVer("2.10.3")).toEqual({ major: 2, minor: 10, patch: 3 });
|
||||
for (const bad of ["v1.0.0", "1.0", "1.0.0-rc1", "latest", ""]) {
|
||||
expect(() => parseSemVer(bad)).toThrowError(/major.minor.patch/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* BI-P011's adversarial list, at the compiler layer:
|
||||
*
|
||||
* tenant A query B dataset · tenant A forged dataset ID · raw SQL attempt
|
||||
* SQL injection · dimension identifier injection · filter injection
|
||||
*
|
||||
* These are the cases that never reach the network. The cases that DO reach a
|
||||
* real server — and the with-fix/without-fix proof BI-P011 actually demands —
|
||||
* are tests/integration/analytics-tenant-isolation.integration.test.ts.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
assertSafeIdentifier,
|
||||
compileMetricQuery,
|
||||
compileProjectionQuery,
|
||||
dimensionCatalogFrom,
|
||||
restrictedTenantUserDdl,
|
||||
} from "@/src/analytics/engine/query-compiler";
|
||||
|
||||
const catalog = dimensionCatalogFrom({ severity: "severity", make: "vehicle_make" });
|
||||
|
||||
const HOSTILE = [
|
||||
"x'; DROP TABLE opt_analytics_event; --",
|
||||
"1 OR 1=1",
|
||||
"tenant_id",
|
||||
"*/ OR 1=1 /*",
|
||||
"severity UNION ALL SELECT tenant_id FROM opt_analytics_event",
|
||||
"dim_values[1]",
|
||||
"__proto__",
|
||||
"constructor",
|
||||
"sever ity",
|
||||
"sévérité",
|
||||
"a".repeat(200),
|
||||
"",
|
||||
];
|
||||
|
||||
describe("BI-P011 injection surfaces at the compiler", () => {
|
||||
it("dimension identifier injection: hostile group_by names are refused, not escaped", () => {
|
||||
for (const bad of HOSTILE) {
|
||||
expect(() =>
|
||||
compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "m", aggregate: "sum", group_by: [bad] },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
),
|
||||
`group_by "${bad}" was not refused`,
|
||||
).toThrowError(/unknown group_by dimension/);
|
||||
}
|
||||
});
|
||||
|
||||
it("filter injection: hostile filter KEYS are refused by catalog resolution", () => {
|
||||
for (const bad of HOSTILE) {
|
||||
expect(() =>
|
||||
compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "m", aggregate: "sum", filters: { [bad]: "v" } },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
),
|
||||
`filter key "${bad}" was not refused`,
|
||||
).toThrowError(/unknown filter dimension/);
|
||||
}
|
||||
});
|
||||
|
||||
it("prototype-chain keys resolve to undefined rather than to Object.prototype members", () => {
|
||||
// A naive `catalog[name]` lookup would resolve "constructor" to a
|
||||
// function and "toString" to a method; the Map-based catalog must not.
|
||||
for (const key of ["constructor", "toString", "hasOwnProperty", "__proto__", "valueOf"]) {
|
||||
expect(catalog.resolve(key)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("filter VALUES are never spliced, however hostile", () => {
|
||||
const compiled = compileMetricQuery(
|
||||
{
|
||||
tenant_id: "t_alpha",
|
||||
metric: "m",
|
||||
aggregate: "sum",
|
||||
filters: { make: "volvo'; DROP TABLE opt_analytics_event; --" },
|
||||
},
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
);
|
||||
expect(compiled.sql).not.toContain("DROP TABLE");
|
||||
expect(compiled.sql).not.toContain("volvo");
|
||||
expect(compiled.params.param_f0v).toBe("volvo'; DROP TABLE opt_analytics_event; --");
|
||||
// The physical column name came from the catalog, not the caller.
|
||||
expect(compiled.params.param_f0n).toBe("vehicle_make");
|
||||
});
|
||||
|
||||
it("metric and dataset VALUES are never spliced either", () => {
|
||||
const compiled = compileMetricQuery(
|
||||
{
|
||||
tenant_id: "t_alpha",
|
||||
metric: "m'; SELECT 1; --",
|
||||
dataset_id: "ds'; SELECT 1; --",
|
||||
aggregate: "sum",
|
||||
},
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
);
|
||||
expect(compiled.sql).not.toContain("SELECT 1");
|
||||
expect(compiled.sql.match(/SELECT/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("raw SQL attempt: there is no request shape that reaches a second table", () => {
|
||||
const compiled = compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "m", aggregate: "sum" },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
);
|
||||
expect(compiled.sql).not.toMatch(/\bUNION\b/i);
|
||||
expect(compiled.sql).not.toMatch(/\bJOIN\b/i);
|
||||
expect((compiled.sql.match(/\bFROM\b/gi) ?? []).length).toBe(1);
|
||||
expect(compiled.table).toBe("opt_analytics_event");
|
||||
});
|
||||
|
||||
it("a forged table name is refused rather than reaching the FROM clause", () => {
|
||||
expect(() =>
|
||||
compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "m", aggregate: "sum", table: "system.tables" as never },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
),
|
||||
).toThrowError(/invalid table identifier/);
|
||||
});
|
||||
|
||||
it("an unsupported aggregate is refused rather than being interpolated", () => {
|
||||
expect(() =>
|
||||
compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "m", aggregate: "sum(1),tenant_id" as never },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
),
|
||||
).toThrowError(/unsupported aggregate/);
|
||||
});
|
||||
|
||||
it("group-by cardinality is capped so a caller cannot explode the GROUP BY", () => {
|
||||
const many = Array.from({ length: 9 }, () => "severity");
|
||||
expect(() =>
|
||||
compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "m", aggregate: "sum", group_by: many },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
),
|
||||
).toThrowError(/at most 8 group-by/);
|
||||
});
|
||||
|
||||
it("LIMIT is clamped to the governance ceiling, and never below 1", () => {
|
||||
const big = compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "m", aggregate: "sum", limit: 10_000_000 },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
500,
|
||||
);
|
||||
expect(big.sql).toContain("LIMIT 500");
|
||||
const zero = compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "m", aggregate: "sum", limit: 0 },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
500,
|
||||
);
|
||||
expect(zero.sql).toContain("LIMIT 1");
|
||||
});
|
||||
|
||||
it("the projection compiler forces the tenant and parameterizes entity keys", () => {
|
||||
const compiled = compileProjectionQuery(
|
||||
{ tenant_id: "t_alpha", dataset_id: "ds_x", entity_keys: ["a'; DROP", "b"] },
|
||||
"opt_analytics",
|
||||
100,
|
||||
);
|
||||
expect(compiled.sql).toMatch(/WHERE tenant_id = \{tenant:String\}/);
|
||||
expect(compiled.sql).not.toContain("DROP");
|
||||
expect(compiled.params.param_entityKeys).toBe(JSON.stringify(["a'; DROP", "b"]));
|
||||
});
|
||||
|
||||
it("assertSafeIdentifier accepts real identifiers and rejects everything else", () => {
|
||||
for (const good of ["severity", "vehicle_make", "_x", "a1_b2"]) {
|
||||
expect(assertSafeIdentifier(good, "test")).toBe(good);
|
||||
}
|
||||
for (const bad of ["1abc", "a-b", "a b", "a;b", "", "a".repeat(64)]) {
|
||||
expect(() => assertSafeIdentifier(bad, "test")).toThrowError(/invalid test identifier/);
|
||||
}
|
||||
});
|
||||
|
||||
it("restrictedTenantUserDdl refuses a tenant id or password it cannot safely embed", () => {
|
||||
expect(() =>
|
||||
restrictedTenantUserDdl({
|
||||
db: "opt_analytics",
|
||||
tenantId: "t_alpha' OR '1'='1",
|
||||
userName: "opt_t_alpha",
|
||||
password: "pw",
|
||||
tables: ["opt_analytics_event"],
|
||||
}),
|
||||
).toThrowError(/not policy-safe/);
|
||||
|
||||
expect(() =>
|
||||
restrictedTenantUserDdl({
|
||||
db: "opt_analytics",
|
||||
tenantId: "t_alpha",
|
||||
userName: "opt_t_alpha",
|
||||
password: "pw' OR '1'='1",
|
||||
tables: ["opt_analytics_event"],
|
||||
}),
|
||||
).toThrowError(/cannot be embedded/);
|
||||
|
||||
const stmts = restrictedTenantUserDdl({
|
||||
db: "opt_analytics",
|
||||
tenantId: "t_alpha",
|
||||
userName: "opt_t_alpha",
|
||||
password: "safe_pw_123",
|
||||
tables: ["opt_analytics_event"],
|
||||
});
|
||||
expect(stmts.join(" ")).toContain("CREATE ROW POLICY IF NOT EXISTS");
|
||||
expect(stmts.join(" ")).toContain("USING tenant_id = 't_alpha'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* BI-P012 — CLICKHOUSE RESOURCE GOVERNANCE, the part provable without an
|
||||
* engine. The "intentional query bomb is terminated" half of the DoD needs a
|
||||
* real server and lives in
|
||||
* tests/integration/analytics-resource-governance.integration.test.ts.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyQuota,
|
||||
BUDGET_TIERS,
|
||||
clickhouseSettings,
|
||||
ConcurrencyGate,
|
||||
concurrencyProfileSql,
|
||||
DEFAULT_BUDGETS,
|
||||
REQUIRED_GOVERNANCE_SETTINGS,
|
||||
} from "@/src/analytics/engine/resource-governance";
|
||||
|
||||
describe("BI-P012 resource governance", () => {
|
||||
it("every setting the program names has a value somewhere in the enforcement path", () => {
|
||||
const settings = clickhouseSettings(DEFAULT_BUDGETS.INTERACTIVE);
|
||||
const profile = concurrencyProfileSql("opt_t_alpha", DEFAULT_BUDGETS.INTERACTIVE).join(" ");
|
||||
for (const name of REQUIRED_GOVERNANCE_SETTINGS) {
|
||||
const present = name in settings || profile.includes(name) || name === "max_concurrent_queries";
|
||||
expect(present, `${name} is not enforced anywhere`).toBe(true);
|
||||
}
|
||||
// The five per-statement ones must be on the STATEMENT, not only in a
|
||||
// profile a future operator might forget to attach.
|
||||
for (const name of [
|
||||
"max_execution_time",
|
||||
"max_result_rows",
|
||||
"max_rows_to_read",
|
||||
"max_bytes_to_read",
|
||||
"max_memory_usage",
|
||||
]) {
|
||||
expect(settings[name], `${name} missing from per-statement settings`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("readonly=1 rides on every SELECT so the query text cannot re-set the limits", () => {
|
||||
for (const tier of BUDGET_TIERS) {
|
||||
expect(clickhouseSettings(DEFAULT_BUDGETS[tier]).readonly).toBe("1");
|
||||
}
|
||||
});
|
||||
|
||||
it("overflow modes are THROW, not BREAK — a partial result must never look complete", () => {
|
||||
const s = clickhouseSettings(DEFAULT_BUDGETS.INTERACTIVE);
|
||||
expect(s.result_overflow_mode).toBe("throw");
|
||||
expect(s.read_overflow_mode).toBe("throw");
|
||||
expect(s.timeout_overflow_mode).toBe("throw");
|
||||
});
|
||||
|
||||
it("a CL0UD quota can only TIGHTEN a tier, never loosen it", () => {
|
||||
const base = DEFAULT_BUDGETS.INTERACTIVE;
|
||||
const loosened = applyQuota(base, {
|
||||
maxExecutionTimeSec: 100000,
|
||||
maxResultRows: 10_000_000,
|
||||
maxRowsToRead: Number.MAX_SAFE_INTEGER,
|
||||
maxBytesToRead: Number.MAX_SAFE_INTEGER,
|
||||
maxMemoryUsageBytes: Number.MAX_SAFE_INTEGER,
|
||||
maxConcurrentQueries: 1000,
|
||||
});
|
||||
expect(loosened).toEqual(base);
|
||||
|
||||
const tightened = applyQuota(base, { maxResultRows: 100, maxConcurrentQueries: 1 });
|
||||
expect(tightened.maxResultRows).toBe(100);
|
||||
expect(tightened.maxConcurrentQueries).toBe(1);
|
||||
expect(tightened.maxExecutionTimeSec).toBe(base.maxExecutionTimeSec);
|
||||
});
|
||||
|
||||
it("a nonsense quota (zero, negative, NaN) falls back to the tier rather than to no limit", () => {
|
||||
const base = DEFAULT_BUDGETS.INTERACTIVE;
|
||||
expect(applyQuota(base, { maxResultRows: 0 }).maxResultRows).toBe(base.maxResultRows);
|
||||
expect(applyQuota(base, { maxResultRows: -5 }).maxResultRows).toBe(base.maxResultRows);
|
||||
expect(applyQuota(base, { maxResultRows: Number.NaN }).maxResultRows).toBe(base.maxResultRows);
|
||||
expect(applyQuota(base, undefined)).toEqual(base);
|
||||
});
|
||||
|
||||
it("tiers get progressively more generous, and INTERACTIVE is the mean default", () => {
|
||||
expect(DEFAULT_BUDGETS.INTERACTIVE.maxExecutionTimeSec).toBeLessThan(DEFAULT_BUDGETS.ANALYTICAL.maxExecutionTimeSec);
|
||||
expect(DEFAULT_BUDGETS.ANALYTICAL.maxExecutionTimeSec).toBeLessThan(DEFAULT_BUDGETS.BATCH.maxExecutionTimeSec);
|
||||
expect(DEFAULT_BUDGETS.INTERACTIVE.maxRowsToRead).toBeLessThan(DEFAULT_BUDGETS.ANALYTICAL.maxRowsToRead);
|
||||
});
|
||||
|
||||
it("the concurrency gate throttles ONE tenant without touching another", () => {
|
||||
const gate = new ConcurrencyGate((t) => (t === "t_noisy" ? 2 : 5));
|
||||
const a = gate.acquire("t_noisy");
|
||||
const b = gate.acquire("t_noisy");
|
||||
expect(gate.inFlightFor("t_noisy")).toBe(2);
|
||||
expect(() => gate.acquire("t_noisy")).toThrowError(/queries in flight/);
|
||||
|
||||
// The quiet tenant is unaffected — BI-P012's "without degrading unrelated
|
||||
// tenant" in the smallest testable form.
|
||||
const quiet = gate.acquire("t_quiet");
|
||||
expect(gate.inFlightFor("t_quiet")).toBe(1);
|
||||
|
||||
a();
|
||||
expect(gate.inFlightFor("t_noisy")).toBe(1);
|
||||
expect(() => gate.acquire("t_noisy")).not.toThrow();
|
||||
b();
|
||||
quiet();
|
||||
});
|
||||
|
||||
it("releasing twice does not free a slot that was never taken", () => {
|
||||
const gate = new ConcurrencyGate(() => 1);
|
||||
const release = gate.acquire("t_x");
|
||||
release();
|
||||
release();
|
||||
expect(gate.inFlightFor("t_x")).toBe(0);
|
||||
expect(gate.totalInFlight()).toBe(0);
|
||||
});
|
||||
|
||||
it("the settings profile SQL carries max_concurrent_queries' server-side half", () => {
|
||||
const stmts = concurrencyProfileSql("opt_t_alpha", DEFAULT_BUDGETS.INTERACTIVE);
|
||||
expect(stmts.join(" ")).toContain("CREATE SETTINGS PROFILE IF NOT EXISTS opt_t_alpha");
|
||||
expect(stmts.join(" ")).toContain("readonly = 1");
|
||||
expect(stmts.join(" ")).toContain("CREATE QUOTA IF NOT EXISTS opt_t_alpha_quota");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* BI-P009's non-negotiable line: "Do not regress the existing forced tenant
|
||||
* predicate."
|
||||
*
|
||||
* This suite is written to FAIL IF THE FORCING IS REMOVED, which is a
|
||||
* different thing from testing that it currently works. Concretely, each test
|
||||
* below would go red under a specific, plausible regression:
|
||||
*
|
||||
* * someone makes `tenant_id` optional in the metric DSL -> "refuses a
|
||||
* blank tenant" fails;
|
||||
* * someone "simplifies" the WHERE builder and drops the tenant condition
|
||||
* -> "every compiled statement contains the predicate" fails on a missing
|
||||
* substring;
|
||||
* * someone starts splicing the tenant value into SQL text instead of
|
||||
* binding it -> "the tenant VALUE never appears in the SQL text" fails;
|
||||
* * someone lets a caller pass a tenant through the request body rather
|
||||
* than the verified identity -> "identity wins over any request field"
|
||||
* fails.
|
||||
*
|
||||
* It covers BOTH compilers: the PRESERVED `ClickHouseAnalytics.queryMetric`
|
||||
* (src/analytics/clickhouse-analytics.ts, untouched by BI-P009) and the new
|
||||
* `compileMetricQuery` — because "preserved" means the old guarantee has to
|
||||
* keep holding, not merely that the file still exists.
|
||||
*
|
||||
* The ClickHouseAnalytics half asserts against the URL the adapter actually
|
||||
* builds, captured with a stub `fetch`. That is deliberate: a mock ENGINE
|
||||
* could be made to return anything, but the SQL text on the wire is the thing
|
||||
* the real server would receive, so asserting on it cannot be faked by the
|
||||
* test double.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { ClickHouseAnalytics } from "@/src/analytics/clickhouse-analytics";
|
||||
import {
|
||||
compileMetricQuery,
|
||||
compileProjectionQuery,
|
||||
dimensionCatalogFrom,
|
||||
FORCED_TENANT_PREDICATE,
|
||||
} from "@/src/analytics/engine/query-compiler";
|
||||
|
||||
const catalog = dimensionCatalogFrom({ severity: "severity", make: "vehicle_make" });
|
||||
|
||||
/** Captures every URL the adapter fetches, returning an empty JSON result. */
|
||||
function captureFetch(): { urls: string[]; restore: () => void } {
|
||||
const urls: string[] = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
urls.push(String(input));
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
return { urls, restore: () => (globalThis.fetch = original) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("BI-P009 regression guard: the forced tenant predicate", () => {
|
||||
it("PRESERVED ClickHouseAnalytics still emits tenant_id = {tenant:String} on the wire", async () => {
|
||||
const cap = captureFetch();
|
||||
try {
|
||||
const analytics = new ClickHouseAnalytics({ baseUrl: "http://ch.invalid:8123", database: "opt_test" });
|
||||
await analytics.queryMetric({ tenant_id: "t_alpha", metric: "rows", aggregate: "sum" });
|
||||
expect(cap.urls).toHaveLength(1);
|
||||
// URLSearchParams decodes `+` as a space, which raw decodeURIComponent
|
||||
// does not — read the statement the way the server will.
|
||||
const query = new URL(cap.urls[0]).searchParams.get("query") ?? "";
|
||||
expect(query).toContain("tenant_id = {tenant:String}");
|
||||
expect(query).toMatch(/WHERE tenant_id = \{tenant:String\}/);
|
||||
// The VALUE travels as a bound parameter, never inside the statement.
|
||||
expect(query).not.toContain("t_alpha");
|
||||
expect(new URL(cap.urls[0]).searchParams.get("param_tenant")).toBe("t_alpha");
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("PRESERVED ClickHouseAnalytics still refuses a blank tenant_id", async () => {
|
||||
const analytics = new ClickHouseAnalytics({ baseUrl: "http://ch.invalid:8123" });
|
||||
await expect(analytics.queryMetric({ tenant_id: " ", metric: "x", aggregate: "sum" })).rejects.toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
});
|
||||
});
|
||||
|
||||
it("PRESERVED ClickHouseAnalytics still refuses a hostile group_by identifier", async () => {
|
||||
const analytics = new ClickHouseAnalytics({ baseUrl: "http://ch.invalid:8123" });
|
||||
await expect(
|
||||
analytics.queryMetric({
|
||||
tenant_id: "t_alpha",
|
||||
metric: "x",
|
||||
aggregate: "sum",
|
||||
group_by: "sev'; DROP TABLE opt_metric_events; --",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INVALID_REQUEST" });
|
||||
});
|
||||
|
||||
it("every statement the NEW compiler produces contains the forced predicate", () => {
|
||||
const shapes = [
|
||||
{ metric: "m", aggregate: "sum" as const },
|
||||
{ metric: "m", aggregate: "count" as const, group_by: ["severity"] },
|
||||
{ metric: "m", aggregate: "avg" as const, filters: { make: "volvo" }, dataset_id: "ds_1" },
|
||||
{ metric: "m", aggregate: "max" as const, from_time: "2026-01-01", to_time: "2026-02-01" },
|
||||
{ metric: "m", aggregate: "count_distinct" as const, group_by: ["severity", "make"], limit: 5 },
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const compiled = compileMetricQuery({ tenant_id: "t_alpha", ...shape }, "opt_analytics", catalog, 1000);
|
||||
expect(compiled.sql).toContain(FORCED_TENANT_PREDICATE);
|
||||
// The predicate must be in the WHERE, not in a comment or an alias.
|
||||
expect(compiled.sql).toMatch(/WHERE tenant_id = \{tenant:String\}/);
|
||||
expect(compiled.params.param_tenant).toBe("t_alpha");
|
||||
expect(compiled.sql).not.toContain("t_alpha");
|
||||
}
|
||||
const projection = compileProjectionQuery(
|
||||
{ tenant_id: "t_alpha", dataset_id: "ds_1", entity_keys: ["a", "b"] },
|
||||
"opt_analytics",
|
||||
1000,
|
||||
);
|
||||
expect(projection.sql).toMatch(/WHERE tenant_id = \{tenant:String\}/);
|
||||
expect(projection.sql).not.toContain("t_alpha");
|
||||
});
|
||||
|
||||
it("the NEW compiler fails closed on a blank/absent tenant instead of emitting an unfiltered query", () => {
|
||||
for (const tenant of ["", " "]) {
|
||||
expect(() =>
|
||||
compileMetricQuery({ tenant_id: tenant, metric: "m", aggregate: "sum" }, "opt_analytics", catalog, 1000),
|
||||
).toThrowError(/tenant_id is required/);
|
||||
}
|
||||
// The undefined case, which TypeScript would normally forbid, is what a
|
||||
// JS caller or a loosely-typed HTTP boundary can actually produce.
|
||||
expect(() =>
|
||||
compileMetricQuery(
|
||||
{ tenant_id: undefined as unknown as string, metric: "m", aggregate: "sum" },
|
||||
"opt_analytics",
|
||||
catalog,
|
||||
1000,
|
||||
),
|
||||
).toThrowError(/tenant_id is required/);
|
||||
});
|
||||
|
||||
it("the constant itself is the predicate, so deleting the forcing cannot pass silently", () => {
|
||||
// If someone redefines FORCED_TENANT_PREDICATE to something harmless
|
||||
// (e.g. "1 = 1") to make the substring assertions above pass, this fails.
|
||||
expect(FORCED_TENANT_PREDICATE).toBe("tenant_id = {tenant:String}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* BI-P009 (canonical OLAP provider) + BI-P010 (production schema), against a
|
||||
* LIVE ClickHouse.
|
||||
*
|
||||
* Every one of the eight `SiaxAnalyticsEngine` capabilities is exercised for
|
||||
* real here. A capability that only has a unit test with a stubbed fetch is a
|
||||
* capability whose SQL has never been parsed by ClickHouse — `EXPLAIN
|
||||
* ESTIMATE`, `KILL QUERY` and `OPTIMIZE ... PARTITION` in particular are
|
||||
* syntax the server either accepts or does not, and no mock can tell you
|
||||
* which.
|
||||
*
|
||||
* Gated on CLICKHOUSE_URL; skips honestly when unset. CI's `integration` job
|
||||
* supplies it and hard-fails on a skipped suite.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { ClickHouseAnalyticsEngine } from "@/src/analytics/engine/clickhouse-engine";
|
||||
import { ANALYTICS_MATERIALIZED_VIEWS, ANALYTICS_TABLES } from "@/src/analytics/engine/schema";
|
||||
import type { AnalyticsIdentity } from "@/src/analytics/engine/siax-analytics-engine";
|
||||
|
||||
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
||||
const CLICKHOUSE_AUTH = process.env.CLICKHOUSE_AUTH;
|
||||
const run = CLICKHOUSE_URL ? describe : describe.skip;
|
||||
|
||||
const DB = `opt_engine_${Date.now().toString(36)}`;
|
||||
const ID: AnalyticsIdentity = { tenant_id: "t_alpha", project_id: "p_main", principal_id: "usr_alpha" };
|
||||
|
||||
let engine: ClickHouseAnalyticsEngine;
|
||||
|
||||
async function raw(sql: string, body?: string): Promise<string> {
|
||||
const url = new URL(CLICKHOUSE_URL ?? "http://skipped.invalid");
|
||||
url.searchParams.set("query", sql);
|
||||
const headers: Record<string, string> = {};
|
||||
if (CLICKHOUSE_AUTH) headers["Authorization"] = `Basic ${Buffer.from(CLICKHOUSE_AUTH).toString("base64")}`;
|
||||
const res = await fetch(url.toString(), { method: "POST", headers, body: body ?? "" });
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`CH ${res.status}: ${text.slice(0, 300)}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
run("BI-P009/BI-P010 ClickHouse provider against a LIVE server", () => {
|
||||
beforeAll(async () => {
|
||||
engine = new ClickHouseAnalyticsEngine({
|
||||
baseUrl: CLICKHOUSE_URL!,
|
||||
basicAuth: CLICKHOUSE_AUTH,
|
||||
database: DB,
|
||||
dimensionCatalogs: { t_alpha: { severity: "severity", make: "vehicle_make" } },
|
||||
});
|
||||
await engine.ensureSchema();
|
||||
await engine.ensureSchema(); // idempotent, as the DDL claims
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (CLICKHOUSE_URL) await raw(`DROP DATABASE IF EXISTS ${DB}`);
|
||||
}, 60_000);
|
||||
|
||||
// ── capability 1: health ────────────────────────────────────────────────
|
||||
|
||||
it("health() reports the live server's version", async () => {
|
||||
const h = await engine.health();
|
||||
expect(h.healthy).toBe(true);
|
||||
expect(h.provider).toBe("clickhouse");
|
||||
expect(h.version).toMatch(/^\d+\.\d+/);
|
||||
});
|
||||
|
||||
// ── BI-P010: the schema is what it claims to be ─────────────────────────
|
||||
|
||||
it("BI-P010: four purpose-built tables exist, NOT one giant table", async () => {
|
||||
const listed = await raw(
|
||||
`SELECT name FROM system.tables WHERE database = '${DB}' AND engine != 'MaterializedView' ORDER BY name FORMAT TSV`,
|
||||
);
|
||||
const names = listed.trim().split("\n").filter(Boolean);
|
||||
// The three this module owns, PLUS the preserved opt_metric_events.
|
||||
for (const t of ANALYTICS_TABLES) expect(names).toContain(t);
|
||||
expect(names).toContain("opt_metric_events");
|
||||
expect(names.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("BI-P010: the tables use different engines and different sort keys — the split is real", async () => {
|
||||
const meta = await raw(
|
||||
`SELECT name, engine, sorting_key FROM system.tables WHERE database = '${DB}' ORDER BY name FORMAT JSON`,
|
||||
);
|
||||
const rows = (JSON.parse(meta) as { data: { name: string; engine: string; sorting_key: string }[] }).data;
|
||||
const by = Object.fromEntries(rows.map((r) => [r.name, r]));
|
||||
expect(by["opt_analytics_event"].engine).toBe("MergeTree");
|
||||
expect(by["opt_metric_daily"].engine).toBe("AggregatingMergeTree");
|
||||
expect(by["opt_dataset_projection"].engine).toBe("ReplacingMergeTree");
|
||||
// Different ORDER BY keys — the reason one table cannot serve both jobs.
|
||||
expect(by["opt_analytics_event"].sorting_key).not.toBe(by["opt_dataset_projection"].sorting_key);
|
||||
// Every one still leads with tenant_id.
|
||||
for (const t of ANALYTICS_TABLES) expect(by[t].sorting_key.startsWith("tenant_id")).toBe(true);
|
||||
});
|
||||
|
||||
it("BI-P010: TTL and LowCardinality are actually applied, not just written in a comment", async () => {
|
||||
const ddl = await raw(`SHOW CREATE TABLE ${DB}.opt_analytics_event FORMAT TSVRaw`);
|
||||
expect(ddl).toContain("TTL");
|
||||
expect(ddl).toContain("LowCardinality(String)");
|
||||
expect(ddl).toContain("PARTITION BY toYYYYMM(event_time)");
|
||||
const rollup = await raw(`SHOW CREATE TABLE ${DB}.opt_metric_daily FORMAT TSVRaw`);
|
||||
expect(rollup).toContain("AggregateFunction(sum, Float64)");
|
||||
});
|
||||
|
||||
it("BI-P010: the materialized view actually populates the rollup", async () => {
|
||||
const views = await raw(
|
||||
`SELECT name FROM system.tables WHERE database='${DB}' AND engine='MaterializedView' FORMAT TSV`,
|
||||
);
|
||||
for (const v of ANALYTICS_MATERIALIZED_VIEWS) expect(views).toContain(v);
|
||||
|
||||
await engine.ingestProjection(ID, { dataset_id: "warmup", rows: [{ entity_key: "w1" }] });
|
||||
const rows = Array.from({ length: 20 }, (_, i) =>
|
||||
JSON.stringify({
|
||||
tenant_id: "t_alpha",
|
||||
project_id: "p_main",
|
||||
dataset_id: "ds_mv",
|
||||
metric: "mv_metric",
|
||||
event_id: `mv${i}`,
|
||||
event_time: "2026-09-03 12:00:00.000",
|
||||
value: 2,
|
||||
dim_names: [],
|
||||
dim_values: [],
|
||||
}),
|
||||
).join("\n");
|
||||
await raw(`INSERT INTO ${DB}.opt_analytics_event FORMAT JSONEachRow`, rows);
|
||||
|
||||
const rolled = await raw(
|
||||
`SELECT sumMerge(sum_state) AS s, countMerge(count_state) AS c FROM ${DB}.opt_metric_daily ` +
|
||||
`WHERE tenant_id = 't_alpha' AND metric = 'mv_metric' FORMAT JSON`,
|
||||
);
|
||||
const d = (JSON.parse(rolled) as { data: { s: number | string; c: number | string }[] }).data[0];
|
||||
expect(Number(d.s)).toBe(40); // 20 rows x 2
|
||||
expect(Number(d.c)).toBe(20);
|
||||
});
|
||||
|
||||
// ── capability 2: executeMetric (already covered by the isolation suite) ─
|
||||
|
||||
it("executeMetric returns grouped rows keyed by the CALLER-FACING dimension name", async () => {
|
||||
const rows = ["warning", "critical", "critical"].map((sev, i) =>
|
||||
JSON.stringify({
|
||||
tenant_id: "t_alpha",
|
||||
project_id: "p_main",
|
||||
dataset_id: "ds_g",
|
||||
metric: "grouped",
|
||||
event_id: `g${i}`,
|
||||
event_time: "2026-09-04 09:00:00.000",
|
||||
value: 5,
|
||||
dim_names: ["severity"],
|
||||
dim_values: [sev],
|
||||
}),
|
||||
);
|
||||
await raw(`INSERT INTO ${DB}.opt_analytics_event FORMAT JSONEachRow`, rows.join("\n"));
|
||||
const result = await engine.executeMetric(ID, {
|
||||
metric: "grouped",
|
||||
aggregate: "sum",
|
||||
group_by: ["severity"],
|
||||
});
|
||||
const critical = result.rows.find((r) => r.groups?.severity === "critical");
|
||||
expect(critical?.value).toBe(10);
|
||||
expect(result.stats.result_rows).toBe(2);
|
||||
expect(result.query_id.startsWith("opt_")).toBe(true);
|
||||
});
|
||||
|
||||
// ── capability 4: estimateQuery ─────────────────────────────────────────
|
||||
|
||||
it("estimateQuery gets a real EXPLAIN ESTIMATE from the server and compares it to the budget", async () => {
|
||||
const est = await engine.estimateQuery(ID, { metric: "grouped", aggregate: "sum" });
|
||||
expect(est.estimated_rows_to_read).toBeGreaterThan(0);
|
||||
expect(est.within_budget).toBe(true);
|
||||
expect(est.budget.maxRowsToRead).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("estimateQuery REFUSES a query whose estimate exceeds a tightened quota", async () => {
|
||||
const tiny: AnalyticsIdentity = { ...ID, quota: { maxRowsToRead: 1 } };
|
||||
const est = await engine.estimateQuery(tiny, { metric: "grouped", aggregate: "sum" });
|
||||
expect(est.within_budget).toBe(false);
|
||||
expect(est.reason).toMatch(/over budget/);
|
||||
});
|
||||
|
||||
// ── capability 6: explain ───────────────────────────────────────────────
|
||||
|
||||
it("explain() proves the forced predicate reaches the PRIMARY KEY, pruning parts", async () => {
|
||||
const ex = await engine.explain(ID, { metric: "grouped", aggregate: "sum", group_by: ["severity"] });
|
||||
expect(ex.forced_tenant_predicate).toBe("tenant_id = {tenant:String}");
|
||||
expect(ex.table).toBe("opt_analytics_event");
|
||||
// This is the payoff of BI-P010's tenant-first ORDER BY: the server
|
||||
// reports tenant_id as a PrimaryKey key with a condition on it, i.e. the
|
||||
// isolation predicate is an index range scan, not a post-read filter.
|
||||
expect(ex.plan).toContain("ReadFromMergeTree");
|
||||
expect(ex.plan).toContain("PrimaryKey");
|
||||
expect(ex.plan).toMatch(/Keys:[\s\S]*tenant_id/);
|
||||
expect(ex.plan).toMatch(/tenant_id in \['t_alpha', 't_alpha'\]/);
|
||||
// The plan describes only the CALLER's own tenant; no other tenant id
|
||||
// may appear in a plan handed back to them.
|
||||
expect(ex.plan).not.toContain("t_beta");
|
||||
});
|
||||
|
||||
// ── capability 5: cancelQuery ───────────────────────────────────────────
|
||||
|
||||
it("cancelQuery issues a real KILL QUERY the server accepts", async () => {
|
||||
// Nothing is running under this id, so the honest outcome is "the KILL
|
||||
// statement was valid and matched nothing" -> true, with no exception.
|
||||
const ok = await engine.cancelQuery(ID, `${(await engine.executeMetric(ID, { metric: "grouped", aggregate: "count" })).query_id}`);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
// ── capabilities 7 + 8: ingestProjection / refreshProjection ────────────
|
||||
|
||||
it("ingestProjection writes the SERVING tier with the identity's tenant, ignoring any row-level claim", async () => {
|
||||
const res = await engine.ingestProjection(ID, {
|
||||
dataset_id: "ds_vehicles",
|
||||
rows: [
|
||||
{ entity_key: "VIN001", attributes: { make: "volvo" }, measures: { price: 189000 } },
|
||||
{ entity_key: "VIN002", attributes: { make: "saab" }, measures: { price: 42000 } },
|
||||
],
|
||||
});
|
||||
expect(res.ingested).toBe(2);
|
||||
|
||||
const stored = await raw(
|
||||
`SELECT uniqExact(tenant_id) AS t FROM ${DB}.opt_dataset_projection WHERE dataset_id = 'ds_vehicles' FORMAT JSON`,
|
||||
);
|
||||
expect(Number((JSON.parse(stored) as { data: { t: number | string }[] }).data[0].t)).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshProjection makes a re-ingest idempotent (ReplacingMergeTree + OPTIMIZE FINAL)", async () => {
|
||||
// Re-ingest the SAME entity keys at a higher version.
|
||||
await engine.ingestProjection(ID, {
|
||||
dataset_id: "ds_vehicles",
|
||||
rows: [
|
||||
{ entity_key: "VIN001", version: 9_999_999_999_999, measures: { price: 175000 } },
|
||||
{ entity_key: "VIN002", version: 9_999_999_999_999, measures: { price: 41000 } },
|
||||
],
|
||||
});
|
||||
const refreshed = await engine.refreshProjection(ID, "ds_vehicles");
|
||||
expect(refreshed.row_count).toBe(2); // 2 entities, not 4 rows
|
||||
expect(refreshed.dataset_id).toBe("ds_vehicles");
|
||||
|
||||
const winner = await raw(
|
||||
`SELECT measures['price'] AS p FROM ${DB}.opt_dataset_projection FINAL ` +
|
||||
`WHERE tenant_id='t_alpha' AND dataset_id='ds_vehicles' AND entity_key='VIN001' FORMAT JSON`,
|
||||
);
|
||||
expect(Number((JSON.parse(winner) as { data: { p: number | string }[] }).data[0].p)).toBe(175000);
|
||||
});
|
||||
|
||||
// ── the PRESERVED path still works through the engine ───────────────────
|
||||
|
||||
it("the PRESERVED ClickHouseAnalytics path still ingests and answers through the engine", async () => {
|
||||
await engine.legacyAnalytics.ingest([
|
||||
{ tenant_id: "t_alpha", dataset_id: "ds_legacy", metric: "legacy_metric", value: 7 },
|
||||
{ tenant_id: "t_beta", dataset_id: "ds_legacy", metric: "legacy_metric", value: 700 },
|
||||
]);
|
||||
const res = await engine.executeLegacyMetric(ID, { metric: "legacy_metric", aggregate: "sum" });
|
||||
expect(res.rows[0].value).toBe(7); // NOT 707
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* BI-P012 DoD, against a LIVE ClickHouse:
|
||||
*
|
||||
* "Intentional query bomb is terminated without degrading unrelated tenant
|
||||
* beyond declared SLO."
|
||||
*
|
||||
* Two halves, and the second is the one that is usually skipped:
|
||||
*
|
||||
* 1. TERMINATED — a deliberately enormous query is refused by the server,
|
||||
* with the ClickHouse limit that stopped it identified by code. Each
|
||||
* limit BI-P012 names is fired individually, so "some limit stopped it"
|
||||
* is never mistaken for "the limit I claimed stopped it".
|
||||
* 2. UNRELATED TENANT NOT DEGRADED — a second tenant's interactive query is
|
||||
* timed WHILE the bomb is in flight and compared against a declared SLO,
|
||||
* not merely observed to "still work".
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { ClickHouseAnalyticsEngine } from "@/src/analytics/engine/clickhouse-engine";
|
||||
import { DEFAULT_BUDGETS } from "@/src/analytics/engine/resource-governance";
|
||||
import type { AnalyticsIdentity } from "@/src/analytics/engine/siax-analytics-engine";
|
||||
|
||||
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
||||
const CLICKHOUSE_AUTH = process.env.CLICKHOUSE_AUTH;
|
||||
const run = CLICKHOUSE_URL ? describe : describe.skip;
|
||||
|
||||
const DB = `opt_gov_${Date.now().toString(36)}`;
|
||||
|
||||
/**
|
||||
* The SLO this test asserts against. Declared as a constant so the claim is
|
||||
* falsifiable rather than "it still seemed to work".
|
||||
*
|
||||
* 5000 ms is deliberately generous for a 500-row lookup (it measures ~30 ms
|
||||
* on an idle server). It is set for the SHARED CI runner, not for a quiet
|
||||
* laptop: a tenant that had actually been starved by the bomb would take
|
||||
* seconds or fail outright, so the assertion still discriminates, while a
|
||||
* merely busy runner does not turn this gate into a flake.
|
||||
*/
|
||||
const UNRELATED_TENANT_SLO_MS = 5000;
|
||||
|
||||
const VICTIM: AnalyticsIdentity = { tenant_id: "t_victim", project_id: "p_main", principal_id: "usr_v" };
|
||||
|
||||
let engine: ClickHouseAnalyticsEngine;
|
||||
|
||||
/** Run a statement with explicit settings and report the ClickHouse code. */
|
||||
async function chRun(
|
||||
sql: string,
|
||||
settings: Record<string, string> = {},
|
||||
body?: string,
|
||||
): Promise<{ ok: boolean; code?: string; text: string; ms: number }> {
|
||||
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 headers: Record<string, string> = {};
|
||||
if (CLICKHOUSE_AUTH) headers["Authorization"] = `Basic ${Buffer.from(CLICKHOUSE_AUTH).toString("base64")}`;
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(url.toString(), { method: "POST", headers, body: body ?? "" });
|
||||
const text = await res.text();
|
||||
return { ok: res.ok, code: /Code: (\d+)/.exec(text)?.[1], text, ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
run("BI-P012 resource governance against a LIVE ClickHouse", () => {
|
||||
beforeAll(async () => {
|
||||
engine = new ClickHouseAnalyticsEngine({
|
||||
baseUrl: CLICKHOUSE_URL!,
|
||||
basicAuth: CLICKHOUSE_AUTH,
|
||||
database: DB,
|
||||
dimensionCatalogs: { t_victim: { severity: "severity" } },
|
||||
});
|
||||
await engine.ensureSchema();
|
||||
// A small, real dataset for the unrelated tenant.
|
||||
const rows = Array.from({ length: 500 }, (_, i) =>
|
||||
JSON.stringify({
|
||||
tenant_id: "t_victim",
|
||||
project_id: "p_main",
|
||||
dataset_id: "ds_v",
|
||||
metric: "victim_metric",
|
||||
event_id: `v${i}`,
|
||||
event_time: "2026-09-05 08:00:00.000",
|
||||
value: 1,
|
||||
dim_names: ["severity"],
|
||||
dim_values: ["warning"],
|
||||
}),
|
||||
).join("\n");
|
||||
const ins = await chRun(`INSERT INTO ${DB}.opt_analytics_event FORMAT JSONEachRow`, {}, rows);
|
||||
expect(ins.ok, ins.text.slice(0, 200)).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (CLICKHOUSE_URL) await chRun(`DROP DATABASE IF EXISTS ${DB}`);
|
||||
}, 60_000);
|
||||
|
||||
// ── each named limit, fired individually ────────────────────────────────
|
||||
|
||||
it("max_rows_to_read terminates a read bomb (CH code 158 TOO_MANY_ROWS)", async () => {
|
||||
const r = await chRun("SELECT count() FROM numbers(1000000000) FORMAT JSON", {
|
||||
readonly: "1",
|
||||
max_rows_to_read: "1000",
|
||||
read_overflow_mode: "throw",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe("158");
|
||||
});
|
||||
|
||||
it("max_bytes_to_read terminates a scan bomb (CH code 307 TOO_MANY_BYTES)", async () => {
|
||||
const r = await chRun("SELECT sum(number) FROM numbers(1000000000) FORMAT JSON", {
|
||||
readonly: "1",
|
||||
max_bytes_to_read: "1024",
|
||||
read_overflow_mode: "throw",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe("307");
|
||||
});
|
||||
|
||||
it("max_execution_time terminates a slow bomb (CH code 159 TIMEOUT_EXCEEDED)", async () => {
|
||||
const r = await chRun("SELECT count() FROM numbers(50000000000) FORMAT JSON", {
|
||||
readonly: "1",
|
||||
max_execution_time: "1",
|
||||
timeout_overflow_mode: "throw",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe("159");
|
||||
expect(r.ms).toBeLessThan(20_000);
|
||||
});
|
||||
|
||||
it("max_result_rows terminates a result bomb and does NOT return a partial result", async () => {
|
||||
const r = await chRun("SELECT number FROM numbers(100000) FORMAT JSON", {
|
||||
readonly: "1",
|
||||
max_result_rows: "10",
|
||||
result_overflow_mode: "throw",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe("396"); // TOO_MANY_ROWS_OR_BYTES
|
||||
// The dangerous alternative: result_overflow_mode=break returns 10 rows
|
||||
// with HTTP 200 and no indication of truncation.
|
||||
const broken = await chRun("SELECT number FROM numbers(100000) FORMAT JSON", {
|
||||
readonly: "1",
|
||||
max_result_rows: "10",
|
||||
result_overflow_mode: "break",
|
||||
});
|
||||
// MEASURED on ClickHouse 24.8: break mode answers HTTP 200 with 65409 of
|
||||
// 100000 rows (it stops at a block boundary, not at the stated 10) and
|
||||
// nothing in the payload says it is short. That is why every SELECT in
|
||||
// this engine ships result_overflow_mode=throw.
|
||||
expect(broken.ok).toBe(true);
|
||||
const brokenRows = (JSON.parse(broken.text) as { data: unknown[] }).data.length;
|
||||
expect(brokenRows).toBeGreaterThan(10);
|
||||
expect(brokenRows).toBeLessThan(100_000);
|
||||
});
|
||||
|
||||
it("max_memory_usage terminates a memory bomb (CH code 241 MEMORY_LIMIT_EXCEEDED)", async () => {
|
||||
const r = await chRun(
|
||||
"SELECT uniqExact(toString(number)) FROM numbers(50000000) FORMAT JSON",
|
||||
{ readonly: "1", max_memory_usage: "1000000", max_execution_time: "30" },
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe("241");
|
||||
});
|
||||
|
||||
it("readonly=1 means the query text cannot re-raise its own limits", async () => {
|
||||
const r = await chRun(
|
||||
"SELECT count() FROM numbers(1000000000) SETTINGS max_rows_to_read = 10000000000 FORMAT JSON",
|
||||
{ readonly: "1", max_rows_to_read: "1000", read_overflow_mode: "throw" },
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
// 164 = READONLY (changing settings is forbidden); 158 = the limit still
|
||||
// applied. Either proves the caller did not win — but NOT a 200.
|
||||
expect(["164", "158"]).toContain(r.code);
|
||||
});
|
||||
|
||||
it("readonly=1 blocks writes through the query surface", async () => {
|
||||
const r = await chRun(`DROP TABLE ${DB}.opt_analytics_event FORMAT JSON`, { readonly: "1" });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
// ── the half that is usually skipped ────────────────────────────────────
|
||||
|
||||
it("an unrelated tenant stays within its declared SLO WHILE a query bomb is in flight", async () => {
|
||||
// Baseline first, so "fast enough" is measured against this server, not
|
||||
// against a number someone hoped for.
|
||||
const baseline = await engine.executeMetric(VICTIM, { metric: "victim_metric", aggregate: "count" });
|
||||
expect(baseline.rows[0].value).toBe(500);
|
||||
const baselineMs = baseline.stats.duration_ms;
|
||||
|
||||
// Fire a genuine bomb the server must spend real CPU on. MEASURED: a bare
|
||||
// `count()` over numbers() is NOT a bomb — ClickHouse answers
|
||||
// numbers(20e9) in ~5s because it can skip the values entirely. Hashing
|
||||
// every row cannot be short-circuited, so this one really does burn CPU
|
||||
// until the limit kills it.
|
||||
const bomb = chRun("SELECT sum(cityHash64(number)) FROM numbers(20000000000) FORMAT JSON", {
|
||||
readonly: "1",
|
||||
max_execution_time: "8",
|
||||
timeout_overflow_mode: "throw",
|
||||
});
|
||||
|
||||
// While it burns, run the unrelated tenant's interactive query repeatedly.
|
||||
const samples: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = await engine.executeMetric(VICTIM, { metric: "victim_metric", aggregate: "count" });
|
||||
expect(r.rows[0].value).toBe(500); // still CORRECT, not just fast
|
||||
samples.push(r.stats.duration_ms);
|
||||
}
|
||||
|
||||
const bombResult = await bomb;
|
||||
expect(bombResult.ok, "the bomb was NOT terminated").toBe(false);
|
||||
expect(bombResult.code).toBe("159");
|
||||
|
||||
const worst = Math.max(...samples);
|
||||
expect(
|
||||
worst,
|
||||
`unrelated tenant degraded: baseline ${baselineMs}ms, worst under load ${worst}ms, SLO ${UNRELATED_TENANT_SLO_MS}ms`,
|
||||
).toBeLessThan(UNRELATED_TENANT_SLO_MS);
|
||||
}, 60_000);
|
||||
|
||||
// ── the local admission gate, on the real engine ────────────────────────
|
||||
|
||||
it("the per-tenant concurrency gate refuses the N+1st in-flight query for THAT tenant only", async () => {
|
||||
const limit = DEFAULT_BUDGETS.INTERACTIVE.maxConcurrentQueries;
|
||||
// Fire limit+4 concurrently; some must be refused with RATE_LIMITED
|
||||
// rather than all of them reaching the server.
|
||||
const attempts = Array.from({ length: limit + 4 }, () =>
|
||||
engine
|
||||
.executeMetric(VICTIM, { metric: "victim_metric", aggregate: "count" })
|
||||
.then(() => "ok" as const)
|
||||
.catch((e: { code?: string }) => e.code ?? "error"),
|
||||
);
|
||||
const outcomes = await Promise.all(attempts);
|
||||
expect(outcomes.filter((o) => o === "RATE_LIMITED").length).toBeGreaterThan(0);
|
||||
expect(outcomes.filter((o) => o === "ok").length).toBeGreaterThan(0);
|
||||
|
||||
// A different tenant is unaffected by the noisy one's exhausted slots.
|
||||
const other = await engine.executeMetric(
|
||||
{ tenant_id: "t_quiet", project_id: "p_main", principal_id: "usr_q" },
|
||||
{ metric: "victim_metric", aggregate: "count" },
|
||||
);
|
||||
expect(other.rows[0].value).toBe(0); // its own (empty) data, and no error
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* BI-P011 — CLICKHOUSE TENANT ISOLATION, proven against a LIVE server.
|
||||
*
|
||||
* The shape of this proof is the estate's own convention for isolation work:
|
||||
* DEMONSTRATE THE HOLE WITHOUT THE FIX, then demonstrate it CLOSED WITH the
|
||||
* fix. A suite that only exercises the fixed path proves that the fixed path
|
||||
* works, which is not the same claim.
|
||||
*
|
||||
* So each layer is tested twice:
|
||||
*
|
||||
* layer 2 (0pt query compiler forces tenant)
|
||||
* hole: a hand-written statement WITHOUT the predicate — the same query
|
||||
* minus the forcing — returns another tenant's rows. This is the
|
||||
* literal regression a future refactor would cause.
|
||||
* closed: the compiled statement returns only this tenant's rows.
|
||||
*
|
||||
* layer 3 (restricted database users + row policies)
|
||||
* hole: the UNRESTRICTED admin user, issuing a statement with NO tenant
|
||||
* predicate at all, sees every tenant.
|
||||
* closed: the RESTRICTED per-tenant user, issuing the SAME statement,
|
||||
* sees only its own tenant — the server filters, so the result
|
||||
* holds even if layer 2 were removed entirely.
|
||||
*
|
||||
* Gated on CLICKHOUSE_URL and SKIPS HONESTLY when unset, matching
|
||||
* tests/integration/analytics-clickhouse.integration.test.ts. CI's
|
||||
* `integration` job sets it from a service container and hard-fails if the
|
||||
* suites skip, so this is not a self-skipping green in practice.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { ClickHouseAnalyticsEngine } from "@/src/analytics/engine/clickhouse-engine";
|
||||
import {
|
||||
compileMetricQuery,
|
||||
dimensionCatalogFrom,
|
||||
restrictedTenantUserDdl,
|
||||
} from "@/src/analytics/engine/query-compiler";
|
||||
import { ANALYTICS_TABLES, sortingKeySql } from "@/src/analytics/engine/schema";
|
||||
import { DEFAULT_BUDGETS } from "@/src/analytics/engine/resource-governance";
|
||||
import type { AnalyticsIdentity } from "@/src/analytics/engine/siax-analytics-engine";
|
||||
|
||||
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
||||
const CLICKHOUSE_AUTH = process.env.CLICKHOUSE_AUTH;
|
||||
const run = CLICKHOUSE_URL ? describe : describe.skip;
|
||||
|
||||
const DB = `opt_isolation_${Date.now().toString(36)}`;
|
||||
const RESTRICTED_USER = `opt_iso_${Date.now().toString(36)}`;
|
||||
const RESTRICTED_PW = `iso_pw_${Math.random().toString(36).slice(2, 12)}`;
|
||||
|
||||
const ALPHA: AnalyticsIdentity = { tenant_id: "t_alpha", project_id: "p_main", principal_id: "usr_alpha" };
|
||||
const BETA: AnalyticsIdentity = { tenant_id: "t_beta", project_id: "p_main", principal_id: "usr_beta" };
|
||||
|
||||
const catalog = dimensionCatalogFrom({ severity: "severity" });
|
||||
const ALL_TABLES: readonly string[] = ANALYTICS_TABLES;
|
||||
|
||||
/** Raw HTTP against the live server, as a named user. */
|
||||
async function chQuery(
|
||||
sql: string,
|
||||
auth: string | undefined,
|
||||
params: Record<string, string> = {},
|
||||
settings: Record<string, string> = {},
|
||||
body?: string,
|
||||
): Promise<{ ok: boolean; status: number; text: string }> {
|
||||
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);
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||
const headers: Record<string, string> = {};
|
||||
if (auth) headers["Authorization"] = `Basic ${Buffer.from(auth).toString("base64")}`;
|
||||
const res = await fetch(url.toString(), { method: "POST", headers, body: body ?? "" });
|
||||
return { ok: res.ok, status: res.status, text: await res.text() };
|
||||
}
|
||||
|
||||
function rowsOf(text: string): { grp?: string; value: number }[] {
|
||||
if (!text.trim()) return [];
|
||||
const parsed = JSON.parse(text) as { data?: { grp?: string; value: number | string }[] };
|
||||
return (parsed.data ?? []).map((r) => ({ ...(r.grp !== undefined ? { grp: r.grp } : {}), value: Number(r.value) }));
|
||||
}
|
||||
|
||||
let engine: ClickHouseAnalyticsEngine;
|
||||
|
||||
run("BI-P011 tenant isolation against a LIVE ClickHouse", () => {
|
||||
beforeAll(async () => {
|
||||
engine = new ClickHouseAnalyticsEngine({
|
||||
baseUrl: CLICKHOUSE_URL!,
|
||||
basicAuth: CLICKHOUSE_AUTH,
|
||||
database: DB,
|
||||
dimensionCatalogs: { t_alpha: { severity: "severity" }, t_beta: { severity: "severity" } },
|
||||
});
|
||||
const health = await engine.health();
|
||||
expect(health.healthy, `ClickHouse not healthy: ${health.detail ?? ""}`).toBe(true);
|
||||
await engine.ensureSchema();
|
||||
|
||||
// Two tenants, deliberately different magnitudes so a leak is unmistakable
|
||||
// in the NUMBER, not just in the row count.
|
||||
const rows: string[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
rows.push(
|
||||
JSON.stringify({
|
||||
tenant_id: "t_alpha",
|
||||
project_id: "p_main",
|
||||
dataset_id: "ds_shared",
|
||||
metric: "rows_ingested",
|
||||
event_id: `a${i}`,
|
||||
event_time: "2026-09-01 10:00:00.000",
|
||||
value: 1,
|
||||
dim_names: ["severity"],
|
||||
dim_values: [i % 2 === 0 ? "warning" : "critical"],
|
||||
}),
|
||||
);
|
||||
rows.push(
|
||||
JSON.stringify({
|
||||
tenant_id: "t_beta",
|
||||
project_id: "p_main",
|
||||
dataset_id: "ds_shared",
|
||||
metric: "rows_ingested",
|
||||
event_id: `b${i}`,
|
||||
event_time: "2026-09-01 10:00:00.000",
|
||||
value: 1000,
|
||||
dim_names: ["severity"],
|
||||
dim_values: ["critical"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
const ins = await chQuery(
|
||||
`INSERT INTO ${DB}.opt_analytics_event FORMAT JSONEachRow`,
|
||||
CLICKHOUSE_AUTH,
|
||||
{},
|
||||
{},
|
||||
rows.join("\n"),
|
||||
);
|
||||
expect(ins.ok, `seed insert failed: ${ins.text.slice(0, 200)}`).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!CLICKHOUSE_URL) return;
|
||||
// Disposable by construction: drop the user, its policies, and the DB.
|
||||
for (const table of ANALYTICS_TABLES) {
|
||||
await chQuery(`DROP ROW POLICY IF EXISTS ${RESTRICTED_USER}_${table}_rp ON ${DB}.${table}`, CLICKHOUSE_AUTH);
|
||||
}
|
||||
await chQuery(`DROP USER IF EXISTS ${RESTRICTED_USER}`, CLICKHOUSE_AUTH);
|
||||
await chQuery(`DROP DATABASE IF EXISTS ${DB}`, CLICKHOUSE_AUTH);
|
||||
}, 60_000);
|
||||
|
||||
// ── layer 2: the compiler ────────────────────────────────────────────────
|
||||
|
||||
it("HOLE, layer 2 REMOVED: the same query WITHOUT the forced predicate returns t_beta's rows to t_alpha", async () => {
|
||||
// This is the exact statement the compiler would produce if someone
|
||||
// deleted the forcing — proof that the predicate is doing real work
|
||||
// against real data, not decorating a query that was safe anyway.
|
||||
const unsafeSql =
|
||||
`SELECT sum(value) AS value FROM ${DB}.opt_analytics_event ` +
|
||||
`WHERE metric = {metric:String} LIMIT 1 FORMAT JSON`;
|
||||
const res = await chQuery(unsafeSql, CLICKHOUSE_AUTH, { param_metric: "rows_ingested" });
|
||||
expect(res.ok).toBe(true);
|
||||
// 50*1 (alpha) + 50*1000 (beta) = 50050 — both tenants, one result.
|
||||
expect(rowsOf(res.text)[0].value).toBe(50050);
|
||||
});
|
||||
|
||||
it("CLOSED, layer 2 PRESENT: the compiled query returns t_alpha's 50 and never t_beta's 50000", async () => {
|
||||
const result = await engine.executeMetric(ALPHA, { metric: "rows_ingested", aggregate: "sum" });
|
||||
expect(result.rows[0].value).toBe(50);
|
||||
const beta = await engine.executeMetric(BETA, { metric: "rows_ingested", aggregate: "sum" });
|
||||
expect(beta.rows[0].value).toBe(50_000);
|
||||
});
|
||||
|
||||
it("tenant A querying tenant B's dataset id gets ZERO rows, not B's rows", async () => {
|
||||
const result = await engine.executeMetric(ALPHA, {
|
||||
metric: "rows_ingested",
|
||||
aggregate: "count",
|
||||
dataset_id: "ds_shared",
|
||||
});
|
||||
expect(result.rows[0].value).toBe(50); // only alpha's 50, though beta shares the dataset id
|
||||
});
|
||||
|
||||
it("a forged dataset id yields an empty result, never an error that leaks existence", async () => {
|
||||
const result = await engine.executeMetric(ALPHA, {
|
||||
metric: "rows_ingested",
|
||||
aggregate: "count",
|
||||
dataset_id: "ds_that_does_not_exist_or_belongs_to_someone_else",
|
||||
});
|
||||
expect(result.rows[0].value).toBe(0);
|
||||
});
|
||||
|
||||
it("a tenant with no data at all sees zero, not everyone's aggregate", async () => {
|
||||
const ghost: AnalyticsIdentity = { tenant_id: "t_gamma", project_id: "p_main", principal_id: "usr_g" };
|
||||
const result = await engine.executeMetric(ghost, { metric: "rows_ingested", aggregate: "count" });
|
||||
expect(result.rows[0].value).toBe(0);
|
||||
});
|
||||
|
||||
it("group-by results carry only this tenant's dimension values", async () => {
|
||||
const result = await engine.executeMetric(ALPHA, {
|
||||
metric: "rows_ingested",
|
||||
aggregate: "count",
|
||||
group_by: ["severity"],
|
||||
});
|
||||
const total = result.rows.reduce((a, r) => a + r.value, 0);
|
||||
expect(total).toBe(50);
|
||||
// t_beta's rows are all "critical" with value 1000; if any leaked, the
|
||||
// critical bucket would exceed alpha's 25.
|
||||
const critical = result.rows.find((r) => r.groups?.severity === "critical");
|
||||
expect(critical?.value).toBe(25);
|
||||
});
|
||||
|
||||
it("SQL/dimension/filter injection through the live path changes nothing", async () => {
|
||||
await expect(
|
||||
engine.executeMetric(ALPHA, {
|
||||
metric: "rows_ingested",
|
||||
aggregate: "sum",
|
||||
group_by: ["severity'; SELECT tenant_id FROM system.tables; --"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INVALID_REQUEST" });
|
||||
|
||||
// A hostile VALUE reaches the server as a bound parameter and simply
|
||||
// matches nothing.
|
||||
const injected = await engine.executeMetric(ALPHA, {
|
||||
metric: "rows_ingested",
|
||||
aggregate: "count",
|
||||
filters: { severity: "warning' OR 1=1 --" },
|
||||
});
|
||||
expect(injected.rows[0].value).toBe(0);
|
||||
|
||||
// And the table is still there afterwards.
|
||||
const still = await engine.executeMetric(ALPHA, { metric: "rows_ingested", aggregate: "count" });
|
||||
expect(still.rows[0].value).toBe(50);
|
||||
});
|
||||
|
||||
it("raw SQL attempt: the analyst-SQL surface is refused, not silently executed", async () => {
|
||||
await expect(
|
||||
engine.executeQuery(ALPHA, { sql: `SELECT * FROM ${DB}.opt_analytics_event`, dataset_ids: [] }),
|
||||
).rejects.toMatchObject({ code: "CAPABILITY_UNAVAILABLE" });
|
||||
});
|
||||
|
||||
// ── layer 3: restricted users + row policies ─────────────────────────────
|
||||
|
||||
it("HOLE, layer 3 ABSENT: an unrestricted user with no predicate sees every tenant", async () => {
|
||||
const res = await chQuery(
|
||||
`SELECT uniqExact(tenant_id) AS value FROM ${DB}.opt_analytics_event FORMAT JSON`,
|
||||
CLICKHOUSE_AUTH,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(rowsOf(res.text)[0].value).toBe(2); // t_alpha AND t_beta
|
||||
});
|
||||
|
||||
it("CLOSED, layer 3 PRESENT: the restricted user runs the SAME predicate-free statement and sees ONE tenant", async () => {
|
||||
for (const stmt of restrictedTenantUserDdl({
|
||||
db: DB,
|
||||
tenantId: "t_alpha",
|
||||
userName: RESTRICTED_USER,
|
||||
password: RESTRICTED_PW,
|
||||
tables: ALL_TABLES,
|
||||
})) {
|
||||
const r = await chQuery(stmt, CLICKHOUSE_AUTH);
|
||||
expect(r.ok, `DDL failed: ${stmt.slice(0, 80)} -> ${r.text.slice(0, 200)}`).toBe(true);
|
||||
}
|
||||
|
||||
const restrictedAuth = `${RESTRICTED_USER}:${RESTRICTED_PW}`;
|
||||
// The IDENTICAL statement that leaked above, now as the restricted user.
|
||||
const res = await chQuery(
|
||||
`SELECT uniqExact(tenant_id) AS value FROM ${DB}.opt_analytics_event FORMAT JSON`,
|
||||
restrictedAuth,
|
||||
);
|
||||
expect(res.ok, res.text.slice(0, 300)).toBe(true);
|
||||
expect(rowsOf(res.text)[0].value).toBe(1);
|
||||
|
||||
const sum = await chQuery(
|
||||
`SELECT sum(value) AS value FROM ${DB}.opt_analytics_event FORMAT JSON`,
|
||||
restrictedAuth,
|
||||
);
|
||||
expect(rowsOf(sum.text)[0].value).toBe(50); // alpha's total, not 50050
|
||||
});
|
||||
|
||||
it("the restricted user cannot write, and cannot reach a table it was not granted", async () => {
|
||||
const restrictedAuth = `${RESTRICTED_USER}:${RESTRICTED_PW}`;
|
||||
const write = await chQuery(
|
||||
`INSERT INTO ${DB}.opt_analytics_event FORMAT JSONEachRow`,
|
||||
restrictedAuth,
|
||||
{},
|
||||
{},
|
||||
JSON.stringify({ tenant_id: "t_beta", project_id: "p", dataset_id: "d", metric: "m", event_id: "x", event_time: "2026-09-01 00:00:00.000", value: 1, dim_names: [], dim_values: [] }),
|
||||
);
|
||||
expect(write.ok).toBe(false);
|
||||
|
||||
const foreign = await chQuery(`SELECT count() FROM system.users FORMAT JSON`, restrictedAuth);
|
||||
expect(foreign.ok).toBe(false);
|
||||
});
|
||||
|
||||
// ── the structural precondition both layers rely on ──────────────────────
|
||||
|
||||
it("every analytics table's sorting key LEADS with tenant_id (what makes the predicate cheap)", async () => {
|
||||
for (const table of ALL_TABLES) {
|
||||
const res = await chQuery(sortingKeySql(DB, table), CLICKHOUSE_AUTH);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.text.trim().startsWith("tenant_id"), `${table} sorting key: ${res.text.trim()}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("the tenant value never appears in the statement text the server receives", async () => {
|
||||
const compiled = compileMetricQuery(
|
||||
{ tenant_id: "t_alpha", metric: "rows_ingested", aggregate: "sum" },
|
||||
DB,
|
||||
catalog,
|
||||
DEFAULT_BUDGETS.INTERACTIVE.maxResultRows,
|
||||
);
|
||||
expect(compiled.sql).not.toContain("t_alpha");
|
||||
const res = await chQuery(compiled.sql, CLICKHOUSE_AUTH, compiled.params);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(rowsOf(res.text)[0].value).toBe(50);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user