From 004bdadf6a5194dd302eccc5049d4a2871c1cb91 Mon Sep 17 00:00:00 2001 From: Peter Leonov Date: Mon, 22 Jun 2026 14:55:45 +0200 Subject: [PATCH 1/5] Adding the `RowBinary` library and skill with CI workflow scoping for skill-only changes (#864) ## Summary Adding a high-performance `RowBinary` reader library supporting 95% of types. The library is also itself a skill for LLMs to generate even faster (2-3x) bespoke optimized concrete response type parsers. Additionally, CI workflow triggers were updated so skill-only changes under `skills/**` do not run unrelated monorepo library test workflows (Node/Web/Bun/OSS dependents/examples), while skills validation remains covered by the Skills E2E workflow. ## Checklist Delete items not relevant to your PR: - [x] Unit and integration tests covering the common scenarios were added - [x] A human-readable description of the changes was provided to include in CHANGELOG - [ ] For significant changes, documentation in https://github.com/ClickHouse/clickhouse-docs was updated with further explanations or tutorials --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/examples.yml | 2 + .../publish-skill-rowbinary-parser.yml | 50 + .github/workflows/tests-bun.yml | 2 + .github/workflows/tests-node.yml | 2 + .github/workflows/tests-oss-dependents.yml | 2 + .../tests-skill-rowbinary-parser.yml | 75 + .github/workflows/tests-web.yml | 2 + CHANGELOG.md | 3 + packages/client-node/package.json | 6 +- .../.gitignore | 4 + .../EXAMPLES.md | 48 + .../README.md | 248 +++ .../SKILL.md | 190 +++ .../case-studies/iot-rowbinary-vs-json.md | 83 ++ .../case-studies/ledger-rowbinary-vs-json.md | 103 ++ .../case-studies/logs-json-wins.md | 86 ++ .../case-studies/wasm-vs-js.md | 172 +++ .../eval_result.md | 105 ++ .../eval_result_composer.md | 73 + .../eval_result_haiku.md | 80 + .../eval_result_sonnet.md | 99 ++ .../package-lock.json | 1328 +++++++++++++++++ .../package.json | 63 + .../src/aggregateFunction.ts | 34 + .../src/bool.ts | 10 + .../src/columnar.ts | 125 ++ .../src/composite.ts | 181 +++ .../src/core.ts | 77 + .../src/datetime.ts | 113 ++ .../src/decimals.ts | 57 + .../src/dynamic.ts | 328 ++++ .../src/enums.ts | 28 + .../src/examples/carts.ts | 71 + .../src/examples/events.ts | 51 + .../src/examples/iot.ts | 158 ++ .../src/examples/ledger.ts | 98 ++ .../src/examples/logs.ts | 73 + .../src/examples/observability.ts | 142 ++ .../src/examples/orders.ts | 65 + .../src/examples/profiles.ts | 60 + .../src/examples/telemetry.ts | 102 ++ .../src/floats.ts | 32 + .../src/geo.ts | 109 ++ .../src/integers.ts | 95 ++ .../src/interval.ts | 54 + .../src/ip.ts | 93 ++ .../src/json.ts | 33 + .../src/lowCardinality.ts | 18 + .../src/nested.ts | 23 + .../src/nothing.ts | 29 + .../src/reader.ts | 51 + .../src/rows.ts | 58 + .../src/simpleAggregateFunction.ts | 20 + .../src/stream.ts | 276 ++++ .../src/strings.ts | 55 + .../src/time.ts | 61 + .../src/uuid.ts | 153 ++ .../src/varint.ts | 70 + .../tests/Array.test.ts | 61 + .../tests/BFloat16.test.ts | 44 + .../tests/Bool.test.ts | 38 + .../tests/Date.test.ts | 40 + .../tests/Date32.test.ts | 42 + .../tests/DateTime.test.ts | 45 + .../tests/DateTime64.test.ts | 54 + .../tests/DateTime64P3.test.ts | 39 + .../tests/DateTime64P6.test.ts | 40 + .../tests/DateTime64P9.test.ts | 40 + .../tests/Decimal128.test.ts | 46 + .../tests/Decimal256.test.ts | 48 + .../tests/Decimal32.test.ts | 55 + .../tests/Decimal64.test.ts | 45 + .../tests/Dynamic.test.ts | Bin 0 -> 12872 bytes .../tests/Enum16.test.ts | 44 + .../tests/Enum8.test.ts | 47 + .../tests/FixedString.test.ts | 43 + .../tests/FixedStringBytes.test.ts | 44 + .../tests/Float32.test.ts | 47 + .../tests/Float64.test.ts | 47 + .../tests/Geometry.test.ts | 66 + .../tests/IPv4.test.ts | 48 + .../tests/IPv6.test.ts | 67 + .../tests/Int128.test.ts | 49 + .../tests/Int16.test.ts | 75 + .../tests/Int256.test.ts | 52 + .../tests/Int32.test.ts | 46 + .../tests/Int64.test.ts | 50 + .../tests/Int8.test.ts | 46 + .../tests/Interval.test.ts | 45 + .../tests/JSON.test.ts | 108 ++ .../tests/LineString.test.ts | 38 + .../tests/Map.test.ts | 65 + .../tests/MultiLineString.test.ts | 40 + .../tests/MultiPolygon.test.ts | 45 + .../tests/Nullable.test.ts | 61 + .../tests/Point.test.ts | 36 + .../tests/Polygon.test.ts | 41 + .../tests/Ring.test.ts | 39 + .../tests/String.test.ts | 52 + .../tests/Time.test.ts | 49 + .../tests/Time64.test.ts | 48 + .../tests/Tuple.test.ts | 47 + .../tests/TupleNamed.test.ts | 55 + .../tests/UInt128.test.ts | 47 + .../tests/UInt16.test.ts | 43 + .../tests/UInt256.test.ts | 50 + .../tests/UInt32.test.ts | 38 + .../tests/UInt64.test.ts | 44 + .../tests/UInt8.test.ts | 38 + .../tests/UUID.test.ts | 119 ++ .../tests/UUIDBigInt.test.ts | 60 + .../tests/UUIDHiLo.test.ts | 59 + .../tests/UVarint.test.ts | 99 ++ .../tests/Variant.test.ts | 66 + .../tests/advance.test.ts | 85 ++ .../tests/aggregateFunction.test.ts | 87 ++ .../tests/carts.bench.ts | 47 + .../tests/carts.example.test.ts | 62 + .../tests/clickhouse.ts | 39 + .../tests/coalesceChunks.test.ts | 143 ++ .../tests/columnar.test.ts | 150 ++ .../tests/combinations.generated.test.ts | 298 ++++ .../tests/events.bench.ts | 48 + .../tests/events.example.test.ts | 48 + .../tests/framing-interleaved.test.ts | 352 +++++ .../tests/framing-nested.test.ts | 344 +++++ .../tests/framing.test.ts | 609 ++++++++ .../tests/iot.bench.ts | 134 ++ .../tests/iot.columnar.bench.ts | 57 + .../tests/iot.wasm-headroom.bench.ts | 132 ++ .../tests/ledger.bench.ts | 201 +++ .../tests/logs.bench.ts | 112 ++ .../tests/lowCardinality.test.ts | 51 + .../tests/nested.test.ts | 57 + .../tests/nothing.test.ts | 75 + .../tests/observability.bench.ts | 64 + .../tests/observability.example.test.ts | 84 ++ .../tests/orders.bench.ts | 48 + .../tests/orders.example.test.ts | 64 + .../tests/profiles.bench.ts | 47 + .../tests/profiles.example.test.ts | 46 + .../tests/qbit.test.ts | 74 + .../tests/readUUID.bench.ts | 38 + .../tests/rows.test.ts | 213 +++ .../tests/simpleAggregateFunction.test.ts | 57 + .../tests/streamRowBatches.test.ts | 169 +++ .../tests/streamingRow.bench.ts | 227 +++ .../tests/telemetry.bench.ts | 50 + .../tests/telemetry.example.test.ts | 80 + .../tests/wasm-int128.experiment.mjs | 246 +++ .../tsconfig.build.json | 15 + .../tsconfig.json | 18 + .../vitest.config.ts | 11 + 153 files changed, 13350 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-skill-rowbinary-parser.yml create mode 100644 .github/workflows/tests-skill-rowbinary-parser.yml create mode 100644 skills/clickhouse-js-node-rowbinary-parser/.gitignore create mode 100644 skills/clickhouse-js-node-rowbinary-parser/EXAMPLES.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/README.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/SKILL.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/case-studies/iot-rowbinary-vs-json.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/case-studies/ledger-rowbinary-vs-json.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/case-studies/logs-json-wins.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/case-studies/wasm-vs-js.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/eval_result.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/eval_result_composer.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/eval_result_haiku.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/eval_result_sonnet.md create mode 100644 skills/clickhouse-js-node-rowbinary-parser/package-lock.json create mode 100644 skills/clickhouse-js-node-rowbinary-parser/package.json create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/aggregateFunction.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/bool.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/columnar.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/composite.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/core.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/enums.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/carts.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/events.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/iot.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/ledger.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/logs.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/observability.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/orders.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/profiles.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/examples/telemetry.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/floats.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/geo.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/integers.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/interval.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/ip.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/json.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/lowCardinality.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/nested.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/nothing.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/reader.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/rows.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/simpleAggregateFunction.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/stream.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/strings.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/time.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/src/varint.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Array.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/BFloat16.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Bool.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Date.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Date32.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/DateTime.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P3.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P6.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P9.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Decimal128.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Decimal256.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Decimal32.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Decimal64.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Dynamic.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Enum16.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Enum8.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/FixedString.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/FixedStringBytes.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Float32.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Float64.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Geometry.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/IPv4.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/IPv6.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Int128.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Int16.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Int256.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Int32.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Int64.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Int8.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Interval.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/JSON.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/LineString.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Map.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/MultiLineString.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/MultiPolygon.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Nullable.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Point.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Polygon.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Ring.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/String.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Time.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Time64.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Tuple.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/TupleNamed.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UInt128.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UInt16.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UInt256.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UInt32.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UInt64.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UInt8.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UUID.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UUIDBigInt.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UUIDHiLo.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/UVarint.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/Variant.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/advance.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/aggregateFunction.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/carts.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/carts.example.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/clickhouse.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/coalesceChunks.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/columnar.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/combinations.generated.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/events.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/events.example.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/framing-interleaved.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/framing-nested.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/framing.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/iot.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/iot.columnar.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/iot.wasm-headroom.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/ledger.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/logs.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/lowCardinality.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/nested.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/nothing.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/observability.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/observability.example.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/orders.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/orders.example.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/profiles.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/profiles.example.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/qbit.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/readUUID.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/rows.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/simpleAggregateFunction.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/streamRowBatches.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/streamingRow.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.bench.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.example.test.ts create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tests/wasm-int128.experiment.mjs create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tsconfig.build.json create mode 100644 skills/clickhouse-js-node-rowbinary-parser/tsconfig.json create mode 100644 skills/clickhouse-js-node-rowbinary-parser/vitest.config.ts diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index b90e7f842..a6ac10344 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -11,11 +11,13 @@ on: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" pull_request: paths-ignore: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" concurrency: group: "${{ github.workflow }}-${{ github.ref }}" diff --git a/.github/workflows/publish-skill-rowbinary-parser.yml b/.github/workflows/publish-skill-rowbinary-parser.yml new file mode 100644 index 000000000..59ae71e82 --- /dev/null +++ b/.github/workflows/publish-skill-rowbinary-parser.yml @@ -0,0 +1,50 @@ +name: "publish: rowbinary parser" + +# Independent publish for the standalone @clickhouse/rowbinary +# package (the RowBinary parser skill). It is NOT part of the npm workspace +# lockstep release driven by publish.yml — it carries its own version in +# skills/clickhouse-js-node-rowbinary-parser/package.json and ships on its own +# cadence. Triggered manually; publishes the version currently in package.json +# with the "latest" tag using npm OIDC authentication and provenance. + +permissions: + contents: read + id-token: write # Required for npm OIDC authentication and provenance + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +on: + workflow_dispatch: + +defaults: + run: + working-directory: skills/clickhouse-js-node-rowbinary-parser + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm-publish + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Publish to npm + # prepack copies the repo-root LICENSE and rebuilds dist before packing. + run: npm publish --access public --provenance diff --git a/.github/workflows/tests-bun.yml b/.github/workflows/tests-bun.yml index b464da771..ff2c109c0 100644 --- a/.github/workflows/tests-bun.yml +++ b/.github/workflows/tests-bun.yml @@ -10,11 +10,13 @@ on: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" pull_request: paths-ignore: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" concurrency: group: "${{ github.workflow }}-${{ github.ref }}" diff --git a/.github/workflows/tests-node.yml b/.github/workflows/tests-node.yml index 260300722..3b10523bd 100644 --- a/.github/workflows/tests-node.yml +++ b/.github/workflows/tests-node.yml @@ -10,11 +10,13 @@ on: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" pull_request: paths-ignore: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" schedule: - cron: "0 9 * * *" diff --git a/.github/workflows/tests-oss-dependents.yml b/.github/workflows/tests-oss-dependents.yml index 38dc93ca7..69f6f3702 100644 --- a/.github/workflows/tests-oss-dependents.yml +++ b/.github/workflows/tests-oss-dependents.yml @@ -10,11 +10,13 @@ on: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" pull_request: paths-ignore: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" schedule: - cron: "0 9 * * *" diff --git a/.github/workflows/tests-skill-rowbinary-parser.yml b/.github/workflows/tests-skill-rowbinary-parser.yml new file mode 100644 index 000000000..16aaccd26 --- /dev/null +++ b/.github/workflows/tests-skill-rowbinary-parser.yml @@ -0,0 +1,75 @@ +name: "skill: rowbinary parser" + +permissions: {} +on: + workflow_dispatch: + push: + branches: + - main + paths: + - .github/workflows/tests-skill-rowbinary-parser.yml + - skills/clickhouse-js-node-rowbinary-parser/** + pull_request: + paths: + - .github/workflows/tests-skill-rowbinary-parser.yml + - skills/clickhouse-js-node-rowbinary-parser/** + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + typecheck: + timeout-minutes: 5 + runs-on: ubuntu-latest + defaults: + run: + working-directory: skills/clickhouse-js-node-rowbinary-parser + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup NodeJS + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + unit-tests: + timeout-minutes: 5 + runs-on: ubuntu-latest + defaults: + run: + working-directory: skills/clickhouse-js-node-rowbinary-parser + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] + clickhouse: [head, latest] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Start ClickHouse (version - ${{ matrix.clickhouse }}) in Docker + uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0 + env: + CLICKHOUSE_VERSION: ${{ matrix.clickhouse }} + with: + # The skill suite only needs the single-node HTTP server on :8123. + compose-file: "docker-compose.yml" + services: "clickhouse" + down-flags: "--volumes" + + - name: Setup NodeJS ${{ matrix.node }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ matrix.node }} + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test diff --git a/.github/workflows/tests-web.yml b/.github/workflows/tests-web.yml index fd7d53dc2..c368f09b5 100644 --- a/.github/workflows/tests-web.yml +++ b/.github/workflows/tests-web.yml @@ -10,11 +10,13 @@ on: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" pull_request: paths-ignore: - "**/*.md" - "LICENSE" - "benchmarks/**" + - "skills/**" schedule: - cron: "0 9 * * *" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3804223a5..b410d50db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - (Node.js) Brotli (`{ codec: "br" }`) is now supported for `compression.request` / `compression.response`, alongside gzip and zstd. Unlike zstd, Brotli is available on every supported Node.js version (no minimum-version requirement). The `compression.request` option is a per-codec discriminated union, so each codec exposes its own tuning option: a `level` for gzip/zstd, a `quality` for Brotli (`{ codec: "br", quality }`). When omitted, Brotli defaults to quality 4 for request bodies, since zlib's brotli default of 11 (max) is far too slow for a streaming insert path. Response decompression follows the server's `Content-Encoding`. Supported only by `@clickhouse/client` (Node.js). +- (Node.js) Added a RowBinary reader library and agent skill under [`skills/clickhouse-js-node-rowbinary-parser`](./skills/clickhouse-js-node-rowbinary-parser). It ships type-specific, monomorphizable building blocks for decoding `RowBinary` / `RowBinaryWithNames` / `RowBinaryWithNamesAndTypes` streams (full-buffer and chunked), plus a skill that guides an agent to generate bespoke high-performance parsers from a query's column types. The skill is bundled into `@clickhouse/client` (registered in `agents.skills`) and is also published independently as the [`@clickhouse/rowbinary`](https://www.npmjs.com/package/@clickhouse/rowbinary) package. A matching RowBinary writer is planned. ([#864]) + ## Internal changes (`@clickhouse/client-common`) > These only affect code that imports the low-level connection primitives from the deprecated `@clickhouse/client-common` package directly (e.g. a custom `Connection` implementation). The `createClient` `compression` option is unchanged and fully backwards compatible — if you only use `@clickhouse/client` or `@clickhouse/client-web`, you are not affected. @@ -64,6 +66,7 @@ await client.query({ [#825]: https://github.com/ClickHouse/clickhouse-js/pull/825 [#827]: https://github.com/ClickHouse/clickhouse-js/pull/827 [#828]: https://github.com/ClickHouse/clickhouse-js/pull/828 +[#864]: https://github.com/ClickHouse/clickhouse-js/pull/864 ## Bug Fixes diff --git a/packages/client-node/package.json b/packages/client-node/package.json index 6fc0b578e..2e41ea5fb 100644 --- a/packages/client-node/package.json +++ b/packages/client-node/package.json @@ -32,12 +32,16 @@ { "name": "clickhouse-js-node-troubleshooting", "path": "./skills/clickhouse-js-node-troubleshooting" + }, + { + "name": "clickhouse-js-node-rowbinary-parser", + "path": "./skills/clickhouse-js-node-rowbinary-parser" } ] }, "scripts": { "pack": "npm pack", - "prepack": "rm -rf skills && cp ../../README.md ../../LICENSE . && cp -r ../../skills .", + "prepack": "rm -rf skills && cp ../../README.md ../../LICENSE . && cp -r ../../skills . && RBP=skills/clickhouse-js-node-rowbinary-parser && rm -rf $RBP/tests $RBP/node_modules $RBP/dist $RBP/package.json $RBP/package-lock.json $RBP/tsconfig.json $RBP/tsconfig.build.json $RBP/vitest.config.ts $RBP/.gitignore $RBP/LICENSE $RBP/eval_result*.md", "typecheck": "tsc --noEmit", "lint": "eslint --max-warnings=0 .", "lint:fix": "eslint . --fix", diff --git a/skills/clickhouse-js-node-rowbinary-parser/.gitignore b/skills/clickhouse-js-node-rowbinary-parser/.gitignore new file mode 100644 index 000000000..a2bb8401f --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +# Copied from the repo root by `prepack` so the published tarball carries it. +LICENSE diff --git a/skills/clickhouse-js-node-rowbinary-parser/EXAMPLES.md b/skills/clickhouse-js-node-rowbinary-parser/EXAMPLES.md new file mode 100644 index 000000000..9a18b686b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/EXAMPLES.md @@ -0,0 +1,48 @@ +# RowBinary reader examples + +Six end-to-end examples. Each `src/examples/*.ts` exports TWO readers for the +same row — `readXRow` (built from the generic combinator API; easiest to read) +and `readXRowFast` (the optimized, monomorphized form: leaf reads inlined, +combinators flattened to straight-line loops, `advance()` coalesced over +fixed-width runs — still streaming-safe). The matching `tests/X.example.test.ts` +runs the full create → populate → read-back round trip against a live ClickHouse +server (verified, not illustrative), and `tests/X.bench.ts` decodes a large +`numbers()`-generated buffer with both readers (equivalence-checked before +timing) to measure the speedup. + +To use one: find the example whose column types match your result, open its +reader, and adapt it. `readRows(readXRow)` drives a row reader over a whole +result; `streamRowBatches(chunks, readXRow)` drives it over a chunked HTTP stream. + +| Example | SQL schema (the trigger) | Speedup | Reader · Test | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| **orders** | `id UInt8, uid UUID, price Decimal64(2), status Enum8(...)` | **~3.4x** | [`src/examples/orders.ts`](src/examples/orders.ts) · [`tests/orders.example.test.ts`](tests/orders.example.test.ts) | +| **carts** | `cart_id UInt32, items Array(Tuple(sku String, qty UInt16)), discounts Array(Nullable(Int32))` | **~2.0x** | [`src/examples/carts.ts`](src/examples/carts.ts) · [`tests/carts.example.test.ts`](tests/carts.example.test.ts) | +| **telemetry** | `host String, tags Map(String,String), cpu Array(Float64), region Nullable(String), window Tuple(start UInt32, count UInt16)` | **~1.4x** | [`src/examples/telemetry.ts`](src/examples/telemetry.ts) · [`tests/telemetry.example.test.ts`](tests/telemetry.example.test.ts) | +| **observability** | `id UInt64, ts DateTime64(3), level Enum8, trace_id UUID, payload Variant(String,Int64,Float64), tags Map(LowCardinality(String),String), metrics Array(Tuple(LowCardinality(String),Float64)), attrs Array(Nullable(Int64))` | **~1.4x** | [`src/examples/observability.ts`](src/examples/observability.ts) · [`tests/observability.example.test.ts`](tests/observability.example.test.ts) | +| **profiles** | `id UInt32, tags Array(String), score Nullable(Int32)` | **~1.3x** | [`src/examples/profiles.ts`](src/examples/profiles.ts) · [`tests/profiles.example.test.ts`](tests/profiles.example.test.ts) | +| **events** | `id UInt64, name String, ts DateTime('UTC')` | **~1.05x — on par** | [`src/examples/events.ts`](src/examples/events.ts) · [`tests/events.example.test.ts`](tests/events.example.test.ts) | + +Speedups: Node 24 / V8, decoding a 20k-row buffer — read the ratio, not the +absolute hz, and run `npm run bench` for your own numbers. Two independent levers +drive them: **composite monomorphization** (removes per-row combinator closures — +`carts` / `telemetry` / `observability`) and **per-row formatting** (`orders` is +all-scalar yet the biggest win, almost entirely from the `formatUUIDTable` swap). +A flat scalar row with no hot formatter (`events`) is within noise, so prefer the +clearer API reader there. When in doubt, benchmark — the `*.bench.ts` files are +the template. + +The readers live under `src/examples/` and are excluded from the published build +(`tsconfig.build.json`): reference material and test fixtures, type-checked by the +base `tsconfig.json` and run by the suite, not part of the package's public API. + +## Columnar decode (struct-of-arrays) — the ~4x numeric path + +The examples above produce one object per row (array-of-structs). For a +**numeric, fixed-width result the consumer reads column-wise** (aggregate / scan +/ filter / plot, or hand off to a Worker / WASM kernel), decode the same +row-major bytes directly into **one typed array per column** in the same single +pass — no per-row object, no `Date`, no number boxing. That removes the +allocation that dominates a numeric decode for a **measured ~4.2x**. + +See example: [`decodeIotColumnar`](src/examples/iot.ts). diff --git a/skills/clickhouse-js-node-rowbinary-parser/README.md b/skills/clickhouse-js-node-rowbinary-parser/README.md new file mode 100644 index 000000000..441c5de10 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/README.md @@ -0,0 +1,248 @@ +# ClickHouse Node.js RowBinary Parser Generator + +**If JS had a -O3 compiler flag, this skill would be it.** (for RowBinary parsing) + +A skill and a library that lets a coding agent generate bespoke RowBinary parsers on the first pass from the column type definitions of a ClickHouse response. The [spirit](#the-spirit) behind the approach. + +**Reader only** for now. Today this covers reading (decoding) RowBinary streams. A matching RowBinary writer (encoding) is planned. + +## Status + +- ✅ Sonnet 4.6: 60% -> 94.0% pass rate +- ✅ Opus 4.8: 71% -> 94.7% pass rate +- ✅ Haiku 4.5: 52% -> 86.0% pass rate +- ✅ Composer 2.5 Fast: 3x parser performance +- ✅ 469/469 tests +- ✅ type-checked +- ✅ benchmarked + +## Example + +Take a small orders result: + +```sql +SELECT id, uid, price, status FROM orders +-- id UInt8 +-- uid UUID +-- price Decimal64(2) +-- status Enum8('new' = 1, 'shipped' = 2, 'done' = 3) +``` + +**The API-only reader** — what you write by composing the library's combinators. Correct, clear, and a fine default: + +```ts +const readOrderRow: Reader = (s) => ({ + id: readUInt8(s), + uid: formatUUID(readUUID(s)), + price: readDecimal64(2)(s), + status: readEnum8(s), +}); +``` + +**The optimized reader the skill generates** — same row, monomorphized to +straight-line code. The whole row is fixed-width (1 + 16 + 8 + 1 = 26 bytes), so +the four separate bounds checks coalesce into one `advance(s, 26)` and every leaf +read happens at a constant offset; the per-field combinators are gone: + +```ts +const readOrderRowFast: Reader = (s) => { + const { buf, view } = s; + const o = advance(s, 26); // one bounds check for the whole 26-byte row + const id = buf[o]!; + const uid = formatUUIDTable(buf.subarray(o + 1, o + 17)); + const price: DecimalValue = [view.getBigInt64(o + 17, true), 2]; + const status = view.getInt8(o + 25); + return { id, uid, price, status }; +}; +``` + +Same values, same streaming-safety — **~3.4x** faster. + +## How to use + +As a library (comes with the skill): + +```bash +npm install @clickhouse/rowbinary +npx skills-npm setup +``` + +As a skill only: + +```bash +npx skills add ClickHouse/clickhouse-js/skills/clickhouse-js-node-rowbinary-parser +``` + +```console +> Hey, Claude, tell me what the rowbinary parser skill can do for me. +> A lot! It generates custom, high-performance RowBinary parsers… +> Super, generate a parser for the queries in app/src/model.ts. +< Reading skill clickhouse-js-node-rowbinary-parser… +``` + +## Why it's worth it + +Four pillars — speed, correctness, judgment, and lifting smaller models: + +- **~2–3x faster code than the straightforward decoder.** The skill emits + monomorphized, flattened, straight-line code — inlined reads, bounds checks + coalesced across adjacent fixed-width columns, the right array layout — measured + at ~1.3–3.4x over the _same logic written with the plain combinator API_ + (`npm run bench`). This is why + - inlined JIT friendly code + - benchmarked hot paths + - minimal allocations + - v8 and Node.js specific optimizations +- **Correct on the gotchas that otherwise quietly break.** UUID byte + order, `Variant`'s sort-by-type-name discriminant, `DateTime64` sub-second + precision, signed-high-word wide integers, faithful decimals, `Dynamic`/`JSON` + self-description, transparent wrappers, opaque `AggregateFunction` — each + encoded with a live, server-verified test ([details below](#correctness-on-the-gotcha-heavy-types)). +- **Judgment, not just code.** The skill carries the working knowledge to make + the right call _before_ writing a line, so the agent neither over- nor + under-engineers: + - **Is RowBinary even right?** For string-heavy results read as text, a `JSON*` + format + V8's native `JSON.parse` (plus `gzip`/`zstd`) can beat a JS RowBinary + decoder — reach for RowBinary when the data is numeric / wide-integer / + binary-blob heavy. + - **Whole buffer or stream?** Drop the `advance()` bounds checks for a complete + in-memory buffer (faster); keep them for a chunked HTTP response that must + survive rows straddling chunk boundaries. + - **Drop the portability scaffolding.** RowBinary is little-endian and the + target is x86/ARM, so the skill steers away from big-endian / byte-swap + "portability" code a cautious one-shot pass tends to add. +- **Improves smaller models' performance.** Because the skill hands over the + hard-won answers up front, it lifts a weaker model the most. In a 24-eval + with-skill vs no-skill benchmark, the skill [raised](eval_result_sonnet.md) **Sonnet 4.6** from 60.4% to + **94.0%** (+34pp) — bringing it level with skill-equipped **Opus 4.8** (94.7%), + which itself [gained](eval_result.md) +23pp (71.5% → 94.7%). Composer 2.5 Fast + [got](eval_result_composer.md) a 3x parser performance boost, Haiku 4.5 + [raised](eval_result_haiku.md) from 52% to 86% — the skill closes + most of the model-capability gap on this task. + +## What it does + +Given the columns of a query result — their names and ClickHouse type +definitions (as returned by `RowBinaryWithNamesAndTypes`, or supplied by the +user) — the skill generates parser code tailored to exactly those types. Rather +than shipping a generic, runtime-driven decoder, it emits straight-line code +that reads each column in order, so the parser only contains the logic the +specific result shape needs. + +## Correctness on the gotcha-heavy types + +For a plain `UInt64, String, DateTime` result a strong model already writes fast, +correct code on its own. The skill earns its keep on the **long tail of RowBinary +traps** — the encodings where a from-scratch decoder is quietly wrong — each one +captured here with a live, server-verified test: + +- **UUID** — two little-endian `UInt64` halves, each byte-reversed vs. the text + form (not 16 bytes in order). +- **`Variant(...)`** — the 1-byte discriminant indexes the alternatives sorted by + **type name** (ClickHouse globally sorts them), NOT declaration order; `0xFF` + is NULL. +- **`DateTime64(P)`** — returned as `[Date, nanoseconds]` so the sub-second part + isn't lost to a `Date`'s millisecond resolution; `Time`/`Time64` are durations, + not instants. +- **Wide integers** — `Int128`/`Int256` compose from 64-bit words with the **high + word read signed**; 64-bit values stay `bigint`, never a lossy `number`. +- **Decimals** — kept as the exact `[unscaled, scale]` pair, not a lossy float. +- **`Dynamic` / `JSON`** — self-describing: a per-value binary type encoding, then + the value; declared typed `JSON` paths are written without a tag (need the + schema). Wrappers are erased (`Nullable`/`Variant` → concrete type). +- **Transparent wrappers** — `LowCardinality(T)` / `SimpleAggregateFunction(f, T)` + decode as the inner `T` (no dictionary layer in RowBinary); `Nested(...)` is + `Array(Tuple(...))` with no wire of its own. +- **`AggregateFunction(...)`** — opaque, unframed state: not decodable or even + skippable; finalize server-side instead. +- **`FixedString`** preserves trailing NUL padding; **`Enum`** decodes to the + underlying int (the name map is metadata); **`BFloat16`** is the top 16 bits of + a `Float32`. + +This is also where a raw model is most likely to go wrong. In a clean-room test +on a `Variant` / `UUID` / `DateTime64` / `LowCardinality` schema, a no-skill +Sonnet produced a **silently wrong UUID** (treated the bytes as plain, missing +the two-reversed-halves layout), and a no-skill Opus got it right only after +**three web searches**. The skill hands over these answers up front — correct by +construction, no lookups. See `baseline/README.md` for the full control. + +And the failure isn't a one-off — it's a coin-flip. Running the same no-skill +Sonnet on the `orders` schema (`UInt8, UUID, Decimal64(2), Enum8`) **5 times in +isolation**, only **3 of 5** runs decoded correctly; both failures were the same +UUID byte-order scramble. Even the passing runs varied ~1.9x in generated-code +throughput. With the skill, every run is correct. So a single A/B undersells the +gap: from scratch the model is right roughly 60% of the time and silently wrong +the rest, while the skill makes correctness deterministic. + +## Examples + +Six end-to-end examples live in [EXAMPLES.md](EXAMPLES.md). Each ships both an API-combinator +reader and an optimized, monomorphized one, with a runnable round-trip test and +a benchmark — so the speedups below are measured, not claimed (Node 24 / V8; +`npm run bench` for your own numbers): + +| Example | Columns | Optimized speedup | +| ----------------- | ---------------------------------------------------- | ------------------- | +| **orders** | `UUID`, `Decimal64`, `Enum8` | **~3.4x** | +| **carts** | nested `Array(Tuple(...))`, `Array(Nullable(...))` | **~2.0x** | +| **telemetry** | `Map`, `Array(Float64)`, `Nullable`, named `Tuple` | **~1.4x** | +| **observability** | `Variant`, `DateTime64(3)`, `LowCardinality`, nested | **~1.4x** | +| **profiles** | `Array(String)`, `Nullable(Int32)` | **~1.3x** | +| **events** | `UInt64`, `String`, `DateTime` scalars | **~1.05x — on par** | + +Two axes drive the win. **Composite structure** is one: monomorphization pays in +proportion to how many per-row combinator closures it removes (`carts` / +`telemetry` / `observability`). **Per-row formatting** is the other, independent +of composites: `orders` is all-scalar yet the biggest win (~3.4x), almost +entirely from swapping the BigInt UUID formatter for the lookup-table +`formatUUIDTable`. The genuinely flat case — a scalar row with no hot formatter +(`events`) — is on par, so the simpler API reader is the right call there. +Measure, don't assume. + +## Scope + +- **In scope:** `RowBinary`, `RowBinaryWithNames`, and + `RowBinaryWithNamesAndTypes` decoding for Node.js — full-buffer and streaming + (chunked) via `advance()`/`NeedMoreData`, `readRows()`, and the async + `streamRowBatches()` (with a built-in small-chunk warning and the optional + `coalesceChunks()` debounce filter). +- **Planned:** RowBinary **writing / encoding** (the inverse of everything above) +- **Out of scope (for now):** browsers and Edge runtimes, non-RowBinary formats + (JSON / CSV / TSV / Parquet), and big-endian hosts. + +## The spirit + +A RowBinary parser generator is a narrow thing. But it's built as an instance of +a broader bet about what libraries become once a capable LLM is part of the +toolchain. Three shifts, each already visible in this repo: + +- **Self-modifiable software.** The library deliberately ships _several_ + equivalent decoders for the same type — `readUUID` / `readUUIDBigInt` / + `readUUIDHiLo`, `formatUUID` / `formatUUIDTable`, `new Array(n)` vs `[]`+push, + streaming vs whole-buffer — because the fastest one depends on the workload, + not the type. Today the agent picks at generation time from measured + benchmarks. The next step is to pair the skill with a tracing layer that runs + variant A against variant B _on the live workload_ and keeps whichever wins for + this data shape and access pattern — a parser that re-tunes itself as the + traffic drifts, instead of freezing one author's guess into a release. + +- **Custom software.** The value here isn't a fixed high-level API; it's the + benchmarked building blocks plus the judgment to combine them. So the end user + doesn't bend their code to the authors' generic surface — they have the agent + assemble the high-level API _they_ actually want, shaped to their queries, row + shapes, and latency/memory budget. Two teams with different workloads grow two + different libraries from the same primitives, and neither inherits a design + decision that was only ever right for the original authors' use case. + +- **Read-write libraries.** For either of the above to be safe, the source has to + be legible to an LLM, not merely runnable. So this repo is written _read-write_: + every tradeoff is commented where it's made — the per-column ClickHouse type + annotations, the `SAFE TO TOGGLE` markers on the fast variants, each reader's + doc comment carrying its exact monomorphized form. An LLM can + read _why_ a decision was made and change it in depth with confidence — not + just call the public functions, but safely rework the internals. + +The through-line: the last mile is glue the LLM writes over stable, benchmarked +blocks, so the authors' job shrinks to exporting good primitives and documenting +their tradeoffs honestly — rather than trying to bake the right performance +constants for every possible workload into the library ahead of time. diff --git a/skills/clickhouse-js-node-rowbinary-parser/SKILL.md b/skills/clickhouse-js-node-rowbinary-parser/SKILL.md new file mode 100644 index 000000000..f424cd5e4 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/SKILL.md @@ -0,0 +1,190 @@ +--- +name: clickhouse-js-node-rowbinary-parser +description: > + Generate TypeScript/JavaScript code that reads and decodes ClickHouse + RowBinary streams from the ClickHouse HTTP server. + Use this skill whenever a user wants to parse `RowBinary`, + `RowBinaryWithNames`, or `RowBinaryWithNamesAndTypes`. + Node.js only, doesn't cover browsers. +--- + +# ClickHouse JS RowBinary Parser Generator for Node.js + +## First: is RowBinary even the right format? + +RowBinary exists for throughput, but it is **not automatically the fastest +path** — match the format to the shape of the data before committing to a +bespoke parser. + +**Prefer a `JSON*` format (e.g. `JSONEachRow`) when** the result is mostly +strings / JSON-like values that you consume wholesale — randomly accessing +essentially every field, running string/regexp methods on them, treating values +as text. V8's native `JSON.parse` is heavily optimized C++ and builds JS strings +and objects faster than a JS-level RowBinary decoder can; pair it with HTTP +response compression (`gzip` / `zstd`, which crushes JSON's repetitive keys) and +the wire cost shrinks too. + +**RowBinary clearly wins when** the result is dominated by: + +- **Wide numerics** — `Int128`/`Int256`/`UInt128`/`UInt256`, + `Decimal128`/`Decimal256`. +- **Binary / fixed-width blobs** — `IPv4`, `IPv6`, `UUID`, `FixedString`. +- **High-volume fixed-width numeric columns** generally, where each value is a + single `DataView` read. + +**Prefer the `Native` format when** columnar load and client-side analytics are +the main goal (fold/scan/filter columns, feed typed arrays to a Worker or WASM). +`Native` is column-major, so it loads straight into one typed array per column +with no transpose. + +For help choosing and consuming a `JSON*` format (or CSV / TSV) instead, use the +**`clickhouse-js-node-coding`** skill. + +## Second: complete buffer, or incremental stream? + +Decide this before writing the reader — it changes the shape of the code and is +a real performance fork. + +- **Incremental / streaming (the default here).** You consume the HTTP response + chunk by chunk as it arrives — low latency to the first row, bounded memory. + It is generally the best choice for large results, but slower per-row. + +- **Whole buffer in memory (faster, when it fits).** If you already hold the + entire response as one `Buffer`, the bounds check never fires — so you can drop + `advance()` entirely and read at a running offset in one monolithic loop. + This is 2-3x faster but introduces latency and unbounded memory use. + +The exposed API is streaming by default and requires an optimisation pass. + +## Third: row objects, or columnar (typed arrays)? + +The default output is one object per row (array-of-structs). For a **numeric, +fixed-width result that the consumer reads column-wise**, decode instead into one +typed array per column (struct-of-arrays) — it is **~4x faster and several times +smaller** because it removes the per-row object, `Date`, and number-boxing +allocations that dominate a numeric decode (the byte reads are already at memory +bandwidth). Measured in `tests/iot.columnar.bench.ts`; rationale in +`case-studies/wasm-vs-js.md`. + +- **Use columnar when** columns are numeric/fixed-width and the consumer + aggregates / filters / scans / plots them, or hands the buffers to a Worker or + WASM kernel (typed-array `ArrayBuffer`s are transferable — zero-copy). +- **The preallocation trick:** if EVERY column is fixed-width the row stride is + known, so the exact count is `buf.length / stride` — allocate each column once, + write at `[i]`, no growth, no per-row bounds check. +- **Streaming columnar is just that arithmetic per chunk.** Fixed width means + honoring a partial buffer needs no `advance()`/`NeedMoreData`/restart: the + complete-row count is `(chunk.length / stride) | 0`, and the leftover bytes + carry to the next chunk. Yield one typed-array batch per chunk, each owning a + fresh transferable `ArrayBuffer` (see `streamSensorColumns` in + `src/columnar.ts`). +- **Stay row-oriented when** downstream code is row-shaped, the row is + string-dominated (columnar's win is numeric — a JS string allocates either + way), or the schema is nested/heterogeneous (`Array`/`Map`/`Tuple`). +- **Hybrid:** store columnar, expose a lazy `rowAt(i)` accessor that builds an + object only for rows actually touched (see `iotRowAt` in `src/examples/iot.ts`). + +## Core guidance + +When generating a parser, follow these: + +- **Little-endian only.** RowBinary is little-endian; target x86/ARM. Read every + multi-byte number with `DataView` accessors passing a **literal** `true` for + the `littleEndian` flag. + +- **Correct first, then optimize.** First emit a correct reader built from the + plain per-type API. Only after it's correct (and tested) specialize it. Don't + bake performance assumptions in before correctness. + +- **Monomorphize generic/composite types.** Emit specialized, inlined code per + type combination instead of passing functions as arguments where the type + is known ahead of time. + +- **Streaming: throw + restart, not generators.** To signal "need more bytes", + a synchronous reader that throws a sentinel (`NeedMoreData`) and restarts the + row beats generators for realistic chunk sizes; + +- **Keep an eye on chunk sizes.** Partial trailing rows, small chunks are a silent + throughput killer: `streamRowBatches` warns once when + rows-per-chunk falls too low, and `coalesceChunks(source, { minSize, timeoutMs })` + merges small chunks in front of it when the source size isn't yours to raise. + +- **Shared scratch is not reentrant.** Some hot methods reuse a module-level + scratch buffer as a write-then-read pair — correct only because reads are fully + synchronous. An `async`/`yield` boundary between populating and reading it + corrupts the value. + +- **Hoist the cursor into locals.** Prefer the working buffer and view declared + once at the top of the generated reader, and keep the read offset in a **local variable**, + operating on it directly instead of re-reading from an object. + +- **Coalesce `advance()` across adjacent fixed-width columns.** A run of + neighbouring fixed-width columns has a known combined size, so bounds-check it + ONCE. + +- **Inline the leaf reads.** The per-type `readX` functions are the correct, + composable reference; the generated parser should INLINE their bodies, not call + them, so the row reader is straight-line with no per-field indirection (and so + the two points above can fold the offset arithmetic together). + +- **Annotate the decoded type per column.** Inlining erases the type structure, + so put a short comment above each column's decode block naming the ClickHouse + type it reads. + +- **Pre-allocate small result arrays.** RowBinary gives every array/map its + element count up front (the LEB128 prefix), so DEFAULT is to `new Array(n)`. + NOTE: for **large** arrays the application will iterate or compute over repeatedly, + prefer `[]` + `push` (faster to traverse in V8) — or a typed array (`Float64Array`…) + for numeric elements. + +- **TypeScript by default.** Generate TypeScript parsers and helpers unless the + user explicitly asks for plain JavaScript. + +## Type family references + +The readers live as real code under `src/`, split by type family. + +| Result contains (trigger) | Open | +| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Always** — cursor state, `advance()`, `NeedMoreData`, `Reader` | `src/core.ts` | +| LEB128 length/count prefixes for `String`/`Array`/`Map` (`readUVarint`) | `src/varint.ts` | +| `Int8`–`Int256`, `UInt8`–`UInt256` | `src/integers.ts` | +| `Bool` | `src/bool.ts` | +| `Enum8`, `Enum16` | `src/enums.ts` | +| `Float32`, `Float64`, `BFloat16` | `src/floats.ts` | +| `Decimal32/64/128/256`, `Decimal(P, S)` | `src/decimals.ts` | +| `String`, `FixedString(N)` | `src/strings.ts` | +| `UUID` | `src/uuid.ts` | +| `IPv4`, `IPv6` | `src/ip.ts` | +| `Date`, `Date32`, `DateTime`, `DateTime(tz)`, `DateTime64(P[, tz])` | `src/datetime.ts` | +| `Time`, `Time64(P)` | `src/time.ts` | +| `IntervalNanosecond` … `IntervalYear` | `src/interval.ts` | +| `Array(T)`, `Map(K, V)`, `Tuple(...)`, `Nullable(T)`, `Variant(...)`, `QBit(...)` | `src/composite.ts` | +| `Point`, `Ring`, `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`, `Geometry` | `src/geo.ts` | +| `Dynamic` (and `Variant`/`Interval`/`Nested`/`Dynamic` nested inside it) | `src/dynamic.ts` | +| `JSON` | `src/json.ts` | +| The whole result — loop rows to EOF (`readRows`) | `src/rows.ts` | +| A chunked HTTP response — `streamRowBatches`, `coalesceChunks` | `src/stream.ts` | +| **Numeric/fixed-width result read column-wise** (aggregate/scan/plot, hand to a Worker/WASM) → decode into typed arrays, not row objects (~4x) | `src/columnar.ts` (`streamSensorColumns` — streaming, yields transferable typed-array batches); `decodeIotColumnar` in `src/examples/iot.ts` is the whole-buffer form | +| `LowCardinality(T)` — transparent, decode as `T` | `src/lowCardinality.ts` | +| `SimpleAggregateFunction(f, T)` — transparent, decode as `T` | `src/simpleAggregateFunction.ts` | +| `Nested(...)` — no wire of its own; `Array(Tuple(...))` | `src/nested.ts` | +| `Nothing` — zero-width, never decoded (only wrapped) | `src/nothing.ts` | +| `AggregateFunction(...)` — opaque state; finalize server-side | `src/aggregateFunction.ts` | + +## Worked examples + +Six end-to-end examples with real speedup are catalogued in [EXAMPLES.md](EXAMPLES.md). + +## Out of scope + +- **JSON / CSV / TSV / Parquet parsing** → use `clickhouse-js-node-coding`. +- **Connection errors, hangs, type mismatches** → use + `clickhouse-js-node-troubleshooting`. +- **Browser / Web Worker / Edge** → `@clickhouse/client-web`. + +## Still Stuck? + +- [ClickHouse RowBinary format](https://clickhouse.com/docs/interfaces/formats#rowbinary) +- [ClickHouse data types](https://clickhouse.com/docs/sql-reference/data-types) +- [ClickHouse JS client docs](https://clickhouse.com/docs/integrations/javascript) diff --git a/skills/clickhouse-js-node-rowbinary-parser/case-studies/iot-rowbinary-vs-json.md b/skills/clickhouse-js-node-rowbinary-parser/case-studies/iot-rowbinary-vs-json.md new file mode 100644 index 000000000..f3118ecaf --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/case-studies/iot-rowbinary-vs-json.md @@ -0,0 +1,83 @@ +# Case study: RowBinary vs JSON on a table of IoT readings + +**TL;DR** — On a dense fixed-width numeric row, the skill's optimized RowBinary +reader decodes **3.5x faster than the best JSON format** (`JSONCompactEachRow`) +and **5.4x faster than `JSONEachRow`**, over a wire that is **1.6–3.3x smaller**. +This is the workload shape the [SKILL's format-choice +guidance](../SKILL.md#first-is-rowbinary-even-the-right-format) points at +RowBinary for — and the numbers below are _measured_, not assumed. + +Reproduce: `npx vitest bench --run tests/iot.bench.ts` (against a live +ClickHouse server). Source: [`tests/iot.bench.ts`](../tests/iot.bench.ts), +reader: [`src/examples/iot.ts`](../src/examples/iot.ts). + +## The data + +A table of IoT sensor readings — every column fixed-width, not a string in the +row, so the whole record is a flat 41-byte run: + +```sql +sensor_id UInt32 -- 4 bytes +ts DateTime64(3) -- 8 bytes +temperature Float64 -- 8 bytes +humidity Float64 -- 8 bytes +pressure Float64 -- 8 bytes +battery Float32 -- 4 bytes +status UInt8 -- 1 byte +``` + +50,000 rows, fetched from a live server in three formats and decoded into +equivalent JS objects. A cross-format check asserts the RowBinary (binary +float) and JSON (decimal-text → float) decodes agree on every numeric column +before any timing is taken — so this measures the same work three ways, not +three different results. + +## What was compared + +- **RowBinary — optimized.** The skill's monomorphized reader: the seven column + bounds checks coalesce into one `advance(s, 41)`, every field read at a + constant offset off that base. +- **RowBinary — API combinators.** The same logic written with the plain + per-type readers (`readUInt32`, `readFloat64`, …) — the clear default. +- **JSONCompactEachRow — `JSON.parse`.** Newline-delimited _arrays_ (no repeated + keys). The strongest JSON contender a knowledgeable user would pick. +- **JSONEachRow — `JSON.parse`.** Newline-delimited _objects_ (keys repeated + every row) — the naive idiomatic choice. + +Both JSON paths use the fastest idiomatic decode: splice the rows into one +`[...]` document and hand it to V8's native `JSON.parse` in a single call. + +## Wire size (HTTP response bytes) + +| Format | Size | B/row | vs RowBinary | +| ------------------ | ------- | ----- | ------------ | +| RowBinary | 2.05 MB | 41.0 | 1.0x | +| JSONCompactEachRow | 3.38 MB | 67.6 | 1.6x | +| JSONEachRow | 6.68 MB | 133.6 | 3.3x | + +## Decode throughput (full 50k-row decode; higher = faster) + +| Decoder | ops/s | ms/decode | ≈ rows/s | speedup | +| --------------------------------- | ----- | --------- | -------- | -------- | +| **RowBinary — optimized** | 399 | 2.50 | ~20.0 M | **1.0x** | +| RowBinary — API combinators | 159 | 6.31 | ~7.9 M | 0.40x | +| JSONCompactEachRow — `JSON.parse` | 114 | 8.76 | ~5.7 M | 0.29x | +| JSONEachRow — `JSON.parse` | 74 | 13.47 | ~3.7 M | 0.19x | + +_Node 24 / V8. Your numbers will vary; run `npm run bench` on your own hardware._ + +## Takeaways + +- **This is the textbook RowBinary win.** High-volume fixed-width numerics where + each field is one `DataView` read and there is no text to tokenize or numbers + to parse from decimal strings. The monomorphization win (2.5x over the + combinator API) is unusually large here because the whole row coalesces into a + _single_ bounds check with constant-offset reads. +- **Format choice matters more than the optimization.** Even the plain + combinator-API RowBinary reader (~7.9 M rows/s) beats the best JSON option — + before any monomorphization. +- **The flip side still holds.** Had this been a string-heavy result (logs, JSON + blobs, text consumed wholesale), `JSON.parse`'s optimized C++ would likely + _win_, and the skill would steer you to `JSONEachRow` + compression instead. + For IoT telemetry, RowBinary is clearly right — match the format to the shape + of the data. diff --git a/skills/clickhouse-js-node-rowbinary-parser/case-studies/ledger-rowbinary-vs-json.md b/skills/clickhouse-js-node-rowbinary-parser/case-studies/ledger-rowbinary-vs-json.md new file mode 100644 index 000000000..c9c69ac40 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/case-studies/ledger-rowbinary-vs-json.md @@ -0,0 +1,103 @@ +# Case study: RowBinary vs JSON on a financial ledger (wide ints & decimals) + +**TL;DR** — When every column is wider than a JS `number` can hold (`UInt128`, +`Int64`, `Decimal128(18)`, `UInt256`), RowBinary wins _twice over_. Stock +`JSON.parse` is not merely slow here — it is **silently wrong**, rounding every +value to a float64. The only correct JSON path quotes the values server-side and +re-parses each string into a `bigint`/decimal pair by hand, which is **~5x +slower** than the optimized RowBinary reader over a **2.1–2.6x larger** wire. +RowBinary reads each value exactly, straight off the wire. + +This is the workload the [SKILL's format-choice +guidance](../SKILL.md#first-is-rowbinary-even-the-right-format) calls out +explicitly: "RowBinary clearly wins when the result is dominated by **wide +numerics** — `Int128`/`Int256`/`UInt128`/`UInt256`, `Decimal128`/`Decimal256`." + +Reproduce: `npx vitest bench --run tests/ledger.bench.ts` (against a live +ClickHouse server). Source: [`tests/ledger.bench.ts`](../tests/ledger.bench.ts), +reader: [`src/examples/ledger.ts`](../src/examples/ledger.ts). + +## The data + +A financial ledger — every column exceeds IEEE-754 double's 53-bit exact range: + +```sql +txn_id UInt128 -- 16 bytes +account Int64 -- 8 bytes (values past 2^53) +amount Decimal128(18) -- 16 bytes (~32 significant digits) +balance Decimal128(18) -- 16 bytes +fee Decimal64(4) -- 8 bytes +volume UInt256 -- 32 bytes +``` + +50,000 rows, fixed-width (96 bytes/row), fetched from a live server. + +## The correctness trap + +ClickHouse emits these types as **bare, unquoted JSON numbers**. So stock +`JSON.parse` parses them as float64 and silently corrupts every one — measured +on row 0 of the live result: + +| Column | Exact value (RowBinary) | `JSON.parse` of bare JSON | | +| --------- | ----------------------------------------- | ----------------------------------------- | ---------------- | +| `txn_id` | `340282366920938463463374607431768200000` | `340282366920938463463374607431768211456` | ✗ off by 11 456 | +| `account` | `9007199254740993` | `9007199254740992` | ✗ off by 1 | +| `amount` | `98765432109876.123456789012345678` | `98765432109876.12` | ✗ lost 16 digits | + +No exception, no warning — just wrong numbers. For money and IDs, that is a +correctness bug, not a performance footnote. + +### Making JSON correct costs extra work + +The only way to get exact values through JSON is to **quote them server-side** so +they arrive as strings, then re-parse each one: + +```sql +... SETTINGS output_format_json_quote_64bit_integers = 1, + output_format_json_quote_decimals = 1 +``` + +```ts +txn_id: BigInt(r.txn_id), // string -> bigint +amount: parseDecimal(r.amount, 18), // string -> [unscaled, scale] +``` + +That per-field `BigInt(...)` / decimal parse is work RowBinary doesn't do — it +reads the exact `bigint` directly with two `DataView` reads — and it lands on +top of a larger wire (strings are longer than the binary words). + +## Wire size (correct paths quote wide values as strings) + +| Format | Size | vs RowBinary | +| --------------------------- | -------- | ------------ | +| RowBinary | 4.80 MB | 1.0x | +| JSONCompactEachRow (quoted) | 9.88 MB | 2.1x | +| JSONEachRow (quoted) | 12.28 MB | 2.6x | + +## Decode throughput (full 50k-row decode; higher = faster) + +| Decoder | ops/s | ms/decode | ≈ rows/s | speedup | correct? | +| -------------------------------------------------- | ----- | --------- | -------- | -------- | -------------- | +| **RowBinary — optimized** | 130 | 7.71 | ~6.5 M | **1.0x** | ✅ | +| RowBinary — API combinators | 80 | 12.50 | ~4.0 M | 0.62x | ✅ | +| JSONEachRow bare — `JSON.parse` only | 44 | 22.74 | ~2.2 M | 0.34x | ❌ **corrupt** | +| JSONCompactEachRow quoted — parse + BigInt/decimal | 26 | 37.78 | ~1.3 M | 0.20x | ✅ | +| JSONEachRow quoted — parse + BigInt/decimal | 25 | 40.70 | ~1.2 M | 0.19x | ✅ | + +_Node 24 / V8. Your numbers will vary; run `npm run bench` on your own hardware._ + +## Takeaways + +- **The fast JSON path is the wrong one.** Bare `JSON.parse` is JSON's quickest + option and it is still 2.95x slower than RowBinary — _and_ it silently + corrupts every wide value. There is no "fast and correct" JSON here. +- **The correct JSON path is ~5x slower.** Quote + per-field `BigInt`/decimal + parsing is the price of correctness, on top of a 2.1–2.6x larger wire. +- **RowBinary is correct by construction.** Each value is composed from 64-bit + words read at constant offsets (high word signed for the signed types), + yielding an exact `bigint` or `[unscaled, scale]` pair — no rounding, no + string re-parsing. +- **Contrast with the [IoT case study](iot-rowbinary-vs-json.md):** there the + numbers fit a float64 and the win was purely throughput (3.5x). Here the values + don't fit, so the win is _correctness first_, throughput second. Match the + format to the shape of the data. diff --git a/skills/clickhouse-js-node-rowbinary-parser/case-studies/logs-json-wins.md b/skills/clickhouse-js-node-rowbinary-parser/case-studies/logs-json-wins.md new file mode 100644 index 000000000..951d84085 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/case-studies/logs-json-wins.md @@ -0,0 +1,86 @@ +# Case study: JSON beats RowBinary on a string-heavy log table + +**TL;DR** — This is the honest counter-case. When the result is mostly **text +consumed wholesale** (an application log table), `JSONCompactEachRow` + +`JSON.parse` decodes **1.4x faster** than the optimized RowBinary reader — and +once you turn on HTTP compression, RowBinary's raw-wire size advantage +**disappears**: gzip ties the two, and with **zstd the JSON response is actually +slightly smaller**. For this shape the skill steers you _away_ from RowBinary — +and proving that is what makes its "use RowBinary here" advice (see the +[IoT](iot-rowbinary-vs-json.md) and [ledger](ledger-rowbinary-vs-json.md) +studies) trustworthy. + +This is exactly what the [SKILL's format-choice +guidance](../SKILL.md#first-is-rowbinary-even-the-right-format) says: prefer a +`JSON*` format when the result is "mostly strings / JSON-like values that you +consume wholesale," because V8's native `JSON.parse` is heavily optimized C++ +and "pair it with HTTP response compression (`gzip` / `zstd`, which crushes +JSON's repetitive keys)." + +Reproduce: `npx vitest bench --run tests/logs.bench.ts` (against a live +ClickHouse server). Source: [`tests/logs.bench.ts`](../tests/logs.bench.ts), +reader: [`src/examples/logs.ts`](../src/examples/logs.ts). + +## The data + +An application log table — four of five columns are text consumed as text: + +```sql +ts DateTime +level LowCardinality(String) -- transparent in RowBinary -> plain String +service LowCardinality(String) +message String -- templated log line, varying values +trace_id String -- high-cardinality 32-char hex +``` + +50,000 rows, fetched from a live server. The two `LowCardinality` columns carry +no dictionary on the RowBinary wire — they decode as plain `String`. + +## Decode throughput (full 50k-row decode; higher = faster) + +| Decoder | ops/s | ms/decode | ≈ rows/s | speedup | +| ------------------------------------- | ----- | --------- | -------- | -------- | +| **JSONCompactEachRow — `JSON.parse`** | 93 | 10.73 | ~4.7 M | **1.0x** | +| JSONEachRow — `JSON.parse` | 72 | 13.89 | ~3.6 M | 0.77x | +| RowBinary — optimized (monomorphized) | 66 | 15.07 | ~3.3 M | 0.71x | +| RowBinary — API combinators | 54 | 18.68 | ~2.7 M | 0.57x | + +`JSONCompactEachRow` (arrays, no repeated keys) is the fastest JSON option and +beats even the optimized RowBinary reader by ~1.4x. A RowBinary string is a +varint length + `buf.toString("utf8", …)` decoded one field at a time in JS; +`JSON.parse` builds the same JS strings in one optimized C++ pass. + +## Wire size — raw, and compressed (gzip / zstd) + +| Format | raw | gzip | zstd | +| ------------------ | ------- | ------- | ------- | +| RowBinary | 5.04 MB | 1.46 MB | 1.35 MB | +| JSONCompactEachRow | 6.84 MB | 1.51 MB | 1.32 MB | +| JSONEachRow | 8.84 MB | 1.52 MB | 1.33 MB | + +RowBinary is 1.4–1.8x smaller **raw**, which is the usual argument for it. But +that edge is mostly JSON's repeated structure (keys, punctuation) — exactly what +a compressor removes. With `gzip` the three are within ~4% of each other, and +with `zstd` the JSON responses are _slightly smaller_ than RowBinary. Any +production HTTP path should have compression on, so the wire-size case for +RowBinary on this data effectively vanishes. + +_Node 24 / V8. Your numbers will vary; run `npm run bench` on your own hardware._ + +## Takeaways + +- **JSON wins both axes here.** Faster to decode (~1.4x) _and_, once compressed, + no larger on the wire. There is no reason to hand-write a RowBinary parser for + this shape. +- **`JSONCompactEachRow` is the one to reach for** — it drops the per-row + repeated keys, so it parses faster than `JSONEachRow` and compresses about the + same. +- **Compression erases RowBinary's raw-size advantage on text.** RowBinary's + smaller raw wire comes largely from not repeating keys; a compressor already + does that for JSON. Always compare _compressed_ sizes when the data is + string-heavy. +- **This is the boundary of the skill.** RowBinary earns its keep on + numeric/wide/binary data ([IoT](iot-rowbinary-vs-json.md), + [ledger](ledger-rowbinary-vs-json.md)); on string-heavy results read as text, + the right answer is `JSONCompactEachRow` + compression. Match the format to the + shape of the data — and measure. diff --git a/skills/clickhouse-js-node-rowbinary-parser/case-studies/wasm-vs-js.md b/skills/clickhouse-js-node-rowbinary-parser/case-studies/wasm-vs-js.md new file mode 100644 index 000000000..8083c03d4 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/case-studies/wasm-vs-js.md @@ -0,0 +1,172 @@ +# Case study: why JS, not WASM, for RowBinary parsing (and the one place WASM wins) + +**TL;DR** — A JIT-compiled JS RowBinary reader already streams bytes at **memory +bandwidth** (~16 GB/s), and the dominant cost of decoding is allocating the JS +values themselves (objects, `Date`, strings, `BigInt`) — which **WASM cannot do +and therefore cannot remove**. So for the skill's actual job, turning a RowBinary +response into usable JS data, WASM buys ~nothing (a wash, or a loss after the +copy-in tax). WASM wins decisively in exactly **one** different problem: +**in-place aggregation of wide integers / decimals** (and hash group-by), where +JS is forced onto heap `BigInt`/`Map`. There we measured a hand-written WASM +kernel at **27–38x** over JS. But that is _compute_, not parsing — and it is +usually pushable to ClickHouse anyway. And if you genuinely need heavier +**client-side analytics**, the lever isn't WASM-over-RowBinary at all — it's a +**columnar wire format (`Native`)**, coming soon to the JS client out of the +Python-client collaboration; RowBinary is row-major and fights every analytical +pass. + +Reproduce: + +- `npx vitest bench --run tests/iot.wasm-headroom.bench.ts` (the parsing headroom) +- `node tests/wasm-int128.experiment.mjs` (the hand-emitted WASM kernel) + +All numbers Node 24 / V8; yours will vary. + +## The idea under test + +A tempting architecture: a _dynamic WASM JIT inside the JS runtime_. A type +builder (`t.Int32()`, `t.Map(t.FixedString, t.Int32())`) plus a query DSL +(`q.sum(q.column(1))`) compile **on the fly** to a WASM module that parses the +raw network chunk sitting at address 0 in linear memory, computes the answer, +writes it to a result region, and returns the offset where the incomplete +trailing row begins (streaming resume). Elegant. The question is _what it would +win_ — and the honest answer needs three measurements. + +## Proof 1 — JIT-compiled JS reads at memory speed + +V8 compiles `DataView` accessors to native loads. Folding a 32 MB column of +native-width values (`Float64`) in a plain JS loop: + +| Read | ms / 32 MB | throughput | +| ---------------------- | ---------- | ------------- | +| JS `DataView` f64 fold | 1.94 ms | **16.5 GB/s** | + +That is essentially RAM bandwidth. **There is no headroom for a "faster +language" to read these bytes** — JS is already at the metal. A WASM parser +reading the same bytes lands in the same place (see Proof 3, where the WASM +kernel reads at 28 GB/s doing _integer_ loads — same order, also bandwidth-bound, +not 10x). + +## Proof 2 — the parsing bottleneck is allocation, which WASM can't touch + +On the best case for RowBinary (IoT, every column fixed-width numeric), three +decoders over the same buffer (`tests/iot.wasm-headroom.bench.ts`): + +| Decode | ms | vs current | what it isolates | +| ---------------------------------------------------- | ---- | ---------- | -------------------- | +| **rows** — current fast reader (objects + `Date`) | 3.48 | 1.0x | full materialization | +| **columnar** — into typed arrays, no per-row objects | 0.86 | 4.0x | drop the objects | +| **parseOnly** — reads only, zero allocation | 0.61 | 5.8x | the pure-read floor | + +**~83% of decode time is JS-side object/`Date` allocation**, not byte reading. +A WASM parser still has to produce those JS values across the boundary, so it +_cannot_ remove that 83%. Even if WASM made the parse slice instantaneous and the +copy-in free, the row-object decode would drop only `3.48 → 2.88 ms` — a **max +~1.2x**, and realistically a wash once you add the copy into linear memory. + +The 4.0x that _is_ on the table comes from the **output contract** (columnar +typed arrays), and it's available in **plain JS** — no WASM. (That columnar path +is worth shipping; it's the real win this whole investigation surfaced.) + +## Proof 3 — the one place WASM wins: wide-int / decimal aggregation + +Summing an `Int128` column forces JS onto heap `BigInt` (one allocation per +row). A hand-emitted WASM kernel (94 bytes; native `i64` add-with-carry) does it +in registers. Same 32 MB buffer, result verified equal to the BigInt sum +(`tests/wasm-int128.experiment.mjs`): + +| Sum of an `Int128` column | ms / 32 MB | throughput | | +| --------------------------------------- | ---------- | ---------- | -------------- | +| **JS BigInt-128 sum** (what JS must do) | 42.93 ms | 0.7 GB/s | correct | +| WASM `i64` add-carry — kernel only | 1.14 ms | 28.2 GB/s | correct | +| WASM + copy-in boundary tax | 1.62 ms | 19.7 GB/s | (copy 0.49 ms) | + +**WASM is 37.8x faster than JS (26.5x including the copy into linear memory).** +Note _why_: the win is escaping `BigInt`, not reading bytes faster — the WASM +kernel (28 GB/s) is the same order as the JS f64 floor (16.5 GB/s). JS pays a +**22x `BigInt` tax** purely to add 128-bit integers; WASM's native `i64` reclaims +it. The same logic applies to `Decimal128/256` accumulation and to hash group-by +(WASM open-addressing table in linear memory vs JS `Map` + GC). + +## Verdict on the dynamic-WASM-JIT + +The architecture is **sound for the aggregation regime and only that regime**. +It targets the one quadrant where WASM beats well-written JS: _parse and compute +in place, return a small result, never cross the boundary per value._ The design +answers its own open questions well: + +- **Where does the answer go?** Scalars return directly (`i128` via multi-value + or two `i64`s); group-by results go to a reserved linear-memory region that JS + reads as a typed-array view — only the small final result crosses. +- **Streaming.** Returning the resume offset (vs throwing across the FFI) is + clean, and accumulator state lives in linear memory across chunks — the module + _is_ the streaming aggregation state. + +But three caveats bound where it's worth building: + +1. **For parsing → JS values, use generated JS, not WASM.** Proofs 1–2: JS is + already at memory speed and the cost is materialization WASM can't remove. A + `DSL → new Function(generatedJS)` backend captures the parse + native-numeric + aggregation case with **zero toolchain**, debuggable. This is the skill's + existing monomorphization thesis. +2. **Reserve a WASM backend for the wide-int/decimal + group-by kernels only** — + gate it on the presence of `Int128/256`, `Decimal128/256`, or a `GROUP BY`, + where Proof 3's 27–38x is real. For `Float64` sums it would tie JS. +3. **SIMD won't help much** — RowBinary is row-major (AoS); strided columns + defeat Wasm SIMD (no gather) without a transpose pass. The WASM win here is + native `i64` + no GC, not vectorization. +4. **The elephant: push it down.** `q.sum(col)` is `SELECT sum(col)` — ClickHouse + will beat any client. Client-side aggregation only justifies itself when you + _can't_ push down: folding a stream you already receive for another reason, + combining across queries/sources, or compute SQL can't express. + +## If you need more client-side analytical strength: reach for Native columnar + +Step back from WASM and look at _why_ the wins above are so narrow. RowBinary is +**row-major (AoS)**: every row interleaves all columns, so any analytical pass — +fold a column, vectorize, build a column-at-a-time accumulator — has to stride +over the bytes it doesn't want and re-materialize a value at a time. That is the +same row-major tax that defeats SIMD (caveat 3) and that makes the free **4x in +Proof 2 cost a transpose** today (you decode rows, _then_ pack into typed +arrays). + +So the honest answer to _"I need real client-side analytical strength"_ is **not +a smarter parser over RowBinary, and not WASM** — it is a **columnar wire +format**. ClickHouse's **`Native`** format is **column-major (SoA)**: each block +arrives as contiguous per-column runs. That flips every constraint in this study: + +- The Proof-2 columnar typed-array path stops needing a transpose — the wire + _is_ already `Float64Array`-shaped, so you `subarray`/`set` a column in one + move instead of decoding rows first. +- Vectorization becomes real: a contiguous column is exactly what `v128.load` / + SIMD (and even auto-vectorized JS) want — the gather problem disappears. +- The wide-int/decimal aggregation win (Proof 3) keeps applying, now over + contiguous input, which is the friendliest possible layout for it. + +A columnar reader is **coming to the JS client soon**, out of the **collaboration +with the Python client** (which already ships a mature `Native`/columnar path — +the format and lessons port directly). When it lands, the order of preference for +client-side analytics becomes: **push down to ClickHouse → if you can't, decode +`Native` columnar → reserve WASM for the wide-int/decimal/group-by kernel on top +of those columns.** RowBinary stays the right tool for what this skill targets — +turning a result into JS _rows/values_ — not for analytics over them. + +## Takeaways + +- **Generated JS is the right engine for the parser.** It reads at memory + bandwidth; the remaining cost is JS-value materialization that no language + swap removes. WASM for parsing is a wash-to-loss. +- **The free 4x is a columnar (typed-array) output contract — in pure JS.** Worth + capturing as a first-class option for numeric results. +- **WASM earns its complexity in one place: in-place wide-int/decimal/group-by + aggregation** (27–38x measured), where JS is trapped in `BigInt`/`Map`. And + even then, prefer pushing the aggregation to ClickHouse unless you genuinely + can't. +- **For real client-side analytical strength, the answer is columnar, not WASM.** + RowBinary is row-major and taxes every analytical pass; a `Native` (SoA) + columnar reader — coming to the JS client soon via the Python-client + collaboration — removes the transpose, unlocks SIMD, and is the natural + substrate for the aggregation kernels above. +- Matches the rest of the studies' through-line: pick the tool for the shape of + the work, and **measure** — the 94-byte WASM kernel exists precisely so this + claim isn't hand-waved. diff --git a/skills/clickhouse-js-node-rowbinary-parser/eval_result.md b/skills/clickhouse-js-node-rowbinary-parser/eval_result.md new file mode 100644 index 000000000..76a32c1e2 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/eval_result.md @@ -0,0 +1,105 @@ +# Eval result — RowBinary skill (24 evals, with-skill vs no-skill) + +**Date:** 2026-06-20 +**Model:** Claude Opus 4.8 (1M context) — `claude-opus-4-8[1m]` (executors and grader) +**Harness:** Claude Code 2.1.183 +**Method:** skill-creator eval loop. For each of the 24 evals in +[`evals/evals.json`](evals/evals.json), an isolated subagent generated a parser +**with the skill** (told to read `SKILL.md` + the routed `src/` reader) and a +**no-skill control** (own knowledge, no access to the skill). Each output was +graded against that eval's assertions. 1 run per cell; graded by an LLM grader +against the assertions (no live-ClickHouse ground truth — that is what +[`skill-bench`](.claude/skills/skill-bench/SKILL.md) adds). + +## Headline + +| Metric | With skill | Without skill | Delta | +| ------------- | ------------- | ------------- | -------------- | +| Pass rate | **94.7%** | 71.5% | **+23pp** | +| Wall time | 90.4s ± 36.7s | 58.0s ± 21.4s | +32.4s (~1.6×) | +| Output tokens | 32066 ± 7096 | 17483 ± 2028 | +14583 (~1.8×) | + +The skill raised correctness on every knowledge-heavy type and never _lowered_ +it on the hard ones. The cost is reading `SKILL.md` + the routed reader and +emitting extra variants/self-tests. + +## Per-eval pass rate + +| Eval | With | Without | Δ | +| ----------------------------------------- | -------- | -------- | --------- | +| 0 fixed-width numerics (Buffer) | 1.00 | 0.40 | +0.60 | +| 1 DateTime64(3)/Float32 endianness | 1.00 | 0.50 | +0.50 | +| 2 varint length reader | 1.00 | 0.40 | +0.60 | +| 3 Int64/Int128 precision | 1.00 | 0.60 | +0.40 | +| 4 Buffer slice / DataView windowing | 1.00 | 0.80 | +0.20 | +| 5 Decimal64/IPv4 format separation | 1.00 | 0.67 | +0.33 | +| 6 UUID byte-order | 1.00 | 1.00 | 0 | +| 7 FixedString / binary String | 1.00 | 1.00 | 0 | +| 8 BFloat16 array | 1.00 | 1.00 | 0 | +| 9 Enum8 underlying int | 1.00 | 0.83 | +0.17 | +| 10 Date/DateTime tz metadata | 0.80 | 0.80 | 0 | +| 11 DateTime64(9) nanoseconds | 0.60 | 0.40 | +0.20 | +| 12 Time/Time64 durations | 1.00 | 0.60 | +0.40 | +| 13 LowCardinality/SimpleAggregateFunction | 1.00 | 1.00 | 0 | +| 14 Variant discriminant name-sort | 1.00 | 1.00 | 0 | +| 15 Nested = Array(Tuple) | 1.00 | 1.00 | 0 | +| 16 AggregateFunction opaque state | **1.00** | **0.00** | **+1.00** | +| 17 Dynamic runtime dispatch | 1.00 | 1.00 | 0 | +| 18 Dynamic nested type-headers | 1.00 | 0.67 | +0.33 | +| 19 JSON = paths + Dynamic | 1.00 | 0.33 | +0.67 | +| 20 hot UUID/IPv6/Array zero-copy | 0.83 | 0.83 | 0 | +| 21 Float32 array benchmark | **0.67** | **0.83** | **−0.17** | +| 22 documented String/Int64 toggles | 1.00 | 0.83 | +0.17 | +| 23 Array(Tuple) monomorphized | 0.83 | 0.67 | +0.17 | + +## Where the skill clearly earns its keep + +Correctness gaps the model gets wrong unaided: + +- **eval-16 AggregateFunction opaque state — 100% vs 0%.** Without the skill the + agent invents a byte-level decoder and claims the state is splittable / + round-trippable. The skill correctly refuses and points to server-side + finalization. +- **eval-19 JSON — 100% vs 33%.** Without the skill the agent falls back to + `CAST(col AS String)` + `JSON.parse`; only the skill decodes the + varuint-path-count + (String path, Dynamic value) wire and handles the + JSON-in-Dynamic `0x30` header and typed-path bail-out. +- **eval-18 Dynamic nested type-headers — 100% vs 67%.** The control invents + wrong type-encoding tag bytes and never consumes `max_dynamic_types`. +- **evals 0/2 (DataView windowing, varint unrolling) — 100% vs 40%**, plus + endianness scaffolding (1), Time64 ScaledTicks (12), signed Int128 high word + (3), Decimal scale preservation (5). + +## Genuine gaps the eval surfaced (candidate skill fixes) + +1. **Skill regressed on eval-21 (float32 benchmark): 67% vs 83%.** Both configs + emitted `.mjs` not TypeScript (TS-default assertion failed in _both_), and the + no-skill control added an independent source-byte oracle while the with-skill + run only cross-validated the two strategies against each other. The skill + teaches equivalence-before-timing but not an _independent_ oracle. +2. **TypeScript-default is unreliable on optimization/benchmark prompts** — + with-skill emitted plain `.mjs` on evals 21 and 23 despite the "TS by default" + assertion (eval-0 did produce `.ts`). +3. **Holey-array rule misfired on the one eval that targets it (eval-20, 5/6 + both):** both used `new Array(n)` + index for a "millions of rows" hot tag + array; the skill's small-vs-large `[]`+push heuristic didn't fire and the run + even justified `new Array(n)`. The guidance is ambiguous when an array is both + count-known _and_ large. +4. **Weakest with the skill: eval-10 (0.80) and eval-11 (0.60)** — the skill + version omitted the "one `Date` allocation per value, offer raw-count on a hot + path" note (10) and only partially delivered the `[Date, nanoseconds]` split + (11). + +## Caveats + +- Non-discriminating evals (6, 7, 8, 13, 14, 15, 17 all tie ≈100%) measure + baseline model competence, not skill lift. On 14/17 the no-skill run even got + the subtle name-sort / runtime-dispatch right; the assertions don't test the + concrete Dynamic tag bytes, which is where no-skill was actually shaky. +- 1 run per (eval, config); per-eval deltas are point estimates, not + variance-controlled. For server-truth correctness (decode vs what the live + ClickHouse server produced) use [`skill-bench`](.claude/skills/skill-bench/SKILL.md). + +_Raw per-assertion gradings, the benchmark JSON, and the interactive review +viewer live in the sibling `…-workspace/iteration-1/` directory +(`benchmark.json`, `benchmark.md`, `review.html`)._ diff --git a/skills/clickhouse-js-node-rowbinary-parser/eval_result_composer.md b/skills/clickhouse-js-node-rowbinary-parser/eval_result_composer.md new file mode 100644 index 000000000..e5a0d81fa --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/eval_result_composer.md @@ -0,0 +1,73 @@ +# Eval result (Composer) — RowBinary skill (skill-bench orders, with-skill vs no-skill) + +**Date:** 2026-06-21 +**Model:** Composer 2.5 Fast — `composer-2.5-fast` (executors). No separate grader — correctness scored against live ClickHouse server output (same fixture procedure as [`skill-bench`](.claude/skills/skill-bench/SKILL.md)). +**Harness:** Cursor Task subagents (isolated work dirs under `/tmp/rb-skill-bench/orders/`) +**Method:** The [`skill-bench` orders contract](.claude/skills/skill-bench/SKILL.md) — one fixed schema (`UInt8`, `UUID`, `Decimal64(2)`, `Enum8`) — run as a 2×1 matrix (Composer with-skill vs no-skill). Fixture bytes and expected fields built from a live ClickHouse server (64 rows); throughput measured on a 64×400 = 25,600-row concatenated buffer (equivalence-checked, best of 5 rounds). 1 run per cell. This is **server-truth** scoring, not the 24 LLM-graded evals in [`evals/evals.json`](evals/evals.json) (see [Sonnet](eval_result_sonnet.md) / [Opus](eval_result.md) for those). + +## Headline + +| Metric | With skill | Without skill | Delta | +| ------------------------- | ---------------- | ------------- | --------- | +| Correctness (64 rows) | **100%** | **100%** | 0 | +| Generated-code throughput | **14.0M rows/s** | 5.8M rows/s | **2.40×** | +| Parser size | 99 lines | 49 lines | +50 lines | +| Agent wall time | N/A | N/A | — | +| Output tokens | N/A | N/A | — | + +**Both cells decoded every field correctly** against what ClickHouse itself produced — including the three classic traps this schema is chosen for (UUID byte order, Decimal64 fixed scale, Enum8 underlying int). The skill's lift on Composer is **throughput and optimization patterns**, not correctness on this run: the no-skill parser got UUID/decimal/enum right without peeking at the skill. + +## With-skill vs without-skill by trap (orders schema) + +The orders skill-bench task exercises the same gotchas several evals target individually: + +| Trap | Eval analogue | With skill | Without skill | Δ | +| ----------------------------------------------------------- | ----------------- | ---------------- | ------------- | -------------------- | +| UUID two-LE-halves byte order | eval-6 | ✓ 64/64 | ✓ 64/64 | tie | +| Decimal64(2) → exactly 2 fractional digits | eval-5 | ✓ 64/64 | ✓ 64/64 | tie | +| Enum8 → underlying int (1/2/3), not name | eval-9 | ✓ 64/64 | ✓ 64/64 | tie | +| Hot-path codegen (lookup table, bigint decimal, row stride) | eval-20 / eval-23 | **14.0M rows/s** | 5.8M rows/s | **+140% throughput** | + +## Composer vs Sonnet (skill-bench orders, same fixture shape) + +Cross-run comparison uses the same orders contract and scoring rules; Sonnet numbers are from the prior claude-CLI skill-bench cell in [`.claude/skills/skill-bench/results/orders/`](.claude/skills/skill-bench/results/orders/). + +| | With skill | Without skill | Skill throughput lift | +| --------------------------------------- | -------------- | ------------- | --------------------- | +| **Composer 2.5 Fast** | ✓ 14.0M rows/s | ✓ 5.8M rows/s | **2.40×** | +| **Sonnet 4.6** (no-skill only recorded) | — | ✓ 8.0M rows/s | — | + +Composer-no-skill is **slower** than Sonnet-no-skill on this one run (5.8M vs 8.0M rows/s) despite equal correctness — the Sonnet baseline used a tighter hand-rolled loop, while Composer-no-skill used `readUInt32LE`/`readInt32LE` for decimal and string-built UUID hex. Composer-with-skill more than closes that gap and beats Sonnet-no-skill by **1.76×** on generated-code speed. + +## Where the skill clearly earned its keep (Composer) + +Correctness gaps the skill closes on weaker models (see [Sonnet eval-6 at 0.20](eval_result_sonnet.md)) did **not** appear here — Composer-unaided passed server truth. What the skill _did_ deliver: + +- **`formatUUIDTable` lookup-table path** — adapted from the orders example (`src/examples/orders.ts` / skill UUID guidance) instead of per-byte string concatenation in the no-skill cell. +- **Bigint + `DataView.getBigInt64` decimal path** — faithful signed Int64 unscaled units with scale-2 padding; no-skill used JS number arithmetic on 32-bit limbs. +- **Flattened 26-byte fixed row** — single stride (`1 + 16 + 8 + 1`), pre-sized `new Array(rowCount)`, column comments — the "flatten the assembled row reader" tier from `SKILL.md`. + +**Isolation audit (no-skill):** clean — hand-rolled `formatUUID` with per-half byte reversal, no `formatUUIDTable` / `UUID_HEX16` / skill module names; verified the agent did not read paths under `skills/clickhouse-js-node-rowbinary-parser-generator`. + +## Findings specific to this Composer run + +1. **Composer-unaided correctness on orders is strong.** One run, but both UUID and decimal formatting matched ClickHouse `toString()` output — unlike Sonnet-no-skill on eval-6 (0.20 pass rate across the 24-eval suite). Skill-bench still recommends multiple no-skill trials before claiming stability; this run is a point estimate only. +2. **Skill value here is performance, not rescue.** The 2.40× throughput gap is the headline; the skill cell is also ~2× the line count because it inlines the optimized UUID table and bigint formatters. +3. **Agent cost not measured.** Cursor Task subagents do not emit the `stream-json` transcript the claude-CLI skill-bench procedure uses for turns/tokens/USD — only generated-code metrics are reported. + +## Findings that align with the Opus / Sonnet eval runs + +These skill-bench observations are consistent with themes from the 24-eval A/B runs, even though Composer did not execute that suite: + +- **Gotcha types are where no-skill breaks on weaker models** — Composer passed this small schema; Sonnet's 24-eval no-skill pass rate was 60.4% with UUID/JSON/AggregateFunction as the big holes. +- **Optimization tier is not automatic without the skill** — no-skill Composer reached for readable `Buffer.read*LE` helpers; with-skill reached for the benchmarked hot path. Same pattern as eval-23 (monomorphized / inlined) and eval-20 (zero-copy / packed arrays). +- **The orders schema is a weak correctness discriminator for strong models** — both Composer cells correct; the schema discriminates **code quality and speed** instead (as [`skill-bench` expects](.claude/skills/skill-bench/SKILL.md) for Sonnet-no-skill UUID flakiness, not for every model). + +## Caveats + +- **Server-truth scoring on one schema**, not the 24 LLM-graded evals — for assertion-level coverage across all type families, see [eval_result_sonnet.md](eval_result_sonnet.md) and [eval_result.md](eval_result.md). +- **1 run per (cell)** — per-cell results are point estimates; Sonnet-no-skill UUID failure is non-deterministic across runs. +- **Agent cost metrics unavailable** in the Cursor Task harness (wall time, turns, tokens, USD all N/A). +- **Cross-model throughput comparison is provisional** — Sonnet-no-skill is from a different fixture build (fresh INSERT each run); row bytes differ, but schema and scoring rules match. + +_Raw parsers, fixture, and machine-readable scores: [`.claude/skills/skill-bench/results/orders/`](.claude/skills/skill-bench/results/orders/) (`composer-{noskill,skill}.parser.mjs`, `fixture.json`, `results.json`, `report.md`). Work dirs: `/tmp/rb-skill-bench/orders/composer-{noskill,skill}/`._ diff --git a/skills/clickhouse-js-node-rowbinary-parser/eval_result_haiku.md b/skills/clickhouse-js-node-rowbinary-parser/eval_result_haiku.md new file mode 100644 index 000000000..02fcc806f --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/eval_result_haiku.md @@ -0,0 +1,80 @@ +# Eval result (Haiku) — RowBinary skill (26 evals, with-skill vs no-skill) + +**Date:** 2026-06-22 +**Model:** Claude Haiku 4.5 — `claude-haiku-4-5` (executors). Grader: Claude Opus 4.8 — `claude-opus-4-8[1m]` (held constant as the measurement instrument, same as the [Opus](eval_result.md) and [Sonnet](eval_result_sonnet.md) runs). +**Harness:** Claude Code Workflow (background `pipeline`) — a 52-cell generate→grade pipeline (26 evals × {with-skill, no-skill}); 104 subagents total, ~1.94M subagent tokens, ~296s wall. +**Method:** same A/B as the Opus/Sonnet runs — for each eval in [`evals/evals.json`](evals/evals.json) an isolated **Haiku** subagent generated a parser **with the skill** and a **no-skill control** (instructed not to read repo files), scored by an **Opus** grader against the eval's assertions (1 = met, 0.5 = partial, 0 = miss/violation; overall = mean). 1 run per cell; no live-ClickHouse ground truth (use [`skill-bench`](.claude/skills/skill-bench/SKILL.md) for server truth). This run covers **26** evals — the original 24 plus the two columnar streaming cases (24, 25) added after the Opus/Sonnet runs; a 0–23 subset is reported below for apples-to-apples cross-model comparison. + +## Headline + +| Metric | With skill | Without skill | Delta | +| ----------------------------- | ---------- | ------------- | --------- | +| Pass rate (26 evals) | **86.2%** | 53.2% | **+33pp** | +| Pass rate (0–23 subset) | **86.0%** | 52.2% | **+34pp** | +| Wall time / tokens (per cell) | N/A | N/A | — | + +**On Haiku the skill is worth +33pp** — it lifts a weak 53% unaided baseline to 86%. The delta matches Sonnet's (+34pp) and is larger than Opus's (+23pp), for the same reason the Sonnet run gave: the skill mostly closes the _baseline_ gap. But unlike Sonnet, **Haiku-with-skill does not reach the Opus/Sonnet with-skill ceiling** (86% vs ~94%) — Haiku sometimes can't faithfully _apply_ the skill on the hardest cases (it emits prose instead of code on eval-18, and silently drops the skill's signature no-bigint trick on eval-25). Per-cell wall/token are N/A: the Workflow harness reports run-level totals only, not the per-cell `stream-json` the Sonnet run used. + +## With-skill vs without-skill by eval (Haiku) + +| Eval | With | Without | Δ | +| ------------------------------------------------- | -------- | -------- | --------- | +| 0 fixed-width numerics (Buffer) | 0.80 | 0.60 | +0.20 | +| 1 DateTime64(3)/Float32 endianness | 1.00 | 1.00 | 0 | +| 2 varint length reader | **1.00** | **0.00** | **+1.00** | +| 3 Int64/Int128 precision | 1.00 | 1.00 | 0 | +| 4 Buffer slice / DataView windowing | 1.00 | 0.40 | +0.60 | +| 5 Decimal64/IPv4 format separation | 0.92 | 0.25 | +0.67 | +| 6 UUID byte-order | 1.00 | 0.90 | +0.10 | +| 7 FixedString / binary String | 0.83 | 0.00 | +0.83 | +| 8 BFloat16 array | 1.00 | 0.90 | +0.10 | +| 9 Enum8 underlying int | 0.92 | 0.75 | +0.17 | +| 10 Date/DateTime tz metadata | 0.80 | 0.50 | +0.30 | +| 11 DateTime64(9) nanoseconds | 0.80 | 0.20 | +0.60 | +| 12 Time/Time64 durations | 0.90 | 0.00 | +0.90 | +| 13 LowCardinality/SimpleAggregateFunction | 1.00 | 0.58 | +0.42 | +| 14 Variant discriminant name-sort | 1.00 | 0.70 | +0.30 | +| 15 Nested = Array(Tuple) | 0.83 | 0.50 | +0.33 | +| 16 AggregateFunction opaque state | **1.00** | **0.00** | **+1.00** | +| 17 Dynamic runtime dispatch | 1.00 | 1.00 | 0 | +| 18 Dynamic nested type-headers | **0.25** | **0.42** | **−0.17** | +| 19 JSON = paths + Dynamic | 0.67 | 0.08 | +0.58 | +| 20 hot UUID/IPv6/Array zero-copy | 0.67 | 0.67 | 0 | +| 21 Float32 array benchmark | **0.33** | **0.83** | **−0.50** | +| 22 documented String/Int64 toggles | 0.92 | 0.58 | +0.33 | +| 23 Array(Tuple) monomorphized | 1.00 | 0.67 | +0.33 | +| 24 streaming columnar (sensor, no per-row bigint) | 0.93 | 0.71 | +0.21 | +| 25 streaming columnar (trades, 2×64-bit) | 0.86 | 0.58 | +0.27 | + +## Haiku vs Opus vs Sonnet (grader = Opus 4.8, 0–23 subset) + +| Executors | With skill | Without skill | Delta | +| -------------- | ---------- | ------------- | ----- | +| **Opus 4.8** | 94.7% | 71.5% | +23pp | +| **Sonnet 4.6** | 94.0% | 60.4% | +34pp | +| **Haiku 4.5** | 86.0% | 52.2% | +34pp | + +Haiku's _unaided_ baseline is the weakest of the three (52.2%), and the skill rescues it by the same magnitude it rescues Sonnet — but the **with-skill ceiling is ~8pp below** Opus/Sonnet. Two things hold Haiku-with-skill back that don't hold the bigger models back: + +- **eval-18 Dynamic nested type-headers — 0.25, a regression below its own no-skill 0.42.** With the skill, Haiku produced _prose describing_ the header layout but almost no decoding code; the no-skill cell at least emitted (wrong) code that scored partial. Haiku couldn't operationalize the skill's most complex section. +- **eval-25 columnar trades — with-skill **violated** the headline no-per-row-bigint expectation.** Haiku read both 64-bit columns with `getBigInt64`/`getBigUint64` per row instead of the two-`getUint32`-words-into-a-`Uint32Array`-view trick — exactly the manual finding from the case-25 spot-check. It adapted the schema (stride 29, `BigUint64Array`, offsets) correctly but dropped the optimization the moment it couldn't copy it verbatim. + +## Findings that reproduce across ALL THREE models (highest-priority skill fixes) + +1. **eval-21 (float32 benchmark) regression — Haiku 0.33 vs 0.83 no-skill.** Identical shape to Sonnet (0.33 vs 0.83) and Opus (0.67 vs 0.83): the with-skill run times the two decoders without an equivalence check, no disqualification statement, no runnable test. **This defect now reproduces on every model tested** — the skill teaches equivalence-before-timing but doesn't make the agent actually wire up the guard. Clearest, most reproducible skill defect. +2. **eval-10 / eval-11 are among the weakest with the skill (0.80 / 0.80).** Same gaps as Opus/Sonnet: the per-`Date` allocation note is missing (10), and the `[Date, nanoseconds]` split / `Nanoseconds` alias / P3-vs-P9 note is only partial (11). + +## Findings specific to the Haiku run + +1. **No-skill _refusals_, not just wrong answers.** eval-7 (FixedString/binary String) and eval-12 (Time/Time64) scored **0.00 no-skill because Haiku refused / emitted no code**; eval-12 also asserted the types are unsigned. The skill turns both into 0.83 / 0.90. Weaker models don't just guess wrong unaided — they sometimes don't attempt the decoder at all. +2. **The skill's signature optimization doesn't fully transfer to Haiku.** On the two new columnar evals the no-bigint word-copy trick is **skill-exclusive** (no-skill Haiku used `readBigInt64LE` per row on both 24 and 25), but even _with_ the skill Haiku reproduced it only on the matching schema (eval-24, raw ticks kept) and dropped it on the novel two-64-bit schema (eval-25). This is the inverse of a strong model: Opus/Sonnet-with-skill carried the trick to the new schema; Haiku needs the example to match. +3. **Faithful where the skill is concrete and copyable.** eval-2 (varint), eval-16 (AggregateFunction = don't decode), eval-13 (transparent wrappers), eval-23 (monomorphized Array(Tuple)) all hit 1.00 with skill from 0.00–0.67 without — Haiku reliably reproduces well-scoped, single-pattern guidance. + +## Caveats + +- LLM-graded against assertions; no live-server ground truth (same as the Opus/Sonnet runs). +- 1 run per (eval, config) — per-eval deltas are point estimates; Haiku's no-skill refusals (7, 12) may not reproduce every run. +- Per-cell wall-time/token metrics unavailable in the Workflow harness (run-level only: 104 agents, ~1.94M subagent tokens, ~296s wall). +- No-skill isolation relied on a prompt instruction not to read repo files (not sandbox-enforced); a peeking agent could have seen `evals.json`. Same method caveat as the Sonnet run. +- Cross-model headline uses the **0–23 subset** so it matches the 24-eval Opus/Sonnet runs; the 26-eval figure (86.2% / 53.2%) includes the two columnar cases. +- Non-discriminating evals on Haiku (tie): 1, 3, 17 (tie at 100%), 20 (tie at 0.67) — and two with-skill **regressions** (18, 21), more than Sonnet had. diff --git a/skills/clickhouse-js-node-rowbinary-parser/eval_result_sonnet.md b/skills/clickhouse-js-node-rowbinary-parser/eval_result_sonnet.md new file mode 100644 index 000000000..6b1c7e39f --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/eval_result_sonnet.md @@ -0,0 +1,99 @@ +# Eval result (Sonnet) — RowBinary skill (24 evals, with-skill vs no-skill) + +**Date:** 2026-06-20 +**Model:** Claude Sonnet 4.6 — `claude-sonnet-4-6` (executors). Grader: Claude Opus 4.8 — `claude-opus-4-8[1m]` (held constant as the measurement instrument, same as the [Opus run](eval_result.md)) +**Harness:** Claude Code 2.1.183 +**Method:** identical to the [Opus run](eval_result.md) — for each of the 24 evals in +[`evals/evals.json`](evals/evals.json), an isolated Sonnet subagent generated a parser +**with the skill** and a **no-skill control**, scored by an Opus grader against the +eval's assertions. 1 run per cell; no live-ClickHouse ground truth (use +[`skill-bench`](.claude/skills/skill-bench/SKILL.md) for server truth). + +## Headline + +| Metric | With skill | Without skill | Delta | +| ------------- | ------------- | ------------- | --------------- | +| Pass rate | **94.0%** | 60.4% | **+34pp** | +| Wall time | 93.3s ± 26.9s | 73.4s ± 24.8s | +19.9s (~1.27×) | +| Output tokens | 30529 ± 5335 | 18416 ± 1987 | +12113 (~1.66×) | + +**The skill delta is larger on Sonnet (+34pp) than on Opus (+23pp)** — not because +with-skill is better (94.0% vs Opus's 94.7%, essentially tied), but because Sonnet's +_unaided_ baseline is weaker (60.4% vs Opus's 71.5%). In other words, the skill brings +Sonnet up to roughly the same place Opus-with-skill reaches, closing most of the +model-capability gap. + +## With-skill vs without-skill by eval (Sonnet) + +| Eval | With | Without | Δ | +| ----------------------------------------- | -------- | -------- | --------- | +| 0 fixed-width numerics (Buffer) | 1.00 | 0.80 | +0.20 | +| 1 DateTime64(3)/Float32 endianness | 1.00 | 0.25 | +0.75 | +| 2 varint length reader | 1.00 | 0.40 | +0.60 | +| 3 Int64/Int128 precision | 1.00 | 0.60 | +0.40 | +| 4 Buffer slice / DataView windowing | 1.00 | 0.80 | +0.20 | +| 5 Decimal64/IPv4 format separation | 1.00 | 0.50 | +0.50 | +| 6 UUID byte-order | 1.00 | 0.20 | +0.80 | +| 7 FixedString / binary String | 1.00 | 1.00 | 0 | +| 8 BFloat16 array | 1.00 | 1.00 | 0 | +| 9 Enum8 underlying int | 0.83 | 0.67 | +0.16 | +| 10 Date/DateTime tz metadata | 0.80 | 0.80 | 0 | +| 11 DateTime64(9) nanoseconds | 0.60 | 0.40 | +0.20 | +| 12 Time/Time64 durations | 1.00 | 0.40 | +0.60 | +| 13 LowCardinality/SimpleAggregateFunction | 1.00 | 1.00 | 0 | +| 14 Variant discriminant name-sort | 1.00 | 1.00 | 0 | +| 15 Nested = Array(Tuple) | 1.00 | 1.00 | 0 | +| 16 AggregateFunction opaque state | **1.00** | **0.00** | **+1.00** | +| 17 Dynamic runtime dispatch | 1.00 | 0.67 | +0.33 | +| 18 Dynamic nested type-headers | 1.00 | 0.50 | +0.50 | +| 19 JSON = paths + Dynamic | **1.00** | **0.00** | **+1.00** | +| 20 hot UUID/IPv6/Array zero-copy | 1.00 | 0.50 | +0.50 | +| 21 Float32 array benchmark | **0.33** | **0.83** | **−0.50** | +| 22 documented String/Int64 toggles | 1.00 | 0.67 | +0.33 | +| 23 Array(Tuple) monomorphized | 1.00 | 0.50 | +0.50 | + +## Sonnet vs Opus (both grader = Opus 4.8) + +| | With skill | Without skill | Delta | +| ------------------------ | ---------- | ------------- | ----- | +| **Opus 4.8** executors | 94.7% | 71.5% | +23pp | +| **Sonnet 4.6** executors | 94.0% | 60.4% | +34pp | + +Where Sonnet-unaided falls down harder than Opus-unaided (and the skill rescues it): + +- **eval-6 UUID — 0.20 vs Opus-noskill 1.00.** Sonnet misdiagnoses the layout as + big-endian and hexes bytes in wire order — exactly the scrambling the prompt describes. +- **eval-19 JSON — 0.00 vs Opus 0.33.** Sonnet insists the column is plain UTF-8 JSON text. +- **eval-16 AggregateFunction — 0.00.** Invents a LEB128 length prefix for the unframed state. +- **eval-1 endianness — 0.25**, **eval-12 Time — 0.40**, **eval-5 Decimal — 0.50**, + **eval-17/18 Dynamic — 0.67/0.50.** All lifted to 1.00 with the skill. + +## Findings that reproduce across BOTH models (highest-priority skill fixes) + +1. **eval-21 (float32 benchmark) regression — and worse on Sonnet: 0.33 vs 0.83 + (Opus: 0.67 vs 0.83).** Same root cause both times: the with-skill run omits the + equivalence guard, timing the two decoders without ever comparing their outputs. + The skill teaches equivalence-before-timing but not an _independent_ correctness + oracle. This is the clearest, most reproducible skill defect. +2. **eval-10 / eval-11 are the weakest with the skill on both models** (Date-allocation + note missing; `[Date, nanoseconds]` split only partial, no `Nanoseconds` alias / P3-vs-P9 note). + +## Findings that differ from the Opus run + +- **Holey-array rule (eval-20):** on Sonnet the with-skill run correctly used `[]`+push + (1.00) while no-skill used `new Array(n)` (0.50) — here the skill _helped_. On Opus both + used `new Array(n)` and tied at 0.83. The rule is followed inconsistently across + models; tightening the large-vs-count-known guidance would make it reliable. +- **TypeScript-default:** Sonnet-with-skill followed it better than Opus-with-skill + (emitted `.ts` on the optimization evals 21/23), so the "TS by default" gap is + more an Opus-with-skill issue. + +## Caveats + +- LLM-graded against assertions; no live-server ground truth. +- 1 run per (eval, config) — per-eval deltas are point estimates. +- Non-discriminating evals on Sonnet (tie at 100%): 7, 8, 13, 14, 15 — fewer than the + Opus run, i.e. the eval set discriminates skill value more sharply at Sonnet's level. + +_Raw gradings, `benchmark.json`, and the interactive `review.html` (with the Opus run as +the "previous" comparison) live in the sibling `…-workspace/iteration-2/` directory._ diff --git a/skills/clickhouse-js-node-rowbinary-parser/package-lock.json b/skills/clickhouse-js-node-rowbinary-parser/package-lock.json new file mode 100644 index 000000000..73caa2a71 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/package-lock.json @@ -0,0 +1,1328 @@ +{ + "name": "@clickhouse/rowbinary", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@clickhouse/rowbinary", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^26.0.0", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/package.json b/skills/clickhouse-js-node-rowbinary-parser/package.json new file mode 100644 index 000000000..494dc7b20 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/package.json @@ -0,0 +1,63 @@ +{ + "name": "@clickhouse/rowbinary", + "version": "0.1.0", + "description": "RowBinary building blocks for Node.js — read/decode ClickHouse RowBinary / RowBinaryWithNames(AndTypes) streams (a matching writer is planned). Ships with the clickhouse-js-node-rowbinary-parser agent skill.", + "homepage": "https://github.com/ClickHouse/clickhouse-js/tree/main/skills/clickhouse-js-node-rowbinary-parser", + "license": "Apache-2.0", + "keywords": [ + "clickhouse", + "rowbinary", + "parser", + "decoder", + "streaming", + "skill" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/ClickHouse/clickhouse-js.git", + "directory": "skills/clickhouse-js-node-rowbinary-parser" + }, + "type": "module", + "sideEffects": false, + "main": "./dist/reader.js", + "module": "./dist/reader.js", + "types": "./dist/reader.d.ts", + "exports": { + ".": { + "types": "./dist/reader.d.ts", + "import": "./dist/reader.js" + }, + "./*": { + "types": "./dist/*.d.ts", + "import": "./dist/*.js" + } + }, + "files": [ + "dist", + "src", + "SKILL.md", + "README.md", + "EXAMPLES.md" + ], + "agents": { + "skills": [ + { + "name": "clickhouse-js-node-rowbinary-parser", + "path": "." + } + ] + }, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "test": "vitest run", + "test:watch": "vitest", + "bench": "vitest bench --run", + "typecheck": "tsc --noEmit", + "prepack": "cp ../../LICENSE . && npm run build" + }, + "devDependencies": { + "@types/node": "^26.0.0", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/aggregateFunction.ts b/skills/clickhouse-js-node-rowbinary-parser/src/aggregateFunction.ts new file mode 100644 index 000000000..2778b25d4 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/aggregateFunction.ts @@ -0,0 +1,34 @@ +import { type Reader } from "./core.js"; + +/** + * `AggregateFunction(func, T…)` holds an OPAQUE serialized aggregation STATE + * (what `-State` combinators produce). In RowBinary this state is written RAW, + * with **NO length prefix** and a layout entirely specific to `func` (and to the + * ClickHouse version): `sumState(UInt64)` is 8 bytes, `uniqState(...)` is a + * variable-length hash-set blob, etc. + * + * So it cannot be decoded generically (there is no schema in the bytes) and + * cannot even be SKIPPED generically (there is no length to skip past) — without + * knowing `func`'s exact byte layout you cannot find where it ends, and every + * later column in the row misaligns. There is therefore NO generic reader. + * + * Fix it server-side (RECOMMENDED): finalize with the `-Merge` combinator or + * `finalizeAggregation()` in SQL so the column becomes a normal value + * (`sum` -> `UInt64`, `uniq` -> `UInt64`, `avg` -> `Float64`, …) and use the + * matching reader. Never ship raw `-State` columns to the client unless you + * intend to merge them later. + * + * ESCAPE HATCH: a few functions' state IS just a value of a known type (e.g. + * `sumState(UInt64)` is literally that `UInt64`), so you may decode it as that + * type — fragile and version-specific; only when you truly know the layout. See + * `tests/aggregateFunction.test.ts`. + * + * This reader throws to stop a generic parser from silently misaligning the row. + */ +export const readAggregateFunction: Reader = () => { + throw new Error( + "RowBinary: AggregateFunction is opaque, unframed aggregation state with no " + + "length prefix — not generically decodable or skippable. Finalize server-side " + + "(-Merge / finalizeAggregation()) and decode the concrete result type instead.", + ); +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/bool.ts b/skills/clickhouse-js-node-rowbinary-parser/src/bool.ts new file mode 100644 index 000000000..fcc0bf0ed --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/bool.ts @@ -0,0 +1,10 @@ +import { RowBinaryState } from "./core.js"; +import { readUInt8 } from "./integers.js"; + +/** + * Read a `Bool`: 1 byte, stored as `UInt8` (`0` = false, `1` = true). Treats any + * non-zero byte as true. + */ +export function readBool(state: RowBinaryState): boolean { + return readUInt8(state) !== 0; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/columnar.ts b/skills/clickhouse-js-node-rowbinary-parser/src/columnar.ts new file mode 100644 index 000000000..4749d6b29 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/columnar.ts @@ -0,0 +1,125 @@ +/** + * Streaming COLUMNAR decode for an all-numeric, fixed-width RowBinary result — + * one concrete example reader that ties together the three wins this skill keeps + * pointing at. The schema is hard-coded on purpose: a real columnar reader is + * MONOMORPHIZED to its result, so the row loop is straight-line constant-offset + * reads with no per-field dispatch. Generate one shaped like this per schema. + * + * The example schema (`sensor_id UInt32, ts DateTime64(3), value Float64, + * quality Float32, status UInt8`) — every column fixed-width, stride 25 bytes: + * + * sensor_id UInt32 @ o+0 getUint32 -> Uint32Array + * ts DateTime64(3) @ o+4 2x getUint32 -> BigInt64Array (raw ms ticks) + * value Float64 @ o+12 getFloat64 -> Float64Array + * quality Float32 @ o+20 getFloat32 -> Float32Array + * status UInt8 @ o+24 buf[o+24] -> Uint8Array + * + * The three wins: + * + * 1. COLUMNAR (struct-of-arrays). One typed array per column, not one object + * per row — removes the per-row object / `Date` / number-boxing allocation + * that dominates a numeric decode (~4x in plain JS; see `src/examples/iot.ts` + * and `tests/iot.columnar.bench.ts`). Keep `ts` as raw `BigInt64Array` ticks + * and make a `Date` lazily, per displayed row — never allocate 50k `Date`s. + * The `Int64` column is itself filled WITHOUT allocating a bigint per row: + * copy the two little-endian 32-bit words straight into a `Uint32Array` view + * over its buffer (`getBigInt64` would box a bigint each row); the bigint is + * materialized lazily, only when the consumer reads `ts[i]`. + * + * 2. TRANSFERABLE. Each column is a fresh, exactly-sized typed array that OWNS + * its `ArrayBuffer` at offset 0, so a batch ships to a Worker / WASM kernel + * zero-copy: `postMessage(batch, columns.map(c => c.buffer))`. + * + * 3. RESPECTS INCOMPLETE BUFFERS (streaming). Because the stride is constant, + * honoring a partial trailing row is pure ARITHMETIC: the number of complete + * rows in the buffer is `(work.length / STRIDE) | 0`. No `advance()`, no + * `NeedMoreData`, no throw/restart — the leftover `work.length % STRIDE` bytes + * just carry to the next chunk. Strictly cheaper than the row-oriented + * `streamRowBatches`, which re-decodes the partial row on every boundary. + * + * SCOPE: fixed-width numeric columns only — the ClickHouse types with a 1:1 + * native TypedArray (`Int8/16/32/64`, `UInt8/16/32/64`, `Float32/64`). Anything + * whose value isn't one native-typed number has no constant stride to divide by + * (`String`/`Array`/`Map`/`Tuple`) or no 1:1 array (`Int128`+, `Decimal*`, + * `BFloat16`); decode those row-wise. `Bool`/`Enum`/`Date*`/`DateTime*` ride + * their underlying int here for the RAW value. + */ + +/** One decoded batch of the example schema: `rows` complete rows, one typed array per column. */ +export interface SensorColumnBatch { + /** Number of complete rows decoded in this batch. */ + rows: number; + columns: { + sensor_id: Uint32Array; + ts: BigInt64Array; // raw DateTime64(3) ms ticks + value: Float64Array; + quality: Float32Array; + status: Uint8Array; + }; +} + +/** Byte stride of one fixed-width row: 4 + 8 + 8 + 4 + 1. */ +const STRIDE = 25; + +const EMPTY_CHUNK = Buffer.alloc(0); + +/** + * Stream a chunked RowBinary response of the example schema into columnar + * batches: one `{ rows, columns }` per incoming chunk, holding exactly the rows + * that completed within it. + * + * BACKPRESSURE: a pull stream — the next chunk is requested only when the + * consumer asks for the next batch. SMALL CHUNKS: tiny chunks mean tiny batches + * (more allocations, worse Worker amortization); compose `coalesceChunks` (from + * `./stream.js`) in front to merge them up to a target size first. + */ +export async function* streamSensorColumns( + chunks: AsyncIterable, +): AsyncGenerator { + let carry: Buffer = EMPTY_CHUNK; + for await (const chunk of chunks) { + // Wrap as a Buffer VIEW over the chunk's bytes — no copy (a Buffer made from + // an ArrayBuffer slice shares it). We own the chunk for the life of this + // generator, so holding a view into it is safe. + const incoming = Buffer.from( + chunk.buffer, + chunk.byteOffset, + chunk.byteLength, + ); + const work = + carry.length === 0 ? incoming : Buffer.concat([carry, incoming]); + + // Complete rows available right now — pure arithmetic, since STRIDE is fixed. + const n = (work.length / STRIDE) | 0; + if (n > 0) { + const view = new DataView(work.buffer, work.byteOffset, work.byteLength); + const sensor_id = new Uint32Array(n); + const ts = new BigInt64Array(n); + // Uint32 view over ts's OWN bytes: 2 little-endian words per Int64, + // [lo, hi, lo, hi, ...]. Filling ts through this view copies the raw bytes + // and skips the per-row bigint allocation `getBigInt64` would force; the + // bigint is materialized lazily, only for rows the consumer indexes. + const tsWords = new Uint32Array(ts.buffer); + const value = new Float64Array(n); + const quality = new Float32Array(n); + const status = new Uint8Array(n); + for (let i = 0, o = 0; i < n; i++, o += STRIDE) { + sensor_id[i] = view.getUint32(o, true); // UInt32 @ o+0 + // DateTime64(3) Int64 @ o+4: two LE 32-bit words, no bigint allocated. + tsWords[i * 2] = view.getUint32(o + 4, true); // low word + tsWords[i * 2 + 1] = view.getUint32(o + 8, true); // high word + value[i] = view.getFloat64(o + 12, true); // Float64 @ o+12 + quality[i] = view.getFloat32(o + 20, true); // Float32 @ o+20 + status[i] = work[o + 24]!; // UInt8 @ o+24 + } + yield { rows: n, columns: { sensor_id, ts, value, quality, status } }; + } + // Carry the partial trailing row (if any) to the next chunk. + carry = work.subarray(n * STRIDE); + } + if (carry.length > 0) { + throw new Error( + `RowBinary stream ended mid-row: ${carry.length} trailing byte(s) left undecoded`, + ); + } +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/composite.ts b/skills/clickhouse-js-node-rowbinary-parser/src/composite.ts new file mode 100644 index 000000000..b91a25aff --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/composite.ts @@ -0,0 +1,181 @@ +import { type Reader } from "./core.js"; +import { readUInt8 } from "./integers.js"; +import { readUVarint } from "./varint.js"; + +/** + * Read a `Nullable(T)`: a 1-byte null flag (0 = present, non-zero = NULL). + * Curried: pass the inner reader, get a `Reader`. + * + * GOTCHA: the inner value bytes follow ONLY when the flag is 0. A NULL is the + * single `0x01` flag byte with nothing after it — so do NOT read the inner value + * when the flag is set, or the cursor desyncs. + * + * `readValue` decodes the inner `T`. This generic combinator is the reference + * shape; when generating code, MONOMORPHIZE — emit a dedicated `readNullableX` + * that inlines the inner read: + * + * const readNullableUInt32 = (s) => readUInt8(s) !== 0 ? null : readUInt32(s); + */ +export function readNullable(readValue: Reader): Reader { + return (state) => (readUInt8(state) !== 0 ? null : readValue(state)); +} + +/** + * Read an `Array(T)`: a LEB128 element count, then that many `T` values + * back-to-back. An empty array is just the count byte `0x00`. Curried: pass the + * element reader, get a `Reader`. + * + * ARRAY LAYOUT: the count is known up front (the LEB128 prefix), so a generated + * reader can pre-size. Pick by how the result is used: + * - small / consumed-as-is (the common case) → DEFAULT to `new Array(n)` + + * index assignment; it skips `push`'s repeated capacity growth. A clean-room + * benchmark found this edged out `push` on the small composite arrays here + * (`baseline/README.md`). + * - large + iterated/computed-over downstream → `[]` + `push` keeps it a PACKED + * elements kind (faster to traverse; a pre-sized array is HOLEY), or use a + * typed array (`Float64Array`…) for numeric elements. + * This generic combinator uses `push` for simplicity; the monomorphized + * `readArrayX` below should choose per the rule above. + * + * `readElement` decodes one element. This generic combinator is the reference + * shape; when generating code, MONOMORPHIZE — emit a dedicated `readArrayX` that + * inlines the element read in the loop (and pre-sizes for the common small case): + * + * function readArrayUInt32(s) { + * const n = readUVarint(s); + * const out = new Array(n); + * for (let i = 0; i < n; i++) out[i] = readUInt32(s); + * return out; + * } + */ +export function readArray(readElement: Reader): Reader { + return (state) => { + const n = readUVarint(state); + const out: T[] = []; + for (let i = 0; i < n; i++) out.push(readElement(state)); + return out; + }; +} + +/** + * Read a `QBit(element_type, dimension)` vector. `QBit` is a vector-search type + * whose ON-DISK layout is quantized and bit-transposed — but that is a STORAGE / + * Native-format concern. In RowBinary a `QBit` is fully TRANSPARENT: it is the + * plain vector, encoded byte-for-byte like `Array(element_type)` (a LEB128 + * length, then `dimension` element values). So this is just {@link readArray}. + * + * `element_type` is one of `BFloat16` / `Float32` / `Float64`, so `readElement` + * is the matching float reader. When generating code, MONOMORPHIZE — inline the + * element read in the loop. + */ +export function readQBit(readElement: Reader): Reader { + return readArray(readElement); +} + +/** + * Read a `Tuple(...)` into a positional array: each element's value back-to-back, + * with NO count and NO delimiter. Curried: pass one reader per element (in + * order), get a `Reader` of the tuple. For a named tuple as an object, use + * {@link readTupleNamed} (identical wire). + * + * Reference shape; when generating code, MONOMORPHIZE — emit the inline sequence + * with no array-of-readers and no loop: + * + * [readUInt32(s), readString(s)] + */ +export function readTuple(readers: { + [K in keyof T]: Reader; +}): Reader { + return (state) => { + const out: unknown[] = []; + for (const read of readers as ReadonlyArray>) { + out.push(read(state)); + } + return out as unknown as T; + }; +} + +/** + * Read a named `Tuple(name1 T1, ...)` into an object. The wire is identical to + * an unnamed tuple — values back-to-back, no count or delimiter — so the + * `readers` object's keys MUST be listed in the tuple's declared field order + * (JS iterates string keys in insertion order), and each reader runs in that + * order. Curried: pass the readers object, get a `Reader` of the result object. + * + * Reference shape; when generating code, MONOMORPHIZE — emit the inline object + * literal instead of looping over entries: + * + * { id: readUInt32(s), name: readString(s) } + */ +export function readTupleNamed>(readers: { + [K in keyof T]: Reader; +}): Reader { + const fns = readers as Record>; + const keys = Object.keys(fns); + return (state) => { + const out: Record = {}; + for (const key of keys) out[key] = fns[key]!(state); + return out as T; + }; +} + +/** + * Read a `Map(K, V)`: a LEB128 pair count, then that many key/value pairs with + * key and value interleaved (k, v, k, v, ...) — a flattened `Array(Tuple(K, V))`. + * An empty map is just the count byte `0x00`. Curried: pass the key and value + * readers, get a `Reader>`. + * + * The key is read BEFORE the value in each pair. Returns a JS `Map`, which keeps + * insertion order and accepts any key type. + * + * Reference shape; when generating code, MONOMORPHIZE — inline both reads in the + * loop. + */ +export function readMap( + readKey: Reader, + readValue: Reader, +): Reader> { + return (state) => { + const n = readUVarint(state); + const out = new Map(); + for (let i = 0; i < n; i++) { + const key = readKey(state); + out.set(key, readValue(state)); + } + return out; + }; +} + +/** + * Read a `Variant(T1, ..., Tn)`: a 1-byte discriminant selecting the active + * alternative, then that alternative's value. Discriminant `0xFF` means NULL. + * Curried: pass the alternative readers (in sorted-type-name order), get a + * `Reader`. + * + * GOTCHA: the discriminant indexes the alternatives sorted by type NAME + * (ClickHouse globally sorts them), NOT their declaration order. So `readers` + * MUST be ordered by sorted type name. E.g. `Variant(UInt8, String)` sorts to + * ["String", "UInt8"], so discriminant 0 = String and 1 = UInt8. + * + * Reference shape; when generating code, MONOMORPHIZE — emit a `switch` over the + * discriminant with each branch inlined, alternatives in sorted order, `0xFF` + * -> null. + */ +export function readVariant(readers: { + [K in keyof T]: Reader; +}): Reader { + const fns = readers as ReadonlyArray>; + return (state) => { + const discriminant = readUInt8(state); + if (discriminant === 0xff) return null; + const fn = fns[discriminant]; + if (fn === undefined) { + // Out-of-range discriminant (corrupted/truncated input): fail loudly + // instead of throwing a cryptic "fns[discriminant] is not a function". + throw new RangeError( + `RowBinary Variant: discriminant ${discriminant} out of range (${fns.length} alternatives)`, + ); + } + return fn(state); + }; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/core.ts b/skills/clickhouse-js-node-rowbinary-parser/src/core.ts new file mode 100644 index 000000000..8a956bc4e --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/core.ts @@ -0,0 +1,77 @@ +/** + * Thrown by {@link advance} when the buffer lacks the bytes a read needs — the + * "need more bytes" signal for incremental decoding over a still-filling buffer. + * A driver catches it (`err === NeedMoreData`), waits for more input, and retries + * the row from its last committed position. + * + * A bare sentinel, NOT an `Error` subclass, on purpose: constructing an Error + * captures a stack trace — the expensive part of throwing — and on a path that + * starves once per chunk that cost is pure waste. Throwing a constant skips it, + * which is why throw + restart beats a generator's yield for realistic chunks + * (see `streamingRow.bench.ts`). + */ +export const NeedMoreData = Symbol("RowBinary.NeedMoreData"); + +/** + * The cursor state every reader threads through: the input `Buffer`, the current + * position, and a `DataView` over the same bytes. + * + * Deliberately STATE only — no read methods. Decoding lives in the free + * `readX(state, ...)` functions in the sibling modules, so a generated parser + * pulls in only the per-type readers a result needs. `view`/`buf` are public so + * those free functions can reach them. + */ +export class RowBinaryState { + pos = 0; + + /** + * Node-only skill, so the input is a `Buffer`: number reads go through + * {@link RowBinaryState.view} (DataView), while `String`/`FixedString` use the + * fast `buf.toString("utf8", ...)`. + */ + readonly buf: Buffer; + + /** + * `DataView` over the same bytes, for fixed-width integer/float reads. Built + * with the buffer's own `byteOffset`/`byteLength`: a `Buffer` is often a window + * into a larger pooled `ArrayBuffer`, so `new DataView(buf.buffer)` alone would + * point at the wrong bytes. + */ + readonly view: DataView; + + constructor(buf: Buffer) { + this.buf = buf; + this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + } +} + +/** + * A `Reader` decodes one value of type `T` from the cursor, advancing it. Leaf + * readers (e.g. `readUInt32`) are `Reader`s directly; combinators (e.g. + * `readArray`) take sub-`Reader`s and return a `Reader`, so types compose with no + * per-element closures. + */ +export type Reader = (state: RowBinaryState) => T; + +/** + * Reserve `n` bytes for the next read: bounds-check them, advance the cursor past + * them, and return the offset the read starts at (the value BEFORE advancing). + * Every fixed-width read goes through this, so the length check and cursor + * bookkeeping live in one place: + * + * function readInt32(s) { return s.view.getInt32(advance(s, 4), true); } + * + * Throws {@link NeedMoreData} when fewer than `n` bytes remain, WITHOUT moving the + * cursor, so a driver can rewind to its last committed row and retry. + * + * SAFE TO TOGGLE: for a complete in-memory buffer the check never fires — a parser + * for that case can drop `advance` and read against `state.pos` directly, trading + * streaming tolerance for one fewer compare per read. + */ +export function advance(state: RowBinaryState, n: number): number { + const start = state.pos; + const next = start + n; + if (next > state.buf.length) throw NeedMoreData; + state.pos = next; + return start; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts b/skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts new file mode 100644 index 000000000..2955377ad --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts @@ -0,0 +1,113 @@ +import { type Reader, RowBinaryState } from "./core.js"; +import { readInt32, readInt64, readUInt16, readUInt32 } from "./integers.js"; + +/** + * Semantic aliases for `number` that mark the unit of a temporal value in a + * return type. They are plain `number`s (no runtime brand) — purely to make + * `[Date, Nanoseconds]` etc. self-documenting at the call site. + */ +export type Milliseconds = number; +export type Microseconds = number; +export type Nanoseconds = number; + +/** + * Read a `Date`: 2-byte `UInt16` count of days since 1970-01-01 (UTC), returned + * as a JS `Date` at UTC midnight. A ClickHouse `Date` has no time or timezone; + * `.toISOString().slice(0, 10)` gives "YYYY-MM-DD". + * + * SAFE TO TOGGLE: a `Date` is an object allocation per value. On a hot path that + * only needs the calendar number, read the raw `UInt16` (days) instead. + */ +export function readDate(state: RowBinaryState): Date { + return new Date(readUInt16(state) * 86_400_000); +} + +/** + * Read a `Date32`: 4-byte signed `Int32` count of days since 1970-01-01 (UTC), + * returned as a JS `Date` at UTC midnight (pre-1970 dates are negative day + * counts, which `Date` handles). + */ +export function readDate32(state: RowBinaryState): Date { + return new Date(readInt32(state) * 86_400_000); +} + +/** + * Read a `DateTime` (and `DateTime(tz)`): 4-byte `UInt32` Unix seconds, returned + * as a JS `Date` (exact at second resolution). The instant is UTC-based; a + * column's timezone is display metadata, not in the bytes. + */ +export function readDateTime(state: RowBinaryState): Date { + return new Date(readUInt32(state) * 1000); +} + +/** + * Read a `DateTime64(P)` (and `DateTime64(P, tz)`): 8-byte signed `Int64` count + * of `10^-P`-second ticks since the epoch. Curried: `readDateTime64(P)` returns + * the reader. + * + * Returns a pair `[date, nanoseconds]`: `date` is a JS `Date` truncated to whole + * seconds, and `nanoseconds` is the sub-second remainder in nanoseconds + * (0..999_999_999). The split keeps full precision a `Date` alone (millisecond + * resolution) can't hold. `nanoseconds` is always in ns regardless of P. Timezone + * is metadata. + * + * For the typical precisions, prefer the specialized variants + * {@link readDateTime64P3} (ms — returns a plain `Date`), + * {@link readDateTime64P6} (µs), and {@link readDateTime64P9} (ns). + */ +export function readDateTime64(precision: number): Reader<[Date, Nanoseconds]> { + return (state) => { + const ticks = readInt64(state); + const scale = 10n ** BigInt(precision); + let sec = ticks / scale; + let frac = ticks % scale; + if (frac < 0n) { + // Floor toward -inf so the fractional remainder stays in [0, scale). + frac += scale; + sec -= 1n; + } + return [new Date(Number(sec) * 1000), Number(frac) * 10 ** (9 - precision)]; + }; +} + +/** + * Read a `DateTime64(3)` ({@link Milliseconds}) — the most common precision — as + * a plain JS `Date`. P=3 is exactly a `Date`'s own millisecond resolution, so the + * instant is represented losslessly with no separate fraction. Specialized + * variant of {@link readDateTime64} with the scale baked in. + */ +export function readDateTime64P3(state: RowBinaryState): Date { + return new Date(Number(readInt64(state))); +} + +/** + * Read a `DateTime64(6)` (microseconds) as `[date, microseconds]`: a JS `Date` + * truncated to whole seconds plus the sub-second remainder in microseconds. + * Specialized variant of {@link readDateTime64}. + */ +export function readDateTime64P6(state: RowBinaryState): [Date, Microseconds] { + const ticks = readInt64(state); + let sec = ticks / 1_000_000n; + let frac = ticks % 1_000_000n; // microseconds within the second + if (frac < 0n) { + frac += 1_000_000n; + sec -= 1n; + } + return [new Date(Number(sec) * 1000), Number(frac)]; +} + +/** + * Read a `DateTime64(9)` (nanoseconds) as `[date, nanoseconds]`: a JS `Date` + * truncated to whole seconds plus the sub-second remainder in nanoseconds. + * Specialized variant of {@link readDateTime64} with the scale baked in. + */ +export function readDateTime64P9(state: RowBinaryState): [Date, Nanoseconds] { + const ticks = readInt64(state); + let sec = ticks / 1_000_000_000n; + let frac = ticks % 1_000_000_000n; // nanoseconds within the second + if (frac < 0n) { + frac += 1_000_000_000n; + sec -= 1n; + } + return [new Date(Number(sec) * 1000), Number(frac)]; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts b/skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts new file mode 100644 index 000000000..e83b90d89 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts @@ -0,0 +1,57 @@ +import { type Reader, RowBinaryState } from "./core.js"; +import { readInt32, readInt64, readInt128, readInt256 } from "./integers.js"; + +/** + * A decimal kept lossless as its raw parts: `value = unscaled / 10 ** scale`. + * The `readDecimal*` readers return this so no precision or scale information is + * thrown away at decode time. + */ +export type DecimalValue = readonly [unscaled: bigint, scale: number]; + +/** + * Format a {@link DecimalValue} as a fixed-point decimal string with `scale` + * fractional digits (e.g. `[15000n, 4]` -> `"1.5000"`). Plug in only when you + * need a string. + * + * Trailing zeros are preserved to reflect the declared scale, deliberately unlike + * ClickHouse's text output, which trims them (`"1.5"`) and drops the point for + * integers (`"10"`). + */ +export function formatDecimal([unscaled, scale]: DecimalValue): string { + if (scale === 0) return unscaled.toString(); + if (unscaled < 0n) { + const digits = (-unscaled).toString().padStart(scale + 1, "0"); + const point = digits.length - scale; + return `-${digits.slice(0, point)}.${digits.slice(point)}`; + } + const digits = unscaled.toString().padStart(scale + 1, "0"); + const point = digits.length - scale; + return `${digits.slice(0, point)}.${digits.slice(point)}`; +} + +/** + * Read a `Decimal32(P, S)`: a 4-byte little-endian signed integer (same wire + * shape as `Int32`) scaled by 10^S. Pass the column's scale `S`; returns a + * `Reader` of the raw `[unscaled, scale]` pair (see {@link formatDecimal}). + * + * `Decimal(P, S)` is an alias: pick the width reader by precision P — P<=9 -> + * Decimal32, <=18 -> Decimal64, <=38 -> Decimal128, <=76 -> Decimal256. + */ +export function readDecimal32(scale: number): Reader { + return (state) => [BigInt(readInt32(state)), scale]; +} + +/** Read a `Decimal64(P, S)`: 8-byte LE signed integer scaled by 10^S. Returns `[unscaled, scale]`; see {@link formatDecimal}. */ +export function readDecimal64(scale: number): Reader { + return (state) => [readInt64(state), scale]; +} + +/** Read a `Decimal128(P, S)`: 16-byte LE signed integer scaled by 10^S. Returns `[unscaled, scale]`; see {@link formatDecimal}. */ +export function readDecimal128(scale: number): Reader { + return (state) => [readInt128(state), scale]; +} + +/** Read a `Decimal256(P, S)`: 32-byte LE signed integer scaled by 10^S. Returns `[unscaled, scale]`; see {@link formatDecimal}. */ +export function readDecimal256(scale: number): Reader { + return (state) => [readInt256(state), scale]; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts b/skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts new file mode 100644 index 000000000..22df140cd --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts @@ -0,0 +1,328 @@ +import { type Reader, RowBinaryState } from "./core.js"; +import { readUVarint } from "./varint.js"; +import { + readInt8, + readInt16, + readInt32, + readInt64, + readInt128, + readInt256, + readUInt8, + readUInt16, + readUInt32, + readUInt64, + readUInt128, + readUInt256, +} from "./integers.js"; +import { readBool } from "./bool.js"; +import { readFloat32, readFloat64 } from "./floats.js"; +import { readString, readFixedString } from "./strings.js"; +import { readUUID } from "./uuid.js"; +import { readIPv4, readIPv6 } from "./ip.js"; +import { + readDate, + readDate32, + readDateTime, + readDateTime64, +} from "./datetime.js"; +import { + readDecimal32, + readDecimal64, + readDecimal128, + readDecimal256, +} from "./decimals.js"; +import { + INTERVAL_UNITS, + type IntervalValue, + readInterval, +} from "./interval.js"; +import { + readArray, + readMap, + readNullable, + readTuple, + readTupleNamed, + readVariant, +} from "./composite.js"; +import { readJSON } from "./json.js"; + +/** + * Read one `Dynamic` value. A `Dynamic` is SELF-DESCRIBING: every value is a + * binary TYPE ENCODING followed by the value's RowBinary bytes. So unlike every + * other reader, the type is not known until runtime — this is the one place a + * generic runtime dispatch is correct and unavoidable. {@link readDynamicType} + * parses the type header into a value `Reader`; here we just invoke it. + * + * GOTCHA — wrappers are erased. `Dynamic` stores the CONCRETE type of the stored + * value, never a wrapper: a non-null `Nullable(UInt8)` is stored as plain + * `UInt8`, an active `Variant(...)` as just its current alternative's type. A + * NULL (from any source) is stored as `Nothing` (tag 0x00) and decodes to + * `null`. So you never see Nullable/Variant tags here. + * + * For a Dynamic-heavy hot path where the same few types recur, parse the type + * once and reuse the returned reader across rows instead of re-parsing. + */ +export function readDynamic(state: RowBinaryState): unknown { + return readDynamicType(state)(state); +} + +/** + * Parse a binary TYPE ENCODING (the header ClickHouse writes before each + * `Dynamic` value) and return a `Reader` that reads ONE value of that type. The + * type bytes are consumed now; the returned reader consumes only value bytes + * when called. Composites recurse: element/key/field types are parsed eagerly + * into inner readers, then composed with the existing combinators + * ({@link readArray}/{@link readTuple}/{@link readMap}/...). + * + * The leading byte is a 1-byte tag; parameterized types (FixedString, Enum, + * Decimal, DateTime64, timezone'd DateTime) carry extra LEB128/varint and string + * fields in the header, which we consume to reach the value reader. + * + * Only the tags ClickHouse actually emits for stored `Dynamic` values are + * handled (wrappers are erased — see {@link readDynamic}). Unknown tags throw + * with the tag value so you can extend this switch for the types your data + * actually contains. + */ +export function readDynamicType(state: RowBinaryState): Reader { + const tag = readUInt8(state); + switch (tag) { + // Nothing — a stored NULL. Zero value bytes. + case 0x00: + return () => null; + // Unsigned integers. + case 0x01: + return readUInt8; + case 0x02: + return readUInt16; + case 0x03: + return readUInt32; + case 0x04: + return readUInt64; + case 0x05: + return readUInt128; + case 0x06: + return readUInt256; + // Signed integers. + case 0x07: + return readInt8; + case 0x08: + return readInt16; + case 0x09: + return readInt32; + case 0x0a: + return readInt64; + case 0x0b: + return readInt128; + case 0x0c: + return readInt256; + // Floats. + case 0x0d: + return readFloat32; + case 0x0e: + return readFloat64; + // Dates and times. The timezone'd variants carry a tz string in the header + // (metadata only — identical value wire to the untimezoned form). + case 0x0f: + return readDate; + case 0x10: + return readDate32; + case 0x11: + return readDateTime; + case 0x12: + readString(state); // timezone name (metadata) + return readDateTime; + case 0x13: + return readDateTime64(readUVarint(state)); + case 0x14: { + const precision = readUVarint(state); + readString(state); // timezone name (metadata) + return readDateTime64(precision); + } + // String / FixedString(N). + case 0x15: + return readString; + case 0x16: + return readFixedString(readUVarint(state)); + // Enum8 / Enum16: a count then (name String, value Int8/Int16) pairs. The + // name<->value map is metadata; the stored value is the underlying int. + case 0x17: { + const n = readUVarint(state); + for (let i = 0; i < n; i++) { + readString(state); + readInt8(state); + } + return readInt8; + } + case 0x18: { + const n = readUVarint(state); + for (let i = 0; i < n; i++) { + readString(state); + readInt16(state); + } + return readInt16; + } + // Decimals: header carries precision P then scale S (both varint). Only S + // matters for decoding; P is consumed and dropped. Returns [unscaled, S]. + case 0x19: { + readUVarint(state); + return readDecimal32(readUVarint(state)); + } + case 0x1a: { + readUVarint(state); + return readDecimal64(readUVarint(state)); + } + case 0x1b: { + readUVarint(state); + return readDecimal128(readUVarint(state)); + } + case 0x1c: { + readUVarint(state); + return readDecimal256(readUVarint(state)); + } + case 0x1d: + return readUUID; + // Array(T): parse the element type once, then read a length-prefixed run. + case 0x1e: + return readArray(readDynamicType(state)); + // Tuple(...): a field count, then that many element type encodings. + case 0x1f: { + const n = readUVarint(state); + const fields: Array> = []; + for (let i = 0; i < n; i++) fields.push(readDynamicType(state)); + return readTuple(fields); + } + // Named Tuple: a count, then (name String, type) pairs. Names shape the + // result object; the value wire is identical to an unnamed tuple. + case 0x20: { + const n = readUVarint(state); + const fields: Record> = {}; + for (let i = 0; i < n; i++) { + const name = readString(state); + fields[name] = readDynamicType(state); + } + return readTupleNamed(fields); + } + // Set (0x21): a type used inside IN-expressions, not a stored column value. + case 0x21: + throw new RangeError( + "RowBinary: Dynamic type 0x21 (Set) has no decodable value form", + ); + // Interval (0x22): the header carries a 1-byte unit kind (0x00 Nanosecond + // ... 0x0a Year), then the value is a signed Int64 count of that unit. Here + // — unlike a standalone Interval* column — the unit IS in the wire, so we + // pair it with the count as an IntervalValue rather than dropping it. + case 0x22: { + const kind = readUInt8(state); + const unit = INTERVAL_UNITS[kind]; + if (unit === undefined) { + throw new RangeError( + `RowBinary: unknown Interval kind ${kind} in Dynamic type encoding`, + ); + } + return (s): IntervalValue => [readInterval(s), unit]; + } + // Nullable(T): a NULL flag byte then (if not null) the inner value. At the + // TOP level Dynamic erases Nullable, but NESTED inside Array/Tuple/Map the + // element type really is Nullable(T) — e.g. Array(Nullable(UInt8)) — so the + // tag does appear here. + case 0x23: + return readNullable(readDynamicType(state)); + // Function (0x24): a higher-order function type (lambda), header-only with no + // stored value form. + case 0x24: + throw new RangeError( + "RowBinary: Dynamic type 0x24 (Function) has no decodable value form", + ); + // AggregateFunction (0x25): an opaque, UNFRAMED aggregation state with a + // function-specific layout and no length prefix, so it cannot be decoded OR + // skipped generically. Finalize server-side before putting it in a Dynamic. + case 0x25: + throw new RangeError( + "RowBinary: Dynamic type 0x25 (AggregateFunction) is an opaque unframed state — finalize it server-side", + ); + // LowCardinality(T): transparent — keep the inner type's reader as-is. + case 0x26: + return readDynamicType(state); + // Map(K, V): parse the key type then the value type. + case 0x27: { + const key = readDynamicType(state); + const value = readDynamicType(state); + return readMap(key, value); + } + case 0x28: + return readIPv4; + case 0x29: + return readIPv6; + // Variant (0x2a): the header is (count, then each alternative's type + // encoding). ClickHouse writes the alternatives ALREADY SORTED by type name, + // so the parsed readers line up with the discriminant directly. The value is + // a 1-byte discriminant (0xff = NULL) then the chosen value. NOTE: top-level + // Dynamic erases Variant, so this tag only appears NESTED. + case 0x2a: { + const n = readUVarint(state); + const alternatives: Array> = []; + for (let i = 0; i < n; i++) alternatives.push(readDynamicType(state)); + return readVariant(alternatives); + } + // Dynamic (0x2b): a Dynamic nested inside a Dynamic. The header is a single + // max_dynamic_types byte; the value is itself a type-encoding + value, so it + // is just a recursive readDynamic. We skip max_dynamic_types because it does + // NOT affect value decoding — it is a storage/Native-format overflow + // threshold; in RowBinary every value is normalized to a plain (tag, value). + case 0x2b: + readUInt8(state); // max_dynamic_types — storage threshold, not used to decode + return readDynamic; + // Custom type (0x2c): the type name is written as a String and must be + // re-parsed to learn the real type — we don't have a type-name parser. + case 0x2c: + throw new RangeError( + "RowBinary: Dynamic type 0x2c (custom type, name-encoded) is not supported — requires parsing the type name string", + ); + case 0x2d: + return readBool; + // SimpleAggregateFunction (0x2e): transparent — the value is just its + // underlying type T. The header is (function_name String, argument types); + // extend here by consuming those, then returning T's reader. + case 0x2e: + throw new RangeError( + "RowBinary: Dynamic type 0x2e (SimpleAggregateFunction) is not supported yet — consume the header, then read the inner T", + ); + // Nested(...) (0x2f): on the wire it IS Array(Tuple(...)). The header is + // identical to a named Tuple's (count, then (name String, type) pairs), and + // the value is an Array of those tuples, so compose readArray + readTupleNamed. + case 0x2f: { + const n = readUVarint(state); + const fields: Record> = {}; + for (let i = 0; i < n; i++) { + const name = readString(state); + fields[name] = readDynamicType(state); + } + return readArray(readTupleNamed(fields)); + } + // JSON (0x30): the type-encoding header is a version byte, max_dynamic_paths + // (varuint), max_dynamic_types (uint8), then the typed-path / skip-path / + // skip-regexp lists. We consume it to reach the value body. Typed paths are + // serialized WITHOUT a Dynamic tag, so a schema-less reader can't decode them + // — bail if any are declared. + case 0x30: { + readUInt8(state); // serialization version (observed 0x00) + readUVarint(state); // max_dynamic_paths + readUInt8(state); // max_dynamic_types + const typedPaths = readUVarint(state); + if (typedPaths !== 0) { + throw new RangeError( + "RowBinary: JSON with declared typed paths is not supported — read each typed path with its known type", + ); + } + const skipPaths = readUVarint(state); + for (let i = 0; i < skipPaths; i++) readString(state); + const skipRegexps = readUVarint(state); + for (let i = 0; i < skipRegexps; i++) readString(state); + return readJSON; + } + default: + throw new RangeError( + `RowBinary: unknown Dynamic type tag 0x${tag.toString(16)} (not in the binary type encoding table)`, + ); + } +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/enums.ts b/skills/clickhouse-js-node-rowbinary-parser/src/enums.ts new file mode 100644 index 000000000..ccbb4d388 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/enums.ts @@ -0,0 +1,28 @@ +import { RowBinaryState, advance } from "./core.js"; + +/** + * Read an `Enum8`: the value's underlying signed `Int8`. The name<->value map + * lives in the column's type, not the bytes. Two strategies, both better than one + * shared name-resolving reader: + * + * - Keep the number: carry the raw Int8 and map to a name only where needed — + * most hot loops never need it. + * - Or generate a per-enum reader with a baked-in constant map, so the JIT can + * monomorphize each enum's decode: + * + * const STATUS = { 1: "active", 2: "closed" } as const; + * const readStatusEnum = (s) => STATUS[readInt8(s) as keyof typeof STATUS]; + */ +export function readEnum8(state: RowBinaryState): number { + return state.view.getInt8(advance(state, 1)); +} + +/** + * Read an `Enum16`: the value's underlying signed `Int16` (2 bytes). The + * name<->value map lives in the column's type definition, not the bytes. Prefer + * keeping the number, or a generated per-enum reader with a baked-in constant + * map so the JIT can optimize each enum's decode independently. + */ +export function readEnum16(state: RowBinaryState): number { + return state.view.getInt16(advance(state, 2), true); +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/carts.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/carts.ts new file mode 100644 index 000000000..902412be5 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/carts.ts @@ -0,0 +1,71 @@ +import { readArray, readNullable, readTupleNamed } from "../composite.js"; +import { type Reader, advance } from "../core.js"; +import { readInt32, readUInt16, readUInt32 } from "../integers.js"; +import { readString } from "../strings.js"; +import { readUVarint } from "../varint.js"; + +/** + * Example: a carts table — nested generics. + * + * Columns (the trigger): + * cart_id UInt32 + * items Array(Tuple(sku String, qty UInt16)) + * discounts Array(Nullable(Int32)) + * + * The element reader of an `Array` can itself be a combinator: `items` is an + * `Array` whose element is a named `Tuple`, `discounts` an `Array` whose element + * is a `Nullable`. Combinators nest to any depth, matching the column type. + */ +export type CartRow = { + cartId: number; + items: { sku: string; qty: number }[]; + discounts: (number | null)[]; +}; + +export const readCartRow: Reader = (s) => ({ + cartId: readUInt32(s), + items: readArray(readTupleNamed({ sku: readString, qty: readUInt16 }))(s), + discounts: readArray(readNullable(readInt32))(s), +}); + +/** + * Optimized {@link readCartRow}, monomorphized through the nesting: the API + * version rebuilds the outer `readArray`, the inner `readTupleNamed`, and the + * `readNullable` closures on every row (and the tuple reader iterates a keys + * array per element). Here both arrays are inlined loops, the tuple element is a + * flat object literal, and the nullable is an inline branch — no closures, no key + * iteration, at either nesting level. + * + * MEASURED (Node 24 / V8, `carts.bench.ts`): ~2x faster — nested combinators + * (outer `readArray`, inner `readTupleNamed` / `readNullable`) rebuilt per row in + * the API version, all flattened here. + */ +export const readCartRowFast: Reader = (s) => { + const { buf, view } = s; + + // cart_id UInt32. + const cartId = view.getUint32(advance(s, 4), true); + + // items Array(Tuple(sku String, qty UInt16)): count, then per element a + // length-prefixed string and a 2-byte int. + const itemsN = readUVarint(s); + const items = new Array<{ sku: string; qty: number }>(itemsN); + for (let i = 0; i < itemsN; i++) { + const len = readUVarint(s); + const start = advance(s, len); + const sku = buf.toString("utf8", start, start + len); + const qty = view.getUint16(advance(s, 2), true); + items[i] = { sku, qty }; + } + + // discounts Array(Nullable(Int32)): count, then per element a null-flag byte + // and, if non-null, a 4-byte int. + const discN = readUVarint(s); + const discounts = new Array(discN); + for (let i = 0; i < discN; i++) { + discounts[i] = + buf[advance(s, 1)]! !== 0 ? null : view.getInt32(advance(s, 4), true); + } + + return { cartId, items, discounts }; +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/events.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/events.ts new file mode 100644 index 000000000..b07614523 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/events.ts @@ -0,0 +1,51 @@ +import { type Reader, advance } from "../core.js"; +import { readDateTime } from "../datetime.js"; +import { readUInt64 } from "../integers.js"; +import { readString } from "../strings.js"; +import { readUVarint } from "../varint.js"; + +/** + * Example: a plain events table — the scalar baseline. + * + * Columns (the trigger — generate this reader when a result has these types): + * id UInt64 + * name String + * ts DateTime('UTC') + * + * A wide integer (returned as `bigint`, never a lossy `number`), an arbitrary + * `String`, and a `DateTime` rendered to an ISO-8601 string here for a stable, + * timezone-independent result (the raw reader returns a JS `Date`). Drive it over + * a whole result with `readRows(readEventRow)`. + */ +export type EventRow = { id: bigint; name: string; ts: string }; + +export const readEventRow: Reader = (s) => ({ + id: readUInt64(s), + name: readString(s), + ts: readDateTime(s).toISOString(), +}); + +/** + * Optimized {@link readEventRow}: the same three reads inlined into one function + * body — no per-field reader calls, the `String` length + slice and the + * `DateTime` math written out in place. All scalars, so there is little for the + * monomorphization to remove (the JIT already inlines the leaf readers); see + * `events.bench.ts`. Still goes through `advance()`, so it stays streaming-safe. + * + * MEASURED (Node 24 / V8, `events.bench.ts`): ~1.05x — essentially ON PAR, within + * run-to-run noise. A purely scalar row has no per-row closures to remove and V8 + * already inlines the leaf readers, so there is no real win here: prefer the + * clearer API `readEventRow` unless your own profiling says otherwise. (Contrast + * the composite examples, where monomorphization removes per-row closures and + * wins 1.3x–2.7x.) + */ +export const readEventRowFast: Reader = (s) => { + const id = s.view.getBigUint64(advance(s, 8), true); + const len = readUVarint(s); + const start = advance(s, len); + const name = s.buf.toString("utf8", start, start + len); + const ts = new Date( + s.view.getUint32(advance(s, 4), true) * 1000, + ).toISOString(); + return { id, name, ts }; +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/iot.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/iot.ts new file mode 100644 index 000000000..24da52536 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/iot.ts @@ -0,0 +1,158 @@ +import { type Reader, advance } from "../core.js"; +import { readDateTime64P3 } from "../datetime.js"; +import { readFloat32, readFloat64 } from "../floats.js"; +import { readUInt8, readUInt32 } from "../integers.js"; + +/** + * Example: a table of IoT sensor readings — the dense, fixed-width NUMERIC case + * that RowBinary is built for, and the headline of the RowBinary-vs-JSON + * comparison in `iot.bench.ts`. + * + * Columns (the trigger — generate this reader when a result has these types): + * sensor_id UInt32 + * ts DateTime64(3) + * temperature Float64 + * humidity Float64 + * pressure Float64 + * battery Float32 + * status UInt8 + * + * Every column is fixed-width and there is not a single string or composite in + * the row, so the whole record is a flat 4 + 8 + 8 + 8 + 8 + 4 + 1 = 41-byte + * run. This is the shape where a JS RowBinary decoder beats `JSON.parse`: the + * wire is ~1/3 the size and each field is one `DataView` read, versus JSON's + * tokenize-and-number-parse over a much larger, key-repeating text. + */ +export type IotRow = { + sensor_id: number; + ts: Date; + temperature: number; + humidity: number; + pressure: number; + battery: number; + status: number; +}; + +/** + * API-combinator reader: correct and clear, one leaf reader per column. A fine + * default; `readIotRowFast` is the monomorphized form `iot.bench.ts` measures. + */ +export const readIotRow: Reader = (s) => ({ + sensor_id: readUInt32(s), + ts: readDateTime64P3(s), + temperature: readFloat64(s), + humidity: readFloat64(s), + pressure: readFloat64(s), + battery: readFloat32(s), + status: readUInt8(s), +}); + +/** + * Optimized {@link readIotRow}: every column is fixed-width, so the seven + * separate bounds checks coalesce into one `advance(s, 41)` and each field is + * read at a constant offset off that base — no per-field reader calls, no cursor + * write-back between fields. Stays streaming-safe (one `advance`), so a row that + * straddles a chunk boundary still rewinds and retries cleanly. + * + * sensor_id UInt32 @ o+0 getUint32 + * ts DateTime64(3) @ o+4 getBigInt64 (ms ticks -> Date) + * temperature Float64 @ o+12 getFloat64 + * humidity Float64 @ o+20 getFloat64 + * pressure Float64 @ o+28 getFloat64 + * battery Float32 @ o+36 getFloat32 + * status UInt8 @ o+40 buf[o+40] + */ +export const readIotRowFast: Reader = (s) => { + const { buf, view } = s; + const o = advance(s, 41); // one bounds check for the whole 41-byte row + const sensor_id = view.getUint32(o, true); + // DateTime64(3): Int64 millisecond ticks; ms fits a JS number, so Number() is exact here. + const ts = new Date(Number(view.getBigInt64(o + 4, true))); + const temperature = view.getFloat64(o + 12, true); + const humidity = view.getFloat64(o + 20, true); + const pressure = view.getFloat64(o + 28, true); + const battery = view.getFloat32(o + 36, true); + const status = buf[o + 40]!; + return { sensor_id, ts, temperature, humidity, pressure, battery, status }; +}; + +/** Byte width of one fixed-width IoT row: 4 + 8 + 8 + 8 + 8 + 4 + 1. */ +export const IOT_ROW_BYTES = 41; + +/** + * Columnar (struct-of-arrays) form of the IoT result: one typed array per + * column instead of one object per row. `ts` is kept as epoch milliseconds in a + * `Float64Array` (format the few you display; don't allocate 50k `Date`s). + */ +export type IotColumns = { + sensor_id: Uint32Array; + ts: Float64Array; // epoch ms + temperature: Float64Array; + humidity: Float64Array; + pressure: Float64Array; + battery: Float32Array; + status: Uint8Array; +}; + +/** + * Decode the whole IoT result into columns (SoA) rather than row objects (AoS). + * + * MEASURED (`iot.columnar.bench.ts`): ~4x faster than `readIotRowFast` over the + * same buffer, and several times smaller in memory. The win is entirely from + * what it does NOT do — no per-row object, no `Date`, no number boxing — so the + * cost drops to one unboxed store per field. It is a NUMERIC win; it would not + * help a string column (a JS string must be allocated either way). + * + * WHOLE-BUFFER ONLY: this needs the complete response in one `Buffer`. Because + * every IoT column is fixed-width the row stride is known, so the exact row + * count is `buf.length / IOT_ROW_BYTES` — one exact allocation per column, no + * growth, no bounds check in the loop. + * + * Reach for this when the consumer is column-oriented (aggregate / filter / + * scan / plot / feed a Worker or WASM kernel via the transferable + * `ArrayBuffer`s). Prefer the row reader when downstream code is row-shaped. + */ +export function decodeIotColumnar(buf: Buffer): IotColumns { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const n = (buf.length / IOT_ROW_BYTES) | 0; + // Hold each column in a LOCAL so the fill loop writes through a register-held + // typed-array reference, not a property load off a result object every + // iteration; assemble the object once, on return. + const sensor_id = new Uint32Array(n); + const ts = new Float64Array(n); + const temperature = new Float64Array(n); + const humidity = new Float64Array(n); + const pressure = new Float64Array(n); + const battery = new Float32Array(n); + const status = new Uint8Array(n); + let o = 0; + for (let i = 0; i < n; i++) { + sensor_id[i] = view.getUint32(o, true); // UInt32 + ts[i] = Number(view.getBigInt64(o + 4, true)); // DateTime64(3) ms ticks + temperature[i] = view.getFloat64(o + 12, true); // Float64 + humidity[i] = view.getFloat64(o + 20, true); // Float64 + pressure[i] = view.getFloat64(o + 28, true); // Float64 + battery[i] = view.getFloat32(o + 36, true); // Float32 + status[i] = buf[o + 40]!; // UInt8 + o += IOT_ROW_BYTES; + } + return { sensor_id, ts, temperature, humidity, pressure, battery, status }; +} + +/** + * Hybrid accessor: reconstruct a single {@link IotRow} object from columns on + * demand (here is where `ts` becomes a `Date`). Store columnar, and pay the + * object/`Date` cost only for the rows a caller actually touches — best when + * row access is sparse. + */ +export function iotRowAt(c: IotColumns, i: number): IotRow { + return { + sensor_id: c.sensor_id[i]!, + ts: new Date(c.ts[i]!), + temperature: c.temperature[i]!, + humidity: c.humidity[i]!, + pressure: c.pressure[i]!, + battery: c.battery[i]!, + status: c.status[i]!, + }; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/ledger.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/ledger.ts new file mode 100644 index 000000000..3e0dbc63c --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/ledger.ts @@ -0,0 +1,98 @@ +import { type Reader, advance } from "../core.js"; +import { + type DecimalValue, + readDecimal64, + readDecimal128, +} from "../decimals.js"; +import { readInt64, readUInt128, readUInt256 } from "../integers.js"; + +/** + * Example: a financial ledger — the WIDE-NUMERIC case where RowBinary wins on + * *correctness*, not merely speed, and the headline of the wide-int/decimal + * comparison in `ledger.bench.ts`. + * + * Columns (the trigger — generate this reader when a result has these types): + * txn_id UInt128 + * account Int64 + * amount Decimal128(18) + * balance Decimal128(18) + * fee Decimal64(4) + * volume UInt256 + * + * Every value here exceeds what a JS `number` (IEEE-754 double, 53-bit mantissa) + * can hold exactly, and ClickHouse emits them as **bare JSON numbers**. So + * `JSON.parse` silently rounds every field — the only correct JSON path is to + * quote the values server-side and re-parse each string into a `bigint` / + * decimal pair by hand. RowBinary reads each as an exact `bigint` straight off + * the wire. The whole row is fixed-width (16+8+16+16+8+32 = 96 bytes). + */ +export type LedgerRow = { + txn_id: bigint; + account: bigint; + amount: DecimalValue; + balance: DecimalValue; + fee: DecimalValue; + volume: bigint; +}; + +/** + * API-combinator reader: correct and clear, one leaf reader per column. A fine + * default; `readLedgerRowFast` is the monomorphized form `ledger.bench.ts` + * measures. Both return identical values. + */ +export const readLedgerRow: Reader = (s) => ({ + txn_id: readUInt128(s), + account: readInt64(s), + amount: readDecimal128(18)(s), + balance: readDecimal128(18)(s), + fee: readDecimal64(4)(s), + volume: readUInt256(s), +}); + +/** + * Optimized, monomorphized reader: the six column bounds checks coalesce into + * one `advance(s, 96)`; each wide value is composed from 64-bit words read at + * constant offsets off that base, with the high word read **signed** for the + * signed types (`Int64`, the `Decimal128` unscaled value, no high word needed + * for the unsigned `UInt128`/`UInt256`). Stays streaming-safe (one `advance`). + * + * txn_id UInt128 @ o+0 lo + (hi<<64) unsigned + * account Int64 @ o+16 getBigInt64 + * amount Decimal128(18) @ o+24 lo + (hiSigned<<64) -> [v, 18] + * balance Decimal128(18) @ o+40 lo + (hiSigned<<64) -> [v, 18] + * fee Decimal64(4) @ o+56 getBigInt64 -> [v, 4] + * volume UInt256 @ o+64 w0 + w1<<64 + w2<<128 + w3<<192 unsigned + */ +export const readLedgerRowFast: Reader = (s) => { + const { view } = s; + const o = advance(s, 96); // one bounds check for the whole 96-byte row + + // UInt128 — unsigned, both words unsigned. + const txn_id = + view.getBigUint64(o, true) + (view.getBigUint64(o + 8, true) << 64n); + + // Int64 — signed. + const account = view.getBigInt64(o + 16, true); + + // Decimal128(18) — Int128 unscaled (low word unsigned, high word signed), scale 18. + const amount: DecimalValue = [ + view.getBigUint64(o + 24, true) + (view.getBigInt64(o + 32, true) << 64n), + 18, + ]; + const balance: DecimalValue = [ + view.getBigUint64(o + 40, true) + (view.getBigInt64(o + 48, true) << 64n), + 18, + ]; + + // Decimal64(4) — Int64 unscaled, scale 4. + const fee: DecimalValue = [view.getBigInt64(o + 56, true), 4]; + + // UInt256 — unsigned, four unsigned words. + const volume = + view.getBigUint64(o + 64, true) + + (view.getBigUint64(o + 72, true) << 64n) + + (view.getBigUint64(o + 80, true) << 128n) + + (view.getBigUint64(o + 88, true) << 192n); + + return { txn_id, account, amount, balance, fee, volume }; +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/logs.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/logs.ts new file mode 100644 index 000000000..4fd3b233f --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/logs.ts @@ -0,0 +1,73 @@ +import { type Reader, advance } from "../core.js"; +import { readDateTime } from "../datetime.js"; +import { readString } from "../strings.js"; +import { readUVarint } from "../varint.js"; + +/** + * Example: an application log table — the STRING-HEAVY case where the skill + * steers you AWAY from RowBinary and toward a `JSON*` format. The honest + * counter-case to the IoT and ledger studies; see `logs.bench.ts`. + * + * Columns (the trigger — generate this reader when a result has these types): + * ts DateTime + * level LowCardinality(String) + * service LowCardinality(String) + * message String + * trace_id String + * + * Four of the five columns are text consumed wholesale, and `LowCardinality(T)` + * is transparent in RowBinary (decodes as the inner `String`, no dictionary on + * the wire). A RowBinary string read is a varint length + `buf.toString("utf8", + * …)` per field in JS; V8's native `JSON.parse` builds the same JS strings in + * optimized C++ and tends to WIN here. This reader exists so the comparison is + * apples-to-apples — not because RowBinary is the right call for this shape. + */ +export type LogRow = { + ts: Date; + level: string; + service: string; + message: string; + trace_id: string; +}; + +/** + * API-combinator reader: one leaf reader per column, the clear default. There is + * little to monomorphize on a mostly-string row — the only fixed-width field is + * `ts` — so `readLogRowFast` below is barely different and barely faster; the + * real lesson (see `logs.bench.ts`) is that JSON beats both. + */ +export const readLogRow: Reader = (s) => ({ + ts: readDateTime(s), + level: readString(s), // LowCardinality(String) — transparent, decode as String + service: readString(s), // LowCardinality(String) — transparent, decode as String + message: readString(s), + trace_id: readString(s), +}); + +/** + * Optimized {@link readLogRow}: the four string reads inlined (varint length + + * `buf.toString` in place) and the `DateTime` read written out. Note how little + * monomorphization can do when the row is dominated by variable-length strings — + * there are no adjacent fixed-width columns to coalesce, so this stays close to + * the API version. Included to make `logs.bench.ts` a fair fight; the takeaway + * is to pick `JSONEachRow` for this shape, not to tune this reader. + */ +export const readLogRowFast: Reader = (s) => { + const { buf } = s; + // DateTime: 4-byte LE Unix seconds. + const ts = new Date(s.view.getUint32(advance(s, 4), true) * 1000); + // Four UTF-8 Strings (the two LowCardinality columns are plain String on the wire). + let len = readUVarint(s); + let o = advance(s, len); + const level = buf.toString("utf8", o, o + len); + len = readUVarint(s); + o = advance(s, len); + const service = buf.toString("utf8", o, o + len); + len = readUVarint(s); + o = advance(s, len); + const message = buf.toString("utf8", o, o + len); + len = readUVarint(s); + o = advance(s, len); + const trace_id = buf.toString("utf8", o, o + len); + return { ts, level, service, message, trace_id }; +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/observability.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/observability.ts new file mode 100644 index 000000000..7897af584 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/observability.ts @@ -0,0 +1,142 @@ +import { + readArray, + readMap, + readNullable, + readTupleNamed, + readVariant, +} from "../composite.js"; +import { type Reader, advance } from "../core.js"; +import { readDateTime64P3 } from "../datetime.js"; +import { readEnum8 } from "../enums.js"; +import { readFloat64 } from "../floats.js"; +import { readInt64, readUInt64 } from "../integers.js"; +import { readString } from "../strings.js"; +import { formatUUID, formatUUIDTable, readUUID } from "../uuid.js"; +import { readUVarint } from "../varint.js"; + +/** + * Example: an observability/events table — the gotcha-heavy one. It packs the + * traps that trip a from-scratch decoder; the skill's job is getting them right. + * + * Columns (the trigger): + * id UInt64 + * ts DateTime64(3, 'UTC') + * level Enum8('debug'=1, 'info'=2, 'warn'=3, 'error'=4) + * trace_id UUID + * payload Variant(String, Int64, Float64) + * tags Map(LowCardinality(String), String) + * metrics Array(Tuple(name LowCardinality(String), value Float64)) + * attrs Array(Nullable(Int64)) + * + * Gotchas exercised, all in one row: + * - `Variant(String, Int64, Float64)`: the discriminant indexes the alternatives + * SORTED BY TYPE NAME — ["Float64", "Int64", "String"] → 0=Float64, 1=Int64, + * 2=String (NOT declaration order); `0xFF` = NULL. `readVariant` takes the + * readers in that sorted order. + * - `DateTime64(3)`: 8-byte Int64 of millisecond ticks; P=3 is exactly a `Date`'s + * resolution, so `readDateTime64P3` returns a plain `Date` (here ISO-stringed). + * - `LowCardinality(String)` (in the Map key and the Tuple field) is TRANSPARENT + * in RowBinary — decode as plain `String`, no dictionary layer. + * - `UUID` is two little-endian `UInt64` halves, byte-reversed vs the text form. + * - `Array(Nullable(Int64))`: per element a null flag then (if present) an Int64, + * kept as `bigint`. + */ +export type ObsRow = { + id: bigint; + ts: string; + level: number; + traceId: string; + payload: number | bigint | string | null; + tags: Map; + metrics: { name: string; value: number }[]; + attrs: (bigint | null)[]; +}; + +/** + * API-combinator reader. Note the `Variant` readers are in sorted-type-name + * order (Float64, Int64, String), and `LowCardinality` columns just use the + * inner `String` reader. + */ +export const readObsRow: Reader = (s) => ({ + id: readUInt64(s), + ts: readDateTime64P3(s).toISOString(), + level: readEnum8(s), + traceId: formatUUID(readUUID(s)), + payload: readVariant([readFloat64, readInt64, readString])(s), + tags: readMap(readString, readString)(s), + metrics: readArray(readTupleNamed({ name: readString, value: readFloat64 }))( + s, + ), + attrs: readArray(readNullable(readInt64))(s), +}); + +/** + * Optimized {@link readObsRow}, flattened per the SKILL.md guidance: + * - `buf`/`view` hoisted to locals. + * - the leading run of FIXED-WIDTH columns — `id` UInt64 (8) + `ts` DateTime64 (8) + * + `level` Enum8 (1) + `trace_id` UUID (16) = 33 bytes — is bounds-checked ONCE + * (`advance(s, 33)`) and read at constant offsets, instead of four `advance`s. + * - the `Variant` is an inlined `switch` over the discriminant (sorted order). + * - leaf reads inlined, `formatUUIDTable` for the UUID, pre-sized arrays. + * The variable-width columns (`payload`/`tags`/`metrics`/`attrs`) each start a new + * `advance` run because their size isn't known until decoded. + */ +export const readObsRowFast: Reader = (s) => { + const { buf, view } = s; + + // One bounds check for the 33-byte fixed-width head. + const o = advance(s, 33); + const id = view.getBigUint64(o, true); + const ts = new Date(Number(view.getBigInt64(o + 8, true))).toISOString(); + const level = view.getInt8(o + 16); + const traceId = formatUUIDTable(buf.subarray(o + 17, o + 33)); + + // payload Variant(String, Int64, Float64): 1-byte discriminant (sorted names: + // 0=Float64, 1=Int64, 2=String), 0xFF = NULL. + let payload: number | bigint | string | null; + const disc = buf[advance(s, 1)]!; + if (disc === 0xff) { + payload = null; + } else if (disc === 0) { + payload = view.getFloat64(advance(s, 8), true); + } else if (disc === 1) { + payload = view.getBigInt64(advance(s, 8), true); + } else { + const len = readUVarint(s); + const st = advance(s, len); + payload = buf.toString("utf8", st, st + len); + } + + // tags Map(LowCardinality(String) -> String): count, then key/value strings. + const tagN = readUVarint(s); + const tags = new Map(); + for (let i = 0; i < tagN; i++) { + let len = readUVarint(s); + let st = advance(s, len); + const k = buf.toString("utf8", st, st + len); + len = readUVarint(s); + st = advance(s, len); + tags.set(k, buf.toString("utf8", st, st + len)); + } + + // metrics Array(Tuple(name LowCardinality(String), value Float64)). + const mN = readUVarint(s); + const metrics = new Array<{ name: string; value: number }>(mN); + for (let i = 0; i < mN; i++) { + const len = readUVarint(s); + const st = advance(s, len); + const name = buf.toString("utf8", st, st + len); + const value = view.getFloat64(advance(s, 8), true); + metrics[i] = { name, value }; + } + + // attrs Array(Nullable(Int64)). + const aN = readUVarint(s); + const attrs = new Array(aN); + for (let i = 0; i < aN; i++) { + attrs[i] = + buf[advance(s, 1)]! !== 0 ? null : view.getBigInt64(advance(s, 8), true); + } + + return { id, ts, level, traceId, payload, tags, metrics, attrs }; +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/orders.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/orders.ts new file mode 100644 index 000000000..ddf862457 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/orders.ts @@ -0,0 +1,65 @@ +import { type Reader, advance } from "../core.js"; +import { type DecimalValue, readDecimal64 } from "../decimals.js"; +import { readEnum8 } from "../enums.js"; +import { readUInt8 } from "../integers.js"; +import { formatUUID, formatUUIDTable, readUUID } from "../uuid.js"; + +/** + * Example: an orders table — UUID, Decimal, and Enum (awkward-as-JSON types). + * + * Columns (the trigger): + * id UInt8 + * uid UUID + * price Decimal64(2) + * status Enum8('new' = 1, 'shipped' = 2, 'done' = 3) + * + * Shows the parse/format split and faithful values: `uid` is read as raw bytes + * then formatted with `formatUUID`; `price` stays the exact `[unscaled, scale]` + * pair (`[1234n, 2]` == 12.34), not a lossy float; `status` decodes to the + * underlying `Int8` value (1/2/3), the name<->value map being type metadata, not + * on the wire. The declared scale `2` is baked into `readDecimal64(2)`. + */ +export type OrderRow = { + id: number; + uid: string; + price: DecimalValue; + status: number; +}; + +export const readOrderRow: Reader = (s) => ({ + id: readUInt8(s), + uid: formatUUID(readUUID(s)), + price: readDecimal64(2)(s), + status: readEnum8(s), +}); + +/** + * Optimized {@link readOrderRow}, flattened per the SKILL.md guidance: + * - `buf`/`view` hoisted to locals (one property load, not one per field). + * - every column here is FIXED-WIDTH, so the whole row — `id` UInt8 (1) + `uid` + * UUID (16) + `price` Decimal64 (8) + `status` Enum8 (1) = 26 bytes — is + * bounds-checked ONCE (`advance(s, 26)`) and read at constant offsets, instead + * of four separate `advance`s. (This is the exact worked example in SKILL.md.) + * - the four leaf reads are inlined, and the BigInt `formatUUID` is swapped for + * the lookup-table `formatUUIDTable` (~1.6x on its own; see `readUUID.bench.ts`). + * Since this example formats every UUID to a string, that swap is the dominant + * win — the `readDecimal64(2)` closure (rebuilt per row above) is inlined too. + * + * MEASURED (Node 24 / V8, `orders.bench.ts`): ~2.6x faster — the largest win of + * the examples, dominated by the `formatUUIDTable` swap (every row stringifies a + * UUID; the table formatter is ~1.7x on its own and the row is otherwise cheap). + * + * `formatUUIDTable` uses a shared scratch buffer, so it is non-reentrant — fine + * here because the bytes are copied into the returned string synchronously before + * the next call. + */ +export const readOrderRowFast: Reader = (s) => { + const { buf, view } = s; + // One bounds check for the whole 26-byte fixed-width row. + const o = advance(s, 26); + const id = buf[o]!; + const uid = formatUUIDTable(buf.subarray(o + 1, o + 17)); + const price: DecimalValue = [view.getBigInt64(o + 17, true), 2]; + const status = view.getInt8(o + 25); + return { id, uid, price, status }; +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/profiles.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/profiles.ts new file mode 100644 index 000000000..7a09c3148 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/profiles.ts @@ -0,0 +1,60 @@ +import { readArray, readNullable } from "../composite.js"; +import { type Reader, advance } from "../core.js"; +import { readInt32, readUInt32 } from "../integers.js"; +import { readString } from "../strings.js"; +import { readUVarint } from "../varint.js"; + +/** + * Example: a profiles table — Array and Nullable wrappers. + * + * Columns (the trigger): + * id UInt32 + * tags Array(String) + * score Nullable(Int32) + * + * `readArray(elem)` reads a LEB128 length then that many elements; `readNullable` + * reads a 1-byte present/NULL flag then the value. Both are combinators: pass the + * inner reader and they return a `Reader`. Empty array and NULL are the sharp + * cases (a single byte each). + */ +export type ProfileRow = { id: number; tags: string[]; score: number | null }; + +export const readProfileRow: Reader = (s) => ({ + id: readUInt32(s), + tags: readArray(readString)(s), + score: readNullable(readInt32)(s), +}); + +/** + * Optimized {@link readProfileRow}, monomorphized: `readArray(readString)` and + * `readNullable(readInt32)` each allocate a fresh combinator closure on EVERY + * row in the version above; here the array loop and the null-flag branch are + * inlined, so no per-row closures are created and the element/inner reads are + * straight-line. This is the kind of win the SKILL's "monomorphize" step targets; + * see `profiles.bench.ts`. + * + * MEASURED (Node 24 / V8, `profiles.bench.ts`): ~1.3x faster — removing the two + * per-row combinator closures (`readArray(readString)`, `readNullable(readInt32)`) + * is the win. + */ +export const readProfileRowFast: Reader = (s) => { + const { buf, view } = s; + + // id UInt32. + const id = view.getUint32(advance(s, 4), true); + + // tags Array(String): count, then each a length-prefixed UTF-8 string. + const n = readUVarint(s); + const tags = new Array(n); + for (let i = 0; i < n; i++) { + const len = readUVarint(s); + const start = advance(s, len); + tags[i] = buf.toString("utf8", start, start + len); + } + + // score Nullable(Int32): null-flag byte, then if non-null a 4-byte int. + const score = + buf[advance(s, 1)]! !== 0 ? null : view.getInt32(advance(s, 4), true); + + return { id, tags, score }; +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/examples/telemetry.ts b/skills/clickhouse-js-node-rowbinary-parser/src/examples/telemetry.ts new file mode 100644 index 000000000..3cc38a5af --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/examples/telemetry.ts @@ -0,0 +1,102 @@ +import { + readArray, + readMap, + readNullable, + readTupleNamed, +} from "../composite.js"; +import { type Reader, advance } from "../core.js"; +import { readFloat64 } from "../floats.js"; +import { readUInt16, readUInt32 } from "../integers.js"; +import { readString } from "../strings.js"; +import { readUVarint } from "../varint.js"; + +/** + * Example: a telemetry table — composite readers that nest. + * + * Columns (the trigger): + * host String + * tags Map(String, String) + * cpu Array(Float64) + * region Nullable(String) + * window Tuple(start UInt32, count UInt16) + * + * The combinators compose exactly the way the column type nests: + * `readMap(k, v)`, `readArray(elem)`, `readNullable(inner)`, and + * `readTupleNamed({...})` each take sub-readers and return a `Reader`. This is the + * generic (closure-per-element) API; a generated parser would monomorphize these + * into inlined per-type loops, but the result shape is exactly this. + */ +export type TelemetryRow = { + host: string; + tags: Map; + cpu: number[]; + region: string | null; + window: { start: number; count: number }; +}; + +export const readTelemetryRow: Reader = (s) => ({ + host: readString(s), + tags: readMap(readString, readString)(s), + cpu: readArray(readFloat64)(s), + region: readNullable(readString)(s), + window: readTupleNamed({ start: readUInt32, count: readUInt16 })(s), +}); + +/** + * Optimized {@link readTelemetryRow}, fully monomorphized: the API version above + * builds FOUR combinator closures per row (`readMap(...)`, `readArray(...)`, + * `readNullable(...)`, `readTupleNamed(...)`) and, for the named tuple, iterates + * a keys array building an object field by field. Here every loop and branch is + * inlined and the `window` object is a flat literal — no per-row closures, no + * key iteration. The most composite-heavy example. + * + * MEASURED (Node 24 / V8, `telemetry.bench.ts`): ~1.4x faster — four per-row + * combinator closures and the named-tuple key iteration removed. + */ +export const readTelemetryRowFast: Reader = (s) => { + const { buf, view } = s; + + // host String: length prefix, then the bytes. + let len = readUVarint(s); + let start = advance(s, len); + const host = buf.toString("utf8", start, start + len); + + // tags Map(String, String): count, then key/value strings. + const mapN = readUVarint(s); + const tags = new Map(); + for (let i = 0; i < mapN; i++) { + len = readUVarint(s); + start = advance(s, len); + const k = buf.toString("utf8", start, start + len); + len = readUVarint(s); + start = advance(s, len); + tags.set(k, buf.toString("utf8", start, start + len)); + } + + // cpu Array(Float64): count, then 8 bytes each. + const cpuN = readUVarint(s); + const cpu = new Array(cpuN); + for (let i = 0; i < cpuN; i++) { + cpu[i] = view.getFloat64(advance(s, 8), true); + } + + // region Nullable(String): null-flag byte, then if non-null a string. + let region: string | null; + if (buf[advance(s, 1)]! !== 0) { + region = null; + } else { + len = readUVarint(s); + start = advance(s, len); + region = buf.toString("utf8", start, start + len); + } + + // window Tuple(start UInt32, count UInt16): two adjacent fixed-width fields, + // bounds-checked once (6 bytes), then read at literal offsets. + const w = advance(s, 6); + const window = { + start: view.getUint32(w, true), + count: view.getUint16(w + 4, true), + }; + + return { host, tags, cpu, region, window }; +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/floats.ts b/skills/clickhouse-js-node-rowbinary-parser/src/floats.ts new file mode 100644 index 000000000..863498710 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/floats.ts @@ -0,0 +1,32 @@ +import { RowBinaryState, advance } from "./core.js"; + +/** + * Scratch view for widening a `BFloat16`: its 16 bits are the top half of an + * IEEE 754 float32, so we stage them into a 4-byte buffer and read a float32. + */ +const bf16Scratch = new DataView(new ArrayBuffer(4)); + +/** Read a `Float32`: 4 bytes, little-endian IEEE 754 single precision. */ +export function readFloat32(state: RowBinaryState): number { + return state.view.getFloat32(advance(state, 4), true); +} + +/** Read a `Float64`: 8 bytes, little-endian IEEE 754 double precision. */ +export function readFloat64(state: RowBinaryState): number { + return state.view.getFloat64(advance(state, 8), true); +} + +/** + * Read a `BFloat16`: 2 bytes, little-endian. BFloat16 is the high 16 bits of a + * float32 (same 8-bit exponent, 7-bit mantissa), so placing the bits in the top + * half of a 32-bit float and reading it back is exact. + * + * NOTE: `bf16Scratch` is module-level shared state written-then-read in this + * function. That is safe because the read is synchronous; do NOT introduce an + * `await`/`yield` between the `setUint32` and the `getFloat32`. + */ +export function readBFloat16(state: RowBinaryState): number { + const bits = state.view.getUint16(advance(state, 2), true); + bf16Scratch.setUint32(0, bits * 0x10000, true); + return bf16Scratch.getFloat32(0, true); +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/geo.ts b/skills/clickhouse-js-node-rowbinary-parser/src/geo.ts new file mode 100644 index 000000000..b5c715046 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/geo.ts @@ -0,0 +1,109 @@ +import { RowBinaryState } from "./core.js"; +import { readFloat64 } from "./floats.js"; +import { readUInt8 } from "./integers.js"; +import { readUVarint } from "./varint.js"; + +/** A geo `Point`: `[x, y]`, the base of every ClickHouse geo type. */ +export type Point = [x: number, y: number]; + +// Geo types are concrete compositions of Point = Tuple(Float64, Float64). They +// are monomorphic (no sub-readers) — the generator can emit them as-is. + +/** Read a `Point`: `Tuple(Float64, Float64)` -> `[x, y]`. */ +export function readPoint(state: RowBinaryState): Point { + const x = readFloat64(state); + const y = readFloat64(state); + return [x, y]; +} + +/** + * Read a `Ring`: `Array(Point)` — a LEB128 point count, then that many points. + * `LineString` has the identical wire (see {@link readLineString}). `readPoint` + * is inlined here (two `readFloat64`s) to drop a call per point on this hot path. + */ +export function readRing(state: RowBinaryState): Point[] { + const n = readUVarint(state); + const out: Point[] = []; + for (let i = 0; i < n; i++) { + const x = readFloat64(state); + const y = readFloat64(state); + out.push([x, y]); + } + return out; +} + +/** + * Read a `LineString`: `Array(Point)` (identical wire to a `Ring`). Points are + * inlined (two `readFloat64`s) to drop a call per point on this hot path. + */ +export function readLineString(state: RowBinaryState): Point[] { + const n = readUVarint(state); + const out: Point[] = []; + for (let i = 0; i < n; i++) { + const x = readFloat64(state); + const y = readFloat64(state); + out.push([x, y]); + } + return out; +} + +/** Read a `Polygon`: `Array(Ring)` — the outer ring first, then any holes. */ +export function readPolygon(state: RowBinaryState): Point[][] { + const n = readUVarint(state); + const out: Point[][] = []; + for (let i = 0; i < n; i++) out.push(readRing(state)); + return out; +} + +/** Read a `MultiLineString`: `Array(LineString)` (identical wire to a `Polygon`). */ +export function readMultiLineString(state: RowBinaryState): Point[][] { + const n = readUVarint(state); + const out: Point[][] = []; + for (let i = 0; i < n; i++) out.push(readLineString(state)); + return out; +} + +/** Read a `MultiPolygon`: `Array(Polygon)`. */ +export function readMultiPolygon(state: RowBinaryState): Point[][][] { + const n = readUVarint(state); + const out: Point[][][] = []; + for (let i = 0; i < n; i++) out.push(readPolygon(state)); + return out; +} + +/** + * Read a `Geometry`: a named `Variant` over the six geo types. This is the + * MONOMORPHIZED form of `readVariant` for a concrete variant — a switch over the + * discriminant with each branch inlined, no reader array. The alternatives, + * sorted by type name (so in discriminant order), are LineString(0), + * MultiLineString(1), MultiPolygon(2), Point(3), Polygon(4), Ring(5); 0xFF is NULL. + * + * NOTE: the value shapes overlap — LineString and Ring are both `Point[]`, + * MultiLineString and Polygon both `Point[][]` — so the value alone does not say + * which geo type it was. If you need the kind, branch on the discriminant. + */ +export function readGeometry( + state: RowBinaryState, +): Point | Point[] | Point[][] | Point[][][] | null { + const discriminant = readUInt8(state); + switch (discriminant) { + case 0: + return readLineString(state); + case 1: + return readMultiLineString(state); + case 2: + return readMultiPolygon(state); + case 3: + return readPoint(state); + case 4: + return readPolygon(state); + case 5: + return readRing(state); + case 0xff: + return null; + default: + throw new RangeError( + `RowBinary: unknown Geometry discriminant ${discriminant}`, + ); + } +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/integers.ts b/skills/clickhouse-js-node-rowbinary-parser/src/integers.ts new file mode 100644 index 000000000..4b010341c --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/integers.ts @@ -0,0 +1,95 @@ +import { RowBinaryState, advance } from "./core.js"; + +/** Read a single unsigned byte and advance. */ +export function readUInt8(state: RowBinaryState): number { + return state.buf[advance(state, 1)]!; +} + +/** Read an `Int8`: 1 byte, two's-complement signed (-128 .. 127). */ +export function readInt8(state: RowBinaryState): number { + return state.view.getInt8(advance(state, 1)); +} + +/** Read a `UInt16`: 2 bytes, little-endian (0 .. 65535). */ +export function readUInt16(state: RowBinaryState): number { + return state.view.getUint16(advance(state, 2), true); +} + +/** + * Read an `Int16`: 2 bytes, little-endian, two's-complement signed (-32768 .. + * 32767). `DataView` reads from any offset and decodes explicitly little-endian, + * so the value never depends on host byte order. + */ +export function readInt16(state: RowBinaryState): number { + return state.view.getInt16(advance(state, 2), true); +} + +/** Read a `UInt32`: 4 bytes, little-endian (0 .. 4294967295). */ +export function readUInt32(state: RowBinaryState): number { + return state.view.getUint32(advance(state, 4), true); +} + +/** Read an `Int32`: 4 bytes, little-endian, two's-complement signed. */ +export function readInt32(state: RowBinaryState): number { + return state.view.getInt32(advance(state, 4), true); +} + +/** + * Read a `UInt64`: 8 bytes, little-endian. Returns a `bigint`. + * SAFE TO TOGGLE: if the values fit in 53 bits, wrap in `Number(...)`. + */ +export function readUInt64(state: RowBinaryState): bigint { + return state.view.getBigUint64(advance(state, 8), true); +} + +/** + * Read an `Int64`: 8 bytes, little-endian, two's-complement. Returns a `bigint` + * (range exceeds `Number.MAX_SAFE_INTEGER`). + * SAFE TO TOGGLE: if the values fit in 53 bits, wrap in `Number(...)`. + */ +export function readInt64(state: RowBinaryState): bigint { + return state.view.getBigInt64(advance(state, 8), true); +} + +/** Read a `UInt128`: 16 bytes, little-endian. Always a `bigint`. */ +export function readUInt128(state: RowBinaryState): bigint { + const start = advance(state, 16); + const lo = state.view.getBigUint64(start, true); + const hi = state.view.getBigUint64(start + 8, true); + return (hi << 64n) + lo; +} + +/** + * Read an `Int128`: 16 bytes, little-endian, two's-complement. Always a + * `bigint`, composed from the low (unsigned) and high (signed) 64-bit words — + * reading the high word signed extends the sign across all 128 bits. + */ +export function readInt128(state: RowBinaryState): bigint { + const start = advance(state, 16); + const lo = state.view.getBigUint64(start, true); + const hi = state.view.getBigInt64(start + 8, true); + return (hi << 64n) + lo; +} + +/** Read a `UInt256`: 32 bytes, little-endian. Always a `bigint`. */ +export function readUInt256(state: RowBinaryState): bigint { + const start = advance(state, 32); + const w0 = state.view.getBigUint64(start, true); + const w1 = state.view.getBigUint64(start + 8, true); + const w2 = state.view.getBigUint64(start + 16, true); + const w3 = state.view.getBigUint64(start + 24, true); + return w0 + (w1 << 64n) + (w2 << 128n) + (w3 << 192n); +} + +/** + * Read an `Int256`: 32 bytes, little-endian, two's-complement. Always a + * `bigint`. The most-significant 64-bit word is read signed. + */ +export function readInt256(state: RowBinaryState): bigint { + const start = advance(state, 32); + const w0 = state.view.getBigUint64(start, true); + const w1 = state.view.getBigUint64(start + 8, true); + const w2 = state.view.getBigUint64(start + 16, true); + const w3 = state.view.getBigInt64(start + 24, true); + return (w3 << 192n) + (w2 << 128n) + (w1 << 64n) + w0; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/interval.ts b/skills/clickhouse-js-node-rowbinary-parser/src/interval.ts new file mode 100644 index 000000000..ea70b6473 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/interval.ts @@ -0,0 +1,54 @@ +import { RowBinaryState } from "./core.js"; +import { readInt64 } from "./integers.js"; + +/** The 11 `Interval` units, in ClickHouse's ascending order. */ +export type IntervalUnit = + | "Nanosecond" + | "Microsecond" + | "Millisecond" + | "Second" + | "Minute" + | "Hour" + | "Day" + | "Week" + | "Month" + | "Quarter" + | "Year"; + +/** + * `Interval` units indexed by the kind byte the binary type encoding writes + * after the `0x22` tag (`0x00` = Nanosecond ... `0x0a` = Year). Exported because + * the `Dynamic` reader needs it to decode an `Interval` nested in a `Dynamic`. + */ +export const INTERVAL_UNITS: readonly IntervalUnit[] = [ + "Nanosecond", + "Microsecond", + "Millisecond", + "Second", + "Minute", + "Hour", + "Day", + "Week", + "Month", + "Quarter", + "Year", +]; + +/** + * An `Interval` decoded where the unit is carried IN the wire (inside a + * `Dynamic`): the signed `Int64` count plus its unit. A standalone `Interval*` + * column has no unit byte — there, use {@link readInterval} and take the unit + * from the column type instead. + */ +export type IntervalValue = readonly [count: bigint, unit: IntervalUnit]; + +/** + * Read an `Interval` — any of `IntervalNanosecond` ... `IntervalYear`: a signed + * `Int64` count of the unit. The unit is in the type name, not the bytes, and + * all 11 interval types share this exact wire, so this one reader covers them + * all; the caller knows the unit from the column type. Returns a `bigint`; wrap + * in `Number(...)` if the counts are known to fit in 53 bits. + */ +export function readInterval(state: RowBinaryState): bigint { + return readInt64(state); +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/ip.ts b/skills/clickhouse-js-node-rowbinary-parser/src/ip.ts new file mode 100644 index 000000000..ea64cdf2f --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/ip.ts @@ -0,0 +1,93 @@ +import { RowBinaryState, advance } from "./core.js"; + +/** + * Read an `IPv4`: stored as a 4-byte little-endian `UInt32`. Returns the raw + * 32-bit value (the little-endian load already orders the octets); pass it to + * {@link formatIPv4} for the dotted-quad string. + */ +export function readIPv4(state: RowBinaryState): number { + return state.view.getUint32(advance(state, 4), true); +} + +/** + * Read an `IPv6`: 16 bytes in network (big-endian) order. Returns the raw bytes + * as a zero-copy view; pass them to {@link formatIPv6} for the canonical string. + * + * The view shares memory with the response buffer, so keeping it alive pins the + * whole response chunk in memory. If the value must outlive the row/response, + * copy it with `Buffer.from(...)`. + */ +export function readIPv6(state: RowBinaryState): Buffer { + const start = advance(state, 16); + return state.buf.subarray(start, start + 16); +} + +/** + * Format an `IPv4` (the raw 32-bit value from {@link readIPv4}) as a dotted-quad + * string. Kept aside so the hot read path can skip building a string when the + * numeric value is all the caller needs. + */ +export function formatIPv4(value: number): string { + return `${(value >>> 24) & 0xff}.${(value >>> 16) & 0xff}.${(value >>> 8) & 0xff}.${value & 0xff}`; +} + +/** + * Join groups `[from, to)` as colon-separated lowercase hex, by concatenating + * into a string in a loop. Benchmarks faster than `slice().map().join(":")`, + * which allocates an intermediate array. Returns `""` for an empty range. + */ +function joinGroupsHex(g: number[], from: number, to: number): string { + if (from >= to) return ""; + let s = g[from]!.toString(16); + for (let i = from + 1; i < to; i++) s += ":" + g[i]!.toString(16); + return s; +} + +/** + * Format an `IPv6` (the raw 16 bytes from {@link readIPv6}) as the canonical + * RFC 5952 string: lowercase, no leading zeros, the longest run of zero groups + * (>= 2) collapsed to `::` (leftmost on a tie), and the `::ffff:a.b.c.d` form + * for IPv4-mapped addresses (matching ClickHouse). + * + * Kept aside from the read so the hot path only formats when a string is + * actually needed. + */ +export function formatIPv6(b: Buffer): string { + // IPv4-mapped (::ffff:a.b.c.d): first 10 bytes zero, then 0xffff. + let mapped = b[10] === 0xff && b[11] === 0xff; + for (let i = 0; mapped && i < 10; i++) { + if (b[i] !== 0) mapped = false; + } + if (mapped) { + return `::ffff:${b[12]}.${b[13]}.${b[14]}.${b[15]}`; + } + + // Eight 16-bit groups, big-endian. + const g: number[] = []; + for (let i = 0; i < 8; i++) { + g.push((b[2 * i]! << 8) | b[2 * i + 1]!); + } + + // Longest run of >= 2 zero groups becomes "::" (leftmost wins on a tie). + let bestStart = -1; + let bestLen = 0; + let curStart = -1; + let curLen = 0; + for (let i = 0; i < 8; i++) { + if (g[i] === 0) { + if (curStart < 0) curStart = i; + curLen++; + if (curLen > bestLen) { + bestLen = curLen; + bestStart = curStart; + } + } else { + curStart = -1; + curLen = 0; + } + } + if (bestLen < 2) { + return joinGroupsHex(g, 0, 8); + } + return `${joinGroupsHex(g, 0, bestStart)}::${joinGroupsHex(g, bestStart + bestLen, 8)}`; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/json.ts b/skills/clickhouse-js-node-rowbinary-parser/src/json.ts new file mode 100644 index 000000000..81f5e7866 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/json.ts @@ -0,0 +1,33 @@ +import { RowBinaryState } from "./core.js"; +import { readUVarint } from "./varint.js"; +import { readString } from "./strings.js"; +import { readDynamic } from "./dynamic.js"; + +/** + * Read a `JSON` value. ClickHouse's `JSON` is NOT JSON text and NOT BSON — it is + * a list of (path, value) pairs built on the same machinery as `Dynamic`: + * + * then pathCount x ( ) + * + * Nested objects are FLATTENED to dotted paths (`{a:{b:2}}` -> path `"a.b"`), and + * each leaf value is a self-describing `Dynamic`, so this just loops + * {@link readString} + {@link readDynamic}. Returns a `Map` keyed by the flat + * dotted path. Path order on the wire is not significant. + * + * GOTCHA: a null-valued path is NOT stored at all — `{"a":null}` serializes as + * zero paths, identical to `{}`. JSON arrays come back as `Array(Nullable(T))`. + * + * LIMITATION — typed paths only. This reads a plain `JSON` column, where every + * path is dynamic (tagged). A `JSON(a T, ...)` with DECLARED typed paths + * serializes those paths' values WITHOUT a type tag, so they cannot be decoded + * without the schema; read each typed path with its known `T` reader instead. + */ +export function readJSON(state: RowBinaryState): Map { + const n = readUVarint(state); + const out = new Map(); + for (let i = 0; i < n; i++) { + const path = readString(state); + out.set(path, readDynamic(state)); + } + return out; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/lowCardinality.ts b/skills/clickhouse-js-node-rowbinary-parser/src/lowCardinality.ts new file mode 100644 index 000000000..c4c150818 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/lowCardinality.ts @@ -0,0 +1,18 @@ +import { type Reader } from "./core.js"; + +/** + * `LowCardinality(T)` is TRANSPARENT in RowBinary: it is encoded byte-for-byte + * the same as `T`, with NO dictionary/index layer. (The dictionary encoding + * exists only in the Native format — do not look for it here.) So there is + * nothing to decode at this level: use `T`'s own reader directly. + * + * This identity combinator exists only to document that, and to let a generated + * parser name the wrapper at the call site if it wants the type to read + * literally — it returns the inner reader unchanged: + * + * readLowCardinality(readString) === readString + * + * Prefer just calling the inner reader. + */ +export const readLowCardinality = (readValue: Reader): Reader => + readValue; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/nested.ts b/skills/clickhouse-js-node-rowbinary-parser/src/nested.ts new file mode 100644 index 000000000..c4e1e69fa --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/nested.ts @@ -0,0 +1,23 @@ +import { readArray, readTupleNamed } from "./composite.js"; +import { type Reader } from "./core.js"; + +/** + * `Nested(a T1, b T2, …)` has NO wire format of its own: + * - `flatten_nested = 1` (the default): the column expands into separate + * columns `a Array(T1)`, `b Array(T2)`, … — decode each with `readArray`. + * - `flatten_nested = 0`: the column is `Array(Tuple(a T1, b T2, …))` — decode + * with `readArray` + `readTupleNamed`. + * + * Either way it reuses existing readers; there is no dedicated Nested wire. This + * thin alias just composes the two for the `flatten_nested = 0` shape, as + * documentation that "Nested === Array(Tuple(...))": + * + * readNested({ a: readUInt8, b: readString }) + * === readArray(readTupleNamed({ a: readUInt8, b: readString })) + * + * When generating code, prefer inlining (monomorphize the array + tuple) over + * this generic composition. + */ +export const readNested = >(fields: { + [K in keyof T]: Reader; +}): Reader => readArray(readTupleNamed(fields)); diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/nothing.ts b/skills/clickhouse-js-node-rowbinary-parser/src/nothing.ts new file mode 100644 index 000000000..75921fbc7 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/nothing.ts @@ -0,0 +1,29 @@ +import { type Reader } from "./core.js"; + +/** + * `Nothing` is the empty type: it has NO values and occupies ZERO bytes. It is + * never a column on its own (you cannot materialize a value of it) — it only + * appears wrapped, as the inferred element of an untyped literal: + * + * [] -> Array(Nothing) -> always the empty array (varint length 0x00) + * NULL -> Nullable(Nothing) -> always NULL (lone flag byte 0x01) + * + * So a `Nothing` value is NEVER read: `readArray`'s element reader and + * `readNullable`'s inner reader are not called in those cases (the array is + * empty / the value is NULL). There is nothing to decode. + * + * Wire this in as the inner reader to make that invariant loud: it throws if it + * is ever actually invoked, which would mean a `Nothing` reader was placed where + * a real element/inner type was expected. + * + * readArray(readNothing) // [] — readNothing never runs + * readNullable(readNothing) // null — readNothing never runs + */ +export const readNothing: Reader = () => { + throw new Error( + "RowBinary: Nothing is zero-width and is never decoded — it only appears as " + + "an empty Array(Nothing) or a NULL Nullable(Nothing), where the inner reader " + + "is not called. Reaching here means a Nothing reader was wired where a real " + + "element/inner type was expected.", + ); +}; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/reader.ts b/skills/clickhouse-js-node-rowbinary-parser/src/reader.ts new file mode 100644 index 000000000..589941add --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/reader.ts @@ -0,0 +1,51 @@ +/** + * Barrel re-export of the RowBinary reader, split by type family into the + * sibling modules. Import from here for everything in one place, or from a + * specific module (e.g. `./integers.js`, `./strings.js`) to pull in only the + * sub-parsers a given result actually needs — the latter is what a generated + * parser should do, copying just the modules its column types require. + * + * - core — RowBinaryState, Reader, advance, NeedMoreData + * - varint — readUVarint + * - integers — readUInt8..readUInt256, readInt8..readInt256 + * - bool / enums / floats + * - decimals — DecimalValue, formatDecimal, readDecimal32..256 + * - strings — readString, readFixedString, readFixedStringBytes + * - uuid — readUUID(+BigInt/HiLo), formatUUID(+Table) + * - ip — readIPv4/6, formatIPv4/6 + * - datetime / time / interval + * - composite — readArray/Map/Tuple/TupleNamed/Nullable/Variant/QBit + * - rows — readRows + * - geo — Point, readPoint/Ring/LineString/Polygon/MultiLineString/MultiPolygon/Geometry + * - dynamic — readDynamic, readDynamicType + * - json — readJSON + * - stream — streamRowBatches, coalesceChunks + * - transparent / special wrappers (mostly documentation; see each file): + * lowCardinality (readLowCardinality), simpleAggregateFunction + * (readSimpleAggregateFunction), nested (readNested), nothing (readNothing), + * aggregateFunction (readAggregateFunction) + */ +export * from "./core.js"; +export * from "./varint.js"; +export * from "./integers.js"; +export * from "./bool.js"; +export * from "./enums.js"; +export * from "./floats.js"; +export * from "./decimals.js"; +export * from "./strings.js"; +export * from "./uuid.js"; +export * from "./ip.js"; +export * from "./datetime.js"; +export * from "./time.js"; +export * from "./interval.js"; +export * from "./composite.js"; +export * from "./rows.js"; +export * from "./geo.js"; +export * from "./dynamic.js"; +export * from "./json.js"; +export * from "./stream.js"; +export * from "./lowCardinality.js"; +export * from "./simpleAggregateFunction.js"; +export * from "./nested.js"; +export * from "./nothing.js"; +export * from "./aggregateFunction.js"; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/rows.ts b/skills/clickhouse-js-node-rowbinary-parser/src/rows.ts new file mode 100644 index 000000000..bed2bbd67 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/rows.ts @@ -0,0 +1,58 @@ +import { NeedMoreData, type Reader, RowBinaryState } from "./core.js"; + +/** + * Drive `readRow` over every row of a plain `RowBinary` result into an array. + * Curried: `readRows(readRow)` returns a `Reader`. Rows are concatenated on + * the wire with no count, length prefix, or delimiter, so the result is exhausted + * only when the cursor reaches the buffer end. + * + * `readRow` must consume EXACTLY one row's bytes — a byte short or long compounds + * across rows and the cursor overshoots or never lands on `buf.length`. Returns + * `[]` for an empty buffer. When generating code, inline the per-column reads + * into the loop body: + * + * function readRowsUser(s) { + * const out = []; + * while (s.pos < s.buf.length) { + * out.push({ id: readUInt64(s), name: readString(s) }); + * } + * return out; + * } + * + * STREAMING (partial trailing row): a chunk of a still-arriving response may end + * mid-row. `pos` is committed only AFTER a row reads cleanly, so when a row + * starves and `readRow` throws {@link NeedMoreData}, this catches it, rewinds + * `pos` to the last complete row boundary, and returns the rows so far — never a + * half-built row. The cursor is left at the straddling row, a commit point the + * driver carries forward: + * + * const drive = readRows(readRow); + * let committed = 0; + * for (const chunk of chunks) { // chunk = growing prefix + * const s = new RowBinaryState(chunk); + * s.pos = committed; + * emit(drive(s)); // complete rows in this chunk + * committed = s.pos; // start of the straddling row + * } + * + * On a complete buffer no read starves, so the catch never runs. Errors other + * than {@link NeedMoreData} are real decode faults and propagate. See also + * `streamRowBatches`, the async driver built on this. + */ +export function readRows(readRow: Reader): Reader { + return (state) => { + const out: T[] = []; + let committed = state.pos; + try { + while (state.pos < state.buf.length) { + const row = readRow(state); + committed = state.pos; // row read cleanly — advance the commit point + out.push(row); + } + } catch (e) { + if (e !== NeedMoreData) throw e; + state.pos = committed; // drop the partial trailing row; resume next chunk + } + return out; + }; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/simpleAggregateFunction.ts b/skills/clickhouse-js-node-rowbinary-parser/src/simpleAggregateFunction.ts new file mode 100644 index 000000000..86d18b756 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/simpleAggregateFunction.ts @@ -0,0 +1,20 @@ +import { type Reader } from "./core.js"; + +/** + * `SimpleAggregateFunction(func, T)` is TRANSPARENT in RowBinary: the column + * already holds a finished value of the underlying type `T` (the partial + * aggregate of a "simple" function — sum / min / max / groupArrayArray / … — is + * just a value of `T`), so it is encoded byte-for-byte the same as `T`. Decode + * the inner `T` directly. + * + * Do NOT confuse it with `AggregateFunction(func, T)`, whose value is an opaque + * serialized aggregation STATE with a function-specific binary layout — see + * `./aggregateFunction.js`. + * + * Identity combinator, documentation only: + * + * readSimpleAggregateFunction(readUInt64) === readUInt64 + */ +export const readSimpleAggregateFunction = ( + readValue: Reader, +): Reader => readValue; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/stream.ts b/skills/clickhouse-js-node-rowbinary-parser/src/stream.ts new file mode 100644 index 000000000..251d82029 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/stream.ts @@ -0,0 +1,276 @@ +import { type Reader, RowBinaryState } from "./core.js"; +import { readRows } from "./rows.js"; + +/** Empty buffer reused as the "no carry" sentinel between chunks. */ +const EMPTY_CHUNK = Buffer.alloc(0); + +/** Stats captured at the moment the small-chunk warning fires. */ +export interface SmallChunkStats { + /** Chunks consumed so far. */ + chunks: number; + /** Rows decoded so far. */ + rows: number; + /** `rows / chunks` — the ratio that tripped the threshold. */ + rowsPerChunk: number; +} + +/** + * Tuning for {@link streamRowBatches}'s small-chunk warning. Pass `false` to + * disable it, `true` / omit for the defaults, or an object to tune. + */ +export type WarnOnSmallChunks = + | boolean + | { + /** + * Warn when the running `rows / chunks` average drops below this. Default + * `2`: throw + restart re-decodes the partial trailing row on EVERY chunk, + * so once a chunk barely covers a row or two the re-scan dominates — the + * regime where `streamingRow.bench.ts` shows throw+restart losing to a lean + * generator. Keep it low so the warning only fires when chunks are + * genuinely too small, never on a healthy hundreds-of-rows-per-chunk stream. + */ + minRowsPerChunk?: number; + /** + * Don't evaluate until this many chunks have been seen. Default `16`: + * lets the average settle and suppresses the warning on small results, + * where the gotcha doesn't bite (it only matters at megabytes / millions + * of rows). A stream that ends before this never warns. + */ + warmupChunks?: number; + /** Where the warning goes. Default `console.warn`. */ + warn?: (message: string, stats: SmallChunkStats) => void; + }; + +/** Options for {@link streamRowBatches}. */ +export interface StreamRowBatchesOptions { + /** + * Diagnostic that catches a silent throughput killer: chunks so small that the + * throw+restart streaming strategy spends most of its time re-decoding the + * partial trailing row instead of making progress. Fires AT MOST ONCE per + * stream. On by default; see {@link WarnOnSmallChunks} to tune or disable. + * + * The fix it points at is usually upstream — raise the HTTP response's read + * size (Node sets the socket/stream `highWaterMark`; a fetch `Response.body` + * reader delivers larger chunks than a hand-rolled tiny read) into the + * tens–hundreds of KB range — or, when chunk size isn't yours to control, + * compose {@link coalesceChunks} in front to merge small chunks first. + */ + warnOnSmallChunks?: WarnOnSmallChunks; +} + +/** + * Stream a chunked `RowBinary` response into batches of decoded rows. This is + * the async front door built on {@link readRows}: feed it the byte chunks of an + * HTTP response (anything async-iterable — a Node `Readable`, `response.body`, + * etc.) and a per-row `Reader`, and `for await` the batches. + * + * One batch is yielded per incoming chunk — exactly the rows that completed + * within it — so batch size tracks chunk size, which the caller controls. A + * chunk that doesn't complete a new row yields nothing; its bytes are carried + * into the next chunk. Empty batches are never yielded. + * + * How it works (the carry-buffer driver): + * - Join the leftover `carry` from the previous chunk to the new chunk, build a + * state over the join, and run `readRows`. It decodes whole rows, stops cleanly + * on the partial trailing row (catching `NeedMoreData`), and leaves `pos` at + * that row's start. + * - The unread tail `pos..end` becomes the next `carry` as a `subarray` VIEW, + * NOT a copy. The joined buffer is owned entirely by this generator — it is + * never yielded to the caller — so there is no aliasing hazard in keeping a + * view into it, and we skip a per-chunk copy of the tail. The view is also + * short-lived: the next chunk's `Buffer.concat` copies these bytes into a + * fresh buffer, after which the old one is released. + * - When the stream ends, any non-empty carry means the response was truncated + * mid-row — a malformed stream — so it throws rather than silently dropping + * bytes. + * + * `readRow` is a `Reader` — write it as `(s) => ({ id: readUInt64(s), + * name: readString(s) })`. Build any configured/combinator readers ONCE (e.g. + * `const readRow = readTupleNamed({...})`) and reuse, rather than rebuilding them + * per chunk. + * + * ZERO-COPY NOTE: raw-bytes readers (`readUUID`/`readIPv6`/`readFixedStringBytes` + * and binary `String`) return views into the current chunk's joined buffer. Those + * stay valid as long as you hold the row objects, but are NOT views into one + * stable buffer across batches. If you retain them long-term, copy in `readRow`. + * + * BACKPRESSURE: this is a pull stream — the next chunk is only requested when the + * consumer asks for the next batch, so a slow consumer naturally throttles reading. + * + * The per-chunk bookkeeping for the small-chunk warning (two integer adds and a + * compare) runs once per CHUNK, not per row, so it is off every hot path; the + * default-on warning is documented in {@link StreamRowBatchesOptions}. + */ +export async function* streamRowBatches( + chunks: AsyncIterable, + readRow: Reader, + options?: StreamRowBatchesOptions, +): AsyncGenerator { + const drive = readRows(readRow); + let carry: Buffer = EMPTY_CHUNK; + + // Resolve the warning config once, outside the loop. + const warnCfg = options?.warnOnSmallChunks; + const warnEnabled = warnCfg !== false; + const warnObj = typeof warnCfg === "object" ? warnCfg : undefined; + const minRowsPerChunk = warnObj?.minRowsPerChunk ?? 2; + const warmupChunks = warnObj?.warmupChunks ?? 16; + const warn = warnObj?.warn ?? ((message: string) => console.warn(message)); + let chunkCount = 0; + let rowCount = 0; + let warned = false; + + for await (const chunk of chunks) { + // Normalize to a Buffer without copying (a Uint8Array shares its ArrayBuffer). + const incoming = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + const work = + carry.length === 0 ? incoming : Buffer.concat([carry, incoming]); + + const state = new RowBinaryState(work); + const rows = drive(state); + if (rows.length > 0) yield rows; + + // Carry the unread tail (the partial trailing row, if any) to the next + // chunk. A view, not a copy: we own `work` and never expose it, so keeping a + // subarray into it is safe; the next concat copies these bytes out. + carry = state.pos < work.length ? work.subarray(state.pos) : EMPTY_CHUNK; + + if (warnEnabled && !warned) { + chunkCount++; + rowCount += rows.length; + const rowsPerChunk = rowCount / chunkCount; + if (chunkCount >= warmupChunks && rowsPerChunk < minRowsPerChunk) { + warned = true; + warn( + `RowBinary stream: chunks look too small — ${rowsPerChunk.toFixed(2)} rows/chunk over ${chunkCount} chunks. ` + + `Streaming throws + restarts the partial trailing row on every chunk, so tiny chunks spend most of their ` + + `time re-decoding instead of advancing. Increase the upstream read/highWaterMark to tens–hundreds of KB, ` + + `or compose coalesceChunks() in front of this stream to merge small chunks first.`, + { chunks: chunkCount, rows: rowCount, rowsPerChunk }, + ); + } + } + } + if (carry.length > 0) { + throw new Error( + `RowBinary stream ended mid-row: ${carry.length} trailing byte(s) left undecoded`, + ); + } +} + +/** A timeout result distinct from any `IteratorResult`. */ +const TIMED_OUT = Symbol("coalesceChunks.timeout"); + +/** + * Coalesce (debounce) a chunk stream so each emitted chunk is at least `minSize` + * bytes — a filter you compose IN FRONT of {@link streamRowBatches} when the + * source delivers chunks too small to stream efficiently and you can't enlarge + * them upstream: + * + * streamRowBatches(coalesceChunks(httpChunks, { minSize: 64 * 1024, timeoutMs: 50 }), readRow) + * + * WHY: the throw+restart streaming strategy re-decodes the partial trailing row + * on every chunk boundary, so the smaller the chunks the more time is wasted + * re-scanning (see `streamingRow.bench.ts`). Merging small chunks up front cuts + * the number of boundaries — and the backtracking with it. + * + * THE TRADE-OFF (latency vs. reallocation vs. backtracking): merging holds bytes + * back until enough accumulate, so it ADDS up to `timeoutMs` of latency to data + * that arrives in a trickle, and it COPIES via `Buffer.concat` to join the parts + * (one extra allocation per emitted chunk). In return the downstream parser + * backtracks far less. Tune `minSize` to the downstream sweet spot (tens–hundreds + * of KB) and `timeoutMs` to the latency you can spare. + * + * SEMANTICS: + * - Accumulates incoming chunks until their total reaches `minSize`, then emits + * the join immediately. + * - A batch below `minSize` is flushed early when `timeoutMs` elapses from the + * moment its FIRST byte arrived (the deadline is anchored, not reset per + * chunk — a steady trickle of tiny chunks can't defer the flush forever). + * - While nothing is buffered it blocks indefinitely for the next chunk: an idle + * or finished stream is never charged the timeout. + * - End of stream flushes whatever remains (possibly below `minSize`); a single + * already-large-enough chunk passes straight through with no copy. + * + * It keeps exactly ONE outstanding pull on the source at a time (never calls + * `next()` while a prior result is still in flight), reads one chunk ahead so it + * can race arrival against the timer, and releases the source via `return()` if + * the consumer abandons it early. + */ +export async function* coalesceChunks( + source: AsyncIterable, + { minSize, timeoutMs }: { minSize: number; timeoutMs: number }, +): AsyncGenerator { + const it = source[Symbol.asyncIterator](); + // The single in-flight pull. Read one ahead so we always have a promise to + // race the timer against; never start a second next() before this resolves. + let pull = it.next(); + let parts: Buffer[] = []; + let buffered = 0; + let deadline = 0; // ms timestamp; armed when the first byte enters an empty batch + + const asBuffer = (u8: Uint8Array): Buffer => + Buffer.isBuffer(u8) + ? u8 + : Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength); + + const flush = (): Buffer => { + // One part: hand it back as-is (no concat, no copy). Many: join them. + const out = parts.length === 1 ? parts[0]! : Buffer.concat(parts, buffered); + parts = []; + buffered = 0; + return out; + }; + + const take = (u8: Uint8Array): void => { + const b = asBuffer(u8); + parts.push(b); + buffered += b.length; + }; + + try { + while (true) { + if (buffered === 0) { + // Nothing buffered: block for the next chunk with no timeout. + const r = await pull; + if (r.done) return; + take(r.value); + deadline = Date.now() + timeoutMs; + pull = it.next(); + if (buffered >= minSize) yield flush(); + continue; + } + + // Below minSize with bytes in hand: race the next chunk against the time + // left on this batch's anchored deadline. + const remaining = deadline - Date.now(); + if (remaining <= 0) { + yield flush(); + continue; + } + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMED_OUT), remaining); + }); + const r = await Promise.race([pull, timeout]); + clearTimeout(timer); // no-op if it already fired; frees the loop otherwise + if (r === TIMED_OUT) { + // pull is STILL outstanding — keep it; just flush what we have so far. + yield flush(); + continue; + } + if (r.done) { + yield flush(); // emit the tail; stream is over + return; + } + take(r.value); + pull = it.next(); + if (buffered >= minSize) yield flush(); + } + } finally { + // Consumer broke out early (break/throw): let the source clean up. + if (typeof it.return === "function") await it.return(); + } +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/strings.ts b/skills/clickhouse-js-node-rowbinary-parser/src/strings.ts new file mode 100644 index 000000000..992e41743 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/strings.ts @@ -0,0 +1,55 @@ +import { type Reader, RowBinaryState, advance } from "./core.js"; +import { readUVarint } from "./varint.js"; + +/** + * Read a `String`: a varint byte-length prefix followed by that many bytes, + * decoded as UTF-8. + * + * NOTE: ClickHouse `String` is arbitrary bytes, not guaranteed UTF-8. For binary + * columns, read `state.buf.subarray(start, start + len)` and skip the decode to + * keep the raw bytes. + */ +export function readString(state: RowBinaryState): string { + const len = readUVarint(state); + const start = advance(state, len); + return state.buf.toString("utf8", start, start + len); +} + +/** + * Read a `FixedString(N)`: exactly `size` raw bytes, decoded as UTF-8. Curried: + * `readFixedString(N)` returns the reader. + * + * The value is right-padded with NUL bytes to `size`; those trailing `\x00` are + * part of the stored value and are preserved here. Trim them + * (`.replace(/\x00+$/, "")`) only if your column holds NUL-terminated text. + * + * ClickHouse server returns `FixedString`s in JSON with the trailing NULs, + * therefore this reader preserves them as well. + */ +export function readFixedString(size: number): Reader { + return (state) => { + const start = advance(state, size); + return state.buf.toString("utf8", start, start + size); + }; +} + +/** + * Read a `FixedString(N)` as raw bytes (no UTF-8 decode) — for binary columns. + * Curried: `readFixedStringBytes(N)` returns the reader. Returns a zero-copy + * view: no allocation, but the slice shares memory with the response, so + * retaining any one slice pins the entire chunk buffer in memory. + * + * SAFE TO TOGGLE — if the bytes outlive the row/response, return an independent + * copy instead so the chunk can be freed: + * + * // return Buffer.from(state.buf.subarray(start, start + size)); + * + * Make an educated tradeoff: view (default) when consumed immediately, a copy + * when retained. + */ +export function readFixedStringBytes(size: number): Reader { + return (state) => { + const start = advance(state, size); + return state.buf.subarray(start, start + size); + }; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/time.ts b/skills/clickhouse-js-node-rowbinary-parser/src/time.ts new file mode 100644 index 000000000..4400e54bf --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/time.ts @@ -0,0 +1,61 @@ +import { type Reader, RowBinaryState } from "./core.js"; +import { readInt32, readInt64 } from "./integers.js"; + +/** Semantic alias for `number` marking a seconds value (see {@link readTime}). */ +export type Seconds = number; + +/** + * A signed sub-second duration kept lossless as its raw parts: the value is + * `ticks / 10 ** precision` seconds. Used by `Time64` (a time-of-day duration, + * which has no natural JS type), carrying the precision so nothing is lost. + */ +export type ScaledTicks = readonly [ticks: bigint, precision: number]; + +/** + * Read a `Time`: 4-byte signed `Int32` seconds-of-day (range ±999:59:59). + * Returns the raw seconds; pass it to {@link formatTime}. + */ +export function readTime(state: RowBinaryState): Seconds { + return readInt32(state); +} + +/** + * Read a `Time64(P)`: 8-byte signed `Int64` count of `10^-P`-second ticks. + * Curried: `readTime64(P)` returns the reader. Returns `[ticks, precision]` (a + * {@link ScaledTicks}); pass it to {@link formatTime64}. + */ +export function readTime64(precision: number): Reader { + return (state) => [readInt64(state), precision]; +} + +/** + * Format a `Time` value (signed seconds-of-day) as "[-]HH:MM:SS". The hour + * field can exceed two digits (the range is ±999:59:59). + */ +export function formatTime(seconds: Seconds): string { + const sign = seconds < 0 ? "-" : ""; + const s = Math.abs(seconds); + const hh = Math.floor(s / 3600); + const mm = Math.floor((s % 3600) / 60); + const ss = s % 60; + return `${sign}${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; +} + +/** + * Format a `Time64` [ticks, precision] (signed sub-second time-of-day) as + * "[-]HH:MM:SS[.fff]". + */ +export function formatTime64([ticks, precision]: ScaledTicks): string { + const sign = ticks < 0n ? "-" : ""; + const t = ticks < 0n ? -ticks : ticks; + const scale = 10n ** BigInt(precision); + const totalSec = Number(t / scale); + const frac = t % scale; + const hh = Math.floor(totalSec / 3600); + const mm = Math.floor((totalSec % 3600) / 60); + const ss = totalSec % 60; + const base = `${sign}${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`; + return precision > 0 + ? `${base}.${frac.toString().padStart(precision, "0")}` + : base; +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts b/skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts new file mode 100644 index 000000000..d9f07d612 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts @@ -0,0 +1,153 @@ +import { RowBinaryState, advance } from "./core.js"; + +/** + * `UUID_HEX16[b]` packs the two lowercase ASCII hex chars of byte `b`, low char + * in the low byte. Drives the lookup-table UUID formatter {@link formatUUIDTable}. + */ +const UUID_HEX16 = new Uint16Array(256); +for (let b = 0; b < 256; b++) { + const hex = b.toString(16).padStart(2, "0"); + UUID_HEX16[b] = hex.charCodeAt(0) | (hex.charCodeAt(1) << 8); +} + +/** + * Reusable 36-byte scratch for {@link formatUUIDTable}. The four `-` separators + * are written once and never touched again; each call overwrites only the 32 + * hex slots, then copies the bytes out as a string. + */ +const UUID_OUT = Buffer.alloc(36); +UUID_OUT[8] = UUID_OUT[13] = UUID_OUT[18] = UUID_OUT[23] = 0x2d; // '-' + +/** + * Read a `UUID`: 16 raw bytes (two little-endian `UInt64` halves on the wire). + * Returns a zero-copy view; pass it to {@link formatUUID} for the canonical + * `xxxxxxxx-...` string. + * + * The view shares memory with the response buffer, so keeping it alive pins the + * whole chunk; copy with `Buffer.from(...)` if it must outlive the row. + * + * FAST ALTERNATIVE: if you stringify every UUID, use {@link formatUUIDTable} + * (lookup table, no BigInt, ~1.6x faster). + */ +export function readUUID(state: RowBinaryState): Buffer { + const start = advance(state, 16); + return state.buf.subarray(start, start + 16); +} + +/** + * Read a `UUID` as a single 128-bit `bigint` (`hi << 64 | lo`) — useful for + * numeric storage, comparison, or de-duplication without a string. + * + * Reads the halves with `DataView.getBigUint64` rather than + * `Buffer.readBigUInt64LE`: V8 inlines the DataView accessors, measurably faster + * for 8-byte reads. For the canonical string, use {@link readUUID} + {@link formatUUID}. + */ +export function readUUIDBigInt(state: RowBinaryState): bigint { + const start = advance(state, 16); + const hi = state.view.getBigUint64(start, true); + const lo = state.view.getBigUint64(start + 8, true); + return (hi << 64n) | lo; +} + +/** + * Read a `UUID` as its two raw little-endian `UInt64` halves, `[hi, lo]` — the + * faithful wire split with no combining work. Cheaper than {@link readUUIDBigInt} + * (skips `hi << 64 | lo`) and a compact two-value key for comparison/dedup. For + * the canonical string, use {@link readUUID} + {@link formatUUID}. + */ +export function readUUIDHiLo(state: RowBinaryState): [hi: bigint, lo: bigint] { + const start = advance(state, 16); + const hi = state.view.getBigUint64(start, true); + const lo = state.view.getBigUint64(start + 8, true); + return [hi, lo]; +} + +/** + * Format a `UUID` (raw 16 bytes from {@link readUUID}) as the canonical + * `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` string. + * + * THE TRAP: ClickHouse stores a UUID as two little-endian `UInt64` halves (high + * then low), so each half is byte-reversed vs the text form. Reading each half + * with `readBigUInt64LE` undoes that; concatenating high then low gives the 32 + * canonical hex digits. (Hexing the 16 bytes in wire order scrambles the value.) + * Kept aside from the read so the hot path can skip stringifying when raw bytes + * suffice. + * + * FAST ALTERNATIVE: to format every value, {@link formatUUIDTable} does the same + * via a byte->hex lookup table with no BigInt (~1.6x faster). + */ +export function formatUUID(b: Buffer): string { + const hex = ((b.readBigUInt64LE(0) << 64n) | b.readBigUInt64LE(8)) + .toString(16) + .padStart(32, "0"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** + * Fast {@link formatUUID}: same canonical string via a byte -> two-hex-char + * lookup table (`UUID_HEX16`) written into a reused 36-byte buffer (`UUID_OUT`, + * dashes preset), no BigInt, no slicing. ~1.6x faster (see `readUUID.bench.ts`). + * Takes the raw 16 bytes from {@link readUUID}. + * + * Same byte-reversal as formatUUID: emit the high half in reverse (`b[7]..b[0]`) + * then the low half (`b[15]..b[8]`). + * + * SAFE TO TOGGLE — opt-in fast formatter, not the default. `UUID_OUT` is shared + * scratch, so NOT reentrant; safe for synchronous formatting because the bytes + * are copied into the returned string before the next call (don't alias + * `UUID_OUT`). Worth it only when you stringify every UUID. + */ +export function formatUUIDTable(b: Buffer): string { + let p: number; + // High half: bytes b[7]..b[0] -> hex positions 0..7 (chars 0..15). + p = UUID_HEX16[b[7]!]!; + UUID_OUT[0] = p & 0xff; + UUID_OUT[1] = p >>> 8; + p = UUID_HEX16[b[6]!]!; + UUID_OUT[2] = p & 0xff; + UUID_OUT[3] = p >>> 8; + p = UUID_HEX16[b[5]!]!; + UUID_OUT[4] = p & 0xff; + UUID_OUT[5] = p >>> 8; + p = UUID_HEX16[b[4]!]!; + UUID_OUT[6] = p & 0xff; + UUID_OUT[7] = p >>> 8; + p = UUID_HEX16[b[3]!]!; + UUID_OUT[9] = p & 0xff; + UUID_OUT[10] = p >>> 8; + p = UUID_HEX16[b[2]!]!; + UUID_OUT[11] = p & 0xff; + UUID_OUT[12] = p >>> 8; + p = UUID_HEX16[b[1]!]!; + UUID_OUT[14] = p & 0xff; + UUID_OUT[15] = p >>> 8; + p = UUID_HEX16[b[0]!]!; + UUID_OUT[16] = p & 0xff; + UUID_OUT[17] = p >>> 8; + // Low half: bytes b[15]..b[8] -> hex positions 8..15 (chars 19..35). + p = UUID_HEX16[b[15]!]!; + UUID_OUT[19] = p & 0xff; + UUID_OUT[20] = p >>> 8; + p = UUID_HEX16[b[14]!]!; + UUID_OUT[21] = p & 0xff; + UUID_OUT[22] = p >>> 8; + p = UUID_HEX16[b[13]!]!; + UUID_OUT[24] = p & 0xff; + UUID_OUT[25] = p >>> 8; + p = UUID_HEX16[b[12]!]!; + UUID_OUT[26] = p & 0xff; + UUID_OUT[27] = p >>> 8; + p = UUID_HEX16[b[11]!]!; + UUID_OUT[28] = p & 0xff; + UUID_OUT[29] = p >>> 8; + p = UUID_HEX16[b[10]!]!; + UUID_OUT[30] = p & 0xff; + UUID_OUT[31] = p >>> 8; + p = UUID_HEX16[b[9]!]!; + UUID_OUT[32] = p & 0xff; + UUID_OUT[33] = p >>> 8; + p = UUID_HEX16[b[8]!]!; + UUID_OUT[34] = p & 0xff; + UUID_OUT[35] = p >>> 8; + return UUID_OUT.toString("latin1"); +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/varint.ts b/skills/clickhouse-js-node-rowbinary-parser/src/varint.ts new file mode 100644 index 000000000..3f4275ebd --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/src/varint.ts @@ -0,0 +1,70 @@ +import { RowBinaryState, advance } from "./core.js"; + +/** + * Read a LEB128 unsigned varint (used for string/array lengths). + * + * Returns a JS `number`, so it is NOT bigint-friendly: only values up to + * `Number.MAX_SAFE_INTEGER` (2^53 - 1) are representable exactly. A varint + * larger than that throws rather than silently losing precision. RowBinary + * lengths never approach this in practice. + * + * The loop is unrolled: each byte carries 7 bits, so its place value is the + * constant 2^(7*k). The overwhelmingly common 1–2 byte case costs one or two + * reads and a compare. + * + * Multipliers must stay as `*` (not `<<`): JS bitwise shift is 32-bit and would wrap past bit 31. + * + * SAFE TO TOGGLE — how many bytes to handle: + * - If you know the maximum blob/array size, keep only the steps you need and + * delete the rest along with the overflow guard. E.g. lengths < 2^28 fit in + * 4 bytes, so everything below the `* 268435456` step can go. + * - Keep all eight steps (the default) when lengths are untrusted. + * If you genuinely need lengths beyond 2^53, create a bigint version of this + * function with a bigint accumulator instead of removing the guard. + * + * OPTIMIZATION HINT — for a known invariant, emit a dedicated named variant + * rather than toggling here. E.g. a `readUVarint32` for lengths guaranteed to be + * 32-bit would unroll only the first five bytes and throw past 2^32 - 1. + */ +export function readUVarint(state: RowBinaryState): number { + // Each byte reserves its space through `advance(1)` (the bounds check), but + // the read itself stays inlined as `state.buf[...]` rather than calling + // readUInt8 — this is the hottest loop in the reader. + let byte = state.buf[advance(state, 1)]!; + if (byte < 0x80) return byte; // 1 byte -> 2^0 + let result = byte & 0x7f; + + byte = state.buf[advance(state, 1)]!; + if (byte < 0x80) return result + byte * 128; // 2^7 + result += (byte & 0x7f) * 128; + + byte = state.buf[advance(state, 1)]!; + if (byte < 0x80) return result + byte * 16384; // 2^14 + result += (byte & 0x7f) * 16384; + + byte = state.buf[advance(state, 1)]!; + if (byte < 0x80) return result + byte * 2097152; // 2^21 + result += (byte & 0x7f) * 2097152; + + byte = state.buf[advance(state, 1)]!; + if (byte < 0x80) return result + byte * 268435456; // 2^28 + result += (byte & 0x7f) * 268435456; + + byte = state.buf[advance(state, 1)]!; + if (byte < 0x80) return result + byte * 34359738368; // 2^35 + result += (byte & 0x7f) * 34359738368; + + byte = state.buf[advance(state, 1)]!; + if (byte < 0x80) return result + byte * 4398046511104; // 2^42 + result += (byte & 0x7f) * 4398046511104; + + // 8th byte: only its low 4 payload bits (bits 49..52) fit under 2^53. A larger + // payload, or a continuation bit signalling a 9th byte, overflows MAX_SAFE_INTEGER. + byte = state.buf[advance(state, 1)]!; + if (byte > 0x0f) { + throw new RangeError( + "RowBinary: varint exceeds Number.MAX_SAFE_INTEGER (2^53 - 1)", + ); + } + return result + byte * 562949953421312; // 2^49 +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Array.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Array.test.ts new file mode 100644 index 000000000..6755009dd --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Array.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readArray, readNullable } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt32, readUInt8 } from "../src/integers.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readArray", () => { + it("decodes a fixed-width element array", async () => { + const r = await reader("CAST([1, 2, 3] AS Array(UInt32))"); + expect(readArray(readUInt32)(r)).toEqual([1, 2, 3]); + expect(r.pos).toBe(13); // 1 count + 3 * 4 + }); + + it("decodes the empty array (just the count byte)", async () => { + const r = await reader("CAST([] AS Array(UInt32))"); + expect(readArray(readUInt32)(r)).toEqual([]); + expect(r.pos).toBe(1); + }); + + it("decodes a variable-length element array", async () => { + const r = await reader("CAST(['a', 'bb'] AS Array(String))"); + expect(readArray(readString)(r)).toEqual(["a", "bb"]); + }); + + // Nesting composes by nesting the element reader. + it("decodes Array(Array(UInt8))", async () => { + const r = await reader("CAST([[1], [2, 3]] AS Array(Array(UInt8)))"); + expect(readArray(readArray(readUInt8))(r)).toEqual([[1], [2, 3]]); + }); + + // Composes with Nullable: the NULL element is just its flag byte. + it("decodes Array(Nullable(UInt8)) with a NULL element", async () => { + const r = await reader("CAST([1, NULL, 3] AS Array(Nullable(UInt8)))"); + expect(readArray(readNullable(readUInt8))(r)).toEqual([1, null, 3]); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST([1, 2, 3] AS Array(UInt32)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readArray(readUInt32)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/BFloat16.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/BFloat16.test.ts new file mode 100644 index 000000000..f1ca0026f --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/BFloat16.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readBFloat16 } from "../src/floats.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readBFloat16", () => { + it("decodes 0", async () => { + const r = await reader("toBFloat16(0)"); + expect(readBFloat16(r)).toBe(0); + expect(r.pos).toBe(2); + }); + + // Values whose float32 mantissa fits in BFloat16's 7 bits, so they survive + // the round-trip exactly. + it("decodes 1.5", async () => { + expect(readBFloat16(await reader("toBFloat16(1.5)"))).toBe(1.5); + }); + + it("decodes -2.5", async () => { + expect(readBFloat16(await reader("toBFloat16(-2.5)"))).toBe(-2.5); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toBFloat16(1.5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readBFloat16(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Bool.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Bool.test.ts new file mode 100644 index 000000000..f065fc254 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Bool.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readBool } from "../src/bool.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readBool", () => { + it("decodes true", async () => { + const r = await reader("true"); + expect(readBool(r)).toBe(true); + expect(r.pos).toBe(1); + }); + + it("decodes false", async () => { + expect(readBool(await reader("false"))).toBe(false); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT true FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readBool(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Date.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Date.test.ts new file mode 100644 index 000000000..088e9f15b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Date.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readDate } from "../src/datetime.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDate", () => { + it("decodes to a JS Date at UTC midnight", async () => { + const r = await reader("toDate('2021-03-15')"); + const d = readDate(r); + expect(d.toISOString()).toBe("2021-03-15T00:00:00.000Z"); + expect(r.pos).toBe(2); + }); + + it("decodes the epoch", async () => { + const d = readDate(await reader("toDate('1970-01-01')")); + expect(d.toISOString()).toBe("1970-01-01T00:00:00.000Z"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toDate('2021-03-15') FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDate(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Date32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Date32.test.ts new file mode 100644 index 000000000..9c561cc1c --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Date32.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readDate32 } from "../src/datetime.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDate32", () => { + it("decodes a pre-1970 date (negative days) to a JS Date", async () => { + const r = await reader("toDate32('1950-01-01')"); + const d = readDate32(r); + expect(d.toISOString()).toBe("1950-01-01T00:00:00.000Z"); + expect(r.pos).toBe(4); + }); + + it("decodes a post-1970 date", async () => { + const d = readDate32(await reader("toDate32('2021-03-15')")); + expect(d.toISOString()).toBe("2021-03-15T00:00:00.000Z"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toDate32('1950-01-01') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDate32(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime.test.ts new file mode 100644 index 000000000..bf1ebab63 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readDateTime } from "../src/datetime.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDateTime", () => { + it("decodes Unix seconds to a JS Date", async () => { + const r = await reader("toDateTime('2021-01-01 00:00:00', 'UTC')"); + const d = readDateTime(r); + expect(d.toISOString()).toBe("2021-01-01T00:00:00.000Z"); + expect(d.getTime()).toBe(1609459200000); + expect(r.pos).toBe(4); + }); + + it("decodes the epoch", async () => { + const d = readDateTime( + await reader("toDateTime('1970-01-01 00:00:00', 'UTC')"), + ); + expect(d.getTime()).toBe(0); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toDateTime('2021-01-01 00:00:00', 'UTC') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDateTime(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64.test.ts new file mode 100644 index 000000000..b7a899152 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readDateTime64 } from "../src/datetime.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDateTime64", () => { + it("decodes P=3 to [Date (whole seconds), nanoseconds]", async () => { + const r = await reader("toDateTime64('2021-01-01 00:00:00.123', 3, 'UTC')"); + const [date, nanos] = readDateTime64(3)(r); + expect(date.toISOString()).toBe("2021-01-01T00:00:00.000Z"); + expect(nanos).toBe(123_000_000); + expect(r.pos).toBe(8); + }); + + it("keeps nanosecond precision (P=9) that a Date alone can't hold", async () => { + const [date, nanos] = readDateTime64(9)( + await reader("toDateTime64('2021-01-01 00:00:00.123456789', 9, 'UTC')"), + ); + expect(date.toISOString()).toBe("2021-01-01T00:00:00.000Z"); + expect(nanos).toBe(123456789); + }); + + it("decodes P=0 with a zero fraction", async () => { + const [date, nanos] = readDateTime64(0)( + await reader("toDateTime64('2021-01-01 00:00:00', 0, 'UTC')"), + ); + expect(date.toISOString()).toBe("2021-01-01T00:00:00.000Z"); + expect(nanos).toBe(0); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toDateTime64('2021-01-01 00:00:00.123', 3, 'UTC') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDateTime64(3)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P3.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P3.test.ts new file mode 100644 index 000000000..76483b9b6 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P3.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readDateTime64P3 } from "../src/datetime.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDateTime64P3", () => { + // P=3 is exactly Date's resolution: the millisecond instant is lossless in + // a single Date, no separate fraction. + it("decodes milliseconds straight into a JS Date", async () => { + const r = await reader("toDateTime64('2021-01-01 00:00:00.123', 3, 'UTC')"); + const d = readDateTime64P3(r); + expect(d.toISOString()).toBe("2021-01-01T00:00:00.123Z"); + expect(r.pos).toBe(8); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toDateTime64('2021-01-01 00:00:00.123', 3, 'UTC') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDateTime64P3(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P6.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P6.test.ts new file mode 100644 index 000000000..fcc650737 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P6.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readDateTime64P6 } from "../src/datetime.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDateTime64P6", () => { + it("decodes microseconds to [Date (whole seconds), microseconds]", async () => { + const r = await reader( + "toDateTime64('2021-01-01 00:00:00.123456', 6, 'UTC')", + ); + const [date, micros] = readDateTime64P6(r); + expect(date.toISOString()).toBe("2021-01-01T00:00:00.000Z"); + expect(micros).toBe(123456); // native microseconds + expect(r.pos).toBe(8); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toDateTime64('2021-01-01 00:00:00.123456', 6, 'UTC') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDateTime64P6(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P9.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P9.test.ts new file mode 100644 index 000000000..a69dc200e --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P9.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readDateTime64P9 } from "../src/datetime.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDateTime64P9", () => { + it("decodes nanoseconds to [Date (whole seconds), nanoseconds]", async () => { + const r = await reader( + "toDateTime64('2021-01-01 00:00:00.123456789', 9, 'UTC')", + ); + const [date, nanos] = readDateTime64P9(r); + expect(date.toISOString()).toBe("2021-01-01T00:00:00.000Z"); + expect(nanos).toBe(123456789); + expect(r.pos).toBe(8); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toDateTime64('2021-01-01 00:00:00.123456789', 9, 'UTC') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDateTime64P9(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal128.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal128.test.ts new file mode 100644 index 000000000..add059c3b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal128.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatDecimal, readDecimal128 } from "../src/decimals.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDecimal128", () => { + it("decodes -123.456789 at scale 6", async () => { + const r = await reader("toDecimal128('-123.456789', 6)"); + const dec = readDecimal128(6)(r); + expect(dec).toEqual([-123456789n, 6]); + expect(r.pos).toBe(16); + expect(formatDecimal(dec)).toBe("-123.456789"); + }); + + // Unscaled = 2^63, beyond Int64 range — exercises the 128-bit composition. + it("decodes a value whose unscaled int exceeds 64 bits", async () => { + const r = await reader("toDecimal128('92233720368547758.08', 2)"); + const dec = readDecimal128(2)(r); + expect(dec).toEqual([9223372036854775808n, 2]); + expect(formatDecimal(dec)).toBe("92233720368547758.08"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toDecimal128('-123.456789', 6) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDecimal128(6)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal256.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal256.test.ts new file mode 100644 index 000000000..1494a68a2 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal256.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatDecimal, readDecimal256 } from "../src/decimals.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDecimal256", () => { + it("decodes -1 at scale 0", async () => { + const r = await reader("toDecimal256('-1', 0)"); + const dec = readDecimal256(0)(r); + expect(dec).toEqual([-1n, 0]); + expect(r.pos).toBe(32); + expect(formatDecimal(dec)).toBe("-1"); + }); + + // A large unscaled magnitude that only fits in 256 bits. + it("decodes a large value at scale 10", async () => { + const r = await reader( + "toDecimal256('123456789012345678901234567890.0123456789', 10)", + ); + const dec = readDecimal256(10)(r); + expect(dec).toEqual([1234567890123456789012345678900123456789n, 10]); + expect(formatDecimal(dec)).toBe( + "123456789012345678901234567890.0123456789", + ); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toDecimal256('-1', 0) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDecimal256(0)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal32.test.ts new file mode 100644 index 000000000..d05702c2b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal32.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatDecimal, readDecimal32 } from "../src/decimals.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDecimal32", () => { + it("decodes 1.5 at scale 4 as [unscaled, scale]", async () => { + const r = await reader("toDecimal32(1.5, 4)"); + const dec = readDecimal32(4)(r); + expect(dec).toEqual([15000n, 4]); + expect(r.pos).toBe(4); + // formatDecimal keeps the trailing zeros (CH text would show "1.5"). + expect(formatDecimal(dec)).toBe("1.5000"); + }); + + it("keeps a pure fraction lossless", async () => { + const dec = readDecimal32(3)(await reader("toDecimal32(0.005, 3)")); + expect(dec).toEqual([5n, 3]); + expect(formatDecimal(dec)).toBe("0.005"); + }); + + it("decodes a negative value", async () => { + const dec = readDecimal32(4)(await reader("toDecimal32(-1.5, 4)")); + expect(dec).toEqual([-15000n, 4]); + expect(formatDecimal(dec)).toBe("-1.5000"); + }); + + it("decodes scale 0 (formats with no decimal point)", async () => { + const dec = readDecimal32(0)(await reader("toDecimal32(42, 0)")); + expect(dec).toEqual([42n, 0]); + expect(formatDecimal(dec)).toBe("42"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toDecimal32(1.5, 4) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDecimal32(4)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal64.test.ts new file mode 100644 index 000000000..baab7f166 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal64.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatDecimal, readDecimal64 } from "../src/decimals.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readDecimal64", () => { + it("decodes -12.34 at scale 2", async () => { + const r = await reader("toDecimal64(-12.34, 2)"); + const dec = readDecimal64(2)(r); + expect(dec).toEqual([-1234n, 2]); + expect(r.pos).toBe(8); + expect(formatDecimal(dec)).toBe("-12.34"); + }); + + it("keeps the declared scale's trailing zero in the raw value", async () => { + const dec = readDecimal64(2)(await reader("toDecimal64(1.20, 2)")); + expect(dec).toEqual([120n, 2]); + // CH text would show "1.2"; formatDecimal keeps the scale: "1.20". + expect(formatDecimal(dec)).toBe("1.20"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toDecimal64(-12.34, 2) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readDecimal64(2)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Dynamic.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Dynamic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8654b706db1b1190a9ca64c1f518c3c3faa6b4da GIT binary patch literal 12872 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 { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readEnum16", () => { + it("decodes a 16-bit underlying value", async () => { + const r = await reader("CAST('big' AS Enum16('small' = 1, 'big' = 300))"); + const value = readEnum16(r); + expect(value).toBe(300); + expect(r.pos).toBe(2); + }); + + it("decodes a negative enum value", async () => { + const value = readEnum16( + await reader("CAST('lo' AS Enum16('lo' = -1000, 'hi' = 1000))"), + ); + expect(value).toBe(-1000); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST('big' AS Enum16('small' = 1, 'big' = 300)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readEnum16(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Enum8.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Enum8.test.ts new file mode 100644 index 000000000..8fd8df39b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Enum8.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readEnum8 } from "../src/enums.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readEnum8", () => { + it("decodes the underlying value and resolves the name via a lookup", async () => { + const r = await reader("CAST('b' AS Enum8('a' = 1, 'b' = 2))"); + const value = readEnum8(r); + expect(value).toBe(2); + expect(r.pos).toBe(1); + // The name map comes from the column's type definition, not the wire. + const NAMES: Record = { 1: "a", 2: "b" }; + expect(NAMES[value]).toBe("b"); + }); + + it("decodes a negative enum value", async () => { + const value = readEnum8( + await reader("CAST('x' AS Enum8('x' = -1, 'y' = 2))"), + ); + expect(value).toBe(-1); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST('b' AS Enum8('a' = 1, 'b' = 2)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readEnum8(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/FixedString.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/FixedString.test.ts new file mode 100644 index 000000000..08c5224c3 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/FixedString.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readFixedString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readFixedString", () => { + it("decodes a full-width value (no padding)", async () => { + const r = await reader("toFixedString('abcd', 4)"); + expect(readFixedString(4)(r)).toBe("abcd"); + expect(r.pos).toBe(4); + }); + + // Shorter content is right-padded with NUL bytes, which are preserved. + it("preserves trailing NUL padding", async () => { + expect(readFixedString(4)(await reader("toFixedString('ab', 4)"))).toBe( + "ab\x00\x00", + ); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toFixedString('ab', 4) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readFixedString(4)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/FixedStringBytes.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/FixedStringBytes.test.ts new file mode 100644 index 000000000..9ac9aedb2 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/FixedStringBytes.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readFixedStringBytes } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readFixedStringBytes", () => { + it("returns the raw bytes, padding included", async () => { + const r = await reader("toFixedString('ab', 4)"); + expect(readFixedStringBytes(4)(r)).toEqual(Buffer.from([0x61, 0x62, 0, 0])); + expect(r.pos).toBe(4); + }); + + // The default is a zero-copy view, so it shares memory with the source. + it("returns a zero-copy view sharing memory", async () => { + const r = await reader("toFixedString('ab', 4)"); + const bytes = readFixedStringBytes(4)(r); + r.buf[0] = 0xff; + expect(bytes[0]).toBe(0xff); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toFixedString('ab', 4) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readFixedStringBytes(4)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Float32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Float32.test.ts new file mode 100644 index 000000000..9fcd214cd --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Float32.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readFloat32 } from "../src/floats.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readFloat32", () => { + it("decodes 0", async () => { + const r = await reader("toFloat32(0)"); + expect(readFloat32(r)).toBe(0); + expect(r.pos).toBe(4); + }); + + // Values exactly representable in float32, so no rounding to account for. + it("decodes 1.5", async () => { + expect(readFloat32(await reader("toFloat32(1.5)"))).toBe(1.5); + }); + + it("decodes -2.5", async () => { + expect(readFloat32(await reader("toFloat32(-2.5)"))).toBe(-2.5); + }); + + it("decodes inf", async () => { + expect(readFloat32(await reader("toFloat32(inf)"))).toBe(Infinity); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toFloat32(1.5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readFloat32(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Float64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Float64.test.ts new file mode 100644 index 000000000..e9ae430b8 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Float64.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readFloat64 } from "../src/floats.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readFloat64", () => { + it("decodes 0", async () => { + const r = await reader("toFloat64(0)"); + expect(readFloat64(r)).toBe(0); + expect(r.pos).toBe(8); + }); + + it("decodes 1.5", async () => { + expect(readFloat64(await reader("toFloat64(1.5)"))).toBe(1.5); + }); + + // float64 represents 0.1 exactly as the same double JS uses. + it("decodes 0.1", async () => { + expect(readFloat64(await reader("toFloat64(0.1)"))).toBe(0.1); + }); + + it("decodes -inf", async () => { + expect(readFloat64(await reader("toFloat64(-inf)"))).toBe(-Infinity); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toFloat64(1.5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readFloat64(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Geometry.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Geometry.test.ts new file mode 100644 index 000000000..f7a253a15 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Geometry.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readGeometry } from "../src/geo.js"; + +// Geometry's variant has "similar" alternatives (LineString/Ring), so the type +// needs allow_suspicious_variant_types; the value still casts through a geo type. +async function reader(expr: string): Promise { + return new RowBinaryState( + await query( + `SELECT ${expr} SETTINGS allow_suspicious_variant_types = 1 FORMAT RowBinary`, + ), + ); +} + +describe("readGeometry", () => { + it("decodes a Point (discriminant 3)", async () => { + const r = await reader("CAST(CAST((1.5, 2.5) AS Point) AS Geometry)"); + expect(readGeometry(r)).toEqual([1.5, 2.5]); + }); + + it("decodes a LineString (discriminant 0)", async () => { + const r = await reader( + "CAST(CAST([(0, 0), (1, 2)] AS LineString) AS Geometry)", + ); + expect(readGeometry(r)).toEqual([ + [0, 0], + [1, 2], + ]); + }); + + it("decodes a MultiPolygon (discriminant 2)", async () => { + const r = await reader( + "CAST(CAST([[[(0, 0), (1, 0), (1, 1)]]] AS MultiPolygon) AS Geometry)", + ); + expect(readGeometry(r)).toEqual([ + [ + [ + [0, 0], + [1, 0], + [1, 1], + ], + ], + ]); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST(CAST((1.5, 2.5) AS Point) AS Geometry) SETTINGS allow_suspicious_variant_types = 1 FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readGeometry(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/IPv4.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/IPv4.test.ts new file mode 100644 index 000000000..2cf5286e4 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/IPv4.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatIPv4, readIPv4 } from "../src/ip.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readIPv4", () => { + it("decodes the raw UInt32 and formats a dotted quad", async () => { + const r = await reader("toIPv4('1.2.3.4')"); + const value = readIPv4(r); + expect(value).toBe(0x01020304); + expect(r.pos).toBe(4); + expect(formatIPv4(value)).toBe("1.2.3.4"); + }); + + it("decodes 0.0.0.0", async () => { + const value = readIPv4(await reader("toIPv4('0.0.0.0')")); + expect(value).toBe(0); + expect(formatIPv4(value)).toBe("0.0.0.0"); + }); + + it("decodes 255.255.255.255", async () => { + const value = readIPv4(await reader("toIPv4('255.255.255.255')")); + expect(value).toBe(0xffffffff); + expect(formatIPv4(value)).toBe("255.255.255.255"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toIPv4('1.2.3.4') FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readIPv4(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/IPv6.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/IPv6.test.ts new file mode 100644 index 000000000..e8b0becf2 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/IPv6.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatIPv6, readIPv6 } from "../src/ip.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readIPv6", () => { + it("returns the raw 16 bytes and formats loopback ::1", async () => { + const r = await reader("toIPv6('::1')"); + const bytes = readIPv6(r); + expect(r.pos).toBe(16); + expect(bytes).toEqual( + Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]), + ); + expect(formatIPv6(bytes)).toBe("::1"); + }); + + it("formats the all-zero address ::", async () => { + expect(formatIPv6(readIPv6(await reader("toIPv6('::')")))).toBe("::"); + }); + + it("collapses the longest zero run", async () => { + expect(formatIPv6(readIPv6(await reader("toIPv6('2001:db8::1')")))).toBe( + "2001:db8::1", + ); + }); + + // Two zero runs: the longer one (positions 4-6) is collapsed, not the first. + it("collapses the longest run, not the leftmost", async () => { + expect( + formatIPv6(readIPv6(await reader("toIPv6('1:0:0:2:0:0:0:3')"))), + ).toBe("1:0:0:2::3"); + }); + + it("leaves a fully-populated address uncompressed", async () => { + expect( + formatIPv6(readIPv6(await reader("toIPv6('2001:db8:1:2:3:4:5:6')"))), + ).toBe("2001:db8:1:2:3:4:5:6"); + }); + + it("renders IPv4-mapped addresses as ::ffff:a.b.c.d", async () => { + expect(formatIPv6(readIPv6(await reader("toIPv6('::ffff:1.2.3.4')")))).toBe( + "::ffff:1.2.3.4", + ); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toIPv6('2001:db8::1') FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readIPv6(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int128.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int128.test.ts new file mode 100644 index 000000000..d5c1c63c6 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int128.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readInt128 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +const MAX = 170141183460469231731687303715884105727n; // 2^127 - 1 +const MIN = -170141183460469231731687303715884105728n; // -2^127 + +describe("readInt128", () => { + it("decodes 0n", async () => { + const r = await reader("toInt128(0)"); + expect(readInt128(r)).toBe(0n); + expect(r.pos).toBe(16); + }); + + it("decodes -1n (all 0xff, sign spans both words)", async () => { + expect(readInt128(await reader("toInt128('-1')"))).toBe(-1n); + }); + + it("decodes the max (2^127 - 1)", async () => { + expect(readInt128(await reader(`toInt128('${MAX}')`))).toBe(MAX); + }); + + it("decodes the min (-2^127)", async () => { + expect(readInt128(await reader(`toInt128('${MIN}')`))).toBe(MIN); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toInt128(-5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readInt128(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int16.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int16.test.ts new file mode 100644 index 000000000..4bc589e49 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int16.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readInt16 } from "../src/integers.js"; + +/** + * Int16 is 2 bytes, little-endian, two's-complement. Each case selects the + * value with `FORMAT RowBinary` and decodes the bytes the server produces. + */ +async function int16Reader(expr: string): Promise { + return new RowBinaryState( + await query(`SELECT toInt16(${expr}) FORMAT RowBinary`), + ); +} + +describe("readInt16", () => { + it("decodes 0", async () => { + const r = await int16Reader("0"); + expect(readInt16(r)).toBe(0); + expect(r.pos).toBe(2); + }); + + it("decodes 1", async () => { + const r = await int16Reader("1"); + expect(readInt16(r)).toBe(1); + }); + + it("decodes -1 (0xffff)", async () => { + const r = await int16Reader("-1"); + expect(readInt16(r)).toBe(-1); + }); + + // Confirms little-endian byte order: 258 = 0x0102 -> bytes 02 01. + it("decodes 258 (little-endian byte order)", async () => { + const r = await int16Reader("258"); + expect(readInt16(r)).toBe(258); + }); + + it("decodes 32767 (max)", async () => { + const r = await int16Reader("32767"); + expect(readInt16(r)).toBe(32767); + }); + + it("decodes -32768 (min)", async () => { + const r = await int16Reader("-32768"); + expect(readInt16(r)).toBe(-32768); + }); + + // Guards the byteOffset handling: a Buffer that is a window into a larger + // ArrayBuffer (nonzero byteOffset) must still decode correctly. + it("decodes from a buffer window with a nonzero byteOffset", () => { + const ab = Uint8Array.from([0xaa, 0xbb, 0xcc, 0x02, 0x01]).buffer; // 258 at offset 3 + const sub = Buffer.from(ab, 3, 2); + expect(sub.byteOffset).toBe(3); + expect(readInt16(new RowBinaryState(sub))).toBe(258); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toInt16(-12345) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readInt16(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int256.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int256.test.ts new file mode 100644 index 000000000..cc117a13a --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int256.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readInt256 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +// 2^255 - 1 and -2^255 +const MAX = + 57896044618658097711785492504343953926634992332820282019728792003956564819967n; +const MIN = + -57896044618658097711785492504343953926634992332820282019728792003956564819968n; + +describe("readInt256", () => { + it("decodes 0n", async () => { + const r = await reader("toInt256(0)"); + expect(readInt256(r)).toBe(0n); + expect(r.pos).toBe(32); + }); + + it("decodes -1n (all 0xff, sign spans all four words)", async () => { + expect(readInt256(await reader("toInt256('-1')"))).toBe(-1n); + }); + + it("decodes the max (2^255 - 1)", async () => { + expect(readInt256(await reader(`toInt256('${MAX}')`))).toBe(MAX); + }); + + it("decodes the min (-2^255)", async () => { + expect(readInt256(await reader(`toInt256('${MIN}')`))).toBe(MIN); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toInt256(-5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readInt256(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int32.test.ts new file mode 100644 index 000000000..9444ba444 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int32.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readInt32 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readInt32", () => { + it("decodes 0", async () => { + const r = await reader("toInt32(0)"); + expect(readInt32(r)).toBe(0); + expect(r.pos).toBe(4); + }); + + it("decodes -1", async () => { + expect(readInt32(await reader("toInt32(-1)"))).toBe(-1); + }); + + it("decodes 2147483647 (max)", async () => { + expect(readInt32(await reader("toInt32(2147483647)"))).toBe(2147483647); + }); + + it("decodes -2147483648 (min)", async () => { + expect(readInt32(await reader("toInt32(-2147483648)"))).toBe(-2147483648); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toInt32(-5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readInt32(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int64.test.ts new file mode 100644 index 000000000..7df0979a0 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int64.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readInt64 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readInt64", () => { + it("decodes 0n", async () => { + const r = await reader("toInt64(0)"); + expect(readInt64(r)).toBe(0n); + expect(r.pos).toBe(8); + }); + + it("decodes -1n", async () => { + expect(readInt64(await reader("toInt64(-1)"))).toBe(-1n); + }); + + it("decodes 9223372036854775807n (max)", async () => { + expect(readInt64(await reader("toInt64(9223372036854775807)"))).toBe( + 9223372036854775807n, + ); + }); + + it("decodes -9223372036854775808n (min)", async () => { + expect(readInt64(await reader("toInt64(-9223372036854775808)"))).toBe( + -9223372036854775808n, + ); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toInt64(-5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readInt64(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int8.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int8.test.ts new file mode 100644 index 000000000..f330273ae --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int8.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readInt8 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readInt8", () => { + it("decodes 0", async () => { + const r = await reader("toInt8(0)"); + expect(readInt8(r)).toBe(0); + expect(r.pos).toBe(1); + }); + + it("decodes 127 (max)", async () => { + expect(readInt8(await reader("toInt8(127)"))).toBe(127); + }); + + it("decodes -1", async () => { + expect(readInt8(await reader("toInt8(-1)"))).toBe(-1); + }); + + it("decodes -128 (min)", async () => { + expect(readInt8(await reader("toInt8(-128)"))).toBe(-128); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toInt8(-1) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readInt8(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Interval.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Interval.test.ts new file mode 100644 index 000000000..5fff43ac5 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Interval.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readInterval } from "../src/interval.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +// All 11 Interval* types share one Int64 wire; the unit lives in the type name. +describe("readInterval", () => { + it("decodes a positive count (IntervalSecond)", async () => { + const r = await reader("toIntervalSecond(5)"); + expect(readInterval(r)).toBe(5n); + expect(r.pos).toBe(8); + }); + + it("decodes a negative count (IntervalDay)", async () => { + expect(readInterval(await reader("toIntervalDay(-3)"))).toBe(-3n); + }); + + it("decodes a large count (IntervalNanosecond)", async () => { + expect(readInterval(await reader("toIntervalNanosecond(1000000000)"))).toBe( + 1000000000n, + ); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toIntervalSecond(5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readInterval(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/JSON.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/JSON.test.ts new file mode 100644 index 000000000..45d1a2c63 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/JSON.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readDynamic } from "../src/dynamic.js"; +import { readJSON } from "../src/json.js"; + +const J = "SETTINGS allow_experimental_json_type = 1, enable_json_type = 1"; + +async function reader(expr: string): Promise { + return new RowBinaryState( + await query(`SELECT ${expr} ${J} FORMAT RowBinary`), + ); +} + +describe("readJSON", () => { + it("reads (path, Dynamic value) pairs into a Map", async () => { + const r = await reader(`'{"a":1}'::JSON`); + expect(readJSON(r)).toEqual(new Map([["a", 1n]])); // a -> Int64 1 + }); + + it("reads multiple paths, each with its own inline type", async () => { + const r = await reader(`'{"a":1,"b":"hi"}'::JSON`); + expect(readJSON(r)).toEqual( + new Map([ + ["b", "hi"], // String + ["a", 1n], // Int64 + ]), + ); + }); + + it("FLATTENS nested objects to dotted paths", async () => { + const r = await reader(`'{"a":{"b":2}}'::JSON`); + expect(readJSON(r)).toEqual(new Map([["a.b", 2n]])); + }); + + it("an empty object is zero paths", async () => { + const r = await reader(`'{}'::JSON`); + expect(readJSON(r)).toEqual(new Map()); + }); + + it("a null-valued path is NOT stored — same as an empty object", async () => { + const r = await reader(`'{"a":null}'::JSON`); + expect(readJSON(r)).toEqual(new Map()); // {"a":null} serializes as 0 paths + }); + + it("a JSON array path decodes as Array(Nullable(T)) via Dynamic", async () => { + const r = await reader(`'{"a":[1,2]}'::JSON`); + expect(readJSON(r)).toEqual(new Map([["a", [1n, 2n]]])); + }); + + it("decodes mixed scalar types (Float64, Bool)", async () => { + const r = await reader(`'{"x":1.5,"y":true}'::JSON`); + expect(readJSON(r)).toEqual( + new Map([ + ["y", true], + ["x", 1.5], + ]), + ); + }); + + // JSON nested inside a Dynamic: the 0x30 tag's type-encoding header precedes + // the body, which readDynamicType consumes before delegating to readJSON. + describe("inside a Dynamic (tag 0x30, with the type-encoding header)", () => { + async function dyn(expr: string): Promise { + return new RowBinaryState( + await query( + `SELECT CAST(${expr} AS Dynamic) ${J}, allow_experimental_dynamic_type = 1 FORMAT RowBinary`, + ), + ); + } + + it("decodes a JSON value, skipping the parameter header", async () => { + const r = await dyn(`'{"a":1}'::JSON`); + expect(readDynamic(r)).toEqual(new Map([["a", 1n]])); + }); + + it("recurses through Array(JSON)", async () => { + const r = await dyn(`['{"a":1}'::JSON, '{"b":2}'::JSON]`); + expect(readDynamic(r)).toEqual([ + new Map([["a", 1n]]), + new Map([["b", 2n]]), + ]); + }); + + it("throws on declared typed paths (need the schema to read them)", async () => { + const r = await dyn(`'{"a":1}'::JSON(b UInt32)`); + expect(() => readDynamic(r)).toThrow(/typed paths/); + }); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query(`SELECT '{"a":1}'::JSON ${J} FORMAT RowBinary`); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readJSON(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/LineString.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/LineString.test.ts new file mode 100644 index 000000000..e59f9ec59 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/LineString.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readLineString } from "../src/geo.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readLineString", () => { + it("decodes a LineString as an array of points", async () => { + const r = await reader("CAST([(3, 4), (5, 6)] AS LineString)"); + expect(readLineString(r)).toEqual([ + [3, 4], + [5, 6], + ]); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST([(3, 4), (5, 6)] AS LineString) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readLineString(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Map.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Map.test.ts new file mode 100644 index 000000000..fd6cac615 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Map.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readMap, readNullable } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt32, readUInt8 } from "../src/integers.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readMap", () => { + it("decodes key/value pairs into a JS Map", async () => { + const r = await reader("CAST(map('a', 1, 'b', 2) AS Map(String, UInt32))"); + const m = readMap(readString, readUInt32)(r); + expect(m).toEqual( + new Map([ + ["a", 1], + ["b", 2], + ]), + ); + expect(r.pos).toBe(13); // 1 count + 2 * (2-byte key + 4-byte value) + }); + + it("decodes the empty map (just the count byte)", async () => { + const r = await reader("CAST(map() AS Map(String, UInt32))"); + expect(readMap(readString, readUInt32)(r)).toEqual(new Map()); + expect(r.pos).toBe(1); + }); + + // Composes: a Nullable value (NULL is just its flag byte). + it("decodes Map(UInt8, Nullable(String)) with a NULL value", async () => { + const r = await reader( + "CAST(map(1, 'x', 2, NULL) AS Map(UInt8, Nullable(String)))", + ); + const m = readMap(readUInt8, readNullable(readString))(r); + expect(m).toEqual( + new Map([ + [1, "x"], + [2, null], + ]), + ); + expect(r.pos).toBe(7); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST(map('a', 1, 'b', 2) AS Map(String, UInt8)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readMap(readString, readUInt8)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/MultiLineString.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/MultiLineString.test.ts new file mode 100644 index 000000000..6065223a0 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/MultiLineString.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readMultiLineString } from "../src/geo.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readMultiLineString", () => { + it("decodes a MultiLineString as an array of line strings", async () => { + const r = await reader("CAST([[(0, 0), (1, 1)]] AS MultiLineString)"); + expect(readMultiLineString(r)).toEqual([ + [ + [0, 0], + [1, 1], + ], + ]); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST([[(0, 0), (1, 1)]] AS MultiLineString) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readMultiLineString(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/MultiPolygon.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/MultiPolygon.test.ts new file mode 100644 index 000000000..4d79b25d9 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/MultiPolygon.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readMultiPolygon } from "../src/geo.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readMultiPolygon", () => { + it("decodes a MultiPolygon as an array of polygons", async () => { + const r = await reader( + "CAST([[[(0, 0), (1, 0), (1, 1)]]] AS MultiPolygon)", + ); + expect(readMultiPolygon(r)).toEqual([ + [ + [ + [0, 0], + [1, 0], + [1, 1], + ], + ], + ]); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST([[[(0, 0), (1, 0), (1, 1)]]] AS MultiPolygon) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readMultiPolygon(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Nullable.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Nullable.test.ts new file mode 100644 index 000000000..d7058cee9 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Nullable.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readNullable } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt32, readUInt8 } from "../src/integers.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readNullable", () => { + it("decodes a present value (flag 0 + value)", async () => { + const r = await reader("CAST(42 AS Nullable(UInt32))"); + expect(readNullable(readUInt32)(r)).toBe(42); + expect(r.pos).toBe(5); // 1 flag + 4 value + }); + + it("decodes NULL as the lone flag byte (no value follows)", async () => { + const r = await reader("CAST(NULL AS Nullable(UInt32))"); + expect(readNullable(readUInt32)(r)).toBeNull(); + expect(r.pos).toBe(1); // just the flag — the inner reader must not run + }); + + // Framing across rows: NULL then 1 -> bytes 01 00 01. + it("keeps the cursor aligned across NULL and non-NULL rows", async () => { + const r = await reader( + "CAST(number = 0 ? NULL : number AS Nullable(UInt8)) FROM numbers(2)", + ); + expect(readNullable(readUInt8)(r)).toBeNull(); + expect(r.pos).toBe(1); + expect(readNullable(readUInt8)(r)).toBe(1); + expect(r.pos).toBe(3); + }); + + // A variable-length inner type: the inner reader must not run on NULL. + it("works with a variable-length inner type (String)", async () => { + const r = await reader("CAST('hi' AS Nullable(String))"); + expect(readNullable(readString)(r)).toBe("hi"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST(42 AS Nullable(UInt32)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readNullable(readUInt32)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Point.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Point.test.ts new file mode 100644 index 000000000..ce01bc778 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Point.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readPoint } from "../src/geo.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readPoint", () => { + it("decodes a Point as [x, y]", async () => { + const r = await reader("CAST((1.5, 2.5) AS Point)"); + expect(readPoint(r)).toEqual([1.5, 2.5]); + expect(r.pos).toBe(16); // two Float64 + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST((1.5, 2.5) AS Point) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readPoint(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Polygon.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Polygon.test.ts new file mode 100644 index 000000000..6708539f4 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Polygon.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readPolygon } from "../src/geo.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readPolygon", () => { + it("decodes a Polygon as an array of rings", async () => { + const r = await reader("CAST([[(0, 0), (1, 0), (1, 1)]] AS Polygon)"); + expect(readPolygon(r)).toEqual([ + [ + [0, 0], + [1, 0], + [1, 1], + ], + ]); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST([[(0, 0), (1, 0), (1, 1)]] AS Polygon) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readPolygon(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Ring.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Ring.test.ts new file mode 100644 index 000000000..9e51f7694 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Ring.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readRing } from "../src/geo.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readRing", () => { + it("decodes a Ring as an array of points", async () => { + const r = await reader("CAST([(0, 0), (1, 2)] AS Ring)"); + expect(readRing(r)).toEqual([ + [0, 0], + [1, 2], + ]); + expect(r.pos).toBe(33); // 1 count + 2 * 16 + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST([(0, 0), (1, 2)] AS Ring) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readRing(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/String.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/String.test.ts new file mode 100644 index 000000000..201691221 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/String.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readString", () => { + it("decodes a short ASCII string", async () => { + const r = await reader("'hello'"); + expect(readString(r)).toBe("hello"); + // 1-byte varint length (5) + 5 payload bytes. + expect(r.pos).toBe(6); + }); + + it("decodes the empty string", async () => { + const r = await reader("''"); + expect(readString(r)).toBe(""); + expect(r.pos).toBe(1); // just the 0x00 length byte + }); + + it("decodes multi-byte UTF-8", async () => { + expect(readString(await reader("'héllo · 日本'"))).toBe("héllo · 日本"); + }); + + // A string longer than 127 bytes uses a 2-byte varint length prefix. + it("decodes a string with a multi-byte length prefix", async () => { + const r = await reader("repeat('x', 300)"); + expect(readString(r)).toBe("x".repeat(300)); + expect(r.pos).toBe(302); // 2-byte length + 300 payload + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT 'hello' FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readString(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Time.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Time.test.ts new file mode 100644 index 000000000..01bbdca53 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Time.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatTime, readTime } from "../src/time.js"; + +// Time / Time64 need enable_time_time64_type; pass it inline via SETTINGS. +async function reader(expr: string): Promise { + return new RowBinaryState( + await query( + `SELECT ${expr} SETTINGS enable_time_time64_type = 1 FORMAT RowBinary`, + ), + ); +} + +describe("readTime", () => { + it("decodes seconds-of-day", async () => { + const r = await reader("CAST('12:34:56' AS Time)"); + const secs = readTime(r); + expect(secs).toBe(45296); + expect(r.pos).toBe(4); + expect(formatTime(secs)).toBe("12:34:56"); + }); + + it("decodes a negative time", async () => { + const secs = readTime(await reader("CAST('-01:00:00' AS Time)")); + expect(secs).toBe(-3600); + expect(formatTime(secs)).toBe("-01:00:00"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST('12:34:56' AS Time) SETTINGS enable_time_time64_type = 1 FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readTime(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Time64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Time64.test.ts new file mode 100644 index 000000000..0f100592d --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Time64.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatTime64, readTime64 } from "../src/time.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState( + await query( + `SELECT ${expr} SETTINGS enable_time_time64_type = 1 FORMAT RowBinary`, + ), + ); +} + +describe("readTime64", () => { + it("decodes millisecond ticks (P=3)", async () => { + const r = await reader("toTime64('12:34:56.123', 3)"); + const t = readTime64(3)(r); + expect(t).toEqual([45296123n, 3]); + expect(r.pos).toBe(8); + expect(formatTime64(t)).toBe("12:34:56.123"); + }); + + it("decodes a negative time with fractional seconds", async () => { + const t = readTime64(3)(await reader("toTime64('-01:00:00.500', 3)")); + expect(t).toEqual([-3600500n, 3]); + expect(formatTime64(t)).toBe("-01:00:00.500"); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toTime64('12:34:56.123', 3) SETTINGS enable_time_time64_type = 1 FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readTime64(3)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Tuple.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Tuple.test.ts new file mode 100644 index 000000000..e6bf6bb11 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Tuple.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readNullable, readTuple } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt32, readUInt8 } from "../src/integers.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readTuple", () => { + it("decodes heterogeneous elements back-to-back (no count)", async () => { + const r = await reader("CAST((42, 'hi') AS Tuple(UInt32, String))"); + const t = readTuple([readUInt32, readString])(r); + expect(t).toEqual([42, "hi"]); + expect(r.pos).toBe(7); // 4 (UInt32) + 1 (len) + 2 ("hi") + }); + + // Composes with Nullable: (1, NULL) -> bytes 01 01. + it("composes with Nullable elements", async () => { + const r = await reader("CAST((1, NULL) AS Tuple(UInt8, Nullable(UInt8)))"); + const t = readTuple([readUInt8, readNullable(readUInt8)])(r); + expect(t).toEqual([1, null]); + expect(r.pos).toBe(2); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST((1, 'x') AS Tuple(UInt8, String)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readTuple([readUInt8, readString])(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/TupleNamed.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/TupleNamed.test.ts new file mode 100644 index 000000000..4a8081b3f --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/TupleNamed.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readNullable, readTupleNamed } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt32, readUInt8 } from "../src/integers.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readTupleNamed", () => { + it("decodes a named tuple into an object (same wire as unnamed)", async () => { + const r = await reader("CAST((42, 'hi') AS Tuple(a UInt32, b String))"); + const obj = readTupleNamed({ + a: readUInt32, + b: readString, + })(r); + expect(obj).toEqual({ a: 42, b: "hi" }); + expect(r.pos).toBe(7); // 4 (UInt32) + 1 (len) + 2 ("hi") + }); + + // Keys are read in listed order; the result object carries the names. + it("composes with Nullable and preserves field names", async () => { + const r = await reader( + "CAST((1, NULL) AS Tuple(id UInt8, parent Nullable(UInt8)))", + ); + const obj = readTupleNamed({ + id: readUInt8, + parent: readNullable(readUInt8), + })(r); + expect(obj).toEqual({ id: 1, parent: null }); + expect(r.pos).toBe(2); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST((1, 'x') AS Tuple(a UInt8, b String)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readTupleNamed({ a: readUInt8, b: readString })(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt128.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt128.test.ts new file mode 100644 index 000000000..52c4f4216 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt128.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt128 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +const MAX = 340282366920938463463374607431768211455n; // 2^128 - 1 + +describe("readUInt128", () => { + it("decodes 0n", async () => { + const r = await reader("toUInt128(0)"); + expect(readUInt128(r)).toBe(0n); + expect(r.pos).toBe(16); + }); + + // Value in the high word confirms the low/high composition. + it("decodes 2^64 (only the high word set)", async () => { + expect(readUInt128(await reader("toUInt128('18446744073709551616')"))).toBe( + 18446744073709551616n, + ); + }); + + it("decodes the max (2^128 - 1)", async () => { + expect(readUInt128(await reader(`toUInt128('${MAX}')`))).toBe(MAX); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toUInt128(5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUInt128(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt16.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt16.test.ts new file mode 100644 index 000000000..701fa8766 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt16.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt16 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readUInt16", () => { + it("decodes 0", async () => { + const r = await reader("toUInt16(0)"); + expect(readUInt16(r)).toBe(0); + expect(r.pos).toBe(2); + }); + + // Confirms little-endian byte order: 258 = 0x0102 -> bytes 02 01. + it("decodes 258 (little-endian byte order)", async () => { + expect(readUInt16(await reader("toUInt16(258)"))).toBe(258); + }); + + it("decodes 65535 (max)", async () => { + expect(readUInt16(await reader("toUInt16(65535)"))).toBe(65535); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toUInt16(258) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUInt16(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt256.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt256.test.ts new file mode 100644 index 000000000..c74048b9b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt256.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt256 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +const MAX = + 115792089237316195423570985008687907853269984665640564039457584007913129639935n; // 2^256 - 1 + +describe("readUInt256", () => { + it("decodes 0n", async () => { + const r = await reader("toUInt256(0)"); + expect(readUInt256(r)).toBe(0n); + expect(r.pos).toBe(32); + }); + + // Set the third word (bits 128..191) to confirm word ordering. + it("decodes 2^128 (only the third word set)", async () => { + expect( + readUInt256( + await reader("toUInt256('340282366920938463463374607431768211456')"), + ), + ).toBe(340282366920938463463374607431768211456n); + }); + + it("decodes the max (2^256 - 1)", async () => { + expect(readUInt256(await reader(`toUInt256('${MAX}')`))).toBe(MAX); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toUInt256(5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUInt256(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt32.test.ts new file mode 100644 index 000000000..8a807ba16 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt32.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt32 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readUInt32", () => { + it("decodes 0", async () => { + const r = await reader("toUInt32(0)"); + expect(readUInt32(r)).toBe(0); + expect(r.pos).toBe(4); + }); + + it("decodes 4294967295 (max)", async () => { + expect(readUInt32(await reader("toUInt32(4294967295)"))).toBe(4294967295); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toUInt32(258) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUInt32(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt64.test.ts new file mode 100644 index 000000000..d08800e8e --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt64.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt64 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readUInt64", () => { + it("decodes 0n", async () => { + const r = await reader("toUInt64(0)"); + expect(readUInt64(r)).toBe(0n); + expect(r.pos).toBe(8); + }); + + it("decodes 1n", async () => { + expect(readUInt64(await reader("toUInt64(1)"))).toBe(1n); + }); + + it("decodes 18446744073709551615n (max)", async () => { + expect(readUInt64(await reader("toUInt64(18446744073709551615)"))).toBe( + 18446744073709551615n, + ); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toUInt64(5) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUInt64(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt8.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt8.test.ts new file mode 100644 index 000000000..ba687e93a --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt8.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt8 } from "../src/integers.js"; + +describe("readUInt8", () => { + it("reads sequential unsigned bytes", () => { + const r = new RowBinaryState(Buffer.from([1, 2, 255])); + expect(readUInt8(r)).toBe(1); + expect(readUInt8(r)).toBe(2); + expect(readUInt8(r)).toBe(255); + expect(r.pos).toBe(3); + }); + + it("decodes a UInt8 straight from ClickHouse", async () => { + const bytes = await query("SELECT toUInt8(255) FORMAT RowBinary"); + const r = new RowBinaryState(bytes); + expect(readUInt8(r)).toBe(255); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query("SELECT toUInt8(255) FORMAT RowBinary"); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUInt8(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UUID.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UUID.test.ts new file mode 100644 index 000000000..a948db7cc --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UUID.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { formatUUID, formatUUIDTable, readUUID } from "../src/uuid.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readUUID", () => { + it("returns the raw 16 bytes and formats them (per-half byte order)", async () => { + const r = await reader("toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0')"); + const bytes = readUUID(r); + expect(r.pos).toBe(16); + // Wire layout: each LE UInt64 half is byte-reversed vs the text form. + expect(bytes).toEqual( + Buffer.from([ + 0xe7, 0x11, 0xb3, 0x5c, 0x04, 0xc4, 0xf0, 0x61, 0xa0, 0xdb, 0xd3, 0x6a, + 0x00, 0xa6, 0x7b, 0x90, + ]), + ); + expect(formatUUID(bytes)).toBe("61f0c404-5cb3-11e7-907b-a6006ad3dba0"); + }); + + it("formats the nil UUID", async () => { + expect( + formatUUID( + readUUID( + await reader("toUUID('00000000-0000-0000-0000-000000000000')"), + ), + ), + ).toBe("00000000-0000-0000-0000-000000000000"); + }); + + // Smallest non-zero value: exercises zero-padding of leading hex digits. + it("zero-pads leading digits", async () => { + expect( + formatUUID( + readUUID( + await reader("toUUID('00000000-0000-0000-0000-000000000001')"), + ), + ), + ).toBe("00000000-0000-0000-0000-000000000001"); + }); + + it("formats the all-ones UUID", async () => { + expect( + formatUUID( + readUUID( + await reader("toUUID('ffffffff-ffff-ffff-ffff-ffffffffffff')"), + ), + ), + ).toBe("ffffffff-ffff-ffff-ffff-ffffffffffff"); + }); + + // The fast formatter: formatUUIDTable must match formatUUID exactly on the + // same raw bytes (lookup table instead of BigInt). + describe("formatUUIDTable (fast lookup-table formatter)", () => { + it("matches formatUUID for a typical value", async () => { + const b = readUUID( + await reader("toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0')"), + ); + expect(formatUUIDTable(b)).toBe("61f0c404-5cb3-11e7-907b-a6006ad3dba0"); + expect(formatUUIDTable(b)).toBe(formatUUID(b)); + }); + + it("zero-pads leading digits and formats the nil UUID", async () => { + expect( + formatUUIDTable( + readUUID( + await reader("toUUID('00000000-0000-0000-0000-000000000001')"), + ), + ), + ).toBe("00000000-0000-0000-0000-000000000001"); + expect( + formatUUIDTable( + readUUID( + await reader("toUUID('00000000-0000-0000-0000-000000000000')"), + ), + ), + ).toBe("00000000-0000-0000-0000-000000000000"); + }); + + it("reuses the shared output buffer across calls without corruption", async () => { + const a = formatUUIDTable( + readUUID( + await reader("toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0')"), + ), + ); + const b = formatUUIDTable( + readUUID( + await reader("toUUID('ffffffff-ffff-ffff-ffff-ffffffffffff')"), + ), + ); + expect(a).toBe("61f0c404-5cb3-11e7-907b-a6006ad3dba0"); // earlier result is a copied string, not clobbered + expect(b).toBe("ffffffff-ffff-ffff-ffff-ffffffffffff"); + }); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUUID(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDBigInt.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDBigInt.test.ts new file mode 100644 index 000000000..00786097d --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDBigInt.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUUIDBigInt } from "../src/uuid.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readUUIDBigInt", () => { + it("decodes a UUID as its 128-bit value", async () => { + const r = await reader("toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0')"); + expect(readUUIDBigInt(r)).toBe(0x61f0c4045cb311e7907ba6006ad3dba0n); + expect(r.pos).toBe(16); + }); + + it("decodes the nil UUID as 0n", async () => { + expect( + readUUIDBigInt( + await reader("toUUID('00000000-0000-0000-0000-000000000000')"), + ), + ).toBe(0n); + }); + + it("decodes ...0001 as 1n", async () => { + expect( + readUUIDBigInt( + await reader("toUUID('00000000-0000-0000-0000-000000000001')"), + ), + ).toBe(1n); + }); + + it("decodes the all-ones UUID as 2^128 - 1", async () => { + expect( + readUUIDBigInt( + await reader("toUUID('ffffffff-ffff-ffff-ffff-ffffffffffff')"), + ), + ).toBe((1n << 128n) - 1n); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUUIDBigInt(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDHiLo.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDHiLo.test.ts new file mode 100644 index 000000000..6ff436154 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDHiLo.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { + formatUUID, + readUUID, + readUUIDBigInt, + readUUIDHiLo, +} from "../src/uuid.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +describe("readUUIDHiLo", () => { + it("returns the two little-endian UInt64 halves [hi, lo]", async () => { + const r = await reader("toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0')"); + expect(readUUIDHiLo(r)).toEqual([0x61f0c4045cb311e7n, 0x907ba6006ad3dba0n]); + expect(r.pos).toBe(16); + }); + + it("composes back to the same value as readUUIDBigInt / formatUUID", async () => { + const expr = "toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0')"; + const [hi, lo] = readUUIDHiLo(await reader(expr)); + // hi << 64 | lo is exactly what readUUIDBigInt returns. + expect((hi << 64n) | lo).toBe(readUUIDBigInt(await reader(expr))); + // The 32 hex digits are hi then lo, zero-padded — matching formatUUID. + const hex = + hi.toString(16).padStart(16, "0") + lo.toString(16).padStart(16, "0"); + expect(hex).toBe( + formatUUID(readUUID(await reader(expr))).replaceAll("-", ""), + ); + }); + + it("decodes the nil UUID as [0, 0]", async () => { + const r = await reader("toUUID('00000000-0000-0000-0000-000000000000')"); + expect(readUUIDHiLo(r)).toEqual([0n, 0n]); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0') FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUUIDHiLo(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UVarint.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UVarint.test.ts new file mode 100644 index 000000000..f1a022783 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UVarint.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUVarint } from "../src/varint.js"; + +/** + * RowBinary prefixes every String with its length as a LEB128 unsigned varint. + * So a real, server-encoded varint of value N is just the leading bytes of + * `SELECT repeat('a', N) FORMAT RowBinary`, followed by N payload bytes. + * + * Edge cases worth covering are the byte-width boundaries of LEB128: + * 0 — empty, single 0x00 byte + * 127 / 128 — 1-byte max -> first 2-byte value + * 16383/16384 — 2-byte max -> first 3-byte value + */ +async function repeatReader(n: number): Promise { + return new RowBinaryState( + await query(`SELECT repeat('a', ${n}) FORMAT RowBinary`), + ); +} + +describe("readUVarint", () => { + it("decodes 0 (single 0x00 byte)", async () => { + const r = await repeatReader(0); + expect(readUVarint(r)).toBe(0); + expect(r.pos).toBe(r.buf.length - 0); + }); + + it("decodes 1", async () => { + const r = await repeatReader(1); + expect(readUVarint(r)).toBe(1); + expect(r.pos).toBe(r.buf.length - 1); + }); + + it("decodes 127 (1-byte max)", async () => { + const r = await repeatReader(127); + expect(readUVarint(r)).toBe(127); + expect(r.pos).toBe(r.buf.length - 127); + }); + + it("decodes 128 (first 2-byte value)", async () => { + const r = await repeatReader(128); + expect(readUVarint(r)).toBe(128); + expect(r.pos).toBe(r.buf.length - 128); + }); + + it("decodes 16383 (2-byte max)", async () => { + const r = await repeatReader(16383); + expect(readUVarint(r)).toBe(16383); + expect(r.pos).toBe(r.buf.length - 16383); + }); + + it("decodes 16384 (first 3-byte value)", async () => { + const r = await repeatReader(16384); + expect(readUVarint(r)).toBe(16384); + expect(r.pos).toBe(r.buf.length - 16384); + }); + + // The upper bound of what readUVarint can represent exactly: values past + // Number.MAX_SAFE_INTEGER (2^53 - 1) would lose precision (it returns a JS + // number, not a bigint). Such a length is far larger than any string we + // could SELECT, so the bytes are constructed directly rather than fetched. + it("decodes Number.MAX_SAFE_INTEGER (2^53 - 1)", () => { + // 53 bits set: seven full 7-bit groups (0xff) plus a 4-bit top group (0x0f). + const r = new RowBinaryState( + Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f]), + ); + expect(readUVarint(r)).toBe(Number.MAX_SAFE_INTEGER); + expect(r.pos).toBe(8); + }); + + // One past the safe ceiling: 2^53 (MAX_SAFE_INTEGER + 1). The top group's + // bit 4 (0x10) sets bit 53; everything below is zero. Must throw rather than + // return an imprecise number. + it("throws when a varint exceeds Number.MAX_SAFE_INTEGER", () => { + const r = new RowBinaryState( + Buffer.from([0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10]), + ); + expect(() => readUVarint(r)).toThrow(RangeError); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix of a multi-byte varint", () => { + // 16384 -> 3-byte LEB128 [0x80, 0x80, 0x01]; each shorter prefix ends on a + // byte with the continuation bit set and no following byte, so it must starve. + const full = Buffer.from([0x80, 0x80, 0x01]); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUVarint(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len}`).toBe(NeedMoreData); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Variant.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Variant.test.ts new file mode 100644 index 000000000..9a7bfbe02 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Variant.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readVariant } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readFloat64 } from "../src/floats.js"; +import { readUInt64, readUInt8 } from "../src/integers.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState( + await query( + `SELECT ${expr} SETTINGS allow_experimental_variant_type = 1 FORMAT RowBinary`, + ), + ); +} + +describe("readVariant", () => { + // Variant(UInt8, String) types sort to ["String", "UInt8"], so the readers + // are listed in that sorted order: String first (discriminant 0), UInt8 (1). + it("picks the alternative by sorted-order discriminant (UInt8 = 1)", async () => { + const r = await reader("CAST(42 AS Variant(UInt8, String))"); + expect(readVariant([readString, readUInt8])(r)).toBe(42); + }); + + it("picks the String alternative (discriminant 0)", async () => { + const r = await reader("CAST('hi' AS Variant(UInt8, String))"); + expect(readVariant([readString, readUInt8])(r)).toBe("hi"); + }); + + it("decodes NULL (discriminant 0xFF)", async () => { + const r = await reader("CAST(NULL AS Variant(UInt8, String))"); + expect(readVariant([readString, readUInt8])(r)).toBeNull(); + expect(r.pos).toBe(1); + }); + + // Three alternatives sort to [Float64, String, UInt64] -> discriminants 0,1,2. + it("handles three sorted alternatives", async () => { + const t = "Variant(Float64, String, UInt64)"; + + const a = await reader(`CAST(toUInt64(9) AS ${t})`); + expect(readVariant([readFloat64, readString, readUInt64])(a)).toBe(9n); // discriminant 2 + + const b = await reader(`CAST(toFloat64(1.5) AS ${t})`); + expect(readVariant([readFloat64, readString, readUInt64])(b)).toBe(1.5); // discriminant 0 + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST(42 AS Variant(UInt8, String)) SETTINGS allow_experimental_variant_type = 1 FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readVariant([readString, readUInt8])(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/advance.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/advance.test.ts new file mode 100644 index 000000000..97a99b98e --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/advance.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt64 } from "../src/integers.js"; +import { readString } from "../src/strings.js"; + +/** + * `advance` / `NeedMoreData` tests: the per-read "need more bytes" throw that is + * the foundation for streaming. A read that would cross the end of the buffer + * throws `NeedMoreData` instead of reading garbage, WITHOUT moving the cursor — + * so a driver can rewind to its last committed row and retry once more bytes + * arrive. (This is just the per-read throw; chunk reassembly and commit tracking + * are the driver's job, modelled here in the tests but not yet in the reader.) + * + * The data is real ClickHouse output; the truncation is simulated by viewing a + * prefix of the response buffer (`buf.subarray(0, avail)`), which is exactly + * what the reader treats as "all the bytes there are so far". + */ +describe("advance() and NeedMoreData", () => { + it("throws NeedMoreData when a fixed-width read crosses the end, leaving pos put", async () => { + const full = await query("SELECT toUInt64(1) FORMAT RowBinary"); // 8 bytes + const r = new RowBinaryState(full.subarray(0, 5)); // one byte short of nothing + let thrown: unknown; + try { + readUInt64(r); + } catch (e) { + thrown = e; + } + expect(thrown).toBe(NeedMoreData); + expect(r.pos).toBe(0); // cursor not advanced on a starved read + }); + + it("throws NeedMoreData when a String body is truncated past its length prefix", async () => { + // "hello" -> 1 varint length byte (0x05) + 5 bytes. Reveal length + 2 body + // bytes: the varint read succeeds, the body read starves. + const full = await query("SELECT 'hello' FORMAT RowBinary"); // 6 bytes + const r = new RowBinaryState(full.subarray(0, 3)); + let thrown: unknown; + try { + readString(r); + } catch (e) { + thrown = e; + } + expect(thrown).toBe(NeedMoreData); + }); + + it("a throw+restart driver reassembles every row from a chunked stream", async () => { + // (UInt64, String) rows of varying width, so chunk boundaries land mid-field + // and mid-row, exercising the throw on both the number and the string read. + const full = await query( + "SELECT number AS id, repeat('ab', number) AS s FROM numbers(20) FORMAT RowBinary", + ); + + const expected = Array.from({ length: 20 }, (_, i) => ({ + id: BigInt(i), + s: "ab".repeat(i), + })); + + // Drive the reader the way a streaming consumer would: reveal `chunk` more + // bytes whenever a read starves, and restart the row from the last commit. + for (const chunk of [1, 3, 7, 64, 4096]) { + const rows: Array<{ id: bigint; s: string }> = []; + let committed = 0; + let avail = 0; + while (committed < full.length) { + avail = Math.min(full.length, avail + chunk); + const r = new RowBinaryState(full.subarray(0, avail)); + r.pos = committed; + try { + while (r.pos < r.buf.length) { + const id = readUInt64(r); + const s = readString(r); + rows.push({ id, s }); // only reached once BOTH reads succeed + committed = r.pos; // commit the row boundary + } + } catch (e) { + if (e !== NeedMoreData) throw e; + // starved: loop, reveal more, retry from `committed` (no double-push, + // because the row is pushed only after a clean id+s read). + } + } + expect(rows, `chunk size ${chunk}`).toEqual(expected); + } + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/aggregateFunction.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/aggregateFunction.test.ts new file mode 100644 index 000000000..585c6c047 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/aggregateFunction.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readAggregateFunction } from "../src/aggregateFunction.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt64, readUInt8 } from "../src/integers.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +/** + * `AggregateFunction(func, T...)` holds an OPAQUE serialized aggregation STATE + * (what `-State` combinators produce). In RowBinary this state is written RAW, + * with **NO length prefix** and a layout that is entirely specific to `func` + * (and to the ClickHouse version). For example `sumState(UInt64)` is 8 bytes + * (the running sum), while `uniqState(...)` is a variable-length hash-set blob. + * + * Consequences for a generic parser: + * - It cannot be decoded generically — there is no schema in the bytes. + * - It cannot even be SKIPPED generically — there is no length to skip past; + * you must know `func`'s exact byte layout to find where it ends, otherwise + * every column after it in the row is misaligned. + * + * So there is NO generic reader. Two real options: + * + * 1. RECOMMENDED — finalize server-side, decode the concrete result type. + * Apply the `-Merge` combinator or `finalizeAggregation()` in SQL so the + * column becomes a normal value (`sum` -> `UInt64`, `uniq` -> `UInt64`, + * `avg` -> `Float64`, ...) and use the matching reader. Never ship raw + * `-State` columns to the client unless you intend to merge them later. + * + * 2. ESCAPE HATCH — known fixed layout only. A few functions' state IS just a + * value of a known type (e.g. `sumState(UInt64)` is literally that UInt64), + * so you may decode it as that type. This is fragile and version-specific; + * only do it when you truly know the internal layout. See below. + */ +describe("AggregateFunction (opaque state — finalize server-side)", () => { + it("RECOMMENDED: finalize with -Merge / finalizeAggregation, then decode normally", async () => { + // uniqMerge collapses the uniq state to a concrete UInt64 cardinality. + const merged = await reader( + "uniqMerge(s) FROM (SELECT uniqState(number) AS s FROM numbers(5))", + ); + expect(readUInt64(merged)).toBe(5n); + + // finalizeAggregation does the same inline without a GROUP BY context. + const finalized = await reader( + "finalizeAggregation(sumState(toUInt64(42)))", + ); + expect(readUInt64(finalized)).toBe(42n); // type is now plain UInt64 + }); + + it("ESCAPE HATCH: sumState(UInt64) state happens to be the raw UInt64 (fragile, layout-specific)", async () => { + // sumState's state is just the running sum, with NO length prefix: the next + // column begins immediately after the 8 sum bytes. We decode it as UInt64 + // only because we know this exact layout — do not generalize this. + const r = await reader("sumState(toUInt64(42)) AS a, toUInt8(255) AS b"); + expect(readUInt64(r)).toBe(42n); // the state, read as its known UInt64 shape + expect(r.pos).toBe(8); // no framing — column b starts right here + expect(readUInt8(r)).toBe(255); // proves the 8-byte state was exact + }); + + it("readAggregateFunction is a guard: it always throws (never decode opaque state)", () => { + const r = new RowBinaryState(Buffer.alloc(0)); + expect(() => readAggregateFunction(r)).toThrow(/opaque/i); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + // sumState(UInt64) is a raw 8-byte sum with no length prefix. + const full = await query( + "SELECT sumState(toUInt64(42)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUInt64(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/carts.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/carts.bench.ts new file mode 100644 index 000000000..1611c8f30 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/carts.bench.ts @@ -0,0 +1,47 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { + type CartRow, + readCartRow, + readCartRowFast, +} from "../src/examples/carts.js"; + +/** + * Benchmark: API-combinator `readCartRow` vs monomorphized `readCartRowFast` + * over the same large buffer. Nested generics — `Array(Tuple(...))` and + * `Array(Nullable(...))` — so the API version rebuilds nested closures per row; + * the inlined version flattens both levels. + */ +const N = 20_000; +const BUF = await query( + `SELECT toUInt32(number) AS cart_id, ` + + `arrayMap(x -> CAST(tuple(concat('s', toString(x)), toUInt16(x)) AS Tuple(sku String, qty UInt16)), range(number % 3)) AS items, ` + + `arrayMap(x -> CAST(if(x % 2 = 0, toInt32(x), NULL) AS Nullable(Int32)), range(number % 4)) AS discounts ` + + `FROM numbers(${N}) FORMAT RowBinary`, +); + +function decodeAll(read: Reader): CartRow[] { + const s = new RowBinaryState(BUF); + const out: CartRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +const norm = (rows: CartRow[]): string => JSON.stringify(rows); +{ + const a = decodeAll(readCartRow); + const b = decodeAll(readCartRowFast); + if (a.length !== N) + throw new Error(`carts: decoded ${a.length} rows, expected ${N}`); + if (norm(a) !== norm(b)) throw new Error("carts: API vs fast mismatch"); +} + +describe("example carts: API vs optimized", () => { + bench("API (combinators)", () => { + decodeAll(readCartRow); + }); + bench("optimized (monomorphized)", () => { + decodeAll(readCartRowFast); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/carts.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/carts.example.test.ts new file mode 100644 index 000000000..4bda2e4ae --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/carts.example.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { type CartRow, readCartRow } from "../src/examples/carts.js"; +import { readRows } from "../src/rows.js"; + +/** + * Runs the `carts` example end to end (nested generics): an Array of named + * Tuples and an Array of Nullables. Populated via `JSONEachRow`; the second row + * is fully empty so both arrays read as a single count byte. + */ +describe("example: carts (nested generics via JSONEachRow)", () => { + it("creates, populates, and reads back through readCartRow", async () => { + const t = "rb_example_carts"; + await query(`DROP TABLE IF EXISTS ${t}`); + await query( + `CREATE TABLE ${t} (` + + `cart_id UInt32, ` + + `items Array(Tuple(sku String, qty UInt16)), ` + + `discounts Array(Nullable(Int32))` + + `) ENGINE = Memory`, + ); + try { + const rows = [ + { + cart_id: 1, + items: [ + { sku: "A", qty: 2 }, + { sku: "B", qty: 1 }, + ], + discounts: [10, null, 5], + }, + { cart_id: 2, items: [], discounts: [] }, + ]; + await query( + `INSERT INTO ${t} FORMAT JSONEachRow\n` + + rows.map((r) => JSON.stringify(r)).join("\n"), + ); + + const r = new RowBinaryState( + await query( + `SELECT cart_id, items, discounts FROM ${t} ORDER BY cart_id FORMAT RowBinary`, + ), + ); + const out: CartRow[] = readRows(readCartRow)(r); + expect(out).toEqual([ + { + cartId: 1, + items: [ + { sku: "A", qty: 2 }, + { sku: "B", qty: 1 }, + ], + discounts: [10, null, 5], + }, + { cartId: 2, items: [], discounts: [] }, + ]); + expect(r.pos).toBe(r.buf.length); + } finally { + await query(`DROP TABLE IF EXISTS ${t}`); + } + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/clickhouse.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/clickhouse.ts new file mode 100644 index 000000000..16577b0ff --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/clickhouse.ts @@ -0,0 +1,39 @@ +/** + * The one shared test helper: run SQL against a live ClickHouse server over + * HTTP and return the raw response bytes. + * + * Unit tests query ClickHouse directly (no static fixtures) so the bytes under + * test are always exactly what the server produces. Pass a complete statement + * including the format, e.g. `SELECT toUInt8(255) FORMAT RowBinary`. + * + * Assumes a ClickHouse server is already running. Override the connection with + * env vars: + * CLICKHOUSE_URL (default http://localhost:8123) + * CLICKHOUSE_USER (default default) + * CLICKHOUSE_PASSWORD (default empty) + * + * Note: the suite hard-depends on a reachable server — with none running, every + * test errors rather than skips. Intentional for now; if a friendlier + * "no server -> skip with a clear message" behavior is wanted later, add it here. + */ +const URL_BASE = process.env.CLICKHOUSE_URL ?? "http://localhost:8123"; +const USER = process.env.CLICKHOUSE_USER ?? "default"; +const PASSWORD = process.env.CLICKHOUSE_PASSWORD ?? ""; + +export async function query(sql: string): Promise { + const res = await fetch(URL_BASE, { + method: "POST", + headers: { + "X-ClickHouse-User": USER, + "X-ClickHouse-Key": PASSWORD, + }, + body: sql, + }); + if (!res.ok) { + throw new Error( + `ClickHouse ${res.status} for query: ${sql}\n${await res.text()}`, + ); + } + // Buffer.from(ArrayBuffer) is a no-copy view over the response bytes. + return Buffer.from(await res.arrayBuffer()); +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/coalesceChunks.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/coalesceChunks.test.ts new file mode 100644 index 000000000..eca6ac13b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/coalesceChunks.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { readUInt64 } from "../src/integers.js"; +import { coalesceChunks, streamRowBatches } from "../src/stream.js"; +import { readString } from "../src/strings.js"; + +/** + * `coalesceChunks` merges a too-small chunk stream into chunks of at least + * `minSize` bytes (flushing early on `timeoutMs` or end-of-stream). It is a pure + * byte-level filter — no ClickHouse needed for most of it — composed in front of + * `streamRowBatches`. These tests pin both the size-based and timeout-based + * flush paths and prove the bytes survive the round-trip unchanged. + */ +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** A buffer holding the sequential bytes `[from, to)` (mod 256). */ +const seq = (from: number, to: number): Buffer => + Buffer.from(Array.from({ length: to - from }, (_, i) => (from + i) & 0xff)); + +async function collect(src: AsyncIterable): Promise { + const out: Buffer[] = []; + for await (const c of src) out.push(c); + return out; +} + +describe("coalesceChunks (debounce small chunks before streaming)", () => { + it("accumulates tiny chunks up to minSize, preserving byte order", async () => { + async function* tiny(): AsyncGenerator { + for (let i = 0; i < 20; i++) yield seq(i, i + 1); // 20 × 1 byte + } + // Long timeout: chunks arrive back-to-back, so only the size rule fires. + const out = await collect( + coalesceChunks(tiny(), { minSize: 5, timeoutMs: 10_000 }), + ); + expect(out.map((c) => c.length)).toEqual([5, 5, 5, 5]); + expect(Buffer.concat(out)).toEqual(seq(0, 20)); + }); + + it("passes a single already-large chunk straight through without copying", async () => { + const big = seq(0, 100); + async function* one(): AsyncGenerator { + yield big; + } + const out = await collect( + coalesceChunks(one(), { minSize: 16, timeoutMs: 10_000 }), + ); + expect(out).toHaveLength(1); + expect(out[0]).toBe(big); // same reference: no concat when one part suffices + }); + + it("flushes the remainder below minSize at end of stream", async () => { + async function* short(): AsyncGenerator { + yield seq(0, 3); + yield seq(3, 7); // 7 bytes total, never reaches minSize + } + const out = await collect( + coalesceChunks(short(), { minSize: 1000, timeoutMs: 10_000 }), + ); + expect(out).toHaveLength(1); + expect(out[0]).toEqual(seq(0, 7)); + }); + + it("flushes early on the timeout when data arrives in a trickle", async () => { + async function* trickle(): AsyncGenerator { + yield seq(0, 10); // below minSize + await delay(80); // > timeoutMs, so the buffered 10 bytes flush first + yield seq(10, 20); + } + const out = await collect( + coalesceChunks(trickle(), { minSize: 1000, timeoutMs: 20 }), + ); + // First 10 bytes flushed by the timer, last 10 by end-of-stream. + expect(out).toHaveLength(2); + expect(out[0]).toEqual(seq(0, 10)); + expect(out[1]).toEqual(seq(10, 20)); + }); + + it("anchors the deadline at first byte — a steady trickle can't defer forever", async () => { + async function* drip(): AsyncGenerator { + // Five 1-byte chunks, each ~15ms apart; deadline is 25ms from the FIRST. + for (let i = 0; i < 5; i++) { + yield seq(i, i + 1); + await delay(15); + } + } + const out = await collect( + coalesceChunks(drip(), { minSize: 1000, timeoutMs: 25 }), + ); + // The flush must land mid-trickle (not swallow all five into one), proving + // the deadline is anchored and not reset by each arriving chunk. + expect(out.length).toBeGreaterThan(1); + expect(Buffer.concat(out)).toEqual(seq(0, 5)); + }); + + it("releases the source when the consumer breaks out early", async () => { + let returned = false; + async function* infinite(): AsyncGenerator { + try { + for (let i = 0; ; i++) yield seq(i, i + 1); + } finally { + returned = true; // source cleanup ran + } + } + // minSize 1 → the first chunk flushes immediately; then we bail. + for await (const _ of coalesceChunks(infinite(), { + minSize: 1, + timeoutMs: 10_000, + })) { + break; + } + expect(returned).toBe(true); + }); + + it("composes in front of streamRowBatches: 1-byte chunks decode correctly", async () => { + type Row = { id: bigint; s: string }; + const readRow = (s: RowBinaryState): Row => ({ + id: readUInt64(s), + s: readString(s), + }); + const full = await query( + "SELECT number AS id, repeat('q', number % 7) AS s FROM numbers(40) FORMAT RowBinary", + ); + const expected: Row[] = Array.from({ length: 40 }, (_, i) => ({ + id: BigInt(i), + s: "q".repeat(i % 7), + })); + + async function* oneByteAtATime(): AsyncGenerator { + for (let i = 0; i < full.length; i++) yield full.subarray(i, i + 1); + } + + const rows: Row[] = []; + for await (const batch of streamRowBatches( + coalesceChunks(oneByteAtATime(), { minSize: 32, timeoutMs: 10_000 }), + readRow, + )) { + rows.push(...batch); + } + expect(rows).toEqual(expected); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/columnar.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/columnar.test.ts new file mode 100644 index 000000000..d04d5a53b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/columnar.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { streamSensorColumns } from "../src/columnar.js"; + +/** + * Eval for the streaming columnar decoder (`streamSensorColumns`). The example + * schema — `sensor_id UInt32, ts DateTime64(3), value Float64, quality Float32, + * status UInt8` — is a 25-byte fixed-width row, generated here by the live + * ClickHouse server so we decode against its OWN RowBinary bytes. + * + * Three things under test: + * 1. correctness of every column, checked against a reference decode of the + * same buffer; + * 2. the streaming contract — chunk boundaries that fall mid-row must not + * corrupt or drop rows, and a truncated stream must throw; + * 3. THE COLUMNAR INVARIANT THAT MATTERS HERE: the Int64 (`ts`) column is filled + * by copying two 32-bit words, NOT via `getBigInt64`, so no bigint is + * allocated per row on the decode path. We prove it by spying on + * `DataView.prototype.getBigInt64` and asserting it is never called while + * decoding. + */ + +const STRIDE = 25; +const N = 1000; + +// Deterministic, exactly representable values per column. `ts` is a DateTime64(3), +// whose wire form is Int64 millisecond ticks — here (1700000000 + i) * 1000. +const SELECT = + `SELECT toUInt32(number) AS sensor_id, ` + + `toDateTime64(1700000000 + number, 3) AS ts, ` + + `toFloat64(number) / 2 AS value, ` + + `toFloat32(number) AS quality, ` + + `toUInt8(number % 256) AS status ` + + `FROM numbers(${N})`; + +const BUF = await query(`${SELECT} FORMAT RowBinary`); + +/** Reference decode of the whole buffer — the test is free to allocate bigints. */ +function reference(buf: Buffer) { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); + const sensor_id: number[] = []; + const ts: bigint[] = []; + const value: number[] = []; + const quality: number[] = []; + const status: number[] = []; + for (let o = 0; o + STRIDE <= buf.length; o += STRIDE) { + sensor_id.push(view.getUint32(o, true)); + ts.push(view.getBigInt64(o + 4, true)); + value.push(view.getFloat64(o + 12, true)); + quality.push(view.getFloat32(o + 20, true)); + status.push(buf[o + 24]!); + } + return { sensor_id, ts, value, quality, status }; +} + +/** Yield `buf` in chunks of the given repeating sizes (deliberately mid-row). */ +async function* chunked( + buf: Buffer, + sizes: number[], +): AsyncGenerator { + let o = 0; + let k = 0; + while (o < buf.length) { + const len = sizes[k++ % sizes.length]!; + yield buf.subarray(o, Math.min(o + len, buf.length)); + o += len; + } +} + +/** Drain the columnar stream into flat per-column arrays. */ +async function collect(chunks: AsyncIterable) { + const sensor_id: number[] = []; + const ts: bigint[] = []; + const value: number[] = []; + const quality: number[] = []; + const status: number[] = []; + let batches = 0; + for await (const b of streamSensorColumns(chunks)) { + batches++; + for (let i = 0; i < b.rows; i++) { + sensor_id.push(b.columns.sensor_id[i]!); + ts.push(b.columns.ts[i]!); + value.push(b.columns.value[i]!); + quality.push(b.columns.quality[i]!); + status.push(b.columns.status[i]!); + } + } + return { sensor_id, ts, value, quality, status, batches }; +} + +describe("streamSensorColumns", () => { + it("matches a reference decode of the live RowBinary buffer", async () => { + const ref = reference(BUF); + // One chunk = whole buffer. + const got = await collect(chunked(BUF, [BUF.length])); + expect(got.sensor_id).toEqual(ref.sensor_id); + expect(got.ts).toEqual(ref.ts); + expect(got.value).toEqual(ref.value); + expect(got.quality).toEqual(ref.quality); + expect(got.status).toEqual(ref.status); + expect(got.sensor_id.length).toBe(N); + }); + + it("survives chunk boundaries that split rows mid-field", async () => { + const ref = reference(BUF); + // Sizes coprime-ish to STRIDE (25) so boundaries land inside every field. + const got = await collect(chunked(BUF, [1, 7, 13, 100, 3])); + expect(got.batches).toBeGreaterThan(1); + expect(got.sensor_id).toEqual(ref.sensor_id); + expect(got.ts).toEqual(ref.ts); + expect(got.value).toEqual(ref.value); + expect(got.quality).toEqual(ref.quality); + expect(got.status).toEqual(ref.status); + }); + + it("throws on a stream truncated mid-row", async () => { + const truncated = BUF.subarray(0, BUF.length - 3); + await expect(collect(chunked(truncated, [256]))).rejects.toThrow(/mid-row/); + }); + + describe("Int64 column is not transferred through a bigint allocation", () => { + const original = DataView.prototype.getBigInt64; + afterEach(() => { + DataView.prototype.getBigInt64 = original; + }); + + it("never calls getBigInt64 while decoding", async () => { + let calls = 0; + // Spy that ALSO returns a correct value, so if the decoder regressed to + // using it the column would still be right — the test would fail only on + // the call count, pinpointing the allocation, not on a value mismatch. + DataView.prototype.getBigInt64 = function ( + this: DataView, + byteOffset: number, + littleEndian?: boolean, + ): bigint { + calls++; + return original.call(this, byteOffset, littleEndian); + }; + + const got = await collect(chunked(BUF, [1, 7, 13, 100, 3])); + + expect(calls).toBe(0); + // sanity: ts still decoded correctly via the two-word copy + expect(got.ts.length).toBe(N); + expect(got.ts[0]).toBe(1700000000n * 1000n); + expect(got.ts[N - 1]).toBe(BigInt(1700000000 + N - 1) * 1000n); + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/combinations.generated.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/combinations.generated.test.ts new file mode 100644 index 000000000..c5b939d92 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/combinations.generated.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { + readArray, + readMap, + readNullable, + readTuple, + readVariant, +} from "../src/composite.js"; +import { NeedMoreData, type Reader, RowBinaryState } from "../src/core.js"; +import { readInt32, readUInt8 } from "../src/integers.js"; +import { readString } from "../src/strings.js"; + +/** + * GENERATED type-combination coverage — the systematic companion to the curated, + * hand-written cases in `framing-nested.test.ts`. + * + * The bug surface for composite readers is boundary DESYNC: an inner reader + * miscounts its bytes and silently shifts everything after it. The space of + * nestings (every combinator wrapping every inner, at every depth, with every + * edge payload) is exponential, so we don't take the Cartesian product. + * + * Instead, following the "one-leaf-at-a-time" strategy from + * type-predicate-generator issue #18: build small case generators that compose + * like the readers do, then vary ONE position at a time — each combinator is + * shown wrapping each *category* of inner (fixed-width / variable-length / + * nullable / NULL / self-describing / composite) plus the bug-prone edge + * payloads (empty string, zero, empty array, NULL-flag-only). That turns N^M into + * ~combinators x categories while still exercising each edge IN a nesting + * context. ClickHouse is the byte oracle — a case only declares its SQL + * expression and expected JS value; the server produces the bytes. + * + * Each case is checked two ways: + * 1. FRAMED `i32(LEAD), X, i32(TRAIL)` — reading TRAIL back is only possible if + * X consumed EXACTLY its bytes (same harness as framing-nested.test.ts). + * 2. TRUNCATION SWEEP — every incomplete prefix `0 .. full.length-1` must throw + * `NeedMoreData` (generalizes Array.test.ts's prefix sweep to the whole + * matrix, covering the streaming/`advance()` half of the bug surface). + */ + +const LEAD = 123456789; +const TRAIL = 987654321; + +const SETTINGS = [ + "enable_time_time64_type = 1", + "allow_experimental_variant_type = 1", + "allow_suspicious_variant_types = 1", + "allow_experimental_dynamic_type = 1", + "allow_experimental_json_type = 1", + "enable_json_type = 1", + "allow_experimental_qbit_type = 1", + "allow_suspicious_low_cardinality_types = 1", +].join(", "); + +/** + * One self-checking node of a generated case. `expr` is a fully type-annotated + * ClickHouse expression (every level casts, so server-side type inference is + * never ambiguous); `read` decodes it; `expected` is the decoded JS value. + * The flags gate which outer combinators may legally wrap this node. + */ +type Gen = { + type: string; + expr: string; + read: Reader; + expected: unknown; + label: string; + /** plain scalar — the only thing `Nullable(...)` is allowed to wrap */ + leaf: boolean; + /** already a `Nullable` — can't be re-wrapped by Nullable or put in a Variant */ + nullableRoot: boolean; + /** already a `Variant` — can't be an alternative of another Variant */ + variantRoot: boolean; +}; + +// ---- leaf generators: the white-box edge set, not arbitrary values ---------- + +const u8 = (n: number): Gen => ({ + type: "UInt8", + expr: `${n}::UInt8`, + read: readUInt8, + expected: n, + label: `u8=${n}`, + leaf: true, + nullableRoot: false, + variantRoot: false, +}); + +const str = (s: string): Gen => ({ + type: "String", + expr: `'${s}'::String`, + read: readString, + expected: s, + label: s === "" ? "str=''" : `str='${s}'`, + leaf: true, + nullableRoot: false, + variantRoot: false, +}); + +// ---- combinator generators: compose like the readers do --------------------- + +const nullablePresent = (inner: Gen): Gen => ({ + type: `Nullable(${inner.type})`, + expr: `${inner.expr}::Nullable(${inner.type})`, + read: readNullable(inner.read), + expected: inner.expected, + label: `Nullable(${inner.label})`, + leaf: false, + nullableRoot: true, + variantRoot: false, +}); + +const nullValue = (inner: Gen): Gen => ({ + type: `Nullable(${inner.type})`, + expr: `NULL::Nullable(${inner.type})`, + read: readNullable(inner.read), + expected: null, + label: `Nullable(${inner.label})=NULL`, + leaf: false, + nullableRoot: true, + variantRoot: false, +}); + +const array = (inner: Gen, n: number): Gen => { + const elems = Array.from({ length: n }, () => inner.expr); + return { + type: `Array(${inner.type})`, + expr: `[${elems.join(", ")}]::Array(${inner.type})`, + read: readArray(inner.read), + expected: Array.from({ length: n }, () => inner.expected), + label: `Array[len=${n}](${inner.label})`, + leaf: false, + nullableRoot: false, + variantRoot: false, + }; +}; + +const tuple2 = (a: Gen, b: Gen): Gen => ({ + type: `Tuple(${a.type}, ${b.type})`, + expr: `(${a.expr}, ${b.expr})::Tuple(${a.type}, ${b.type})`, + read: readTuple([a.read, b.read]), + expected: [a.expected, b.expected], + label: `Tuple(${a.label}, ${b.label})`, + leaf: false, + nullableRoot: false, + variantRoot: false, +}); + +const map = (key: Gen, value: Gen): Gen => ({ + type: `Map(${key.type}, ${value.type})`, + expr: `map(${key.expr}, ${value.expr})::Map(${key.type}, ${value.type})`, + read: readMap(key.read, value.read), + expected: new Map([[key.expected, value.expected]]), + label: `Map(${key.label} => ${value.label})`, + leaf: false, + nullableRoot: false, + variantRoot: false, +}); + +/** + * Hold `chosen` inside a `Variant(chosen, marker)`. ClickHouse sorts alternatives + * by type NAME and the discriminant indexes that sorted order, so the reader list + * must be sorted the same way (see `readVariant`'s gotcha). + */ +const variantHolding = (chosen: Gen, marker: Gen): Gen => { + const sorted = [chosen, marker].sort((x, y) => (x.type < y.type ? -1 : 1)); + const type = `Variant(${sorted.map((g) => g.type).join(", ")})`; + return { + type, + expr: `${chosen.expr}::${type}`, + read: readVariant(sorted.map((g) => g.read)), + expected: chosen.expected, + label: `Variant<${chosen.label}>`, + leaf: false, + nullableRoot: false, + variantRoot: true, + }; +}; + +// ---- the matrix: combinators (rows) x inner categories (columns) ------------ + +const INNERS: Gen[] = [ + u8(1), // fixed-width + u8(0), // edge: zero + str("hi"), // variable-length + str(""), // edge: empty string + nullablePresent(u8(1)), // nullable, present + nullValue(u8(1)), // edge: NULL flag only + variantHolding(str("hi"), u8(7)), // self-describing + array(u8(1), 2), // composite -> drives depth-2 nesting +]; + +// A distinct-typed Variant marker so the two alternatives never collide. +const markerFor = (inner: Gen): Gen => + inner.type === "String" ? u8(7) : str("zz"); + +const COMBINATORS: Array<{ + label: string; + accepts: (inner: Gen) => boolean; + build: (inner: Gen) => Gen; +}> = [ + { + label: "Array(len=0)", // edge: count byte only, inner never read + accepts: () => true, + build: (inner) => array(inner, 0), + }, + { + label: "Array(len=2)", + accepts: () => true, + build: (inner) => array(inner, 2), + }, + { + label: "Nullable", // only legal around a plain scalar + accepts: (inner) => inner.leaf, + build: (inner) => nullablePresent(inner), + }, + { + label: "Tuple2", // inner adjacent to a fixed sibling + accepts: () => true, + build: (inner) => tuple2(inner, u8(1)), + }, + { + label: "Map(String,_)", + accepts: () => true, + build: (inner) => map(str("k"), inner), + }, + { + label: "Variant", // can't wrap a Nullable or another Variant + accepts: (inner) => !inner.nullableRoot && !inner.variantRoot, + build: (inner) => variantHolding(inner, markerFor(inner)), + }, +]; + +const cases: Gen[] = []; +const skipped: string[] = []; +for (const comb of COMBINATORS) { + for (const inner of INNERS) { + if (comb.accepts(inner)) { + cases.push({ + ...comb.build(inner), + label: `${comb.label} / ${inner.label}`, + }); + } else { + skipped.push(`${comb.label} / ${inner.label}`); + } + } +} + +async function framedBytes(expr: string): Promise { + const sql = + `SELECT toInt32(${LEAD}) AS a, ${expr} AS x, toInt32(${TRAIL}) AS b` + + ` SETTINGS ${SETTINGS} FORMAT RowBinary`; + return query(sql); +} + +describe("type combinations (generated, one-leaf-at-a-time)", () => { + for (const c of cases) { + it(c.label, async () => { + const full = await framedBytes(c.expr); + + // 1. framed: decodes correctly AND consumes exactly its bytes. + const r = new RowBinaryState(full); + expect(readInt32(r)).toBe(LEAD); + expect(c.read(r)).toEqual(c.expected); + expect(readInt32(r), `${c.label}: x over/under-read`).toBe(TRAIL); + + // 2. truncation sweep: every incomplete prefix must starve, never desync. + for (let len = 0; len < full.length; len++) { + const p = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readInt32(p); + c.read(p); + readInt32(p); + } catch (e) { + thrown = e; + } + expect(thrown, `${c.label}: prefix ${len}/${full.length}`).toBe( + NeedMoreData, + ); + } + }); + } + + // No silent caps: lock the skip list so an accidental new gap fails the test. + it("skips only the type-system-illegal combinations", () => { + expect(skipped.sort()).toEqual( + [ + "Nullable / Array[len=2](u8=1)", + "Nullable / Nullable(u8=1)", + "Nullable / Nullable(u8=1)=NULL", + "Nullable / Variant", + "Variant / Nullable(u8=1)", + "Variant / Nullable(u8=1)=NULL", + "Variant / Variant", + ].sort(), + ); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/events.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/events.bench.ts new file mode 100644 index 000000000..8a285e2a8 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/events.bench.ts @@ -0,0 +1,48 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { + type EventRow, + readEventRow, + readEventRowFast, +} from "../src/examples/events.js"; + +/** + * Benchmark: the API-combinator `readEventRow` vs the inlined `readEventRowFast`, + * both decoding the SAME large `FORMAT RowBinary` buffer (N rows from numbers()). + * Each `bench` op decodes the whole buffer, so the number is rows/iteration; the + * ratio between the two contenders is the takeaway. Equivalence is checked once + * before timing — a faster wrong answer is worthless. + */ +const N = 20_000; +const BUF = await query( + `SELECT toUInt64(number) AS id, concat('name', toString(number)) AS name, ` + + `toDateTime('2021-01-01 00:00:00', 'UTC') + number AS ts ` + + `FROM numbers(${N}) FORMAT RowBinary`, +); + +function decodeAll(read: Reader): EventRow[] { + const s = new RowBinaryState(BUF); + const out: EventRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +const norm = (rows: EventRow[]): string => + JSON.stringify(rows, (_k, v) => (typeof v === "bigint" ? `${v}n` : v)); +{ + const a = decodeAll(readEventRow); + const b = decodeAll(readEventRowFast); + if (a.length !== N) + throw new Error(`events: decoded ${a.length} rows, expected ${N}`); + if (norm(a) !== norm(b)) throw new Error("events: API vs fast mismatch"); +} + +describe("example events: API vs optimized", () => { + bench("API (combinators)", () => { + decodeAll(readEventRow); + }); + bench("optimized (inlined)", () => { + decodeAll(readEventRowFast); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/events.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/events.example.test.ts new file mode 100644 index 000000000..8e62e53ac --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/events.example.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { type EventRow, readEventRow } from "../src/examples/events.js"; +import { readRows } from "../src/rows.js"; + +/** + * Runs the `events` example end to end: CREATE a table, populate it (here via + * `JSONEachRow` — an INSERT carries its rows in the same HTTP body after the + * FORMAT clause), SELECT it back `FORMAT RowBinary`, and decode with the reader + * imported from `src/examples/events.ts`. ENGINE = Memory + a finally-drop keeps + * re-runs clean; the SELECT's ORDER BY gives a stable order to assert against. + */ +describe("example: events (scalars via JSONEachRow)", () => { + it("creates, populates, and reads back through readEventRow", async () => { + const t = "rb_example_events"; + await query(`DROP TABLE IF EXISTS ${t}`); + await query( + `CREATE TABLE ${t} (id UInt64, name String, ts DateTime('UTC')) ENGINE = Memory`, + ); + try { + const rows = [ + { id: 1, name: "alpha", ts: "2021-01-01 00:00:00" }, + { id: 2, name: "bravo", ts: "2021-06-15 12:30:00" }, + { id: 3, name: "", ts: "1970-01-01 00:00:00" }, + ]; + await query( + `INSERT INTO ${t} FORMAT JSONEachRow\n` + + rows.map((r) => JSON.stringify(r)).join("\n"), + ); + + const r = new RowBinaryState( + await query( + `SELECT id, name, ts FROM ${t} ORDER BY id FORMAT RowBinary`, + ), + ); + const out: EventRow[] = readRows(readEventRow)(r); + expect(out).toEqual([ + { id: 1n, name: "alpha", ts: "2021-01-01T00:00:00.000Z" }, + { id: 2n, name: "bravo", ts: "2021-06-15T12:30:00.000Z" }, + { id: 3n, name: "", ts: "1970-01-01T00:00:00.000Z" }, + ]); + expect(r.pos).toBe(r.buf.length); + } finally { + await query(`DROP TABLE IF EXISTS ${t}`); + } + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/framing-interleaved.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/framing-interleaved.test.ts new file mode 100644 index 000000000..7e910b3c9 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/framing-interleaved.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { + readArray, + readMap, + readNullable, + readTuple, + readVariant, +} from "../src/composite.js"; +import { RowBinaryState } from "../src/core.js"; +import { readDynamic } from "../src/dynamic.js"; +import { readInt32, readUInt8 } from "../src/integers.js"; +import { readJSON } from "../src/json.js"; +import { readString } from "../src/strings.js"; + +/** + * Interleaving framing tests: TWO variable-length / self-describing columns are + * placed adjacent between the Int32 sentinels — `i32(LEAD), X, Y, i32(TRAIL)`. + * + * The point is the X→Y boundary: there is NO sentinel between them, so if X's + * reader stops one byte early or late, Y decodes from the wrong offset (wrong + * value) AND the trailing sentinel is wrong too. Pairing two variadic types + * (Array, Map, Tuple, Nullable, Variant, Dynamic, JSON) — especially two + * self-describing ones back-to-back, and 1-byte NULL values next to them — is + * the case most likely to expose an off-by-one in a buggy reader. + */ +const LEAD = 123456789; +const TRAIL = 987654321; + +const SETTINGS = [ + "enable_time_time64_type = 1", + "allow_experimental_variant_type = 1", + "allow_suspicious_variant_types = 1", + "allow_experimental_dynamic_type = 1", + "allow_experimental_json_type = 1", + "enable_json_type = 1", + "allow_experimental_qbit_type = 1", + "allow_suspicious_low_cardinality_types = 1", +].join(", "); + +/** Build `i32(LEAD), X, Y, i32(TRAIL)` and return a reader over the bytes. */ +async function framed(exprX: string, exprY: string): Promise { + const sql = + `SELECT toInt32(${LEAD}) AS a, ${exprX} AS x, ${exprY} AS y,` + + ` toInt32(${TRAIL}) AS b SETTINGS ${SETTINGS} FORMAT RowBinary`; + return new RowBinaryState(await query(sql)); +} + +// Inner-reader shorthands, as in framing-nested.test.ts. +const u8 = readUInt8; +const str = readString; +// Variant(UInt8, String) sorts to [String(0), UInt8(1)]. +const variantU8Str = readVariant([readString, readUInt8]); + +describe("framing (interleaved): i32, X, Y, i32 — the X→Y boundary must be exact", () => { + describe("X = Array(UInt8)", () => { + it("Array, Array", async () => { + const r = await framed("[1, 2, 3]::Array(UInt8)", "[4, 5]::Array(UInt8)"); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(u8)(r)).toEqual([1, 2, 3]); + expect(readArray(u8)(r)).toEqual([4, 5]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array, Map", async () => { + const r = await framed( + "[1, 2, 3]::Array(UInt8)", + "map('a', 1, 'b', 2)::Map(String, UInt8)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(u8)(r)).toEqual([1, 2, 3]); + expect(readMap(str, u8)(r)).toEqual( + new Map([ + ["a", 1], + ["b", 2], + ]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array, Variant", async () => { + const r = await framed( + "[1, 2, 3]::Array(UInt8)", + "'hi'::Variant(UInt8, String)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(u8)(r)).toEqual([1, 2, 3]); + expect(readVariant([readString, readUInt8])(r)).toBe("hi"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array, Dynamic", async () => { + const r = await framed("[1, 2, 3]::Array(UInt8)", "toInt32(7)::Dynamic"); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(u8)(r)).toEqual([1, 2, 3]); + expect(readDynamic(r)).toBe(7); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array, JSON", async () => { + const r = await framed("[1, 2, 3]::Array(UInt8)", `'{"a":1}'::JSON`); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(u8)(r)).toEqual([1, 2, 3]); + expect(readJSON(r)).toEqual(new Map([["a", 1n]])); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array, Nullable (NULL)", async () => { + const r = await framed( + "[1, 2, 3]::Array(UInt8)", + "CAST(NULL AS Nullable(String))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(u8)(r)).toEqual([1, 2, 3]); + expect(readNullable(str)(r)).toBeNull(); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("empty Array, Array — the empty array is a lone count byte", async () => { + const r = await framed("[]::Array(UInt8)", "[9]::Array(UInt8)"); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(u8)(r)).toEqual([]); + expect(readArray(u8)(r)).toEqual([9]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("X = Map(String, UInt8)", () => { + it("Map, Array", async () => { + const r = await framed( + "map('a', 1)::Map(String, UInt8)", + "[7, 8]::Array(UInt8)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readMap(str, u8)(r)).toEqual(new Map([["a", 1]])); + expect(readArray(u8)(r)).toEqual([7, 8]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Map, Variant", async () => { + const r = await framed( + "map('a', 1)::Map(String, UInt8)", + "42::Variant(UInt8, String)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readMap(str, u8)(r)).toEqual(new Map([["a", 1]])); + expect(variantU8Str(r)).toBe(42); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Map, Dynamic", async () => { + const r = await framed( + "map('a', 1)::Map(String, UInt8)", + "'hi'::Dynamic", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readMap(str, u8)(r)).toEqual(new Map([["a", 1]])); + expect(readDynamic(r)).toBe("hi"); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("X = Tuple", () => { + it("Tuple, Tuple", async () => { + const r = await framed( + "(1, 'x')::Tuple(UInt8, String)", + "(2, 'y')::Tuple(UInt8, String)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([u8, str])(r)).toEqual([1, "x"]); + expect(readTuple([u8, str])(r)).toEqual([2, "y"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple, Variant", async () => { + const r = await framed( + "(1, 'x')::Tuple(UInt8, String)", + "'z'::Variant(UInt8, String)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([u8, str])(r)).toEqual([1, "x"]); + expect(variantU8Str(r)).toBe("z"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple, Dynamic", async () => { + const r = await framed( + "(1, 'x')::Tuple(UInt8, String)", + "toInt32(9)::Dynamic", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([u8, str])(r)).toEqual([1, "x"]); + expect(readDynamic(r)).toBe(9); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("X = Variant", () => { + it("Variant, Variant", async () => { + const r = await framed( + "42::Variant(UInt8, String)", + "'hi'::Variant(UInt8, String)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(variantU8Str(r)).toBe(42); + expect(variantU8Str(r)).toBe("hi"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Variant (NULL), Variant — the NULL is a lone discriminant byte", async () => { + const r = await framed( + "NULL::Variant(UInt8, String)", + "7::Variant(UInt8, String)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(variantU8Str(r)).toBeNull(); + expect(variantU8Str(r)).toBe(7); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Variant, Dynamic", async () => { + const r = await framed( + "'hi'::Variant(UInt8, String)", + "toInt32(7)::Dynamic", + ); + expect(readInt32(r)).toBe(LEAD); + expect(variantU8Str(r)).toBe("hi"); + expect(readDynamic(r)).toBe(7); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Variant, Array", async () => { + const r = await framed( + "42::Variant(UInt8, String)", + "[1, 2]::Array(UInt8)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(variantU8Str(r)).toBe(42); + expect(readArray(u8)(r)).toEqual([1, 2]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("X = Dynamic", () => { + it("Dynamic, Dynamic", async () => { + const r = await framed("toInt32(7)::Dynamic", "'hi'::Dynamic"); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toBe(7); + expect(readDynamic(r)).toBe("hi"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic (NULL), Dynamic — the NULL is a lone Nothing tag", async () => { + const r = await framed("NULL::Dynamic", "toInt32(7)::Dynamic"); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toBeNull(); + expect(readDynamic(r)).toBe(7); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic, Variant", async () => { + const r = await framed( + "toInt32(7)::Dynamic", + "'hi'::Variant(UInt8, String)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toBe(7); + expect(variantU8Str(r)).toBe("hi"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic, JSON", async () => { + const r = await framed("toInt32(7)::Dynamic", `'{"a":1}'::JSON`); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toBe(7); + expect(readJSON(r)).toEqual(new Map([["a", 1n]])); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic (Array), Array", async () => { + const r = await framed( + "[1, 2, 3]::Array(UInt8)::Dynamic", + "[4, 5]::Array(UInt8)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toEqual([1, 2, 3]); + expect(readArray(u8)(r)).toEqual([4, 5]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("X = JSON", () => { + it("JSON, JSON", async () => { + const r = await framed(`'{"a":1}'::JSON`, `'{"b":2}'::JSON`); + expect(readInt32(r)).toBe(LEAD); + expect(readJSON(r)).toEqual(new Map([["a", 1n]])); + expect(readJSON(r)).toEqual(new Map([["b", 2n]])); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("JSON, Dynamic", async () => { + const r = await framed(`'{"a":1}'::JSON`, "toInt32(7)::Dynamic"); + expect(readInt32(r)).toBe(LEAD); + expect(readJSON(r)).toEqual(new Map([["a", 1n]])); + expect(readDynamic(r)).toBe(7); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("JSON, Array", async () => { + const r = await framed(`'{"a":1}'::JSON`, "[1, 2]::Array(UInt8)"); + expect(readInt32(r)).toBe(LEAD); + expect(readJSON(r)).toEqual(new Map([["a", 1n]])); + expect(readArray(u8)(r)).toEqual([1, 2]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("X = Nullable (1-byte boundaries)", () => { + it("Nullable (NULL), Nullable (value)", async () => { + const r = await framed( + "CAST(NULL AS Nullable(String))", + "CAST('v' AS Nullable(String))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readNullable(str)(r)).toBeNull(); + expect(readNullable(str)(r)).toBe("v"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Nullable (value), Variant", async () => { + const r = await framed( + "CAST('v' AS Nullable(String))", + "42::Variant(UInt8, String)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readNullable(str)(r)).toBe("v"); + expect(variantU8Str(r)).toBe(42); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Nullable (NULL), Dynamic", async () => { + const r = await framed( + "CAST(NULL AS Nullable(String))", + "toInt32(7)::Dynamic", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readNullable(str)(r)).toBeNull(); + expect(readDynamic(r)).toBe(7); + expect(readInt32(r)).toBe(TRAIL); + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/framing-nested.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/framing-nested.test.ts new file mode 100644 index 000000000..364ea25b1 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/framing-nested.test.ts @@ -0,0 +1,344 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { + readArray, + readMap, + readNullable, + readTuple, + readVariant, +} from "../src/composite.js"; +import { RowBinaryState } from "../src/core.js"; +import { readDynamic } from "../src/dynamic.js"; +import { readInt32, readUInt8 } from "../src/integers.js"; +import { readJSON } from "../src/json.js"; +import { readString } from "../src/strings.js"; + +/** + * Framing tests for NESTED self-describing / variable-length types — the place + * where a buggy reader is most likely to desync. Each value combines two types + * that carry an internal type description or a variable length (`Dynamic`, + * `Variant`, `JSON`, `Array`, `Map`, `Tuple`, `Nullable`), so the boundary + * between inner readers is "blurry": a reader that miscounts one element's bytes + * silently shifts everything after it. + * + * Same harness as framing.test.ts: the value sits as the MIDDLE column between + * two distinct Int32 sentinels `i32(LEAD), X, i32(TRAIL)`. Reading TRAIL back + * correctly is only possible if X consumed EXACTLY its bytes. The adjacency + * cases (Tuple of two variable things, NULL/empty inners) are the sharpest. + */ +const LEAD = 123456789; +const TRAIL = 987654321; + +// Every experimental / suspicious flag on, as in framing.test.ts. +const SETTINGS = [ + "enable_time_time64_type = 1", + "allow_experimental_variant_type = 1", + "allow_suspicious_variant_types = 1", + "allow_experimental_dynamic_type = 1", + "allow_experimental_json_type = 1", + "enable_json_type = 1", + "allow_experimental_qbit_type = 1", + "allow_suspicious_low_cardinality_types = 1", +].join(", "); + +async function framed(expr: string): Promise { + const sql = + `SELECT toInt32(${LEAD}) AS a, ${expr} AS x, toInt32(${TRAIL}) AS b` + + ` SETTINGS ${SETTINGS} FORMAT RowBinary`; + return new RowBinaryState(await query(sql)); +} + +// Shared inner readers, written once so the nesting reads cleanly below. +const u8 = readUInt8; +const str = readString; +// Variant(UInt8, String) sorts to [String(0), UInt8(1)]. +const variantU8Str = readVariant([readString, readUInt8]); + +describe("framing (nested): two self-describing / variable types — boundaries stay exact", () => { + describe("Array of a variable / self-describing inner", () => { + it("Array(Variant(UInt8, String)) with a NULL element", async () => { + const r = await framed( + "[42::Variant(UInt8, String), 'hi'::Variant(UInt8, String), NULL::Variant(UInt8, String)]::Array(Variant(UInt8, String))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(variantU8Str)(r)).toEqual([42, "hi", null]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array(Dynamic)", async () => { + const r = await framed("['x'::Dynamic, 'y'::Dynamic]::Array(Dynamic)"); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(readDynamic)(r)).toEqual(["x", "y"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array(Nullable(String)) with a hole", async () => { + const r = await framed("['a', NULL, 'b']::Array(Nullable(String))"); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(readNullable(str))(r)).toEqual(["a", null, "b"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array(Array(String)) — variable-length inner arrays", async () => { + const r = await framed("[['a', 'b'], ['c']]::Array(Array(String))"); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(readArray(str))(r)).toEqual([["a", "b"], ["c"]]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array(Map(String, UInt8))", async () => { + const r = await framed( + "[map('a', 1), map('b', 2)]::Array(Map(String, UInt8))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(readMap(str, u8))(r)).toEqual([ + new Map([["a", 1]]), + new Map([["b", 2]]), + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array(Tuple(UInt8, String))", async () => { + const r = await framed( + "[(1, 'x'), (2, 'y')]::Array(Tuple(UInt8, String))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(readTuple([u8, str]))(r)).toEqual([ + [1, "x"], + [2, "y"], + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Map(String, variable value)", () => { + it("Map(String, Variant(UInt8, String))", async () => { + const r = await framed( + "map('a', 42::Variant(UInt8, String), 'b', 'hi'::Variant(UInt8, String))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readMap(str, variantU8Str)(r)).toEqual( + new Map([ + ["a", 42], + ["b", "hi"], + ]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Map(String, Dynamic) with mixed value types", async () => { + const r = await framed( + "map('a', toInt32(7)::Dynamic, 'b', 'hi'::Dynamic)::Map(String, Dynamic)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readMap(str, readDynamic)(r)).toEqual( + new Map([ + ["a", 7], + ["b", "hi"], + ]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Map(String, Array(UInt8))", async () => { + const r = await framed( + "map('x', [1, 2], 'y', [3])::Map(String, Array(UInt8))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readMap(str, readArray(u8))(r)).toEqual( + new Map([ + ["x", [1, 2]], + ["y", [3]], + ]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Map(String, Nullable(UInt8)) with a NULL value", async () => { + const r = await framed( + "map('a', 1, 'b', NULL)::Map(String, Nullable(UInt8))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readMap(str, readNullable(u8))(r)).toEqual( + new Map([ + ["a", 1], + ["b", null], + ]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Tuple adjacency — two variable things back-to-back", () => { + it("Tuple(Dynamic, Dynamic)", async () => { + const r = await framed( + "(toInt32(7)::Dynamic, 'hi'::Dynamic)::Tuple(Dynamic, Dynamic)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([readDynamic, readDynamic])(r)).toEqual([7, "hi"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple(Dynamic, Dynamic) — first is a 1-byte NULL", async () => { + const r = await framed( + "(NULL::Dynamic, toInt32(9)::Dynamic)::Tuple(Dynamic, Dynamic)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([readDynamic, readDynamic])(r)).toEqual([null, 9]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple(Variant, Variant)", async () => { + const r = await framed( + "(42::Variant(UInt8, String), 'x'::Variant(UInt8, String))::Tuple(Variant(UInt8, String), Variant(UInt8, String))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([variantU8Str, variantU8Str])(r)).toEqual([42, "x"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple(Array(UInt8), Array(UInt8)) — two adjacent length-prefixed arrays", async () => { + const r = await framed( + "([1, 2], [3, 4])::Tuple(Array(UInt8), Array(UInt8))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([readArray(u8), readArray(u8)])(r)).toEqual([ + [1, 2], + [3, 4], + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple(Array(UInt8), String)", async () => { + const r = await framed("([1, 2], 'x')::Tuple(Array(UInt8), String)"); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([readArray(u8), str])(r)).toEqual([[1, 2], "x"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple(Nullable(String), Nullable(String)) — NULL then value", async () => { + const r = await framed( + "(NULL, 'x')::Tuple(Nullable(String), Nullable(String))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([readNullable(str), readNullable(str)])(r)).toEqual([ + null, + "x", + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Variant whose active alternative is variable-length", () => { + it("Variant(Array(UInt8), String) holding the Array (discriminant 0)", async () => { + const r = await framed("[1, 2, 3]::Variant(Array(UInt8), String)"); + expect(readInt32(r)).toBe(LEAD); + // sorted [Array(UInt8)(0), String(1)] + expect(readVariant([readArray(u8), readString])(r)).toEqual([1, 2, 3]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Variant(Array(UInt8), String) holding the String (discriminant 1)", async () => { + const r = await framed("'hi'::Variant(Array(UInt8), String)"); + expect(readInt32(r)).toBe(LEAD); + expect(readVariant([readArray(u8), readString])(r)).toBe("hi"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Variant(Map(String, UInt8), UInt8) holding the Map (discriminant 0)", async () => { + const r = await framed("map('a', 1)::Variant(Map(String, UInt8), UInt8)"); + expect(readInt32(r)).toBe(LEAD); + // sorted [Map(String, UInt8)(0), UInt8(1)] + expect(readVariant([readMap(str, u8), readUInt8])(r)).toEqual( + new Map([["a", 1]]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Variant(Tuple(UInt8, String), UInt8) holding the Tuple (discriminant 0)", async () => { + const r = await framed("(1, 'x')::Variant(Tuple(UInt8, String), UInt8)"); + expect(readInt32(r)).toBe(LEAD); + // sorted [Tuple(UInt8, String)(0), UInt8(1)] + expect(readVariant([readTuple([u8, str]), readUInt8])(r)).toEqual([ + 1, + "x", + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Dynamic wrapping a nested self-describing type", () => { + it("Dynamic(Array(Variant(UInt8, String)))", async () => { + const r = await framed( + "[42::Variant(UInt8, String), 'hi'::Variant(UInt8, String)]::Array(Variant(UInt8, String))::Dynamic", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toEqual([42, "hi"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic(Map(String, Variant(UInt8, String)))", async () => { + const r = await framed( + "map('a', 42::Variant(UInt8, String))::Map(String, Variant(UInt8, String))::Dynamic", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toEqual(new Map([["a", 42]])); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic(Array(Dynamic))", async () => { + const r = await framed( + "['x'::Dynamic, 'y'::Dynamic]::Array(Dynamic)::Dynamic", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toEqual(["x", "y"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic(JSON)", async () => { + const r = await framed(`'{"a":1}'::JSON::Dynamic`); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toEqual(new Map([["a", 1n]])); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic(Nested)", async () => { + const r = await framed( + "[(1, 'x'), (2, 'y')]::Nested(a UInt8, b String)::Dynamic", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toEqual([ + { a: 1, b: "x" }, + { a: 2, b: "y" }, + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("JSON with several self-describing paths", () => { + it("mixed value types (Int64, String, Array)", async () => { + const r = await framed(`'{"i":1,"s":"hi","arr":[1,2]}'::JSON`); + expect(readInt32(r)).toBe(LEAD); + expect(readJSON(r)).toEqual( + new Map([ + ["arr", [1n, 2n]], + ["s", "hi"], + ["i", 1n], + ]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("nested object flattened to dotted paths", async () => { + const r = await framed(`'{"a":{"b":2},"c":3}'::JSON`); + expect(readInt32(r)).toBe(LEAD); + expect(readJSON(r)).toEqual( + new Map([ + ["a.b", 2n], + ["c", 3n], + ]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/framing.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/framing.test.ts new file mode 100644 index 000000000..5c2be487d --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/framing.test.ts @@ -0,0 +1,609 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readBool } from "../src/bool.js"; +import { + readArray, + readMap, + readNullable, + readQBit, + readTuple, + readTupleNamed, + readVariant, +} from "../src/composite.js"; +import { RowBinaryState } from "../src/core.js"; +import { + readDate, + readDate32, + readDateTime, + readDateTime64, +} from "../src/datetime.js"; +import { + readDecimal128, + readDecimal256, + readDecimal32, + readDecimal64, +} from "../src/decimals.js"; +import { readDynamic } from "../src/dynamic.js"; +import { readEnum16, readEnum8 } from "../src/enums.js"; +import { readBFloat16, readFloat32, readFloat64 } from "../src/floats.js"; +import { + readGeometry, + readLineString, + readMultiLineString, + readMultiPolygon, + readPoint, + readPolygon, + readRing, +} from "../src/geo.js"; +import { + readInt128, + readInt16, + readInt256, + readInt32, + readInt64, + readInt8, + readUInt128, + readUInt16, + readUInt256, + readUInt32, + readUInt64, + readUInt8, +} from "../src/integers.js"; +import { readInterval } from "../src/interval.js"; +import { formatIPv4, formatIPv6, readIPv4, readIPv6 } from "../src/ip.js"; +import { readJSON } from "../src/json.js"; +import { readFixedString, readString } from "../src/strings.js"; +import { readTime, readTime64 } from "../src/time.js"; +import { formatUUID, readUUID } from "../src/uuid.js"; + +/** + * Framing tests: every type is placed as the MIDDLE column between two distinct + * Int32 sentinels — `i32(LEAD), X, i32(TRAIL)`. Reading the trailing sentinel + * correctly only works if X's reader consumed EXACTLY its bytes: one byte short + * or long and the final `readInt32()` returns garbage instead of TRAIL. So each + * test reads LEAD, then X, then asserts TRAIL — a tight check that the middle + * reader stops on the dot. + * + * The NULL / empty cases (Nullable null, Variant null, Dynamic null, empty + * Array) are the sharpest: the value is a single byte, so an over-read is caught + * immediately by the sentinel. + */ +const LEAD = 123456789; +const TRAIL = 987654321; + +/** + * Every experimental / suspicious type flag this skill targets, enabled for all + * queries. It is fine to turn everything on unconditionally here — the skill is + * meant to read whatever a server emits — so each case just names its type and + * shares this one settings string. + */ +const SETTINGS = [ + "enable_time_time64_type = 1", + "allow_experimental_variant_type = 1", + "allow_suspicious_variant_types = 1", + "allow_experimental_dynamic_type = 1", + "allow_experimental_json_type = 1", + "enable_json_type = 1", + "allow_experimental_qbit_type = 1", + "allow_suspicious_low_cardinality_types = 1", +].join(", "); + +/** + * Build a `i32(LEAD), X, i32(TRAIL)` row, run it, and return a fresh reader over + * the bytes. Each test reads the leading sentinel, X, and the trailing sentinel + * itself. + */ +async function framed(expr: string): Promise { + const sql = + `SELECT toInt32(${LEAD}) AS a, ${expr} AS x, toInt32(${TRAIL}) AS b` + + ` SETTINGS ${SETTINGS} FORMAT RowBinary`; + return new RowBinaryState(await query(sql)); +} + +describe("framing: i32, X, i32 — the middle reader must stop at the exact byte", () => { + describe("Integers", () => { + it("Int8", async () => { + const r = await framed("toInt8(-5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readInt8(r)).toBe(-5); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Int16", async () => { + const r = await framed("toInt16(-12345)"); + expect(readInt32(r)).toBe(LEAD); + expect(readInt16(r)).toBe(-12345); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Int32", async () => { + const r = await framed("toInt32(-70000)"); + expect(readInt32(r)).toBe(LEAD); + expect(readInt32(r)).toBe(-70000); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Int64", async () => { + const r = await framed("toInt64(-5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readInt64(r)).toBe(-5n); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Int128", async () => { + const r = await framed("toInt128(-5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readInt128(r)).toBe(-5n); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Int256", async () => { + const r = await framed("toInt256(-5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readInt256(r)).toBe(-5n); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("UInt8", async () => { + const r = await framed("toUInt8(200)"); + expect(readInt32(r)).toBe(LEAD); + expect(readUInt8(r)).toBe(200); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("UInt16", async () => { + const r = await framed("toUInt16(60000)"); + expect(readInt32(r)).toBe(LEAD); + expect(readUInt16(r)).toBe(60000); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("UInt32", async () => { + const r = await framed("toUInt32(4000000000)"); + expect(readInt32(r)).toBe(LEAD); + expect(readUInt32(r)).toBe(4000000000); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("UInt64", async () => { + const r = await framed("toUInt64(5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readUInt64(r)).toBe(5n); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("UInt128", async () => { + const r = await framed("toUInt128(5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readUInt128(r)).toBe(5n); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("UInt256", async () => { + const r = await framed("toUInt256(5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readUInt256(r)).toBe(5n); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Floats", () => { + it("Float32", async () => { + const r = await framed("toFloat32(1.5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readFloat32(r)).toBe(1.5); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Float64", async () => { + const r = await framed("toFloat64(1.5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readFloat64(r)).toBe(1.5); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("BFloat16", async () => { + const r = await framed("toBFloat16(1.5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readBFloat16(r)).toBe(1.5); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Decimals", () => { + it("Decimal32", async () => { + const r = await framed("toDecimal32(1.5, 4)"); + expect(readInt32(r)).toBe(LEAD); + expect(readDecimal32(4)(r)).toEqual([15000n, 4]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Decimal64", async () => { + const r = await framed("toDecimal64(-12.34, 2)"); + expect(readInt32(r)).toBe(LEAD); + expect(readDecimal64(2)(r)).toEqual([-1234n, 2]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Decimal128", async () => { + const r = await framed("toDecimal128(1.5, 4)"); + expect(readInt32(r)).toBe(LEAD); + expect(readDecimal128(4)(r)).toEqual([15000n, 4]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Decimal256", async () => { + const r = await framed("toDecimal256(1.5, 4)"); + expect(readInt32(r)).toBe(LEAD); + expect(readDecimal256(4)(r)).toEqual([15000n, 4]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Bool and strings", () => { + it("Bool", async () => { + const r = await framed("true"); + expect(readInt32(r)).toBe(LEAD); + expect(readBool(r)).toBe(true); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("String", async () => { + const r = await framed("'hello'"); + expect(readInt32(r)).toBe(LEAD); + expect(readString(r)).toBe("hello"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("FixedString (NUL padding counts toward the fixed width)", async () => { + const r = await framed("CAST('ab' AS FixedString(5))"); + expect(readInt32(r)).toBe(LEAD); + expect(readFixedString(5)(r)).toBe("ab\x00\x00\x00"); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Dates and times", () => { + it("Date", async () => { + const r = await framed("toDate('2021-03-15')"); + expect(readInt32(r)).toBe(LEAD); + expect(readDate(r).toISOString()).toBe("2021-03-15T00:00:00.000Z"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Date32", async () => { + const r = await framed("toDate32('1950-01-01')"); + expect(readInt32(r)).toBe(LEAD); + expect(readDate32(r).toISOString()).toBe("1950-01-01T00:00:00.000Z"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("DateTime", async () => { + const r = await framed("toDateTime('2021-01-01 00:00:00', 'UTC')"); + expect(readInt32(r)).toBe(LEAD); + expect(readDateTime(r).getTime()).toBe(1609459200000); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("DateTime64", async () => { + const r = await framed( + "toDateTime64('2021-01-01 00:00:00.123', 3, 'UTC')", + ); + expect(readInt32(r)).toBe(LEAD); + const [d, n] = readDateTime64(3)(r); + expect(d.toISOString()).toBe("2021-01-01T00:00:00.000Z"); + expect(n).toBe(123_000_000); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Time", async () => { + const r = await framed("CAST('12:34:56' AS Time)"); + expect(readInt32(r)).toBe(LEAD); + expect(readTime(r)).toBe(45296); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Time64", async () => { + const r = await framed("toTime64('12:34:56.123', 3)"); + expect(readInt32(r)).toBe(LEAD); + expect(readTime64(3)(r)).toEqual([45296123n, 3]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("UUID and networking", () => { + it("UUID", async () => { + const r = await framed("toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0')"); + expect(readInt32(r)).toBe(LEAD); + expect(formatUUID(readUUID(r))).toBe( + "61f0c404-5cb3-11e7-907b-a6006ad3dba0", + ); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("IPv4", async () => { + const r = await framed("toIPv4('1.2.3.4')"); + expect(readInt32(r)).toBe(LEAD); + expect(formatIPv4(readIPv4(r))).toBe("1.2.3.4"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("IPv6", async () => { + const r = await framed("toIPv6('2001:db8::1')"); + expect(readInt32(r)).toBe(LEAD); + expect(formatIPv6(readIPv6(r))).toBe("2001:db8::1"); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Enums", () => { + it("Enum8", async () => { + const r = await framed("CAST('b' AS Enum8('a' = 1, 'b' = 2))"); + expect(readInt32(r)).toBe(LEAD); + expect(readEnum8(r)).toBe(2); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Enum16", async () => { + const r = await framed("CAST('big' AS Enum16('small' = 1, 'big' = 300))"); + expect(readInt32(r)).toBe(LEAD); + expect(readEnum16(r)).toBe(300); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Composite and wrappers", () => { + it("Nullable (value present)", async () => { + const r = await framed("CAST(7 AS Nullable(UInt8))"); + expect(readInt32(r)).toBe(LEAD); + expect(readNullable(readUInt8)(r)).toBe(7); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Nullable (NULL — a single flag byte)", async () => { + const r = await framed("CAST(NULL AS Nullable(UInt8))"); + expect(readInt32(r)).toBe(LEAD); + expect(readNullable(readUInt8)(r)).toBeNull(); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("LowCardinality(String)", async () => { + const r = await framed("CAST('x' AS LowCardinality(String))"); + expect(readInt32(r)).toBe(LEAD); + expect(readString(r)).toBe("x"); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array(UInt8)", async () => { + const r = await framed("CAST([1, 2, 3] AS Array(UInt8))"); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(readUInt8)(r)).toEqual([1, 2, 3]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Array empty (a single count byte)", async () => { + const r = await framed("CAST([] AS Array(UInt8))"); + expect(readInt32(r)).toBe(LEAD); + expect(readArray(readUInt8)(r)).toEqual([]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple", async () => { + const r = await framed("CAST((1, 'x') AS Tuple(UInt8, String))"); + expect(readInt32(r)).toBe(LEAD); + expect(readTuple([readUInt8, readString])(r)).toEqual([1, "x"]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Tuple named", async () => { + const r = await framed("CAST((1, 'x') AS Tuple(a UInt8, b String))"); + expect(readInt32(r)).toBe(LEAD); + expect(readTupleNamed({ a: readUInt8, b: readString })(r)).toEqual({ + a: 1, + b: "x", + }); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Map", async () => { + const r = await framed("CAST(map('a', 1, 'b', 2) AS Map(String, UInt8))"); + expect(readInt32(r)).toBe(LEAD); + expect(readMap(readString, readUInt8)(r)).toEqual( + new Map([ + ["a", 1], + ["b", 2], + ]), + ); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Nested", async () => { + const r = await framed( + "CAST([(1, 'x'), (2, 'y')] AS Nested(a UInt8, b String))", + ); + expect(readInt32(r)).toBe(LEAD); + expect( + readArray(readTupleNamed({ a: readUInt8, b: readString }))(r), + ).toEqual([ + { a: 1, b: "x" }, + { a: 2, b: "y" }, + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Geo", () => { + it("Point", async () => { + const r = await framed("CAST((1.5, 2.5) AS Point)"); + expect(readInt32(r)).toBe(LEAD); + expect(readPoint(r)).toEqual([1.5, 2.5]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Ring", async () => { + const r = await framed("CAST([(0, 0), (1, 2)] AS Ring)"); + expect(readInt32(r)).toBe(LEAD); + expect(readRing(r)).toEqual([ + [0, 0], + [1, 2], + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("LineString", async () => { + const r = await framed("CAST([(3, 4), (5, 6)] AS LineString)"); + expect(readInt32(r)).toBe(LEAD); + expect(readLineString(r)).toEqual([ + [3, 4], + [5, 6], + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("MultiLineString", async () => { + const r = await framed("CAST([[(0, 0), (1, 1)]] AS MultiLineString)"); + expect(readInt32(r)).toBe(LEAD); + expect(readMultiLineString(r)).toEqual([ + [ + [0, 0], + [1, 1], + ], + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Polygon", async () => { + const r = await framed("CAST([[(0, 0), (1, 0), (1, 1)]] AS Polygon)"); + expect(readInt32(r)).toBe(LEAD); + expect(readPolygon(r)).toEqual([ + [ + [0, 0], + [1, 0], + [1, 1], + ], + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("MultiPolygon", async () => { + const r = await framed( + "CAST([[[(0, 0), (1, 0), (1, 1)]]] AS MultiPolygon)", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readMultiPolygon(r)).toEqual([ + [ + [ + [0, 0], + [1, 0], + [1, 1], + ], + ], + ]); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Geometry", async () => { + const r = await framed("CAST(CAST((1.5, 2.5) AS Point) AS Geometry)"); + expect(readInt32(r)).toBe(LEAD); + expect(readGeometry(r)).toEqual([1.5, 2.5]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Intervals", () => { + it("Interval", async () => { + const r = await framed("toIntervalSecond(5)"); + expect(readInt32(r)).toBe(LEAD); + expect(readInterval(r)).toBe(5n); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Variant", () => { + it("Variant (value)", async () => { + const r = await framed("CAST(42 AS Variant(UInt8, String))"); + expect(readInt32(r)).toBe(LEAD); + expect(readVariant([readString, readUInt8])(r)).toBe(42); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Variant (NULL — a single discriminant byte)", async () => { + const r = await framed("CAST(NULL AS Variant(UInt8, String))"); + expect(readInt32(r)).toBe(LEAD); + expect(readVariant([readString, readUInt8])(r)).toBeNull(); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Dynamic", () => { + it("Dynamic (scalar)", async () => { + const r = await framed("CAST(toUInt64(42) AS Dynamic)"); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toBe(42n); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic (NULL — a single Nothing tag)", async () => { + const r = await framed("CAST(NULL AS Dynamic)"); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toBeNull(); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("Dynamic (Array)", async () => { + const r = await framed("CAST([1, 2, 3]::Array(UInt8) AS Dynamic)"); + expect(readInt32(r)).toBe(LEAD); + expect(readDynamic(r)).toEqual([1, 2, 3]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("JSON", () => { + it("JSON", async () => { + const r = await framed(`'{"a":1}'::JSON`); + expect(readInt32(r)).toBe(LEAD); + expect(readJSON(r)).toEqual(new Map([["a", 1n]])); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Aggregate state", () => { + it("SimpleAggregateFunction (transparent → inner UInt64)", async () => { + const r = await framed( + "CAST(42 AS SimpleAggregateFunction(sum, UInt64))", + ); + expect(readInt32(r)).toBe(LEAD); + expect(readUInt64(r)).toBe(42n); + expect(readInt32(r)).toBe(TRAIL); + }); + + it("AggregateFunction (sumState: known UInt64 layout)", async () => { + const r = await framed("sumState(toUInt64(42))"); + expect(readInt32(r)).toBe(LEAD); + expect(readUInt64(r)).toBe(42n); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Vector search", () => { + it("QBit", async () => { + const r = await framed("CAST([1.0, 2.0] AS QBit(Float32, 2))"); + expect(readInt32(r)).toBe(LEAD); + expect(readQBit(readFloat32)(r)).toEqual([1, 2]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); + + describe("Misc", () => { + it("Nothing (empty Array(Nothing) — element reader never runs)", async () => { + const r = await framed("[]"); + expect(readInt32(r)).toBe(LEAD); + expect( + readArray(() => { + throw new Error("unreachable"); + })(r), + ).toEqual([]); + expect(readInt32(r)).toBe(TRAIL); + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.bench.ts new file mode 100644 index 000000000..bfad67583 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.bench.ts @@ -0,0 +1,134 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { + type IotRow, + readIotRow, + readIotRowFast, +} from "../src/examples/iot.js"; + +/** + * Benchmark: RowBinary vs JSON for a table of IoT sensor readings — the + * dense-numeric, fixed-width shape RowBinary exists for. The SKILL's + * format-choice guidance says reach for JSON when the result is string-heavy and + * for RowBinary when it is "high-volume fixed-width numeric"; this is the latter, + * so we measure the gap honestly against the JSON formats a knowledgeable user + * would actually choose: + * + * - JSONEachRow — newline-delimited objects (keys repeated every row) + * - JSONCompactEachRow — newline-delimited arrays (no repeated keys; smaller) + * + * For each JSON format we use the fastest idiomatic decode: splice the rows into + * one `[...]` document and hand it to V8's native `JSON.parse` in a single call. + */ +const N = 50_000; + +// Same rows, three formats. Deterministic, dense-numeric IoT readings. +const SELECT = + `SELECT toUInt32(number % 1000) AS sensor_id, ` + + `toDateTime64(1700000000 + number, 3) AS ts, ` + + `20 + (number % 1500) / 100 AS temperature, ` + + `30 + (number % 7000) / 100 AS humidity, ` + + `980 + (number % 6000) / 100 AS pressure, ` + + `toFloat32(3 + (number % 200) / 100) AS battery, ` + + `toUInt8(number % 4) AS status ` + + `FROM numbers(${N})`; + +const RB_BUF = await query(`${SELECT} FORMAT RowBinary`); +const JSON_BUF = await query(`${SELECT} FORMAT JSONEachRow`); +const JSON_COMPACT_BUF = await query(`${SELECT} FORMAT JSONCompactEachRow`); + +// --- decoders --------------------------------------------------------------- + +function decodeRowBinary(read: Reader): IotRow[] { + const s = new RowBinaryState(RB_BUF); + const out: IotRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +// Wrap newline-delimited JSON rows into one array and parse in a single call — +// the fastest way to drive V8's native JSON.parse over a whole response. +function decodeJsonArray(buf: Buffer): unknown[] { + const text = buf.toString("utf8"); + return JSON.parse(`[${text.trimEnd().replaceAll("\n", ",")}]`); +} + +// --- correctness cross-check (runs once at load) ---------------------------- + +const COLS: (keyof IotRow)[] = [ + "sensor_id", + "temperature", + "humidity", + "pressure", + "battery", + "status", +]; +{ + const rb = decodeRowBinary(readIotRowFast); + const api = decodeRowBinary(readIotRow); + const je = decodeJsonArray(JSON_BUF) as Record[]; + const jc = decodeJsonArray(JSON_COMPACT_BUF) as (number | string)[][]; + + if (rb.length !== N) + throw new Error(`RowBinary: ${rb.length} rows, expected ${N}`); + if (je.length !== N) + throw new Error(`JSONEachRow: ${je.length} rows, expected ${N}`); + if (jc.length !== N) + throw new Error(`JSONCompactEachRow: ${jc.length} rows, expected ${N}`); + + // API reader and fast reader must agree exactly. + if (JSON.stringify(api) !== JSON.stringify(rb)) + throw new Error("iot: API vs fast mismatch"); + + // RowBinary (binary float) and JSON (decimal text -> float) must agree to a + // tiny epsilon on every numeric column, spot-checked across the result. + const order: (keyof IotRow)[] = [ + "sensor_id", + "ts", + "temperature", + "humidity", + "pressure", + "battery", + "status", + ]; + for (const i of [0, 1, 123, 4999, N - 1]) { + for (const c of COLS) { + const a = rb[i]![c] as number; + const b = Number(je[i]![c]); + const d = Number(jc[i]![order.indexOf(c)]); + const tol = c === "battery" ? 1e-2 : 1e-9; // Float32 battery has less precision + if (Math.abs(a - b) > tol || Math.abs(a - d) > tol) { + throw new Error( + `iot: ${c}@${i} mismatch rb=${a} json=${b} compact=${d}`, + ); + } + } + } + + const mb = (b: Buffer) => (b.length / 1e6).toFixed(2); + const perRow = (b: Buffer) => (b.length / N).toFixed(1); + console.log( + `\n IoT readings — ${N.toLocaleString()} rows, wire size on the HTTP response:\n` + + ` RowBinary ${mb(RB_BUF)} MB (${perRow(RB_BUF)} B/row)\n` + + ` JSONCompactEachRow ${mb(JSON_COMPACT_BUF)} MB (${perRow(JSON_COMPACT_BUF)} B/row) ${(JSON_COMPACT_BUF.length / RB_BUF.length).toFixed(1)}x\n` + + ` JSONEachRow ${mb(JSON_BUF)} MB (${perRow(JSON_BUF)} B/row) ${(JSON_BUF.length / RB_BUF.length).toFixed(1)}x\n`, + ); +} + +// --- benchmarks ------------------------------------------------------------- + +describe("IoT readings: RowBinary vs JSON decode throughput", () => { + bench("RowBinary — optimized (monomorphized)", () => { + decodeRowBinary(readIotRowFast); + }); + bench("RowBinary — API (combinators)", () => { + decodeRowBinary(readIotRow); + }); + bench("JSONCompactEachRow — JSON.parse", () => { + decodeJsonArray(JSON_COMPACT_BUF); + }); + bench("JSONEachRow — JSON.parse", () => { + decodeJsonArray(JSON_BUF); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.columnar.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.columnar.bench.ts new file mode 100644 index 000000000..fe51704bd --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.columnar.bench.ts @@ -0,0 +1,57 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { + type IotRow, + decodeIotColumnar, + iotRowAt, + readIotRowFast, +} from "../src/examples/iot.js"; + +/** + * Benchmark: row-objects (AoS, `readIotRowFast`) vs columnar (SoA, + * `decodeIotColumnar`) over the same fixed-width IoT buffer. Columnar removes + * the per-row object + `Date` allocation that dominates a numeric decode, for a + * ~4x win — the "free 4x, in plain JS" the WASM investigation surfaced (see + * `case-studies/wasm-vs-js.md`). + */ +const N = 50_000; +const SELECT = + `SELECT toUInt32(number % 1000) AS sensor_id, ` + + `toDateTime64(1700000000 + number, 3) AS ts, ` + + `20 + (number % 1500) / 100 AS temperature, ` + + `30 + (number % 7000) / 100 AS humidity, ` + + `980 + (number % 6000) / 100 AS pressure, ` + + `toFloat32(3 + (number % 200) / 100) AS battery, ` + + `toUInt8(number % 4) AS status ` + + `FROM numbers(${N})`; +const BUF = await query(`${SELECT} FORMAT RowBinary`); + +function decodeRows(): IotRow[] { + const s = new RowBinaryState(BUF); + const out: IotRow[] = []; + while (s.pos < s.buf.length) out.push(readIotRowFast(s)); + return out; +} + +// correctness: columnar (via the lazy row accessor) must equal the row decode +{ + const rows = decodeRows(); + const cols = decodeIotColumnar(BUF); + if (rows.length !== N || cols.sensor_id.length !== N) + throw new Error("columnar: row count"); + for (const i of [0, 1, 123, 4999, N - 1]) { + if (JSON.stringify(rows[i]) !== JSON.stringify(iotRowAt(cols, i))) { + throw new Error(`columnar: row ${i} mismatch`); + } + } +} + +describe("IoT decode: row-objects vs columnar", () => { + bench("rows — readIotRowFast (objects + Date)", () => { + decodeRows(); + }); + bench("columnar — decodeIotColumnar (typed arrays)", () => { + decodeIotColumnar(BUF); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.wasm-headroom.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.wasm-headroom.bench.ts new file mode 100644 index 000000000..280f4f41b --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.wasm-headroom.bench.ts @@ -0,0 +1,132 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { type IotRow, readIotRowFast } from "../src/examples/iot.js"; + +/** + * WASM headroom probe — NOT a WASM implementation, but the measurement that + * decides whether writing one is worth it. + * + * A WASM parser can read bytes, but it CANNOT allocate JS objects / strings / + * BigInts / Dates — those must be materialized on the JS side whatever decodes + * the bytes. So the maximum a WASM parser could ever shave off our current + * row-object decode is bounded by: + * + * (full row-object decode time) − (unavoidable JS-side materialization) + * + * We bracket that headroom with three decoders over the SAME IoT buffer (the + * best case for RowBinary — every column fixed-width numeric): + * + * 1. rows — the current fast reader: builds {…} objects + Date per row. + * 2. columnar — same reads, written into preallocated typed arrays, NO + * per-row objects. The "different output contract" a WASM + * parser would target. + * 3. parseOnly — same reads, accumulated into a scalar checksum, allocates + * NOTHING. The pure byte-arithmetic floor: a WASM parser + * cannot beat this slice by much (V8 already compiles DataView + * reads to native loads), and still has to pay it. + * + * Read the gaps: rows→parseOnly is the materialization WASM can't remove; + * rows→columnar is the win available in plain JS by changing the output shape. + */ +const N = 50_000; +const SELECT = + `SELECT toUInt32(number % 1000) AS sensor_id, ` + + `toDateTime64(1700000000 + number, 3) AS ts, ` + + `20 + (number % 1500) / 100 AS temperature, ` + + `30 + (number % 7000) / 100 AS humidity, ` + + `980 + (number % 6000) / 100 AS pressure, ` + + `toFloat32(3 + (number % 200) / 100) AS battery, ` + + `toUInt8(number % 4) AS status ` + + `FROM numbers(${N})`; +const BUF = await query(`${SELECT} FORMAT RowBinary`); +const ROW_BYTES = 41; + +// 1. Current output contract: an array of row objects. +function decodeRows(): IotRow[] { + const s = new RowBinaryState(BUF); + const out: IotRow[] = []; + while (s.pos < s.buf.length) out.push(readIotRowFast(s)); + return out; +} + +type Columns = { + sensor_id: Uint32Array; + ts: Float64Array; // epoch ms + temperature: Float64Array; + humidity: Float64Array; + pressure: Float64Array; + battery: Float32Array; + status: Uint8Array; +}; + +// 2. Columnar contract: straight into typed arrays, no per-row objects. +function decodeColumnar(): Columns { + const view = new DataView(BUF.buffer, BUF.byteOffset, BUF.byteLength); + const n = (BUF.length / ROW_BYTES) | 0; + const c: Columns = { + sensor_id: new Uint32Array(n), + ts: new Float64Array(n), + temperature: new Float64Array(n), + humidity: new Float64Array(n), + pressure: new Float64Array(n), + battery: new Float32Array(n), + status: new Uint8Array(n), + }; + let o = 0; + for (let i = 0; i < n; i++) { + c.sensor_id[i] = view.getUint32(o, true); + c.ts[i] = Number(view.getBigInt64(o + 4, true)); + c.temperature[i] = view.getFloat64(o + 12, true); + c.humidity[i] = view.getFloat64(o + 20, true); + c.pressure[i] = view.getFloat64(o + 28, true); + c.battery[i] = view.getFloat32(o + 36, true); + c.status[i] = BUF[o + 40]!; + o += ROW_BYTES; + } + return c; +} + +// 3. Pure parse floor: read everything, allocate nothing, fold into a checksum. +let sink = 0; +function parseOnly(): number { + const view = new DataView(BUF.buffer, BUF.byteOffset, BUF.byteLength); + const n = (BUF.length / ROW_BYTES) | 0; + let acc = 0; + let o = 0; + for (let i = 0; i < n; i++) { + acc += view.getUint32(o, true); + acc += Number(view.getBigInt64(o + 4, true)); + acc += view.getFloat64(o + 12, true); + acc += view.getFloat64(o + 20, true); + acc += view.getFloat64(o + 28, true); + acc += view.getFloat32(o + 36, true); + acc += BUF[o + 40]!; + o += ROW_BYTES; + } + return (sink = acc); // observable, so V8 can't elide the reads +} + +// sanity: all three agree on row count / a sampled value +{ + const rows = decodeRows(); + const cols = decodeColumnar(); + if (rows.length !== N || cols.sensor_id.length !== N) + throw new Error("headroom: row count"); + if (rows[123]!.temperature !== cols.temperature[123]) + throw new Error("headroom: value mismatch"); + parseOnly(); + if (!Number.isFinite(sink)) throw new Error("headroom: checksum"); +} + +describe("WASM headroom on IoT RowBinary (best case for RowBinary)", () => { + bench("rows — current fast reader (objects + Date)", () => { + decodeRows(); + }); + bench("columnar — into typed arrays (no per-row objects)", () => { + decodeColumnar(); + }); + bench("parseOnly — reads only, zero allocation (the WASM floor)", () => { + parseOnly(); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/ledger.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/ledger.bench.ts new file mode 100644 index 000000000..468f3d99d --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/ledger.bench.ts @@ -0,0 +1,201 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { type DecimalValue, formatDecimal } from "../src/decimals.js"; +import { + type LedgerRow, + readLedgerRow, + readLedgerRowFast, +} from "../src/examples/ledger.js"; + +/** + * Benchmark + correctness proof: RowBinary vs JSON for a financial ledger whose + * every column is WIDER than a JS `number` can hold — `UInt128`, `Int64`, + * `Decimal128(18)`, `UInt256`. The SKILL says RowBinary "clearly wins" on wide + * numerics; here it wins twice over, because for this shape JSON isn't just + * slower, it's WRONG: + * + * - ClickHouse emits these as BARE JSON numbers, so stock `JSON.parse` rounds + * every one to a float64 — silent, lossy corruption (demonstrated below). + * - The only correct JSON path quotes the values server-side + * (`output_format_json_quote_64bit_integers` + `..._quote_decimals`) and + * re-parses each string into a `bigint`/decimal pair by hand — extra work on + * top of a larger wire. + * + * RowBinary reads each value as an exact `bigint` straight off the wire. + */ +const N = 50_000; + +const SELECT = + `SELECT ` + + // UInt128 near the top of the range, varied per row. + `toUInt128('340282366920938463463374607431768200000') + number AS txn_id, ` + + // Int64 starting at 2^53 + 1 — already past exact-double range on row 0. + `toInt64(9007199254740993) + number AS account, ` + + // Decimal128(18): ~14 integer digits + 18 fractional = 32 significant digits. + `CAST(concat(toString(toUInt64(98765432109876 + number)), '.123456789012345678') AS Decimal128(18)) AS amount, ` + + `CAST(concat(toString(toUInt64(12345678901234 + number)), '.111111111111111111') AS Decimal128(18)) AS balance, ` + + `CAST(concat(toString(toUInt64(1000 + number % 9000)), '.5678') AS Decimal64(4)) AS fee, ` + + // UInt256 near the top of the range. + `toUInt256('115792089237316195423570985008687907853269984665640564039457000000000') + number AS volume ` + + `FROM numbers(${N})`; + +const RB_BUF = await query(`${SELECT} FORMAT RowBinary`); +// Naive JSON: bare numbers. Fast to parse, but every wide value is corrupted. +const JSON_BARE_BUF = await query(`${SELECT} FORMAT JSONEachRow`); +// Correct JSON: quote wide ints AND decimals so values arrive as exact strings. +const QUOTE = + "SETTINGS output_format_json_quote_64bit_integers = 1, output_format_json_quote_decimals = 1"; +const JSON_STR_BUF = await query(`${SELECT} ${QUOTE} FORMAT JSONEachRow`); +const JSON_COMPACT_STR_BUF = await query( + `${SELECT} ${QUOTE} FORMAT JSONCompactEachRow`, +); + +// --- decoders --------------------------------------------------------------- + +function decodeRowBinary(read: Reader): LedgerRow[] { + const s = new RowBinaryState(RB_BUF); + const out: LedgerRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +function jsonArray(buf: Buffer): unknown[] { + return JSON.parse( + `[${buf.toString("utf8").trimEnd().replaceAll("\n", ",")}]`, + ); +} + +// Parse a fixed-point decimal string ("123.456") into the exact [unscaled, scale] +// pair RowBinary returns — the per-field work JSON must do to stay lossless. +function parseDecimal(str: string, scale: number): DecimalValue { + const neg = str.charCodeAt(0) === 45; // '-' + const s = neg ? str.slice(1) : str; + const dot = s.indexOf("."); + let digits: string; + let frac: number; + if (dot === -1) { + digits = s; + frac = 0; + } else { + digits = s.slice(0, dot) + s.slice(dot + 1); + frac = s.length - dot - 1; + } + let unscaled = BigInt(digits); + if (frac < scale) unscaled *= 10n ** BigInt(scale - frac); + else if (frac > scale) unscaled /= 10n ** BigInt(frac - scale); + return [neg ? -unscaled : unscaled, scale]; +} + +// Correct decode of the quoted JSON: turn the string fields back into the exact +// bigint / decimal-pair shape RowBinary produces. +function decodeJsonObjectsCorrect(buf: Buffer): LedgerRow[] { + const rows = jsonArray(buf) as Record[]; + const out: LedgerRow[] = new Array(rows.length); + for (let i = 0; i < rows.length; i++) { + const r = rows[i]!; + out[i] = { + txn_id: BigInt(r.txn_id!), + account: BigInt(r.account!), + amount: parseDecimal(r.amount!, 18), + balance: parseDecimal(r.balance!, 18), + fee: parseDecimal(r.fee!, 4), + volume: BigInt(r.volume!), + }; + } + return out; +} + +function decodeJsonCompactCorrect(buf: Buffer): LedgerRow[] { + const rows = jsonArray(buf) as string[][]; + const out: LedgerRow[] = new Array(rows.length); + for (let i = 0; i < rows.length; i++) { + const r = rows[i]!; + out[i] = { + txn_id: BigInt(r[0]!), + account: BigInt(r[1]!), + amount: parseDecimal(r[2]!, 18), + balance: parseDecimal(r[3]!, 18), + fee: parseDecimal(r[4]!, 4), + volume: BigInt(r[5]!), + }; + } + return out; +} + +// --- correctness cross-check + the corruption demonstration (runs at load) --- + +const eqDec = (a: DecimalValue, b: DecimalValue) => + a[0] === b[0] && a[1] === b[1]; +const eqRow = (a: LedgerRow, b: LedgerRow) => + a.txn_id === b.txn_id && + a.account === b.account && + eqDec(a.amount, b.amount) && + eqDec(a.balance, b.balance) && + eqDec(a.fee, b.fee) && + a.volume === b.volume; + +{ + const rb = decodeRowBinary(readLedgerRowFast); + const api = decodeRowBinary(readLedgerRow); + const jObj = decodeJsonObjectsCorrect(JSON_STR_BUF); + const jCompact = decodeJsonCompactCorrect(JSON_COMPACT_STR_BUF); + const bare = jsonArray(JSON_BARE_BUF) as Record[]; // the WRONG path + + if (rb.length !== N) + throw new Error(`RowBinary: ${rb.length} rows, expected ${N}`); + for (let i = 0; i < N; i++) { + if (!eqRow(rb[i]!, api[i]!)) + throw new Error(`ledger: API vs fast mismatch @${i}`); + if (!eqRow(rb[i]!, jObj[i]!)) + throw new Error(`ledger: RowBinary vs quoted-JSON mismatch @${i}`); + if (!eqRow(rb[i]!, jCompact[i]!)) + throw new Error(`ledger: RowBinary vs quoted-compact mismatch @${i}`); + } + + // The corruption: stock JSON.parse over the BARE numbers disagrees with the + // exact RowBinary value on every wide column of row 0. + const r0 = rb[0]!; + const b0 = bare[0]!; + console.log( + `\n Financial ledger — ${N.toLocaleString()} rows. Stock JSON.parse on bare numbers (row 0):\n` + + ` txn_id RowBinary ${r0.txn_id}\n` + + ` JSON.parse ${BigInt(Math.trunc(b0.txn_id as unknown as number)).toString()} ${BigInt(Math.trunc(b0.txn_id as unknown as number)) === r0.txn_id ? "ok" : "✗ CORRUPTED"}\n` + + ` account RowBinary ${r0.account}\n` + + ` JSON.parse ${b0.account} ${BigInt(b0.account!) === r0.account ? "ok" : "✗ CORRUPTED"}\n` + + ` amount RowBinary ${formatDecimal(r0.amount)}\n` + + ` JSON.parse ${b0.amount} ✗ CORRUPTED (only ~16 sig digits survive)\n`, + ); + + const mb = (b: Buffer) => (b.length / 1e6).toFixed(2); + const x = (b: Buffer) => `${(b.length / RB_BUF.length).toFixed(1)}x`; + console.log( + ` Wire size (correct paths quote wide values as strings):\n` + + ` RowBinary ${mb(RB_BUF)} MB\n` + + ` JSONCompactEachRow quoted ${mb(JSON_COMPACT_STR_BUF)} MB ${x(JSON_COMPACT_STR_BUF)}\n` + + ` JSONEachRow quoted ${mb(JSON_STR_BUF)} MB ${x(JSON_STR_BUF)}\n`, + ); +} + +// --- benchmarks ------------------------------------------------------------- + +describe("Financial ledger: RowBinary vs JSON decode throughput", () => { + bench("RowBinary — optimized (monomorphized)", () => { + decodeRowBinary(readLedgerRowFast); + }); + bench("RowBinary — API (combinators)", () => { + decodeRowBinary(readLedgerRow); + }); + bench( + "JSONCompactEachRow quoted — JSON.parse + BigInt/decimal (correct)", + () => { + decodeJsonCompactCorrect(JSON_COMPACT_STR_BUF); + }, + ); + bench("JSONEachRow quoted — JSON.parse + BigInt/decimal (correct)", () => { + decodeJsonObjectsCorrect(JSON_STR_BUF); + }); + bench("JSONEachRow bare — JSON.parse only (FAST BUT WRONG)", () => { + jsonArray(JSON_BARE_BUF); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/logs.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/logs.bench.ts new file mode 100644 index 000000000..6504863a5 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/logs.bench.ts @@ -0,0 +1,112 @@ +import { gzipSync, zstdCompressSync } from "node:zlib"; +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { + type LogRow, + readLogRow, + readLogRowFast, +} from "../src/examples/logs.js"; + +/** + * Benchmark: RowBinary vs JSON for a STRING-HEAVY application log table — the + * honest counter-case. The SKILL's format-choice guidance says prefer a `JSON*` + * format when the result is mostly strings consumed wholesale, because V8's + * native `JSON.parse` builds JS strings in optimized C++ faster than a JS-level + * RowBinary string decoder, and JSON's repetitive keys compress away on the + * wire. This measures both halves of that claim and is expected to show JSON + * WINNING — the result that makes the skill's "don't use RowBinary here" advice + * trustworthy. + */ +const N = 50_000; + +// Realistic log lines: repeated templates (compress well) with varying values, +// two LowCardinality columns, a high-cardinality hex trace_id. Deterministic. +const SELECT = + `SELECT ` + + `toDateTime(1700000000 + number) AS ts, ` + + `['INFO','INFO','INFO','WARN','ERROR','DEBUG'][number % 6 + 1]::LowCardinality(String) AS level, ` + + `['api','auth','db','cache','worker','scheduler'][number % 6 + 1]::LowCardinality(String) AS service, ` + + `concat('handled ', ['GET','POST','PUT'][number % 3 + 1], ' /api/v1/resource/', toString(number % 200), ` + + `' in ', toString(number % 1000), 'ms status=', toString([200,200,200,404,500][number % 5 + 1])) AS message, ` + + `lower(hex(MD5(toString(number)))) AS trace_id ` + + `FROM numbers(${N})`; + +const RB_BUF = await query(`${SELECT} FORMAT RowBinary`); +const JSON_BUF = await query(`${SELECT} FORMAT JSONEachRow`); +const JSON_COMPACT_BUF = await query(`${SELECT} FORMAT JSONCompactEachRow`); + +// --- decoders --------------------------------------------------------------- + +function decodeRowBinary(read: Reader): LogRow[] { + const s = new RowBinaryState(RB_BUF); + const out: LogRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +function decodeJsonArray(buf: Buffer): unknown[] { + return JSON.parse( + `[${buf.toString("utf8").trimEnd().replaceAll("\n", ",")}]`, + ); +} + +// --- correctness cross-check + wire-size report (runs at load) -------------- + +const COLS: (keyof LogRow)[] = ["level", "service", "message", "trace_id"]; +{ + const rb = decodeRowBinary(readLogRowFast); + const api = decodeRowBinary(readLogRow); + const je = decodeJsonArray(JSON_BUF) as Record[]; + const order: (keyof LogRow)[] = [ + "ts", + "level", + "service", + "message", + "trace_id", + ]; + const jc = decodeJsonArray(JSON_COMPACT_BUF) as string[][]; + + if (rb.length !== N) + throw new Error(`RowBinary: ${rb.length} rows, expected ${N}`); + if (JSON.stringify(api) !== JSON.stringify(rb)) + throw new Error("logs: API vs fast mismatch"); + for (const i of [0, 1, 123, 4999, N - 1]) { + for (const c of COLS) { + if (rb[i]![c] !== je[i]![c]) + throw new Error(`logs: ${c}@${i} RowBinary vs JSON mismatch`); + if (rb[i]![c] !== jc[i]![order.indexOf(c)]) + throw new Error(`logs: ${c}@${i} RowBinary vs compact mismatch`); + } + } + + const mb = (b: Buffer) => (b.length / 1e6).toFixed(2); + // Compressed wire size: what gzip / zstd on the HTTP response would send. + const gz = (b: Buffer) => (gzipSync(b, { level: 6 }).length / 1e6).toFixed(2); + const zs = (b: Buffer) => (zstdCompressSync(b).length / 1e6).toFixed(2); + const row = (name: string, b: Buffer) => + ` ${name.padEnd(18)} raw ${mb(b)} MB gzip ${gz(b)} MB zstd ${zs(b)} MB`; + console.log( + `\n Application logs — ${N.toLocaleString()} rows, wire size (raw + compressed):\n` + + `${row("RowBinary", RB_BUF)}\n` + + `${row("JSONCompactEachRow", JSON_COMPACT_BUF)}\n` + + `${row("JSONEachRow", JSON_BUF)}\n`, + ); +} + +// --- benchmarks ------------------------------------------------------------- + +describe("Application logs (string-heavy): RowBinary vs JSON decode throughput", () => { + bench("JSONEachRow — JSON.parse", () => { + decodeJsonArray(JSON_BUF); + }); + bench("JSONCompactEachRow — JSON.parse", () => { + decodeJsonArray(JSON_COMPACT_BUF); + }); + bench("RowBinary — optimized (monomorphized)", () => { + decodeRowBinary(readLogRowFast); + }); + bench("RowBinary — API (combinators)", () => { + decodeRowBinary(readLogRow); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/lowCardinality.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/lowCardinality.test.ts new file mode 100644 index 000000000..38d0965eb --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/lowCardinality.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readNullable } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readLowCardinality } from "../src/lowCardinality.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +/** + * `LowCardinality(T)` is TRANSPARENT in RowBinary: it is encoded byte-for-byte + * the same as `T`, with NO dictionary/index layer. (The dictionary encoding + * exists only in the Native format — do not look for it here.) So there is no + * dedicated reader: decode the inner `T` directly. + */ +describe("LowCardinality (transparent — decode as the inner type)", () => { + it("LowCardinality(String) decodes exactly like String", async () => { + const r = await reader("CAST('x' AS LowCardinality(String))"); + // readLowCardinality is the identity combinator: it just returns readString. + expect(readLowCardinality(readString)(r)).toBe("x"); // identical bytes to String 'x': 01 78 + expect(r.pos).toBe(2); + }); + + it("LowCardinality(Nullable(String)) is just the inner Nullable(String)", async () => { + const r = await reader("CAST(NULL AS LowCardinality(Nullable(String)))"); + expect(readNullable(readString)(r)).toBeNull(); + expect(r.pos).toBe(1); // lone null flag, no dictionary anything + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST('x' AS LowCardinality(String)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readString(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/nested.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/nested.test.ts new file mode 100644 index 000000000..005db5f73 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/nested.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readArray, readTupleNamed } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt8 } from "../src/integers.js"; +import { readNested } from "../src/nested.js"; +import { readString } from "../src/strings.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +/** + * `Nested(...)` has no wire format of its own: + * - With the default `flatten_nested=1`, a `Nested(a T1, b T2)` column expands + * into separate columns `a Array(T1)`, `b Array(T2)` — decode each with + * readArray. + * - With `flatten_nested=0`, the column is `Array(Tuple(a T1, b T2))` — decode + * with readArray + readTupleNamed (verified byte-identical to a real Nested + * column). + * + * Either way it reuses existing readers; there is no dedicated Nested reader. + */ +describe("Nested (decode as Array(Tuple(...)))", () => { + it("decodes a Nested column as an array of named rows", async () => { + // Byte-identical to `Nested(x UInt8, y String)` under flatten_nested=0. + const r = await reader( + "CAST([(1, 'a'), (2, 'b')] AS Array(Tuple(x UInt8, y String)))", + ); + // readNested is the thin alias readArray(readTupleNamed(...)). + const rows = readNested({ x: readUInt8, y: readString })(r); + expect(rows).toEqual([ + { x: 1, y: "a" }, + { x: 2, y: "b" }, + ]); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST([(1, 'a'), (2, 'b')] AS Array(Tuple(x UInt8, y String))) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readArray(readTupleNamed({ x: readUInt8, y: readString }))(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/nothing.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/nothing.test.ts new file mode 100644 index 000000000..807111f20 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/nothing.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readArray, readNullable } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readNothing } from "../src/nothing.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +/** + * `Nothing` is the empty type: it has NO values and occupies ZERO bytes. It is + * never a column on its own (you cannot materialize a value of it) — it only + * shows up wrapped, as the inferred element of a literal with no type: + * + * - `[]` -> `Array(Nothing)` -> always the empty array (varint len 0) + * - `NULL` -> `Nullable(Nothing)` -> always NULL (lone flag byte 0x01) + * + * So there is no dedicated reader, and no "read a Nothing" ever happens: the + * Array is empty (the element reader is not called) and the Nullable is NULL + * (the inner reader is not called). The throwing readers below assert exactly + * that — wrap with readArray / readNullable and the inner fn is unreachable. + * + * In practice, CAST a bare `[]`/`NULL` to a concrete type before SELECTing if + * you want real elements; `Nothing` only appears for untyped literals. + */ +describe("Nothing (zero-width — only appears as Array(Nothing) / Nullable(Nothing))", () => { + it("Array(Nothing) is the empty array; the element reader is never called", async () => { + const r = await reader("[]"); + // readNothing throws if ever called; the empty array means it never is. + expect(readArray(readNothing)(r)).toEqual([]); + expect(r.pos).toBe(1); // just the varint length 0x00 + }); + + it("Nullable(Nothing) is NULL; the inner reader is never called", async () => { + const r = await reader("NULL"); + // readNothing throws if ever called; the NULL flag means it never is. + expect(readNullable(readNothing)(r)).toBeNull(); + expect(r.pos).toBe(1); // lone NULL flag 0x01 + }); + + describe("advance() edge cases", () => { + it("Array(Nothing): throws NeedMoreData for every incomplete prefix", async () => { + const full = await query("SELECT [] FORMAT RowBinary"); // single 0x00 count byte + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readArray(() => { + throw new Error("element reader must not run"); + })(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len}`).toBe(NeedMoreData); + } + }); + + it("Nullable(Nothing): throws NeedMoreData for every incomplete prefix", async () => { + const full = await query("SELECT NULL FORMAT RowBinary"); // single 0x01 flag byte + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readNullable(() => { + throw new Error("inner reader must not run"); + })(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len}`).toBe(NeedMoreData); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/observability.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/observability.bench.ts new file mode 100644 index 000000000..f44376dfd --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/observability.bench.ts @@ -0,0 +1,64 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { + type ObsRow, + readObsRow, + readObsRowFast, +} from "../src/examples/observability.js"; + +/** + * API-combinator `readObsRow` vs flattened `readObsRowFast` over the same large + * buffer. The gotcha-heavy schema is also the most composite-heavy (Variant + + * Map + nested Tuple array + Nullable array), so it's where the flatten tier — + * coalesced `advance()` over the 33-byte fixed head, inlined reads, pre-sized + * arrays, `formatUUIDTable` — should pay the most. + */ +const N = 20_000; +const BUF = await query( + `SELECT + toUInt64(number) AS id, + toDateTime64('2021-01-01 00:00:00', 3, 'UTC') + number AS ts, + CAST(number % 4 + 1 AS Enum8('debug'=1,'info'=2,'warn'=3,'error'=4)) AS level, + generateUUIDv4() AS trace_id, + multiIf( + number % 3 = 0, CAST(toInt64(number) AS Variant(String, Int64, Float64)), + number % 3 = 1, CAST(concat('s', toString(number)) AS Variant(String, Int64, Float64)), + CAST(toFloat64(number) / 2 AS Variant(String, Int64, Float64)) + ) AS payload, + CAST(map('env','prod','az',toString(number % 3)) AS Map(LowCardinality(String), String)) AS tags, + arrayMap(x -> CAST(tuple(concat('m', toString(x)), toFloat64(x)/10) AS Tuple(name LowCardinality(String), value Float64)), range(number % 3)) AS metrics, + arrayMap(x -> CAST(if(x % 2 = 0, toInt64(x)*1000000000, NULL) AS Nullable(Int64)), range(number % 4)) AS attrs + FROM numbers(${N}) + SETTINGS allow_experimental_variant_type=1, allow_suspicious_variant_types=1, allow_suspicious_low_cardinality_types=1 + FORMAT RowBinary`, +); + +function decodeAll(read: Reader): ObsRow[] { + const s = new RowBinaryState(BUF); + const out: ObsRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +const norm = (rows: ObsRow[]): string => + JSON.stringify(rows, (_k, v) => + typeof v === "bigint" ? `${v}n` : v instanceof Map ? [...v] : v, + ); +{ + const a = decodeAll(readObsRow); + const b = decodeAll(readObsRowFast); + if (a.length !== N) + throw new Error(`observability: decoded ${a.length} rows, expected ${N}`); + if (norm(a) !== norm(b)) + throw new Error("observability: API vs fast mismatch"); +} + +describe("example observability: API vs optimized", () => { + bench("API (combinators)", () => { + decodeAll(readObsRow); + }); + bench("optimized (flattened)", () => { + decodeAll(readObsRowFast); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/observability.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/observability.example.test.ts new file mode 100644 index 000000000..e73f34def --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/observability.example.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { + type ObsRow, + readObsRow, + readObsRowFast, +} from "../src/examples/observability.js"; +import { readRows } from "../src/rows.js"; + +/** + * The gotcha-heavy example end to end: a single SELECT (no table needed) builds + * rows with `Variant`, `DateTime64(3)`, `Map(LowCardinality(String), String)`, + * `Array(Tuple(LowCardinality(String), Float64))`, `Array(Nullable(Int64))`, + * `Enum8`, and `UUID`, then both readers decode it. The experimental types need + * their `SETTINGS` flags on the query. + */ +const SQL = (n: number): string => + `SELECT + toUInt64(number) AS id, + toDateTime64('2021-01-01 00:00:00', 3, 'UTC') + number AS ts, + CAST(number % 4 + 1 AS Enum8('debug'=1,'info'=2,'warn'=3,'error'=4)) AS level, + generateUUIDv4() AS trace_id, + multiIf( + number % 3 = 0, CAST(toInt64(number) AS Variant(String, Int64, Float64)), + number % 3 = 1, CAST(concat('s', toString(number)) AS Variant(String, Int64, Float64)), + CAST(toFloat64(number) / 2 AS Variant(String, Int64, Float64)) + ) AS payload, + CAST(map('env','prod','az',toString(number % 3)) AS Map(LowCardinality(String), String)) AS tags, + arrayMap(x -> CAST(tuple(concat('m', toString(x)), toFloat64(x)/10) AS Tuple(name LowCardinality(String), value Float64)), range(number % 3)) AS metrics, + arrayMap(x -> CAST(if(x % 2 = 0, toInt64(x)*1000000000, NULL) AS Nullable(Int64)), range(number % 4)) AS attrs + FROM numbers(${n}) + SETTINGS allow_experimental_variant_type=1, allow_suspicious_variant_types=1, allow_suspicious_low_cardinality_types=1 + FORMAT RowBinary`; + +describe("example: observability (Variant / DateTime64 / LowCardinality / nested)", () => { + it("API and optimized readers agree, consume exactly, and decode the gotchas", async () => { + const buf = await query(SQL(64)); + + const a = new RowBinaryState(buf); + const viaApi: ObsRow[] = readRows(readObsRow)(a); + expect(a.pos, "API reader consumes the whole buffer").toBe(a.buf.length); + + const b = new RowBinaryState(buf); + const viaFast: ObsRow[] = readRows(readObsRowFast)(b); + expect(b.pos, "fast reader consumes the whole buffer").toBe(b.buf.length); + + // The optimized reader must produce byte-identical results to the API one. + expect(viaFast).toEqual(viaApi); + expect(viaApi).toHaveLength(64); + + // Spot-check the gotchas on the first rows. + expect(viaApi[0]!.id).toBe(0n); // UInt64 -> bigint + expect(viaApi[0]!.ts).toBe("2021-01-01T00:00:00.000Z"); // DateTime64(3) + expect(viaApi[0]!.level).toBe(1); // Enum8 underlying int ('debug') + expect(viaApi[0]!.tags).toEqual( + new Map([ + ["env", "prod"], + ["az", "0"], + ]), + ); + expect(viaApi[0]!.metrics).toEqual([]); + expect(viaApi[0]!.attrs).toEqual([]); + + // Variant active type rotates by row — proves the sort-by-type-name + // discriminant mapping (0=Float64, 1=Int64, 2=String) is right. + expect(viaApi[0]!.payload).toBe(0n); // number%3==0 -> Int64 + expect(viaApi[1]!.payload).toBe("s1"); // number%3==1 -> String + expect(viaApi[2]!.payload).toBe(1); // number==2 -> Float64 1.0 + + // Nested + Nullable + wide int. + expect(viaApi[2]!.metrics).toEqual([ + { name: "m0", value: 0 }, + { name: "m1", value: 0.1 }, + ]); + expect(viaApi[1]!.attrs).toEqual([0n]); + expect(viaApi[2]!.attrs).toEqual([0n, null]); + + // trace_id is a canonical UUID string. + expect(viaApi[0]!.traceId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/orders.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/orders.bench.ts new file mode 100644 index 000000000..9094e1f37 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/orders.bench.ts @@ -0,0 +1,48 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { + type OrderRow, + readOrderRow, + readOrderRowFast, +} from "../src/examples/orders.js"; + +/** + * Benchmark: API-combinator `readOrderRow` (BigInt `formatUUID`) vs + * `readOrderRowFast` (lookup-table `formatUUIDTable` + inlined reads) over the + * same large buffer. Since every row stringifies a UUID, the formatter swap is + * expected to dominate the win. + */ +const N = 20_000; +const BUF = await query( + `SELECT toUInt8(number % 251) AS id, generateUUIDv4() AS uid, ` + + `toDecimal64(number / 100, 2) AS price, ` + + `CAST(toInt8(number % 3 + 1) AS Enum8('new' = 1, 'shipped' = 2, 'done' = 3)) AS status ` + + `FROM numbers(${N}) FORMAT RowBinary`, +); + +function decodeAll(read: Reader): OrderRow[] { + const s = new RowBinaryState(BUF); + const out: OrderRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +const norm = (rows: OrderRow[]): string => + JSON.stringify(rows, (_k, v) => (typeof v === "bigint" ? `${v}n` : v)); +{ + const a = decodeAll(readOrderRow); + const b = decodeAll(readOrderRowFast); + if (a.length !== N) + throw new Error(`orders: decoded ${a.length} rows, expected ${N}`); + if (norm(a) !== norm(b)) throw new Error("orders: API vs fast mismatch"); +} + +describe("example orders: API vs optimized", () => { + bench("API (formatUUID + combinators)", () => { + decodeAll(readOrderRow); + }); + bench("optimized (formatUUIDTable + inlined)", () => { + decodeAll(readOrderRowFast); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/orders.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/orders.example.test.ts new file mode 100644 index 000000000..799ca17b4 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/orders.example.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { type OrderRow, readOrderRow } from "../src/examples/orders.js"; +import { readRows } from "../src/rows.js"; + +/** + * Runs the `orders` example end to end (UUID / Decimal / Enum). These types are + * awkward or lossy as JSON — UUID and Enum as names, Decimal as a float that + * can't represent every value exactly — so the rows go in as raw SQL `VALUES` + * instead of `JSONEachRow`. + */ +describe("example: orders (UUID / Decimal / Enum via raw VALUES)", () => { + it("creates, populates, and reads back through readOrderRow", async () => { + const t = "rb_example_orders"; + await query(`DROP TABLE IF EXISTS ${t}`); + await query( + `CREATE TABLE ${t} (` + + `id UInt8, ` + + `uid UUID, ` + + `price Decimal64(2), ` + + `status Enum8('new' = 1, 'shipped' = 2, 'done' = 3)` + + `) ENGINE = Memory`, + ); + try { + await query( + `INSERT INTO ${t} VALUES ` + + `(1, '61f0c404-5cb3-11e7-907b-a6006ad3dba0', 12.34, 'new'), ` + + `(2, '00000000-0000-0000-0000-000000000000', 0.00, 'shipped'), ` + + `(3, 'ffffffff-ffff-ffff-ffff-ffffffffffff', -9.99, 'done')`, + ); + + const r = new RowBinaryState( + await query( + `SELECT id, uid, price, status FROM ${t} ORDER BY id FORMAT RowBinary`, + ), + ); + const out: OrderRow[] = readRows(readOrderRow)(r); + expect(out).toEqual([ + { + id: 1, + uid: "61f0c404-5cb3-11e7-907b-a6006ad3dba0", + price: [1234n, 2], + status: 1, + }, + { + id: 2, + uid: "00000000-0000-0000-0000-000000000000", + price: [0n, 2], + status: 2, + }, + { + id: 3, + uid: "ffffffff-ffff-ffff-ffff-ffffffffffff", + price: [-999n, 2], + status: 3, + }, + ]); + expect(r.pos).toBe(r.buf.length); + } finally { + await query(`DROP TABLE IF EXISTS ${t}`); + } + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.bench.ts new file mode 100644 index 000000000..c2ed2a4db --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.bench.ts @@ -0,0 +1,47 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { + type ProfileRow, + readProfileRow, + readProfileRowFast, +} from "../src/examples/profiles.js"; + +/** + * Benchmark: API-combinator `readProfileRow` vs monomorphized + * `readProfileRowFast` over the same large buffer. This is the first case where + * the API version allocates a combinator closure per row (`readArray(readString)` + * and `readNullable(readInt32)`), so the inlined version should pull ahead. + */ +const N = 20_000; +const BUF = await query( + `SELECT toUInt32(number) AS id, ` + + `arrayMap(x -> concat('t', toString(x)), range(number % 4)) AS tags, ` + + `CAST(if(number % 3 = 0, NULL, toInt32(number) - 50) AS Nullable(Int32)) AS score ` + + `FROM numbers(${N}) FORMAT RowBinary`, +); + +function decodeAll(read: Reader): ProfileRow[] { + const s = new RowBinaryState(BUF); + const out: ProfileRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +const norm = (rows: ProfileRow[]): string => JSON.stringify(rows); +{ + const a = decodeAll(readProfileRow); + const b = decodeAll(readProfileRowFast); + if (a.length !== N) + throw new Error(`profiles: decoded ${a.length} rows, expected ${N}`); + if (norm(a) !== norm(b)) throw new Error("profiles: API vs fast mismatch"); +} + +describe("example profiles: API vs optimized", () => { + bench("API (combinators)", () => { + decodeAll(readProfileRow); + }); + bench("optimized (monomorphized)", () => { + decodeAll(readProfileRowFast); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.example.test.ts new file mode 100644 index 000000000..0588ce9ea --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.example.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { type ProfileRow, readProfileRow } from "../src/examples/profiles.js"; +import { readRows } from "../src/rows.js"; + +/** + * Runs the `profiles` example end to end (Array + Nullable). Populated via + * `JSONEachRow`; the empty array and the NULL score are the single-byte edge + * cases the reader has to get right. + */ +describe("example: profiles (Array + Nullable via JSONEachRow)", () => { + it("creates, populates, and reads back through readProfileRow", async () => { + const t = "rb_example_profiles"; + await query(`DROP TABLE IF EXISTS ${t}`); + await query( + `CREATE TABLE ${t} (id UInt32, tags Array(String), score Nullable(Int32)) ENGINE = Memory`, + ); + try { + const rows = [ + { id: 1, tags: ["a", "b"], score: 10 }, + { id: 2, tags: [], score: null }, + { id: 3, tags: ["solo"], score: -5 }, + ]; + await query( + `INSERT INTO ${t} FORMAT JSONEachRow\n` + + rows.map((r) => JSON.stringify(r)).join("\n"), + ); + + const r = new RowBinaryState( + await query( + `SELECT id, tags, score FROM ${t} ORDER BY id FORMAT RowBinary`, + ), + ); + const out: ProfileRow[] = readRows(readProfileRow)(r); + expect(out).toEqual([ + { id: 1, tags: ["a", "b"], score: 10 }, + { id: 2, tags: [], score: null }, + { id: 3, tags: ["solo"], score: -5 }, + ]); + expect(r.pos).toBe(r.buf.length); + } finally { + await query(`DROP TABLE IF EXISTS ${t}`); + } + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/qbit.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/qbit.test.ts new file mode 100644 index 000000000..dc76a5cc3 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/qbit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readQBit } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readBFloat16, readFloat32, readFloat64 } from "../src/floats.js"; +import { readUInt8 } from "../src/integers.js"; + +// QBit is experimental; the type needs allow_experimental_qbit_type. +async function reader(expr: string): Promise { + return new RowBinaryState( + await query( + `SELECT ${expr} SETTINGS allow_experimental_qbit_type = 1 FORMAT RowBinary`, + ), + ); +} + +/** + * `QBit(element_type, dimension)` is a vector-search type whose storage keeps + * the vector bit-transposed for quantized distance math. But that layout is a + * STORAGE / Native-format concern: in RowBinary a `QBit` is materialized as the + * plain vector — encoded byte-for-byte like `Array(element_type)` (a LEB128 + * length, then `dimension` element values). So `readQBit` is transparent: it + * just reads it as an array of the element type. + * + * The element type is one of the quantizable floats: `BFloat16`, `Float32`, + * `Float64` — read each with the matching float reader. + */ +describe("QBit (transparent in RowBinary — decode as Array(element_type))", () => { + it("QBit(Float32, N) decodes exactly like Array(Float32)", async () => { + const r = await reader("[1.0, 2.0, 3.0, 4.0]::QBit(Float32, 4)"); + expect(readQBit(readFloat32)(r)).toEqual([1, 2, 3, 4]); + expect(r.pos).toBe(1 + 4 * 4); // length byte + 4 Float32s + }); + + it("QBit(Float64, N) decodes exactly like Array(Float64)", async () => { + const r = await reader("[1.5, 2.5]::QBit(Float64, 2)"); + expect(readQBit(readFloat64)(r)).toEqual([1.5, 2.5]); + }); + + it("QBit(BFloat16, N) decodes exactly like Array(BFloat16)", async () => { + const r = await reader("[1.0, 2.0, 3.0, 4.0]::QBit(BFloat16, 4)"); + // BFloat16 of small integers is exact (they fit the float32 high half). + expect(readQBit(readBFloat16)(r)).toEqual([1, 2, 3, 4]); + }); + + it("is length-prefixed like Array — the next column starts right after", async () => { + const r = await reader( + "[1.0, 2.0]::QBit(Float32, 2) AS q, toUInt8(255) AS m", + ); + expect(readQBit(readFloat32)(r)).toEqual([1, 2]); + expect(r.pos).toBe(1 + 2 * 4); // exact: no quantization padding + expect(readUInt8(r)).toBe(255); // proves the framing matched + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT [1.0, 2.0]::QBit(Float32, 2) SETTINGS allow_experimental_qbit_type = 1 FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readQBit(readFloat32)(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/readUUID.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/readUUID.bench.ts new file mode 100644 index 000000000..5f894b5ca --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/readUUID.bench.ts @@ -0,0 +1,38 @@ +import { bench, describe } from "vitest"; +import { formatUUID, formatUUIDTable } from "../src/reader.js"; + +/** + * Benchmark: the BigInt-based formatUUID vs the lookup-table formatUUIDTable + * (byte -> packed-hex table written into a preallocated buffer, no BigInt, no + * intermediate slices). Both format the same raw 16 bytes. + * + * This benchmark is reader-independent: each case formats a static 16-byte + * buffer directly, so it measures only the formatting cost — `readUUID` just + * returns a zero-copy 16-byte subarray, which would otherwise add noise. + */ + +// Wire bytes for 61f0c404-5cb3-11e7-907b-a6006ad3dba0 (two LE UInt64 halves). +const WIRE = Buffer.from([ + 0xe7, 0x11, 0xb3, 0x5c, 0x04, 0xc4, 0xf0, 0x61, 0xa0, 0xdb, 0xd3, 0x6a, 0x00, + 0xa6, 0x7b, 0x90, +]); +const EXPECTED = "61f0c404-5cb3-11e7-907b-a6006ad3dba0"; + +// Equivalence guard: a faster wrong answer is worthless. Validate before timing. +const viaFormat = formatUUID(WIRE); +const viaTable = formatUUIDTable(WIRE); +if (viaFormat !== EXPECTED || viaTable !== EXPECTED) { + throw new Error( + `UUID mismatch: format=${viaFormat} table=${viaTable} expected=${EXPECTED}`, + ); +} + +describe("UUID formatting", () => { + bench("formatUUID (BigInt + toString)", () => { + formatUUID(WIRE); + }); + + bench("formatUUIDTable (lookup table + preallocated buffer)", () => { + formatUUIDTable(WIRE); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/rows.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/rows.test.ts new file mode 100644 index 000000000..302241018 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/rows.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { readInt32, readUInt64 } from "../src/integers.js"; +import { readRows } from "../src/rows.js"; +import { readString } from "../src/strings.js"; + +/** + * Multi-row tests: plain `RowBinary` concatenates rows back-to-back with no row + * count, length prefix, or delimiter between them — a row is just its columns, + * and the next row's bytes begin immediately after the previous row's last + * column. So decoding N rows is the same per-row read run N times against one + * shared buffer/cursor. + * + * Each test asserts two things: (a) every row decodes to the expected values, + * and (b) the cursor lands EXACTLY on `buf.length` after the final row. The + * end-position check is the tight one — if any row's reader stopped one byte + * short or long, the misalignment compounds across rows and the cursor misses + * the end, so a wrong row boundary can't slip through unnoticed. + * + * Variable-width columns (String) are the sharp case: there is no fixed row + * stride to resync on, so the only thing keeping rows aligned is each reader + * consuming exactly its bytes. + */ +describe("multiple rows from one buffer", () => { + it("fixed-width single column: read a known row count in a loop", async () => { + const r = new RowBinaryState( + await query("SELECT toInt32(number) FROM numbers(5) FORMAT RowBinary"), + ); + const out: number[] = []; + for (let i = 0; i < 5; i++) out.push(readInt32(r)); + expect(out).toEqual([0, 1, 2, 3, 4]); + expect(r.pos).toBe(r.buf.length); + }); + + it("two fixed-width columns per row", async () => { + const r = new RowBinaryState( + await query( + "SELECT toUInt64(number), toInt32(-number) FROM numbers(4) FORMAT RowBinary", + ), + ); + const out: Array<[bigint, number]> = []; + for (let i = 0; i < 4; i++) out.push([readUInt64(r), readInt32(r)]); + expect(out).toEqual([ + [0n, 0], + [1n, -1], + [2n, -2], + [3n, -3], + ]); + expect(r.pos).toBe(r.buf.length); + }); + + it("variable-width column: rows of differing String length stay aligned", async () => { + // repeat('x', number) yields strings of length 0,1,2,3,4 — every row has a + // different byte width, so alignment depends entirely on readString + // consuming exactly its varint length + bytes. + const r = new RowBinaryState( + await query( + "SELECT repeat('x', number) FROM numbers(5) FORMAT RowBinary", + ), + ); + const out: string[] = []; + for (let i = 0; i < 5; i++) out.push(readString(r)); + expect(out).toEqual(["", "x", "xx", "xxx", "xxxx"]); + expect(r.pos).toBe(r.buf.length); + }); + + it("mixed fixed + variable columns per row", async () => { + const r = new RowBinaryState( + await query( + "SELECT number AS n, repeat('ab', number) AS s FROM numbers(3) FORMAT RowBinary", + ), + ); + const out: Array<[bigint, string]> = []; + for (let i = 0; i < 3; i++) out.push([readUInt64(r), readString(r)]); + expect(out).toEqual([ + [0n, ""], + [1n, "ab"], + [2n, "abab"], + ]); + expect(r.pos).toBe(r.buf.length); + }); + + it("drives the loop on cursor position, without knowing the row count", async () => { + // With no row count on the wire, a reader that doesn't know N up front + // loops until the cursor reaches the buffer end. This works precisely + // because each row consumes exactly its bytes — the end is a row boundary. + const r = new RowBinaryState( + await query("SELECT toInt32(number) FROM numbers(10) FORMAT RowBinary"), + ); + const out: number[] = []; + while (r.pos < r.buf.length) out.push(readInt32(r)); + expect(out).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(r.pos).toBe(r.buf.length); + }); + + it("zero rows: an empty result is an empty buffer", async () => { + const r = new RowBinaryState( + await query("SELECT toInt32(1) WHERE 0 FORMAT RowBinary"), + ); + expect(r.buf.length).toBe(0); + const out: number[] = []; + while (r.pos < r.buf.length) out.push(readInt32(r)); + expect(out).toEqual([]); + expect(r.pos).toBe(r.buf.length); + }); + + describe("readRows() helper: the position-bounded loop as a method", () => { + it("reads every row via a per-row callback", async () => { + const r = new RowBinaryState( + await query( + "SELECT number AS id, repeat('ab', number) AS name FROM numbers(3) FORMAT RowBinary", + ), + ); + const out = readRows((s) => ({ + id: readUInt64(s), + name: readString(s), + }))(r); + expect(out).toEqual([ + { id: 0n, name: "" }, + { id: 1n, name: "ab" }, + { id: 2n, name: "abab" }, + ]); + expect(r.pos).toBe(r.buf.length); + }); + + it("returns [] for an empty result", async () => { + const r = new RowBinaryState( + await query("SELECT toInt32(1) WHERE 0 FORMAT RowBinary"), + ); + expect(readRows(readInt32)(r)).toEqual([]); + expect(r.pos).toBe(r.buf.length); + }); + }); + + describe("readRows() with NeedMoreData (partial trailing row)", () => { + it("returns the complete rows and rewinds pos to the last row boundary", async () => { + // 4 rows of (UInt64, String) — fixed 8 bytes + a varint-prefixed string. + const full = await query( + "SELECT number AS id, repeat('ab', number) AS s FROM numbers(4) FORMAT RowBinary", + ); + + // Find the byte offset where row 2 (0-indexed) ends, by decoding the full + // buffer once and committing per row. + const probe = new RowBinaryState(full); + const ends: number[] = []; + readRows((s) => { + readUInt64(s); + readString(s); + ends.push(s.pos); + return null; + })(probe); + // Cut the buffer one byte before the end so the LAST row is truncated. + const r = new RowBinaryState(full.subarray(0, full.length - 1)); + const rows = readRows((s) => ({ + id: readUInt64(s), + s: readString(s), + }))(r); + // Only the 3 complete rows come back; the 4th (straddling) is dropped. + expect(rows).toEqual([ + { id: 0n, s: "" }, + { id: 1n, s: "ab" }, + { id: 2n, s: "abab" }, + ]); + // pos is rewound to the start of the incomplete 4th row, NOT left mid-row. + expect(r.pos).toBe(ends[2]); + expect(r.pos).toBeLessThan(r.buf.length); + }); + + it("drives a chunked stream to completion via the commit point", async () => { + const full = await query( + "SELECT number AS id, repeat('x', number % 7) AS s FROM numbers(50) FORMAT RowBinary", + ); + const expected = Array.from({ length: 50 }, (_, i) => ({ + id: BigInt(i), + s: "x".repeat(i % 7), + })); + + // Reveal the buffer in fixed chunks; each step decodes whatever complete + // rows are now visible and carries the commit point forward. Tiny chunk + // sizes guarantee rows straddle boundaries. + for (const chunk of [1, 5, 13, 4096]) { + const rows: Array<{ id: bigint; s: string }> = []; + let committed = 0; + let avail = 0; + while (committed < full.length) { + avail = Math.min(full.length, avail + chunk); + const r = new RowBinaryState(full.subarray(0, avail)); + r.pos = committed; + rows.push( + ...readRows((s) => ({ id: readUInt64(s), s: readString(s) }))(r), + ); + // Guard against a stall: a non-final chunk that completed no new row + // just means we need more bytes — the loop reveals more next pass. + committed = r.pos; + } + expect(rows, `chunk size ${chunk}`).toEqual(expected); + } + }); + + it("still propagates a non-NeedMoreData error from the row reader", async () => { + const r = new RowBinaryState( + await query("SELECT toInt32(1) FROM numbers(3) FORMAT RowBinary"), + ); + const boom = new Error("decode fault"); + expect(() => + readRows(() => { + throw boom; + })(r), + ).toThrow(boom); + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/simpleAggregateFunction.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/simpleAggregateFunction.test.ts new file mode 100644 index 000000000..092516bad --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/simpleAggregateFunction.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { readArray } from "../src/composite.js"; +import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { readUInt64, readUInt8 } from "../src/integers.js"; +import { readSimpleAggregateFunction } from "../src/simpleAggregateFunction.js"; + +async function reader(expr: string): Promise { + return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +} + +/** + * `SimpleAggregateFunction(func, T)` is TRANSPARENT in RowBinary: the column + * already holds a finished value of the underlying type `T` (the partial + * aggregate of a "simple" function — sum, min, max, groupArrayArray, ... — is + * just a value of `T`), so it is encoded byte-for-byte the same as `T`. There + * is no dedicated reader: decode the inner `T` directly. + * + * Do NOT confuse it with `AggregateFunction(func, T)`, whose value is an opaque + * serialized aggregation STATE with a function-specific binary layout. + */ +describe("SimpleAggregateFunction (transparent — decode as the inner type)", () => { + it("SimpleAggregateFunction(sum, UInt64) decodes exactly like UInt64", async () => { + const r = await reader("CAST(42 AS SimpleAggregateFunction(sum, UInt64))"); + // readSimpleAggregateFunction is the identity combinator: just readUInt64. + expect(readSimpleAggregateFunction(readUInt64)(r)).toBe(42n); // identical bytes to UInt64 42: 2a 00 00 00 00 00 00 00 + expect(r.pos).toBe(8); + }); + + it("SimpleAggregateFunction(groupArrayArray, Array(UInt8)) is just Array(UInt8)", async () => { + const r = await reader( + "CAST([1, 2, 3] AS SimpleAggregateFunction(groupArrayArray, Array(UInt8)))", + ); + expect(readArray(readUInt8)(r)).toEqual([1, 2, 3]); // varint len 03, then bytes + expect(r.pos).toBe(4); + }); + + describe("advance() edge cases", () => { + it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { + const full = await query( + "SELECT CAST(42 AS SimpleAggregateFunction(sum, UInt64)) FORMAT RowBinary", + ); + for (let len = 0; len < full.length; len++) { + const r = new RowBinaryState(full.subarray(0, len)); + let thrown: unknown; + try { + readUInt64(r); + } catch (e) { + thrown = e; + } + expect(thrown, `prefix length ${len} of ${full.length}`).toBe( + NeedMoreData, + ); + } + }); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/streamRowBatches.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/streamRowBatches.test.ts new file mode 100644 index 000000000..c46e87198 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/streamRowBatches.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { readUInt64 } from "../src/integers.js"; +import { type SmallChunkStats, streamRowBatches } from "../src/stream.js"; +import { readString } from "../src/strings.js"; + +/** + * `streamRowBatches` is the async front door over `readRows`: an async iterable + * of byte chunks in, an async generator of row batches out. A real HTTP response + * arrives as such a chunk stream; here we take the full `FORMAT RowBinary` bytes + * ClickHouse returns and re-slice them into fixed-size chunks, which is exactly + * what the function consumes — and lets us drive every chunk-boundary case + * (mid-field, mid-row, aligned) deterministically. + */ +async function* chunked(buf: Buffer, size: number): AsyncGenerator { + for (let i = 0; i < buf.length; i += size) { + yield buf.subarray(i, Math.min(i + size, buf.length)); + } +} + +type Row = { id: bigint; s: string }; +const readRow = (s: RowBinaryState): Row => ({ + id: readUInt64(s), + s: readString(s), +}); + +describe("streamRowBatches (async, chunked stream -> row batches)", () => { + it("reassembles every row across chunk sizes, with no empty batches", async () => { + const full = await query( + "SELECT number AS id, repeat('x', number % 9) AS s FROM numbers(60) FORMAT RowBinary", + ); + const expected: Row[] = Array.from({ length: 60 }, (_, i) => ({ + id: BigInt(i), + s: "x".repeat(i % 9), + })); + + // Tiny sizes force rows to straddle boundaries; the large one delivers + // everything in a single batch. + for (const size of [1, 3, 13, 64, full.length, full.length * 2]) { + const batches: Row[][] = []; + for await (const batch of streamRowBatches( + chunked(full, size), + readRow, + )) { + batches.push(batch); + } + expect( + batches.every((b) => b.length > 0), + `chunk size ${size}: no empty batches`, + ).toBe(true); + expect(batches.flat(), `chunk size ${size}`).toEqual(expected); + } + }); + + it("delivers everything in one batch when the whole buffer is one chunk", async () => { + const full = await query( + "SELECT number AS id, repeat('y', number) AS s FROM numbers(5) FORMAT RowBinary", + ); + const batches: Row[][] = []; + for await (const batch of streamRowBatches( + chunked(full, full.length), + readRow, + )) { + batches.push(batch); + } + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(5); + }); + + it("yields nothing for an empty result", async () => { + const full = await query("SELECT toInt32(1) WHERE 0 FORMAT RowBinary"); + expect(full.length).toBe(0); + const batches: Row[][] = []; + for await (const batch of streamRowBatches(chunked(full, 8), readRow)) { + batches.push(batch); + } + expect(batches).toEqual([]); + }); + + it("throws when the stream ends mid-row (truncated response)", async () => { + const full = await query( + "SELECT number AS id, repeat('z', number + 1) AS s FROM numbers(4) FORMAT RowBinary", + ); + const truncated = full.subarray(0, full.length - 1); // cut the last row short + const consume = async () => { + const out: Row[] = []; + for await (const batch of streamRowBatches( + chunked(truncated, 5), + readRow, + )) { + out.push(...batch); + } + return out; + }; + await expect(consume()).rejects.toThrow(/ended mid-row/); + }); + + it("warns once when chunks are pathologically small (rows straddle them)", async () => { + const full = await query( + "SELECT number AS id, repeat('w', number % 5) AS s FROM numbers(80) FORMAT RowBinary", + ); + const warnings: SmallChunkStats[] = []; + const out: Row[] = []; + // 1-byte chunks: every row spans many chunks, so rows/chunk is far below 1. + for await (const batch of streamRowBatches(chunked(full, 1), readRow, { + warnOnSmallChunks: { warn: (_msg, stats) => warnings.push(stats) }, + })) { + out.push(...batch); + } + expect(out).toHaveLength(80); // still decodes correctly + expect(warnings).toHaveLength(1); // fires exactly once, not per chunk + expect(warnings[0]!.rowsPerChunk).toBeLessThan(2); + expect(warnings[0]!.chunks).toBeGreaterThanOrEqual(16); // past the default warmup + }); + + it("does not warn on a healthy stream (many rows per chunk)", async () => { + const full = await query( + "SELECT number AS id, '' AS s FROM numbers(2000) FORMAT RowBinary", + ); + let warned = false; + // ~50-byte rows in 4 KB chunks → ~450 rows/chunk, well above the threshold, + // and a low warmup so the average is actually evaluated. + for await (const batch of streamRowBatches(chunked(full, 4096), readRow, { + warnOnSmallChunks: { warmupChunks: 2, warn: () => (warned = true) }, + })) { + void batch; + } + expect(warned).toBe(false); + }); + + it("respects warnOnSmallChunks: false (disabled)", async () => { + const full = await query( + "SELECT number AS id, '' AS s FROM numbers(80) FORMAT RowBinary", + ); + let warned = false; + const orig = console.warn; + console.warn = () => (warned = true); // would catch the default sink too + try { + for await (const batch of streamRowBatches(chunked(full, 1), readRow, { + warnOnSmallChunks: false, + })) { + void batch; + } + } finally { + console.warn = orig; + } + expect(warned).toBe(false); + }); + + it("accepts plain Uint8Array chunks (not just Buffer)", async () => { + const full = await query( + "SELECT number AS id, '' AS s FROM numbers(3) FORMAT RowBinary", + ); + async function* asU8(): AsyncGenerator { + // Hand back a non-Buffer view to exercise the normalization path. + yield new Uint8Array(full.buffer, full.byteOffset, full.byteLength); + } + const batches: Row[][] = []; + for await (const batch of streamRowBatches(asU8(), readRow)) { + batches.push(batch); + } + expect(batches.flat()).toEqual([ + { id: 0n, s: "" }, + { id: 1n, s: "" }, + { id: 2n, s: "" }, + ]); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/streamingRow.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/streamingRow.bench.ts new file mode 100644 index 000000000..b619b0860 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/streamingRow.bench.ts @@ -0,0 +1,227 @@ +import { bench, describe } from "vitest"; + +/** + * Streaming "need more bytes" mechanism benchmark — throw vs generator-yield. + * + * A streaming parser must, when it runs out of bytes mid-result, suspend and + * resume once more arrive. Two ways to signal "give me more bytes": + * + * 1. THROW a sentinel and let the driver re-enter the parser (this file's + * `parseThrow`). A plain function; the cost is throw/catch stack unwinding. + * 2. YIELD a request from a generator and resume it via `.next()` once bytes + * arrive. Ergonomic — the parser reads as if synchronous — but every read + * runs inside a generator state machine. + * + * To measure ONLY that mechanism, the model is deliberately stripped down: + * + * - The stream is ONE contiguous buffer whose AVAILABLE length grows. "More + * bytes arrived" = bump `avail` by a chunk. So no contender pays for + * stitching separate chunk buffers — that cost is identical for both in + * reality and would only add noise here. + * - The row shape is known (5 × little-endian UInt32 = 20 bytes), as it always + * is for a bespoke generated parser. So each parser checks whether the WHOLE + * row is available before reading any field. Consequence: neither parser + * ever re-reads a field on resume — `parseThrow` restarts from a clean row + * boundary, the generators suspend between rows. The "generators avoid + * re-parsing" argument therefore does NOT apply here; what's left is purely + * throw/catch unwinding vs generator resume. + * + * Contenders: + * - throw + restart — plain function, `throw MORE` when starved. + * - generator (yield* reader) — combinator style: `const a = yield* r.u32()`. + * Two levels of generator delegation per field; + * the "great on paper" form. + * - generator (inline yield) — one generator, row-level `while(...) yield`, + * fields read inline. The lean generator, shown + * so the combinator's overhead isn't mistaken + * for "all generators". + * + * Two chunk regimes are timed: a realistic large chunk (suspends rarely; steady + * state dominates) and a tiny sub-row chunk (suspends constantly; the mechanism + * cost dominates). Read the numbers on your own machine — that's the point. + */ + +const ROWS = 50_000; +const FIELDS_PER_ROW = 5; +const ROW_BYTES = FIELDS_PER_ROW * 4; // 5 × UInt32 +const N = ROWS * FIELDS_PER_ROW; // total field count +const TOTAL = ROWS * ROW_BYTES; + +// Build the payload once: field k (global index) holds the value k, so the +// expected checksum is the exact triangular sum and stays < 2^53 (no masking). +const PAYLOAD = new Uint8Array(TOTAL); +{ + const dv = new DataView(PAYLOAD.buffer); + for (let k = 0; k < N; k++) dv.setUint32(k * 4, k, true); +} +const EXPECTED = { rows: ROWS, sum: (N * (N - 1)) / 2 }; + +type Result = { rows: number; sum: number }; + +/** Singleton sentinel thrown on starvation — a bare value, so no Error stack is + * captured (that capture, not the unwind, is what makes throwing Errors slow). */ +const MORE = Symbol("need-more-bytes"); + +/** + * THROW approach. `avail` grows by `chunkSize` each time the parser starves. + * Reads restart from `committed` (the last completed row); because the whole-row + * check precedes any field read, a restart re-reads nothing. + */ +function parseThrow(bytes: Uint8Array, chunkSize: number): Result { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let avail = 0; + let committed = 0; + let rows = 0; + let sum = 0; + for (;;) { + try { + let pos = committed; + while (pos < TOTAL) { + if (pos + ROW_BYTES > avail) throw MORE; + sum += + dv.getUint32(pos, true) + + dv.getUint32(pos + 4, true) + + dv.getUint32(pos + 8, true) + + dv.getUint32(pos + 12, true) + + dv.getUint32(pos + 16, true); + pos += ROW_BYTES; + rows++; + committed = pos; + } + return { rows, sum }; + } catch (err) { + if (err !== MORE) throw err; + avail = Math.min(TOTAL, avail + chunkSize); + } + } +} + +/** + * Combinator generator reader. Each `u32()` is itself a generator that suspends + * until its 4 bytes are available, so the parse body reads as if synchronous: + * `const a = yield* r.u32()`. `avail` is mutated on the reader between resumes. + */ +function makeGenReader(bytes: Uint8Array) { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { + pos: 0, + avail: 0, + *u32(): Generator { + while (this.pos + 4 > this.avail) yield; + const v = dv.getUint32(this.pos, true); + this.pos += 4; + return v; + }, + }; +} + +function* parseGenCombinator( + r: ReturnType, +): Generator { + let rows = 0; + let sum = 0; + while (r.pos < TOTAL) { + const a = yield* r.u32(); + const b = yield* r.u32(); + const c = yield* r.u32(); + const d = yield* r.u32(); + const e = yield* r.u32(); + sum += a + b + c + d + e; + rows++; + } + return { rows, sum }; +} + +/** + * Lean generator: a single generator, whole-row availability checked with an + * inline `while (...) yield`, fields read inline. No per-field delegation, so it + * runs at near-normal speed between the (rare) suspensions. + */ +function* parseGenInline( + bytes: Uint8Array, + box: { avail: number }, +): Generator { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let pos = 0; + let rows = 0; + let sum = 0; + while (pos < TOTAL) { + while (pos + ROW_BYTES > box.avail) yield; + sum += + dv.getUint32(pos, true) + + dv.getUint32(pos + 4, true) + + dv.getUint32(pos + 8, true) + + dv.getUint32(pos + 12, true) + + dv.getUint32(pos + 16, true); + pos += ROW_BYTES; + rows++; + } + return { rows, sum }; +} + +/** Drive a generator to completion, revealing `chunkSize` more bytes per + * suspension via the shared `holder.avail`. Returns the generator's `return`. */ +function driveGen( + gen: Generator, + holder: { avail: number }, + chunkSize: number, +): Result { + let step = gen.next(); + while (!step.done) { + holder.avail = Math.min(TOTAL, holder.avail + chunkSize); + step = gen.next(); + } + return step.value; +} + +function runGenCombinator(chunkSize: number): Result { + const r = makeGenReader(PAYLOAD); + return driveGen(parseGenCombinator(r), r, chunkSize); +} + +function runGenInline(chunkSize: number): Result { + const box = { avail: 0 }; + return driveGen(parseGenInline(PAYLOAD, box), box, chunkSize); +} + +// Equivalence guard: a faster wrong answer is worthless. Validate every +// contender at both chunk regimes before any timing runs. +function assertCorrect(label: string, got: Result): void { + if (got.rows !== EXPECTED.rows || got.sum !== EXPECTED.sum) { + throw new Error( + `${label} mismatch: got rows=${got.rows} sum=${got.sum}, ` + + `expected rows=${EXPECTED.rows} sum=${EXPECTED.sum}`, + ); + } +} +for (const cs of [64 * 1024, 8]) { + assertCorrect(`throw cs=${cs}`, parseThrow(PAYLOAD, cs)); + assertCorrect(`gen-combinator cs=${cs}`, runGenCombinator(cs)); + assertCorrect(`gen-inline cs=${cs}`, runGenInline(cs)); +} + +describe("streaming need-more-bytes: 64 KB chunks (suspends rarely)", () => { + const cs = 64 * 1024; + bench("throw + restart", () => { + parseThrow(PAYLOAD, cs); + }); + bench("generator (yield* reader)", () => { + runGenCombinator(cs); + }); + bench("generator (inline yield)", () => { + runGenInline(cs); + }); +}); + +describe("streaming need-more-bytes: 8-byte chunks (suspends constantly)", () => { + const cs = 8; + bench("throw + restart", () => { + parseThrow(PAYLOAD, cs); + }); + bench("generator (yield* reader)", () => { + runGenCombinator(cs); + }); + bench("generator (inline yield)", () => { + runGenInline(cs); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.bench.ts new file mode 100644 index 000000000..afb346af6 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.bench.ts @@ -0,0 +1,50 @@ +import { bench, describe } from "vitest"; +import { query } from "./clickhouse.js"; +import { type Reader, RowBinaryState } from "../src/core.js"; +import { + type TelemetryRow, + readTelemetryRow, + readTelemetryRowFast, +} from "../src/examples/telemetry.js"; + +/** + * Benchmark: API-combinator `readTelemetryRow` vs fully monomorphized + * `readTelemetryRowFast` over the same large buffer. The most composite-heavy + * example — Map + Array + Nullable + named Tuple — so the API version builds four + * closures per row plus a keyed object build; the biggest expected win. + */ +const N = 20_000; +const BUF = await query( + `SELECT concat('h', toString(number)) AS host, ` + + `map('env', 'prod', 'az', toString(number % 3)) AS tags, ` + + `arrayMap(x -> toFloat64(x) / 10, range(number % 5)) AS cpu, ` + + `CAST(if(number % 2 = 0, 'us', NULL) AS Nullable(String)) AS region, ` + + `CAST(tuple(toUInt32(number), toUInt16(number % 100)) AS Tuple(start UInt32, count UInt16)) AS window ` + + `FROM numbers(${N}) FORMAT RowBinary`, +); + +function decodeAll(read: Reader): TelemetryRow[] { + const s = new RowBinaryState(BUF); + const out: TelemetryRow[] = []; + while (s.pos < s.buf.length) out.push(read(s)); + return out; +} + +const norm = (rows: TelemetryRow[]): string => + JSON.stringify(rows, (_k, v) => (v instanceof Map ? [...v] : v)); +{ + const a = decodeAll(readTelemetryRow); + const b = decodeAll(readTelemetryRowFast); + if (a.length !== N) + throw new Error(`telemetry: decoded ${a.length} rows, expected ${N}`); + if (norm(a) !== norm(b)) throw new Error("telemetry: API vs fast mismatch"); +} + +describe("example telemetry: API vs optimized", () => { + bench("API (combinators)", () => { + decodeAll(readTelemetryRow); + }); + bench("optimized (monomorphized)", () => { + decodeAll(readTelemetryRowFast); + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.example.test.ts new file mode 100644 index 000000000..4e865ecef --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.example.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { query } from "./clickhouse.js"; +import { RowBinaryState } from "../src/core.js"; +import { + type TelemetryRow, + readTelemetryRow, +} from "../src/examples/telemetry.js"; +import { readRows } from "../src/rows.js"; + +/** + * Runs the `telemetry` example end to end (Map / Array / Nullable / named + * Tuple). Populated via `JSONEachRow`; the second row exercises every empty / + * NULL branch at once (empty Map, empty Array, NULL region). + */ +describe("example: telemetry (composite columns via JSONEachRow)", () => { + it("creates, populates, and reads back through readTelemetryRow", async () => { + const t = "rb_example_telemetry"; + await query(`DROP TABLE IF EXISTS ${t}`); + await query( + `CREATE TABLE ${t} (` + + `host String, ` + + `tags Map(String, String), ` + + `cpu Array(Float64), ` + + `region Nullable(String), ` + + `window Tuple(start UInt32, count UInt16)` + + `) ENGINE = Memory`, + ); + try { + const rows = [ + { + host: "a", + tags: { env: "prod", az: "1" }, + cpu: [0.5, 0.25], + region: "us", + window: { start: 1000, count: 3 }, + }, + { + host: "b", + tags: {}, + cpu: [], + region: null, + window: { start: 2000, count: 0 }, + }, + ]; + await query( + `INSERT INTO ${t} FORMAT JSONEachRow\n` + + rows.map((r) => JSON.stringify(r)).join("\n"), + ); + + const r = new RowBinaryState( + await query( + `SELECT host, tags, cpu, region, window FROM ${t} ORDER BY host FORMAT RowBinary`, + ), + ); + const out: TelemetryRow[] = readRows(readTelemetryRow)(r); + expect(out).toEqual([ + { + host: "a", + tags: new Map([ + ["env", "prod"], + ["az", "1"], + ]), + cpu: [0.5, 0.25], + region: "us", + window: { start: 1000, count: 3 }, + }, + { + host: "b", + tags: new Map(), + cpu: [], + region: null, + window: { start: 2000, count: 0 }, + }, + ]); + expect(r.pos).toBe(r.buf.length); + } finally { + await query(`DROP TABLE IF EXISTS ${t}`); + } + }); +}); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/wasm-int128.experiment.mjs b/skills/clickhouse-js-node-rowbinary-parser/tests/wasm-int128.experiment.mjs new file mode 100644 index 000000000..9f30b82dc --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/wasm-int128.experiment.mjs @@ -0,0 +1,246 @@ +/** + * WASM proof kernel — the one experiment from the "why JS, not WASM" case study. + * + * Sums an `Int128` column three ways over the SAME 32 MB RowBinary buffer: + * 1. JS BigInt — what JS MUST do to add 128-bit integers (heap bigints). + * 2. JS f64 fold — the raw read floor (reads the same bytes, wrong math) to + * show how fast V8 streams the memory: "JITed JS at mem speed". + * 3. WASM — a hand-emitted kernel doing native i64 add-with-carry, + * the one place WASM structurally beats JS. + * + * Also measures the boundary tax: copying the buffer into WASM linear memory. + * + * Run: node tests/wasm-int128.experiment.mjs + * Needs a live ClickHouse at $CLICKHOUSE_URL (default http://localhost:8123). + */ +const URL_BASE = process.env.CLICKHOUSE_URL ?? "http://localhost:8123"; +const N = 2_000_000; + +// --- 1. hand-emit a tiny WASM module: void sum128(i32 ptr, i32 lenBytes) ---- +// It folds 16-byte little-endian Int128s into a 128-bit accumulator (two i64s +// with carry) and writes [lo @ mem0, hi @ mem8]. The whole point: the per-row +// add never touches the JS heap. +const leb = (n) => { + const out = []; + do { + let b = n & 0x7f; + n >>>= 7; + if (n) b |= 0x80; + out.push(b); + } while (n); + return out; +}; +const str = (s) => [s.length, ...[...Buffer.from(s)]]; +const section = (id, content) => [id, ...leb(content.length), ...content]; + +// locals after params (0=ptr i32, 1=len i32): +// 2=p i32, 3=end i32, 4=accLo i64, 5=accHi i64, 6=lo i64, 7=hi i64, 8=newLo i64 +const body = [ + 0x02, + 0x02, + 0x7f, + 0x05, + 0x7e, // locals: 2×i32, 5×i64 + 0x20, + 0x00, + 0x21, + 0x02, // p = ptr + 0x20, + 0x00, + 0x20, + 0x01, + 0x6a, + 0x21, + 0x03, // end = ptr + len + 0x02, + 0x40, // block + 0x03, + 0x40, // loop + 0x20, + 0x02, + 0x20, + 0x03, + 0x4f, + 0x0d, + 0x01, // if p >=u end: break + 0x20, + 0x02, + 0x29, + 0x03, + 0x00, + 0x21, + 0x06, // lo = i64.load[p] + 0x20, + 0x02, + 0x29, + 0x03, + 0x08, + 0x21, + 0x07, // hi = i64.load[p+8] + 0x20, + 0x04, + 0x20, + 0x06, + 0x7c, + 0x21, + 0x08, // newLo = accLo + lo + 0x20, + 0x05, + 0x20, + 0x07, + 0x7c, // accHi + hi + 0x20, + 0x08, + 0x20, + 0x04, + 0x54, + 0xad, + 0x7c, // + (newLo () + ...section(3, [0x01, 0x00]), // func 0 : type 0 + ...section(5, [0x01, 0x00, 0x02]), // memory: min 2 pages + ...section(7, [ + 0x02, + ...str("memory"), + 0x02, + 0x00, + ...str("sum128"), + 0x00, + 0x00, + ]), + ...section(10, [0x01, ...leb(body.length), ...body]), // code +]); + +const mod = await WebAssembly.compile(moduleBytes); // throws if malformed — a free validator +const inst = await WebAssembly.instantiate(mod, {}); +const { memory, sum128 } = inst.exports; + +// --- fetch the Int128 column ------------------------------------------------ +const sql = `SELECT toInt128(number) * 123456789012345 AS x FROM numbers(${N}) FORMAT RowBinary`; +const res = await fetch(URL_BASE, { method: "POST", body: sql }); +if (!res.ok) throw new Error(`ClickHouse ${res.status}: ${await res.text()}`); +const buf = Buffer.from(await res.arrayBuffer()); +const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); +const MB = buf.length / 1e6; +const reps = 20; +const ms = (t) => Number(t) / 1e6 / reps; +const gbs = (msPass) => MB / 1e3 / (msPass / 1e3); + +// --- 1. JS BigInt 128-bit sum (correct; what you must do today) ------------- +let acc = 0n; +let t = process.hrtime.bigint(); +for (let r = 0; r < reps; r++) { + acc = 0n; + for (let o = 0; o < buf.length; o += 16) { + const lo = view.getBigUint64(o, true); + const hi = view.getBigInt64(o + 8, true); + acc += (hi << 64n) + lo; + } +} +const bigintMs = ms(process.hrtime.bigint() - t); + +// --- 2. JS f64 fold: the raw read floor (V8 at memory speed) ---------------- +let sink = 0; +t = process.hrtime.bigint(); +for (let r = 0; r < reps; r++) { + let a = 0; + for (let o = 0; o < buf.length; o += 16) + a += view.getFloat64(o, true) + view.getFloat64(o + 8, true); + sink = a; +} +const floorMs = ms(process.hrtime.bigint() - t); + +// --- 3. WASM kernel --------------------------------------------------------- +const INPUT_OFF = 64; // results live in mem[0..16); input clear of them +const needPages = Math.ceil((INPUT_OFF + buf.length) / 65536); +const havePages = memory.buffer.byteLength / 65536; +if (needPages > havePages) memory.grow(needPages - havePages); + +// boundary tax: copy the network buffer into linear memory +t = process.hrtime.bigint(); +for (let r = 0; r < reps; r++) + new Uint8Array(memory.buffer, INPUT_OFF, buf.length).set(buf); +const copyMs = ms(process.hrtime.bigint() - t); + +// kernel only (buffer already resident) +t = process.hrtime.bigint(); +for (let r = 0; r < reps; r++) sum128(INPUT_OFF, buf.length); +const kernelMs = ms(process.hrtime.bigint() - t); + +const mview = new DataView(memory.buffer); +const wasmSum = + (mview.getBigUint64(8, true) << 64n) + mview.getBigUint64(0, true); +if (wasmSum !== acc) throw new Error(`WASM sum ${wasmSum} != BigInt ${acc}`); + +console.log( + `\nInt128 column sum — ${N.toLocaleString()} rows, ${MB.toFixed(0)} MB, ${reps} reps (Node ${process.version})`, +); +console.log(` correctness: WASM == BigInt == ${acc} ✓\n`); +const row = (name, msPass, note = "") => + console.log( + ` ${name.padEnd(34)} ${msPass.toFixed(2).padStart(7)} ms ${gbs(msPass).toFixed(1).padStart(5)} GB/s ${note}`, + ); +row("1. JS BigInt-128 sum (correct)", bigintMs, "← what JS must do"); +row("2. JS f64 fold (read floor)", floorMs, "← V8 at memory speed"); +row("3. WASM i64 add-carry, kernel only", kernelMs); +row( + " WASM + copy-in boundary tax", + kernelMs + copyMs, + `(copy ${copyMs.toFixed(2)} ms)`, +); +console.log( + `\n BigInt tax vs read floor : ${(bigintMs / floorMs).toFixed(1)}x`, +); +console.log( + ` WASM kernel vs BigInt : ${(bigintMs / kernelMs).toFixed(1)}x faster`, +); +console.log( + ` WASM+copy vs BigInt : ${(bigintMs / (kernelMs + copyMs)).toFixed(1)}x faster`, +); +console.log( + ` WASM kernel vs read floor: ${(kernelMs / floorMs).toFixed(2)}x (1.0 = at memory speed)`, +); +if (sink === undefined) throw new Error("floor sink"); // keep `sink` observable so V8 can't elide the fold diff --git a/skills/clickhouse-js-node-rowbinary-parser/tsconfig.build.json b/skills/clickhouse-js-node-rowbinary-parser/tsconfig.build.json new file mode 100644 index 000000000..29ed1e716 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "noEmitOnError": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/examples"] +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/tsconfig.json b/skills/clickhouse-js-node-rowbinary-parser/tsconfig.json new file mode 100644 index 000000000..2a7a84367 --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["vitest/globals", "node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "erasableSyntaxOnly": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "tests", "vitest.config.ts"] +} diff --git a/skills/clickhouse-js-node-rowbinary-parser/vitest.config.ts b/skills/clickhouse-js-node-rowbinary-parser/vitest.config.ts new file mode 100644 index 000000000..8a3bbd8be --- /dev/null +++ b/skills/clickhouse-js-node-rowbinary-parser/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + include: ["tests/**/*.test.ts"], + benchmark: { + include: ["tests/**/*.bench.ts"], + }, + }, +}); From a07286d874fdc2c910f8ed2a49790f2d9013eb29 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:18:57 +0000 Subject: [PATCH 2/5] chore: bump version to 1.23.0 (#869) Bumps version from `1.22.0` to `1.23.0` (minor bump). Co-authored-by: github-actions[bot] --- package-lock.json | 12 ++++++------ packages/client-common/package.json | 2 +- packages/client-common/src/version.ts | 2 +- packages/client-node/package.json | 4 ++-- packages/client-node/src/version.ts | 2 +- packages/client-web/package.json | 4 ++-- packages/client-web/src/version.ts | 2 +- tests/clickhouse-test-runner/package.json | 2 +- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index b072addb7..84678bc89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7191,16 +7191,16 @@ }, "packages/client-common": { "name": "@clickhouse/client-common", - "version": "1.22.0", + "version": "1.23.0", "license": "Apache-2.0", "devDependencies": {} }, "packages/client-node": { "name": "@clickhouse/client", - "version": "1.22.0", + "version": "1.23.0", "license": "Apache-2.0", "dependencies": { - "@clickhouse/client-common": "1.22.0" + "@clickhouse/client-common": "1.23.0" }, "devDependencies": { "simdjson": "^0.9.2" @@ -7211,15 +7211,15 @@ }, "packages/client-web": { "name": "@clickhouse/client-web", - "version": "1.22.0", + "version": "1.23.0", "license": "Apache-2.0", "dependencies": { - "@clickhouse/client-common": "1.22.0" + "@clickhouse/client-common": "1.23.0" } }, "tests/clickhouse-test-runner": { "name": "@clickhouse/clickhouse-test-runner", - "version": "1.22.0", + "version": "1.23.0", "dependencies": { "@clickhouse/client": "*" }, diff --git a/packages/client-common/package.json b/packages/client-common/package.json index 99aaecd3d..10c76ed79 100644 --- a/packages/client-common/package.json +++ b/packages/client-common/package.json @@ -2,7 +2,7 @@ "name": "@clickhouse/client-common", "description": "Official JS client for ClickHouse DB - common types", "homepage": "https://clickhouse.com", - "version": "1.22.0", + "version": "1.23.0", "license": "Apache-2.0", "keywords": [ "clickhouse", diff --git a/packages/client-common/src/version.ts b/packages/client-common/src/version.ts index cbfb1f9da..dff654268 100644 --- a/packages/client-common/src/version.ts +++ b/packages/client-common/src/version.ts @@ -1 +1 @@ -export default "1.22.0"; +export default "1.23.0"; diff --git a/packages/client-node/package.json b/packages/client-node/package.json index 2e41ea5fb..bd592db8b 100644 --- a/packages/client-node/package.json +++ b/packages/client-node/package.json @@ -2,7 +2,7 @@ "name": "@clickhouse/client", "description": "Official JS client for ClickHouse DB - Node.js implementation", "homepage": "https://clickhouse.com", - "version": "1.22.0", + "version": "1.23.0", "license": "Apache-2.0", "keywords": [ "clickhouse", @@ -48,7 +48,7 @@ "build": "rm -rf dist; tsc" }, "dependencies": { - "@clickhouse/client-common": "1.22.0" + "@clickhouse/client-common": "1.23.0" }, "devDependencies": { "simdjson": "^0.9.2" diff --git a/packages/client-node/src/version.ts b/packages/client-node/src/version.ts index cbfb1f9da..dff654268 100644 --- a/packages/client-node/src/version.ts +++ b/packages/client-node/src/version.ts @@ -1 +1 @@ -export default "1.22.0"; +export default "1.23.0"; diff --git a/packages/client-web/package.json b/packages/client-web/package.json index 15cc51367..a25d0a77e 100644 --- a/packages/client-web/package.json +++ b/packages/client-web/package.json @@ -2,7 +2,7 @@ "name": "@clickhouse/client-web", "description": "Official JS client for ClickHouse DB - Web API implementation", "homepage": "https://clickhouse.com", - "version": "1.22.0", + "version": "1.23.0", "license": "Apache-2.0", "keywords": [ "clickhouse", @@ -31,6 +31,6 @@ "build": "rm -rf dist; tsc" }, "dependencies": { - "@clickhouse/client-common": "1.22.0" + "@clickhouse/client-common": "1.23.0" } } diff --git a/packages/client-web/src/version.ts b/packages/client-web/src/version.ts index cbfb1f9da..dff654268 100644 --- a/packages/client-web/src/version.ts +++ b/packages/client-web/src/version.ts @@ -1 +1 @@ -export default "1.22.0"; +export default "1.23.0"; diff --git a/tests/clickhouse-test-runner/package.json b/tests/clickhouse-test-runner/package.json index 996fe280a..c1e24caea 100644 --- a/tests/clickhouse-test-runner/package.json +++ b/tests/clickhouse-test-runner/package.json @@ -1,7 +1,7 @@ { "name": "@clickhouse/clickhouse-test-runner", "private": true, - "version": "1.22.0", + "version": "1.23.0", "description": "Node.js port of ClickHouse/clickhouse-java tests/clickhouse-client harness", "engines": { "node": ">=20.19.0" From 01497c878d69054670f9b10b9f1d1d4f7facfb5b Mon Sep 17 00:00:00 2001 From: Peter Leonov Date: Mon, 22 Jun 2026 19:22:38 +0200 Subject: [PATCH 3/5] `RowBinary` demo app (#868) ## Summary A short description of the changes with a link to an open issue. ## Checklist Delete items not relevant to your PR: - [ ] Unit and integration tests covering the common scenarios were added - [ ] A human-readable description of the changes was provided to include in CHANGELOG - [ ] For significant changes, documentation in https://github.com/ClickHouse/clickhouse-docs was updated with further explanations or tutorials --------- Co-authored-by: Claude Opus 4.8 (1M context) --- demo/logs/.gitignore | 4 + demo/logs/README.md | 98 ++ demo/logs/app/globals.css | 156 +++ demo/logs/app/layout.tsx | 16 + demo/logs/app/page.tsx | 126 ++ demo/logs/docker-compose.yml | 34 + demo/logs/lib/clickhouse.ts | 53 + demo/logs/lib/logs.ts | 154 +++ demo/logs/next.config.mjs | 15 + demo/logs/package-lock.json | 1026 +++++++++++++++++ demo/logs/package.json | 25 + demo/logs/screenshot.png | Bin 0 -> 96173 bytes demo/logs/scripts/seed.mjs | 96 ++ demo/logs/tsconfig.json | 27 + .../vendor/clickhouse-rowbinary-0.1.0.tgz | Bin 0 -> 93514 bytes .../src/bool.ts | 4 +- .../src/core.ts | 8 +- .../src/datetime.ts | 14 +- .../src/decimals.ts | 2 +- .../src/dynamic.ts | 6 +- .../src/enums.ts | 6 +- .../src/floats.ts | 8 +- .../src/geo.ts | 16 +- .../src/integers.ts | 26 +- .../src/interval.ts | 4 +- .../src/ip.ts | 6 +- .../src/json.ts | 4 +- .../src/reader.ts | 2 +- .../src/rows.ts | 4 +- .../src/stream.ts | 4 +- .../src/strings.ts | 4 +- .../src/time.ts | 4 +- .../src/uuid.ts | 8 +- .../src/varint.ts | 4 +- .../tests/Array.test.ts | 8 +- .../tests/BFloat16.test.ts | 8 +- .../tests/Bool.test.ts | 8 +- .../tests/Date.test.ts | 8 +- .../tests/Date32.test.ts | 8 +- .../tests/DateTime.test.ts | 8 +- .../tests/DateTime64.test.ts | 8 +- .../tests/DateTime64P3.test.ts | 8 +- .../tests/DateTime64P6.test.ts | 8 +- .../tests/DateTime64P9.test.ts | 8 +- .../tests/Decimal128.test.ts | 8 +- .../tests/Decimal256.test.ts | 8 +- .../tests/Decimal32.test.ts | 8 +- .../tests/Decimal64.test.ts | 8 +- .../tests/Dynamic.test.ts | Bin 12872 -> 12824 bytes .../tests/Enum16.test.ts | 8 +- .../tests/Enum8.test.ts | 8 +- .../tests/FixedString.test.ts | 8 +- .../tests/FixedStringBytes.test.ts | 8 +- .../tests/Float32.test.ts | 8 +- .../tests/Float64.test.ts | 8 +- .../tests/Geometry.test.ts | 8 +- .../tests/IPv4.test.ts | 8 +- .../tests/IPv6.test.ts | 8 +- .../tests/Int128.test.ts | 8 +- .../tests/Int16.test.ts | 12 +- .../tests/Int256.test.ts | 8 +- .../tests/Int32.test.ts | 8 +- .../tests/Int64.test.ts | 8 +- .../tests/Int8.test.ts | 8 +- .../tests/Interval.test.ts | 8 +- .../tests/JSON.test.ts | 14 +- .../tests/LineString.test.ts | 8 +- .../tests/Map.test.ts | 8 +- .../tests/MultiLineString.test.ts | 8 +- .../tests/MultiPolygon.test.ts | 8 +- .../tests/Nullable.test.ts | 8 +- .../tests/Point.test.ts | 8 +- .../tests/Polygon.test.ts | 8 +- .../tests/Ring.test.ts | 8 +- .../tests/String.test.ts | 8 +- .../tests/Time.test.ts | 8 +- .../tests/Time64.test.ts | 8 +- .../tests/Tuple.test.ts | 8 +- .../tests/TupleNamed.test.ts | 8 +- .../tests/UInt128.test.ts | 8 +- .../tests/UInt16.test.ts | 8 +- .../tests/UInt256.test.ts | 8 +- .../tests/UInt32.test.ts | 8 +- .../tests/UInt64.test.ts | 8 +- .../tests/UInt8.test.ts | 8 +- .../tests/UUID.test.ts | 8 +- .../tests/UUIDBigInt.test.ts | 8 +- .../tests/UUIDHiLo.test.ts | 8 +- .../tests/UVarint.test.ts | 14 +- .../tests/Variant.test.ts | 8 +- .../tests/advance.test.ts | 8 +- .../tests/aggregateFunction.test.ts | 10 +- .../tests/carts.bench.ts | 4 +- .../tests/carts.example.test.ts | 4 +- .../tests/coalesceChunks.test.ts | 4 +- .../tests/combinations.generated.test.ts | 6 +- .../tests/events.bench.ts | 4 +- .../tests/events.example.test.ts | 4 +- .../tests/framing-interleaved.test.ts | 6 +- .../tests/framing-nested.test.ts | 6 +- .../tests/framing.test.ts | 6 +- .../tests/iot.bench.ts | 4 +- .../tests/iot.columnar.bench.ts | 4 +- .../tests/iot.wasm-headroom.bench.ts | 4 +- .../tests/ledger.bench.ts | 4 +- .../tests/logs.bench.ts | 4 +- .../tests/lowCardinality.test.ts | 8 +- .../tests/nested.test.ts | 8 +- .../tests/nothing.test.ts | 10 +- .../tests/observability.bench.ts | 4 +- .../tests/observability.example.test.ts | 6 +- .../tests/orders.bench.ts | 4 +- .../tests/orders.example.test.ts | 4 +- .../tests/profiles.bench.ts | 4 +- .../tests/profiles.example.test.ts | 4 +- .../tests/qbit.test.ts | 8 +- .../tests/rows.test.ts | 26 +- .../tests/simpleAggregateFunction.test.ts | 8 +- .../tests/streamRowBatches.test.ts | 4 +- .../tests/telemetry.bench.ts | 4 +- .../tests/telemetry.example.test.ts | 4 +- 121 files changed, 2216 insertions(+), 392 deletions(-) create mode 100644 demo/logs/.gitignore create mode 100644 demo/logs/README.md create mode 100644 demo/logs/app/globals.css create mode 100644 demo/logs/app/layout.tsx create mode 100644 demo/logs/app/page.tsx create mode 100644 demo/logs/docker-compose.yml create mode 100644 demo/logs/lib/clickhouse.ts create mode 100644 demo/logs/lib/logs.ts create mode 100644 demo/logs/next.config.mjs create mode 100644 demo/logs/package-lock.json create mode 100644 demo/logs/package.json create mode 100644 demo/logs/screenshot.png create mode 100644 demo/logs/scripts/seed.mjs create mode 100644 demo/logs/tsconfig.json create mode 100644 demo/logs/vendor/clickhouse-rowbinary-0.1.0.tgz diff --git a/demo/logs/.gitignore b/demo/logs/.gitignore new file mode 100644 index 000000000..9b7655a7b --- /dev/null +++ b/demo/logs/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +next-env.d.ts +*.tsbuildinfo diff --git a/demo/logs/README.md b/demo/logs/README.md new file mode 100644 index 000000000..f2cbf983b --- /dev/null +++ b/demo/logs/README.md @@ -0,0 +1,98 @@ +# RowBinary Logs Demo + +Screenshot of the demo app + +A tiny, **server-only** Next.js app that pages through a ClickHouse logs table, +decoding each page from `RowBinary` with +[`@clickhouse/rowbinary`](../../). The browser receives only HTML — there are no +client components and no client-side data fetching. All decoding happens on the +server in [`lib/logs.ts`](lib/logs.ts). + +It's wired up so you can poke at the decoder yourself: change the schema, the +reader, the page size, or swap the API-combinator reader for a monomorphized one. + +## What it shows + +The `demo_logs` table is intentionally a mix of types that exercise the +decoder's interesting paths: + +| Column | ClickHouse type | Decoded as | +| ------------- | ------------------------- | ------------------------------- | +| `timestamp` | `DateTime64(3)` | JS `Date` (ms-lossless) | +| `level` | `Enum8('debug'..'error')` | `number` → name at the edge | +| `service` | `LowCardinality(String)` | plain `String` (transparent) | +| `host` | `IPv4` | dotted-quad `string` | +| `trace_id` | `UUID` | canonical `8-4-4-4-12` `string` | +| `status` | `UInt16` | `number` | +| `duration_ms` | `Float64` | `number` | +| `message` | `String` | `string` | + +The per-column reads live in `readLogRow` in [`lib/logs.ts`](lib/logs.ts) — one +leaf reader per column, in wire order. That's the clear, "correct by default" +combinator form; the comments point at where you'd monomorphize it if this were a +hot path. + +## Prerequisites + +- Node 18+ (built/tested on Node 24) +- A running ClickHouse. This demo ships a self-contained one — from **this + directory** (`demo/logs`): + + ```bash + docker compose up -d + ``` + + That exposes HTTP on `localhost:8123` with the default user and no password. + +## Run it + +From this directory (`demo/logs`): + +```bash +npm install # installs Next + the local @clickhouse/rowbinary tarball +npm run seed # create demo_logs and insert 1000 rows (pass a number to change: npm run seed -- 50000) +npm run dev # http://localhost:3000 +``` + +Then open and use **Newer / Older** to page through the +logs (25 per page). Pagination is plain `LIMIT`/`OFFSET` driven by the `?page=` +query param. + +## Configuration + +All connection settings come from the environment (defaults in parentheses): + +| Variable | Default | +| --------------------- | ----------------------- | +| `CLICKHOUSE_URL` | `http://localhost:8123` | +| `CLICKHOUSE_USER` | `default` | +| `CLICKHOUSE_PASSWORD` | _(empty)_ | +| `CLICKHOUSE_DATABASE` | `default` | + +## Layout + +``` +app/ + layout.tsx root layout + global styles + page.tsx the server component: reads ?page, renders the table + pager + globals.css styling +lib/ + clickhouse.ts minimal fetch-based ClickHouse HTTP access (server-only) + logs.ts the RowBinary reader + fetchLogsPage() ← the interesting bit +scripts/ + seed.mjs standalone seeder (INSERT ... SELECT FROM numbers(N)) +vendor/ + clickhouse-rowbinary-0.1.0.tgz the packed library this demo installs +``` + +## Updating the library + +This app installs `@clickhouse/rowbinary` from the packed tarball in `vendor/`. +To pick up changes you make in the parent package, repack and reinstall — from +the package root (`../../`): + +```bash +npm run build && npm pack +cp clickhouse-rowbinary-*.tgz demo/logs/vendor/clickhouse-rowbinary-0.1.0.tgz +cd demo/logs && npm install +``` diff --git a/demo/logs/app/globals.css b/demo/logs/app/globals.css new file mode 100644 index 000000000..58b5ce773 --- /dev/null +++ b/demo/logs/app/globals.css @@ -0,0 +1,156 @@ +:root { + color-scheme: dark; + --bg: #0b0e14; + --panel: #11151f; + --border: #222937; + --text: #d7dce5; + --muted: #8a93a6; + --debug: #6b7280; + --info: #3b82f6; + --warn: #f59e0b; + --error: #ef4444; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: + 14px/1.5 ui-sans-serif, + system-ui, + -apple-system, + sans-serif; +} + +main { + max-width: 1100px; + margin: 0 auto; + padding: 32px 20px 64px; +} + +header h1 { + font-size: 20px; + margin: 0 0 4px; +} + +header p { + margin: 0 0 24px; + color: var(--muted); +} + +header code { + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; +} + +table { + width: 100%; + border-collapse: collapse; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + font-variant-numeric: tabular-nums; +} + +th, +td { + text-align: left; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +th { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} + +tr:last-child td { + border-bottom: none; +} + +td.msg { + white-space: normal; + color: var(--text); +} + +td.mono, +.mono { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12.5px; + color: var(--muted); +} + +.level { + font-weight: 600; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.03em; +} +.level.debug { + color: var(--debug); +} +.level.info { + color: var(--info); +} +.level.warn { + color: var(--warn); +} +.level.error { + color: var(--error); +} + +.pager { + display: flex; + align-items: center; + gap: 16px; + margin-top: 20px; +} + +.pager a, +.pager span.disabled { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + text-decoration: none; + color: var(--text); +} + +.pager span.disabled { + color: var(--muted); + opacity: 0.5; +} + +.pager .status { + color: var(--muted); + border: none; + background: none; + padding: 0; +} + +.empty { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 24px; +} + +.empty pre { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px; + overflow-x: auto; + color: var(--text); +} diff --git a/demo/logs/app/layout.tsx b/demo/logs/app/layout.tsx new file mode 100644 index 000000000..3c2153271 --- /dev/null +++ b/demo/logs/app/layout.tsx @@ -0,0 +1,16 @@ +import "./globals.css"; +import type { ReactNode } from "react"; + +export const metadata = { + title: "RowBinary Logs Demo", + description: + "Server-rendered ClickHouse log viewer decoded with @clickhouse/rowbinary.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/demo/logs/app/page.tsx b/demo/logs/app/page.tsx new file mode 100644 index 000000000..a8b52c81d --- /dev/null +++ b/demo/logs/app/page.tsx @@ -0,0 +1,126 @@ +import { fetchLogsPage, type LogsPage } from "@/lib/logs"; + +// This page hits ClickHouse on every request and must never be statically +// prerendered or cached — it is a live, server-rendered view. +export const dynamic = "force-dynamic"; + +const PAGE_SIZE = 25; + +function fmtTime(d: Date): string { + // YYYY-MM-DD HH:MM:SS.mmm + return d.toISOString().replace("T", " ").replace("Z", ""); +} + +export default async function LogsPageView({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { + const { page: pageParam } = await searchParams; + const page = Number(pageParam ?? "1"); + + let data: LogsPage | null = null; + let failed = false; + try { + data = await fetchLogsPage(page, PAGE_SIZE); + } catch (e) { + // The underlying error can carry ClickHouse SQL/server details, so log it + // server-side and show the user a generic message instead. + console.error("fetchLogsPage failed:", e); + failed = true; + } + + return ( +
+
+

RowBinary Logs

+

+ Server-rendered from ClickHouse, decoded with{" "} + @clickhouse/rowbinary. The browser only receives HTML — + all decoding happens in lib/logs.ts on the server. +

+
+ + {failed || !data ? ( + + ) : data.total === 0 ? ( + + ) : ( + + )} +
+ ); +} + +function LogsTable({ data }: { data: LogsPage }) { + return ( + <> + + + + + + + + + + + + + + + {data.rows.map((row) => ( + // Real logs can share a trace across rows; combine with the + // timestamp to keep React keys unique. + + + + + + + + + + + ))} + +
TimestampLevelServiceStatusDurationHostTrace IDMessage
{fmtTime(row.timestamp)} + {row.level} + {row.service}{row.status}{row.durationMs.toFixed(1)} ms{row.host}{row.traceId.slice(0, 8)}…{row.message}
+ + + ); +} + +function Pager({ data }: { data: LogsPage }) { + const { page, totalPages, total } = data; + const hasPrev = page > 1; + const hasNext = page < totalPages; + return ( + + ); +} + +function EmptyState({ message }: { message?: string | null }) { + return ( +
+

No logs to show{message ? `: ${message}` : "."}

+

Make sure ClickHouse is running and the table is seeded:

+
{`# from demo/logs\ndocker compose up -d\nnpm run seed`}
+
+ ); +} diff --git a/demo/logs/docker-compose.yml b/demo/logs/docker-compose.yml new file mode 100644 index 000000000..f1e27d70d --- /dev/null +++ b/demo/logs/docker-compose.yml @@ -0,0 +1,34 @@ +# Self-contained ClickHouse for the RowBinary logs demo. +# +# Brings up a single ClickHouse node exposing HTTP on localhost:8123 with the +# default user and no password — exactly what the app and seeder default to +# (see lib/clickhouse.ts / scripts/seed.mjs). No external config files needed: +# CLICKHOUSE_SKIP_USER_SETUP lets the default user connect without a password. +# +# docker compose up -d # start +# npm run seed # create demo_logs and insert rows +# npm run dev # http://localhost:3000 +# docker compose down # stop (add -v to also wipe the data volume) +services: + clickhouse: + image: "clickhouse/clickhouse-server:${CLICKHOUSE_VERSION-latest}" + container_name: "rowbinary-logs-demo-clickhouse" + environment: + CLICKHOUSE_SKIP_USER_SETUP: 1 + ports: + - "8123:8123" # HTTP interface (used by the demo) + - "9000:9000" # native protocol + ulimits: + nofile: + soft: 262144 + hard: 262144 + volumes: + - "clickhouse-data:/var/lib/clickhouse" + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"] + interval: 5s + timeout: 3s + retries: 12 + +volumes: + clickhouse-data: diff --git a/demo/logs/lib/clickhouse.ts b/demo/logs/lib/clickhouse.ts new file mode 100644 index 000000000..953d2c497 --- /dev/null +++ b/demo/logs/lib/clickhouse.ts @@ -0,0 +1,53 @@ +import "server-only"; + +/** + * Tiny ClickHouse HTTP access layer for the demo — no client dependency, just + * `fetch` against the HTTP interface. We deliberately keep this minimal so the + * interesting part stays the RowBinary decode in `lib/logs.ts`. + * + * Connection comes from the environment, defaulting to the single-node + * `docker-compose` ClickHouse this repo ships (HTTP on 8123, default user, no + * password). Override with CLICKHOUSE_URL / CLICKHOUSE_USER / CLICKHOUSE_PASSWORD + * / CLICKHOUSE_DATABASE. + */ +const CONFIG = { + url: process.env.CLICKHOUSE_URL ?? "http://localhost:8123", + user: process.env.CLICKHOUSE_USER ?? "default", + password: process.env.CLICKHOUSE_PASSWORD ?? "", + database: process.env.CLICKHOUSE_DATABASE ?? "default", +}; + +function endpoint(): string { + const u = new URL(CONFIG.url); + u.searchParams.set("database", CONFIG.database); + return u.toString(); +} + +function authHeaders(): Record { + return { + "X-ClickHouse-User": CONFIG.user, + "X-ClickHouse-Key": CONFIG.password, + }; +} + +/** + * Run a query whose result should be decoded as raw `RowBinary` and return the + * complete response as a single `Buffer`. We page with `LIMIT`/`OFFSET`, so each + * response is small — buffering the whole thing and decoding it in one shot is + * both simpler and faster than streaming here (no `advance()` bounds check ever + * needs to fire). `FORMAT RowBinary` is appended by the caller's SQL. + */ +export async function queryRowBinary(sql: string): Promise { + const res = await fetch(endpoint(), { + method: "POST", + headers: authHeaders(), + body: sql, + }); + if (!res.ok) { + throw new Error( + `ClickHouse query failed (${res.status}): ${await res.text()}`, + ); + } + const arrayBuffer = await res.arrayBuffer(); + return Buffer.from(arrayBuffer); +} diff --git a/demo/logs/lib/logs.ts b/demo/logs/lib/logs.ts new file mode 100644 index 000000000..acd531455 --- /dev/null +++ b/demo/logs/lib/logs.ts @@ -0,0 +1,154 @@ +import "server-only"; + +import { + Cursor, + readRows, + readDateTime64P3, + readEnum8, + readString, + readIPv4, + formatIPv4, + readUUID, + formatUUID, + readUInt16, + readFloat64, + type Reader, +} from "@clickhouse/rowbinary"; + +import { queryRowBinary } from "./clickhouse"; + +export const LOGS_TABLE = "demo_logs"; + +/** + * One decoded log row, in the column order the table stores them — which is also + * the order the `SELECT` below lists them, which is the order they arrive on the + * RowBinary wire. Keep these three in lock-step. + */ +export interface LogRow { + timestamp: Date; + level: LogLevel; + service: string; + host: string; + traceId: string; + status: number; + durationMs: number; + message: string; +} + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +/** + * `Enum8('debug'=1,'info'=2,'warn'=3,'error'=4)`. The wire carries only the + * underlying Int8; the name map lives in the column type, so we map it here. (The + * skill's preferred shape: keep the number on the hot path, resolve the name at + * the edge — which is exactly what this reader is.) + */ +const LEVEL_BY_ID: Record = { + 1: "debug", + 2: "info", + 3: "warn", + 4: "error", +}; + +/** + * Read exactly one log row from the cursor. + * + * This is the clear, API-combinator form the `@clickhouse/rowbinary` README calls + * "correct, clear, and a fine default" — one leaf read per column, in wire order. + * The row mixes fixed-width columns (DateTime64, Enum8, IPv4, UUID, UInt16, + * Float64) with variable-width ones (the two Strings), so it is a natural fit for + * the combinator style. If this became a hot path you'd ask the skill to + * monomorphize it: inline each leaf body and coalesce the bounds checks across + * the fixed-width run. For a paged UI it is nowhere near hot — clarity wins. + * + * Wire order matches the SELECT in `fetchLogsPage`: + * timestamp DateTime64(3) | level Enum8 | service LowCardinality(String) + * host IPv4 | trace_id UUID | status UInt16 | duration_ms Float64 | message String + */ +const readLogRow: Reader = (s) => { + // DateTime64(3) — P=3 is a JS Date's own millisecond resolution, lossless. + const timestamp = readDateTime64P3(s); + // Enum8 — underlying Int8, mapped to its name. + const level = LEVEL_BY_ID[readEnum8(s)] ?? "info"; + // LowCardinality(String) — transparent in RowBinary, decode as plain String. + const service = readString(s); + // IPv4 — 4 LE bytes as a UInt32; format to dotted-quad. + const host = formatIPv4(readIPv4(s)); + // UUID — two byte-reversed LE UInt64 halves; format to canonical 8-4-4-4-12. + const traceId = formatUUID(readUUID(s)); + // UInt16 + const status = readUInt16(s); + // Float64 + const durationMs = readFloat64(s); + // String (LEB128 length prefix + UTF-8 bytes) + const message = readString(s); + + return { + timestamp, + level, + service, + host, + traceId, + status, + durationMs, + message, + }; +}; + +/** Drive `readLogRow` over a full RowBinary buffer to an array of rows. */ +const readLogRows = readRows(readLogRow); + +export interface LogsPage { + rows: LogRow[]; + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +/** + * Fetch one page of logs, newest first, decoded from RowBinary. + * + * Two queries: one for the page of rows (`FORMAT RowBinary`, decoded here), one + * for the total count (so the UI can render page N of M). The count is cheap on + * MergeTree and keeps the demo's pager honest. + */ +export async function fetchLogsPage( + page: number, + pageSize: number, +): Promise { + const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; + const safeSize = + Number.isFinite(pageSize) && pageSize > 0 ? Math.floor(pageSize) : 25; + const offset = (safePage - 1) * safeSize; + + const [buffer, total] = await Promise.all([ + queryRowBinary( + `SELECT timestamp, level, service, host, trace_id, status, duration_ms, message + FROM ${LOGS_TABLE} + ORDER BY timestamp DESC + LIMIT ${safeSize} OFFSET ${offset} + FORMAT RowBinary`, + ), + fetchTotal(), + ]); + + const rows = readLogRows(new Cursor(buffer)); + return { + rows, + page: safePage, + pageSize: safeSize, + total, + totalPages: Math.max(1, Math.ceil(total / safeSize)), + }; +} + +/** Total row count, decoded from a one-cell `UInt64` RowBinary response. */ +async function fetchTotal(): Promise { + const buffer = await queryRowBinary( + `SELECT count() FROM ${LOGS_TABLE} FORMAT RowBinary`, + ); + const s = new Cursor(buffer); + // count() is UInt64 → bigint on the wire; the table is demo-sized, so it fits a Number. + return Number(s.view.getBigUint64(0, true)); +} diff --git a/demo/logs/next.config.mjs b/demo/logs/next.config.mjs new file mode 100644 index 000000000..507113cdd --- /dev/null +++ b/demo/logs/next.config.mjs @@ -0,0 +1,15 @@ +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + // The decoder is a regular Node dependency we want to require at runtime on the + // server, not bundle/trace into the server build. Keep it external. + serverExternalPackages: ["@clickhouse/rowbinary"], + // This demo lives inside the clickhouse-js monorepo, which has its own + // lockfiles higher up. Pin the tracing root to this app so Next doesn't infer + // the wrong workspace root. + outputFileTracingRoot: dirname(fileURLToPath(import.meta.url)), +}; + +export default nextConfig; diff --git a/demo/logs/package-lock.json b/demo/logs/package-lock.json new file mode 100644 index 000000000..1f8045262 --- /dev/null +++ b/demo/logs/package-lock.json @@ -0,0 +1,1026 @@ +{ + "name": "rowbinary-logs-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rowbinary-logs-demo", + "version": "0.1.0", + "dependencies": { + "@clickhouse/rowbinary": "file:./vendor/clickhouse-rowbinary-0.1.0.tgz", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@clickhouse/rowbinary": { + "version": "0.1.0", + "resolved": "file:vendor/clickhouse-rowbinary-0.1.0.tgz", + "integrity": "sha512-DotolNbYJGi5T0WavNu8/+pME4KiKH/aLvPE4K9rndCKE23+AuxTpPRHiZJfHX73frkICtubSyRK56fll0iI7g==", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz", + "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz", + "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz", + "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz", + "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz", + "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz", + "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz", + "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz", + "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz", + "integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.19", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.19", + "@next/swc-darwin-x64": "15.5.19", + "@next/swc-linux-arm64-gnu": "15.5.19", + "@next/swc-linux-arm64-musl": "15.5.19", + "@next/swc-linux-x64-gnu": "15.5.19", + "@next/swc-linux-x64-musl": "15.5.19", + "@next/swc-win32-arm64-msvc": "15.5.19", + "@next/swc-win32-x64-msvc": "15.5.19", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/demo/logs/package.json b/demo/logs/package.json new file mode 100644 index 000000000..f01b188ba --- /dev/null +++ b/demo/logs/package.json @@ -0,0 +1,25 @@ +{ + "name": "rowbinary-logs-demo", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Server-only Next.js demo that pages through a ClickHouse logs table, decoded with @clickhouse/rowbinary.", + "scripts": { + "seed": "node scripts/seed.mjs", + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000" + }, + "dependencies": { + "@clickhouse/rowbinary": "file:./vendor/clickhouse-rowbinary-0.1.0.tgz", + "next": "^15.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^5.7.0" + } +} diff --git a/demo/logs/screenshot.png b/demo/logs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..33927d5125bc62abd510b756c4fa3ad9b6b44bc8 GIT binary patch literal 96173 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 { + console.error(err.message ?? err); + process.exit(1); +}); diff --git a/demo/logs/tsconfig.json b/demo/logs/tsconfig.json new file mode 100644 index 000000000..87b93b494 --- /dev/null +++ b/demo/logs/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "allowJs": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/demo/logs/vendor/clickhouse-rowbinary-0.1.0.tgz b/demo/logs/vendor/clickhouse-rowbinary-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5dbc9a328df2961015eb3321cc7cd5fe0301bdb0 GIT binary patch literal 93514 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�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

BCUQOzf7vWGqCc2 zOjTl&j)z(gP2WCv{eEM0OW)vMGGa+K!h~1;2d8%g3X-m)D9N@hk{MQCg~L^fYFTwL zw5xg|uS&ey>i>Ki%c4-1Z1eitPe$8as9ydJ>LZhK#U&R1&wk5eFJe*W)RW-8NLbb#6gj>5j) z<6p>E&_0VcW-STy>~b=ACnNNWx2-Z82zyrRM%XQ^vX#XZW1~JveJGNu`kh5JHJf;94Te@+Z&_$yi+#hug)@Nn&o9>|Mzl2=k5bQ^mG@ zN&J3EPJZE*Z`&x`%$igRw{e$Gx#cn(4iDR*&CO`kZ}L{o0&VEm`FP)^QMo)%=#DK`Z+ zr2KAJ{)tk!8BhLPqRo7M`@c;8XU2csXg*vk;JgO|9`3ck6HN7&qr8IEf9G`GQeH% zCxR*}%!kz-0+q5!O+ef|l!z$O7UbMfY-1Eq!l}p~RUgbG@IdQ>25Kc5mpQ=D1ddSU z<61-VIsIUes7qL)W7rK$YDOpO#eIZ*v~k=DrW!R_nw`;{8<5X?xkK3cgVplpiQ9qH3GSXst&;VU_>Y3 z5Vl82nzCf5u@2H3Tn2bE!64d^Ao2C6`UOZAR7IZ0kCVrd8Pde8QPP40`j;`|?P@OPb;8(5MATg*Dbb%A{2w%Rs zj8XAR8*u!Z$0lvK!FVqls(3$~q?eUnOAtB=P!;%PjH!{=Z}bmwLPKM6??!WFPLKEZ zUL72MXDT3;j!_!LP!iH~VL_@GI@U%3mbXAUj(fd2(0TlxVFuG73=eZkuwWrkRX^@v z6bGoYFfs~lhs6eZWKsr-+k0`ke~eIsu&+3XrQM4{xI^?x^hh^3t|c737ra$RdjgFq zVM4y%cvIux_BIRQpr4{(e-#U*ahG&8Ln;2zi^d$6O+m)6v1^h_;Hn1F_n>#(ki%C5 z-IcZ&}lYc8O_*lp%AS<2QB z5?1Xi^opZErte+X7kejy-xV4LX4mYPssF=%c&jAfRJh3aDc2b{So-MBjP^L5L~KFX zWyx-jqXDlHe_icGQ z{TI7+=hol3_TP=ohXwmD)r4Qlf1gqQ^S47Gg-fw;DHblp!lhUN zax0D0_@@vHKS~@Fm~G-E6-+^lY`DC~=(Y_!tK6=H7U@M=B907)Gxk@3!cnfMPatQc zzjNGC{wLA>lrSTGmu~HN&y;-`sX!T!@*h5~B>jsEU%KsS+p`RL#Jf!yE&f+Xpc1)G zBKTND5#|1ZSEW=zqz$+c`+Eg@*@5L(jEe$)-l4O-uSvSRA#8s#T%W?w|9EEWjQw|- zQ{WR#!gK1sZ#E0@ADdgPrTzD-wEqTE{Gb(Nh&6Ls4RWB+89ZpeATEinJ!Bx)<}&F~ zB4Rfl#OWo1IBSI;eBOg@pAQhjxbQHrVDoRy=MLyJ_MgYAz^u=hxaEwoy}fYHwig*7kFIS z@OCUk+I7QrWKQsL3j5@iS?R|waV)4B$U9;?q*ll%LAQNmM1GAt09t`C_YT^=if;sD z5))Hh8raBhstx37SRvvZAuwDb1O^;dnPy){BkHgRxv0nG>4YaC8L8naZ+E)6mzK?2 z!@NdVm7p8;(g=;NWZaBXf}cUVY=Eir0dzekt>4=di@K7I1_=S=07l$rH3bsnPu$RD&L#M(;W1pI#kN zO(Ayd)YCQ$R8j71;J>y9Cke)Qb5TgK7(y3d@f>Ogx@HJQ6&in_W9TKRR69igig2I} ziti)=viu>6hIqo`$V?%1Q}|54a4`u*n#*29QU=s=N+OJa~!nLvl6 z!3FrkRJwi^-a*+3bwVWUNn|EU_v!%gw>wNcAHLy2uhR+DY*IJA&X}&wd?Py zgL0KRSlNgwcbsEDensn%GW=kQ46F~b0z2DQxgb}9J}_<##66s|YQyYl5IZr12pwn) z^2cN5iO1fh+4+P^{mGCwR&g@XohIGYkzFKF>dh1KKiG>6yd5;WRtL~pr4KU$6Si$! zNr4ax5#`t_>9_BbxI;J^_+3O0f|h4;r70Xc_n5X+U=+{e@M4gpV~{3k&eWrXNKQRI zEFZ5@pF7_r&*6ieDL z9#N{hFHPF-A{7Bma2a1*{)9!hUxlN0!49ek5!&X1V`;L1j@-a8N)jB)XDDXV(PW3tTAApP(E`rpdBa3y|t3L_g_a83>C6pk(W^?5*D z84>0)?3*!s$nph_-nZ+J%{aM14HX2Q0UeY5XmlZhjCLP5aXKz+?CeUyek`o6CkF7o z(Q^q)C}qPS*bPiGaV|RrhOLqe1?DJNm1>dfsN#NilEiaXd9Yx}&2P40?GU|mY*9^h zWK;tk8_ZCPsQ?e>^cDI@oob^i8Xgu;2Ew7(3qq13Xepm4D9r_^rNL0cp#`i`^fxKt zMj-}QVT|P!v12^&hV>oFB_#H1#9gmXkGM4r+ERb{7xMkYfi>U-o`481G>gxK(PUEHng%9L!Vy7^lo_-5!@r!bv> z9uHXNCy8qmy^oWLPKfQ?}eyx_`$g zC5jVK+7xW{QVw0AvxMYM-zOMIWk6^LLmoI*Ar$mwUMY^Pq2r?=({W1XC30hq;T!Z? zqXEZAfKb4o*`#h+bfQ9vJyL|*!2Ecy|C6M4k@+U1JWvj0z}Z0i5U)i{7{>^Z4FVI1 zrUa`@g>OYL)WYeC>78A3T?`>gA?2}xkqs00D5bjsTRSMn#-O5_MbQ2kx$$3zP# zMc^f}i#Zc2EWKhO@*5j7#fD%sw%Xdk801i7Aaxg;?R3Myu zX_E$Aj0TG{(QHpx4ZRwn&>C}zQ42F!T7UBXlt?DnbWeG&qEMa;y72{S?yH4TovDDu zV}3Pt&6(AXZFqoR{ISt!+&C=xNs5&t3@o{T8FROvLcQIXa` zUXT!~Yc#ILRPTbK@8DmMG{|Ocp~_j$#$vP}&?2iwM&Nxi8E9*GjtV=F$UQyy%t4nL zh7O0!r2F*2I_!ZyCzs=Pwq&Cd`E>8u53i5+PY`Evo7o8w6y?Bs)SBI!#EeiI!c=rl zVO7PR4mdWwKqu38sWmUq>(pN|f`c^QBOpq77D#F`BB>Pj1gdeom<+XeU&}W-CUc%7 z@0301H_D7i6;#b|pIX z67K`y?%4P-mfP!a0_n%$1+`%l2)qhdQ&t;dLg`H0*S zPim$ZTU0X%e-LEPDP5^bli%s-f?-HCQU+_}RYC0g&J*k1eWsx3tb z3FeSetRsmEva}Q;!X?H81|XcBfDlDH(hp?q##rf_NL&T-gG2M;_~`Hy51MJ3Xof7= zG3yi!)i4sPG$!@{=yv0F%xoB`g}xh--C{uciAd6Yt3o-yM7|T1tjbWi9FIks$y!@L5Oi8|~nO_QPMChf`C>MR1d z42vzjiS-Hf(i5k4iht7w4M2;j)D&nMWla*Ms^&rfhLSiZ-uX@En}4#1s`Kyi?i zN#VUD)&R|8Ww>`(cM3-c&B1whuzvz4Ls*cEpq;;~aE@zCU8Djvz;s8U@rFnx%l!=q zt8g&Tla7mzTn%I&FieS#S9BeW#O;w$;}p#gcF(GS>Ajy`MOa#L2%NH#)6KtOE7=cE zSrG6i$VLhCB+2G81nQtGdb0m&@9^~C*@-GAdv^NMoSK(f8za`_9Vg?kXJhSc=N0%D zW+t_AKg-}I?t|PCL(A-KrLTz&$OdXR1+Wjgy$QLYqj01_{{G;iUN}Ufq^e9RF|sEF zwY(Px$0w((YcoA`ST*Ya6QF2$^3=9LbdIFt5Q49d*`9RY`Z$Dhl1b`XiHW50*qK?G zsR1*W%}t%h;}qFJ)yc;g8>+YzSeS~)j|~IoKw9r`&gn>qah9@Yew+OpSOmJ$)A1r! zk2>sySgbX)eTghqye2$z+LL_-+LkwXsu^L2V}GMQh(93x!3lw2Lf82Ps_qyCgqMy; z7(t!bdR_K1PauNRN2vw=R=cB@2QswocTiAK7)h-?;plK5!#}Y?63Mb8QHgFUClSE{ z5&Nvjz+sh^i|6I@;=i>XKHMny z|CJSQ_0;6Cq-xlE%CM08JScTQ16;_Q`_(;TpGrDt73m=7B_!#5@MP;F|5^moU2tqyl`p5UW6uc*~#_ob;me zXndGA1-H_s_zZWcjr{xb?%M=2mO8#gomUi0hC%OL*1qLkiRP_DH$vU>#Bf^K%$QjG zN#_FU)!hwl@(Q!q)CtJ4QtM)&k5cd_+=W!`Sw0q#?o&*71CQW}jFI;zlF5ufa#5X)a zx;7zVqEBI|QN>3~J-e)612 ze%!pWsB%lTR>ccKsDMa(Fj1>%qIoOpLEZ@!FPQ?ToT}BySUXCx{8jj}%-c!n*i*t( z<&X;@vk#e!(wKc!@mYbSXXHgNFHcAv8cnFc-Ym_#gf)XE;VZ{jS#ue=&^v~Q9`C!X z2RBi>1g%%Kum_YITXb67Ayam7!_MqzsltUN3bs~N>0&qTBs$t(}VMf|I#nB3{O7H+2kI^W9#lNUw^H>;>BT^mF%*5_)t6d%%Xa@r<}RbU)vdV zb`kT;hEwH^L*L}A-~GMpZUXJf)V4dhOSx=SW_9C5!%pU7Z_VfxHpj@CLd-aS#c&E>)TD+33* z#fU8j;6d&|;~iuOOzy=whoR#VWL7S^#X0O_x^(fm8CM*xtBs5kjaJVz_f{$YYD_>b zj;je&fNxz^)N(>SuwQzP2dhi=N04moA_qJTIx-q{((wT_k!ks0F2O#U+Iq~z&lCmd`{f_Ts{ z5sb@+-B;3Qp@GyQy$iIX*HohAT$COkzBu^)msf9&VUZl&UbP23Z#_F9fmFZlTSkup z^jUyD3(#i)`Yb@71?aN?eHNh4Ic}ke0}(Wr#jwvcH5>Su1AXQJpQy|&kN9+aLtS(Y zaPv-B846<4NT}n~Bh?E`ICS>0WWl_Yd0uGN19D-})R;-RK?>oU_*M`jIKi-Ep*}s} z+GwuK-00cbkrl${JxDT~?POx2v}B+eLv}wAE(4t!Bz}8%BywTx?4Z*^8|)5YFn(uN zx#Ju$n-G464ZUKJ0kr7q8WtDb8W;c-DdN19p+j+)I%M#;f3BHvj>Ve+^?Kt*)A5e@ zx^-%ApLE?x|Hrstogd@IRL{q_VcZ`0tK;*KKaIn~G>CUXXpQ@R>*m25TO7M4UGdXC zh#EnhmSONc+8pp`F6n&&bsf;Vo93Lp3f3lx>1i%puSy6#q7P3hr(fmVD)dds%#zqB z^r^%9JUNL|2#HOfNAcTa>IC2dndF#mL&?~g1LyTj&D{1UTJUgBwH;pozu6K`3+*)8 zy*Yi5+b zqhnBN;DONhktD!PyWuzkHhD(a`p5_sMsr@u-&m!@bU(;?eCTjyAEp_gvNJ%gyT=yZ zotX2bd(Z#JO^f!>rX;7pbhEqS0f#N%n5Sf4{hD=m+jeVPnmv?1@rkf`2?9fuQ4bLt~&YGR7qvONp&ofF8 zs-$lj54yvon7qwWUTioM=`a*<^KYGSce&1z8f+!1`sTua$5BGjPiLOXE_nTpsmp%W zO1{rJCeIRT#&of0PMarM(P|F|utlRZvU%aPq1A6U$PWPfGzq&^+z4j^7HDAHxgxdD zMmYuO?nkR;>%NGV`Q9^*rF-!|Qa;`l6F@G={~1MLj{kA9Qt&^1neiXuG$h41Hu!}m zapY1KdZNP3q*a0wy$6RF(d4qXgn-v{jFtE{n-PR@s@@zwEjP4j#)yk2JurZu4UiC5 zzm5#)VY>_V-B4FV_VmR#9>$=rLSV_ODD&0Z&rq*lIZCV zF+Y^gw?PCwVr!mKjBo@5?TH+pabs~a^Rvfk>2>h4X&R+5<^1trO6PzCWsUZ)bX%;Wq>(e3pTdv9-f#RSnb0H z8zr^Aj_4l3hja;*GL#t>ryn$uU@2Wr1vY}+U3brsDthVA423)5Ez+|vmp%8IP95Dn zsGa4PkZrC7NBCmhos|8}Fex_)*pv&!!=0kkF^(-!F-yw+B2mFt8tEX@hNMEo#HTa8 zW0r?r8&eXnO`@1)Nsdk;25tF|WH>0J`GmFMWowgRf7+YL5F`=>n21DQq+cah189mF zYYlxv$1FRjN1|Nf4uXa`KYrbnmM~;dd&33K*q3^Z3-a;P19kig{`ujD=Ld{s>=@WF zUCZK4gN99Q*jC8diykphIq_O9>9d!HVNZPHrV|=5LJy$0%DPX7xln}z=Od+UEClg(>kAn==?SoyOB zSsTpejwqDIrIg0yl|{$h4et~nq|Ai_wlZj~A^JjOQu%wzfJ%krII_JP4dmVH<58!v zF5S!a(v2P}6xKh#!us13)g|&C&Ja#gcQVfJ<>k{nd$)v?7P@kwE3Zgb{sQ~!r^8e>D#4UqNZDjC5huo6>XzW!gY*K+yaYEeBZ^#9MP|4ZBsT^s8l z9jNG z)H|Pp_C+c^97*$$X)h(y^Ep`G)R2z>W&AS1B;YtK>w&k4>TPIi%a9molplpXch8h} z)*GbbVL}WwG_ie97asq3h}blVjmGeQ!d|MI!MKO0{r&*-sq0G$EW+3i6G`9g_b8eZ zYol?%TieuXdjc8*b?=&N8as>=$;j#9V=ZYM3X3ZmCB@z=u$VMaN_H*pig=PqMzmhS z2o#nKV`}~iUoyCYr2^qwBoY?9MKWQ5Ta<4mpQq1ls0|WyKE${@6G?2kk%<~EncirD zv0s;*qP~=+&RJ1%HWyt&_ckM3`HFPkC#6geh24HidN}Ovr0%$yI|qG_(T`V@pwS`3 za7W{=1R5pYDZ7#+G3+3E3K8+Rg5&n!9my8OG^gt6t2eKm)u=>vQypk3amzS%utX-- z8h>KelNk`Vq|<%+nBc%A3*W(S)Cdce6mfG106SK1lUsDfVAPZez_#3A9lBg^-|{bk zdWN(C400z}1BFiy5warm19}~4Jb8;JsiiBVFyl@SV-=_!9Qp$$mS+6WLxb)h_Rri( zi!r|?ZEF;KV>a&cWfH%;RiZHIP5Z^cV+?NoUbt$an%Yo5HINz2=u;L!-&Fq@2DSI9 zgW&t2*q7s>Zj;0e=O$10kw&*nqnm9m0#QH^vL?y&#rHJEbv1thS_jhyG!aW|E8QU~ zM#sz2dBm#(aSVRmpG z_C0)T<_zGMnHhi&PPz!FMOhCnl99B~b4H@!mnaS~)2A7_RFh85GVDMc3no46c8K!> zzazB&kogBVx_Q>wE0ClUrzxR-`V~cu8?c?UW6wNwr;;ABN`1_Xn@aB^UwUMgRV16l zei)zsGd{zhiVk{D$~WC}*iz^;Ao4t@k0OcE^4Vo_hCEfor{L9DWPBN4^42?>{(ykP z&WgUtcD3#ZlC*bEhD;83Ef0vWtw!FBh(JjeAf5JZnXv95d1TEg$Iw|(IX}>5R-(mJ zLa{5!D3^2tScm#t`F{~*xbp13i|qesv%Zz*|E(AIe_t&AOC2RwaUb~+iN=XG)fR8H zEjZ7H6vwAS98$S6@f=D)Qba)3?)+fViaC;p_6+T$=*f>&+Py$656w4;o;V+i&hs|8 zCQj27qubDxkI`7@&X*vGk#jXfN-Z@0EmCNP6$SkPZRMZi+?xUGa zyg$P$ay+2VLF`82onlb|0iKkW*K0#bIfM!M#K`LTb=ZcWZwQec~hZh#4q^6SK_aL$~4X zzlFvG*Lv^)+j{rlgE(a_Fa6*eZ!TaL^I(5o zudMrbP4o5D)!eVsbjsJQ*%SOpRkDv$#qzVs8#v5mf9hn8CX1B$uO@%$%mls-3Wrb% zPJ-{c7qBwy0&^Y1NLvD828CbJqVtd{jh=T(b%rsP7{T1qSF;7k)VElUOwoDb

75 zk|1&92;2KyvSno1o^t!Dvc}W^&<1SHUGU%o4wH30I(3=nbe(I=C4dhW=%yw8f8%lY z{ImQ2HtU5+S{Vr^lq?x~$pK=fcEqj|{k>Pl6xd1t92;Ci` zPl~cffUAbhvfhq~0SL3Y!*EurtL5TT=}uhY^smND72+agb>p5sJ~~#9pC2E*e);(L z@TY^OAj72(-OjCCeq--TYHYKE*g-kQ>!v$Gog>L5GM5or3(X2Xno4-xT-Vpl{$ke? zR-4nXxqs6of)fr1k4_kYCJh~Lv7rHKZR@hDc6X61?^iHcT8yZM&UD5->LW1u{k zhlsU@J*gz5+1(U(b<8!Bl+VZ4y{_<~X=dJdVzyrspAwF;NFL7(W?%S8E@xm&@O}oR z)8T_BIg%;FA;3~3!_N;u-hXNc(17dK)ggNv7>|P?s3TWNoQ{zJ=~l*>G+1c4m=(nI z#L*=g0XAunoXOR5aHk$lSp>_?pJ*kMv&eHYdlo7E5Ugih-gDP<2~3#FZVBe|v}Kc% zL6>6~ahzCO>?(yR8#&Zl2x=gvKV-C3PcD=AS`+e5^qt$!a*dHRsme(ar*0uZ!L)KW zgECtih>7S3FDZ-A#(4tLL!)n4us-z!JnKFmxS9^iOWHWX8oGGci;Y{(|cp-$w6RxZit7Co3b}#~gBg$?%9mkq)uZ zVb~Wj)}hzr9d`kPUTCP9ehWp#Jfw5vu$}ELP~?CaBn+k85e}!M4?6`iKtfAQGt@;_ zLR?fe5`s06g3IK+*p`9vo+a&1+Hx6dLy7-GCkdNz!&+C8a^yX<^XqwLV*k0CkMCrpfFh15Qd7PB|;6F{N1tg_4nid5y0U zjyj29a%npDaTMHB#4KH2j>@~oNn+pZwe|5a#l?>dviUXEq~_&Tsnb|h-lsJWO)ew% zAXb)Xx<^asmuCn62a))^r{gX>WlISg^)xD6xJn=qQv$@&>6 zJCwwIi0T)ibaCnE^cVqxb~e?MHmH8r#4b!{ zH>w4Ln`C%~?xV*?PYw_1RK?Ai_~U7#ZM9n^+I94v-L)GL76{b$Lvu*;eqQGNoYW7q z;mkVUcrmgc%QjVzfeg%I=wY;ppf3XoY%?gZt#%cB1HWiYpm^e-BS?g3{7+#$@6AGA zR%UqM5qL1bu09!@Cyz&7#Ru)xoA@yFQL$}I+)#`;zp_4|WZQHzpXU0``8N?t+$!V! z45+^pjiI+meFkzWVw2`#G#H;Dr7tlD?y$y1J0y~-QIao7#AFpG9FMd&Kq|`-hfL}O z7dAFjtU{wN5$wt2iP0OE`t#d-GRV&HkDFHllAU@1t`fiTkGf&068P_>+4s zmi1V;_W;x5XT$n*5`wt!+rL*P(*1PQNl%JX)3&G%T;x*I-%NQFURyy4m9v zE*!W@zPi~X7fucx2<>y(NvzD8?IbU>4HQ%@wc&OURA-27n3s-l_9R3LCn3xWNHlvE zqQ$e2myu}pG(-!hA3wM(3e06iSl7)Lo zBVXO@*{Dv=hG;;#fD;bfLoGR6_Y2J39}xd1Tt=(D1X>XPyA|c`f1+ws;Qzi*{NM9~ z@4~17CbHnWg~8V&D}hu}+6p$9--Y==Qs25Hwd?$EM<)75r`-hY7xi3rw3Iu;Zqh&R z-8NaXBr`PStem8TGMYMg@%SIVpz{68;mhNL9}Zr(lo$QHSyg2f`lRv>6S7MBZ0rb$ z66FA=mXkSzDFnr-MO<8xyV-Gxsg`1*P{AnGZi zltO?ryx+0Ek9D@5E@rzN4thNdBOxaR*;^O`gtsbNFf4e}LL&u+781)^x?Az&ks%BQ zf{K3jw!Xy#p;$fL;9Mp>Osv^M?$WC(P`ktb`>+3w^gAeXb2{QQgy@vL5eiXsAUjSw zo5u46m@eJxUKd%k=#l_le2dmr>)RIcr;b!>cNb}HV4k7nn1%>Un{J0@xf+pA94ls) zteatcjh81QEUT*F9INUQ(_tAZX!KTK8mwMI{9$Ev$X5_=`%sKB-Nx^`SK}+pE!Gy9 zOeqr~kuaMZEig#0Q+l$WFqIH9pq(dRcO?3*1#O^#PEtFPq3w_(XdDmE19cF9k*g(O z7p4JLN(Uvo&(+m5^D%m_&@Pe`Xq4oOxQo73QN2;CZq_#IEov1xb}j}1ckl1mYP87` z%2LS9_8fCk=u34QZ~=_LY8+#VU#dd}=pyNlyM0XgNgdYZ+sQ3PYTybgDa;g(xlFac zTvJU+DsS=~Yb%b1#FR8Cbq7N?4>@rYX-^mrnN0MnACC`T9RAzmnz;S;NpVlkC~*IrYIJ_0~4TNARE3)4}wDo z4&v3Or6nTE;>ruvN)KmC?BE?4OC{4ZA-5C>b@X#J!VPH&)Sye=?RcXN8^Sb{h;we= z7boK4Kct$KBS045|F^<0kN=PA#rglm;y(&apwI*gO`y;O3Qb@&n!r6Y0CI(IY^VdX zEtsSYxEZ9^oD7!JowxMGbm+HOG?+I{5pSALOtZp{sBaaalBCz6C7fDf&D6(xe0S`gOeH-U!X%we29-jC?u|F?I8hP8n3hSpDJ}F>&zi3oSACYWNp@??b`PCX0;ZD)ljdk+IHb=FMf)j;^+Q9{|}$~<4XWI2LMJY B$f*DT literal 0 HcmV?d00001 diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/bool.ts b/skills/clickhouse-js-node-rowbinary-parser/src/bool.ts index fcc0bf0ed..52113cc9e 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/bool.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/bool.ts @@ -1,10 +1,10 @@ -import { RowBinaryState } from "./core.js"; +import { Cursor } from "./core.js"; import { readUInt8 } from "./integers.js"; /** * Read a `Bool`: 1 byte, stored as `UInt8` (`0` = false, `1` = true). Treats any * non-zero byte as true. */ -export function readBool(state: RowBinaryState): boolean { +export function readBool(state: Cursor): boolean { return readUInt8(state) !== 0; } diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/core.ts b/skills/clickhouse-js-node-rowbinary-parser/src/core.ts index 8a956bc4e..415da3d30 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/core.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/core.ts @@ -21,12 +21,12 @@ export const NeedMoreData = Symbol("RowBinary.NeedMoreData"); * pulls in only the per-type readers a result needs. `view`/`buf` are public so * those free functions can reach them. */ -export class RowBinaryState { +export class Cursor { pos = 0; /** * Node-only skill, so the input is a `Buffer`: number reads go through - * {@link RowBinaryState.view} (DataView), while `String`/`FixedString` use the + * {@link Cursor.view} (DataView), while `String`/`FixedString` use the * fast `buf.toString("utf8", ...)`. */ readonly buf: Buffer; @@ -51,7 +51,7 @@ export class RowBinaryState { * `readArray`) take sub-`Reader`s and return a `Reader`, so types compose with no * per-element closures. */ -export type Reader = (state: RowBinaryState) => T; +export type Reader = (state: Cursor) => T; /** * Reserve `n` bytes for the next read: bounds-check them, advance the cursor past @@ -68,7 +68,7 @@ export type Reader = (state: RowBinaryState) => T; * for that case can drop `advance` and read against `state.pos` directly, trading * streaming tolerance for one fewer compare per read. */ -export function advance(state: RowBinaryState, n: number): number { +export function advance(state: Cursor, n: number): number { const start = state.pos; const next = start + n; if (next > state.buf.length) throw NeedMoreData; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts b/skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts index 2955377ad..658a4313a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/datetime.ts @@ -1,4 +1,4 @@ -import { type Reader, RowBinaryState } from "./core.js"; +import { type Reader, Cursor } from "./core.js"; import { readInt32, readInt64, readUInt16, readUInt32 } from "./integers.js"; /** @@ -18,7 +18,7 @@ export type Nanoseconds = number; * SAFE TO TOGGLE: a `Date` is an object allocation per value. On a hot path that * only needs the calendar number, read the raw `UInt16` (days) instead. */ -export function readDate(state: RowBinaryState): Date { +export function readDate(state: Cursor): Date { return new Date(readUInt16(state) * 86_400_000); } @@ -27,7 +27,7 @@ export function readDate(state: RowBinaryState): Date { * returned as a JS `Date` at UTC midnight (pre-1970 dates are negative day * counts, which `Date` handles). */ -export function readDate32(state: RowBinaryState): Date { +export function readDate32(state: Cursor): Date { return new Date(readInt32(state) * 86_400_000); } @@ -36,7 +36,7 @@ export function readDate32(state: RowBinaryState): Date { * as a JS `Date` (exact at second resolution). The instant is UTC-based; a * column's timezone is display metadata, not in the bytes. */ -export function readDateTime(state: RowBinaryState): Date { +export function readDateTime(state: Cursor): Date { return new Date(readUInt32(state) * 1000); } @@ -76,7 +76,7 @@ export function readDateTime64(precision: number): Reader<[Date, Nanoseconds]> { * instant is represented losslessly with no separate fraction. Specialized * variant of {@link readDateTime64} with the scale baked in. */ -export function readDateTime64P3(state: RowBinaryState): Date { +export function readDateTime64P3(state: Cursor): Date { return new Date(Number(readInt64(state))); } @@ -85,7 +85,7 @@ export function readDateTime64P3(state: RowBinaryState): Date { * truncated to whole seconds plus the sub-second remainder in microseconds. * Specialized variant of {@link readDateTime64}. */ -export function readDateTime64P6(state: RowBinaryState): [Date, Microseconds] { +export function readDateTime64P6(state: Cursor): [Date, Microseconds] { const ticks = readInt64(state); let sec = ticks / 1_000_000n; let frac = ticks % 1_000_000n; // microseconds within the second @@ -101,7 +101,7 @@ export function readDateTime64P6(state: RowBinaryState): [Date, Microseconds] { * truncated to whole seconds plus the sub-second remainder in nanoseconds. * Specialized variant of {@link readDateTime64} with the scale baked in. */ -export function readDateTime64P9(state: RowBinaryState): [Date, Nanoseconds] { +export function readDateTime64P9(state: Cursor): [Date, Nanoseconds] { const ticks = readInt64(state); let sec = ticks / 1_000_000_000n; let frac = ticks % 1_000_000_000n; // nanoseconds within the second diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts b/skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts index e83b90d89..5e0e499a1 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/decimals.ts @@ -1,4 +1,4 @@ -import { type Reader, RowBinaryState } from "./core.js"; +import { type Reader } from "./core.js"; import { readInt32, readInt64, readInt128, readInt256 } from "./integers.js"; /** diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts b/skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts index 22df140cd..336ea8e7e 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/dynamic.ts @@ -1,4 +1,4 @@ -import { type Reader, RowBinaryState } from "./core.js"; +import { type Reader, Cursor } from "./core.js"; import { readUVarint } from "./varint.js"; import { readInt8, @@ -62,7 +62,7 @@ import { readJSON } from "./json.js"; * For a Dynamic-heavy hot path where the same few types recur, parse the type * once and reuse the returned reader across rows instead of re-parsing. */ -export function readDynamic(state: RowBinaryState): unknown { +export function readDynamic(state: Cursor): unknown { return readDynamicType(state)(state); } @@ -83,7 +83,7 @@ export function readDynamic(state: RowBinaryState): unknown { * with the tag value so you can extend this switch for the types your data * actually contains. */ -export function readDynamicType(state: RowBinaryState): Reader { +export function readDynamicType(state: Cursor): Reader { const tag = readUInt8(state); switch (tag) { // Nothing — a stored NULL. Zero value bytes. diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/enums.ts b/skills/clickhouse-js-node-rowbinary-parser/src/enums.ts index ccbb4d388..cc2513520 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/enums.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/enums.ts @@ -1,4 +1,4 @@ -import { RowBinaryState, advance } from "./core.js"; +import { Cursor, advance } from "./core.js"; /** * Read an `Enum8`: the value's underlying signed `Int8`. The name<->value map @@ -13,7 +13,7 @@ import { RowBinaryState, advance } from "./core.js"; * const STATUS = { 1: "active", 2: "closed" } as const; * const readStatusEnum = (s) => STATUS[readInt8(s) as keyof typeof STATUS]; */ -export function readEnum8(state: RowBinaryState): number { +export function readEnum8(state: Cursor): number { return state.view.getInt8(advance(state, 1)); } @@ -23,6 +23,6 @@ export function readEnum8(state: RowBinaryState): number { * keeping the number, or a generated per-enum reader with a baked-in constant * map so the JIT can optimize each enum's decode independently. */ -export function readEnum16(state: RowBinaryState): number { +export function readEnum16(state: Cursor): number { return state.view.getInt16(advance(state, 2), true); } diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/floats.ts b/skills/clickhouse-js-node-rowbinary-parser/src/floats.ts index 863498710..05bc32b6f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/floats.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/floats.ts @@ -1,4 +1,4 @@ -import { RowBinaryState, advance } from "./core.js"; +import { Cursor, advance } from "./core.js"; /** * Scratch view for widening a `BFloat16`: its 16 bits are the top half of an @@ -7,12 +7,12 @@ import { RowBinaryState, advance } from "./core.js"; const bf16Scratch = new DataView(new ArrayBuffer(4)); /** Read a `Float32`: 4 bytes, little-endian IEEE 754 single precision. */ -export function readFloat32(state: RowBinaryState): number { +export function readFloat32(state: Cursor): number { return state.view.getFloat32(advance(state, 4), true); } /** Read a `Float64`: 8 bytes, little-endian IEEE 754 double precision. */ -export function readFloat64(state: RowBinaryState): number { +export function readFloat64(state: Cursor): number { return state.view.getFloat64(advance(state, 8), true); } @@ -25,7 +25,7 @@ export function readFloat64(state: RowBinaryState): number { * function. That is safe because the read is synchronous; do NOT introduce an * `await`/`yield` between the `setUint32` and the `getFloat32`. */ -export function readBFloat16(state: RowBinaryState): number { +export function readBFloat16(state: Cursor): number { const bits = state.view.getUint16(advance(state, 2), true); bf16Scratch.setUint32(0, bits * 0x10000, true); return bf16Scratch.getFloat32(0, true); diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/geo.ts b/skills/clickhouse-js-node-rowbinary-parser/src/geo.ts index b5c715046..619442ec3 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/geo.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/geo.ts @@ -1,4 +1,4 @@ -import { RowBinaryState } from "./core.js"; +import { Cursor } from "./core.js"; import { readFloat64 } from "./floats.js"; import { readUInt8 } from "./integers.js"; import { readUVarint } from "./varint.js"; @@ -10,7 +10,7 @@ export type Point = [x: number, y: number]; // are monomorphic (no sub-readers) — the generator can emit them as-is. /** Read a `Point`: `Tuple(Float64, Float64)` -> `[x, y]`. */ -export function readPoint(state: RowBinaryState): Point { +export function readPoint(state: Cursor): Point { const x = readFloat64(state); const y = readFloat64(state); return [x, y]; @@ -21,7 +21,7 @@ export function readPoint(state: RowBinaryState): Point { * `LineString` has the identical wire (see {@link readLineString}). `readPoint` * is inlined here (two `readFloat64`s) to drop a call per point on this hot path. */ -export function readRing(state: RowBinaryState): Point[] { +export function readRing(state: Cursor): Point[] { const n = readUVarint(state); const out: Point[] = []; for (let i = 0; i < n; i++) { @@ -36,7 +36,7 @@ export function readRing(state: RowBinaryState): Point[] { * Read a `LineString`: `Array(Point)` (identical wire to a `Ring`). Points are * inlined (two `readFloat64`s) to drop a call per point on this hot path. */ -export function readLineString(state: RowBinaryState): Point[] { +export function readLineString(state: Cursor): Point[] { const n = readUVarint(state); const out: Point[] = []; for (let i = 0; i < n; i++) { @@ -48,7 +48,7 @@ export function readLineString(state: RowBinaryState): Point[] { } /** Read a `Polygon`: `Array(Ring)` — the outer ring first, then any holes. */ -export function readPolygon(state: RowBinaryState): Point[][] { +export function readPolygon(state: Cursor): Point[][] { const n = readUVarint(state); const out: Point[][] = []; for (let i = 0; i < n; i++) out.push(readRing(state)); @@ -56,7 +56,7 @@ export function readPolygon(state: RowBinaryState): Point[][] { } /** Read a `MultiLineString`: `Array(LineString)` (identical wire to a `Polygon`). */ -export function readMultiLineString(state: RowBinaryState): Point[][] { +export function readMultiLineString(state: Cursor): Point[][] { const n = readUVarint(state); const out: Point[][] = []; for (let i = 0; i < n; i++) out.push(readLineString(state)); @@ -64,7 +64,7 @@ export function readMultiLineString(state: RowBinaryState): Point[][] { } /** Read a `MultiPolygon`: `Array(Polygon)`. */ -export function readMultiPolygon(state: RowBinaryState): Point[][][] { +export function readMultiPolygon(state: Cursor): Point[][][] { const n = readUVarint(state); const out: Point[][][] = []; for (let i = 0; i < n; i++) out.push(readPolygon(state)); @@ -83,7 +83,7 @@ export function readMultiPolygon(state: RowBinaryState): Point[][][] { * which geo type it was. If you need the kind, branch on the discriminant. */ export function readGeometry( - state: RowBinaryState, + state: Cursor, ): Point | Point[] | Point[][] | Point[][][] | null { const discriminant = readUInt8(state); switch (discriminant) { diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/integers.ts b/skills/clickhouse-js-node-rowbinary-parser/src/integers.ts index 4b010341c..cf733811c 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/integers.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/integers.ts @@ -1,17 +1,17 @@ -import { RowBinaryState, advance } from "./core.js"; +import { Cursor, advance } from "./core.js"; /** Read a single unsigned byte and advance. */ -export function readUInt8(state: RowBinaryState): number { +export function readUInt8(state: Cursor): number { return state.buf[advance(state, 1)]!; } /** Read an `Int8`: 1 byte, two's-complement signed (-128 .. 127). */ -export function readInt8(state: RowBinaryState): number { +export function readInt8(state: Cursor): number { return state.view.getInt8(advance(state, 1)); } /** Read a `UInt16`: 2 bytes, little-endian (0 .. 65535). */ -export function readUInt16(state: RowBinaryState): number { +export function readUInt16(state: Cursor): number { return state.view.getUint16(advance(state, 2), true); } @@ -20,17 +20,17 @@ export function readUInt16(state: RowBinaryState): number { * 32767). `DataView` reads from any offset and decodes explicitly little-endian, * so the value never depends on host byte order. */ -export function readInt16(state: RowBinaryState): number { +export function readInt16(state: Cursor): number { return state.view.getInt16(advance(state, 2), true); } /** Read a `UInt32`: 4 bytes, little-endian (0 .. 4294967295). */ -export function readUInt32(state: RowBinaryState): number { +export function readUInt32(state: Cursor): number { return state.view.getUint32(advance(state, 4), true); } /** Read an `Int32`: 4 bytes, little-endian, two's-complement signed. */ -export function readInt32(state: RowBinaryState): number { +export function readInt32(state: Cursor): number { return state.view.getInt32(advance(state, 4), true); } @@ -38,7 +38,7 @@ export function readInt32(state: RowBinaryState): number { * Read a `UInt64`: 8 bytes, little-endian. Returns a `bigint`. * SAFE TO TOGGLE: if the values fit in 53 bits, wrap in `Number(...)`. */ -export function readUInt64(state: RowBinaryState): bigint { +export function readUInt64(state: Cursor): bigint { return state.view.getBigUint64(advance(state, 8), true); } @@ -47,12 +47,12 @@ export function readUInt64(state: RowBinaryState): bigint { * (range exceeds `Number.MAX_SAFE_INTEGER`). * SAFE TO TOGGLE: if the values fit in 53 bits, wrap in `Number(...)`. */ -export function readInt64(state: RowBinaryState): bigint { +export function readInt64(state: Cursor): bigint { return state.view.getBigInt64(advance(state, 8), true); } /** Read a `UInt128`: 16 bytes, little-endian. Always a `bigint`. */ -export function readUInt128(state: RowBinaryState): bigint { +export function readUInt128(state: Cursor): bigint { const start = advance(state, 16); const lo = state.view.getBigUint64(start, true); const hi = state.view.getBigUint64(start + 8, true); @@ -64,7 +64,7 @@ export function readUInt128(state: RowBinaryState): bigint { * `bigint`, composed from the low (unsigned) and high (signed) 64-bit words — * reading the high word signed extends the sign across all 128 bits. */ -export function readInt128(state: RowBinaryState): bigint { +export function readInt128(state: Cursor): bigint { const start = advance(state, 16); const lo = state.view.getBigUint64(start, true); const hi = state.view.getBigInt64(start + 8, true); @@ -72,7 +72,7 @@ export function readInt128(state: RowBinaryState): bigint { } /** Read a `UInt256`: 32 bytes, little-endian. Always a `bigint`. */ -export function readUInt256(state: RowBinaryState): bigint { +export function readUInt256(state: Cursor): bigint { const start = advance(state, 32); const w0 = state.view.getBigUint64(start, true); const w1 = state.view.getBigUint64(start + 8, true); @@ -85,7 +85,7 @@ export function readUInt256(state: RowBinaryState): bigint { * Read an `Int256`: 32 bytes, little-endian, two's-complement. Always a * `bigint`. The most-significant 64-bit word is read signed. */ -export function readInt256(state: RowBinaryState): bigint { +export function readInt256(state: Cursor): bigint { const start = advance(state, 32); const w0 = state.view.getBigUint64(start, true); const w1 = state.view.getBigUint64(start + 8, true); diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/interval.ts b/skills/clickhouse-js-node-rowbinary-parser/src/interval.ts index ea70b6473..fbcb7d744 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/interval.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/interval.ts @@ -1,4 +1,4 @@ -import { RowBinaryState } from "./core.js"; +import { Cursor } from "./core.js"; import { readInt64 } from "./integers.js"; /** The 11 `Interval` units, in ClickHouse's ascending order. */ @@ -49,6 +49,6 @@ export type IntervalValue = readonly [count: bigint, unit: IntervalUnit]; * all; the caller knows the unit from the column type. Returns a `bigint`; wrap * in `Number(...)` if the counts are known to fit in 53 bits. */ -export function readInterval(state: RowBinaryState): bigint { +export function readInterval(state: Cursor): bigint { return readInt64(state); } diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/ip.ts b/skills/clickhouse-js-node-rowbinary-parser/src/ip.ts index ea64cdf2f..086102d76 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/ip.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/ip.ts @@ -1,11 +1,11 @@ -import { RowBinaryState, advance } from "./core.js"; +import { Cursor, advance } from "./core.js"; /** * Read an `IPv4`: stored as a 4-byte little-endian `UInt32`. Returns the raw * 32-bit value (the little-endian load already orders the octets); pass it to * {@link formatIPv4} for the dotted-quad string. */ -export function readIPv4(state: RowBinaryState): number { +export function readIPv4(state: Cursor): number { return state.view.getUint32(advance(state, 4), true); } @@ -17,7 +17,7 @@ export function readIPv4(state: RowBinaryState): number { * whole response chunk in memory. If the value must outlive the row/response, * copy it with `Buffer.from(...)`. */ -export function readIPv6(state: RowBinaryState): Buffer { +export function readIPv6(state: Cursor): Buffer { const start = advance(state, 16); return state.buf.subarray(start, start + 16); } diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/json.ts b/skills/clickhouse-js-node-rowbinary-parser/src/json.ts index 81f5e7866..c4bfa1c8a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/json.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/json.ts @@ -1,4 +1,4 @@ -import { RowBinaryState } from "./core.js"; +import { Cursor } from "./core.js"; import { readUVarint } from "./varint.js"; import { readString } from "./strings.js"; import { readDynamic } from "./dynamic.js"; @@ -22,7 +22,7 @@ import { readDynamic } from "./dynamic.js"; * serializes those paths' values WITHOUT a type tag, so they cannot be decoded * without the schema; read each typed path with its known `T` reader instead. */ -export function readJSON(state: RowBinaryState): Map { +export function readJSON(state: Cursor): Map { const n = readUVarint(state); const out = new Map(); for (let i = 0; i < n; i++) { diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/reader.ts b/skills/clickhouse-js-node-rowbinary-parser/src/reader.ts index 589941add..bc20844cb 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/reader.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/reader.ts @@ -5,7 +5,7 @@ * sub-parsers a given result actually needs — the latter is what a generated * parser should do, copying just the modules its column types require. * - * - core — RowBinaryState, Reader, advance, NeedMoreData + * - core — Cursor, Reader, advance, NeedMoreData * - varint — readUVarint * - integers — readUInt8..readUInt256, readInt8..readInt256 * - bool / enums / floats diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/rows.ts b/skills/clickhouse-js-node-rowbinary-parser/src/rows.ts index bed2bbd67..f397c4cf3 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/rows.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/rows.ts @@ -1,4 +1,4 @@ -import { NeedMoreData, type Reader, RowBinaryState } from "./core.js"; +import { NeedMoreData, type Reader } from "./core.js"; /** * Drive `readRow` over every row of a plain `RowBinary` result into an array. @@ -29,7 +29,7 @@ import { NeedMoreData, type Reader, RowBinaryState } from "./core.js"; * const drive = readRows(readRow); * let committed = 0; * for (const chunk of chunks) { // chunk = growing prefix - * const s = new RowBinaryState(chunk); + * const s = new Cursor(chunk); * s.pos = committed; * emit(drive(s)); // complete rows in this chunk * committed = s.pos; // start of the straddling row diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/stream.ts b/skills/clickhouse-js-node-rowbinary-parser/src/stream.ts index 251d82029..174c9393c 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/stream.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/stream.ts @@ -1,4 +1,4 @@ -import { type Reader, RowBinaryState } from "./core.js"; +import { type Reader, Cursor } from "./core.js"; import { readRows } from "./rows.js"; /** Empty buffer reused as the "no carry" sentinel between chunks. */ @@ -128,7 +128,7 @@ export async function* streamRowBatches( const work = carry.length === 0 ? incoming : Buffer.concat([carry, incoming]); - const state = new RowBinaryState(work); + const state = new Cursor(work); const rows = drive(state); if (rows.length > 0) yield rows; diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/strings.ts b/skills/clickhouse-js-node-rowbinary-parser/src/strings.ts index 992e41743..681686173 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/strings.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/strings.ts @@ -1,4 +1,4 @@ -import { type Reader, RowBinaryState, advance } from "./core.js"; +import { type Reader, Cursor, advance } from "./core.js"; import { readUVarint } from "./varint.js"; /** @@ -9,7 +9,7 @@ import { readUVarint } from "./varint.js"; * columns, read `state.buf.subarray(start, start + len)` and skip the decode to * keep the raw bytes. */ -export function readString(state: RowBinaryState): string { +export function readString(state: Cursor): string { const len = readUVarint(state); const start = advance(state, len); return state.buf.toString("utf8", start, start + len); diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/time.ts b/skills/clickhouse-js-node-rowbinary-parser/src/time.ts index 4400e54bf..87ea47a26 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/time.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/time.ts @@ -1,4 +1,4 @@ -import { type Reader, RowBinaryState } from "./core.js"; +import { type Reader, Cursor } from "./core.js"; import { readInt32, readInt64 } from "./integers.js"; /** Semantic alias for `number` marking a seconds value (see {@link readTime}). */ @@ -15,7 +15,7 @@ export type ScaledTicks = readonly [ticks: bigint, precision: number]; * Read a `Time`: 4-byte signed `Int32` seconds-of-day (range ±999:59:59). * Returns the raw seconds; pass it to {@link formatTime}. */ -export function readTime(state: RowBinaryState): Seconds { +export function readTime(state: Cursor): Seconds { return readInt32(state); } diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts b/skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts index d9f07d612..75f7c2360 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/uuid.ts @@ -1,4 +1,4 @@ -import { RowBinaryState, advance } from "./core.js"; +import { Cursor, advance } from "./core.js"; /** * `UUID_HEX16[b]` packs the two lowercase ASCII hex chars of byte `b`, low char @@ -29,7 +29,7 @@ UUID_OUT[8] = UUID_OUT[13] = UUID_OUT[18] = UUID_OUT[23] = 0x2d; // '-' * FAST ALTERNATIVE: if you stringify every UUID, use {@link formatUUIDTable} * (lookup table, no BigInt, ~1.6x faster). */ -export function readUUID(state: RowBinaryState): Buffer { +export function readUUID(state: Cursor): Buffer { const start = advance(state, 16); return state.buf.subarray(start, start + 16); } @@ -42,7 +42,7 @@ export function readUUID(state: RowBinaryState): Buffer { * `Buffer.readBigUInt64LE`: V8 inlines the DataView accessors, measurably faster * for 8-byte reads. For the canonical string, use {@link readUUID} + {@link formatUUID}. */ -export function readUUIDBigInt(state: RowBinaryState): bigint { +export function readUUIDBigInt(state: Cursor): bigint { const start = advance(state, 16); const hi = state.view.getBigUint64(start, true); const lo = state.view.getBigUint64(start + 8, true); @@ -55,7 +55,7 @@ export function readUUIDBigInt(state: RowBinaryState): bigint { * (skips `hi << 64 | lo`) and a compact two-value key for comparison/dedup. For * the canonical string, use {@link readUUID} + {@link formatUUID}. */ -export function readUUIDHiLo(state: RowBinaryState): [hi: bigint, lo: bigint] { +export function readUUIDHiLo(state: Cursor): [hi: bigint, lo: bigint] { const start = advance(state, 16); const hi = state.view.getBigUint64(start, true); const lo = state.view.getBigUint64(start + 8, true); diff --git a/skills/clickhouse-js-node-rowbinary-parser/src/varint.ts b/skills/clickhouse-js-node-rowbinary-parser/src/varint.ts index 3f4275ebd..323b1f2d9 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/src/varint.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/src/varint.ts @@ -1,4 +1,4 @@ -import { RowBinaryState, advance } from "./core.js"; +import { Cursor, advance } from "./core.js"; /** * Read a LEB128 unsigned varint (used for string/array lengths). @@ -26,7 +26,7 @@ import { RowBinaryState, advance } from "./core.js"; * rather than toggling here. E.g. a `readUVarint32` for lengths guaranteed to be * 32-bit would unroll only the first five bytes and throw past 2^32 - 1. */ -export function readUVarint(state: RowBinaryState): number { +export function readUVarint(state: Cursor): number { // Each byte reserves its space through `advance(1)` (the bounds check), but // the read itself stays inlined as `state.buf[...]` rather than calling // readUInt8 — this is the hottest loop in the reader. diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Array.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Array.test.ts index 6755009dd..e76647f78 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Array.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Array.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readArray, readNullable } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt32, readUInt8 } from "../src/integers.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readArray", () => { @@ -45,7 +45,7 @@ describe("readArray", () => { "SELECT CAST([1, 2, 3] AS Array(UInt32)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readArray(readUInt32)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/BFloat16.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/BFloat16.test.ts index f1ca0026f..b375dae05 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/BFloat16.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/BFloat16.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readBFloat16 } from "../src/floats.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readBFloat16", () => { @@ -28,7 +28,7 @@ describe("readBFloat16", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toBFloat16(1.5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readBFloat16(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Bool.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Bool.test.ts index f065fc254..afedd3eed 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Bool.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Bool.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readBool } from "../src/bool.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readBool", () => { @@ -22,7 +22,7 @@ describe("readBool", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT true FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readBool(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Date.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Date.test.ts index 088e9f15b..7128474ea 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Date.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Date.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readDate } from "../src/datetime.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDate", () => { @@ -24,7 +24,7 @@ describe("readDate", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toDate('2021-03-15') FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDate(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Date32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Date32.test.ts index 9c561cc1c..05b0ec414 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Date32.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Date32.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readDate32 } from "../src/datetime.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDate32", () => { @@ -26,7 +26,7 @@ describe("readDate32", () => { "SELECT toDate32('1950-01-01') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDate32(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime.test.ts index bf1ebab63..c65ca1c98 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readDateTime } from "../src/datetime.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDateTime", () => { @@ -29,7 +29,7 @@ describe("readDateTime", () => { "SELECT toDateTime('2021-01-01 00:00:00', 'UTC') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDateTime(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64.test.ts index b7a899152..0857d474f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readDateTime64 } from "../src/datetime.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDateTime64", () => { @@ -38,7 +38,7 @@ describe("readDateTime64", () => { "SELECT toDateTime64('2021-01-01 00:00:00.123', 3, 'UTC') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDateTime64(3)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P3.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P3.test.ts index 76483b9b6..1c9fa5745 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P3.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P3.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readDateTime64P3 } from "../src/datetime.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDateTime64P3", () => { @@ -23,7 +23,7 @@ describe("readDateTime64P3", () => { "SELECT toDateTime64('2021-01-01 00:00:00.123', 3, 'UTC') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDateTime64P3(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P6.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P6.test.ts index fcc650737..7c92da007 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P6.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P6.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readDateTime64P6 } from "../src/datetime.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDateTime64P6", () => { @@ -24,7 +24,7 @@ describe("readDateTime64P6", () => { "SELECT toDateTime64('2021-01-01 00:00:00.123456', 6, 'UTC') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDateTime64P6(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P9.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P9.test.ts index a69dc200e..59e646b0a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P9.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/DateTime64P9.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readDateTime64P9 } from "../src/datetime.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDateTime64P9", () => { @@ -24,7 +24,7 @@ describe("readDateTime64P9", () => { "SELECT toDateTime64('2021-01-01 00:00:00.123456789', 9, 'UTC') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDateTime64P9(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal128.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal128.test.ts index add059c3b..465169f29 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal128.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal128.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatDecimal, readDecimal128 } from "../src/decimals.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDecimal128", () => { @@ -30,7 +30,7 @@ describe("readDecimal128", () => { "SELECT toDecimal128('-123.456789', 6) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDecimal128(6)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal256.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal256.test.ts index 1494a68a2..fca855973 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal256.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal256.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatDecimal, readDecimal256 } from "../src/decimals.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDecimal256", () => { @@ -32,7 +32,7 @@ describe("readDecimal256", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toDecimal256('-1', 0) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDecimal256(0)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal32.test.ts index d05702c2b..170954007 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal32.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal32.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatDecimal, readDecimal32 } from "../src/decimals.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDecimal32", () => { @@ -39,7 +39,7 @@ describe("readDecimal32", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toDecimal32(1.5, 4) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDecimal32(4)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal64.test.ts index baab7f166..b82275a83 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal64.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Decimal64.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatDecimal, readDecimal64 } from "../src/decimals.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readDecimal64", () => { @@ -29,7 +29,7 @@ describe("readDecimal64", () => { "SELECT toDecimal64(-12.34, 2) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readDecimal64(2)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Dynamic.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Dynamic.test.ts index 8654b706db1b1190a9ca64c1f518c3c3faa6b4da..4b52426881000cef763ed66f2c372d36400750e3 100644 GIT binary patch delta 87 zcmX?+G9zU|KAUrCQE`6J#M*1J5SE=nHJ5@yQEEwPQJz9xYPkYLcyj~eS52tm&CS~T RnPKbz{Y#86wt=ApBLMF+At?X= delta 145 zcmbP{aw26yK3`CNxl?9dVo_yqNn%Or#L{cJs3LX>)m#b+MX4pFMR^K&spSf&(wj3F lziNu2ifI5f0KsH!1G&u|+OwF^?AWZKe~A%IY?Gk`BLL2YHDv$* diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Enum16.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Enum16.test.ts index d94c36a4c..932b3aada 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Enum16.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Enum16.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readEnum16 } from "../src/enums.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readEnum16", () => { @@ -28,7 +28,7 @@ describe("readEnum16", () => { "SELECT CAST('big' AS Enum16('small' = 1, 'big' = 300)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readEnum16(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Enum8.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Enum8.test.ts index 8fd8df39b..84fe3e605 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Enum8.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Enum8.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readEnum8 } from "../src/enums.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readEnum8", () => { @@ -31,7 +31,7 @@ describe("readEnum8", () => { "SELECT CAST('b' AS Enum8('a' = 1, 'b' = 2)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readEnum8(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/FixedString.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/FixedString.test.ts index 08c5224c3..eab2e985f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/FixedString.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/FixedString.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readFixedString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readFixedString", () => { @@ -27,7 +27,7 @@ describe("readFixedString", () => { "SELECT toFixedString('ab', 4) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readFixedString(4)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/FixedStringBytes.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/FixedStringBytes.test.ts index 9ac9aedb2..99a30fe91 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/FixedStringBytes.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/FixedStringBytes.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readFixedStringBytes } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readFixedStringBytes", () => { @@ -28,7 +28,7 @@ describe("readFixedStringBytes", () => { "SELECT toFixedString('ab', 4) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readFixedStringBytes(4)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Float32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Float32.test.ts index 9fcd214cd..6ce0dae1a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Float32.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Float32.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readFloat32 } from "../src/floats.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readFloat32", () => { @@ -31,7 +31,7 @@ describe("readFloat32", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toFloat32(1.5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readFloat32(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Float64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Float64.test.ts index e9ae430b8..7de12973a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Float64.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Float64.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readFloat64 } from "../src/floats.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readFloat64", () => { @@ -31,7 +31,7 @@ describe("readFloat64", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toFloat64(1.5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readFloat64(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Geometry.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Geometry.test.ts index f7a253a15..28cd86d3b 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Geometry.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Geometry.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readGeometry } from "../src/geo.js"; // Geometry's variant has "similar" alternatives (LineString/Ring), so the type // needs allow_suspicious_variant_types; the value still casts through a geo type. -async function reader(expr: string): Promise { - return new RowBinaryState( +async function reader(expr: string): Promise { + return new Cursor( await query( `SELECT ${expr} SETTINGS allow_suspicious_variant_types = 1 FORMAT RowBinary`, ), @@ -50,7 +50,7 @@ describe("readGeometry", () => { "SELECT CAST(CAST((1.5, 2.5) AS Point) AS Geometry) SETTINGS allow_suspicious_variant_types = 1 FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readGeometry(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/IPv4.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/IPv4.test.ts index 2cf5286e4..90db4a2e8 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/IPv4.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/IPv4.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatIPv4, readIPv4 } from "../src/ip.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readIPv4", () => { @@ -32,7 +32,7 @@ describe("readIPv4", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toIPv4('1.2.3.4') FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readIPv4(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/IPv6.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/IPv6.test.ts index e8b0becf2..672e23f9f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/IPv6.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/IPv6.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatIPv6, readIPv6 } from "../src/ip.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readIPv6", () => { @@ -51,7 +51,7 @@ describe("readIPv6", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toIPv6('2001:db8::1') FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readIPv6(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int128.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int128.test.ts index d5c1c63c6..81d69d7af 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Int128.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int128.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readInt128 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } const MAX = 170141183460469231731687303715884105727n; // 2^127 - 1 @@ -33,7 +33,7 @@ describe("readInt128", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toInt128(-5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readInt128(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int16.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int16.test.ts index 4bc589e49..0d1641d3e 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Int16.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int16.test.ts @@ -1,16 +1,14 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readInt16 } from "../src/integers.js"; /** * Int16 is 2 bytes, little-endian, two's-complement. Each case selects the * value with `FORMAT RowBinary` and decodes the bytes the server produces. */ -async function int16Reader(expr: string): Promise { - return new RowBinaryState( - await query(`SELECT toInt16(${expr}) FORMAT RowBinary`), - ); +async function int16Reader(expr: string): Promise { + return new Cursor(await query(`SELECT toInt16(${expr}) FORMAT RowBinary`)); } describe("readInt16", () => { @@ -52,14 +50,14 @@ describe("readInt16", () => { const ab = Uint8Array.from([0xaa, 0xbb, 0xcc, 0x02, 0x01]).buffer; // 258 at offset 3 const sub = Buffer.from(ab, 3, 2); expect(sub.byteOffset).toBe(3); - expect(readInt16(new RowBinaryState(sub))).toBe(258); + expect(readInt16(new Cursor(sub))).toBe(258); }); describe("advance() edge cases", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toInt16(-12345) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readInt16(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int256.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int256.test.ts index cc117a13a..52af1bcb6 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Int256.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int256.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readInt256 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } // 2^255 - 1 and -2^255 @@ -36,7 +36,7 @@ describe("readInt256", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toInt256(-5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readInt256(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int32.test.ts index 9444ba444..8bacd22c7 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Int32.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int32.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readInt32 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readInt32", () => { @@ -30,7 +30,7 @@ describe("readInt32", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toInt32(-5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readInt32(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int64.test.ts index 7df0979a0..260a0553f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Int64.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int64.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readInt64 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readInt64", () => { @@ -34,7 +34,7 @@ describe("readInt64", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toInt64(-5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readInt64(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Int8.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Int8.test.ts index f330273ae..c521dcf5d 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Int8.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Int8.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readInt8 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readInt8", () => { @@ -30,7 +30,7 @@ describe("readInt8", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toInt8(-1) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readInt8(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Interval.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Interval.test.ts index 5fff43ac5..d60d4700b 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Interval.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Interval.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readInterval } from "../src/interval.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } // All 11 Interval* types share one Int64 wire; the unit lives in the type name. @@ -29,7 +29,7 @@ describe("readInterval", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toIntervalSecond(5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readInterval(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/JSON.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/JSON.test.ts index 45d1a2c63..05505feea 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/JSON.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/JSON.test.ts @@ -1,15 +1,13 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readDynamic } from "../src/dynamic.js"; import { readJSON } from "../src/json.js"; const J = "SETTINGS allow_experimental_json_type = 1, enable_json_type = 1"; -async function reader(expr: string): Promise { - return new RowBinaryState( - await query(`SELECT ${expr} ${J} FORMAT RowBinary`), - ); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} ${J} FORMAT RowBinary`)); } describe("readJSON", () => { @@ -61,8 +59,8 @@ describe("readJSON", () => { // JSON nested inside a Dynamic: the 0x30 tag's type-encoding header precedes // the body, which readDynamicType consumes before delegating to readJSON. describe("inside a Dynamic (tag 0x30, with the type-encoding header)", () => { - async function dyn(expr: string): Promise { - return new RowBinaryState( + async function dyn(expr: string): Promise { + return new Cursor( await query( `SELECT CAST(${expr} AS Dynamic) ${J}, allow_experimental_dynamic_type = 1 FORMAT RowBinary`, ), @@ -92,7 +90,7 @@ describe("readJSON", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query(`SELECT '{"a":1}'::JSON ${J} FORMAT RowBinary`); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readJSON(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/LineString.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/LineString.test.ts index e59f9ec59..d646d2a58 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/LineString.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/LineString.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readLineString } from "../src/geo.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readLineString", () => { @@ -22,7 +22,7 @@ describe("readLineString", () => { "SELECT CAST([(3, 4), (5, 6)] AS LineString) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readLineString(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Map.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Map.test.ts index fd6cac615..1f6783a23 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Map.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Map.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readMap, readNullable } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt32, readUInt8 } from "../src/integers.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readMap", () => { @@ -49,7 +49,7 @@ describe("readMap", () => { "SELECT CAST(map('a', 1, 'b', 2) AS Map(String, UInt8)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readMap(readString, readUInt8)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/MultiLineString.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/MultiLineString.test.ts index 6065223a0..cae87a61d 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/MultiLineString.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/MultiLineString.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readMultiLineString } from "../src/geo.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readMultiLineString", () => { @@ -24,7 +24,7 @@ describe("readMultiLineString", () => { "SELECT CAST([[(0, 0), (1, 1)]] AS MultiLineString) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readMultiLineString(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/MultiPolygon.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/MultiPolygon.test.ts index 4d79b25d9..0ae3fe4a2 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/MultiPolygon.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/MultiPolygon.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readMultiPolygon } from "../src/geo.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readMultiPolygon", () => { @@ -29,7 +29,7 @@ describe("readMultiPolygon", () => { "SELECT CAST([[[(0, 0), (1, 0), (1, 1)]]] AS MultiPolygon) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readMultiPolygon(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Nullable.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Nullable.test.ts index d7058cee9..ab631439a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Nullable.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Nullable.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readNullable } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt32, readUInt8 } from "../src/integers.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readNullable", () => { @@ -45,7 +45,7 @@ describe("readNullable", () => { "SELECT CAST(42 AS Nullable(UInt32)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readNullable(readUInt32)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Point.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Point.test.ts index ce01bc778..bb47c19f4 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Point.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Point.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readPoint } from "../src/geo.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readPoint", () => { @@ -20,7 +20,7 @@ describe("readPoint", () => { "SELECT CAST((1.5, 2.5) AS Point) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readPoint(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Polygon.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Polygon.test.ts index 6708539f4..31fd6a10b 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Polygon.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Polygon.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readPolygon } from "../src/geo.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readPolygon", () => { @@ -25,7 +25,7 @@ describe("readPolygon", () => { "SELECT CAST([[(0, 0), (1, 0), (1, 1)]] AS Polygon) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readPolygon(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Ring.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Ring.test.ts index 9e51f7694..1c9bfcf12 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Ring.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Ring.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readRing } from "../src/geo.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readRing", () => { @@ -23,7 +23,7 @@ describe("readRing", () => { "SELECT CAST([(0, 0), (1, 2)] AS Ring) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readRing(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/String.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/String.test.ts index 201691221..335a247f1 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/String.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/String.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readString", () => { @@ -36,7 +36,7 @@ describe("readString", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT 'hello' FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readString(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Time.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Time.test.ts index 01bbdca53..95d67712c 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Time.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Time.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatTime, readTime } from "../src/time.js"; // Time / Time64 need enable_time_time64_type; pass it inline via SETTINGS. -async function reader(expr: string): Promise { - return new RowBinaryState( +async function reader(expr: string): Promise { + return new Cursor( await query( `SELECT ${expr} SETTINGS enable_time_time64_type = 1 FORMAT RowBinary`, ), @@ -33,7 +33,7 @@ describe("readTime", () => { "SELECT CAST('12:34:56' AS Time) SETTINGS enable_time_time64_type = 1 FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readTime(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Time64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Time64.test.ts index 0f100592d..c7dccf71c 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Time64.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Time64.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatTime64, readTime64 } from "../src/time.js"; -async function reader(expr: string): Promise { - return new RowBinaryState( +async function reader(expr: string): Promise { + return new Cursor( await query( `SELECT ${expr} SETTINGS enable_time_time64_type = 1 FORMAT RowBinary`, ), @@ -32,7 +32,7 @@ describe("readTime64", () => { "SELECT toTime64('12:34:56.123', 3) SETTINGS enable_time_time64_type = 1 FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readTime64(3)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Tuple.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Tuple.test.ts index e6bf6bb11..5b37552b1 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Tuple.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Tuple.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readNullable, readTuple } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt32, readUInt8 } from "../src/integers.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readTuple", () => { @@ -31,7 +31,7 @@ describe("readTuple", () => { "SELECT CAST((1, 'x') AS Tuple(UInt8, String)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readTuple([readUInt8, readString])(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/TupleNamed.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/TupleNamed.test.ts index 4a8081b3f..c31d55f64 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/TupleNamed.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/TupleNamed.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readNullable, readTupleNamed } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt32, readUInt8 } from "../src/integers.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readTupleNamed", () => { @@ -39,7 +39,7 @@ describe("readTupleNamed", () => { "SELECT CAST((1, 'x') AS Tuple(a UInt8, b String)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readTupleNamed({ a: readUInt8, b: readString })(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt128.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt128.test.ts index 52c4f4216..b90fc3b71 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt128.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt128.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt128 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } const MAX = 340282366920938463463374607431768211455n; // 2^128 - 1 @@ -31,7 +31,7 @@ describe("readUInt128", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toUInt128(5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUInt128(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt16.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt16.test.ts index 701fa8766..0d1b0ab3a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt16.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt16.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt16 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readUInt16", () => { @@ -27,7 +27,7 @@ describe("readUInt16", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toUInt16(258) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUInt16(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt256.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt256.test.ts index c74048b9b..83478d7fd 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt256.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt256.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt256 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } const MAX = @@ -34,7 +34,7 @@ describe("readUInt256", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toUInt256(5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUInt256(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt32.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt32.test.ts index 8a807ba16..a0fd2025e 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt32.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt32.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt32 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readUInt32", () => { @@ -22,7 +22,7 @@ describe("readUInt32", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toUInt32(258) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUInt32(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt64.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt64.test.ts index d08800e8e..d2809929a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt64.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt64.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt64 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readUInt64", () => { @@ -28,7 +28,7 @@ describe("readUInt64", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toUInt64(5) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUInt64(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt8.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt8.test.ts index ba687e93a..0921192e6 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UInt8.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UInt8.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt8 } from "../src/integers.js"; describe("readUInt8", () => { it("reads sequential unsigned bytes", () => { - const r = new RowBinaryState(Buffer.from([1, 2, 255])); + const r = new Cursor(Buffer.from([1, 2, 255])); expect(readUInt8(r)).toBe(1); expect(readUInt8(r)).toBe(2); expect(readUInt8(r)).toBe(255); @@ -14,7 +14,7 @@ describe("readUInt8", () => { it("decodes a UInt8 straight from ClickHouse", async () => { const bytes = await query("SELECT toUInt8(255) FORMAT RowBinary"); - const r = new RowBinaryState(bytes); + const r = new Cursor(bytes); expect(readUInt8(r)).toBe(255); }); @@ -22,7 +22,7 @@ describe("readUInt8", () => { it("throws NeedMoreData for every incomplete prefix (0 .. full.length-1)", async () => { const full = await query("SELECT toUInt8(255) FORMAT RowBinary"); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUInt8(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UUID.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UUID.test.ts index a948db7cc..a28da0705 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UUID.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UUID.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatUUID, formatUUIDTable, readUUID } from "../src/uuid.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readUUID", () => { @@ -103,7 +103,7 @@ describe("readUUID", () => { "SELECT toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUUID(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDBigInt.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDBigInt.test.ts index 00786097d..3902b6adb 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDBigInt.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDBigInt.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUUIDBigInt } from "../src/uuid.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readUUIDBigInt", () => { @@ -44,7 +44,7 @@ describe("readUUIDBigInt", () => { "SELECT toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUUIDBigInt(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDHiLo.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDHiLo.test.ts index 6ff436154..f01ff23f2 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDHiLo.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UUIDHiLo.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { formatUUID, readUUID, @@ -8,8 +8,8 @@ import { readUUIDHiLo, } from "../src/uuid.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } describe("readUUIDHiLo", () => { @@ -43,7 +43,7 @@ describe("readUUIDHiLo", () => { "SELECT toUUID('61f0c404-5cb3-11e7-907b-a6006ad3dba0') FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUUIDHiLo(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/UVarint.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/UVarint.test.ts index f1a022783..8229c530c 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/UVarint.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/UVarint.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUVarint } from "../src/varint.js"; /** @@ -13,10 +13,8 @@ import { readUVarint } from "../src/varint.js"; * 127 / 128 — 1-byte max -> first 2-byte value * 16383/16384 — 2-byte max -> first 3-byte value */ -async function repeatReader(n: number): Promise { - return new RowBinaryState( - await query(`SELECT repeat('a', ${n}) FORMAT RowBinary`), - ); +async function repeatReader(n: number): Promise { + return new Cursor(await query(`SELECT repeat('a', ${n}) FORMAT RowBinary`)); } describe("readUVarint", () => { @@ -62,7 +60,7 @@ describe("readUVarint", () => { // could SELECT, so the bytes are constructed directly rather than fetched. it("decodes Number.MAX_SAFE_INTEGER (2^53 - 1)", () => { // 53 bits set: seven full 7-bit groups (0xff) plus a 4-bit top group (0x0f). - const r = new RowBinaryState( + const r = new Cursor( Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f]), ); expect(readUVarint(r)).toBe(Number.MAX_SAFE_INTEGER); @@ -73,7 +71,7 @@ describe("readUVarint", () => { // bit 4 (0x10) sets bit 53; everything below is zero. Must throw rather than // return an imprecise number. it("throws when a varint exceeds Number.MAX_SAFE_INTEGER", () => { - const r = new RowBinaryState( + const r = new Cursor( Buffer.from([0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10]), ); expect(() => readUVarint(r)).toThrow(RangeError); @@ -85,7 +83,7 @@ describe("readUVarint", () => { // byte with the continuation bit set and no following byte, so it must starve. const full = Buffer.from([0x80, 0x80, 0x01]); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUVarint(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/Variant.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/Variant.test.ts index 9a7bfbe02..98e86a90f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/Variant.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/Variant.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readVariant } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readFloat64 } from "../src/floats.js"; import { readUInt64, readUInt8 } from "../src/integers.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState( +async function reader(expr: string): Promise { + return new Cursor( await query( `SELECT ${expr} SETTINGS allow_experimental_variant_type = 1 FORMAT RowBinary`, ), @@ -50,7 +50,7 @@ describe("readVariant", () => { "SELECT CAST(42 AS Variant(UInt8, String)) SETTINGS allow_experimental_variant_type = 1 FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readVariant([readString, readUInt8])(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/advance.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/advance.test.ts index 97a99b98e..ae39a2e4a 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/advance.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/advance.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt64 } from "../src/integers.js"; import { readString } from "../src/strings.js"; @@ -19,7 +19,7 @@ import { readString } from "../src/strings.js"; describe("advance() and NeedMoreData", () => { it("throws NeedMoreData when a fixed-width read crosses the end, leaving pos put", async () => { const full = await query("SELECT toUInt64(1) FORMAT RowBinary"); // 8 bytes - const r = new RowBinaryState(full.subarray(0, 5)); // one byte short of nothing + const r = new Cursor(full.subarray(0, 5)); // one byte short of nothing let thrown: unknown; try { readUInt64(r); @@ -34,7 +34,7 @@ describe("advance() and NeedMoreData", () => { // "hello" -> 1 varint length byte (0x05) + 5 bytes. Reveal length + 2 body // bytes: the varint read succeeds, the body read starves. const full = await query("SELECT 'hello' FORMAT RowBinary"); // 6 bytes - const r = new RowBinaryState(full.subarray(0, 3)); + const r = new Cursor(full.subarray(0, 3)); let thrown: unknown; try { readString(r); @@ -64,7 +64,7 @@ describe("advance() and NeedMoreData", () => { let avail = 0; while (committed < full.length) { avail = Math.min(full.length, avail + chunk); - const r = new RowBinaryState(full.subarray(0, avail)); + const r = new Cursor(full.subarray(0, avail)); r.pos = committed; try { while (r.pos < r.buf.length) { diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/aggregateFunction.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/aggregateFunction.test.ts index 585c6c047..b1d22654b 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/aggregateFunction.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/aggregateFunction.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readAggregateFunction } from "../src/aggregateFunction.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt64, readUInt8 } from "../src/integers.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } /** @@ -60,7 +60,7 @@ describe("AggregateFunction (opaque state — finalize server-side)", () => { }); it("readAggregateFunction is a guard: it always throws (never decode opaque state)", () => { - const r = new RowBinaryState(Buffer.alloc(0)); + const r = new Cursor(Buffer.alloc(0)); expect(() => readAggregateFunction(r)).toThrow(/opaque/i); }); @@ -71,7 +71,7 @@ describe("AggregateFunction (opaque state — finalize server-side)", () => { "SELECT sumState(toUInt64(42)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUInt64(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/carts.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/carts.bench.ts index 1611c8f30..d93884897 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/carts.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/carts.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type CartRow, readCartRow, @@ -22,7 +22,7 @@ const BUF = await query( ); function decodeAll(read: Reader): CartRow[] { - const s = new RowBinaryState(BUF); + const s = new Cursor(BUF); const out: CartRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/carts.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/carts.example.test.ts index 4bda2e4ae..32229373c 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/carts.example.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/carts.example.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { type CartRow, readCartRow } from "../src/examples/carts.js"; import { readRows } from "../src/rows.js"; @@ -37,7 +37,7 @@ describe("example: carts (nested generics via JSONEachRow)", () => { rows.map((r) => JSON.stringify(r)).join("\n"), ); - const r = new RowBinaryState( + const r = new Cursor( await query( `SELECT cart_id, items, discounts FROM ${t} ORDER BY cart_id FORMAT RowBinary`, ), diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/coalesceChunks.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/coalesceChunks.test.ts index eca6ac13b..2f1b946d7 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/coalesceChunks.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/coalesceChunks.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { readUInt64 } from "../src/integers.js"; import { coalesceChunks, streamRowBatches } from "../src/stream.js"; import { readString } from "../src/strings.js"; @@ -115,7 +115,7 @@ describe("coalesceChunks (debounce small chunks before streaming)", () => { it("composes in front of streamRowBatches: 1-byte chunks decode correctly", async () => { type Row = { id: bigint; s: string }; - const readRow = (s: RowBinaryState): Row => ({ + const readRow = (s: Cursor): Row => ({ id: readUInt64(s), s: readString(s), }); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/combinations.generated.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/combinations.generated.test.ts index c5b939d92..84dd4421c 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/combinations.generated.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/combinations.generated.test.ts @@ -7,7 +7,7 @@ import { readTuple, readVariant, } from "../src/composite.js"; -import { NeedMoreData, type Reader, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, type Reader, Cursor } from "../src/core.js"; import { readInt32, readUInt8 } from "../src/integers.js"; import { readString } from "../src/strings.js"; @@ -258,14 +258,14 @@ describe("type combinations (generated, one-leaf-at-a-time)", () => { const full = await framedBytes(c.expr); // 1. framed: decodes correctly AND consumes exactly its bytes. - const r = new RowBinaryState(full); + const r = new Cursor(full); expect(readInt32(r)).toBe(LEAD); expect(c.read(r)).toEqual(c.expected); expect(readInt32(r), `${c.label}: x over/under-read`).toBe(TRAIL); // 2. truncation sweep: every incomplete prefix must starve, never desync. for (let len = 0; len < full.length; len++) { - const p = new RowBinaryState(full.subarray(0, len)); + const p = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readInt32(p); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/events.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/events.bench.ts index 8a285e2a8..a08a9b5fd 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/events.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/events.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type EventRow, readEventRow, @@ -22,7 +22,7 @@ const BUF = await query( ); function decodeAll(read: Reader): EventRow[] { - const s = new RowBinaryState(BUF); + const s = new Cursor(BUF); const out: EventRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/events.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/events.example.test.ts index 8e62e53ac..65ec4b2da 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/events.example.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/events.example.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { type EventRow, readEventRow } from "../src/examples/events.js"; import { readRows } from "../src/rows.js"; @@ -29,7 +29,7 @@ describe("example: events (scalars via JSONEachRow)", () => { rows.map((r) => JSON.stringify(r)).join("\n"), ); - const r = new RowBinaryState( + const r = new Cursor( await query( `SELECT id, name, ts FROM ${t} ORDER BY id FORMAT RowBinary`, ), diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/framing-interleaved.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/framing-interleaved.test.ts index 7e910b3c9..55b1e2ced 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/framing-interleaved.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/framing-interleaved.test.ts @@ -7,7 +7,7 @@ import { readTuple, readVariant, } from "../src/composite.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { readDynamic } from "../src/dynamic.js"; import { readInt32, readUInt8 } from "../src/integers.js"; import { readJSON } from "../src/json.js"; @@ -39,11 +39,11 @@ const SETTINGS = [ ].join(", "); /** Build `i32(LEAD), X, Y, i32(TRAIL)` and return a reader over the bytes. */ -async function framed(exprX: string, exprY: string): Promise { +async function framed(exprX: string, exprY: string): Promise { const sql = `SELECT toInt32(${LEAD}) AS a, ${exprX} AS x, ${exprY} AS y,` + ` toInt32(${TRAIL}) AS b SETTINGS ${SETTINGS} FORMAT RowBinary`; - return new RowBinaryState(await query(sql)); + return new Cursor(await query(sql)); } // Inner-reader shorthands, as in framing-nested.test.ts. diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/framing-nested.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/framing-nested.test.ts index 364ea25b1..f34b27d60 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/framing-nested.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/framing-nested.test.ts @@ -7,7 +7,7 @@ import { readTuple, readVariant, } from "../src/composite.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { readDynamic } from "../src/dynamic.js"; import { readInt32, readUInt8 } from "../src/integers.js"; import { readJSON } from "../src/json.js"; @@ -41,11 +41,11 @@ const SETTINGS = [ "allow_suspicious_low_cardinality_types = 1", ].join(", "); -async function framed(expr: string): Promise { +async function framed(expr: string): Promise { const sql = `SELECT toInt32(${LEAD}) AS a, ${expr} AS x, toInt32(${TRAIL}) AS b` + ` SETTINGS ${SETTINGS} FORMAT RowBinary`; - return new RowBinaryState(await query(sql)); + return new Cursor(await query(sql)); } // Shared inner readers, written once so the nesting reads cleanly below. diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/framing.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/framing.test.ts index 5c2be487d..8d19363a6 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/framing.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/framing.test.ts @@ -10,7 +10,7 @@ import { readTupleNamed, readVariant, } from "../src/composite.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { readDate, readDate32, @@ -93,11 +93,11 @@ const SETTINGS = [ * the bytes. Each test reads the leading sentinel, X, and the trailing sentinel * itself. */ -async function framed(expr: string): Promise { +async function framed(expr: string): Promise { const sql = `SELECT toInt32(${LEAD}) AS a, ${expr} AS x, toInt32(${TRAIL}) AS b` + ` SETTINGS ${SETTINGS} FORMAT RowBinary`; - return new RowBinaryState(await query(sql)); + return new Cursor(await query(sql)); } describe("framing: i32, X, i32 — the middle reader must stop at the exact byte", () => { diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.bench.ts index bfad67583..f104170c7 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type IotRow, readIotRow, @@ -41,7 +41,7 @@ const JSON_COMPACT_BUF = await query(`${SELECT} FORMAT JSONCompactEachRow`); // --- decoders --------------------------------------------------------------- function decodeRowBinary(read: Reader): IotRow[] { - const s = new RowBinaryState(RB_BUF); + const s = new Cursor(RB_BUF); const out: IotRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.columnar.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.columnar.bench.ts index fe51704bd..6148b7b6f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.columnar.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.columnar.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { type IotRow, decodeIotColumnar, @@ -28,7 +28,7 @@ const SELECT = const BUF = await query(`${SELECT} FORMAT RowBinary`); function decodeRows(): IotRow[] { - const s = new RowBinaryState(BUF); + const s = new Cursor(BUF); const out: IotRow[] = []; while (s.pos < s.buf.length) out.push(readIotRowFast(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.wasm-headroom.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.wasm-headroom.bench.ts index 280f4f41b..0a6eb7292 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/iot.wasm-headroom.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/iot.wasm-headroom.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { type IotRow, readIotRowFast } from "../src/examples/iot.js"; /** @@ -44,7 +44,7 @@ const ROW_BYTES = 41; // 1. Current output contract: an array of row objects. function decodeRows(): IotRow[] { - const s = new RowBinaryState(BUF); + const s = new Cursor(BUF); const out: IotRow[] = []; while (s.pos < s.buf.length) out.push(readIotRowFast(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/ledger.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/ledger.bench.ts index 468f3d99d..b2bffc00f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/ledger.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/ledger.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type DecimalValue, formatDecimal } from "../src/decimals.js"; import { type LedgerRow, @@ -54,7 +54,7 @@ const JSON_COMPACT_STR_BUF = await query( // --- decoders --------------------------------------------------------------- function decodeRowBinary(read: Reader): LedgerRow[] { - const s = new RowBinaryState(RB_BUF); + const s = new Cursor(RB_BUF); const out: LedgerRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/logs.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/logs.bench.ts index 6504863a5..89c520524 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/logs.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/logs.bench.ts @@ -1,7 +1,7 @@ import { gzipSync, zstdCompressSync } from "node:zlib"; import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type LogRow, readLogRow, @@ -39,7 +39,7 @@ const JSON_COMPACT_BUF = await query(`${SELECT} FORMAT JSONCompactEachRow`); // --- decoders --------------------------------------------------------------- function decodeRowBinary(read: Reader): LogRow[] { - const s = new RowBinaryState(RB_BUF); + const s = new Cursor(RB_BUF); const out: LogRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/lowCardinality.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/lowCardinality.test.ts index 38d0965eb..dbc39d66b 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/lowCardinality.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/lowCardinality.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readNullable } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readLowCardinality } from "../src/lowCardinality.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } /** @@ -35,7 +35,7 @@ describe("LowCardinality (transparent — decode as the inner type)", () => { "SELECT CAST('x' AS LowCardinality(String)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readString(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/nested.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/nested.test.ts index 005db5f73..7723b5f42 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/nested.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/nested.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readArray, readTupleNamed } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt8 } from "../src/integers.js"; import { readNested } from "../src/nested.js"; import { readString } from "../src/strings.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } /** @@ -41,7 +41,7 @@ describe("Nested (decode as Array(Tuple(...)))", () => { "SELECT CAST([(1, 'a'), (2, 'b')] AS Array(Tuple(x UInt8, y String))) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readArray(readTupleNamed({ x: readUInt8, y: readString }))(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/nothing.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/nothing.test.ts index 807111f20..9e26c4414 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/nothing.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/nothing.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readArray, readNullable } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readNothing } from "../src/nothing.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } /** @@ -43,7 +43,7 @@ describe("Nothing (zero-width — only appears as Array(Nothing) / Nullable(Noth it("Array(Nothing): throws NeedMoreData for every incomplete prefix", async () => { const full = await query("SELECT [] FORMAT RowBinary"); // single 0x00 count byte for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readArray(() => { @@ -59,7 +59,7 @@ describe("Nothing (zero-width — only appears as Array(Nothing) / Nullable(Noth it("Nullable(Nothing): throws NeedMoreData for every incomplete prefix", async () => { const full = await query("SELECT NULL FORMAT RowBinary"); // single 0x01 flag byte for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readNullable(() => { diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/observability.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/observability.bench.ts index f44376dfd..631b7f969 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/observability.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/observability.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type ObsRow, readObsRow, @@ -35,7 +35,7 @@ const BUF = await query( ); function decodeAll(read: Reader): ObsRow[] { - const s = new RowBinaryState(BUF); + const s = new Cursor(BUF); const out: ObsRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/observability.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/observability.example.test.ts index e73f34def..22e4c4570 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/observability.example.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/observability.example.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { type ObsRow, readObsRow, @@ -37,11 +37,11 @@ describe("example: observability (Variant / DateTime64 / LowCardinality / nested it("API and optimized readers agree, consume exactly, and decode the gotchas", async () => { const buf = await query(SQL(64)); - const a = new RowBinaryState(buf); + const a = new Cursor(buf); const viaApi: ObsRow[] = readRows(readObsRow)(a); expect(a.pos, "API reader consumes the whole buffer").toBe(a.buf.length); - const b = new RowBinaryState(buf); + const b = new Cursor(buf); const viaFast: ObsRow[] = readRows(readObsRowFast)(b); expect(b.pos, "fast reader consumes the whole buffer").toBe(b.buf.length); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/orders.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/orders.bench.ts index 9094e1f37..3a8ffc8cf 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/orders.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/orders.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type OrderRow, readOrderRow, @@ -22,7 +22,7 @@ const BUF = await query( ); function decodeAll(read: Reader): OrderRow[] { - const s = new RowBinaryState(BUF); + const s = new Cursor(BUF); const out: OrderRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/orders.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/orders.example.test.ts index 799ca17b4..1e4bef328 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/orders.example.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/orders.example.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { type OrderRow, readOrderRow } from "../src/examples/orders.js"; import { readRows } from "../src/rows.js"; @@ -30,7 +30,7 @@ describe("example: orders (UUID / Decimal / Enum via raw VALUES)", () => { `(3, 'ffffffff-ffff-ffff-ffff-ffffffffffff', -9.99, 'done')`, ); - const r = new RowBinaryState( + const r = new Cursor( await query( `SELECT id, uid, price, status FROM ${t} ORDER BY id FORMAT RowBinary`, ), diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.bench.ts index c2ed2a4db..7276bc15f 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type ProfileRow, readProfileRow, @@ -22,7 +22,7 @@ const BUF = await query( ); function decodeAll(read: Reader): ProfileRow[] { - const s = new RowBinaryState(BUF); + const s = new Cursor(BUF); const out: ProfileRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.example.test.ts index 0588ce9ea..c42a54d15 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.example.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/profiles.example.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { type ProfileRow, readProfileRow } from "../src/examples/profiles.js"; import { readRows } from "../src/rows.js"; @@ -27,7 +27,7 @@ describe("example: profiles (Array + Nullable via JSONEachRow)", () => { rows.map((r) => JSON.stringify(r)).join("\n"), ); - const r = new RowBinaryState( + const r = new Cursor( await query( `SELECT id, tags, score FROM ${t} ORDER BY id FORMAT RowBinary`, ), diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/qbit.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/qbit.test.ts index dc76a5cc3..d5637c7ba 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/qbit.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/qbit.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readQBit } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readBFloat16, readFloat32, readFloat64 } from "../src/floats.js"; import { readUInt8 } from "../src/integers.js"; // QBit is experimental; the type needs allow_experimental_qbit_type. -async function reader(expr: string): Promise { - return new RowBinaryState( +async function reader(expr: string): Promise { + return new Cursor( await query( `SELECT ${expr} SETTINGS allow_experimental_qbit_type = 1 FORMAT RowBinary`, ), @@ -58,7 +58,7 @@ describe("QBit (transparent in RowBinary — decode as Array(element_type))", () "SELECT [1.0, 2.0]::QBit(Float32, 2) SETTINGS allow_experimental_qbit_type = 1 FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readQBit(readFloat32)(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/rows.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/rows.test.ts index 302241018..ca45f3bca 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/rows.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/rows.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { readInt32, readUInt64 } from "../src/integers.js"; import { readRows } from "../src/rows.js"; import { readString } from "../src/strings.js"; @@ -24,7 +24,7 @@ import { readString } from "../src/strings.js"; */ describe("multiple rows from one buffer", () => { it("fixed-width single column: read a known row count in a loop", async () => { - const r = new RowBinaryState( + const r = new Cursor( await query("SELECT toInt32(number) FROM numbers(5) FORMAT RowBinary"), ); const out: number[] = []; @@ -34,7 +34,7 @@ describe("multiple rows from one buffer", () => { }); it("two fixed-width columns per row", async () => { - const r = new RowBinaryState( + const r = new Cursor( await query( "SELECT toUInt64(number), toInt32(-number) FROM numbers(4) FORMAT RowBinary", ), @@ -54,7 +54,7 @@ describe("multiple rows from one buffer", () => { // repeat('x', number) yields strings of length 0,1,2,3,4 — every row has a // different byte width, so alignment depends entirely on readString // consuming exactly its varint length + bytes. - const r = new RowBinaryState( + const r = new Cursor( await query( "SELECT repeat('x', number) FROM numbers(5) FORMAT RowBinary", ), @@ -66,7 +66,7 @@ describe("multiple rows from one buffer", () => { }); it("mixed fixed + variable columns per row", async () => { - const r = new RowBinaryState( + const r = new Cursor( await query( "SELECT number AS n, repeat('ab', number) AS s FROM numbers(3) FORMAT RowBinary", ), @@ -85,7 +85,7 @@ describe("multiple rows from one buffer", () => { // With no row count on the wire, a reader that doesn't know N up front // loops until the cursor reaches the buffer end. This works precisely // because each row consumes exactly its bytes — the end is a row boundary. - const r = new RowBinaryState( + const r = new Cursor( await query("SELECT toInt32(number) FROM numbers(10) FORMAT RowBinary"), ); const out: number[] = []; @@ -95,7 +95,7 @@ describe("multiple rows from one buffer", () => { }); it("zero rows: an empty result is an empty buffer", async () => { - const r = new RowBinaryState( + const r = new Cursor( await query("SELECT toInt32(1) WHERE 0 FORMAT RowBinary"), ); expect(r.buf.length).toBe(0); @@ -107,7 +107,7 @@ describe("multiple rows from one buffer", () => { describe("readRows() helper: the position-bounded loop as a method", () => { it("reads every row via a per-row callback", async () => { - const r = new RowBinaryState( + const r = new Cursor( await query( "SELECT number AS id, repeat('ab', number) AS name FROM numbers(3) FORMAT RowBinary", ), @@ -125,7 +125,7 @@ describe("multiple rows from one buffer", () => { }); it("returns [] for an empty result", async () => { - const r = new RowBinaryState( + const r = new Cursor( await query("SELECT toInt32(1) WHERE 0 FORMAT RowBinary"), ); expect(readRows(readInt32)(r)).toEqual([]); @@ -142,7 +142,7 @@ describe("multiple rows from one buffer", () => { // Find the byte offset where row 2 (0-indexed) ends, by decoding the full // buffer once and committing per row. - const probe = new RowBinaryState(full); + const probe = new Cursor(full); const ends: number[] = []; readRows((s) => { readUInt64(s); @@ -151,7 +151,7 @@ describe("multiple rows from one buffer", () => { return null; })(probe); // Cut the buffer one byte before the end so the LAST row is truncated. - const r = new RowBinaryState(full.subarray(0, full.length - 1)); + const r = new Cursor(full.subarray(0, full.length - 1)); const rows = readRows((s) => ({ id: readUInt64(s), s: readString(s), @@ -185,7 +185,7 @@ describe("multiple rows from one buffer", () => { let avail = 0; while (committed < full.length) { avail = Math.min(full.length, avail + chunk); - const r = new RowBinaryState(full.subarray(0, avail)); + const r = new Cursor(full.subarray(0, avail)); r.pos = committed; rows.push( ...readRows((s) => ({ id: readUInt64(s), s: readString(s) }))(r), @@ -199,7 +199,7 @@ describe("multiple rows from one buffer", () => { }); it("still propagates a non-NeedMoreData error from the row reader", async () => { - const r = new RowBinaryState( + const r = new Cursor( await query("SELECT toInt32(1) FROM numbers(3) FORMAT RowBinary"), ); const boom = new Error("decode fault"); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/simpleAggregateFunction.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/simpleAggregateFunction.test.ts index 092516bad..817ef55e3 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/simpleAggregateFunction.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/simpleAggregateFunction.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; import { readArray } from "../src/composite.js"; -import { NeedMoreData, RowBinaryState } from "../src/core.js"; +import { NeedMoreData, Cursor } from "../src/core.js"; import { readUInt64, readUInt8 } from "../src/integers.js"; import { readSimpleAggregateFunction } from "../src/simpleAggregateFunction.js"; -async function reader(expr: string): Promise { - return new RowBinaryState(await query(`SELECT ${expr} FORMAT RowBinary`)); +async function reader(expr: string): Promise { + return new Cursor(await query(`SELECT ${expr} FORMAT RowBinary`)); } /** @@ -41,7 +41,7 @@ describe("SimpleAggregateFunction (transparent — decode as the inner type)", ( "SELECT CAST(42 AS SimpleAggregateFunction(sum, UInt64)) FORMAT RowBinary", ); for (let len = 0; len < full.length; len++) { - const r = new RowBinaryState(full.subarray(0, len)); + const r = new Cursor(full.subarray(0, len)); let thrown: unknown; try { readUInt64(r); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/streamRowBatches.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/streamRowBatches.test.ts index c46e87198..0239bfc4e 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/streamRowBatches.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/streamRowBatches.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { readUInt64 } from "../src/integers.js"; import { type SmallChunkStats, streamRowBatches } from "../src/stream.js"; import { readString } from "../src/strings.js"; @@ -20,7 +20,7 @@ async function* chunked(buf: Buffer, size: number): AsyncGenerator { } type Row = { id: bigint; s: string }; -const readRow = (s: RowBinaryState): Row => ({ +const readRow = (s: Cursor): Row => ({ id: readUInt64(s), s: readString(s), }); diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.bench.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.bench.ts index afb346af6..499d23272 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.bench.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.bench.ts @@ -1,6 +1,6 @@ import { bench, describe } from "vitest"; import { query } from "./clickhouse.js"; -import { type Reader, RowBinaryState } from "../src/core.js"; +import { type Reader, Cursor } from "../src/core.js"; import { type TelemetryRow, readTelemetryRow, @@ -24,7 +24,7 @@ const BUF = await query( ); function decodeAll(read: Reader): TelemetryRow[] { - const s = new RowBinaryState(BUF); + const s = new Cursor(BUF); const out: TelemetryRow[] = []; while (s.pos < s.buf.length) out.push(read(s)); return out; diff --git a/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.example.test.ts b/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.example.test.ts index 4e865ecef..9e5ab1fff 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.example.test.ts +++ b/skills/clickhouse-js-node-rowbinary-parser/tests/telemetry.example.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { query } from "./clickhouse.js"; -import { RowBinaryState } from "../src/core.js"; +import { Cursor } from "../src/core.js"; import { type TelemetryRow, readTelemetryRow, @@ -47,7 +47,7 @@ describe("example: telemetry (composite columns via JSONEachRow)", () => { rows.map((r) => JSON.stringify(r)).join("\n"), ); - const r = new RowBinaryState( + const r = new Cursor( await query( `SELECT host, tags, cpu, region, window FROM ${t} ORDER BY host FORMAT RowBinary`, ), From 4d2f01998aebf9f2228f1a99a985d28c884bd71c Mon Sep 17 00:00:00 2001 From: Peter Leonov Date: Mon, 22 Jun 2026 21:31:44 +0200 Subject: [PATCH 4/5] Publish @clickhouse/rowbinary as a standalone package (#870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Makes the standalone `@clickhouse/rowbinary` package (the RowBinary parser skill under `skills/clickhouse-js-node-rowbinary-parser`) ready to publish, and gives it a proper independent release workflow. ## Changes - **`package.json`**: add `publishConfig.access: "public"` so the scoped package publishes publicly on first release, and `engines.node: ">=20"` to match the rest of the repo. - **`.github/workflows/publish-skill-rowbinary-parser.yml`**: bring the existing manual publish workflow up to `publish.yml`'s release rigor — - gate on `npm test` (in addition to typecheck/build), - push a namespaced `rowbinary-v` git tag after publish (won't collide with the main packages' bare `1.x.x` tags), - add an `e2e` job that waits for the published version on npm and smoke-tests a downstream install + import of the main barrel and a subpath export across Node 20/22/24. - **`README.md` / `CHANGELOG.md`**: point the repo's package list at `@clickhouse/rowbinary` and add the 1.23.0 changelog entry. ## Notes - This package is **not** part of the npm workspace lockstep release; it carries its own version and ships on its own manual cadence (`workflow_dispatch`). - Verified locally: clean build, typecheck, 473 unit tests pass, and a packed-tarball install resolves both the main entry and subpath exports. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/examples.yml | 40 +++++-- .../publish-skill-rowbinary-parser.yml | 111 ++++++++++++++++-- .github/workflows/tests-bun.yml | 38 ++++-- .github/workflows/tests-node.yml | 38 ++++-- .github/workflows/tests-oss-dependents.yml | 38 ++++-- .github/workflows/tests-web.yml | 38 ++++-- CHANGELOG.md | 8 +- README.md | 3 +- .../package-lock.json | 4 +- .../package.json | 8 +- 10 files changed, 263 insertions(+), 63 deletions(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index a6ac10344..356a2fc26 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -7,17 +7,37 @@ on: branches: - main - release - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "examples/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/examples.yml" pull_request: - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "examples/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/examples.yml" concurrency: group: "${{ github.workflow }}-${{ github.ref }}" diff --git a/.github/workflows/publish-skill-rowbinary-parser.yml b/.github/workflows/publish-skill-rowbinary-parser.yml index 59ae71e82..f217730f2 100644 --- a/.github/workflows/publish-skill-rowbinary-parser.yml +++ b/.github/workflows/publish-skill-rowbinary-parser.yml @@ -1,11 +1,15 @@ name: "publish: rowbinary parser" -# Independent publish for the standalone @clickhouse/rowbinary +# Independent publish + release for the standalone @clickhouse/rowbinary # package (the RowBinary parser skill). It is NOT part of the npm workspace # lockstep release driven by publish.yml — it carries its own version in # skills/clickhouse-js-node-rowbinary-parser/package.json and ships on its own -# cadence. Triggered manually; publishes the version currently in package.json -# with the "latest" tag using npm OIDC authentication and provenance. +# cadence. Triggered manually; the `publish` job gates on typecheck/test/build, +# publishes the version currently in package.json with the "latest" tag using +# npm OIDC authentication and provenance, then pushes a matching git tag. The +# `e2e` job waits for the freshly published version to appear on the registry +# and verifies it installs and imports in a throwaway downstream project across +# the supported Node versions. permissions: contents: read @@ -18,14 +22,19 @@ concurrency: on: workflow_dispatch: -defaults: - run: - working-directory: skills/clickhouse-js-node-rowbinary-parser - jobs: publish: runs-on: ubuntu-latest + timeout-minutes: 10 environment: npm-publish + permissions: + contents: write # Required to push the release git tag + id-token: write # Required for npm OIDC authentication and provenance + defaults: + run: + working-directory: skills/clickhouse-js-node-rowbinary-parser + outputs: + version: ${{ steps.version.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -42,9 +51,97 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Test + run: npm test + - name: Build run: npm run build + - name: Get the release version + id: version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "Publishing @clickhouse/rowbinary@$VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Publish to npm # prepack copies the repo-root LICENSE and rebuilds dist before packing. run: npm publish --access public --provenance + + - name: Create and push release git tag + env: + RELEASE_TAG: rowbinary-v${{ steps.version.outputs.version }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + echo "Tag ${RELEASE_TAG} already exists on origin; skipping." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${RELEASE_TAG}" -m "Release @clickhouse/rowbinary ${RELEASE_VERSION}" + git push origin "refs/tags/${RELEASE_TAG}" + + e2e: + name: e2e (node ${{ matrix.node }}) + needs: publish + if: needs.publish.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: true + matrix: + node: [20, 22, 24] + env: + PUBLISHED_VERSION: ${{ needs.publish.outputs.version }} + steps: + - name: Setup NodeJS ${{ matrix.node }} + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: ${{ matrix.node }} + registry-url: "https://registry.npmjs.org" + + - name: Wait for @clickhouse/rowbinary@${{ needs.publish.outputs.version }} on npm + run: | + set -euo pipefail + if [ -z "${PUBLISHED_VERSION}" ]; then + echo "PUBLISHED_VERSION is empty; cannot wait for npm publication." >&2 + exit 1 + fi + pkg="@clickhouse/rowbinary" + # Poll the registry for up to ~5 minutes. New versions usually surface + # in seconds, but the registry CDN can lag. + max_attempts=60 + sleep_seconds=5 + attempt=1 + echo "Waiting for ${pkg}@${PUBLISHED_VERSION} to be available on npm..." + while true; do + if npm view "${pkg}@${PUBLISHED_VERSION}" version >/dev/null 2>&1; then + echo " ${pkg}@${PUBLISHED_VERSION} is available." + break + fi + if [ "$attempt" -ge "$max_attempts" ]; then + echo "Timed out waiting for ${pkg}@${PUBLISHED_VERSION} on npm" >&2 + exit 1 + fi + echo " attempt ${attempt}/${max_attempts}: not available yet, sleeping ${sleep_seconds}s..." + attempt=$((attempt + 1)) + sleep "$sleep_seconds" + done + + - name: Install and import the published package + run: | + set -euo pipefail + work="$(mktemp -d)" + cd "$work" + npm init -y >/dev/null 2>&1 + npm install "@clickhouse/rowbinary@${PUBLISHED_VERSION}" + # Verify both the main barrel entry and a subpath export resolve and + # expose their parsers to a downstream consumer. + node --input-type=module -e " + import * as rb from '@clickhouse/rowbinary'; + import * as ints from '@clickhouse/rowbinary/integers'; + if (typeof rb.readRows !== 'function') throw new Error('readRows missing from main export'); + if (typeof ints.readUInt8 !== 'function') throw new Error('readUInt8 missing from subpath export'); + console.log('OK: @clickhouse/rowbinary@${PUBLISHED_VERSION} imports cleanly'); + " diff --git a/.github/workflows/tests-bun.yml b/.github/workflows/tests-bun.yml index ff2c109c0..d6202e709 100644 --- a/.github/workflows/tests-bun.yml +++ b/.github/workflows/tests-bun.yml @@ -6,17 +6,35 @@ on: push: branches: - main - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/tests-bun.yml" pull_request: - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/tests-bun.yml" concurrency: group: "${{ github.workflow }}-${{ github.ref }}" diff --git a/.github/workflows/tests-node.yml b/.github/workflows/tests-node.yml index 3b10523bd..25afd4321 100644 --- a/.github/workflows/tests-node.yml +++ b/.github/workflows/tests-node.yml @@ -6,17 +6,35 @@ on: push: branches: - main - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/tests-node.yml" pull_request: - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/tests-node.yml" schedule: - cron: "0 9 * * *" diff --git a/.github/workflows/tests-oss-dependents.yml b/.github/workflows/tests-oss-dependents.yml index 69f6f3702..8a1e286dd 100644 --- a/.github/workflows/tests-oss-dependents.yml +++ b/.github/workflows/tests-oss-dependents.yml @@ -6,17 +6,35 @@ on: push: branches: - main - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/tests-oss-dependents.yml" pull_request: - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/tests-oss-dependents.yml" schedule: - cron: "0 9 * * *" diff --git a/.github/workflows/tests-web.yml b/.github/workflows/tests-web.yml index c368f09b5..11919b993 100644 --- a/.github/workflows/tests-web.yml +++ b/.github/workflows/tests-web.yml @@ -6,17 +6,35 @@ on: push: branches: - main - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/tests-web.yml" pull_request: - paths-ignore: - - "**/*.md" - - "LICENSE" - - "benchmarks/**" - - "skills/**" + paths: + - "packages/**" + - "tests/**" + - "package.json" + - "package-lock.json" + - "tsconfig.base.json" + - "tsconfig.dev.json" + - "eslint.config.base.mjs" + - "docker-compose.yml" + - "vitest.node.config.ts" + - "vitest.node.setup.ts" + - "vitest.web.config.ts" + - "vitest.web.setup.ts" + - ".github/workflows/tests-web.yml" schedule: - cron: "0 9 * * *" diff --git a/CHANGELOG.md b/CHANGELOG.md index b410d50db..a30fc3a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.23.0 + +## New features + +- (Node.js) Added a RowBinary reader library and agent skill under [`skills/clickhouse-js-node-rowbinary-parser`](./skills/clickhouse-js-node-rowbinary-parser). It ships type-specific, monomorphizable building blocks for decoding `RowBinary` / `RowBinaryWithNames` / `RowBinaryWithNamesAndTypes` streams (full-buffer and chunked), plus a skill that guides an agent to generate bespoke high-performance parsers from a query's column types. The skill is bundled into `@clickhouse/client` (registered in `agents.skills`) and is also published independently as the [`@clickhouse/rowbinary`](https://www.npmjs.com/package/@clickhouse/rowbinary) package. A matching RowBinary writer is planned. ([#864]) + # 1.22.0 ## New features @@ -6,8 +12,6 @@ - (Node.js) Brotli (`{ codec: "br" }`) is now supported for `compression.request` / `compression.response`, alongside gzip and zstd. Unlike zstd, Brotli is available on every supported Node.js version (no minimum-version requirement). The `compression.request` option is a per-codec discriminated union, so each codec exposes its own tuning option: a `level` for gzip/zstd, a `quality` for Brotli (`{ codec: "br", quality }`). When omitted, Brotli defaults to quality 4 for request bodies, since zlib's brotli default of 11 (max) is far too slow for a streaming insert path. Response decompression follows the server's `Content-Encoding`. Supported only by `@clickhouse/client` (Node.js). -- (Node.js) Added a RowBinary reader library and agent skill under [`skills/clickhouse-js-node-rowbinary-parser`](./skills/clickhouse-js-node-rowbinary-parser). It ships type-specific, monomorphizable building blocks for decoding `RowBinary` / `RowBinaryWithNames` / `RowBinaryWithNamesAndTypes` streams (full-buffer and chunked), plus a skill that guides an agent to generate bespoke high-performance parsers from a query's column types. The skill is bundled into `@clickhouse/client` (registered in `agents.skills`) and is also published independently as the [`@clickhouse/rowbinary`](https://www.npmjs.com/package/@clickhouse/rowbinary) package. A matching RowBinary writer is planned. ([#864]) - ## Internal changes (`@clickhouse/client-common`) > These only affect code that imports the low-level connection primitives from the deprecated `@clickhouse/client-common` package directly (e.g. a custom `Connection` implementation). The `createClient` `compression` option is unchanged and fully backwards compatible — if you only use `@clickhouse/client` or `@clickhouse/client-web`, you are not affected. diff --git a/README.md b/README.md index b49f1e2e0..2506505f7 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Official JS client for [ClickHouse](https://clickhouse.com/), written purely in The client has zero external dependencies and is optimized for maximum performance. -The repository consists of three packages: +The repository consists of four packages: - `@clickhouse/client` - a version of the client designed for Node.js platform only. It is built on top of [HTTP](https://nodejs.org/api/http.html) and [Stream](https://nodejs.org/api/stream.html) APIs; supports streaming for both selects and inserts. @@ -41,6 +41,7 @@ The repository consists of three packages: and [Web Streams](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) APIs; supports streaming for selects. Compatible with Chrome/Firefox browsers and Cloudflare workers. - `@clickhouse/client-common` - shared common types and the base framework for building a custom client implementation. +- `@clickhouse/rowbinary` - a library for reading (and soon writing) ClickHouse RowBinary format. ## Installation diff --git a/skills/clickhouse-js-node-rowbinary-parser/package-lock.json b/skills/clickhouse-js-node-rowbinary-parser/package-lock.json index 73caa2a71..334a4d8d8 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/package-lock.json +++ b/skills/clickhouse-js-node-rowbinary-parser/package-lock.json @@ -1,12 +1,12 @@ { "name": "@clickhouse/rowbinary", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@clickhouse/rowbinary", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "devDependencies": { "@types/node": "^26.0.0", diff --git a/skills/clickhouse-js-node-rowbinary-parser/package.json b/skills/clickhouse-js-node-rowbinary-parser/package.json index 494dc7b20..8966ce9f6 100644 --- a/skills/clickhouse-js-node-rowbinary-parser/package.json +++ b/skills/clickhouse-js-node-rowbinary-parser/package.json @@ -1,6 +1,6 @@ { "name": "@clickhouse/rowbinary", - "version": "0.1.0", + "version": "0.1.1", "description": "RowBinary building blocks for Node.js — read/decode ClickHouse RowBinary / RowBinaryWithNames(AndTypes) streams (a matching writer is planned). Ships with the clickhouse-js-node-rowbinary-parser agent skill.", "homepage": "https://github.com/ClickHouse/clickhouse-js/tree/main/skills/clickhouse-js-node-rowbinary-parser", "license": "Apache-2.0", @@ -19,6 +19,12 @@ }, "type": "module", "sideEffects": false, + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + }, "main": "./dist/reader.js", "module": "./dist/reader.js", "types": "./dist/reader.d.ts", From 35c352907c913cf7158afeac9014fd1f7746d8bb Mon Sep 17 00:00:00 2001 From: Peter Leonov Date: Mon, 22 Jun 2026 21:48:57 +0200 Subject: [PATCH 5/5] ci: support the release branch for tests and rowbinary publish (#871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #870. ## Why Dispatching the rowbinary publish workflow from `main` is rejected by GitHub: > Branch "main" is not allowed to deploy to npm-publish due to environment protection rules. The `npm-publish` environment is branch-protected to the `release` branch — the repo's human-in-the-loop release gate (same model as `publish.yml`, whose jobs are guarded by `if: github.ref == 'refs/heads/release'`). The standalone rowbinary publish workflow lacked that guard, and the main test suites weren't validating the `release` branch on push. ## Changes - **`publish-skill-rowbinary-parser.yml`**: guard the `publish` job with `if: github.ref == 'refs/heads/release'` so it skips cleanly on other refs instead of failing the environment protection check, and document that it must be dispatched from `release`. - **`tests-node` / `tests-web` / `tests-bun` / `tests-oss-dependents`**: add `release` to the push-trigger branches so the release branch is validated before/as it publishes (`examples.yml` already had it). ## Release flow (unchanged, now consistent) 1. Land changes on `main`. 2. Promote to `release` → test suites run on the release branch. 3. Dispatch `publish: rowbinary parser` from `release` → publishes with OIDC/provenance and tags `rowbinary-v`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- .../publish-skill-rowbinary-parser.yml | 18 ++++++++++++------ .github/workflows/tests-bun.yml | 1 + .github/workflows/tests-node.yml | 1 + .github/workflows/tests-oss-dependents.yml | 1 + .github/workflows/tests-web.yml | 1 + 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-skill-rowbinary-parser.yml b/.github/workflows/publish-skill-rowbinary-parser.yml index f217730f2..b5c2910ca 100644 --- a/.github/workflows/publish-skill-rowbinary-parser.yml +++ b/.github/workflows/publish-skill-rowbinary-parser.yml @@ -4,12 +4,15 @@ name: "publish: rowbinary parser" # package (the RowBinary parser skill). It is NOT part of the npm workspace # lockstep release driven by publish.yml — it carries its own version in # skills/clickhouse-js-node-rowbinary-parser/package.json and ships on its own -# cadence. Triggered manually; the `publish` job gates on typecheck/test/build, -# publishes the version currently in package.json with the "latest" tag using -# npm OIDC authentication and provenance, then pushes a matching git tag. The -# `e2e` job waits for the freshly published version to appear on the registry -# and verifies it installs and imports in a throwaway downstream project across -# the supported Node versions. +# cadence. Triggered manually, and — like publish.yml — must be dispatched from +# the `release` branch: the npm-publish environment is protected so only that +# branch may deploy (the repo's human-in-the-loop release gate). Dispatches from +# any other ref are skipped by the job-level `if` guard below. The `publish` job +# gates on typecheck/test/build, publishes the version currently in package.json +# with the "latest" tag using npm OIDC authentication and provenance, then pushes +# a matching git tag. The `e2e` job waits for the freshly published version to +# appear on the registry and verifies it installs and imports in a throwaway +# downstream project across the supported Node versions. permissions: contents: read @@ -24,6 +27,9 @@ on: jobs: publish: + # The npm-publish environment only permits the release branch to deploy; + # skip cleanly on any other ref instead of failing the protection check. + if: github.ref == 'refs/heads/release' runs-on: ubuntu-latest timeout-minutes: 10 environment: npm-publish diff --git a/.github/workflows/tests-bun.yml b/.github/workflows/tests-bun.yml index d6202e709..897bdffbf 100644 --- a/.github/workflows/tests-bun.yml +++ b/.github/workflows/tests-bun.yml @@ -6,6 +6,7 @@ on: push: branches: - main + - release paths: - "packages/**" - "tests/**" diff --git a/.github/workflows/tests-node.yml b/.github/workflows/tests-node.yml index 25afd4321..cfb1d2911 100644 --- a/.github/workflows/tests-node.yml +++ b/.github/workflows/tests-node.yml @@ -6,6 +6,7 @@ on: push: branches: - main + - release paths: - "packages/**" - "tests/**" diff --git a/.github/workflows/tests-oss-dependents.yml b/.github/workflows/tests-oss-dependents.yml index 8a1e286dd..14368c0c0 100644 --- a/.github/workflows/tests-oss-dependents.yml +++ b/.github/workflows/tests-oss-dependents.yml @@ -6,6 +6,7 @@ on: push: branches: - main + - release paths: - "packages/**" - "tests/**" diff --git a/.github/workflows/tests-web.yml b/.github/workflows/tests-web.yml index 11919b993..129d93de9 100644 --- a/.github/workflows/tests-web.yml +++ b/.github/workflows/tests-web.yml @@ -6,6 +6,7 @@ on: push: branches: - main + - release paths: - "packages/**" - "tests/**"