@clickhouse/client 1.22.0 → 1.23.0-head.c8dc8d8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +2 -1
  2. package/dist/client.d.ts +2 -2
  3. package/dist/client.js +3 -3
  4. package/dist/client.js.map +1 -1
  5. package/dist/common/clickhouse_types.d.ts +98 -0
  6. package/dist/common/clickhouse_types.js +30 -0
  7. package/dist/common/clickhouse_types.js.map +1 -0
  8. package/dist/common/client.d.ts +233 -0
  9. package/dist/common/client.js +414 -0
  10. package/dist/common/client.js.map +1 -0
  11. package/dist/common/config.d.ts +234 -0
  12. package/dist/common/config.js +364 -0
  13. package/dist/common/config.js.map +1 -0
  14. package/dist/common/connection.d.ts +124 -0
  15. package/dist/common/connection.js +3 -0
  16. package/dist/common/connection.js.map +1 -0
  17. package/dist/common/data_formatter/format_query_params.d.ts +11 -0
  18. package/dist/common/data_formatter/format_query_params.js +128 -0
  19. package/dist/common/data_formatter/format_query_params.js.map +1 -0
  20. package/dist/common/data_formatter/format_query_settings.d.ts +2 -0
  21. package/dist/common/data_formatter/format_query_settings.js +20 -0
  22. package/dist/common/data_formatter/format_query_settings.js.map +1 -0
  23. package/dist/common/data_formatter/formatter.d.ts +41 -0
  24. package/dist/common/data_formatter/formatter.js +78 -0
  25. package/dist/common/data_formatter/formatter.js.map +1 -0
  26. package/dist/common/data_formatter/index.d.ts +3 -0
  27. package/dist/common/data_formatter/index.js +24 -0
  28. package/dist/common/data_formatter/index.js.map +1 -0
  29. package/dist/common/error/error.d.ts +20 -0
  30. package/dist/common/error/error.js +73 -0
  31. package/dist/common/error/error.js.map +1 -0
  32. package/dist/common/error/index.d.ts +1 -0
  33. package/dist/common/error/index.js +18 -0
  34. package/dist/common/error/index.js.map +1 -0
  35. package/dist/common/index.d.ts +67 -0
  36. package/dist/common/index.js +97 -0
  37. package/dist/common/index.js.map +1 -0
  38. package/dist/common/logger.d.ts +80 -0
  39. package/dist/common/logger.js +154 -0
  40. package/dist/common/logger.js.map +1 -0
  41. package/dist/common/parse/column_types.d.ts +127 -0
  42. package/dist/common/parse/column_types.js +586 -0
  43. package/dist/common/parse/column_types.js.map +1 -0
  44. package/dist/common/parse/index.d.ts +2 -0
  45. package/dist/common/parse/index.js +19 -0
  46. package/dist/common/parse/index.js.map +1 -0
  47. package/dist/common/parse/json_handling.d.ts +19 -0
  48. package/dist/common/parse/json_handling.js +8 -0
  49. package/dist/common/parse/json_handling.js.map +1 -0
  50. package/dist/common/result.d.ts +90 -0
  51. package/dist/common/result.js +3 -0
  52. package/dist/common/result.js.map +1 -0
  53. package/dist/common/settings.d.ts +1990 -0
  54. package/dist/common/settings.js +19 -0
  55. package/dist/common/settings.js.map +1 -0
  56. package/dist/common/tracing.d.ts +146 -0
  57. package/dist/common/tracing.js +76 -0
  58. package/dist/common/tracing.js.map +1 -0
  59. package/dist/common/ts_utils.d.ts +4 -0
  60. package/dist/common/ts_utils.js +3 -0
  61. package/dist/common/ts_utils.js.map +1 -0
  62. package/dist/common/utils/connection.d.ts +21 -0
  63. package/dist/common/utils/connection.js +43 -0
  64. package/dist/common/utils/connection.js.map +1 -0
  65. package/dist/common/utils/index.d.ts +5 -0
  66. package/dist/common/utils/index.js +22 -0
  67. package/dist/common/utils/index.js.map +1 -0
  68. package/dist/common/utils/multipart.d.ts +34 -0
  69. package/dist/common/utils/multipart.js +81 -0
  70. package/dist/common/utils/multipart.js.map +1 -0
  71. package/dist/common/utils/sleep.d.ts +4 -0
  72. package/dist/common/utils/sleep.js +12 -0
  73. package/dist/common/utils/sleep.js.map +1 -0
  74. package/dist/common/utils/stream.d.ts +15 -0
  75. package/dist/common/utils/stream.js +50 -0
  76. package/dist/common/utils/stream.js.map +1 -0
  77. package/dist/common/utils/url.d.ts +20 -0
  78. package/dist/common/utils/url.js +67 -0
  79. package/dist/common/utils/url.js.map +1 -0
  80. package/dist/common/version.d.ts +2 -0
  81. package/dist/common/version.js +4 -0
  82. package/dist/common/version.js.map +1 -0
  83. package/dist/config.d.ts +2 -2
  84. package/dist/config.js +2 -2
  85. package/dist/config.js.map +1 -1
  86. package/dist/connection/compression.d.ts +2 -2
  87. package/dist/connection/compression.js +4 -4
  88. package/dist/connection/compression.js.map +1 -1
  89. package/dist/connection/create_connection.d.ts +1 -1
  90. package/dist/connection/node_base_connection.d.ts +3 -3
  91. package/dist/connection/node_base_connection.js +22 -22
  92. package/dist/connection/node_base_connection.js.map +1 -1
  93. package/dist/connection/node_custom_agent_connection.js +2 -2
  94. package/dist/connection/node_custom_agent_connection.js.map +1 -1
  95. package/dist/connection/node_http_connection.js +2 -2
  96. package/dist/connection/node_http_connection.js.map +1 -1
  97. package/dist/connection/node_https_connection.d.ts +1 -1
  98. package/dist/connection/node_https_connection.js +3 -3
  99. package/dist/connection/node_https_connection.js.map +1 -1
  100. package/dist/connection/socket_pool.d.ts +1 -1
  101. package/dist/connection/socket_pool.js +30 -30
  102. package/dist/connection/socket_pool.js.map +1 -1
  103. package/dist/connection/stream.d.ts +1 -1
  104. package/dist/connection/stream.js +9 -9
  105. package/dist/connection/stream.js.map +1 -1
  106. package/dist/index.d.ts +7 -7
  107. package/dist/index.js +24 -24
  108. package/dist/index.js.map +1 -1
  109. package/dist/result_set.d.ts +1 -1
  110. package/dist/result_set.js +10 -10
  111. package/dist/result_set.js.map +1 -1
  112. package/dist/utils/encoder.d.ts +1 -1
  113. package/dist/utils/encoder.js +5 -5
  114. package/dist/utils/encoder.js.map +1 -1
  115. package/dist/version.d.ts +1 -1
  116. package/dist/version.js +1 -1
  117. package/dist/version.js.map +1 -1
  118. package/package.json +7 -5
  119. package/skills/clickhouse-js-node-rowbinary-parser/EXAMPLES.md +48 -0
  120. package/skills/clickhouse-js-node-rowbinary-parser/README.md +248 -0
  121. package/skills/clickhouse-js-node-rowbinary-parser/SKILL.md +190 -0
  122. package/skills/clickhouse-js-node-rowbinary-parser/case-studies/iot-rowbinary-vs-json.md +83 -0
  123. package/skills/clickhouse-js-node-rowbinary-parser/case-studies/ledger-rowbinary-vs-json.md +103 -0
  124. package/skills/clickhouse-js-node-rowbinary-parser/case-studies/logs-json-wins.md +86 -0
  125. package/skills/clickhouse-js-node-rowbinary-parser/case-studies/wasm-vs-js.md +172 -0
  126. package/skills/clickhouse-js-node-rowbinary-parser/src/aggregateFunction.ts +34 -0
  127. package/skills/clickhouse-js-node-rowbinary-parser/src/bool.ts +10 -0
  128. package/skills/clickhouse-js-node-rowbinary-parser/src/columnar.ts +125 -0
  129. package/skills/clickhouse-js-node-rowbinary-parser/src/composite.ts +181 -0
  130. package/skills/clickhouse-js-node-rowbinary-parser/src/core.ts +77 -0
  131. package/skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts +113 -0
  132. package/skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts +57 -0
  133. package/skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts +328 -0
  134. package/skills/clickhouse-js-node-rowbinary-parser/src/enums.ts +28 -0
  135. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/carts.ts +71 -0
  136. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/events.ts +51 -0
  137. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/iot.ts +158 -0
  138. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/ledger.ts +98 -0
  139. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/logs.ts +73 -0
  140. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/observability.ts +142 -0
  141. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/orders.ts +65 -0
  142. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/profiles.ts +60 -0
  143. package/skills/clickhouse-js-node-rowbinary-parser/src/examples/telemetry.ts +102 -0
  144. package/skills/clickhouse-js-node-rowbinary-parser/src/floats.ts +32 -0
  145. package/skills/clickhouse-js-node-rowbinary-parser/src/geo.ts +109 -0
  146. package/skills/clickhouse-js-node-rowbinary-parser/src/integers.ts +95 -0
  147. package/skills/clickhouse-js-node-rowbinary-parser/src/interval.ts +54 -0
  148. package/skills/clickhouse-js-node-rowbinary-parser/src/ip.ts +93 -0
  149. package/skills/clickhouse-js-node-rowbinary-parser/src/json.ts +33 -0
  150. package/skills/clickhouse-js-node-rowbinary-parser/src/lowCardinality.ts +18 -0
  151. package/skills/clickhouse-js-node-rowbinary-parser/src/nested.ts +23 -0
  152. package/skills/clickhouse-js-node-rowbinary-parser/src/nothing.ts +29 -0
  153. package/skills/clickhouse-js-node-rowbinary-parser/src/reader.ts +51 -0
  154. package/skills/clickhouse-js-node-rowbinary-parser/src/rows.ts +58 -0
  155. package/skills/clickhouse-js-node-rowbinary-parser/src/simpleAggregateFunction.ts +20 -0
  156. package/skills/clickhouse-js-node-rowbinary-parser/src/stream.ts +276 -0
  157. package/skills/clickhouse-js-node-rowbinary-parser/src/strings.ts +55 -0
  158. package/skills/clickhouse-js-node-rowbinary-parser/src/time.ts +61 -0
  159. package/skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts +153 -0
  160. package/skills/clickhouse-js-node-rowbinary-parser/src/varint.ts +70 -0
@@ -0,0 +1,83 @@
1
+ # Case study: RowBinary vs JSON on a table of IoT readings
2
+
3
+ **TL;DR** — On a dense fixed-width numeric row, the skill's optimized RowBinary
4
+ reader decodes **3.5x faster than the best JSON format** (`JSONCompactEachRow`)
5
+ and **5.4x faster than `JSONEachRow`**, over a wire that is **1.6–3.3x smaller**.
6
+ This is the workload shape the [SKILL's format-choice
7
+ guidance](../SKILL.md#first-is-rowbinary-even-the-right-format) points at
8
+ RowBinary for — and the numbers below are _measured_, not assumed.
9
+
10
+ Reproduce: `npx vitest bench --run tests/iot.bench.ts` (against a live
11
+ ClickHouse server). Source: [`tests/iot.bench.ts`](../tests/iot.bench.ts),
12
+ reader: [`src/examples/iot.ts`](../src/examples/iot.ts).
13
+
14
+ ## The data
15
+
16
+ A table of IoT sensor readings — every column fixed-width, not a string in the
17
+ row, so the whole record is a flat 41-byte run:
18
+
19
+ ```sql
20
+ sensor_id UInt32 -- 4 bytes
21
+ ts DateTime64(3) -- 8 bytes
22
+ temperature Float64 -- 8 bytes
23
+ humidity Float64 -- 8 bytes
24
+ pressure Float64 -- 8 bytes
25
+ battery Float32 -- 4 bytes
26
+ status UInt8 -- 1 byte
27
+ ```
28
+
29
+ 50,000 rows, fetched from a live server in three formats and decoded into
30
+ equivalent JS objects. A cross-format check asserts the RowBinary (binary
31
+ float) and JSON (decimal-text → float) decodes agree on every numeric column
32
+ before any timing is taken — so this measures the same work three ways, not
33
+ three different results.
34
+
35
+ ## What was compared
36
+
37
+ - **RowBinary — optimized.** The skill's monomorphized reader: the seven column
38
+ bounds checks coalesce into one `advance(s, 41)`, every field read at a
39
+ constant offset off that base.
40
+ - **RowBinary — API combinators.** The same logic written with the plain
41
+ per-type readers (`readUInt32`, `readFloat64`, …) — the clear default.
42
+ - **JSONCompactEachRow — `JSON.parse`.** Newline-delimited _arrays_ (no repeated
43
+ keys). The strongest JSON contender a knowledgeable user would pick.
44
+ - **JSONEachRow — `JSON.parse`.** Newline-delimited _objects_ (keys repeated
45
+ every row) — the naive idiomatic choice.
46
+
47
+ Both JSON paths use the fastest idiomatic decode: splice the rows into one
48
+ `[...]` document and hand it to V8's native `JSON.parse` in a single call.
49
+
50
+ ## Wire size (HTTP response bytes)
51
+
52
+ | Format | Size | B/row | vs RowBinary |
53
+ | ------------------ | ------- | ----- | ------------ |
54
+ | RowBinary | 2.05 MB | 41.0 | 1.0x |
55
+ | JSONCompactEachRow | 3.38 MB | 67.6 | 1.6x |
56
+ | JSONEachRow | 6.68 MB | 133.6 | 3.3x |
57
+
58
+ ## Decode throughput (full 50k-row decode; higher = faster)
59
+
60
+ | Decoder | ops/s | ms/decode | ≈ rows/s | speedup |
61
+ | --------------------------------- | ----- | --------- | -------- | -------- |
62
+ | **RowBinary — optimized** | 399 | 2.50 | ~20.0 M | **1.0x** |
63
+ | RowBinary — API combinators | 159 | 6.31 | ~7.9 M | 0.40x |
64
+ | JSONCompactEachRow — `JSON.parse` | 114 | 8.76 | ~5.7 M | 0.29x |
65
+ | JSONEachRow — `JSON.parse` | 74 | 13.47 | ~3.7 M | 0.19x |
66
+
67
+ _Node 24 / V8. Your numbers will vary; run `npm run bench` on your own hardware._
68
+
69
+ ## Takeaways
70
+
71
+ - **This is the textbook RowBinary win.** High-volume fixed-width numerics where
72
+ each field is one `DataView` read and there is no text to tokenize or numbers
73
+ to parse from decimal strings. The monomorphization win (2.5x over the
74
+ combinator API) is unusually large here because the whole row coalesces into a
75
+ _single_ bounds check with constant-offset reads.
76
+ - **Format choice matters more than the optimization.** Even the plain
77
+ combinator-API RowBinary reader (~7.9 M rows/s) beats the best JSON option —
78
+ before any monomorphization.
79
+ - **The flip side still holds.** Had this been a string-heavy result (logs, JSON
80
+ blobs, text consumed wholesale), `JSON.parse`'s optimized C++ would likely
81
+ _win_, and the skill would steer you to `JSONEachRow` + compression instead.
82
+ For IoT telemetry, RowBinary is clearly right — match the format to the shape
83
+ of the data.
@@ -0,0 +1,103 @@
1
+ # Case study: RowBinary vs JSON on a financial ledger (wide ints & decimals)
2
+
3
+ **TL;DR** — When every column is wider than a JS `number` can hold (`UInt128`,
4
+ `Int64`, `Decimal128(18)`, `UInt256`), RowBinary wins _twice over_. Stock
5
+ `JSON.parse` is not merely slow here — it is **silently wrong**, rounding every
6
+ value to a float64. The only correct JSON path quotes the values server-side and
7
+ re-parses each string into a `bigint`/decimal pair by hand, which is **~5x
8
+ slower** than the optimized RowBinary reader over a **2.1–2.6x larger** wire.
9
+ RowBinary reads each value exactly, straight off the wire.
10
+
11
+ This is the workload the [SKILL's format-choice
12
+ guidance](../SKILL.md#first-is-rowbinary-even-the-right-format) calls out
13
+ explicitly: "RowBinary clearly wins when the result is dominated by **wide
14
+ numerics** — `Int128`/`Int256`/`UInt128`/`UInt256`, `Decimal128`/`Decimal256`."
15
+
16
+ Reproduce: `npx vitest bench --run tests/ledger.bench.ts` (against a live
17
+ ClickHouse server). Source: [`tests/ledger.bench.ts`](../tests/ledger.bench.ts),
18
+ reader: [`src/examples/ledger.ts`](../src/examples/ledger.ts).
19
+
20
+ ## The data
21
+
22
+ A financial ledger — every column exceeds IEEE-754 double's 53-bit exact range:
23
+
24
+ ```sql
25
+ txn_id UInt128 -- 16 bytes
26
+ account Int64 -- 8 bytes (values past 2^53)
27
+ amount Decimal128(18) -- 16 bytes (~32 significant digits)
28
+ balance Decimal128(18) -- 16 bytes
29
+ fee Decimal64(4) -- 8 bytes
30
+ volume UInt256 -- 32 bytes
31
+ ```
32
+
33
+ 50,000 rows, fixed-width (96 bytes/row), fetched from a live server.
34
+
35
+ ## The correctness trap
36
+
37
+ ClickHouse emits these types as **bare, unquoted JSON numbers**. So stock
38
+ `JSON.parse` parses them as float64 and silently corrupts every one — measured
39
+ on row 0 of the live result:
40
+
41
+ | Column | Exact value (RowBinary) | `JSON.parse` of bare JSON | |
42
+ | --------- | ----------------------------------------- | ----------------------------------------- | ---------------- |
43
+ | `txn_id` | `340282366920938463463374607431768200000` | `340282366920938463463374607431768211456` | ✗ off by 11 456 |
44
+ | `account` | `9007199254740993` | `9007199254740992` | ✗ off by 1 |
45
+ | `amount` | `98765432109876.123456789012345678` | `98765432109876.12` | ✗ lost 16 digits |
46
+
47
+ No exception, no warning — just wrong numbers. For money and IDs, that is a
48
+ correctness bug, not a performance footnote.
49
+
50
+ ### Making JSON correct costs extra work
51
+
52
+ The only way to get exact values through JSON is to **quote them server-side** so
53
+ they arrive as strings, then re-parse each one:
54
+
55
+ ```sql
56
+ ... SETTINGS output_format_json_quote_64bit_integers = 1,
57
+ output_format_json_quote_decimals = 1
58
+ ```
59
+
60
+ ```ts
61
+ txn_id: BigInt(r.txn_id), // string -> bigint
62
+ amount: parseDecimal(r.amount, 18), // string -> [unscaled, scale]
63
+ ```
64
+
65
+ That per-field `BigInt(...)` / decimal parse is work RowBinary doesn't do — it
66
+ reads the exact `bigint` directly with two `DataView` reads — and it lands on
67
+ top of a larger wire (strings are longer than the binary words).
68
+
69
+ ## Wire size (correct paths quote wide values as strings)
70
+
71
+ | Format | Size | vs RowBinary |
72
+ | --------------------------- | -------- | ------------ |
73
+ | RowBinary | 4.80 MB | 1.0x |
74
+ | JSONCompactEachRow (quoted) | 9.88 MB | 2.1x |
75
+ | JSONEachRow (quoted) | 12.28 MB | 2.6x |
76
+
77
+ ## Decode throughput (full 50k-row decode; higher = faster)
78
+
79
+ | Decoder | ops/s | ms/decode | ≈ rows/s | speedup | correct? |
80
+ | -------------------------------------------------- | ----- | --------- | -------- | -------- | -------------- |
81
+ | **RowBinary — optimized** | 130 | 7.71 | ~6.5 M | **1.0x** | ✅ |
82
+ | RowBinary — API combinators | 80 | 12.50 | ~4.0 M | 0.62x | ✅ |
83
+ | JSONEachRow bare — `JSON.parse` only | 44 | 22.74 | ~2.2 M | 0.34x | ❌ **corrupt** |
84
+ | JSONCompactEachRow quoted — parse + BigInt/decimal | 26 | 37.78 | ~1.3 M | 0.20x | ✅ |
85
+ | JSONEachRow quoted — parse + BigInt/decimal | 25 | 40.70 | ~1.2 M | 0.19x | ✅ |
86
+
87
+ _Node 24 / V8. Your numbers will vary; run `npm run bench` on your own hardware._
88
+
89
+ ## Takeaways
90
+
91
+ - **The fast JSON path is the wrong one.** Bare `JSON.parse` is JSON's quickest
92
+ option and it is still 2.95x slower than RowBinary — _and_ it silently
93
+ corrupts every wide value. There is no "fast and correct" JSON here.
94
+ - **The correct JSON path is ~5x slower.** Quote + per-field `BigInt`/decimal
95
+ parsing is the price of correctness, on top of a 2.1–2.6x larger wire.
96
+ - **RowBinary is correct by construction.** Each value is composed from 64-bit
97
+ words read at constant offsets (high word signed for the signed types),
98
+ yielding an exact `bigint` or `[unscaled, scale]` pair — no rounding, no
99
+ string re-parsing.
100
+ - **Contrast with the [IoT case study](iot-rowbinary-vs-json.md):** there the
101
+ numbers fit a float64 and the win was purely throughput (3.5x). Here the values
102
+ don't fit, so the win is _correctness first_, throughput second. Match the
103
+ format to the shape of the data.
@@ -0,0 +1,86 @@
1
+ # Case study: JSON beats RowBinary on a string-heavy log table
2
+
3
+ **TL;DR** — This is the honest counter-case. When the result is mostly **text
4
+ consumed wholesale** (an application log table), `JSONCompactEachRow` +
5
+ `JSON.parse` decodes **1.4x faster** than the optimized RowBinary reader — and
6
+ once you turn on HTTP compression, RowBinary's raw-wire size advantage
7
+ **disappears**: gzip ties the two, and with **zstd the JSON response is actually
8
+ slightly smaller**. For this shape the skill steers you _away_ from RowBinary —
9
+ and proving that is what makes its "use RowBinary here" advice (see the
10
+ [IoT](iot-rowbinary-vs-json.md) and [ledger](ledger-rowbinary-vs-json.md)
11
+ studies) trustworthy.
12
+
13
+ This is exactly what the [SKILL's format-choice
14
+ guidance](../SKILL.md#first-is-rowbinary-even-the-right-format) says: prefer a
15
+ `JSON*` format when the result is "mostly strings / JSON-like values that you
16
+ consume wholesale," because V8's native `JSON.parse` is heavily optimized C++
17
+ and "pair it with HTTP response compression (`gzip` / `zstd`, which crushes
18
+ JSON's repetitive keys)."
19
+
20
+ Reproduce: `npx vitest bench --run tests/logs.bench.ts` (against a live
21
+ ClickHouse server). Source: [`tests/logs.bench.ts`](../tests/logs.bench.ts),
22
+ reader: [`src/examples/logs.ts`](../src/examples/logs.ts).
23
+
24
+ ## The data
25
+
26
+ An application log table — four of five columns are text consumed as text:
27
+
28
+ ```sql
29
+ ts DateTime
30
+ level LowCardinality(String) -- transparent in RowBinary -> plain String
31
+ service LowCardinality(String)
32
+ message String -- templated log line, varying values
33
+ trace_id String -- high-cardinality 32-char hex
34
+ ```
35
+
36
+ 50,000 rows, fetched from a live server. The two `LowCardinality` columns carry
37
+ no dictionary on the RowBinary wire — they decode as plain `String`.
38
+
39
+ ## Decode throughput (full 50k-row decode; higher = faster)
40
+
41
+ | Decoder | ops/s | ms/decode | ≈ rows/s | speedup |
42
+ | ------------------------------------- | ----- | --------- | -------- | -------- |
43
+ | **JSONCompactEachRow — `JSON.parse`** | 93 | 10.73 | ~4.7 M | **1.0x** |
44
+ | JSONEachRow — `JSON.parse` | 72 | 13.89 | ~3.6 M | 0.77x |
45
+ | RowBinary — optimized (monomorphized) | 66 | 15.07 | ~3.3 M | 0.71x |
46
+ | RowBinary — API combinators | 54 | 18.68 | ~2.7 M | 0.57x |
47
+
48
+ `JSONCompactEachRow` (arrays, no repeated keys) is the fastest JSON option and
49
+ beats even the optimized RowBinary reader by ~1.4x. A RowBinary string is a
50
+ varint length + `buf.toString("utf8", …)` decoded one field at a time in JS;
51
+ `JSON.parse` builds the same JS strings in one optimized C++ pass.
52
+
53
+ ## Wire size — raw, and compressed (gzip / zstd)
54
+
55
+ | Format | raw | gzip | zstd |
56
+ | ------------------ | ------- | ------- | ------- |
57
+ | RowBinary | 5.04 MB | 1.46 MB | 1.35 MB |
58
+ | JSONCompactEachRow | 6.84 MB | 1.51 MB | 1.32 MB |
59
+ | JSONEachRow | 8.84 MB | 1.52 MB | 1.33 MB |
60
+
61
+ RowBinary is 1.4–1.8x smaller **raw**, which is the usual argument for it. But
62
+ that edge is mostly JSON's repeated structure (keys, punctuation) — exactly what
63
+ a compressor removes. With `gzip` the three are within ~4% of each other, and
64
+ with `zstd` the JSON responses are _slightly smaller_ than RowBinary. Any
65
+ production HTTP path should have compression on, so the wire-size case for
66
+ RowBinary on this data effectively vanishes.
67
+
68
+ _Node 24 / V8. Your numbers will vary; run `npm run bench` on your own hardware._
69
+
70
+ ## Takeaways
71
+
72
+ - **JSON wins both axes here.** Faster to decode (~1.4x) _and_, once compressed,
73
+ no larger on the wire. There is no reason to hand-write a RowBinary parser for
74
+ this shape.
75
+ - **`JSONCompactEachRow` is the one to reach for** — it drops the per-row
76
+ repeated keys, so it parses faster than `JSONEachRow` and compresses about the
77
+ same.
78
+ - **Compression erases RowBinary's raw-size advantage on text.** RowBinary's
79
+ smaller raw wire comes largely from not repeating keys; a compressor already
80
+ does that for JSON. Always compare _compressed_ sizes when the data is
81
+ string-heavy.
82
+ - **This is the boundary of the skill.** RowBinary earns its keep on
83
+ numeric/wide/binary data ([IoT](iot-rowbinary-vs-json.md),
84
+ [ledger](ledger-rowbinary-vs-json.md)); on string-heavy results read as text,
85
+ the right answer is `JSONCompactEachRow` + compression. Match the format to the
86
+ shape of the data — and measure.
@@ -0,0 +1,172 @@
1
+ # Case study: why JS, not WASM, for RowBinary parsing (and the one place WASM wins)
2
+
3
+ **TL;DR** — A JIT-compiled JS RowBinary reader already streams bytes at **memory
4
+ bandwidth** (~16 GB/s), and the dominant cost of decoding is allocating the JS
5
+ values themselves (objects, `Date`, strings, `BigInt`) — which **WASM cannot do
6
+ and therefore cannot remove**. So for the skill's actual job, turning a RowBinary
7
+ response into usable JS data, WASM buys ~nothing (a wash, or a loss after the
8
+ copy-in tax). WASM wins decisively in exactly **one** different problem:
9
+ **in-place aggregation of wide integers / decimals** (and hash group-by), where
10
+ JS is forced onto heap `BigInt`/`Map`. There we measured a hand-written WASM
11
+ kernel at **27–38x** over JS. But that is _compute_, not parsing — and it is
12
+ usually pushable to ClickHouse anyway. And if you genuinely need heavier
13
+ **client-side analytics**, the lever isn't WASM-over-RowBinary at all — it's a
14
+ **columnar wire format (`Native`)**, coming soon to the JS client out of the
15
+ Python-client collaboration; RowBinary is row-major and fights every analytical
16
+ pass.
17
+
18
+ Reproduce:
19
+
20
+ - `npx vitest bench --run tests/iot.wasm-headroom.bench.ts` (the parsing headroom)
21
+ - `node tests/wasm-int128.experiment.mjs` (the hand-emitted WASM kernel)
22
+
23
+ All numbers Node 24 / V8; yours will vary.
24
+
25
+ ## The idea under test
26
+
27
+ A tempting architecture: a _dynamic WASM JIT inside the JS runtime_. A type
28
+ builder (`t.Int32()`, `t.Map(t.FixedString, t.Int32())`) plus a query DSL
29
+ (`q.sum(q.column(1))`) compile **on the fly** to a WASM module that parses the
30
+ raw network chunk sitting at address 0 in linear memory, computes the answer,
31
+ writes it to a result region, and returns the offset where the incomplete
32
+ trailing row begins (streaming resume). Elegant. The question is _what it would
33
+ win_ — and the honest answer needs three measurements.
34
+
35
+ ## Proof 1 — JIT-compiled JS reads at memory speed
36
+
37
+ V8 compiles `DataView` accessors to native loads. Folding a 32 MB column of
38
+ native-width values (`Float64`) in a plain JS loop:
39
+
40
+ | Read | ms / 32 MB | throughput |
41
+ | ---------------------- | ---------- | ------------- |
42
+ | JS `DataView` f64 fold | 1.94 ms | **16.5 GB/s** |
43
+
44
+ That is essentially RAM bandwidth. **There is no headroom for a "faster
45
+ language" to read these bytes** — JS is already at the metal. A WASM parser
46
+ reading the same bytes lands in the same place (see Proof 3, where the WASM
47
+ kernel reads at 28 GB/s doing _integer_ loads — same order, also bandwidth-bound,
48
+ not 10x).
49
+
50
+ ## Proof 2 — the parsing bottleneck is allocation, which WASM can't touch
51
+
52
+ On the best case for RowBinary (IoT, every column fixed-width numeric), three
53
+ decoders over the same buffer (`tests/iot.wasm-headroom.bench.ts`):
54
+
55
+ | Decode | ms | vs current | what it isolates |
56
+ | ---------------------------------------------------- | ---- | ---------- | -------------------- |
57
+ | **rows** — current fast reader (objects + `Date`) | 3.48 | 1.0x | full materialization |
58
+ | **columnar** — into typed arrays, no per-row objects | 0.86 | 4.0x | drop the objects |
59
+ | **parseOnly** — reads only, zero allocation | 0.61 | 5.8x | the pure-read floor |
60
+
61
+ **~83% of decode time is JS-side object/`Date` allocation**, not byte reading.
62
+ A WASM parser still has to produce those JS values across the boundary, so it
63
+ _cannot_ remove that 83%. Even if WASM made the parse slice instantaneous and the
64
+ copy-in free, the row-object decode would drop only `3.48 → 2.88 ms` — a **max
65
+ ~1.2x**, and realistically a wash once you add the copy into linear memory.
66
+
67
+ The 4.0x that _is_ on the table comes from the **output contract** (columnar
68
+ typed arrays), and it's available in **plain JS** — no WASM. (That columnar path
69
+ is worth shipping; it's the real win this whole investigation surfaced.)
70
+
71
+ ## Proof 3 — the one place WASM wins: wide-int / decimal aggregation
72
+
73
+ Summing an `Int128` column forces JS onto heap `BigInt` (one allocation per
74
+ row). A hand-emitted WASM kernel (94 bytes; native `i64` add-with-carry) does it
75
+ in registers. Same 32 MB buffer, result verified equal to the BigInt sum
76
+ (`tests/wasm-int128.experiment.mjs`):
77
+
78
+ | Sum of an `Int128` column | ms / 32 MB | throughput | |
79
+ | --------------------------------------- | ---------- | ---------- | -------------- |
80
+ | **JS BigInt-128 sum** (what JS must do) | 42.93 ms | 0.7 GB/s | correct |
81
+ | WASM `i64` add-carry — kernel only | 1.14 ms | 28.2 GB/s | correct |
82
+ | WASM + copy-in boundary tax | 1.62 ms | 19.7 GB/s | (copy 0.49 ms) |
83
+
84
+ **WASM is 37.8x faster than JS (26.5x including the copy into linear memory).**
85
+ Note _why_: the win is escaping `BigInt`, not reading bytes faster — the WASM
86
+ kernel (28 GB/s) is the same order as the JS f64 floor (16.5 GB/s). JS pays a
87
+ **22x `BigInt` tax** purely to add 128-bit integers; WASM's native `i64` reclaims
88
+ it. The same logic applies to `Decimal128/256` accumulation and to hash group-by
89
+ (WASM open-addressing table in linear memory vs JS `Map` + GC).
90
+
91
+ ## Verdict on the dynamic-WASM-JIT
92
+
93
+ The architecture is **sound for the aggregation regime and only that regime**.
94
+ It targets the one quadrant where WASM beats well-written JS: _parse and compute
95
+ in place, return a small result, never cross the boundary per value._ The design
96
+ answers its own open questions well:
97
+
98
+ - **Where does the answer go?** Scalars return directly (`i128` via multi-value
99
+ or two `i64`s); group-by results go to a reserved linear-memory region that JS
100
+ reads as a typed-array view — only the small final result crosses.
101
+ - **Streaming.** Returning the resume offset (vs throwing across the FFI) is
102
+ clean, and accumulator state lives in linear memory across chunks — the module
103
+ _is_ the streaming aggregation state.
104
+
105
+ But three caveats bound where it's worth building:
106
+
107
+ 1. **For parsing → JS values, use generated JS, not WASM.** Proofs 1–2: JS is
108
+ already at memory speed and the cost is materialization WASM can't remove. A
109
+ `DSL → new Function(generatedJS)` backend captures the parse + native-numeric
110
+ aggregation case with **zero toolchain**, debuggable. This is the skill's
111
+ existing monomorphization thesis.
112
+ 2. **Reserve a WASM backend for the wide-int/decimal + group-by kernels only** —
113
+ gate it on the presence of `Int128/256`, `Decimal128/256`, or a `GROUP BY`,
114
+ where Proof 3's 27–38x is real. For `Float64` sums it would tie JS.
115
+ 3. **SIMD won't help much** — RowBinary is row-major (AoS); strided columns
116
+ defeat Wasm SIMD (no gather) without a transpose pass. The WASM win here is
117
+ native `i64` + no GC, not vectorization.
118
+ 4. **The elephant: push it down.** `q.sum(col)` is `SELECT sum(col)` — ClickHouse
119
+ will beat any client. Client-side aggregation only justifies itself when you
120
+ _can't_ push down: folding a stream you already receive for another reason,
121
+ combining across queries/sources, or compute SQL can't express.
122
+
123
+ ## If you need more client-side analytical strength: reach for Native columnar
124
+
125
+ Step back from WASM and look at _why_ the wins above are so narrow. RowBinary is
126
+ **row-major (AoS)**: every row interleaves all columns, so any analytical pass —
127
+ fold a column, vectorize, build a column-at-a-time accumulator — has to stride
128
+ over the bytes it doesn't want and re-materialize a value at a time. That is the
129
+ same row-major tax that defeats SIMD (caveat 3) and that makes the free **4x in
130
+ Proof 2 cost a transpose** today (you decode rows, _then_ pack into typed
131
+ arrays).
132
+
133
+ So the honest answer to _"I need real client-side analytical strength"_ is **not
134
+ a smarter parser over RowBinary, and not WASM** — it is a **columnar wire
135
+ format**. ClickHouse's **`Native`** format is **column-major (SoA)**: each block
136
+ arrives as contiguous per-column runs. That flips every constraint in this study:
137
+
138
+ - The Proof-2 columnar typed-array path stops needing a transpose — the wire
139
+ _is_ already `Float64Array`-shaped, so you `subarray`/`set` a column in one
140
+ move instead of decoding rows first.
141
+ - Vectorization becomes real: a contiguous column is exactly what `v128.load` /
142
+ SIMD (and even auto-vectorized JS) want — the gather problem disappears.
143
+ - The wide-int/decimal aggregation win (Proof 3) keeps applying, now over
144
+ contiguous input, which is the friendliest possible layout for it.
145
+
146
+ A columnar reader is **coming to the JS client soon**, out of the **collaboration
147
+ with the Python client** (which already ships a mature `Native`/columnar path —
148
+ the format and lessons port directly). When it lands, the order of preference for
149
+ client-side analytics becomes: **push down to ClickHouse → if you can't, decode
150
+ `Native` columnar → reserve WASM for the wide-int/decimal/group-by kernel on top
151
+ of those columns.** RowBinary stays the right tool for what this skill targets —
152
+ turning a result into JS _rows/values_ — not for analytics over them.
153
+
154
+ ## Takeaways
155
+
156
+ - **Generated JS is the right engine for the parser.** It reads at memory
157
+ bandwidth; the remaining cost is JS-value materialization that no language
158
+ swap removes. WASM for parsing is a wash-to-loss.
159
+ - **The free 4x is a columnar (typed-array) output contract — in pure JS.** Worth
160
+ capturing as a first-class option for numeric results.
161
+ - **WASM earns its complexity in one place: in-place wide-int/decimal/group-by
162
+ aggregation** (27–38x measured), where JS is trapped in `BigInt`/`Map`. And
163
+ even then, prefer pushing the aggregation to ClickHouse unless you genuinely
164
+ can't.
165
+ - **For real client-side analytical strength, the answer is columnar, not WASM.**
166
+ RowBinary is row-major and taxes every analytical pass; a `Native` (SoA)
167
+ columnar reader — coming to the JS client soon via the Python-client
168
+ collaboration — removes the transpose, unlocks SIMD, and is the natural
169
+ substrate for the aggregation kernels above.
170
+ - Matches the rest of the studies' through-line: pick the tool for the shape of
171
+ the work, and **measure** — the 94-byte WASM kernel exists precisely so this
172
+ claim isn't hand-waved.
@@ -0,0 +1,34 @@
1
+ import { type Reader } from "./core.js";
2
+
3
+ /**
4
+ * `AggregateFunction(func, T…)` holds an OPAQUE serialized aggregation STATE
5
+ * (what `-State` combinators produce). In RowBinary this state is written RAW,
6
+ * with **NO length prefix** and a layout entirely specific to `func` (and to the
7
+ * ClickHouse version): `sumState(UInt64)` is 8 bytes, `uniqState(...)` is a
8
+ * variable-length hash-set blob, etc.
9
+ *
10
+ * So it cannot be decoded generically (there is no schema in the bytes) and
11
+ * cannot even be SKIPPED generically (there is no length to skip past) — without
12
+ * knowing `func`'s exact byte layout you cannot find where it ends, and every
13
+ * later column in the row misaligns. There is therefore NO generic reader.
14
+ *
15
+ * Fix it server-side (RECOMMENDED): finalize with the `-Merge` combinator or
16
+ * `finalizeAggregation()` in SQL so the column becomes a normal value
17
+ * (`sum` -> `UInt64`, `uniq` -> `UInt64`, `avg` -> `Float64`, …) and use the
18
+ * matching reader. Never ship raw `-State` columns to the client unless you
19
+ * intend to merge them later.
20
+ *
21
+ * ESCAPE HATCH: a few functions' state IS just a value of a known type (e.g.
22
+ * `sumState(UInt64)` is literally that `UInt64`), so you may decode it as that
23
+ * type — fragile and version-specific; only when you truly know the layout. See
24
+ * `tests/aggregateFunction.test.ts`.
25
+ *
26
+ * This reader throws to stop a generic parser from silently misaligning the row.
27
+ */
28
+ export const readAggregateFunction: Reader<never> = () => {
29
+ throw new Error(
30
+ "RowBinary: AggregateFunction is opaque, unframed aggregation state with no " +
31
+ "length prefix — not generically decodable or skippable. Finalize server-side " +
32
+ "(-Merge / finalizeAggregation()) and decode the concrete result type instead.",
33
+ );
34
+ };
@@ -0,0 +1,10 @@
1
+ import { Cursor } from "./core.js";
2
+ import { readUInt8 } from "./integers.js";
3
+
4
+ /**
5
+ * Read a `Bool`: 1 byte, stored as `UInt8` (`0` = false, `1` = true). Treats any
6
+ * non-zero byte as true.
7
+ */
8
+ export function readBool(state: Cursor): boolean {
9
+ return readUInt8(state) !== 0;
10
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Streaming COLUMNAR decode for an all-numeric, fixed-width RowBinary result —
3
+ * one concrete example reader that ties together the three wins this skill keeps
4
+ * pointing at. The schema is hard-coded on purpose: a real columnar reader is
5
+ * MONOMORPHIZED to its result, so the row loop is straight-line constant-offset
6
+ * reads with no per-field dispatch. Generate one shaped like this per schema.
7
+ *
8
+ * The example schema (`sensor_id UInt32, ts DateTime64(3), value Float64,
9
+ * quality Float32, status UInt8`) — every column fixed-width, stride 25 bytes:
10
+ *
11
+ * sensor_id UInt32 @ o+0 getUint32 -> Uint32Array
12
+ * ts DateTime64(3) @ o+4 2x getUint32 -> BigInt64Array (raw ms ticks)
13
+ * value Float64 @ o+12 getFloat64 -> Float64Array
14
+ * quality Float32 @ o+20 getFloat32 -> Float32Array
15
+ * status UInt8 @ o+24 buf[o+24] -> Uint8Array
16
+ *
17
+ * The three wins:
18
+ *
19
+ * 1. COLUMNAR (struct-of-arrays). One typed array per column, not one object
20
+ * per row — removes the per-row object / `Date` / number-boxing allocation
21
+ * that dominates a numeric decode (~4x in plain JS; see `src/examples/iot.ts`
22
+ * and `tests/iot.columnar.bench.ts`). Keep `ts` as raw `BigInt64Array` ticks
23
+ * and make a `Date` lazily, per displayed row — never allocate 50k `Date`s.
24
+ * The `Int64` column is itself filled WITHOUT allocating a bigint per row:
25
+ * copy the two little-endian 32-bit words straight into a `Uint32Array` view
26
+ * over its buffer (`getBigInt64` would box a bigint each row); the bigint is
27
+ * materialized lazily, only when the consumer reads `ts[i]`.
28
+ *
29
+ * 2. TRANSFERABLE. Each column is a fresh, exactly-sized typed array that OWNS
30
+ * its `ArrayBuffer` at offset 0, so a batch ships to a Worker / WASM kernel
31
+ * zero-copy: `postMessage(batch, columns.map(c => c.buffer))`.
32
+ *
33
+ * 3. RESPECTS INCOMPLETE BUFFERS (streaming). Because the stride is constant,
34
+ * honoring a partial trailing row is pure ARITHMETIC: the number of complete
35
+ * rows in the buffer is `(work.length / STRIDE) | 0`. No `advance()`, no
36
+ * `NeedMoreData`, no throw/restart — the leftover `work.length % STRIDE` bytes
37
+ * just carry to the next chunk. Strictly cheaper than the row-oriented
38
+ * `streamRowBatches`, which re-decodes the partial row on every boundary.
39
+ *
40
+ * SCOPE: fixed-width numeric columns only — the ClickHouse types with a 1:1
41
+ * native TypedArray (`Int8/16/32/64`, `UInt8/16/32/64`, `Float32/64`). Anything
42
+ * whose value isn't one native-typed number has no constant stride to divide by
43
+ * (`String`/`Array`/`Map`/`Tuple`) or no 1:1 array (`Int128`+, `Decimal*`,
44
+ * `BFloat16`); decode those row-wise. `Bool`/`Enum`/`Date*`/`DateTime*` ride
45
+ * their underlying int here for the RAW value.
46
+ */
47
+
48
+ /** One decoded batch of the example schema: `rows` complete rows, one typed array per column. */
49
+ export interface SensorColumnBatch {
50
+ /** Number of complete rows decoded in this batch. */
51
+ rows: number;
52
+ columns: {
53
+ sensor_id: Uint32Array;
54
+ ts: BigInt64Array; // raw DateTime64(3) ms ticks
55
+ value: Float64Array;
56
+ quality: Float32Array;
57
+ status: Uint8Array;
58
+ };
59
+ }
60
+
61
+ /** Byte stride of one fixed-width row: 4 + 8 + 8 + 4 + 1. */
62
+ const STRIDE = 25;
63
+
64
+ const EMPTY_CHUNK = Buffer.alloc(0);
65
+
66
+ /**
67
+ * Stream a chunked RowBinary response of the example schema into columnar
68
+ * batches: one `{ rows, columns }` per incoming chunk, holding exactly the rows
69
+ * that completed within it.
70
+ *
71
+ * BACKPRESSURE: a pull stream — the next chunk is requested only when the
72
+ * consumer asks for the next batch. SMALL CHUNKS: tiny chunks mean tiny batches
73
+ * (more allocations, worse Worker amortization); compose `coalesceChunks` (from
74
+ * `./stream.js`) in front to merge them up to a target size first.
75
+ */
76
+ export async function* streamSensorColumns(
77
+ chunks: AsyncIterable<Uint8Array>,
78
+ ): AsyncGenerator<SensorColumnBatch, void, undefined> {
79
+ let carry: Buffer = EMPTY_CHUNK;
80
+ for await (const chunk of chunks) {
81
+ // Wrap as a Buffer VIEW over the chunk's bytes — no copy (a Buffer made from
82
+ // an ArrayBuffer slice shares it). We own the chunk for the life of this
83
+ // generator, so holding a view into it is safe.
84
+ const incoming = Buffer.from(
85
+ chunk.buffer,
86
+ chunk.byteOffset,
87
+ chunk.byteLength,
88
+ );
89
+ const work =
90
+ carry.length === 0 ? incoming : Buffer.concat([carry, incoming]);
91
+
92
+ // Complete rows available right now — pure arithmetic, since STRIDE is fixed.
93
+ const n = (work.length / STRIDE) | 0;
94
+ if (n > 0) {
95
+ const view = new DataView(work.buffer, work.byteOffset, work.byteLength);
96
+ const sensor_id = new Uint32Array(n);
97
+ const ts = new BigInt64Array(n);
98
+ // Uint32 view over ts's OWN bytes: 2 little-endian words per Int64,
99
+ // [lo, hi, lo, hi, ...]. Filling ts through this view copies the raw bytes
100
+ // and skips the per-row bigint allocation `getBigInt64` would force; the
101
+ // bigint is materialized lazily, only for rows the consumer indexes.
102
+ const tsWords = new Uint32Array(ts.buffer);
103
+ const value = new Float64Array(n);
104
+ const quality = new Float32Array(n);
105
+ const status = new Uint8Array(n);
106
+ for (let i = 0, o = 0; i < n; i++, o += STRIDE) {
107
+ sensor_id[i] = view.getUint32(o, true); // UInt32 @ o+0
108
+ // DateTime64(3) Int64 @ o+4: two LE 32-bit words, no bigint allocated.
109
+ tsWords[i * 2] = view.getUint32(o + 4, true); // low word
110
+ tsWords[i * 2 + 1] = view.getUint32(o + 8, true); // high word
111
+ value[i] = view.getFloat64(o + 12, true); // Float64 @ o+12
112
+ quality[i] = view.getFloat32(o + 20, true); // Float32 @ o+20
113
+ status[i] = work[o + 24]!; // UInt8 @ o+24
114
+ }
115
+ yield { rows: n, columns: { sensor_id, ts, value, quality, status } };
116
+ }
117
+ // Carry the partial trailing row (if any) to the next chunk.
118
+ carry = work.subarray(n * STRIDE);
119
+ }
120
+ if (carry.length > 0) {
121
+ throw new Error(
122
+ `RowBinary stream ended mid-row: ${carry.length} trailing byte(s) left undecoded`,
123
+ );
124
+ }
125
+ }