Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
118 commits
Select commit Hold shift + click to select a range
af77085
fix(ci): make classify-paths fail loudly when grep fails
EricAndrechek Sep 1, 2026
e10eb04
feat(discovery): widen schema discovery with DDL, position, version
EricAndrechek Sep 1, 2026
1e92574
feat(policy)!: role-first policies.json with split permission types
EricAndrechek Sep 1, 2026
3b36e89
feat(ingest)!: require a declared Content-Type and honor it
EricAndrechek Sep 1, 2026
5be3dd7
refactor(ingest): buffer the body, add the type-layer seams, add the …
EricAndrechek Sep 1, 2026
f5d9c83
feat(stream)!: positional rows on NATS and SSE, columns sent separately
EricAndrechek Sep 1, 2026
b09dbbe
fix(ingest)!: exclude computed columns from the insert column list
EricAndrechek Sep 1, 2026
f41f0d7
fix(discovery): snapshot the database, correct the DDL rationale
EricAndrechek Sep 2, 2026
db8d35a
test(api): assert the whole 415 contract, and name the cause plainly
EricAndrechek Sep 2, 2026
09c8382
fix(settings): refuse an ambiguous layout instead of guessing v2
EricAndrechek Sep 2, 2026
191fb56
Merge remote-tracking branch 'origin/stack/1-discovery' into stack/2-…
EricAndrechek Sep 2, 2026
ac735f9
Merge remote-tracking branch 'origin/main' into stack/1-discovery
EricAndrechek Sep 2, 2026
0188d42
Merge stack/1-discovery into stack/2-policy
EricAndrechek Sep 2, 2026
842ac9c
fix(policy): address chtypes review — drop unread field, restore rows
EricAndrechek Sep 2, 2026
c5850cc
fix(policy): correct a false invariant and un-export a vestigial type
EricAndrechek Sep 2, 2026
b9a9f71
docs(policy): document the two layout rejections the pages missed
EricAndrechek Sep 2, 2026
6ca8fed
Merge stack/2-policy into stack/3-content-type
EricAndrechek Sep 2, 2026
59ad4f5
fix(ingest): keep the media type when only a parameter is malformed
EricAndrechek Sep 2, 2026
2f4c442
fix(ingest): refuse comma-joined Content-Type, ignore parameters outr…
EricAndrechek Sep 2, 2026
f527e3b
test(settings): pin the layout detector's accept path and empty-block…
EricAndrechek Sep 2, 2026
ff8bddf
test(settings): reattach a detached doc comment, drop a second over-c…
EricAndrechek Sep 2, 2026
72663d8
Merge origin/main into stack/2-policy
EricAndrechek Sep 2, 2026
1e1a6a5
Merge stack/2-policy into stack/3-content-type
EricAndrechek Sep 2, 2026
7cd4be1
fix(ingest): refuse disagreeing Content-Type headers, not just joined…
EricAndrechek Sep 2, 2026
0e4f844
fix(ingest): pin both halves of the duplicate-header guard, document …
EricAndrechek Sep 2, 2026
abedda2
test(api): pin the documented 415 body and the OWS-tolerant media type
EricAndrechek Sep 2, 2026
f56a13f
test(api): pin the 415 body's declared echo and its joined header list
EricAndrechek Sep 2, 2026
425c54d
Merge branch 'main' into stack/2-policy
EricAndrechek Sep 2, 2026
8928131
Merge stack/2-policy into stack/3-content-type
EricAndrechek Sep 2, 2026
36b4c1d
fix(ingest): judge joined and repeated Content-Type by one rule
EricAndrechek Sep 2, 2026
a77db24
test(api): prove "every declaration" past N=2, correct a quoted-comma…
EricAndrechek Sep 2, 2026
2b5ae58
docs(changelog): retract the quoted-comma universal in its last two c…
EricAndrechek Sep 2, 2026
7e8e371
Merge stack/3-content-type into stack/4-seams
EricAndrechek Sep 2, 2026
68f3f28
fix(docs): bound the per-request cap claim, assert the seam test's 403
EricAndrechek Sep 2, 2026
62f4c43
refactor(ingest): one Content-Type resolver, one 415 message
EricAndrechek Sep 2, 2026
97a42cf
fix(ingest): resolve the declaration once, not twice
EricAndrechek Sep 2, 2026
696c91b
docs(api): retire comments the resolver refactor invalidated
EricAndrechek Sep 2, 2026
db08750
test(api): assert the accepted-type list whole, not entry by entry
EricAndrechek Sep 2, 2026
669f6a4
test: pin the accepted-type list to a literal, not to itself
EricAndrechek Sep 2, 2026
d5909fa
docs(test): correct two comments that claim more than their assertion…
EricAndrechek Sep 2, 2026
72d98f4
refactor(ingest): derive the advertised list from the accepted set
EricAndrechek Sep 3, 2026
0dd909c
docs(api): fix two comments the single-source refactor broke
EricAndrechek Sep 3, 2026
c496d13
test(api): name the right slice, retire a dominated test
EricAndrechek Sep 3, 2026
258dba9
Merge stack/3-content-type into stack/4-seams
EricAndrechek Sep 3, 2026
4abe6bf
docs(api): the body cap applies to NDJSON too, and the 413 is now atomic
EricAndrechek Sep 3, 2026
13b75a6
test(stream): make the row-evaluator seam test discriminate both dire…
EricAndrechek Sep 3, 2026
cbea7dc
docs(api): document the atomic 413, the new read-failure body, and re…
EricAndrechek Sep 3, 2026
a586c3f
docs(api): stop two error rows describing the same failure differently
EricAndrechek Sep 3, 2026
091fcf3
docs(sdk): the server no longer streams the parse
EricAndrechek Sep 3, 2026
b5f9977
docs(api): disambiguate the two 400 causes in both ingest tables
EricAndrechek Sep 3, 2026
d4c470d
test(api): pin the body-read-failure 400
EricAndrechek Sep 3, 2026
bf83b24
fix(ingest): a comma inside a quoted parameter is data, not a separator
EricAndrechek Sep 3, 2026
ec9d9ba
docs(api): retire the quoted-comma claim from its last copy
EricAndrechek Sep 3, 2026
7398202
fix(ingest): an unbalanced quote must not swallow the next declaration
EricAndrechek Sep 3, 2026
04fbbe7
fix(ingest): scope the spelling-independence claim to what holds
EricAndrechek Sep 3, 2026
5db4a2c
docs(ingest): the joined/repeated limit has two directions, not one
EricAndrechek Sep 3, 2026
ca8506a
docs(api): the over-rejection is a request-level fact, not just a spe…
EricAndrechek Sep 3, 2026
2e8a882
test(api): state the invariant that holds, not the one the rename rem…
EricAndrechek Sep 3, 2026
328968d
docs(api): name the real trigger for the quoted-comma exception
EricAndrechek Sep 3, 2026
b91ae8b
docs(architecture): the last two copies of the Content-Type claims
EricAndrechek Sep 3, 2026
5d366c2
docs(architecture): add the Content-Type gate to the Ingest Path flow
EricAndrechek Sep 3, 2026
3615da9
refactor(ingest)!: resolve Content-Type with mime.ParseMediaType
EricAndrechek Sep 3, 2026
34af5ce
docs(ingest): let the RFC carry the grammar
EricAndrechek Sep 3, 2026
2dc6044
docs(api): correct both 415 rows for the new resolver
EricAndrechek Sep 3, 2026
7a6caec
fix(ingest): state the tightenings accurately, and pin the fourth
EricAndrechek Sep 3, 2026
9b88f88
fix(ingest): the exception is not "no media type" — say why it really is
EricAndrechek Sep 3, 2026
b6af362
docs(api): scope the comma exception to a malformed parameter
EricAndrechek Sep 3, 2026
3c7462f
test(api): pin the JSON error contract on every 415 subtest
EricAndrechek Sep 3, 2026
4d18506
fix(ingest)!: tolerate a duplicate parameter name, scope the comma claim
EricAndrechek Sep 3, 2026
3d16d7a
docs+comments: one wording for the comma rule, in all nine places
EricAndrechek Sep 3, 2026
32c5bf8
docs(api): state the lossy mis-declaration, restore "as a whole"
EricAndrechek Sep 3, 2026
b71a082
Merge origin/main into stack/3-content-type
EricAndrechek Sep 4, 2026
6684eb0
fix(ingest): bound the Content-Type echoed into the 415 body and the log
EricAndrechek Sep 4, 2026
412b385
fix(ingest): bound the declaration COUNT, not just each declaration's…
EricAndrechek Sep 4, 2026
48101eb
fix(ingest): keep DISTINCT declarations, and sweep the completeness p…
EricAndrechek Sep 4, 2026
548f10e
fix(ingest): pin the disagreeing declaration, and make the bound test…
EricAndrechek Sep 4, 2026
a275821
fix(ingest): give the log the same pinned echo as the response
EricAndrechek Sep 4, 2026
d517212
refactor(ingest): resolve the disagreement once and thread it
EricAndrechek Sep 4, 2026
44759fa
refactor(ingest): one bounded set, read by both the log and the response
EricAndrechek Sep 4, 2026
9b89b9c
docs: name the Content-Type invariant, and warn proxies about merging
EricAndrechek Sep 4, 2026
ede129d
docs: correct the invariant clause, and the silent proxy failure
EricAndrechek Sep 4, 2026
02f3dda
docs: the invariant clause, corrected against the code this time
EricAndrechek Sep 4, 2026
074d551
docs: restore "as a whole", and fix a doubled comma I introduced
EricAndrechek Sep 4, 2026
6b095d5
Merge stack/3-content-type into stack/4-seams
EricAndrechek Sep 4, 2026
8c3729a
test(ingest): pin 415-before-body-read; sync the SDK body-cap docs
EricAndrechek Sep 4, 2026
b2b27c5
Merge main (squashed #552) into stack/4-seams
EricAndrechek Sep 4, 2026
3411e6b
test(ingest): pin the InSet seam and the single-object cap; docs accu…
EricAndrechek Sep 4, 2026
5ed3499
docs(reverse-proxy): correct the single-object memory sizing rule
EricAndrechek Sep 4, 2026
c807b46
docs(reverse-proxy): hedge the sizing rule, make the proxy advice act…
EricAndrechek Sep 4, 2026
07fb8ed
Merge stack/4-seams (#553) into stack/5-positional-wire
EricAndrechek Sep 4, 2026
5312771
fix(ingest)!: send only insertable columns in the envelope
EricAndrechek Sep 4, 2026
0fb4197
fix(ingest)!: refuse a supplied computed column; sync the wire-change…
EricAndrechek Sep 4, 2026
d6b6401
docs: the v2 upgrade also has a silent SSE replay hole
EricAndrechek Sep 4, 2026
7717c33
docs: three pre-existing claims this branch's behavior falsified
EricAndrechek Sep 4, 2026
1c24342
docs(api): the schema-frame contract as the wire actually provides it
EricAndrechek Sep 4, 2026
73b7ffe
fix(policy)!: refuse a check clause the published row cannot carry
EricAndrechek Sep 4, 2026
2c68eea
docs: the second-order fallout of the last commit's three code fixes
EricAndrechek Sep 4, 2026
11bfbc6
refactor(ingest): evaluate the policy-check guard once, pin EPHEMERAL…
EricAndrechek Sep 4, 2026
9adf4b2
docs(go): reattach processRecord's doc comment, clear the docstring gate
EricAndrechek Sep 4, 2026
c58757c
test(ingest): pin the log-once half of the policy-check guard
EricAndrechek Sep 4, 2026
30e67aa
fix(sdk): bound every SSE warn site, not just the skew ones
EricAndrechek Sep 5, 2026
f85a055
test(sdk): pin the two newly bounded warn sites; fix a third comment …
EricAndrechek Sep 5, 2026
b106a87
docs(deployment): the upgrade runbook was missing both pre-upgrade au…
EricAndrechek Sep 5, 2026
3bf7729
fix(ingest,stream,sdk): refuse an envelope naming one column twice
EricAndrechek Sep 5, 2026
89ba3db
Merge remote-tracking branch 'origin/stack/5-positional-wire' into st…
EricAndrechek Sep 5, 2026
989309b
Merge #554 into #555, and drop what #554 already carries
EricAndrechek Sep 5, 2026
d686fa5
docs: remove five more duplicates the merge left behind
EricAndrechek Sep 5, 2026
db3d8e4
docs(agents): qualify the schema-frame guarantee
EricAndrechek Sep 5, 2026
8f017f4
docs(api): scope the defaulting rule, qualify the schema-frame guarantee
EricAndrechek Sep 5, 2026
4a33fd1
docs(sdk): qualify the last unguarded schema-frame claim
EricAndrechek Sep 5, 2026
170ab26
Merge main (squashed #553) into stack/5-positional-wire
EricAndrechek Sep 8, 2026
87977fd
fix(ingest,stream): deterministic guard message, count unpairable env…
EricAndrechek Sep 8, 2026
a5d3a56
fix(stream): count unpairable envelopes on the replay path too
EricAndrechek Sep 8, 2026
141a54f
docs: state the gap-fill exception in the PR that introduces the wire
EricAndrechek Sep 8, 2026
af7b76d
Merge #554 into #555
EricAndrechek Sep 8, 2026
90871d0
Merge main (squashed #554) into #555
EricAndrechek Sep 9, 2026
5269726
docs: drop the duplicated entry, fix the metric name the merge revived
EricAndrechek Sep 9, 2026
1ada649
docs: qualify the absent format, unproject the SSE example, fix inserts
EricAndrechek Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Sixteen internal packages under `internal/` (plus `internal/testutil/` for share
- **`policy/`** — Hasura-style access control, **role-first**: `TablePolicy` is `map[string]RolePermissions`, and a role's grant splits by operation into `SelectPermissions` (columns, row `filter`, aggregations, the `max_*` limits) and `InsertPermissions` (columns, `check`) — so a field only one side honors does not exist on the other. `Evaluate()` resolves ONE operation and leaves the other side **nil** (`Select *ResolvedSelect` / `Insert *ResolvedInsert`), which every accessor fails closed on — nil is "not resolved", distinct from an empty side, which is "unrestricted" (what the admin return builds). Claim templating (`{{ jwt.claim.path }}`) resolves during that call. Policies come from `Source`, a `func() *Policy` read per call (`settings.Store.Policy` in production, `Static(p)` in tests)
- **`query/`** — Structured query AST types + SQL builder with schema validation, structural policy predicate/limit emission, timestamp bucketing
- **`settings/`** — the settings directory: `Validate` (strict JSON, per-file rules, cross-file role references), `Store` (the adopted snapshot + serialized `Reload`, typed accessors read per call, `AfterAdopt` hooks), the fsnotify `Watch`, and the `go:embed`ded seed `wavehouse bootstrap` writes
- **`stream/`** — SSE fan-out: the event `Hub` (registers subscribers by `(topic, role)`; `Broadcast` projects + serializes each event once per role, the #294 delivery hot path — a role carrying a row-level `filter` keeps the shared projection but delivers per subscriber, each subscriber's claims evaluated against the row, #319), `Subscriber` (per-connection outbound `Frame` queue, `Send`/`Frames`; claims fixed at construction, immutable), the `Bucket` fan-out set (`subscriberSet`, one per `(topic, role)`), the `Heartbeater` keepalive wheel, and `Metrics` (the `wavehouse_sse_*` stream instruments)
- **`stream/`** — SSE fan-out: rows travel POSITIONALLY, so each connection is told its projected column list in an `event: schema` frame before its first row and again on drift — **not** guaranteed after a gap-fill across a column change, which can leave a connection reading live rows against a stale list until it reconnects ([#543](https://github.com/Wave-RF/WaveHouse/issues/543)) — (tracked per connection; replay tracks its own). The event `Hub` (registers subscribers by `(topic, role)`; `Broadcast` projects + serializes each event once per role, the #294 delivery hot path — a role carrying a row-level `filter` keeps the shared projection but delivers per subscriber, each subscriber's claims evaluated against the row, #319), `Subscriber` (per-connection outbound `Frame` queue, `Send`/`Frames`; claims fixed at construction, immutable), the `Bucket` fan-out set (`subscriberSet`, one per `(topic, role)`), the `Heartbeater` keepalive wheel, and `Metrics` (the `wavehouse_sse_*` stream instruments)

## Key Design Decisions

Expand All @@ -53,8 +53,8 @@ The invariant index — what must stay true. Full narrative and rationale live i
2. **Bring Your Own Schema** — users create ClickHouse tables; WaveHouse discovers them via `system.columns` and never auto-migrates.
3. **Schema-driven ingest** — `POST /v1/ingest?table={table}` takes flat JSON, validated against the discovered schema (unknown fields rejected, types/nullability enforced). No envelope. The **declared `Content-Type` chooses the format and the bytes never do** (arity within the JSON family is still the body's): no declaration, one whose **media type** is unsupported or unparseable, a comma-bearing value that, as a whole, does not parse as one media type, or repeated lines that **disagree**, is a `415` decided *before* the body is read. A malformed *parameter* on a comma-free line never costs the request (`; charset=a; charset=b` still reads as its media type), and repeated lines are accepted only when they all resolve to the same **supported** format — two agreeing `text/csv` lines are still a `415`. A body declared NDJSON stays NDJSON whatever its bytes, so a bad line is a per-record error rather than a silent re-framing; the reverse (NDJSON sent as `application/json`) is deliberately **not** caught — record one, `200`, the rest ignored ([#561](https://github.com/Wave-RF/WaveHouse/issues/561)). Fail-closed — preserve it when touching `internal/api`.
4. **Async ingestion** — ingest returns 200 after optional dedup + MQ publish; ClickHouse writes happen later via `StartIngestWorker`. NATS full → 503 + Retry-After.
5. **Per-table batching** — the worker groups events by table and bulk-INSERTs in schema column order; each table's batch is independent.
6. **Dead Letter Queue** — failed batch inserts publish to `WAVEHOUSE_DLQ` (`dlq.<table>`), gated per table by `dlq.enabled` in the settings directory's `config.json` (hot-reloadable; off = leave the row unacked for redelivery). No silent data loss.
5. **Per-table batching** — the worker groups events by table, then splits each batch by column list (`groupByColumns`), emitting one `INSERT INTO … (cols) FORMAT JSONCompactEachRow` per distinct list so a schema change mid-stream can't corrupt a statement. Each table's batch is independent.
6. **Dead Letter Queue** — failed batch inserts publish to `WAVEHOUSE_DLQ` (`dlq.<table>`), gated per table by `dlq.enabled` in the settings directory's `config.json` (hot-reloadable; off = leave the row unacked for redelivery). No silent data loss on the insert path. The one drop is an envelope the worker cannot READ (malformed JSON, an unknown **or absent** `format` — a pre-v2 envelope carries none — or columns and row that don't pair): it is poison by construction, so with the DLQ off it is acked-and-dropped rather than redelivered forever — logged at `ERROR` and counted by `wavehouse_ingest_poison_total` under `disposition="dropped"`. With the DLQ on it is parked like any other failure, and counted under `disposition="parked"`.
7. **Auth: always on, fail-loud, decoupled from authz (security)** — the JWT middleware always runs (no `auth.enabled`/`dev_mode` flag); it verifies with HMAC **or** JWKS (not both), with accepted `alg` pinned to the active verifier and checked before any key is used (rejects `alg:none` and cross-family confusion). No/invalid/expired token → empty role → policy `default_role`, with the bad-token reason stashed so a denying gate returns a loud `401`, not a bare `403`. Elevated access needs a valid granted role. **Sanctioned exception:** a configured non-JWT operator key (`auth.operator_key`; presented via `Authorization: Operator <key>` or the `X-Operator-Key` alias) deliberately couples authN+authZ — a constant-time match authorizes a full-access platform operator (stamps the admin role plus an operator bit) independent of the verifier (see #11). Detail: architecture.md § `api/` + `internal/auth`; see also #11, §Security Considerations.
8. **Optional dedup** — opt-in via `dedupe.enabled` in the settings directory's `config.json` (hot-reloadable: a reload opens or closes the Pebble store via `dedupe.Managed`); `dedupe.id_field` there selects the JSON key, overridable per table.
9. **Singleflight** — `TieredCache` coalesces concurrent misses (`x/sync/singleflight`) to prevent cache stampede.
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

- **A policy `check` on a column the table cannot accept is now refused instead of silently unenforced** (BREAKING; `internal/api/ingest.go`, `internal/discovery/discovery.go`, `docs/src/content/docs/{api.md,access-control.mdx}`): a `check` clause naming a column the table does not have, one it computes (`MATERIALIZED`/`ALIAS`), or an `EPHEMERAL` one can never be enforced — the published row carries one slot per insertable column, so an auto-injected value for anything outside that set is dropped on the way out, and an ephemeral column is never stored even though the row does carry it. The record inserted **without** the value the policy required and answered `200 {"ok":true}`. Demonstrated on this branch: a `check` of `tenant _eq {{ jwt.tenant }}` against a `MATERIALIZED tenant` column published `columns:["page"], row:["/a"]` — the tenant constraint absent from the row entirely. It is now refused, naming every offending column and the reason, on **every** insert by that role until the policy or the table is corrected: a single-object request answers `403`, while a batch answers `200` with the same message against each record in `results` — the batch is still read to the end and reports per record, as it does for any other rejection. Policy validation cannot catch this — it never sees the ClickHouse schema — so **audit your `check` blocks against their tables before upgrading**; `wavehouse validate` will not tell you.

- **The ingest envelope carries only insertable columns** (`internal/discovery/discovery.go`, `internal/api/ingest.go`, `internal/stream/hub.go`, `internal/testutil/testutil.go`, `tests/integration/ingest_test.go`, `clients/ts/src/types.ts`): naming columns explicitly in the `INSERT` — the change above — makes a computed column fatal, so the envelope, the compact encoder and the SSE connect-time announcement now use the table's **insertable** subset. Verified against ClickHouse 26.6.3: a `MATERIALIZED` column in an `INSERT` column list is `Cannot insert column …, because it is MATERIALIZED column` (code 44, and `insert_allow_materialized_columns` defaults to `0`); an `ALIAS` column is `No such column …` (code 16). Schema discovery reads every row of `system.columns` with no `default_kind` filter, so without this both would land in the envelope and then in the statement, and **a table carrying either could ingest under the previous column-less `FORMAT JSONEachRow` and could not ingest at all** — every row to the DLQ, or redelivered forever where the DLQ is off. `Column` gains `DefaultKind`; `TableSchema` gains `IsInsertable` / `InsertableColumns` / `InsertableColumnNames`, memoized per table since the ingest path would otherwise rebuild them once per record. `EPHEMERAL` stays insertable — it is insert-only by construction, never stored, confirmed on the same server rather than assumed. `GET /v1/ops/schema` still reports the whole table, now including `default_kind`: a computed column stays queryable, it just cannot be written. No fixture in the suite declared a computed column, which is why every gate was green while this was broken; `tests/integration` now creates one and drives HTTP ingest → NATS → the worker's `INSERT` end to end.
- **The ingest envelope carries only insertable columns** (BREAKING; `internal/discovery/discovery.go`, `internal/api/ingest.go`, `internal/stream/hub.go`, `internal/testutil/testutil.go`, `tests/integration/ingest_test.go`, `clients/ts/src/types.ts`): naming columns explicitly in the `INSERT` — the change above — makes a computed column fatal, so the envelope, the compact encoder and the SSE connect-time announcement now use the table's **insertable** subset. Verified against ClickHouse 26.6.3: a `MATERIALIZED` column in an `INSERT` column list is `Cannot insert column …, because it is MATERIALIZED column` (code 44, and `insert_allow_materialized_columns` defaults to `0`); an `ALIAS` column is `No such column …` (code 16). Schema discovery reads every row of `system.columns` with no `default_kind` filter, so without this both would land in the envelope and then in the statement, and **a table carrying either could ingest under the previous column-less `FORMAT JSONEachRow` and could not ingest at all** — every row to the DLQ, or redelivered forever where the DLQ is off. `Column` gains `DefaultKind`; `TableSchema` gains `IsInsertable` / `InsertableColumns` / `InsertableColumnNames`, memoized per table since the ingest path would otherwise rebuild them once per record. `EPHEMERAL` stays insertable — it is insert-only by construction, never stored, confirmed on the same server rather than assumed. `GET /v1/ops/schema` still reports the whole table, now including `default_kind`, `default_expression` and `position`: a computed column stays queryable, it just cannot be written. No fixture in the suite declared a computed column, which is why every gate was green while this was broken; `tests/integration` now creates one and drives HTTP ingest → NATS → the worker's `INSERT` end to end. **BREAKING:** a record that *supplies* a value for a `MATERIALIZED`/`ALIAS` column is now rejected (`400 … cannot be inserted`) where it was previously accepted and silently dropped by the positional encoder.

- **Ingest reads the request body up front, and the per-record decisions sit behind interfaces** (`internal/api/{ingest,ingest_seams,bufpool,record_reader}.go`, `internal/stream/hub.go`, `internal/ingest/compact.go`): responses are unchanged except at the body cap and one new read-failure body (`400 {"error":"invalid request body"}`, when the body cannot be read at all — a malformed transfer encoding or a truncated upload, which previously surfaced through the decoder as `invalid json`), and at the cap the `413` is now decided before any record is processed: an over-cap batch no longer ingests the prefix it had already decoded, and an over-cap single-object body whose first object was followed by an oversized tail — which used to answer `200` after ingesting that one object — now answers `413`. Both are improvements, since a client retrying a `413` can no longer double-insert a prefix, but they are behavior changes and the memory profile changes too (see below); this is the seam work the native type layer lands against. The handler now reads the whole (already `MaxBytesReader`-capped) body into a pooled `*bytes.Buffer` and runs the record readers over those bytes rather than the live connection — so the `413` surfaces at that read instead of mid-iteration (same status, same message), and the `415` is decided from the header before a single byte is read. Three decision points became interfaces with default implementations that delegate to today's code unchanged: `RecordValidator` (schema validation + timestamp canonicalization — the two calls stay where they are, with the check-clause block between them, since merging them would move checks onto canonicalized values), `InsertChecker` (the `_eq` and `_in` comparisons), and `stream.RowEvaluator` (row visibility, reached by both the live fan-out and replay through the one shared admission step). **The memory profile is not unchanged, and that is the deliberate part.** Streaming meant peak resident bytes on the order of one record: the NDJSON path scanned line by line and the array path let `json.Decoder` compact after each element. Peak is now O(body) per in-flight request — and `bytes.Buffer` grows by doubling, so the peak allocation can exceed the body cap before `MaxBytesReader` errors. `maxPooledBufferBytes` (1 MiB) caps what a request hands *back* to the pool, not its peak, and nothing in `internal/api` bounds total in-flight bytes, so the ceiling is concurrency × the 16 MiB data-plane cap — which has no operator knob (`maxRequestBytes` is test-only), so the outer limit is the reverse proxy's, which the reverse-proxy guide already advises setting. Kept because it is the shape the native type layer lands against, which needs the body addressable rather than consumed; a bound on total in-flight ingest bytes is tracked in [#544](https://github.com/Wave-RF/WaveHouse/issues/544). Operators fronting large batches at high concurrency should size for it or cap body size at the proxy. All three are nil-safe: an un-wired handler or `Hub` uses the default rather than panicking past the check. Also new: `ingest.EncodeCompactRow`, which renders a record as one `JSONCompactEachRow` line — inert in this commit, and the encoder every published row goes through by the end of the release.

Expand Down
7 changes: 4 additions & 3 deletions clients/ts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,15 +264,16 @@ export interface Column {
name: string;
type: string;
is_nullable: boolean;
/** Whether the column declares any default at all. */
/** Whether the column declares any default at all — see `default_kind`. */
has_default: boolean;
/**
* How the default is declared, when there is one: `DEFAULT`, `MATERIALIZED`,
* `ALIAS` or `EPHEMERAL`. Omitted when the column declares none.
*
* `MATERIALIZED` and `ALIAS` columns are computed by ClickHouse and cannot be
* inserted, so they never appear in an ingest envelope or an SSE `schema`
* frame — though they are still reported here and are still queryable.
* inserted — a record naming one is rejected — so they never appear in an
* ingest envelope or an SSE `schema` frame, though they are still reported
* here and are still queryable.
*/
default_kind?: string;
/** 1-based ordinal in the table's declaration order — the order `columns` itself is in. Always present. */
Expand Down
Loading