diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7ce5b3c..adf83c5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,13 +84,14 @@ jobs: - 'src/**' - 'schemas/**' - 'tests/e2e/**' - # #630 Phase 1: tests/e2e/clickhouse-http-transport.spec.js now - # imports this ONE shared spike fixture server directly — a + # #630 Phase 1 named the shared fixture server + # (tests/spike/clickhouse-client/fault-server.mjs) here + # explicitly, since the root e2e suite imports it directly — a # real cross-tree dependency the PR path filter didn't know - # about before. Deliberately narrow (not `tests/spike/**`): - # the rest of that historical spike suite is not a dependency - # of the root e2e suite and stays out of ordinary PR CI. - - 'tests/spike/clickhouse-client/fault-server.mjs' + # about otherwise. #630 Phase 8 moved that fixture to + # packages/clickhouse-http/test/browser/fault-server.mjs, + # already covered by the blanket 'packages/**' entry below, so + # no dedicated entry is needed here anymore. - 'playwright.config.js' - 'build/**' - 'package.json' @@ -150,6 +151,8 @@ jobs: - run: npm ci --no-audit --no-fund - name: Test (vitest + coverage gate) run: npm test + - name: '@altinity/clickhouse-http isolated-package proof (npm pack, install outside the workspace, ESM + TS resolution)' + run: npm run test:clickhouse-http:pack - name: Build single-file SPA run: npm run build - uses: actions/upload-artifact@v7 @@ -253,7 +256,10 @@ jobs: - name: Lint the installer (shellcheck) run: | sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - shellcheck install.sh build/bundle.sh + # #630 Phase 8 modifies both build wrappers (each now composes the + # package build explicitly) — lint both alongside the existing root + # installer shell script. + shellcheck install.sh build/bundle.sh deploy/install.sh # Exercise the published runtime shape, including Caddy's static # Content-Encoding negotiation. Node decodes each selected sidecar, so the @@ -363,7 +369,8 @@ jobs: (github.event_name == 'pull_request' && needs.changes.outputs.e2e == 'true') runs-on: ubuntu-latest env: - # One engine on PRs, all three everywhere else. + # Root SQL Browser e2e: one engine on PRs, all three everywhere else + # (unchanged cost policy — #630 Phase 8 does not widen this). PR_ONLY_CHROMIUM: ${{ github.event_name == 'pull_request' }} steps: - uses: actions/checkout@v7 @@ -372,10 +379,15 @@ jobs: node-version: '22' cache: npm - run: npm ci --no-audit --no-fund + # #630 Phase 8: the @altinity/clickhouse-http package's own Chromium/ + # WebKit regression suite runs on every applicable CI event alongside + # the root suite (issue #630's own required acceptance engines for the + # package), so WebKit is installed on PRs too now, not just Chromium — + # widened from Chromium-only specifically for this package suite. - name: Install Playwright browsers run: | if [ "$PR_ONLY_CHROMIUM" = "true" ]; then - npx playwright install --with-deps chromium + npx playwright install --with-deps chromium webkit else npx playwright install --with-deps chromium firefox webkit fi @@ -386,6 +398,8 @@ jobs: else npm run test:e2e fi + - name: '@altinity/clickhouse-http Chromium+WebKit regression suite' + run: npm run test:clickhouse-http:browser -- --project=chromium --project=webkit - uses: actions/upload-artifact@v7 if: ${{ failure() }} with: diff --git a/.wiki/Architecture.md b/.wiki/Architecture.md index 009c5f38..dab695b5 100644 --- a/.wiki/Architecture.md +++ b/.wiki/Architecture.md @@ -80,7 +80,20 @@ module mocking. `build/build.mjs` bundles `src/main.js` with esbuild, minifies it, and inlines JS and `src/styles.css` into `build/template.html`. Output is `dist/sql.html`, with -no third-party runtime requests. +no third-party runtime requests. `packages/clickhouse-http` (#630 Phase 2's +first npm workspace) has its own independent build/type/test boundary since +Phase 8 — package-local esbuild/tsc produce `dist/**` (unbundled ESM + +declarations), and its manifest resolves there, never to source; root +`npm run build`/`build/bundle.sh`/`deploy/install.sh` all explicitly build +the package first so root esbuild's bare `@altinity/clickhouse-http` import +resolves to that built output through the workspace `node_modules` symlink. +Phase 8 also closes issue #630: the migration-only `ch-client.js` +forwarding aliases are gone, `@clickhouse/client-web` and its executable +vendor-spike wiring (`tests/spike/clickhouse-client/**`) are removed, and +the package's own Chromium+WebKit regression suite +(`packages/clickhouse-http/test/browser/**`) proves the built artifact +directly. See [`docs/clickhouse-http-repository-extraction.md`](../docs/clickhouse-http-repository-extraction.md) +for the #639 handoff. Canonical source: [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) and [`CLAUDE.md`](../CLAUDE.md). diff --git a/.wiki/Decisions-and-Roadmap.md b/.wiki/Decisions-and-Roadmap.md index 1e52b559..7db1352c 100644 --- a/.wiki/Decisions-and-Roadmap.md +++ b/.wiki/Decisions-and-Roadmap.md @@ -369,6 +369,71 @@ Two roadmap tracks are current: (`build/lib/check-legacy-owners.mjs`) rather than a specifier-text regex, since a regex cannot tell which names a named import binds. + **Phase 8** (final phase, merged) claims **A17**/**A18**, completing issue + #630. `packages/clickhouse-http` becomes independently buildable/packable: + package-local `esbuild` (unbundled browser-first ESM, `bundle: false`) and + `tsc` (declaration-only emit) produce `dist/**`; the manifest's `main`/ + `types`/`exports["."]` all point at that built output, never source; + `npm run build`/`build/bundle.sh`/`deploy/install.sh` all explicitly build + the package first (`npm run build:clickhouse-http`) before the root + application builder, so root esbuild's metafile resolves the workspace + symlink to `packages/clickhouse-http/dist/*.js` — attributed to the + `project` ownership bucket, never `external` — with no `packages/ + clickhouse-http/src/*.ts` input anywhere. Root `tsconfig.json`/Vitest + coverage drop package source entirely; package-local `tsconfig.json`/ + `vitest.config.ts` (100/95/90/100 per file) own it instead, exercising the + built public barrel via a relative import to `src/index.ts` (coverage + attribution reasons — the isolated-package proof below is the real + built-artifact proof). A new `test/isolated-package.mjs` (`npm run + test:pack`) builds the package, runs a REAL `npm pack`, installs the + tarball into a fixture OUTSIDE the repository, imports it as ESM, and + compiles a TypeScript consumer against its declarations — proving neither + runtime nor type resolution ever falls back into this repository's source. + A new package-owned Chromium/WebKit regression suite + (`packages/clickhouse-http/test/browser/**`) serves the package's own + generated `dist/**` directly (`harness.html` imports `/dist/index.js`, no + import map); its former root-suite home + (`tests/e2e/clickhouse-http-transport.{html,spec.js}`) splits into that + package suite plus a narrower root `tests/e2e/authenticated-clickhouse- + request.{html,spec.js}` for SQL Browser's own authentication-policy + variants (the package-owned `fault-server.mjs` fixture, moved from the + spike, stays importable from both). The migration-only `ch-client.ts` + forwarding aliases (`chUrl`/`parseExceptionText`/`findExceptionFrame`) are + removed now that every spike consumer is gone; `export-service.ts` imports + `findExceptionFrame` directly from the package under one narrow, named + Rule-D exception (`PHASE8_NARROW_RULE_D_EXCEPTIONS`) rather than through + that retired gateway. Five new/extended architecture guards + (`build/check-boundaries.mjs`/`build/lib/check-legacy-owners.mjs`, all + real-parser-backed, never a hand-rolled regex scanner): package + containment broadens to the package's own `test/**`/`build.mjs`/ + `vitest.config.ts` (Guard 1); the relative-deep-import ban on the package + widens from `src/**` to the whole package directory, catching a + `dist/**` escape a source-only ban would have missed (Guard 2); root-wide + (not just former-owner-scoped) declaration/re-export ownership for the + historical `chUrl`/`createHttpTransport`/`ClickHouseTransport`/ + `TransportDeps`/`TransportRequest` transport surface, with an exemption for + the sanctioned package import itself (Guard 3); the same root-wide + ownership rule for the moved progress-stream/exception-parsing primitives + (Guard 4); and the `@clickhouse/client-web` ban's former "future official + transport file" allowlist is deleted outright, its scan widened to + `src/**`/`packages/clickhouse-http/**` (excluding generated `dist/**`)/ + `tests/**`/`build/**`, plus structural manifest/lock/script/directory + checks (Guard 5). The whole `@clickhouse/client-web` devDependency, its + npm scripts, and the executable `tests/spike/clickhouse-client/**` + directory (33 files by the plan's own count) are deleted per an exact + file-by-file disposition table — most outright, `fault-server.mjs` moved + (above), a handful ported into first-party regressions then deleted; the + candidate-build-only `additionalNotices`/`--notices` plumbing in + `build/build.mjs`/`build/size-report.mjs` goes with them. + `docs/evidence/585/**` and ADR-0005's Rejected decision/historical content + are untouched — only a narrow current-state addendum documents the + executable retirement (`tests/unit/client-web-retirement-policy.test.js` + replaces the former `client-web-spike-policy.test.js`, whose assertions + described the now-retired opposite state). #639 begins with external + repository creation/release and the SQL Browser consumer cutover, per the + tested mechanical extraction handoff this phase adds, + [`docs/clickhouse-http-repository-extraction.md`](../docs/clickhouse-http-repository-extraction.md). + Re-read GitHub before acting because issue state can change; a MERGED PR is not proof its code is on `main` (see the reset above). diff --git a/.wiki/Source-Map.md b/.wiki/Source-Map.md index f9e94a2a..86343db5 100644 --- a/.wiki/Source-Map.md +++ b/.wiki/Source-Map.md @@ -16,9 +16,9 @@ Back to [[Home]]. Related: [[Architecture]], [[Product-and-Features]]. | `src/dashboard/application/dashboard-repaint-plan.js` | pure repaint-decision arbitration extracted from `ui/dashboard.js`'s `renderDashboard` effect (#589) | | `src/ui/dashboard-tile-gestures.js` | Dashboard corner-drag resize, Command/Ctrl-drag reorder, and modifier-cue controller, extracted from `ui/dashboard.js` behind an injected `TileGestureDeps` seam (#589) | | `src/state.js` | signals-backed state model and persistence operations | -| `src/net/ch-client.js` | ClickHouse HTTP execution and schema calls; product operations, `ChCtx` (#585 Phase 1: generic request/stream mechanics delegate through the transport seam, since deleted; #630 Phase 2: `chUrl` re-exported from `@altinity/clickhouse-http`; #630 Phase 3: `parseExceptionText`/`findExceptionFrame`/`StreamLine`/`StreamCallbacks` re-exported; #630 Phase 4: unaffected; #630 Phase 5: `sqlString` also imported directly from the package, replacing the retired `../core/format.js` import; #630 Phase 6: auth/epoch/retry/lifecycle policy (`authedFetch`/`transportFor(ctx)`) MOVED to `authenticated-clickhouse-request.js` below — `ChCtx` `extends AuthenticatedRequestCtx` and adds only `dataLakeCatalogSettingUnsupported`; `queryJson()` delegates to `authenticatedJson()` with a `ClickHouseError`→`Error` compatibility translation; #630 Phase 7: the generic `runQuery`/`RunQueryOptions`/`RunQueryResult`, `exportQuery`/`ExportQueryOptions`, and the ordinary mutable-context `killQuery` are DELETED outright — their SQL Browser policy moved to `src/application/query-execution-service.js`/`export-service.js`; `killQueryWithLease`'s frozen-lease bypass is rewritten onto the package's own stateless `client.killQuery(...)` (dropping its `sqlString` argument — the package now owns that quoting) instead of the retired local transport adapter) | +| `src/net/ch-client.js` | ClickHouse HTTP execution and schema calls; product operations, `ChCtx` (#585 Phase 1: generic request/stream mechanics delegate through the transport seam, since deleted; #630 Phase 2: `chUrl` re-exported from `@altinity/clickhouse-http`; #630 Phase 3: `parseExceptionText`/`findExceptionFrame`/`StreamLine`/`StreamCallbacks` re-exported; #630 Phase 4: unaffected; #630 Phase 5: `sqlString` also imported directly from the package, replacing the retired `../core/format.js` import; #630 Phase 6: auth/epoch/retry/lifecycle policy (`authedFetch`/`transportFor(ctx)`) MOVED to `authenticated-clickhouse-request.js` below — `ChCtx` `extends AuthenticatedRequestCtx` and adds only `dataLakeCatalogSettingUnsupported`; `queryJson()` delegates to `authenticatedJson()` with a `ClickHouseError`→`Error` compatibility translation; #630 Phase 7: the generic `runQuery`/`RunQueryOptions`/`RunQueryResult`, `exportQuery`/`ExportQueryOptions`, and the ordinary mutable-context `killQuery` are DELETED outright — their SQL Browser policy moved to `src/application/query-execution-service.js`/`export-service.js`; `killQueryWithLease`'s frozen-lease bypass is rewritten onto the package's own stateless `client.killQuery(...)` (dropping its `sqlString` argument — the package now owns that quoting) instead of the retired local transport adapter; **#630 Phase 8**: the migration-only re-export gateway is retired — `chUrl`/`parseExceptionText`/`findExceptionFrame` are no longer imported or re-exported here at all (this module's own production code never called any of the three; `export-service.js` now imports `findExceptionFrame` directly from the package under one narrow, named Rule-D exception instead of through this file) — only `sqlString`/`ClickHouseError`/`createClickHouseHttpClient` remain, since this module's own code actually uses them) | | `src/net/authenticated-clickhouse-request.js` | **New in #630 Phase 6.** The sole normal-request auth/epoch/refresh/lifecycle owner: `authenticatedRequest()` (the moved `authedFetch` trust-boundary loop, building the package's `createClickHouseHttpClient(...).request()` directly) plus `authenticatedJson()`/`authenticatedText()`/`authenticatedProgress()`, each composing it with exactly one matching package response consumer (`consumeJsonResponse`/`consumeTextResponse`/`consumeProgressResponse`). Declares the narrow `AuthenticatedRequestCtx` seam `ch-client.js`'s `ChCtx` now extends. Named in `build/check-boundaries.mjs`'s #585 transport-leaf forbidden lists and the #512 `connectionAuthorityFiles` lifecycle-authority list. **#630 Phase 7** adds a fourth wrapper, `authenticatedResponse()` (`authenticatedRequest()` + the package's `ensureClickHouseSuccess()` — the exact successful `Response` by identity, a thrown `ClickHouseError` on non-2xx, no retry): this is now the first real `src/**` consumer of every one of the package's response consumers, wired by `src/ui/app.js` into `query-execution-service.js`'s `runProgress`/`runText` and `export-service.js`'s `exportResponse`/`runEffectText` | -| `packages/clickhouse-http/src/` | First-party npm workspace package (repo's first) — `url.ts` (`chUrl`, the ONE URL-serializer implementation), `client.ts` (`createClickHouseHttpClient`, the low-level request/Fetch invocation, plus #630 Phase 4's `queryJson`/`queryText`/`queryProgress` convenience methods and stateless `killQuery` — since #630 Phase 5, `killQuery` quotes through this package's own `sql-quote.ts` `sqlString`, and the Phase-4 private `quoteKillQueryId` stopgap is gone; since #630 Phase 7 this is also the ONLY generic ClickHouse HTTP transport implementation left in the repository, since `killQueryWithLease` now calls `client.killQuery(...)` directly), `progress-stream.ts` (`streamLines`, the ONE progress-bearing JSON-lines read loop, plus the canonical `StreamLine`/`StreamCallbacks`/`ProgressMetaColumn` wire types), `exceptions.ts` (`parseExceptionText`, `findExceptionFrame`/`ExceptionFrame` — byte-oriented, no caller-side latin1 conversion — plus #630 Phase 4's minimal `ClickHouseError`), `response.ts` (#630 Phase 4, new — `ensureClickHouseSuccess`, `consumeJsonResponse`/`consumeTextResponse`/`consumeProgressResponse`), and — new in #630 Phase 5 — `sql-quote.ts` (`sqlString`/`quoteIdent`/`qualifyIdent`, the ONE ClickHouse SQL-quoting implementation, moved verbatim from `src/core/format.ts`), `clickhouse-type.ts` (`parseClickHouseType`/`analyzeTypeModifiers`/`canonicalType`/the wrapper+enum helpers, the ONE generic type-expression grammar, moved verbatim from `src/core/clickhouse-type.ts` minus SQL Browser's `isSupportedOptionScalar` policy, which stayed at `src/core/param-type.ts`), `sql-spans.ts` (`scanSpans`/`Span`/`SpanKind`, the ONE shared lexical scanner, re-exported because surviving SQL Browser SQL-analysis modules still need it, moved verbatim from `src/core/sql-spans.ts`), and package-private `quoted-span.ts` (`scanDelimited`, moved verbatim from `src/core/quoted-span.ts`, not re-exported) — public export only, zero runtime dependencies, zero bare-specifier imports, no SQL Browser `src/**` dependency (#630 Phase 2; progress-stream/exceptions since Phase 3; response/query/kill APIs since Phase 4 — additive, not consumed by any `src/**` caller until Phase 6; SQL quoting/type grammar/scanner since Phase 5 — real production consumers retargeted). Since #630 Phase 6, `src/net/authenticated-clickhouse-request.js` is the first real `src/**` consumer of `request()` plus the non-consuming classifier/JSON/text/progress consumers; since #630 Phase 7 it also consumes `ensureClickHouseSuccess()` through the new `authenticatedResponse()` wrapper — the convenience `queryJson`/`queryText`/`queryProgress` client methods THEMSELVES still have no `src/**` consumer (Phase 8's concern, not reopened by Phase 7). `src/net/clickhouse-transport.types.js`/`clickhouse-http-transport.js` (the local compatibility transport seam #585 Phase 1 introduced) are deleted outright in #630 Phase 7 — no rows of their own remain here, matching how Phase 5's deleted `src/core/clickhouse-type.ts`/`sql-spans.ts`/`quoted-span.ts` were folded into this row rather than kept as separate entries. Bare package access is now two categories: transport/protocol APIs stay `src/net/**`-only; the pure-language exports above (quoting, type grammar, scanner) may be imported by their real SQL Browser consumers anywhere outside `src/net/**` too (mechanically allowlisted, `build/check-boundaries.mjs` Rule D) | +| `packages/clickhouse-http/src/` | First-party npm workspace package (repo's first) — `url.ts` (`chUrl`, the ONE URL-serializer implementation), `client.ts` (`createClickHouseHttpClient`, the low-level request/Fetch invocation, plus #630 Phase 4's `queryJson`/`queryText`/`queryProgress` convenience methods and stateless `killQuery` — since #630 Phase 5, `killQuery` quotes through this package's own `sql-quote.ts` `sqlString`, and the Phase-4 private `quoteKillQueryId` stopgap is gone; since #630 Phase 7 this is also the ONLY generic ClickHouse HTTP transport implementation left in the repository, since `killQueryWithLease` now calls `client.killQuery(...)` directly), `progress-stream.ts` (`streamLines`, the ONE progress-bearing JSON-lines read loop, plus the canonical `StreamLine`/`StreamCallbacks`/`ProgressMetaColumn` wire types), `exceptions.ts` (`parseExceptionText`, `findExceptionFrame`/`ExceptionFrame` — byte-oriented, no caller-side latin1 conversion — plus #630 Phase 4's minimal `ClickHouseError`), `response.ts` (#630 Phase 4, new — `ensureClickHouseSuccess`, `consumeJsonResponse`/`consumeTextResponse`/`consumeProgressResponse`), and — new in #630 Phase 5 — `sql-quote.ts` (`sqlString`/`quoteIdent`/`qualifyIdent`, the ONE ClickHouse SQL-quoting implementation, moved verbatim from `src/core/format.ts`), `clickhouse-type.ts` (`parseClickHouseType`/`analyzeTypeModifiers`/`canonicalType`/the wrapper+enum helpers, the ONE generic type-expression grammar, moved verbatim from `src/core/clickhouse-type.ts` minus SQL Browser's `isSupportedOptionScalar` policy, which stayed at `src/core/param-type.ts`), `sql-spans.ts` (`scanSpans`/`Span`/`SpanKind`, the ONE shared lexical scanner, re-exported because surviving SQL Browser SQL-analysis modules still need it, moved verbatim from `src/core/sql-spans.ts`), and package-private `quoted-span.ts` (`scanDelimited`, moved verbatim from `src/core/quoted-span.ts`, not re-exported) — public export only, zero runtime dependencies, zero bare-specifier imports, no SQL Browser `src/**` dependency (#630 Phase 2; progress-stream/exceptions since Phase 3; response/query/kill APIs since Phase 4 — additive, not consumed by any `src/**` caller until Phase 6; SQL quoting/type grammar/scanner since Phase 5 — real production consumers retargeted). Since #630 Phase 6, `src/net/authenticated-clickhouse-request.js` is the first real `src/**` consumer of `request()` plus the non-consuming classifier/JSON/text/progress consumers; since #630 Phase 7 it also consumes `ensureClickHouseSuccess()` through the new `authenticatedResponse()` wrapper — the convenience `queryJson`/`queryText`/`queryProgress` client methods THEMSELVES still have no `src/**` consumer as of #630's own closure (Phase 8 does not add one — it is a genuinely open item, not a Phase 8 deliverable). `src/net/clickhouse-transport.types.js`/`clickhouse-http-transport.js` (the local compatibility transport seam #585 Phase 1 introduced) are deleted outright in #630 Phase 7 — no rows of their own remain here, matching how Phase 5's deleted `src/core/clickhouse-type.ts`/`sql-spans.ts`/`quoted-span.ts` were folded into this row rather than kept as separate entries. Bare package access is now two categories: transport/protocol APIs stay `src/net/**`-only; the pure-language exports above (quoting, type grammar, scanner) may be imported by their real SQL Browser consumers anywhere outside `src/net/**` too (mechanically allowlisted, `build/check-boundaries.mjs` Rule D). **#630 Phase 8** (final phase, closes #630): the package gets its own independent build/type/test boundary — package-local `build.mjs` (esbuild, `bundle: false`, unbundled ESM) + `tsc` (declaration-only) produce `dist/**`, and the manifest's `main`/`types`/`exports["."]` all target that built output, never source; root `esbuild` resolves the bare specifier through the workspace `node_modules` symlink to `dist/**` (root `tsconfig.json`/Vitest coverage drop package source entirely — package-local `tsconfig.json`/`vitest.config.ts` own it, at the same 100/95/90/100-per-file floor, exercising the built barrel via a relative import to `src/index.ts` for coverage-attribution reasons). `test/isolated-package.mjs` (`npm run test:pack`) is a real `npm pack` + isolated-install + ESM/TS-resolution proof outside this repository; `test/browser/**` is a new first-party Chromium+WebKit regression suite over the built `dist/**`. The migration-only `ch-client.js` forwarding aliases (`chUrl`/`parseExceptionText`/`findExceptionFrame`) are removed; `export-service.js` imports `findExceptionFrame` directly under one narrow, named Rule-D exception. `@clickhouse/client-web` and its executable `tests/spike/clickhouse-client/**` vendor-comparison wiring are deleted outright (ADR-0005 stays Rejected, untouched); `fault-server.mjs` moves to `packages/clickhouse-http/test/browser/` as first-party fixture infrastructure. See [`docs/clickhouse-http-repository-extraction.md`](../docs/clickhouse-http-repository-extraction.md) for the #639 handoff | | `src/net/oauth.js` | OAuth flow/token exchange | | `src/editor/editor-port.js` | SQL editor contract and safe no-op port | | `src/editor/codemirror-adapter.js` | SQL CodeMirror 6 adapter | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd07a66..a0ae7ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,64 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **#630 Phase 8 (final phase — closes #630): make `@altinity/clickhouse-http` + independently buildable/packable/typecheckable in isolation, and retire the + `@clickhouse/client-web` vendor-comparison spike.** Claims A17/A18. + `packages/clickhouse-http` gets its own build/type/test boundary: + package-local `esbuild` (`bundle: false`, browser-first ESM) + `tsc` + (declaration-only) produce `dist/**`; the manifest's `main`/`types`/ + `exports["."]` all target that built output, never source. Root + `npm run build`/`build/bundle.sh`/`deploy/install.sh` (the latter two call + `node build/build.mjs` directly, bypassing root npm scripts) all explicitly + build the package first (`npm run build:clickhouse-http`), verified from a + clean package `dist/` state — root `esbuild` then resolves the bare + `@altinity/clickhouse-http` specifier through the workspace `node_modules` + symlink to that built output, never package source (root `tsconfig.json`/ + Vitest coverage drop package source from their own trees entirely; + package-local `tsconfig.json`/`vitest.config.ts` own it at the same + 100/95/90/100-per-file floor). A new `test/isolated-package.mjs` + (`npm run test:pack`) runs a real `npm pack`, installs the tarball into a + fixture OUTSIDE this repository, imports it as ESM, and compiles a + TypeScript consumer against its declarations with `--traceResolution` — + proving neither runtime nor type resolution ever falls back into this + repository. A new `packages/clickhouse-http/test/browser/**` Chromium+WebKit + regression suite serves the package's own generated `dist/**` directly (no + import map, no vendor client, no Docker/live ClickHouse); its former root + e2e home (`tests/e2e/clickhouse-http-transport.{html,spec.js}`) splits into + that package suite plus a narrower root + `tests/e2e/authenticated-clickhouse-request.{html,spec.js}` for SQL + Browser's own authentication-policy variants, and `fault-server.mjs` moves + from the retired spike to the package's own `test/browser/` as first-party + fixture infrastructure. The migration-only `ch-client.ts` forwarding + aliases (`chUrl`/`parseExceptionText`/`findExceptionFrame`) are removed; + `export-service.ts` imports `findExceptionFrame` directly from the package + under one narrow, named Rule-D exception. Five architecture guards, all + real-parser-backed (`build/lib/check-legacy-owners.mjs`'s new + `findModuleSpecifiers`/`findTransportSurfaceOwnershipViolations`, never a + hand-rolled regex scanner): package containment broadens to the package's + own `test/**`/`build.mjs`/`vitest.config.ts`; the package relative-deep- + import ban widens from `src/**` to the whole package directory (closing a + `dist/**` escape); root-wide declaration/re-export ownership for the + historical `chUrl`/`createHttpTransport`/`ClickHouseTransport`/ + `TransportDeps`/`TransportRequest` transport surface and the moved + progress-stream/exception-parsing primitives; and the + `@clickhouse/client-web` ban's former allowlist is deleted, its scan + widened across `src/**`/`packages/clickhouse-http/**`/`tests/**`/`build/**` + plus structural manifest/lock/script/directory checks. The + `@clickhouse/client-web` devDependency, its four npm scripts, and the whole + executable `tests/spike/clickhouse-client/**` directory (33 files, per an + exact file-by-file disposition table) are removed, along with the + candidate-build-only `additionalNotices`/`--notices` plumbing in + `build/build.mjs`/`build/size-report.mjs`; `package-lock.json` is + regenerated. `docs/evidence/585/**` and ADR-0005's Rejected + decision/historical content are untouched — only a narrow current-state + addendum documents the executable retirement. + `tests/unit/client-web-spike-policy.test.js` (which enforced the opposite, + spike-executable state) is rewritten as + `tests/unit/client-web-retirement-policy.test.js`. Adds + `docs/clickhouse-http-repository-extraction.md`, the tested mechanical + extraction handoff for issue #639 (external repository creation/release and + the SQL Browser consumer cutover), which starts only after this phase. - **#630 Phase 7: migrate query execution and export off generic `runQuery`/`exportQuery`/mutable-context `killQuery`, then delete those APIs and the local transport seam.** `query-execution-service.ts` no diff --git a/CLAUDE.md b/CLAUDE.md index bd9b3d7f..2444e550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,9 +126,21 @@ all bundled — see hard rule 4). Quality is held by tests. artifact, so the page loads no runtime libraries from third-party CDNs. `packages/clickhouse-http` (#630 Phase 2, the repository's first npm workspace) is **project source, not an eighth bundled runtime - dependency**: it is private, ships no `dependencies`, and esbuild bundles - it exactly like hand-written `src/**` — `build/size-report-lib.mjs` - attributes it to the `project` ownership bucket, not `external`. Adding + dependency**: it is private, ships no `dependencies`, and since #630 + Phase 8 it has its own independent build/type/test boundary — package- + local `esbuild` compiles its `src/**/*.ts` to unbundled, browser-first + ESM (`bundle: false`) at `dist/**`, and package-local `tsc` emits + matching `.d.ts` declarations; its manifest's `main`/`types`/ + `exports["."]` all resolve to that built output, never source. Root + `esbuild` bundles that BUILT `dist/**` into the single served artifact + (via the workspace `node_modules` symlink, exactly like any resolved + dependency) — `build/size-report-lib.mjs` still attributes it to the + `project` ownership bucket, not `external`, since it is project code + either way. Root `npm run build`/`build/bundle.sh`/`deploy/install.sh` + all explicitly build the package first (`npm run build:clickhouse-http`) + so its `dist/**` exists before root `esbuild` ever runs — this + environment's `ignore-scripts=true` means lifecycle hooks never do this + implicitly. Adding *another* runtime dependency is a deliberate decision (it grows the single served file) — don't do it casually. When a feature needs a library, keep the testable logic pure in `src/core/` (chart axis/role/pivot math in @@ -184,7 +196,8 @@ Touch these in one change: |---|---| | `src/core/*` | pure logic, 100% covered | | `src/net/*` | OAuth + ClickHouse client, injected fetch; `authenticated-clickhouse-request.ts` (#630 Phase 6) is the sole normal-request auth/epoch/refresh/lifecycle owner, over the package's `request()` and response consumers | -| `packages/clickhouse-http/src/*` | first-party npm workspace (repo's first, #630 Phase 2) — `chUrl`/URL serialization, the low-level injected-`fetch()` request, the progress-stream read loop and HTTP exception parsing/framing (Phase 3), (Phase 4) non-consuming success/error classification (`ensureClickHouseSuccess`), JSON/text/progress consumers, a minimal `ClickHouseError`, convenience `queryJson`/`queryText`/`queryProgress` client methods, and a stateless wire-level `killQuery`, and (Phase 5) the ONE ClickHouse SQL-quoting implementation (`sqlString`/`quoteIdent`/`qualifyIdent`), the ONE generic type-expression grammar (`parseClickHouseType`/`analyzeTypeModifiers`/`canonicalType`/wrapper/enum helpers), and the shared lexical scanner (`scanSpans`) — behind a public `.` export only; transport/protocol APIs stay `src/net/**`-only, while the pure-language exports above may be imported directly by their real SQL Browser consumers anywhere outside `src/net/**` too (mechanically allowlisted, `build/check-boundaries.mjs` Rule D); since Phase 6, `src/net/authenticated-clickhouse-request.ts` is a real production consumer of `request()` plus the non-consuming classifier/JSON/text/progress consumers — the convenience `queryJson`/`queryText`/`queryProgress` methods themselves still have no `src/**` consumer (that cutover is Phase 7) | +| `packages/clickhouse-http/src/*` | first-party npm workspace (repo's first, #630 Phase 2) — `chUrl`/URL serialization, the low-level injected-`fetch()` request, the progress-stream read loop and HTTP exception parsing/framing (Phase 3), (Phase 4) non-consuming success/error classification (`ensureClickHouseSuccess`), JSON/text/progress consumers, a minimal `ClickHouseError`, convenience `queryJson`/`queryText`/`queryProgress` client methods, and a stateless wire-level `killQuery`, and (Phase 5) the ONE ClickHouse SQL-quoting implementation (`sqlString`/`quoteIdent`/`qualifyIdent`), the ONE generic type-expression grammar (`parseClickHouseType`/`analyzeTypeModifiers`/`canonicalType`/wrapper/enum helpers), and the shared lexical scanner (`scanSpans`) — behind a public `.` export only; transport/protocol APIs stay `src/net/**`-only, while the pure-language exports above may be imported directly by their real SQL Browser consumers anywhere outside `src/net/**` too (mechanically allowlisted, `build/check-boundaries.mjs` Rule D); since Phase 6, `src/net/authenticated-clickhouse-request.ts` is a real production consumer of `request()` plus the non-consuming classifier/JSON/text/progress consumers — the convenience `queryJson`/`queryText`/`queryProgress` methods themselves have no `src/**` consumer as of #630's own closure (a genuinely open item, not reopened or claimed by Phase 8) | +| `packages/clickhouse-http/{test,build.mjs,tsconfig*.json,vitest.config.ts}` | (#630 Phase 8) the package's own independent build/type/test boundary — package-local `esbuild` (unbundled browser-first ESM, `bundle: false`) + `tsc` (declaration-only emit) produce `dist/**`; package-local `vitest.config.ts` (100/95/90/100 per file) exercises the built public barrel via a relative import to `src/index.ts`; `test/isolated-package.mjs` (`npm run test:pack`) proves a real `npm pack` installs, resolves, and typechecks outside this repository with no source fallback; `test/browser/**` is this package's own Chromium+WebKit regression suite over the built `dist/**` (no import map, no vendor client, no Docker/live ClickHouse) | | `src/application/*` | app-level coordination, sessions, and pure projections; no UI/editor imports | | `src/workspace/*` | pure stored-workspace aggregate, persistence contracts, and mutations | | `src/dashboard/*` | Dashboard model, layouts, and application runtime; dependency direction is mechanically checked | diff --git a/build/build.mjs b/build/build.mjs index e179bbd2..84f0fb1d 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -127,18 +127,20 @@ export function esbuildOptions({ repoRoot = root, entryPoint, metafile = false, // it's the report tool's own template, not repository source under test. // // `noticesPath` overrides the default `/THIRD-PARTY-NOTICES.md`. -// `additionalNotices`, when given, is appended after it — the Phase 0 spike -// uses this to attach a candidate-only notice fragment for a devDependency -// that is bundled ONLY in the isolated candidate artifact, never in the -// normal production build. `buildStampOverride` passes through to -// buildStamp() (see there); omitted, normal stamp derivation is unchanged. +// `buildStampOverride` passes through to buildStamp() (see there); omitted, +// normal stamp derivation is unchanged. +// +// Issue #630 Phase 8 removes `additionalNotices` (the #585 Phase 0 vendor +// candidate artifact's own notice-fragment plumbing): that candidate build +// path is retired along with the rest of the executable vendor-comparison +// scaffolding, so this builder no longer needs a second, additive notices +// input. export async function buildArtifact({ repoRoot = root, entryPoint, metafile = false, jsMinify = true, noticesPath, - additionalNotices, buildStampOverride, } = {}) { const result = await build(esbuildOptions({ repoRoot, entryPoint, metafile, jsMinify })); @@ -173,8 +175,7 @@ export async function buildArtifact({ // (legalComments: 'none'), so embed THIRD-PARTY-NOTICES.md as a leading HTML // comment — sanitized so its text can't close the comment early. const baseNotices = await readFile(noticesPath ?? resolve(repoRoot, 'THIRD-PARTY-NOTICES.md'), 'utf8'); - const noticesText = additionalNotices ? `${baseNotices.trim()}\n\n${additionalNotices.trim()}` : baseNotices; - const thirdParty = ''; + const thirdParty = ''; const html = template .replace('', () => thirdParty) @@ -197,12 +198,11 @@ export async function writeArtifact({ entryPoint, jsMinify = true, noticesPath, - additionalNotices, buildStampOverride, outDir = resolve(repoRoot, 'dist'), } = {}) { const { html, fonts } = await buildArtifact({ - repoRoot, entryPoint, jsMinify, noticesPath, additionalNotices, buildStampOverride, + repoRoot, entryPoint, jsMinify, noticesPath, buildStampOverride, }); const source = Buffer.from(html); await mkdir(outDir, { recursive: true }); diff --git a/build/bundle.sh b/build/bundle.sh index 74893178..e15fe41f 100755 --- a/build/bundle.sh +++ b/build/bundle.sh @@ -21,6 +21,9 @@ VERSION="${1:-$(node -p "require('$ROOT/package.json').version")}" OUT="$ROOT/dist" STAGE="$OUT/bundle/altinity-sql-browser" +echo "==> Building @altinity/clickhouse-http" +npm --prefix "$ROOT" run build:clickhouse-http + echo "==> Building SPA" # Pass the resolved version through so the in-HTML build stamp matches the # VERSION file written below (build.mjs honors $ASB_VERSION over package.json). diff --git a/build/check-boundaries.mjs b/build/check-boundaries.mjs index b7b1acc9..a0207d6f 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -14,21 +14,28 @@ // second script. // // Hand-rolled regex scan for the internal src-layering rules (RULES below) -// and the plain package-specifier bans (the @clickhouse/client-web ban, Rule -// B's zero-bare-specifier check): the codebase has no exotic import syntax -// there, so scanning for import/export specifiers is enough and keeps those -// rules a zero-dependency, sub-second pretest step. The exceptions are the -// former-owner rules and BOTH halves of the revised package Rule D below -// (the deep-import-subpath ban and the bare-specifier name/shape check), -// which need identifier/import-shape-level (not specifier-text-level) -// detection and therefore delegate to a real TypeScript parse in -// `build/lib/check-legacy-owners.mjs` — see that module for why textual -// matching was retired there (issue #630 Phase 3), and why the same -// real-parser mechanism (not a new hand-rolled scanner) was required again -// for issue #630 Phase 5's revised Rule D, for both halves: a comment sitting -// between `import`/`export` and the specifier defeats a regex (however far -// its whitespace/delimiter patterns are widened) but is ordinary parser -// trivia to a real parse. +// and Rule B's zero-bare-specifier check: the codebase has no exotic import +// syntax there, so scanning for import/export specifiers is enough and keeps +// those rules a zero-dependency, sub-second pretest step. The exceptions are +// the former-owner rules, Rule C (the package relative-deep-import ban, +// Guard 2 — issue #630 Phase 8, review pass 1), BOTH halves of the revised +// package Rule D (the deep-import-subpath ban and the bare-specifier +// name/shape check), and the `@clickhouse/client-web` reintroduction ban +// (Guard 5) below — all of which need identifier/import-shape-level (not +// specifier-text-level) detection and therefore delegate to a real +// TypeScript parse in `build/lib/check-legacy-owners.mjs` — see that module +// for why textual matching was retired there (issue #630 Phase 3), and why +// the same real-parser mechanism (not a new hand-rolled scanner) was +// required again for issue #630 Phase 5's revised Rule D, and again for +// issue #630 Phase 8's Rule C/Guard 2 broadening and Guard 5: a comment +// sitting between `import`/`export` and the specifier, or an escaped +// string-literal segment, defeats a regex (however far its +// whitespace/delimiter patterns are widened) but is ordinary parser +// trivia/decoded text to a real parse — review pass 1 confirmed Rule C's +// production enforcement still ran the regex (`extractSpecifiers`) despite +// this file's own stated Phase 8 design goal, while its unit-test mirror +// independently reimplemented the identical regex rather than calling the +// real parser. import fs from 'node:fs'; import path from 'node:path'; @@ -48,6 +55,15 @@ import { PHASE7_RETIRED_TOP_LEVEL_NAMES, PHASE7_DELETED_TRANSPORT_FILES, mightReferenceRetiredTopLevelApi, + PHASE8_NARROW_RULE_D_EXCEPTIONS, + findModuleSpecifiers, + mightReferenceForbiddenRelativeDir, + findTransportSurfaceOwnershipViolations, + PHASE8_TRANSPORT_SURFACE_NAMES, + PHASE8_PARSER_SURFACE_NAMES, + manifestDependencyFields, + lockHasPackage, + retiredClientSpikeScriptNames, } from './lib/check-legacy-owners.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -177,12 +193,20 @@ const RULES = [ }, // Issue #630 Phase 2 — Rule C: SQL Browser source must consume the package // through its public export, never a relative deep import into the - // package's own src/** implementation files. - { - dir: 'src', - forbidden: ['packages/clickhouse-http/src'], - why: 'issue #630 Phase 2: SQL Browser must use the package public export', - }, + // package's own implementation files. Issue #630 Phase 8 (plan §21, Guard + // 2) broadens the forbidden target from just `packages/clickhouse-http/src` + // to the WHOLE package directory (`packages/clickhouse-http`, no `/src` + // suffix) — generated `dist/**` is a second possible relative deep-import + // escape a source-only ban would miss (e.g. + // `../../packages/clickhouse-http/dist/client.js`). The bare deep-import + // subpath form (`@altinity/clickhouse-http/dist/client.js`) needs no + // parallel change: Rule D's `findDeepImportSpecifiers` below already bans + // any subpath of the package specifier regardless of what follows the + // slash, dist included. NOT an entry in this RULES array (review pass 1): + // this generic loop's `extractSpecifiers` regex missed a comment-trivia'd + // import clause or an escaped string-literal segment spelling out a + // `packages/clickhouse-http` path, so Rule C is enforced by its own real- + // parser (`findModuleSpecifiers`) block, alongside Rule D, below. ]; function collectFiles(target) { @@ -233,6 +257,24 @@ function extractSpecifiers(source) { // Relative specifiers resolve like esbuild/tsc do: a `.js` specifier written // against a `.ts` source file still resolves to the `.ts` file on disk. +// +// Review pass 2 finding: this used to return the purely LEXICAL path, with +// no symlink canonicalization. The workspace link npm installs for +// `@altinity/clickhouse-http` (package-lock.json's +// `"node_modules/@altinity/clickhouse-http": { "resolved": +// "packages/clickhouse-http", "link": true }`) makes `node_modules/@altinity/ +// clickhouse-http` an actual symlink to `packages/clickhouse-http` — so a +// relative import spelled through that symlink (e.g. +// `../../node_modules/@altinity/clickhouse-http/src/client.ts`) resolved +// lexically to a `node_modules/...` path that never equals or starts with +// `packages/clickhouse-http`, letting Guard 2 (and the generic RULES loop's +// Rule A) miss a deep import into the package's own internals entirely, even +// though the path is the exact same file on disk. `fs.realpathSync` on any +// candidate that actually exists canonicalizes the symlink away before the +// caller compares the resolved path against a forbidden prefix; a +// non-existent candidate (an unresolved import, reported by other means if +// at all) keeps its lexical path unchanged, since there is nothing on disk to +// canonicalize. function resolveRelative(fromFile, spec) { const resolved = path.resolve(path.dirname(fromFile), spec); const noExt = resolved.replace(/\.(ts|tsx|js|mjs)$/, ''); @@ -240,7 +282,12 @@ function resolveRelative(fromFile, spec) { resolved, `${noExt}.ts`, `${noExt}.tsx`, `${noExt}.js`, `${noExt}.mjs`, path.join(resolved, 'index.ts'), path.join(resolved, 'index.js'), ]; - return candidates.find((candidate) => fs.existsSync(candidate)) ?? resolved; + const found = candidates.find((candidate) => fs.existsSync(candidate)) ?? resolved; + try { + return fs.realpathSync(found); + } catch { + return found; // nothing on disk to canonicalize — keep the lexical path + } } const violations = []; @@ -317,25 +364,88 @@ for (const file of collectFiles(path.join(repoRoot, 'src'))) { } } -// Issue #585 Phase 1: no file under src/** may import the official -// `@clickhouse/client-web` package (a bare specifier — the `RULES` loop above -// skips those, `if (!spec.startsWith('.')) continue;`, hence this separate -// block). ADR-0005 (docs/ADR-0005-clickhouse-web-client.md) is Rejected, so -// Phases 2-4 (the official-client cutover) do not proceed without a new -// decision — today this bans the import everywhere in `src/`. The single -// allowlist entry names the FUTURE official transport file (does not exist -// yet); the rule is written so it activates correctly the moment that file is -// born, rather than needing a second edit here. +// Issue #585 Phase 1 / #630 Phase 8 (plan §24, Guard 5): no executable/config +// source anywhere in the repository may import the official +// `@clickhouse/client-web` package, or a subpath of it. ADR-0005 +// (docs/ADR-0005-clickhouse-web-client.md) is and remains Rejected — there is +// no future-transport allowlist anymore (Phase 8 deletes it outright; #639 +// covers only the workspace-extraction side of this issue, never a reversal +// of this ADR). A real-parser scan (`findModuleSpecifiers`), not a +// specifier-text regex, for the same comment-trivia-bypass reason as Rule D +// above — this is exactly the "genuinely new source analysis" case this +// module's header comment requires the real parser for, now covering four +// trees instead of one: `src/**`, `packages/clickhouse-http/**` (excluding +// generated `dist/**`, which is build output, not source), `tests/**`, and +// `build/**`. The expensive real-parser call is gated per file by +// `mightReferencePackage` — the SAME escape-sequence-aware pre-filter Rule D +// uses above, not `mightReferenceRetiredTopLevelApi`'s bare +// `source.includes(name)` substring test. Review pass 2: an import spelled +// through an escaped specifier (e.g. a hex escape, +// `'@clickhouse/client-w\x65b'`) contains no raw `client-web` substring, so +// `mightReferenceRetiredTopLevelApi` — sound only for the identifier-name +// threat model it was built for (Phase 7/Guards 3-4's retired top-level API +// names, never a package specifier) — would silently skip the real parser +// for exactly the file that most needs it, even though `findModuleSpecifiers` +// decodes the escape via `node.text` and would have caught the reintroduced +// import. `mightReferencePackage` is unsound only for a NON-existent +// backslash-free escape, which cannot occur, so it stays fail-closed here. +// Review pass 1: the prefilter now CALLS a shared helper (imported above) +// instead of an inline `source.includes(CLIENT_WEB_SPECIFIER)` — production +// and the in-suite mirror (`tests/unit/client-web-retirement-policy.test.js`) +// previously each hand-copied that same one-line check independently, so a +// production-only regression could leave the mirror's own copy — and its +// sabotage tests — green while production silently diverged; sharing the one +// implementation closes that drift risk, matching this file's convention for +// every other pre-filter above. const CLIENT_WEB_SPECIFIER = '@clickhouse/client-web'; -const CLIENT_WEB_ALLOWLIST = new Set(['src/net/clickhouse-web-transport.ts']); -for (const file of collectFiles(path.join(repoRoot, 'src'))) { - const relFile = path.relative(repoRoot, file).split(path.sep).join('/'); +const CLIENT_WEB_BAN_ROOTS = ['src', 'packages/clickhouse-http', 'tests', 'build']; +for (const rootDir of CLIENT_WEB_BAN_ROOTS) { + const fullRootDir = path.join(repoRoot, rootDir); + if (!fs.existsSync(fullRootDir)) continue; + for (const file of collectFiles(fullRootDir)) { + const relFile = path.relative(repoRoot, file).split(path.sep).join('/'); + if (relFile.startsWith('packages/clickhouse-http/dist/')) continue; // generated, not source + checkedFiles += 1; + const source = fs.readFileSync(file, 'utf8'); + if (!mightReferencePackage(source, CLIENT_WEB_SPECIFIER)) continue; + for (const { spec } of findModuleSpecifiers(source, relFile)) { + if (spec === CLIENT_WEB_SPECIFIER || spec.startsWith(`${CLIENT_WEB_SPECIFIER}/`)) { + violations.push(`${relFile} → ${spec} (issue #630 Phase 8 Guard 5: @clickhouse/client-web must never be reintroduced — ADR-0005 remains Rejected)`); + } + } + } +} + +// Structural manifest/lockfile checks (plan §24) — plain object inspection, +// no parser needed: the vendor dependency, its retired npm scripts, and the +// executable spike directory must all stay absent. +for (const manifestPath of ['package.json', 'packages/clickhouse-http/package.json']) { + const fullManifestPath = path.join(repoRoot, manifestPath); + if (!fs.existsSync(fullManifestPath)) continue; checkedFiles += 1; - const source = fs.readFileSync(file, 'utf8'); - for (const spec of extractSpecifiers(source)) { - if (spec !== CLIENT_WEB_SPECIFIER && !spec.startsWith(`${CLIENT_WEB_SPECIFIER}/`)) continue; - if (CLIENT_WEB_ALLOWLIST.has(relFile)) continue; - violations.push(`${relFile} → ${spec} (issue #585 Phase 1: only the future official transport file may import @clickhouse/client-web — ADR-0005 is Rejected, Phases 2-4 do not proceed without a new decision)`); + const manifest = JSON.parse(fs.readFileSync(fullManifestPath, 'utf8')); + for (const depField of manifestDependencyFields(manifest, CLIENT_WEB_SPECIFIER)) { + violations.push(`${manifestPath} → ${depField}.${CLIENT_WEB_SPECIFIER} (issue #630 Phase 8 Guard 5: the vendor dependency must not return to any manifest)`); + } + if (manifestPath === 'package.json') { + for (const scriptName of retiredClientSpikeScriptNames(manifest.scripts)) { + violations.push(`${manifestPath} → scripts.${scriptName} (issue #630 Phase 8 Guard 5: the retired vendor-spike npm scripts must not return)`); + } + } +} +const lockPath = path.join(repoRoot, 'package-lock.json'); +if (fs.existsSync(lockPath)) { + checkedFiles += 1; + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (lockHasPackage(lock, CLIENT_WEB_SPECIFIER)) { + violations.push(`package-lock.json → ${CLIENT_WEB_SPECIFIER} (issue #630 Phase 8 Guard 5: the vendor package must not remain installed in the lockfile)`); + } +} +{ + const spikeDir = path.join(repoRoot, 'tests/spike/clickhouse-client'); + checkedFiles += 1; + if (fs.existsSync(spikeDir)) { + violations.push('tests/spike/clickhouse-client → directory exists (issue #630 Phase 8 Guard 5: the executable vendor-spike directory must not be recreated)'); } } @@ -363,6 +473,46 @@ if (fs.existsSync(PACKAGE_SRC_DIR)) { } } +// Issue #630 Phase 2 — Rule C (Guard 2, broadened Phase 8 plan §21): SQL +// Browser source must consume the package through its public export only — +// no relative deep import into ANY part of the package directory +// (`packages/clickhouse-http/**`, including generated `dist/**`). This is a +// real TypeScript parse (`findModuleSpecifiers`), NOT the generic RULES +// loop's `extractSpecifiers` regex above (review pass 1 finding): production +// was still running the hand-rolled regex here even though this file's own +// header comment already required the real-parser mechanism for exactly +// this class of escape. A comment sitting between `import`/`export` and the +// specifier, or an escaped string-literal segment (e.g. a hex/unicode escape +// spelling out `../../packages/clickhouse-http/dist/index.js` without ever +// containing that raw substring), both defeat `extractSpecifiers` — it +// captures the still-escaped/comment-adjacent raw source text, which then +// fails to resolve to the real file on disk, so the escape silently slips +// the guard — while a real parse decodes the literal via `node.text` exactly +// like Guard 1, Guard 5, and Rule D below already do for the identical +// reason. No `except` carve-outs apply here (none existed for the old RULES +// entry either). +// +// Review pass 2 (second CI-only timeout occurrence, mirrored by the same +// fix in the in-suite mirror's `beforeAll`): this block originally spawned +// the real-parser check unconditionally for every file — no pre-filter at +// all, unlike Guard 5 (`mightReferenceRetiredTopLevelApi`) and Rule D +// (`mightReferencePackage`) beside it. `mightReferenceForbiddenRelativeDir` +// closes that gap the same accepted-risk way those two already do. +for (const file of collectFiles(path.join(repoRoot, 'src'))) { + const relFile = path.relative(repoRoot, file).split(path.sep).join('/'); + checkedFiles += 1; + const source = fs.readFileSync(file, 'utf8'); + if (!mightReferenceForbiddenRelativeDir(source, ['packages/clickhouse-http'])) continue; + for (const { spec } of findModuleSpecifiers(source, relFile)) { + if (!spec.startsWith('.')) continue; // bare/package specifiers can't reach src dirs + const resolved = resolveRelative(file, spec); + const relResolved = path.relative(repoRoot, resolved).split(path.sep).join('/'); + if (relResolved === 'packages/clickhouse-http' || relResolved.startsWith('packages/clickhouse-http/')) { + violations.push(`${relFile} → ${spec} (resolved: ${relResolved}; src must not import packages/clickhouse-http — issue #630 Phase 2/8 Guard 2: SQL Browser must use the package public export, never a relative deep import into src/** or generated dist/**)`); + } + } +} + // Issue #630 Phase 2 — Rule D (revised Phase 5, plan §8.2): the deep-import // subpath form (`@altinity/clickhouse-http/...`) is forbidden EVERYWHERE // under src/** — only the package's "." export is public (contract A4). @@ -422,10 +572,24 @@ for (const file of collectFiles(path.join(repoRoot, 'src'))) { // `findPackageImportUsages`'s own doc comment for why). Inside // `src/net/**` every access form/name remains unrestricted, matching // existing production usage (`ch-client.ts`, `clickhouse-http-transport.ts`). + // Issue #630 Phase 8 adds exactly ONE additional, narrower exception on top + // of this net-only/language-export split (plan §18) — see + // `PHASE8_NARROW_RULE_D_EXCEPTIONS` below and its own doc comment in + // `check-legacy-owners.mjs`: `src/application/export-service.ts` alone may + // named-import exactly `findExceptionFrame`, a transport/protocol export, + // now that the `ch-client.ts` forwarding gateway it used to resolve through + // is retired. This is a per-file allowlist entry, not a widened category — + // no other application module gains protocol/client access. if (relFile.startsWith('src/net/')) continue; if (!fileMightReferencePackage) continue; + // Issue #630 Phase 8 (plan §18) — a narrow, PER-FILE, PER-NAME exception: + // exactly `src/application/export-service.ts` may named-import exactly + // `findExceptionFrame`, a transport/protocol export that is not on the + // pure-language allowlist. No other file/name pair is granted this. + const narrowExceptionNames = PHASE8_NARROW_RULE_D_EXCEPTIONS[relFile] ?? []; for (const usage of findPackageImportUsages(source, relFile, CLICKHOUSE_HTTP_SPECIFIER)) { if (usage.kind === 'named' && PHASE5_PACKAGE_LANGUAGE_EXPORTS.includes(usage.name)) continue; + if (usage.kind === 'named' && narrowExceptionNames.includes(usage.name)) continue; const label = usage.kind === 'named' ? `named import of '${usage.name}' (transport/protocol API)` : usage.kind === 'default' ? 'default import' : usage.kind === 'namespace' ? 'namespace import' @@ -437,6 +601,98 @@ for (const file of collectFiles(path.join(repoRoot, 'src'))) { } } +// Issue #630 Phase 8 (plan §20, Guard 1) — package containment, broadened +// past Rule A/B's original scope (package `src/**` only) to the package's +// own tooling/test surface too: `test/**`, `build.mjs`, `vitest.config.ts`. +// A real-parser scan (`findModuleSpecifiers`), not a hand-rolled regex, for +// the same comment-trivia-bypass reason as Rule D above — "genuinely new +// source analysis" per this file's own header comment and +// `check-legacy-owners.mjs`'s adopted convention. +// +// Three rules, matching the plan exactly: +// 1. a relative import anywhere in these four targets cannot escape the +// package root (`packages/clickhouse-http/**`) — broader than Rule A, +// which only bans escaping into SQL Browser's `src/**` specifically; +// 2. runtime `src/**` retains zero bare specifiers — already Rule B, +// untouched here (this block explicitly skips bare specifiers under +// `packages/clickhouse-http/src/**` to avoid double-reporting the same +// violation under two different messages); +// 3. package tooling/tests (`test/**`, `build.mjs`, `vitest.config.ts`) +// may bare-import only `node:*` or a dependency the package's OWN +// manifest declares in `devDependencies` — no tool/test may silently +// consume a root-only hoisted dev package (npm hoists many root dev +// dependencies into the same `node_modules` tree the package resolves +// against, so an undeclared import can still resolve locally even +// though the package's own manifest never asked for it). +{ + const packageRoot = path.join(repoRoot, 'packages/clickhouse-http'); + const packageManifestPath = path.join(packageRoot, 'package.json'); + if (fs.existsSync(packageManifestPath)) { + const packageManifest = JSON.parse(fs.readFileSync(packageManifestPath, 'utf8')); + const declaredDevDeps = new Set(Object.keys(packageManifest.devDependencies ?? {})); + const guard1Targets = ['src', 'test', 'build.mjs', 'vitest.config.ts'].map((p) => path.join(packageRoot, p)); + for (const target of guard1Targets) { + if (!fs.existsSync(target)) continue; + const files = fs.statSync(target).isFile() ? [target] : collectFiles(target); + for (const file of files) { + const relFile = path.relative(repoRoot, file).split(path.sep).join('/'); + const isRuntimeSrc = relFile.startsWith('packages/clickhouse-http/src/'); + checkedFiles += 1; + const source = fs.readFileSync(file, 'utf8'); + for (const { spec } of findModuleSpecifiers(source, relFile)) { + if (spec.startsWith('.')) { + const resolved = resolveRelative(file, spec); + const relResolved = path.relative(repoRoot, resolved).split(path.sep).join('/'); + if (relResolved !== 'packages/clickhouse-http' && !relResolved.startsWith('packages/clickhouse-http/')) { + violations.push(`${relFile} → ${spec} (resolved: ${relResolved}; issue #630 Phase 8 Guard 1: a relative import cannot escape the package root)`); + } + continue; + } + if (isRuntimeSrc) continue; // Rule B (above) already owns this exact case + // Package tests legitimately import the package's OWN public name + // (`@altinity/clickhouse-http`) to exercise the barrel like a real + // external consumer would (plan §8's "exercise the source public + // barrel rather than deep-importing private modules") — this is + // not a root-hoisted dependency escape, it is the package testing + // itself through its own declared identity. + if (spec === packageManifest.name) continue; + if (spec === 'node' || spec.startsWith('node:') || declaredDevDeps.has(spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0])) continue; + violations.push(`${relFile} → ${spec} (issue #630 Phase 8 Guard 1: package tooling/tests may bare-import only node:* or a dependency declared in the package's own devDependencies)`); + } + } + } + } +} + +// Issue #630 Phase 8 (plan §22/§23, Guards 3/4) — root-wide top-level +// declaration/re-export ownership for the historical generic +// transport/URL surface (`chUrl`/`createHttpTransport`/`ClickHouseTransport`/ +// `TransportDeps`/`TransportRequest`) and the moved progress-stream/ +// exception-parsing primitives (`streamLines`/`splitBuffer`/ +// `parseExceptionText`/`findExceptionFrame` and their canonical wire/frame +// types), across ALL of SQL Browser `src/**` — broader than Phase 3's +// `PHASE3_LEGACY_OWNER_FILES` former-owner scope (three specific files) and +// broader than Rule D's net-only/language-export split (which governs WHERE +// the package may be imported, not whether a same-named LOCAL declaration or +// forwarding gateway may exist elsewhere). Real production imports of these +// names directly from the package (`chUrl`/`streamLines`/`parseExceptionText` +// in `src/net/**`, `findExceptionFrame` in the one narrow +// `export-service.ts` exception) are exempted by +// `findTransportSurfaceOwnershipViolations`'s own specifier check — see its +// doc comment in `check-legacy-owners.mjs`. +{ + const guard34Names = [...PHASE8_TRANSPORT_SURFACE_NAMES, ...PHASE8_PARSER_SURFACE_NAMES]; + for (const file of collectFiles(path.join(repoRoot, 'src'))) { + const relFile = path.relative(repoRoot, file).split(path.sep).join('/'); + checkedFiles += 1; + const source = fs.readFileSync(file, 'utf8'); + if (!mightReferenceRetiredTopLevelApi(source, guard34Names)) continue; + for (const name of findTransportSurfaceOwnershipViolations(source, relFile, guard34Names, CLICKHOUSE_HTTP_SPECIFIER)) { + violations.push(`${relFile} → top-level ${name} (issue #630 Phase 8 Guards 3/4: the historical generic transport/URL surface and the moved progress-stream/exception-parsing primitives cannot be re-declared or forwarded locally)`); + } + } +} + // Issue #630 Phase 3 — narrow legacy-owner regression rule: the former // production owners of the moved progress-stream/exception-parsing // primitives must not regain them — not as a second implementation and not diff --git a/build/lib/check-legacy-owners.mjs b/build/lib/check-legacy-owners.mjs index 924d967e..73bc5315 100644 --- a/build/lib/check-legacy-owners.mjs +++ b/build/lib/check-legacy-owners.mjs @@ -131,6 +131,17 @@ export const PHASE5_PACKAGE_LANGUAGE_EXPORTS = Object.freeze([ 'canonicalType', ]); +/** Issue #630 Phase 8 (plan §18) — the ONE narrow, named Rule-D exception: + * `src/application/export-service.ts` may named-import exactly + * `findExceptionFrame` from `@altinity/clickhouse-http`, even though it sits + * outside `src/net/**` and `findExceptionFrame` is a transport/protocol + * export (not on `PHASE5_PACKAGE_LANGUAGE_EXPORTS`). This is a per-file, + * per-name allowlist, not a broadened category: no other application module + * gets protocol/client access, and this file gets no other package name. */ +export const PHASE8_NARROW_RULE_D_EXCEPTIONS = Object.freeze({ + 'src/application/export-service.ts': Object.freeze(['findExceptionFrame']), +}); + // ── Shared real-parser plumbing ───────────────────────────────────────────── // Parse `source` (claiming to be the repo-relative `filename`) with the real @@ -367,6 +378,184 @@ export function findRetiredTopLevelApiViolations(source, filename, names = PHASE }); } +// ── Phase 8 — general-purpose AST helpers (plan §19.1) ────────────────────── +// +// Issue #630 Phase 8 broadens architecture-guard coverage across five areas +// (plan §19-24) that are all "genuinely new source analysis" per this +// module's own adopted convention: real TypeScript parsing, never a new +// hand-rolled text/regex scanner (the repeated lexical-bypass lesson from +// Phases 3/5/6/7 this module's header comment already documents). Two +// reusable helpers below cover every new Phase-8 guard; no guard gets its own +// bespoke parser walk. + +/** Issue #630 Phase 8 (plan §19.1) — every module-specifier-bearing form in + * `source`, generically (not filtered to any one package): a static + * `import ... from` (with or without a clause, including a bare + * side-effect `import 'pkg'`), a static `export ... from` (a re-export + * gateway), a dynamic `import(...)` call, and TypeScript's inline + * import-type expression (`type T = import('pkg').Foo`, `typeof + * import('pkg')`) — the same four forms `findDeepImportSpecifiers`/ + * `findPackageImportUsages` already recognize above, generalized to report + * every specifier found rather than filtering for one target package. The + * module specifier itself may be a plain string literal or a + * no-substitution template literal, matching every sibling helper in this + * module. Used by Phase-8 Guards 1 (package containment) and 5 + * (`@clickhouse/client-web` reintroduction) so neither needs its own + * specifier-extraction regex. + * + * @param {string} source + * @param {string} filename repo-relative, forward-slash separated (used only + * for the virtual-file basename/grammar selection) + * @returns {{spec: string, kind: 'import'|'side-effect'|'re-export'|'dynamic'|'import-type'}[]} + */ +export function findModuleSpecifiers(source, filename) { + return withParsedSource(source, filename, (sourceFile) => { + const found = []; + const specText = (node) => { + if ( + !node + || (node.kind !== SyntaxKind.StringLiteral && node.kind !== SyntaxKind.NoSubstitutionTemplateLiteral) + ) return null; + return node.text; + }; + const walk = (node) => { + if (is.isImportDeclaration(node)) { + const spec = specText(node.moduleSpecifier); + if (spec !== null) found.push({ spec, kind: node.importClause ? 'import' : 'side-effect' }); + } + if (is.isExportDeclaration(node)) { + const spec = specText(node.moduleSpecifier); + if (spec !== null) found.push({ spec, kind: 're-export' }); + } + if ( + is.isCallExpression(node) + && node.expression + && node.expression.kind === SyntaxKind.ImportKeyword + ) { + const spec = specText(node.arguments[0]); + if (spec !== null) found.push({ spec, kind: 'dynamic' }); + } + if (is.isImportTypeNode(node)) { + const spec = specText(node.argument && node.argument.literal); + if (spec !== null) found.push({ spec, kind: 'import-type' }); + } + node.forEachChild(walk); + }; + walk(sourceFile); + return found; + }); +} + +/** Issue #630 Phase 8 (plan §19.1) — the plan's own generic vocabulary for + * `findRetiredTopLevelApiViolations` above, which already generalizes over + * an explicit `names` argument (it is not hardcoded to the Phase 7 retired + * API set — see its own doc comment/default parameter). A thin re-export + * under that name, not a second implementation: every new Phase-8 guard + * that needs "which of these names does this file declare/bind at module + * top level" calls this one function. + * + * @param {string} source + * @param {string} filename repo-relative, forward-slash separated + * @param {readonly string[]} names + * @returns {string[]} the forbidden names found, in `names` order, deduplicated + */ +export function findTopLevelOwnedDeclarations(source, filename, names) { + return findRetiredTopLevelApiViolations(source, filename, names); +} + +/** Issue #630 Phase 8 (plan §22/§23, Guards 3/4) — root-wide declaration/ + * re-export ownership for a historical generic-transport/parser name list, + * WITH one exemption `findTopLevelOwnedDeclarations` doesn't need: some of + * these names (`chUrl`, `streamLines`, `parseExceptionText`, + * `findExceptionFrame`, and their canonical wire/frame types) are REAL + * package exports that legitimate production code imports directly under + * Rule D (`src/net/**`'s unrestricted access, and `export-service.ts`'s one + * narrow `findExceptionFrame` exception) — `import { chUrl } from + * '@altinity/clickhouse-http'` followed by calling it is the SANCTIONED + * shape, not a violation. So a top-level IMPORT whose local binding is one + * of `names` is flagged only when its module specifier is something OTHER + * than `packageSpecifier` (a forwarding-alias vector smuggling in a + * same-named local binding from anywhere else, e.g. `import { foo as chUrl} + * from './somewhere.js'`); a top-level DECLARATION or EXPORT (re-export + * gateway, `export { chUrl }` / `export { chUrl } from 'anywhere'`) named + * one of `names` is ALWAYS flagged, regardless of specifier — a second + * implementation and a forwarding wrapper both fail either way. + * Declaration-scoped (`sourceFile.statements` only, never descending into + * function/class/block bodies), same reasoning as + * `findRetiredTopLevelApiViolations` — a nested `client.killQuery(...)`-style + * member call or local variable is never inspected. + * + * @param {string} source + * @param {string} filename repo-relative, forward-slash separated + * @param {readonly string[]} names + * @param {string} packageSpecifier the exact bare specifier whose import is exempted + * @returns {string[]} the forbidden names found, in `names` order, deduplicated + */ +export function findTransportSurfaceOwnershipViolations(source, filename, names, packageSpecifier) { + const watched = new Set(names); + return withParsedSource(source, filename, (sourceFile) => { + const found = new Set(); + const note = (name) => { if (name && watched.has(name)) found.add(name); }; + for (const stmt of sourceFile.statements) { + if ( + is.isFunctionDeclaration(stmt) || is.isClassDeclaration(stmt) + || is.isInterfaceDeclaration(stmt) || is.isTypeAliasDeclaration(stmt) + ) { + note(stmt.name && stmt.name.text); + } else if (is.isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + if (decl.name && decl.name.kind === SyntaxKind.Identifier) note(decl.name.text); + } + } else if (is.isImportDeclaration(stmt)) { + const specNode = stmt.moduleSpecifier; + const specText = (specNode.kind === SyntaxKind.StringLiteral + || specNode.kind === SyntaxKind.NoSubstitutionTemplateLiteral) ? specNode.text : null; + const bindings = stmt.importClause && stmt.importClause.namedBindings; + if (bindings && is.isNamedImports(bindings)) { + for (const el of bindings.elements) { + if (specText === packageSpecifier) continue; // the sanctioned route + note(el.name.text); + } + } + } else if (is.isExportDeclaration(stmt)) { + const clause = stmt.exportClause; + if (clause && is.isNamedExports(clause)) { + for (const el of clause.elements) note(el.name.text); + } + } + } + return names.filter((name) => found.has(name)); + }); +} + +/** Issue #630 Phase 8 (plan §22) — the historical generic transport/URL + * surface Guard 3 protects: `chUrl` is a real package export (exempted via + * `findTransportSurfaceOwnershipViolations`'s specifier check above); the + * other four never had a legitimate top-level import binding anywhere in + * this repository at all, so the exemption is harmless for them too. */ +export const PHASE8_TRANSPORT_SURFACE_NAMES = Object.freeze([ + 'chUrl', + 'createHttpTransport', + 'ClickHouseTransport', + 'TransportDeps', + 'TransportRequest', +]); + +/** Issue #630 Phase 8 (plan §23) — the moved progress-stream/exception-parsing + * primitives Guard 4 protects, root-wide (broader than Phase 3's + * `PHASE3_LEGACY_OWNER_FILES` former-owner scope above — this list runs + * across ALL of `src/**`, not just the three historical owners). */ +export const PHASE8_PARSER_SURFACE_NAMES = Object.freeze([ + 'streamLines', + 'splitBuffer', + 'parseExceptionText', + 'findExceptionFrame', + 'StreamLine', + 'StreamCallbacks', + 'ProgressMetaColumn', + 'ExceptionFrame', +]); + // ── Shared cheap pre-filter (review pass 2 hardening) ─────────────────────── /** @@ -409,6 +598,53 @@ export function mightReferencePackage(source, packageSpecifier) { return source.includes(packageSpecifier) || source.includes('\\'); } +/** + * Cheap textual pre-filter for Rule C / Guard 2's parser-backed relative- + * import check — the whole-package-directory deep-import ban + * (`relativeViolationsParserBacked` in the test mirror; the dedicated Guard 2 + * block in `build/check-boundaries.mjs`) — added after that check shipped + * with NO pre-filter at all (issue #630 Phase 8, review pass 1): it had to + * parse every file under the scanned tree unconditionally, which was the + * single most expensive of the cache-warming calls this suite's `beforeAll` + * makes and, stacked with the other three, pushed CI's more constrained + * scheduling past even the already-generous 30000ms setup timeout (a second + * occurrence of the exact CI-only class of failure `mightReferencePackage` + * above was introduced to fix for Rule D). + * + * Same accepted-risk shape as `mightReferencePackage`: a relative specifier + * can only resolve INTO a directory named `clickhouse-http` (or whatever a + * future `forbiddenDirs` entry's own leaf segment is) by literally spelling + * that segment somewhere in its own text, once any escape sequence is + * decoded — path resolution here is pure textual segment concatenation (via + * `node:path`, no symlinks), so there is no way to reach that directory + * without a path component that names it. Matching only each forbidden + * directory's LAST path segment (rather than its full path, e.g. just + * `clickhouse-http`, not `packages/clickhouse-http`) is deliberately looser + * than an exact-path match: the segment need not sit textually adjacent to + * the rest of the forbidden path in the specifier (e.g. + * `../other/../clickhouse-http/src` still resolves under + * `packages/clickhouse-http` without ever spelling the two segments + * together), so anchoring on the leaf alone keeps this sound for that case + * too. As with `mightReferencePackage`, a bare substring test alone is + * unsound against an escaped spelling (a hex/Unicode escape, or a + * per-character identity escape) — every such form requires at least one + * literal backslash in the source, so "no forbidden leaf substring AND no + * backslash anywhere in the file" is the only combination that can safely + * skip the real-parser check. + * + * @param {string} source + * @param {readonly string[]} forbiddenDirs repo-relative forbidden + * directories (e.g. `['packages/clickhouse-http']`) + * @returns {boolean} true if `source` might contain a relative import + * resolving into one of `forbiddenDirs` (via a plain leaf substring OR an + * escape sequence) and must go through the real-parser check; false only + * when it provably cannot + */ +export function mightReferenceForbiddenRelativeDir(source, forbiddenDirs) { + if (source.includes('\\')) return true; + return forbiddenDirs.some((dir) => source.includes(dir.split('/').pop())); +} + // ── Revised Rule D: deep-import subpath detection ──────────────────────────── /** @@ -617,3 +853,42 @@ export function findPackageImportUsages(source, filename, packageSpecifier) { return found; }); } + +// Issue #630 Phase 8 (plan §24, Guard 5) — the three plain-object structural +// predicates behind the manifest/lockfile/script half of the Guard 5 check +// (`build/check-boundaries.mjs`'s CLIENT_WEB_SPECIFIER block). Unlike every +// AST-backed check above, these need no parser — they inspect already-parsed +// JSON shapes — but they still belong here, not duplicated inline in both +// `build/check-boundaries.mjs` and its mirror +// `tests/unit/client-web-retirement-policy.test.js`: that file used to +// reimplement the exact same three booleans as its own "sabotage" test +// fixtures, which could only ever prove its OWN copy was self-consistent, +// never that the real production check still matched. Exporting the real +// predicates and having both call sites use them removes that drift risk +// outright, the same "one implementation" convention the AST checks above +// already follow. + +/** Returns the dependency field names in `manifest` (a parsed package.json- + * shaped object) that declare `specifier`, out of the four fields npm + * recognizes — empty when none do. The production caller pushes one + * violation per returned field (preserving its existing per-field message); + * a caller that only needs the aggregate yes/no (e.g. a sabotage probe) + * checks `.length > 0`. */ +export function manifestDependencyFields(manifest, specifier) { + return ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] + .filter((field) => Object.prototype.hasOwnProperty.call(manifest[field] ?? {}, specifier)); +} + +/** True when `lock` (a parsed package-lock.json-shaped object) still + * installs `specifier` anywhere under `lock.packages`. */ +export function lockHasPackage(lock, specifier) { + return Object.keys(lock.packages ?? {}).some((k) => k.endsWith(`node_modules/${specifier}`)); +} + +/** Returns the script names in `scripts` (a package.json `scripts` map) + * that are one of the retired issue #585 vendor-spike comparison-harness + * npm scripts issue #630 Phase 8 deleted (`check:client-spike:evidence`, or + * any `test:client-spike*` variant) — empty when none remain. */ +export function retiredClientSpikeScriptNames(scripts) { + return Object.keys(scripts ?? {}).filter((s) => s === 'check:client-spike:evidence' || s.startsWith('test:client-spike')); +} diff --git a/build/size-report.mjs b/build/size-report.mjs index 6dc1b3d8..2982db1d 100644 --- a/build/size-report.mjs +++ b/build/size-report.mjs @@ -23,10 +23,6 @@ // (default: /src/main.ts) // --artifact-out where the assembled dist/sql.html + sidecars are // (re)written (default: /dist) -// --notices an additional notice fragment appended after -// the normal THIRD-PARTY-NOTICES.md — the Phase 0 -// candidate artifact's devDependency notice, never -// passed for the normal production artifact // --include-unminified-js also measure an unminified JS build (same // esbuild options, jsMinify:false only) — no // unminified HTML is produced or shipped @@ -38,7 +34,9 @@ // // Reporting only — it never alters production loading semantics. metafile:true is // pure metadata; the emitted bytes are identical to `npm run build` (when --root, -// --entry, --notices, and --build-stamp are all omitted). +// --entry, and --build-stamp are all omitted). Issue #630 Phase 8 removes the +// former `--notices` option along with the rest of the #585 Phase 0 vendor +// candidate artifact's plumbing — see build/build.mjs's own note. import { build } from 'esbuild'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; @@ -69,7 +67,6 @@ function parseArgs(argv) { root: null, entry: null, artifactOut: null, - notices: null, includeUnminifiedJs: false, buildStamp: null, }; @@ -80,7 +77,6 @@ function parseArgs(argv) { else if (a === '--root') args.root = argv[++i]; else if (a === '--entry') args.entry = argv[++i]; else if (a === '--artifact-out') args.artifactOut = argv[++i]; - else if (a === '--notices') args.notices = argv[++i]; else if (a === '--include-unminified-js') args.includeUnminifiedJs = true; else if (a === '--build-stamp') args.buildStamp = argv[++i]; } @@ -114,16 +110,12 @@ async function main() { const outDir = resolve(repoRoot, args.out); const artifactOutDir = args.artifactOut ? resolve(process.cwd(), args.artifactOut) : resolve(repoRoot, 'dist'); - const additionalNotices = args.notices - ? await readFile(resolve(process.cwd(), args.notices), 'utf8') - : undefined; const buildStampOverride = args.buildStamp === null ? undefined : args.buildStamp; const { html, script, styles, metafile } = await buildArtifact({ repoRoot, entryPoint: args.entry ?? undefined, metafile: true, - additionalNotices, buildStampOverride, }); diff --git a/deploy/install.sh b/deploy/install.sh index 2d50e8a0..12a6d5dd 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -67,6 +67,9 @@ if [[ "$DRY_RUN" != 1 && -n "$CLUSTER" ]]; then fi fi +echo "==> Building @altinity/clickhouse-http" +npm --prefix "$ROOT" run build:clickhouse-http + echo "==> Building dist/sql.html" node "$ROOT/build/build.mjs" @@ -131,7 +134,8 @@ fi upload() { # upload local src="$1" local fname="$2" - local tbl="default._asb_$(echo "$fname" | tr '.-' '__')" + local tbl + tbl="default._asb_$(echo "$fname" | tr '.-' '__')" local on_cluster="" [[ -n "$CLUSTER" ]] && on_cluster="ON CLUSTER '${CLUSTER}'" "${CH[@]}" --query "CREATE TABLE IF NOT EXISTS ${tbl} ${on_cluster} (content String) diff --git a/docs/ADR-0005-clickhouse-web-client.md b/docs/ADR-0005-clickhouse-web-client.md index 178d15f4..b8349fcb 100644 --- a/docs/ADR-0005-clickhouse-web-client.md +++ b/docs/ADR-0005-clickhouse-web-client.md @@ -1185,10 +1185,61 @@ seam's eventual deletion, stay deferred to #630 Phase 7 as already recorded above. Every historical spike result, gate outcome, and date elsewhere in this ADR is unchanged by this addendum. - +### #630 Phase 8 current-state addendum (2026-08-08) + +**Status remains Rejected.** This addendum, like every #630 extraction +addendum before it, does not reopen or rewrite the Rejected decision above, +its evidence, or its dates — it records only what Phase 8 (the final phase of +issue #630) did to the SPIKE's own executable machinery, which by this point +had fully served its purpose: every hand-rolled mechanic it validated is now +`packages/clickhouse-http`'s own production implementation (#630 Phases 2-5), +and its comparison target (`@clickhouse/client-web`) was never adopted. + +The `@clickhouse/client-web@1.23.1` devDependency and its exact-pinned +vendor-comparison harness were historical EVALUATION machinery — a real, +executable spike (`tests/spike/clickhouse-client/**`) that ran the official +client side-by-side with SQL Browser's own hand-rolled mechanics against a +deterministic fixture server and, for the live-only rows, a real ClickHouse +container matrix. Phase 8 removes that executable machinery outright: + +- the `@clickhouse/client-web` dependency (root manifest and lockfile); +- the retired npm scripts (`test:client-spike`, `test:client-spike:matrix`, + `test:client-spike:browser`, `check:client-spike:evidence`); +- the whole `tests/spike/clickhouse-client/` directory — comparison + adapters, the vendor-candidate build entry/notices, the Docker-backed + live-matrix runner, and the evidence validator itself; +- the candidate-build-only plumbing in `build/build.mjs`/`build/size-report.mjs` + (`additionalNotices`/`--notices`) that existed solely to attach the + candidate artifact's vendor notice fragment. + +One piece of the spike's own infrastructure survives, moved rather than +deleted: `fault-server.mjs`, the deterministic Node HTTP fixture server, was +generic and dependency-free from day one — it now lives at +`packages/clickhouse-http/test/browser/fault-server.mjs` as this package's own +first-party Chromium/WebKit regression fixture (its former first-party +consumer, `tests/e2e/clickhouse-http-transport.{html,spec.js}`, was itself +split this phase into the package's own regression suite plus a narrower root +`tests/e2e/authenticated-clickhouse-request.{html,spec.js}` for SQL Browser's +authentication-policy variants — see `.wiki/Architecture.md`). + +**`docs/evidence/585/**` is retained, byte-for-byte, and is not reopened by +this addendum.** The historical commands block that used to follow this +paragraph (spike test suite, matrix runner, browser harness, evidence +validator) is retired below into a HISTORICAL description — none of those +commands exist anymore; they describe what was run to produce the evidence +already committed under `docs/evidence/585/**`, not anything a developer can +run today. Future adoption of any officially-supported ClickHouse client +still requires a new, explicit decision — this ADR's Rejected status is not +superseded by Phase 8's cleanup. + +Historical reproduction commands (Phase 0–Phase 7 only — **none of these +commands exist in this repository anymore** after Phase 8's retirement; kept +here strictly as a historical record of how the committed +`docs/evidence/585/**` evidence was originally produced): ```sh -# spike-only test suite (does not run under normal `npm test`) +# HISTORICAL — retired by issue #630 Phase 8. Do not attempt to run these. +# spike-only test suite (did not run under normal `npm test`) npm run test:client-spike # full evidence-generation matrix (Docker + live ClickHouse rows + browsers) npm run test:client-spike:matrix @@ -1202,7 +1253,11 @@ node tests/spike/clickhouse-client/recompute-decision.mjs # evidence self-consistency validator (this ADR's Status vs. results.json, # wiki status/link, decision-table.md byte match, completeness, credentials) npm run check:client-spike:evidence -# normal repository gate — unaffected by this spike +``` + +The normal repository gate remains, unaffected by this spike's retirement: + +```sh npm run check:types && npm run check:arch && npm run check:schemas \ && npm run check:examples && npm test && npm run build npm run size-report diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cc8a6c8e..0dd86f07 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -655,14 +655,98 @@ cancellation in both required browsers), and **A16** (the generic run/export/ordinary-kill APIs and both local transport files are gone, with architecture guards preventing their return). -Deferred to **Phase 8**: making `packages/clickhouse-http` independently -buildable/packable/typecheckable in isolation (no root-source fallback), -removing migration scaffolding no longer needed after this phase -(compatibility-only package/root aliases, `@clickhouse/client-web` and its -executable vendor-spike wiring), and the final in-repo ownership -cleanup/architecture-guard hardening that prepares the package for -extraction into its own repository (issue #639, which starts only after -Phase 8 ships) — **A17**/**A18**. +### Standalone package build and final retirement (#630 Phase 8) + +Phase 8 (the final phase of issue #630) claims **A17**/**A18** and closes +the issue. + +**A17 — independently buildable/packable/typecheckable, no root-source +fallback.** `packages/clickhouse-http` gets its own build/type/test +boundary, entirely package-local: + +``` +package source (src/**/*.ts) + | + +-- package-local esbuild (bundle: false, platform: browser, + | format: esm, outbase: src, outdir: dist) + | -> packages/clickhouse-http/dist/**/*.js + | + +-- package-local tsc (declaration-only emit) + -> packages/clickhouse-http/dist/**/*.d.ts + | + +--> root tsc resolves dist declarations + | (root tsconfig.json no longer includes + | package src/**/*.ts at all) + | + +--> root esbuild resolves dist ESM through the + workspace node_modules symlink (attributed to + the `project` ownership bucket by + build/size-report-lib.mjs, never `external`, + since it is project code either way) + | + v + dist/sql.html +``` + +The package manifest's `main`/`types`/`exports["."]` all target `dist/**`, +never source — the SAME public surface an eventual extraction (#639) or +external consumer would see. Every production build entrypoint that +bypasses root npm scripts (`build/bundle.sh` and `deploy/install.sh`, both +of which call `node build/build.mjs` directly) gets an explicit +`npm --prefix "$ROOT" run build:clickhouse-http` line before that call, so +the package's own `dist/**` genuinely exists first, in every real +invocation path — verified from a clean `packages/clickhouse-http/dist` +state for both wrappers. `packages/clickhouse-http/test/isolated-package.mjs` +(`npm run test:pack`) is a real, runnable proof: build the package, run a +real `npm pack`, install the tarball into a fixture OUTSIDE this +repository, import it as ESM, and compile a TypeScript consumer against its +declarations with `--traceResolution` — asserting neither runtime nor type +resolution ever falls back into this repository's source. +`packages/clickhouse-http/test/browser/**` is a new first-party +Chromium+WebKit regression suite serving the package's own generated +`dist/**` directly (no import map, no vendor client, no Docker/live +ClickHouse) — see `docs/clickhouse-http-repository-extraction.md` for the +full extraction handoff (#639). + +**A18 — final ownership cleanup and vendor retirement.** The migration-only +`ch-client.ts` forwarding aliases (`chUrl`/`parseExceptionText`/ +`findExceptionFrame`) are removed now that every spike consumer is gone; +`export-service.ts` imports `findExceptionFrame` directly from the package +under one narrow, named Rule-D exception +(`PHASE8_NARROW_RULE_D_EXCEPTIONS`) rather than through that retired +gateway — no other application module gets protocol/client access. Five +architecture guards are added/broadened, all through the same real-parser +mechanism (`build/lib/check-legacy-owners.mjs`), never a hand-rolled +regex/text scanner: package containment now also covers the package's own +`test/**`/`build.mjs`/`vitest.config.ts` (Guard 1); the package +relative-deep-import ban widens from `src/**` to the whole package +directory, closing a `dist/**` escape a source-only ban would have missed +(Guard 2); root-wide declaration/re-export ownership for the historical +`chUrl`/`createHttpTransport`/`ClickHouseTransport`/`TransportDeps`/ +`TransportRequest` transport surface, exempting the sanctioned package +import itself (Guard 3); the same root-wide ownership rule for the moved +progress-stream/exception-parsing primitives (Guard 4); and the +`@clickhouse/client-web` ban's former "future official transport file" +allowlist is deleted outright, its scan widened across +`src/**`/`packages/clickhouse-http/**` (excluding generated `dist/**`)/ +`tests/**`/`build/**`, plus structural manifest/lock/script/directory +checks (Guard 5). The `@clickhouse/client-web` devDependency, its four npm +scripts, and the whole executable `tests/spike/clickhouse-client/**` +directory are removed per an exact file-by-file disposition table (mostly +outright deletion; `fault-server.mjs` moves to the package's own browser +suite as generic, dependency-free fixture infrastructure; its former +first-party consumer splits into that package suite plus a narrower root +`tests/e2e/authenticated-clickhouse-request.{html,spec.js}` for SQL +Browser's own authentication-policy variants) — along with the +candidate-build-only `additionalNotices`/`--notices` plumbing in +`build/build.mjs`/`build/size-report.mjs`. `docs/evidence/585/**` and +ADR-0005's Rejected decision/historical content are untouched; only a +narrow current-state addendum documents the executable retirement. + +At this point issue #630 itself is complete; issue #639 (external +repository creation/release and the SQL Browser consumer cutover) starts +from the tested handoff in +`docs/clickhouse-http-repository-extraction.md`. ## Build @@ -671,11 +755,13 @@ Phase 8 ships) — **A17**/**A18**. bundled runtime dependencies (CodeMirror 6, Chart.js + chartjs-adapter-date-fns + date-fns, dagre, `@preact/signals-core`, marked); none is loaded from a third-party CDN. `packages/clickhouse-http` (#630 -Phase 2, the repository's first npm workspace) is first-party project -source, not an eighth runtime dependency — esbuild resolves its bare -`@altinity/clickhouse-http` import through the workspace's `node_modules` -symlink and bundles it as ordinary source; `build/size-report-lib.mjs` -attributes every `packages/**` input to the `project` ownership bucket -accordingly, and the Dockerfile's build stage copies `packages/` alongside -`src/` before `npm ci && npm run build` so container/release builds resolve -it identically. +Phase 2, the repository's first npm workspace; independently built since +Phase 8 — see above) is first-party project source, not an eighth runtime +dependency — esbuild resolves its bare `@altinity/clickhouse-http` import +through the workspace's `node_modules` symlink to its BUILT `dist/**` +(never source, since Phase 8) and bundles that as ordinary project code; +`build/size-report-lib.mjs` attributes every `packages/**` input to the +`project` ownership bucket accordingly, and the Dockerfile's build stage +copies `packages/` alongside `src/` before `npm ci && npm run build` (which +itself composes `build:clickhouse-http` first) so container/release builds +resolve it identically. diff --git a/docs/clickhouse-http-repository-extraction.md b/docs/clickhouse-http-repository-extraction.md new file mode 100644 index 00000000..6fd68d53 --- /dev/null +++ b/docs/clickhouse-http-repository-extraction.md @@ -0,0 +1,195 @@ +# `@altinity/clickhouse-http` repository extraction handoff + +Written by issue #630 Phase 8, for issue #639 ("extract `packages/clickhouse-http` +into its own repository and publish it"). Phase 8 stabilizes the package +in-tree so it can be moved unchanged; #639 creates/moves/releases externally. + +## 1. Scope + +Issue #630 (this repository, SQL Browser) made `packages/clickhouse-http` +independently buildable, testable, and packable, with a publication-shaped +manifest, while it still physically lives inside this repository as an npm +workspace. It deliberately does **not**: + +- create `Altinity/clickhouse-http` (or any external repository); +- publish an npm release; +- choose the first externally released semver; +- change SQL Browser's dependency on the package from a workspace + dependency to an externally released one; +- delete the workspace package from this repository; +- add external-repository CI/release automation; +- redesign the package's source, public API, build, or test architecture. + +Issue #639 owns every item above. At the point Phase 8 completed, issue #630 +itself is closed. + +## 2. Package tree that moves unchanged + +``` +packages/clickhouse-http/ + .gitignore + LICENSE + README.md + package.json + build.mjs + tsconfig.json + tsconfig.build.json + vitest.config.ts + src/** + test/** +``` + +Generated `dist/`, `coverage/`, `test-results/`, and `playwright-report/` +(all gitignored) are excluded — a fresh `npm run build`/`npm test`/ +`npm run test:pack`/`npm run test:browser` regenerates them in the new +location exactly as it does here. + +## 3. Mechanical extraction + +Conceptual operation: + +```sh +cp -a packages/clickhouse-http/. / +``` + +or an equivalent history-preserving move (`git subtree split`, `git filter-repo`, +etc.) owned by #639's own implementation. No package source, public API, or +test-path rewrite should be required — the package's own build/test +tooling already resolves everything relative to its own directory (see +`build.mjs`/`vitest.config.ts`/`playwright.config.js`'s own `here`-relative +paths), and its manifest declares its own complete `devDependencies` even +though npm hoists most of them in this workspace today (`build/lib/ +check-legacy-owners.mjs`-backed architecture Guard 1 is what keeps this +true: a package tool/test importing an undeclared root-hoisted dependency +fails `check:arch`). + +## 4. Commands actually tested in Phase 8 + +Every command below was run for real as part of this phase's own acceptance +— see §5 for what `test:pack` specifically proves. "Tested" here means +Phase 8's own acceptance actually executed the command, not that it is +merely documented. + +Inside the package directory: + +```sh +npm run check:types +npm run build +npm test +npm run test:pack +npm run test:browser +``` + +Monorepo equivalents, run from the SQL Browser repository root (what root +`npm run check:types`/`build`/`test`/`test:clickhouse-http:pack`/ +`test:clickhouse-http:browser` actually invoke): + +```sh +npm run check:types --workspace @altinity/clickhouse-http +npm run build --workspace @altinity/clickhouse-http +npm run test --workspace @altinity/clickhouse-http +npm run test:pack --workspace @altinity/clickhouse-http +npm run test:browser --workspace @altinity/clickhouse-http +``` + +## 5. What `test:pack` proves + +`packages/clickhouse-http/test/isolated-package.mjs` (invoked by +`npm run test:pack`, which builds the package first) runs this exact +sequence: + +1. **Build prerequisites** — asserts `dist/index.js`, `dist/index.d.ts`, + `README.md`, and `LICENSE` exist, and that the manifest's `main`/ + `types`/`exports["."]` all target `dist/**`. +2. **Real `npm pack`** — `npm pack --json --ignore-scripts --pack-destination + ` from the package directory, into an OS-temp directory + outside this repository. Parses npm's own JSON output for the real + tarball filename (never assumed). +3. **Tarball inventory** — extracts the real tarball and asserts it contains + exactly `package.json`, `README.md`, `LICENSE`, and `dist/**` (only + `.js`/`.d.ts` files under `dist/`) — no `src/**`, `test/**`, `build.mjs`, + `tsconfig*.json`, `vitest.config.*`, or `coverage/**`. Asserts the packed + manifest exposes only `"."`, targets built files, and carries no runtime + dependency map. +4. **Isolated install** — creates a second OS-temp fixture outside this + repository (`{ "private": true, "type": "module" }`), then runs + `npm install --offline --ignore-scripts --no-audit --no-fund + --no-package-lock --no-save ` with `NODE_PATH` + cleared. Asserts the installed package directory contains no `src/`. +5. **ESM proof** — a `consumer.mjs` in the fixture imports `chUrl`, + `createClickHouseHttpClient`, and `parseClickHouseType` from + `@altinity/clickhouse-http` and runs under Node. Asserts + `import.meta.resolve('@altinity/clickhouse-http')` terminates at + `/node_modules/@altinity/clickhouse-http/dist/index.js` and + never reaches this repository. +6. **TypeScript declaration proof** — a `consumer.ts` in the fixture imports + both runtime and type exports and compiles with `module`/ + `moduleResolution: NodeNext`, `noEmit: true`, no `paths`/project + references/workspace mappings. Runs `tsc --traceResolution` and asserts + resolution terminates at + `/node_modules/@altinity/clickhouse-http/dist/index.d.ts`, never + reaching `packages/clickhouse-http/src/**` or this repository's own + `src/**`. +7. **Cleanup** — every temporary directory is deleted in a `finally` block, + on every path including a mid-sequence failure. + +This is a genuinely running, isolated proof, not prose — CI's `test` job +runs `npm run test:clickhouse-http:pack` on every applicable push/PR. + +## 6. SQL Browser consumer references for #639 + +When the workspace package is eventually removed from this repository, +these are every SQL Browser-side reference #639 needs to retarget onto the +externally released package (all deliberately intentional composition +today, not migration debt): + +- **Root workspace dependency** (`package.json`'s `workspaces` array and its + `dependencies["@altinity/clickhouse-http"]`) → a released semver range. +- **Raw-ESM import maps** currently pointing at the workspace package + SOURCE (`"@altinity/clickhouse-http": "/packages/clickhouse-http/src/index.js"`) + in root Playwright e2e harnesses that load `/src/**` unbundled + (`tests/e2e/authenticated-clickhouse-request.html` is the one that still + needs this after Phase 8; grep `tests/e2e/*.html` for the same import-map + entry to find any others) → either a published package's own resolution + (no import map needed at all once it is a real installed dependency) or a + path into the externally released package's own build output, per + whatever #639's release format is. +- **Root e2e imports of the package-owned test fixture** + (`tests/e2e/authenticated-clickhouse-request.spec.js` and + `tests/e2e/export-post-header-cancel.spec.js`, both importing + `packages/clickhouse-http/test/browser/fault-server.mjs` directly) → once + the workspace tree is gone, either vendor a copy of this fixture into SQL + Browser's own e2e helpers, or depend on it being re-exported by the + released package's own test-utilities surface (a new decision #639 makes, + not implied by anything in Phase 8). +- **Root architecture guard entries** naming `packages/clickhouse-http/**` + by path (`build/check-boundaries.mjs`'s Rules A/B/C and the Phase-8 Guard + 1/2 blocks) → once the workspace directory is gone, these rules become + inert (the existing "directory not born yet — rule activates with it" + convention already handles this gracefully) and can be deleted as dead + code in the same change that deletes the workspace. +- **Workspace declaration** (`package.json`'s `workspaces` array) → removed. +- **Workspace deletion** (`packages/clickhouse-http/` itself) → deleted only + after the external repository/release has been integrated and this + repository's own e2e/unit suite passes against the released dependency. + +## 7. #639's own release work (explicitly out of Phase 8's scope) + +- Create the external repository (`Altinity/clickhouse-http` or equivalent). +- External repository's own lockfile, CI, and release automation. +- Flip `version`/`private` in the package manifest for the first real + release (Phase 8 deliberately keeps `"version": "0.0.0"`, + `"private": true` — proving publication SHAPE, not publishing anything). +- Publish to whatever registry #639 selects. +- Retarget SQL Browser's dependency onto the released package (§6 above). +- Re-run SQL Browser's integration/e2e suite against the released + dependency instead of the workspace. +- Remove the workspace from this repository once the above is verified. + +## 8. Rollback + +A copied-but-not-cut-over external repository can be abandoned at any point +without reversing this package's in-tree architecture — the workspace +package keeps working exactly as it does today regardless of whether an +external copy exists, was published, or was abandoned. Nothing in Phase 8's +own architecture depends on the external repository existing. diff --git a/package-lock.json b/package-lock.json index a70838dd..0a1a665f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,6 @@ "marked": "^18.0.7" }, "devDependencies": { - "@clickhouse/client-web": "1.23.1", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@playwright/test": "^1.62.1", @@ -111,13 +110,6 @@ "node": ">=18" } }, - "node_modules/@clickhouse/client-web": { - "version": "1.23.1", - "resolved": "https://registry.npmjs.org/@clickhouse/client-web/-/client-web-1.23.1.tgz", - "integrity": "sha512-VpYPSnBD7PWmEucp8wfd24u9tkyLwFH2ATyMT9R6J8b4U9snbVX1QjanJt/92YtSjam1GblhM7t3oChmJF/m0Q==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@codemirror/autocomplete": { "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", @@ -2939,7 +2931,15 @@ }, "packages/clickhouse-http": { "name": "@altinity/clickhouse-http", - "version": "0.0.0" + "version": "0.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@playwright/test": "^1.62.1", + "@vitest/coverage-v8": "^4.1.10", + "esbuild": "^0.28.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } } } } diff --git a/package.json b/package.json index bea1073f..981e1a0e 100644 --- a/package.json +++ b/package.json @@ -13,27 +13,25 @@ ], "scripts": { "prebuild": "npm run check:schemas && npm run check:examples", - "build": "node build/build.mjs", - "size-report": "node build/size-report.mjs", + "build:clickhouse-http": "npm run build --workspace @altinity/clickhouse-http", + "build": "npm run build:clickhouse-http && node build/build.mjs", + "size-report": "npm run build:clickhouse-http && node build/size-report.mjs", "check:arch": "node build/check-boundaries.mjs", "pretest": "npm run check:schemas && npm run check:examples && npm run check:arch && npm run check:types", - "test": "TZ=America/New_York vitest run --coverage --config tests/vitest.config.ts", - "test:watch": "TZ=America/New_York vitest --config tests/vitest.config.ts", + "test": "npm run build:clickhouse-http && npm run test --workspace @altinity/clickhouse-http && TZ=America/New_York vitest run --coverage --config tests/vitest.config.ts", + "test:watch": "npm run build:clickhouse-http && TZ=America/New_York vitest --config tests/vitest.config.ts", "test:e2e": "playwright test", + "test:clickhouse-http:pack": "npm run test:pack --workspace @altinity/clickhouse-http", + "test:clickhouse-http:browser": "npm run test:browser --workspace @altinity/clickhouse-http", "generate:schemas": "node build/compile-json-schemas.mjs", "check:schemas": "node build/compile-json-schemas.mjs --check", "generate:examples": "node build/compile-example-dashboards.mjs", "check:examples": "node build/compile-example-dashboards.mjs --check", - "check:types": "tsc --noEmit", - "dev": "node build/build.mjs && python3 -m http.server -d dist 8900", - "local": "node build/build.mjs && python3 build/local.py", - "test:client-spike": "TZ=America/New_York vitest run --config tests/spike/clickhouse-client/vitest.config.mjs", - "test:client-spike:matrix": "node tests/spike/clickhouse-client/run-matrix.mjs", - "test:client-spike:browser": "playwright test --config tests/spike/clickhouse-client/playwright.config.js", - "check:client-spike:evidence": "node tests/spike/clickhouse-client/validate-evidence.mjs" + "check:types": "npm run check:types --workspace @altinity/clickhouse-http && npm run build:clickhouse-http && tsc --noEmit", + "dev": "npm run build:clickhouse-http && node build/build.mjs && python3 -m http.server -d dist 8900", + "local": "npm run build:clickhouse-http && node build/build.mjs && python3 build/local.py" }, "devDependencies": { - "@clickhouse/client-web": "1.23.1", "@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", "@playwright/test": "^1.62.1", diff --git a/packages/clickhouse-http/.gitignore b/packages/clickhouse-http/.gitignore new file mode 100644 index 00000000..43cf3aa7 --- /dev/null +++ b/packages/clickhouse-http/.gitignore @@ -0,0 +1,8 @@ +/dist/ +/coverage/ +/test-results/ +/playwright-report/ +# Vitest's own cache when this package is run as vitest's root (root: +# `here` in vitest.config.ts) — not a real dependency install; every real +# dependency is hoisted to the workspace root's node_modules. +/node_modules/ diff --git a/packages/clickhouse-http/LICENSE b/packages/clickhouse-http/LICENSE new file mode 100644 index 00000000..cf677cd7 --- /dev/null +++ b/packages/clickhouse-http/LICENSE @@ -0,0 +1,17 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + Copyright 2026 Altinity, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/clickhouse-http/README.md b/packages/clickhouse-http/README.md new file mode 100644 index 00000000..260a237a --- /dev/null +++ b/packages/clickhouse-http/README.md @@ -0,0 +1,139 @@ +# @altinity/clickhouse-http + +Fetch-native ClickHouse HTTP primitives for browsers. + +## Purpose + +This package is the ONE implementation of the low-level, product-agnostic +mechanics an Altinity SQL Browser-style client needs to talk to ClickHouse's +HTTP interface from a browser: URL/query-string serialization, the raw +`fetch()`-based request, the progress-stream (`JSONStringsEachRowWithProgress`) +read loop, HTTP exception-text and late-exception-frame parsing, response +success/error classification and JSON/text/progress consumers, ClickHouse SQL +string-literal/identifier quoting, and a generic ClickHouse type-expression +grammar (parser, canonicalizer, and enum/wrapper helpers) with the shared +lexical scanner that grammar depends on. + +It was extracted from [Altinity SQL Browser](https://github.com/Altinity/altinity-sql-browser) +(issue #630) as a standalone, independently buildable/publishable unit +(issue #630 Phase 8). It currently lives inside that repository as an npm +workspace package; issue #639 covers moving it to its own repository and +publishing it, without changing its source, public API, or build/test +architecture. + +## Ownership and non-goals + +This package owns transport/protocol MECHANICS only. It deliberately does +**not** own: + +- OAuth or any other credential lifecycle (acquisition, refresh, storage); +- automatic retry — every request this package makes is exactly one `fetch()` + call, with no internal retry loop; +- a product's query registry, result-shape policy, or row-count/format + decisions; +- an ORM or query-builder API — this package quotes/serializes; it never + constructs SQL for you; +- any UI, rendering, or framework integration. + +Those responsibilities belong to the application consuming this package (in +Altinity SQL Browser's case, `src/net/**` and `src/application/**`). + +## Browser/Fetch assumptions + +Every function here is written against the standard `fetch()`/`Response`/ +`ReadableStream` Web APIs, injected rather than imported (no ambient global +reference to `fetch`, `window`, or `document`). This means the package runs +equally well in a real browser or in any Fetch-API-compatible runtime (e.g. +Node's own built-in `fetch`) — it makes no environment assumption beyond that +API surface. + +## Zero runtime dependencies + +This package declares no `dependencies` and ships none. Every dev dependency +(`esbuild`, `typescript`, `vitest`, `@playwright/test`, `@vitest/coverage-v8`) +is build/test tooling only — none of it is bundled into `dist/**`. + +## Public contract: ESM, one export + +The package exposes exactly one public entry point, `"."`, resolving to +`dist/index.js` (runtime) and `dist/index.d.ts` (types). There is no other +public subpath — importing `@altinity/clickhouse-http/client` or any other +internal module path is unsupported and, in the consuming SQL Browser +repository, mechanically forbidden (`build/check-boundaries.mjs`). + +## Public API groups + +- **Transport/protocol** — `createClickHouseHttpClient`, `chUrl`, + `streamLines`, `parseExceptionText`, `findExceptionFrame`, + `ClickHouseError`, `ensureClickHouseSuccess`, `consumeJsonResponse`, + `consumeTextResponse`, `consumeProgressResponse`, plus their request/result + types (`ClickHouseHttpClientDeps`, `ClickHouseHttpRequest`, + `ClickHouseJsonRequest`, `ClickHouseKillQueryRequest`, + `ClickHouseHttpClient`, `ChUrlOpts`, `StreamLine`, `StreamCallbacks`, + `ProgressMetaColumn`, `ExceptionFrame`). +- **ClickHouse SQL language** — `sqlString`, `quoteIdent`, `qualifyIdent`, + `scanSpans` (`Span`/`SpanKind`), `parseClickHouseType` and its + wrapper/enum/canonicalization helpers (`unwrapNullable`, + `unwrapLowCardinality`, `unwrapValueTransparentWrappers`, + `analyzeTypeModifiers`, `typeBaseName`, `arrayElement`, `mapTypes`, + `namedTupleMembers`, `enumMembers`, `enumValues`, `canonicalType`, and their + types `LiteralArg`/`TypeArg`/`TypeNode`/`EnumMember`/`TypeModifiers`). + +## `dist/**` shape + +`npm run build` produces unbundled, browser-first ESM: one `.js` file per +source module under `dist/`, mirroring `src/**` exactly (`bundle: false`), plus +a matching `.d.ts` declaration file per module. Nothing under `dist/**` is +minified or tree-shaken — a consumer's own bundler (in Altinity SQL Browser's +case, the root `esbuild` build) does that. + +## Package-local commands + +Run these from this directory, or with `--workspace @altinity/clickhouse-http` +from the SQL Browser repository root: + +```sh +npm run build # esbuild -> dist/**/*.js, then tsc -> dist/**/*.d.ts +npm run check:types # strict package-local typecheck (no emit) +npm test # package-local unit tests + coverage (100/95/90/100 per file) +npm run test:pack # build, npm pack, install the tarball outside this repo, prove + # it resolves/typechecks with no source fallback +npm run test:browser # Chromium + WebKit regression suite against the built dist/** +``` + +## Publication state + +This package is currently `"private": true` at version `"0.0.0"`. It has +never been published to any npm registry. `npm run test:pack` proves the +package is publication-SHAPED (packs into a tarball containing only +`dist/**`, `README.md`, `LICENSE`, and `package.json`, installs cleanly +outside this repository, and resolves/typechecks with no fallback into this +repository's source) — it does not publish anything, and choosing the first +externally released version is deliberately out of this scope (issue #639). + +## Import examples + +These examples describe the package's shape; they do not imply it is +available from any public registry yet. + +```js +import { createClickHouseHttpClient, chUrl } from '@altinity/clickhouse-http'; + +const client = createClickHouseHttpClient({ + fetch: () => fetch, + origin: () => 'https://your-clickhouse-host', +}); + +const response = await client.request({ + sql: 'SELECT 1', + defaultFormat: 'JSON', + authorization: 'Bearer ', +}); +``` + +```ts +import { parseClickHouseType, sqlString } from '@altinity/clickhouse-http'; + +const type = parseClickHouseType('Nullable(LowCardinality(String))'); +const literal = sqlString("O'Brien"); // "'O''Brien'" +``` diff --git a/packages/clickhouse-http/build.mjs b/packages/clickhouse-http/build.mjs new file mode 100644 index 00000000..468d0b1e --- /dev/null +++ b/packages/clickhouse-http/build.mjs @@ -0,0 +1,72 @@ +// Package-local runtime-JS build for @altinity/clickhouse-http (#630 Phase +// 8, plan §4.1/§7). Produces unbundled, browser-first ESM: every module under +// src/**/*.ts compiles independently (bundle: false) into dist/**/*.js, +// mirroring the source tree exactly (outbase: src, outdir: dist) — root +// esbuild (build/build.mjs) remains the ONE place that bundles/tree-shakes +// the final SQL Browser artifact; this script's job is only to make the +// package's own public surface independently resolvable as real files on +// disk, the shape an extracted/published package would ship. +// +// TypeScript declaration emission is a SEPARATE step (package.json's "build" +// script runs `tsc -p ./tsconfig.build.json` right after this), so this file +// never touches .d.ts output — see tsconfig.build.json. +// +// No root build/**, root tsconfig, root tests, or root source import: this +// script is entirely package-local, exactly as a mechanically extracted copy +// of packages/clickhouse-http/ would need it to be (#639). + +import { build } from 'esbuild'; +import { readdir, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const srcDir = resolve(here, 'src'); +const outDir = resolve(here, 'dist'); + +// Enumerate every source module explicitly (bundle: false requires one entry +// point per module — esbuild does not itself recurse a whole directory as a +// single "compile everything" input) rather than relying on any implicit +// discovery. +async function collectSourceFiles(dir) { + const out = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = resolve(dir, entry.name); + if (entry.isDirectory()) out.push(...await collectSourceFiles(full)); + else if (entry.name.endsWith('.ts')) out.push(full); + } + return out; +} + +async function main() { + await rm(outDir, { recursive: true, force: true }); + + const entryPoints = await collectSourceFiles(srcDir); + if (entryPoints.length === 0) { + throw new Error('@altinity/clickhouse-http build: no source files found under src/**'); + } + + await build({ + entryPoints, + bundle: false, + platform: 'browser', + format: 'esm', + target: 'es2020', + outbase: srcDir, + outdir: outDir, + minify: false, + logLevel: 'info', + }); + + const indexOutput = resolve(outDir, 'index.js'); + if (!existsSync(indexOutput)) { + throw new Error(`@altinity/clickhouse-http build: expected ${indexOutput} to exist after build`); + } + console.log(`@altinity/clickhouse-http: built ${entryPoints.length} module(s) -> dist/`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/clickhouse-http/package.json b/packages/clickhouse-http/package.json index 6e60f1f2..512d8187 100644 --- a/packages/clickhouse-http/package.json +++ b/packages/clickhouse-http/package.json @@ -2,8 +2,35 @@ "name": "@altinity/clickhouse-http", "version": "0.0.0", "private": true, + "description": "Fetch-native ClickHouse HTTP primitives for browsers", "type": "module", + "license": "Apache-2.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { - ".": "./src/index.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "node ./build.mjs && tsc -p ./tsconfig.build.json", + "check:types": "tsc -p ./tsconfig.json --noEmit", + "test": "TZ=America/New_York vitest run --coverage --config ./vitest.config.ts", + "test:pack": "npm run build && node ./test/isolated-package.mjs", + "test:browser": "npm run build && playwright test --config ./test/browser/playwright.config.js" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@vitest/coverage-v8": "^4.1.10", + "esbuild": "^0.28.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10" } } diff --git a/tests/spike/clickhouse-client/fault-server.mjs b/packages/clickhouse-http/test/browser/fault-server.mjs similarity index 94% rename from tests/spike/clickhouse-client/fault-server.mjs rename to packages/clickhouse-http/test/browser/fault-server.mjs index 9a6670ea..48d69acc 100644 --- a/tests/spike/clickhouse-client/fault-server.mjs +++ b/packages/clickhouse-http/test/browser/fault-server.mjs @@ -1,17 +1,28 @@ -// Phase 0 / issue #585, plan §15 "Deterministic protocol-fault server". +// Originally issue #585 Phase 0 (plan §15 "Deterministic protocol-fault +// server"), moved to this package by issue #630 Phase 8 (plan §15 "Reuse +// fault-server.mjs"): it is generic, dependency-free, deterministic HTTP +// fixture infrastructure with no vendor-specific logic at all, so it belongs +// to `@altinity/clickhouse-http`'s own first-party browser regression suite +// (`test/browser/regression.spec.js`) now, not the retired vendor-comparison +// spike. // // A tiny, dependency-free Node `http` server exposing named fixture routes, -// each with a fully deterministic byte sequence and timing — so the parity -// harness can prove exact adapter behavior (progressive first row, mid-stream -// exception detection, malformed/truncated handling, auth retry, raw byte -// exactness) WITHOUT depending on real ClickHouse timing or scheduling. -// Every fixture's expected outcome is declared independently in -// `scenarios.ts`/`precision-corpus.ts` next to the fixture name — this file -// only produces bytes, it never asserts. +// each with a fully deterministic byte sequence and timing — so a real-browser +// regression suite can prove exact transport behavior (progressive first row, +// mid-stream exception detection, malformed/truncated handling, auth retry, +// raw byte exactness, cancellation) WITHOUT depending on real ClickHouse +// timing or scheduling. This file only produces bytes, it never asserts. // -// Kept as plain `.mjs` (not `.ts`) per plan §8: Node orchestration/ -// configuration files stay untyped so the spike doesn't force an unrelated -// repository-wide `@types/node` decision. +// Root SQL Browser browser integration (`tests/e2e/export-post-header- +// cancel.spec.js`, `tests/e2e/authenticated-clickhouse-request.spec.js`) also +// imports this package-owned fixture directly while the workspace exists — +// #639's extraction handoff lists that consumer-side path as one to update +// when the workspace is removed (`docs/clickhouse-http-repository- +// extraction.md`). +// +// Kept as plain `.mjs` (not `.ts`): Node orchestration/configuration files +// stay untyped so this package doesn't need a `@types/node` devDependency +// just for this one fixture server. import { createServer } from 'node:http'; diff --git a/packages/clickhouse-http/test/browser/harness.html b/packages/clickhouse-http/test/browser/harness.html new file mode 100644 index 00000000..3b32274f --- /dev/null +++ b/packages/clickhouse-http/test/browser/harness.html @@ -0,0 +1,369 @@ + + + + + @altinity/clickhouse-http — real-browser regression harness + + + + + + diff --git a/packages/clickhouse-http/test/browser/playwright.config.js b/packages/clickhouse-http/test/browser/playwright.config.js new file mode 100644 index 00000000..92fc8c35 --- /dev/null +++ b/packages/clickhouse-http/test/browser/playwright.config.js @@ -0,0 +1,38 @@ +import { defineConfig } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(here, '..', '..'); + +// Issue #630 Phase 8 (plan §15) — this package's own first-party Chromium/ +// WebKit regression suite over the BUILT public barrel: `server.mjs` serves +// this directory's `harness.html` and the package's generated `dist/**` — +// never source, never a vendor client, never Docker/live ClickHouse. Run via +// `npm run test:browser` (which builds first) — or, from the repository +// root, `npm run test:clickhouse-http:browser`. +export default defineConfig({ + testDir: here, + testMatch: '**/*.spec.js', + webServer: { + command: `node ${resolve(here, 'server.mjs')} 5601`, + url: 'http://127.0.0.1:5601/harness.html', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + cwd: packageRoot, + }, + use: { + baseURL: 'http://127.0.0.1:5601', + }, + // No Docker, no live ClickHouse, no vendor client — deterministic local + // Node HTTP fixtures only (fault-server.mjs). Chromium and WebKit are this + // suite's acceptance engines, matching the issue's own requirement; no + // Firefox project (Firefox cannot launch locally in this repository's + // sandbox, and root CI already supplies Firefox coverage for the SQL + // Browser suite — this package suite's own acceptance is Chromium+WebKit + // by design, not by omission). + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + ], +}); diff --git a/tests/e2e/clickhouse-http-transport.spec.js b/packages/clickhouse-http/test/browser/regression.spec.js similarity index 57% rename from tests/e2e/clickhouse-http-transport.spec.js rename to packages/clickhouse-http/test/browser/regression.spec.js index 4aec0787..24b79d93 100644 --- a/tests/e2e/clickhouse-http-transport.spec.js +++ b/packages/clickhouse-http/test/browser/regression.spec.js @@ -1,34 +1,26 @@ import { randomUUID } from 'node:crypto'; import { test, expect } from '@playwright/test'; -import { startFaultServer, POST_HEADER_ABORT_HOLD_MS } from '../spike/clickhouse-client/fault-server.mjs'; - -// #630 Phase 1 — freezes native Fetch/Response/cancellation semantics, in -// real Chromium/WebKit, against a real cross-origin HTTP server (the shared -// spike fault server, started here in explicit browser/CORS mode). This spec -// owns the fault server's Node-side lifecycle: the root Playwright config -// only starts build/e2e-serve.mjs (the static/raw-ESM host on :5599) — it -// knows nothing about this ephemeral fixture server. Firefox cannot launch -// locally (repo-wide constraint); Chromium and WebKit are this phase's real -// acceptance signal, exactly as the plan requires. +import { startFaultServer, POST_HEADER_ABORT_HOLD_MS } from './fault-server.mjs'; + +// Issue #630 Phase 8 — this package's own first-party Chromium/WebKit +// regression suite, ported (not merely referenced) from the SQL Browser root +// e2e suite's former `tests/e2e/clickhouse-http-transport.spec.js` (issue +// #630 Phases 1/2/3/4/7): package construction, deterministic +// request/Response fidelity, the progress/chunk cancellation family, +// concurrent-request isolation, and raw invalid-UTF-8 byte fidelity — driven +// directly against `createClickHouseHttpClient(...)`/`streamLines()` from +// the package's BUILT public barrel (`harness.html` imports `/dist/index.js` +// directly, served by this suite's own `server.mjs`), with no SQL Browser +// authentication/epoch/lifecycle composition at all. The authenticated +// variants of the post-header family (formerly Scenarios 5-9's `*Auth` +// siblings) stay a root SQL Browser integration test — +// `tests/e2e/authenticated-clickhouse-request.spec.js`. // -// #630 Phase 7 — the harness (`clickhouse-http-transport.html`) no longer -// imports the retired SQL Browser compatibility transport adapter -// (`src/net/clickhouse-http-transport.ts`, deleted this phase). The generic -// scenarios below (1-8, plus invalid-UTF-8) now drive the package's OWN -// `createClickHouseHttpClient(...).request()` directly; the auth/lifecycle -// scenarios (the `*Auth` variants) continue to drive -// `src/net/authenticated-clickhouse-request.ts`'s `authenticatedRequest()`/ -// `authenticatedProgress()`, unchanged. Every original behavioral assertion -// below is preserved byte-for-byte — only the harness's internal transport -// indirection changed. Scenario 9 remains `queryProgress()` coverage, not -// export coverage. - -test.describe('#630 Phase 1 — native Fetch/Response/cancellation characterization', () => { - test.skip( - ({ browserName }) => browserName === 'firefox', - '#630 Phase 1 acceptance is explicitly Chromium/WebKit', - ); +// Firefox cannot launch locally in this repository's sandbox; Chromium and +// WebKit are this suite's real acceptance signal, matching the issue's own +// requirement for this package's regressions. +test.describe('@altinity/clickhouse-http — native Fetch/Response/cancellation regressions', () => { /** @type {Awaited>} */ let fault; @@ -37,15 +29,11 @@ test.describe('#630 Phase 1 — native Fetch/Response/cancellation characterizat }); test.afterAll(async () => { - // Playwright still runs a describe's afterAll even when every test in it - // was test.skip()-ed (Firefox here) — but in that case beforeAll never - // ran, so `fault` is still undefined. Guard rather than let a skipped - // Firefox run fail on an unrelated hook error. await fault?.close(); }); test.beforeEach(async ({ page }) => { - await page.goto('/tests/e2e/clickhouse-http-transport.html'); + await page.goto('/harness.html'); await page.waitForFunction(() => window.__ready === true); }); @@ -194,7 +182,7 @@ test.describe('#630 Phase 1 — native Fetch/Response/cancellation characterizat expect(result.chunksAfter).toBe(result.chunksBefore); }); - test('Scenario 9 (#630 Phase 4) — package client.queryProgress() emits no callbacks after observable cancellation, one Fetch', async ({ page }, testInfo) => { + test('Scenario 9 — package client.queryProgress() emits no callbacks after observable cancellation, one Fetch', async ({ page }, testInfo) => { test.setTimeout(30_000); const queryId = qid('post-header-abort-hold', testInfo.project.name); const result = await page.evaluate( @@ -212,93 +200,6 @@ test.describe('#630 Phase 1 — native Fetch/Response/cancellation characterizat expect(result.chunksAfterWait).toBe(result.chunksAtRejection); }); - // #630 Phase 6 — authenticated-path variants of the post-header - // cancellation family (5-9): the same native Fetch/Response/cancellation - // semantics, now driven through SQL Browser's own credential/epoch - // composition (`authenticatedRequest`/`authenticatedProgress`, - // `/src/net/authenticated-clickhouse-request.js`) rather than the - // compatibility transport/package client with an already-resolved - // Authorization. Scenarios 1-4 (pre-header timing) stay raw-only per the - // plan. - test('Scenario 5 auth variant — native post-header body lifetime through authenticatedRequest()', async ({ page }, testInfo) => { - const queryId = qid('post-header-abort-hold', testInfo.project.name); - const start = await page.evaluate( - ({ baseUrl, queryId }) => window.__scenario5AuthStart(baseUrl, queryId), - { baseUrl: fault.baseUrl, queryId }, - ); - expect(start.identity).toBe(true); - expect(start.bodyUsedBeforeRead).toBe(false); - expect(start.firstDone).toBe(false); - expect(start.firstText).toContain('first'); - expect(start.count).toBe(1); - expect(start.authorization).toBe('Bearer auth-e2e-test-token'); - - const after = await page.evaluate(() => window.__scenario5AuthAbortAndReadNext()); - expect(after.rejectedName).toBe('AbortError'); - // The already-settled authenticatedRequest() Response is untouched by - // the later abort — no already-errored synthetic stream, no status/ok - // mutation, and (invariant 10) abort must never report offline. - expect(after.sendResponseStatus).toBe(200); - expect(after.sendResponseOk).toBe(true); - }); - - test('Scenario 6 auth variant — package streamLines() through authenticatedRequest() emits no callbacks after observable cancellation', async ({ page }, testInfo) => { - test.setTimeout(30_000); - const queryId = qid('post-header-abort-hold', testInfo.project.name); - const result = await page.evaluate( - ({ baseUrl, queryId, holdMs }) => window.__scenario6Auth(baseUrl, queryId, holdMs), - { baseUrl: fault.baseUrl, queryId, holdMs: POST_HEADER_ABORT_HOLD_MS }, - ); - expect(result.rejectedName).toBe('AbortError'); - expect(result.chunksAtRejection).toBeGreaterThanOrEqual(1); - expect(result.linesAfterWait).toBe(result.linesAtRejection); - expect(result.chunksAfterWait).toBe(result.chunksAtRejection); - expect(result.offlineCallsAfterAbort).toBe(0); - }); - - test('Scenario 7 auth variant — cancellation of one concurrent authenticated request cannot affect another sharing the same ctx', async ({ page }, testInfo) => { - test.setTimeout(30_000); - const queryIdA = qid('post-header-abort-hold', testInfo.project.name); - const queryIdB = qid('post-header-abort-hold', testInfo.project.name); - const result = await page.evaluate( - ({ baseUrl, queryIdA, queryIdB, holdMs }) => window.__scenario7Auth(baseUrl, queryIdA, queryIdB, holdMs), - { baseUrl: fault.baseUrl, queryIdA, queryIdB, holdMs: POST_HEADER_ABORT_HOLD_MS }, - ); - expect(result.aRejectedName).toBe('AbortError'); - expect(result.bFirstHeldDone).toBe(false); - expect(result.bFirstHeldText).toContain('after-hold'); - expect(result.bCompletedCleanly).toBe(true); - }); - - test('Scenario 8 auth variant — abort after full body completion through authenticatedRequest() has no effect', async ({ page }, testInfo) => { - const queryId = qid('ordinary-query', testInfo.project.name); - const result = await page.evaluate( - ({ baseUrl, queryId }) => window.__scenario8Auth(baseUrl, queryId), - { baseUrl: fault.baseUrl, queryId }, - ); - expect(result.linesBefore).toBeGreaterThan(0); - expect(result.abortThrew).toBe(false); - expect(result.linesAfter).toBe(result.linesBefore); - expect(result.chunksAfter).toBe(result.chunksBefore); - }); - - test('Scenario 9 auth variant (#630 Phase 6) — authenticatedProgress() emits no callbacks after observable cancellation, one Fetch', async ({ page }, testInfo) => { - test.setTimeout(30_000); - const queryId = qid('post-header-abort-hold', testInfo.project.name); - const result = await page.evaluate( - ({ baseUrl, queryId, holdMs }) => window.__scenario9Auth(baseUrl, queryId, holdMs), - { baseUrl: fault.baseUrl, queryId, holdMs: POST_HEADER_ABORT_HOLD_MS }, - ); - expect(result.rejectedName).toBe('AbortError'); - expect(result.chunksAtRejection).toBeGreaterThanOrEqual(1); - expect(result.countAtRejection).toBe(1); - // One real Fetch throughout — no retry, before or after the wait. - expect(result.countAfterWait).toBe(1); - expect(result.linesAfterWait).toBe(result.linesAtRejection); - expect(result.chunksAfterWait).toBe(result.chunksAtRejection); - expect(result.authorization).toBe('Bearer auth-e2e-test-token'); - }); - test('Extra — invalid UTF-8 raw bytes remain byte-identical at the native boundary', async ({ page }, testInfo) => { const queryId = qid('invalid-utf8-raw', testInfo.project.name); const result = await page.evaluate( diff --git a/packages/clickhouse-http/test/browser/server.mjs b/packages/clickhouse-http/test/browser/server.mjs new file mode 100644 index 00000000..85428a1e --- /dev/null +++ b/packages/clickhouse-http/test/browser/server.mjs @@ -0,0 +1,63 @@ +// Issue #630 Phase 8 (plan §15) — a tiny, dependency-free static file server +// for this package's OWN Chromium/WebKit regression harness. It serves +// exactly two things, both real files on disk (never a bundler, never an +// import map, never source): this directory's `harness.html`, and the +// package's generated `dist/**` (built by `npm run build` — `test:browser`'s +// own npm script runs that first) — proving the harness genuinely loads the +// BUILT public barrel a real external consumer would get, at `/dist/index.js`. +// +// Usage: `node server.mjs ` (playwright.config.js passes the port). + +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, extname, join, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(here, '..', '..'); +const distDir = join(packageRoot, 'dist'); + +const CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', +}; + +function contentTypeFor(path) { + return CONTENT_TYPES[extname(path)] ?? 'application/octet-stream'; +} + +async function resolveFile(pathname) { + if (pathname === '/' || pathname === '/harness.html') { + return join(here, 'harness.html'); + } + if (pathname.startsWith('/dist/')) { + return join(distDir, pathname.slice('/dist/'.length)); + } + return null; +} + +const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + const filePath = await resolveFile(url.pathname); + if (!filePath) { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found: ' + url.pathname); + return; + } + try { + const body = await readFile(filePath); + res.writeHead(200, { 'content-type': contentTypeFor(filePath) }); + res.end(body); + } catch { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found: ' + url.pathname); + } +}); + +const port = Number(process.argv[2]) || 0; +server.listen(port, '127.0.0.1', () => { + const { port: boundPort } = server.address(); + console.log(`@altinity/clickhouse-http browser harness server listening on http://127.0.0.1:${boundPort}`); +}); diff --git a/packages/clickhouse-http/test/isolated-package.mjs b/packages/clickhouse-http/test/isolated-package.mjs new file mode 100644 index 00000000..ce656acb --- /dev/null +++ b/packages/clickhouse-http/test/isolated-package.mjs @@ -0,0 +1,274 @@ +// Issue #630 Phase 8 (plan §14) — the deterministic ISOLATED-PACKAGE proof: +// build the package, pack it with real `npm pack`, install the tarball into +// a fixture OUTSIDE this repository, import it as ESM, and compile a +// TypeScript consumer against its declarations — proving resolution never +// falls back into this repository's source (A17's own definition of done). +// Run via `npm run test:pack` (which builds first). Every step below is a +// REAL runnable check, not prose: this file is what +// `docs/clickhouse-http-repository-extraction.md` documents as "tested". +// +// No root build/**, root tsconfig, root tests, or root source import — this +// script is entirely package-local, exactly as a mechanically extracted copy +// of packages/clickhouse-http/ would need it to be (#639). + +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdtemp, mkdir, readFile, rm, writeFile, readdir } from 'node:fs/promises'; +import { existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve, sep } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(here, '..'); +const distDir = join(packageRoot, 'dist'); + +const failures = []; +function assert(condition, message) { + if (!condition) failures.push(message); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +// Recursively list every file under `dir`, relative to `dir`, forward-slash +// separated — used both for the extracted tarball inventory and the +// installed-package containment check. +async function listFilesRelative(dir) { + const out = []; + async function walk(sub) { + for (const entry of await readdir(join(dir, sub), { withFileTypes: true })) { + const rel = sub ? `${sub}/${entry.name}` : entry.name; + if (entry.isDirectory()) await walk(rel); + else out.push(rel.split(sep).join('/')); + } + } + await walk(''); + return out; +} + +// ── §14.1 — build prerequisites ───────────────────────────────────────────── + +function checkBuildPrerequisites() { + for (const required of ['dist/index.js', 'dist/index.d.ts', 'README.md', 'LICENSE']) { + assert(existsSync(join(packageRoot, required)), `build prerequisite missing: ${required}`); + } + const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); + assert(pkg.main === './dist/index.js', `package.json main must target dist, got ${pkg.main}`); + assert(pkg.types === './dist/index.d.ts', `package.json types must target dist, got ${pkg.types}`); + const exp = pkg.exports?.['.']; + assert(exp && exp.types === './dist/index.d.ts', 'exports["."].types must target dist/index.d.ts'); + assert(exp && exp.import === './dist/index.js', 'exports["."].import must target dist/index.js'); + assert(exp && exp.default === './dist/index.js', 'exports["."].default must target dist/index.js'); + assert(Object.keys(pkg.exports ?? {}).length === 1, 'package.json exports must expose exactly one entry, "."'); +} + +// ── §14.2 — real npm pack ──────────────────────────────────────────────────── + +async function runNpmPack(packDestination) { + const output = execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', packDestination], { + cwd: packageRoot, + encoding: 'utf8', + }); + const [entry] = JSON.parse(output); + return join(packDestination, entry.filename); +} + +// ── §14.3 — tarball inventory ──────────────────────────────────────────────── + +async function extractTarball(tarballPath, extractDir) { + await mkdir(extractDir, { recursive: true }); + execFileSync('tar', ['-xzf', tarballPath, '-C', extractDir]); + // npm packs everything under a top-level "package/" directory. + return join(extractDir, 'package'); +} + +async function checkTarballInventory(packageDir) { + const files = await listFilesRelative(packageDir); + + for (const required of ['package.json', 'README.md', 'LICENSE', 'dist/index.js', 'dist/index.d.ts']) { + assert(files.includes(required), `tarball missing required file: ${required}`); + } + + const forbiddenPrefixes = ['src/', 'test/', 'coverage/']; + const forbiddenExact = ['build.mjs']; + for (const file of files) { + if (forbiddenExact.includes(file)) failures.push(`tarball must not contain: ${file}`); + if (forbiddenPrefixes.some((p) => file.startsWith(p))) failures.push(`tarball must not contain: ${file}`); + if (/^tsconfig.*\.json$/.test(file)) failures.push(`tarball must not contain: ${file}`); + if (/^vitest\.config\./.test(file)) failures.push(`tarball must not contain: ${file}`); + if (file.startsWith('dist/')) { + assert(/\.(js|d\.ts)$/.test(file), `tarball dist/** payload must be only .js/.d.ts, found: ${file}`); + } else { + assert(['package.json', 'README.md', 'LICENSE'].includes(file), `unexpected tarball top-level file: ${file}`); + } + } + // No SQL Browser src/** and no parent/workspace path can appear — the + // extracted directory tree itself proves this: every listed path is + // relative to the tarball's own package/ root, so an escape would need a + // literal path SEGMENT naming it, which the prefix checks above already + // cover for src/ specifically; nothing here can spell a parent path at + // all, since tar extraction cannot produce a `../` entry name. + + const pkg = await readJson(join(packageDir, 'package.json')); + assert(Object.keys(pkg.exports ?? {}).length === 1 && pkg.exports['.'], 'packed manifest must expose only "."'); + assert(Object.keys(pkg.dependencies ?? {}).length === 0, 'packed manifest must carry no runtime dependency map'); + assert(pkg.main === './dist/index.js', 'packed manifest main must target dist/index.js'); + assert(pkg.types === './dist/index.d.ts', 'packed manifest types must target dist/index.d.ts'); +} + +// ── §14.4 — isolated install ───────────────────────────────────────────────── + +async function installIsolated(fixtureDir, tarballPath) { + await mkdir(fixtureDir, { recursive: true }); + await writeFile(join(fixtureDir, 'package.json'), JSON.stringify({ private: true, type: 'module' }, null, 2)); + execFileSync('npm', [ + 'install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', '--no-package-lock', '--no-save', + tarballPath, + ], { + cwd: fixtureDir, + env: { ...process.env, NODE_PATH: '' }, + }); + const installedDir = join(fixtureDir, 'node_modules/@altinity/clickhouse-http'); + assert(existsSync(installedDir), 'installed package directory missing'); + const installedFiles = existsSync(installedDir) ? await listFilesRelative(installedDir) : []; + assert( + !installedFiles.some((f) => f.startsWith('src/') || f === 'src'), + 'installed package must contain no src/', + ); + return installedDir; +} + +// ── §14.5 — ESM proof ──────────────────────────────────────────────────────── + +async function writeConsumerEsm(fixtureDir) { + await writeFile(join(fixtureDir, 'consumer.mjs'), ` +import { chUrl, createClickHouseHttpClient, parseClickHouseType } from '@altinity/clickhouse-http'; +if (typeof chUrl !== 'function') throw new Error('chUrl did not import as a function'); +if (typeof createClickHouseHttpClient !== 'function') throw new Error('createClickHouseHttpClient did not import as a function'); +if (typeof parseClickHouseType !== 'function') throw new Error('parseClickHouseType did not import as a function'); +const resolved = import.meta.resolve('@altinity/clickhouse-http'); +console.log('RESOLVED:' + resolved); +console.log('OK'); +`.trimStart()); +} + +async function runConsumerEsm(fixtureDir) { + const output = execFileSync(process.execPath, ['consumer.mjs'], { cwd: fixtureDir, encoding: 'utf8' }); + assert(output.includes('OK'), `consumer.mjs did not report OK, got: ${output}`); + const resolvedLine = output.split('\n').find((l) => l.startsWith('RESOLVED:')); + const resolvedPath = resolvedLine ? resolvedLine.slice('RESOLVED:'.length).trim() : ''; + const resolvedFsPath = resolvedPath.startsWith('file://') ? fileURLToPath(resolvedPath) : resolvedPath; + const expected = join(fixtureDir, 'node_modules/@altinity/clickhouse-http/dist/index.js'); + assert(resolvedFsPath === expected, `import.meta.resolve must terminate at ${expected}, got ${resolvedFsPath}`); + assert(!resolvedFsPath.includes(`${sep}src${sep}`), 'runtime resolution must never reach package src/**'); + assert(!resolvedFsPath.startsWith(packageRoot), 'runtime resolution must never reach this repository at all'); +} + +// ── §14.6 — TypeScript declaration proof ──────────────────────────────────── + +async function writeConsumerTs(fixtureDir) { + await writeFile(join(fixtureDir, 'consumer.ts'), ` +import { chUrl, createClickHouseHttpClient, parseClickHouseType } from '@altinity/clickhouse-http'; +import type { ClickHouseHttpClient, TypeNode } from '@altinity/clickhouse-http'; +export function useIt(client: ClickHouseHttpClient, node: TypeNode): string { + void client; + void node; + return chUrl('https://example') + typeof createClickHouseHttpClient + typeof parseClickHouseType; +} +`.trimStart()); + await writeFile(join(fixtureDir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + module: 'NodeNext', + moduleResolution: 'NodeNext', + noEmit: true, + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + strict: true, + skipLibCheck: false, + }, + include: ['consumer.ts'], + }, null, 2)); +} + +function resolveTscBinary() { + const require = createRequire(import.meta.url); + const typescriptPkgJson = require.resolve('typescript/package.json'); + return join(dirname(typescriptPkgJson), 'bin/tsc'); +} + +async function runConsumerTs(fixtureDir) { + const tscBin = resolveTscBinary(); + let output; + let threw = false; + try { + output = execFileSync(process.execPath, [tscBin, '-p', 'tsconfig.json', '--traceResolution'], { + cwd: fixtureDir, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + } catch (e) { + threw = true; + output = `${e.stdout ?? ''}${e.stderr ?? ''}`; + } + assert(!threw, `tsc failed to compile the isolated consumer:\n${output.slice(-4000)}`); + + const expectedDts = join(fixtureDir, 'node_modules/@altinity/clickhouse-http/dist/index.d.ts'); + const lines = output.split('\n'); + const moduleLines = lines.filter((l) => l.includes('@altinity/clickhouse-http')); + assert(moduleLines.length > 0, 'expected --traceResolution output to mention @altinity/clickhouse-http at all'); + + const resolvedLines = moduleLines.filter((l) => /Resolution for module/.test(l) || l.includes("'") && l.includes('.d.ts')); + const foundExpected = lines.some((l) => l.includes(expectedDts)); + assert(foundExpected, `--traceResolution must terminate at ${expectedDts}`); + + const forbiddenPackageSrc = join(packageRoot, 'src'); + const forbiddenRepoSrc = resolve(packageRoot, '../../src'); + assert( + !lines.some((l) => l.includes(forbiddenPackageSrc) && l.includes('@altinity/clickhouse-http')), + 'type resolution must never reach packages/clickhouse-http/src/**', + ); + assert( + !lines.some((l) => l.includes(forbiddenRepoSrc) && l.includes('@altinity/clickhouse-http')), + 'type resolution must never reach the SQL Browser repository src/**', + ); + void resolvedLines; // retained for future stricter assertions if needed +} + +// ── main ───────────────────────────────────────────────────────────────────── + +async function main() { + checkBuildPrerequisites(); + + const packDestination = await mkdtemp(join(tmpdir(), 'asb-clickhouse-http-pack-')); + const extractDir = await mkdtemp(join(tmpdir(), 'asb-clickhouse-http-extract-')); + const fixtureDir = await mkdtemp(join(tmpdir(), 'asb-clickhouse-http-consumer-')); + + try { + const tarballPath = await runNpmPack(packDestination); + const extractedPackageDir = await extractTarball(tarballPath, extractDir); + await checkTarballInventory(extractedPackageDir); + + await installIsolated(fixtureDir, tarballPath); + await writeConsumerEsm(fixtureDir); + await runConsumerEsm(fixtureDir); + await writeConsumerTs(fixtureDir); + await runConsumerTs(fixtureDir); + } finally { + await rm(packDestination, { recursive: true, force: true }); + await rm(extractDir, { recursive: true, force: true }); + await rm(fixtureDir, { recursive: true, force: true }); + } + + if (failures.length) { + console.error('isolated-package: FAIL'); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); + } + console.log('isolated-package: OK — packed, installed outside the workspace, imported as ESM, and typechecked with no source fallback.'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/unit/clickhouse-http-type.test.ts b/packages/clickhouse-http/test/unit/clickhouse-type.test.ts similarity index 99% rename from tests/unit/clickhouse-http-type.test.ts rename to packages/clickhouse-http/test/unit/clickhouse-type.test.ts index b2cb1aa0..56ebe696 100644 --- a/tests/unit/clickhouse-http-type.test.ts +++ b/packages/clickhouse-http/test/unit/clickhouse-type.test.ts @@ -9,8 +9,8 @@ import { analyzeTypeModifiers, arrayElement, canonicalType, enumMembers, enumValues, mapTypes, namedTupleMembers, parseClickHouseType, typeBaseName, unwrapLowCardinality, unwrapNullable, unwrapValueTransparentWrappers, -} from '@altinity/clickhouse-http'; -import type { LiteralArg, TypeNode } from '@altinity/clickhouse-http'; +} from '../../src/index.js'; +import type { LiteralArg, TypeNode } from '../../src/index.js'; describe('parseClickHouseType — parser', () => { it('rejects empty and whitespace-only input', () => { diff --git a/tests/unit/clickhouse-http-package.test.ts b/packages/clickhouse-http/test/unit/client.test.ts similarity index 94% rename from tests/unit/clickhouse-http-package.test.ts rename to packages/clickhouse-http/test/unit/client.test.ts index 28a07fa1..a46264b5 100644 --- a/tests/unit/clickhouse-http-package.test.ts +++ b/packages/clickhouse-http/test/unit/client.test.ts @@ -1,15 +1,28 @@ import { describe, expect, it, vi } from 'vitest'; -import { chUrl, createClickHouseHttpClient, ClickHouseError } from '@altinity/clickhouse-http'; -import type { StreamLine } from '@altinity/clickhouse-http'; -import { runTransportContractSuite } from './clickhouse-transport-contract.js'; +import { chUrl, createClickHouseHttpClient, ClickHouseError } from '../../src/index.js'; +import type { StreamLine } from '../../src/index.js'; +import { runTransportContractSuite } from './request-contract.js'; // Issue #630 Phase 2 — direct spec for the new @altinity/clickhouse-http -// package, consumed exclusively through its public package name (contract -// A4) — never a relative/deep import into its own src/**. The package units -// under test (`chUrl`, `createClickHouseHttpClient`) come from the package's -// public export; the shared contract-suite factory is separately imported -// test infrastructure, which does not itself violate A4 (see the Phase 2 -// plan §11's "package-test import wording" note). +// package, consumed exclusively through its public barrel — `src/index.ts`, +// contract A4's "." export target — never a deep import into a private +// implementation module (client.ts/url.ts/etc.) directly. The package units +// under test (`chUrl`, `createClickHouseHttpClient`) come from that barrel; +// the shared contract-suite factory is separately imported test +// infrastructure, which does not itself violate A4 (see the Phase 2 plan +// §11's "package-test import wording" note). +// +// Issue #630 Phase 8 — this suite (and its siblings in this directory) now +// imports the barrel via a RELATIVE path (`../../src/index.js`) rather than +// the bare package name: once the manifest's public "." export points at +// built `dist/index.js` (plan §5), the bare specifier no longer resolves to +// TypeScript source at all, so v8 coverage could never attribute execution +// back to `src/**.ts` (this package's own coverage.include, plan §8) through +// it. The relative import still exercises exactly the same barrel/export +// surface — the isolated-package proof (`test/isolated-package.mjs`) and the +// browser suite (`test/browser/**`) are what actually prove the BUILT bare- +// specifier resolution an external consumer depends on; that is a stronger +// proof than a unit test's import statement ever was. // Register the exact same Phase-1 contract suite directly against the // package's own request() — not the compatibility adapter — so every @@ -18,7 +31,7 @@ import { runTransportContractSuite } from './clickhouse-transport-contract.js'; // invalid UTF-8, …) is proven against the package implementation itself. // // Issue #630 Phase 3 — the suite is now REQUEST/SEND-ONLY (its one -// stream-mechanics case moved out — see `clickhouse-transport-contract.ts`'s +// stream-mechanics case moved out — see `request-contract.ts`'s // header comment), so this registration no longer needs to construct the // SQL Browser compatibility adapter (`createHttpTransport`) at all merely to // borrow its `streamLines` for that case — it registers the package's diff --git a/tests/unit/clickhouse-http-exceptions.test.ts b/packages/clickhouse-http/test/unit/exceptions.test.ts similarity index 99% rename from tests/unit/clickhouse-http-exceptions.test.ts rename to packages/clickhouse-http/test/unit/exceptions.test.ts index 76e8f0ec..744cf85c 100644 --- a/tests/unit/clickhouse-http-exceptions.test.ts +++ b/packages/clickhouse-http/test/unit/exceptions.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseExceptionText, findExceptionFrame } from '@altinity/clickhouse-http'; +import { parseExceptionText, findExceptionFrame } from '../../src/index.js'; // Issue #630 Phase 3 — direct spec for the package's HTTP exception-text // parser and byte-safe late-exception framer, consumed exclusively through diff --git a/tests/unit/clickhouse-http-progress-stream.test.ts b/packages/clickhouse-http/test/unit/progress-stream.test.ts similarity index 98% rename from tests/unit/clickhouse-http-progress-stream.test.ts rename to packages/clickhouse-http/test/unit/progress-stream.test.ts index 4df3d3b6..9bf7db09 100644 --- a/tests/unit/clickhouse-http-progress-stream.test.ts +++ b/packages/clickhouse-http/test/unit/progress-stream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { streamLines } from '@altinity/clickhouse-http'; -import type { StreamLine } from '@altinity/clickhouse-http'; +import { streamLines } from '../../src/index.js'; +import type { StreamLine } from '../../src/index.js'; // Issue #630 Phase 3 — direct spec for the package's progress-stream read // loop, consumed exclusively through the package's public export (contract diff --git a/tests/unit/clickhouse-transport-contract.ts b/packages/clickhouse-http/test/unit/request-contract.ts similarity index 98% rename from tests/unit/clickhouse-transport-contract.ts rename to packages/clickhouse-http/test/unit/request-contract.ts index a76fee3c..e5ad9522 100644 --- a/tests/unit/clickhouse-transport-contract.ts +++ b/packages/clickhouse-http/test/unit/request-contract.ts @@ -19,7 +19,7 @@ // Issue #630 Phase 3 — this suite is now REQUEST/SEND-ONLY: `ClickHouseTransport` // no longer has a `streamLines` member (the progress-stream read loop moved // to `@altinity/clickhouse-http`'s own `streamLines`, tested directly against -// the package in `clickhouse-http-progress-stream.test.ts`). The former +// the package in `progress-stream.test.ts`). The former // "surfaces a mid-stream abort from streamLines rather than swallowing it" // case moved there too (a direct package-level "reader error identity" // proof) rather than staying here — there is intentionally only one @@ -37,11 +37,11 @@ // there is exactly one generic transport implementation left in the // repository (the package's), and this suite proves it against that // implementation's own request() (registered in -// `clickhouse-http-package.test.ts`), with nothing else left to register it +// `client.test.ts`), with nothing else left to register it // against. import { describe, expect, it, vi } from 'vitest'; -import type { ClickHouseHttpClientDeps, ClickHouseHttpRequest } from '@altinity/clickhouse-http'; +import type { ClickHouseHttpClientDeps, ClickHouseHttpRequest } from '../../src/index.js'; type FetchImpl = (url: string, init: RequestInit) => Response | Promise; type HeadersRecord = Record; diff --git a/tests/unit/clickhouse-http-response.test.ts b/packages/clickhouse-http/test/unit/response.test.ts similarity index 99% rename from tests/unit/clickhouse-http-response.test.ts rename to packages/clickhouse-http/test/unit/response.test.ts index 7c3c3185..50f7b340 100644 --- a/tests/unit/clickhouse-http-response.test.ts +++ b/packages/clickhouse-http/test/unit/response.test.ts @@ -5,8 +5,8 @@ import { consumeTextResponse, consumeProgressResponse, ClickHouseError, -} from '@altinity/clickhouse-http'; -import type { StreamLine } from '@altinity/clickhouse-http'; +} from '../../src/index.js'; +import type { StreamLine } from '../../src/index.js'; // Issue #630 Phase 4 — direct spec for the package's response classifier + // consumers, consumed exclusively through the public export (contract A4). diff --git a/tests/unit/clickhouse-http-sql-quote.test.ts b/packages/clickhouse-http/test/unit/sql-quote.test.ts similarity index 97% rename from tests/unit/clickhouse-http-sql-quote.test.ts rename to packages/clickhouse-http/test/unit/sql-quote.test.ts index 4bd1b5b9..a0021744 100644 --- a/tests/unit/clickhouse-http-sql-quote.test.ts +++ b/packages/clickhouse-http/test/unit/sql-quote.test.ts @@ -4,7 +4,7 @@ // the package's public "." export directly. Every expected literal below is // authored by hand, never derived from the production helper under test. import { describe, it, expect } from 'vitest'; -import { sqlString, quoteIdent, qualifyIdent } from '@altinity/clickhouse-http'; +import { sqlString, quoteIdent, qualifyIdent } from '../../src/index.js'; describe('sqlString', () => { it('quotes and doubles single quotes', () => { diff --git a/tests/unit/clickhouse-http-sql-spans.test.ts b/packages/clickhouse-http/test/unit/sql-spans.test.ts similarity index 99% rename from tests/unit/clickhouse-http-sql-spans.test.ts rename to packages/clickhouse-http/test/unit/sql-spans.test.ts index 2e775099..8327343b 100644 --- a/tests/unit/clickhouse-http-sql-spans.test.ts +++ b/packages/clickhouse-http/test/unit/sql-spans.test.ts @@ -5,7 +5,7 @@ // folded in below (as public scanSpans() cases) since the lower-level // scanDelimited() it exercised directly is now package-private. import { describe, it, expect } from 'vitest'; -import { scanSpans } from '@altinity/clickhouse-http'; +import { scanSpans } from '../../src/index.js'; // Reconstruct the classified spans as `[kind, source]` pairs for easy assertion, // and verify they tile the input exactly (contiguous, gap-free, cover-once) and diff --git a/packages/clickhouse-http/tsconfig.build.json b/packages/clickhouse-http/tsconfig.build.json new file mode 100644 index 00000000..a2379c2c --- /dev/null +++ b/packages/clickhouse-http/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**"] +} diff --git a/packages/clickhouse-http/tsconfig.json b/packages/clickhouse-http/tsconfig.json new file mode 100644 index 00000000..5423ed63 --- /dev/null +++ b/packages/clickhouse-http/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "erasableSyntaxOnly": true, + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "test/unit/**/*.ts"] +} diff --git a/packages/clickhouse-http/vitest.config.ts b/packages/clickhouse-http/vitest.config.ts new file mode 100644 index 00000000..32e4c652 --- /dev/null +++ b/packages/clickhouse-http/vitest.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); + +// Package-local unit/coverage ownership (#630 Phase 8, plan §8): the package +// is its own coverage root now, exercising the public barrel (src/index.ts) +// the same way an external consumer would, rather than deep-importing +// private modules. Same per-file thresholds as the root suite so the package +// carries the repository's normal coverage discipline into an eventual +// extraction (#639) unchanged. +export default defineConfig({ + root: here, + test: { + environment: 'node', + include: ['test/unit/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + reportsDirectory: resolve(here, 'coverage'), + include: ['src/**/*.ts'], + thresholds: { + perFile: true, + statements: 100, + functions: 95, + branches: 90, + lines: 100, + }, + }, + }, +}); diff --git a/src/application/export-service.ts b/src/application/export-service.ts index 6579fb76..cafe66d8 100644 --- a/src/application/export-service.ts +++ b/src/application/export-service.ts @@ -73,12 +73,14 @@ import { formatFileMeta, exportFilename, scriptExportName } from '../core/export // Issue #630 Phase 3 — `findExceptionFrame` is package-owned // (`@altinity/clickhouse-http`) and now takes raw bytes directly (no more // caller-side latin1 conversion — see the deleted `latin1()` helper this -// file used to carry). `src/application/**` cannot import the package -// directly (Rule D), so this goes through `ch-client.ts`'s zero-logic -// re-export (#630 Phase 7 — the ONLY remaining `net/ch-client.ts` import this -// file needs; the transport-mechanics re-exports it used to depend on are -// gone). -import { findExceptionFrame } from '../net/ch-client.js'; +// file used to carry). Issue #630 Phase 8 — `src/application/**` cannot +// generally import the package directly (Rule D's language-export allowlist +// is for SQL quoting/type-grammar/scanner consumers, not a general escape +// hatch), but this file gets a narrow, named Rule-D exception (plan §18): +// exactly this file, exactly this one name. The former migration gateway +// (`ch-client.ts`'s zero-logic `findExceptionFrame` re-export) is retired — +// spike consumers are gone, so there is no reason left to route through it. +import { findExceptionFrame } from '@altinity/clickhouse-http'; import type { QueryTab } from '../state.js'; import { variableDoc } from '../state.js'; import type { ResultSort } from '../core/sort.js'; diff --git a/src/net/ch-client.ts b/src/net/ch-client.ts index 0d882e90..5fb33175 100644 --- a/src/net/ch-client.ts +++ b/src/net/ch-client.ts @@ -6,44 +6,27 @@ // { fetch, origin, getToken(): Promise, refresh(): Promise, // onSignedOut() } // so the whole module is unit-testable with plain stubs. - -import { parseAstTables, buildSchemaGraph, externalDbs } from '../core/schema-graph.js'; -import type { SchemaGraphTableRow, SchemaGraphDictRow } from '../core/schema-graph.js'; -// Issue #585 Phase 1 — the transport seam. `chUrl` moved verbatim to -// `clickhouse-http-transport.ts`; re-exported here (with its `ChUrlOpts` -// parameter type) so every existing importer — including -// `tests/spike/clickhouse-client/current-adapter.ts` — keeps resolving. The -// generic request-construction/fetch mechanics lived in `createHttpTransport`; -// at the time this module kept every auth/epoch/retry policy, product -// operation, and `ChCtx` exactly as before, delegating through the transport -// instead of calling `chUrl`/`ctx.fetch` directly (the auth/epoch/retry -// policy itself later moved out — see the Phase 6 note below; the transport -// itself is deleted — see the Phase 7 note below). -// -// Issue #630 Phase 2 — `chUrl` now comes from `@altinity/clickhouse-http` -// (the package is the ONE serializer implementation, contract A5); this -// module's re-export below keeps every existing importer (including the -// historical official-client spike, `tests/spike/clickhouse-client/current- -// adapter.ts`) resolving unchanged. // -// Issue #630 Phase 3 — the progress-stream read loop and the HTTP -// exception-text/late-exception-frame parser are also package-owned now -// (`streamLines`/`parseExceptionText`/`findExceptionFrame`, plus the -// canonical `StreamLine`/`StreamCallbacks` wire types). -// `parseExceptionText`/`findExceptionFrame`/`StreamLine`/`StreamCallbacks` -// are re-exported below as zero-logic migration plumbing: `src/application/**` -// cannot import the package directly (Rule D — its language-export allowlist -// is for the SQL Browser layers that consume generic ClickHouse -// quoting/type-grammar directly, not a general escape hatch), so -// `export-service.ts`'s `findExceptionFrame` use resolves through this one -// gateway instead. +// Issue #630 Phase 2/3/5 — `chUrl`/`parseExceptionText`/`findExceptionFrame` +// (plus `StreamLine`/`StreamCallbacks`) used to be re-exported from here as +// migration plumbing for spike/legacy consumers that imported them through +// this gateway rather than the package directly. Issue #630 Phase 8 removes +// every one of those forwarding aliases now that the spike consumers are +// gone: `chUrl` and `parseExceptionText` were never used by this module's own +// production code (only re-exported), so neither is imported here anymore. +// `findExceptionFrame` had exactly one real consumer, +// `src/application/export-service.ts`, which now imports it directly from +// `@altinity/clickhouse-http` under a narrow Rule-D exception (plan §18) — +// so it is dropped from this module too. `sqlString`/`ClickHouseError`/ +// `createClickHouseHttpClient` remain: this module's own production code +// actually calls all three. // -// Issue #630 Phase 5 — `sqlString` now comes from the package too (the ONE -// quoting implementation, `sql-quote.ts`); this module is itself under -// `src/net/**`, the one layer Rule D always allows to import the package's -// full surface (transport APIs and language exports alike), so it imports -// `sqlString` directly rather than through `../core/format.js` (which no -// longer declares it at all). +// Issue #630 Phase 5 — `sqlString` comes from the package (the ONE quoting +// implementation, `sql-quote.ts`); this module is itself under `src/net/**`, +// the one layer Rule D always allows to import the package's full surface +// (transport APIs and language exports alike), so it imports `sqlString` +// directly rather than through `../core/format.js` (which no longer declares +// it at all). // // Issue #630 Phase 6 — the normal-request auth/epoch/refresh/lifecycle // policy that used to live here as `authedFetch`/`transportFor(ctx)` MOVED @@ -75,14 +58,14 @@ import type { SchemaGraphTableRow, SchemaGraphDictRow } from '../core/schema-gra // `killQueryWithLease` off it, this module's last caller is gone, and there // is exactly one generic ClickHouse HTTP transport implementation left in // the repository (the package's). + +import { parseAstTables, buildSchemaGraph, externalDbs } from '../core/schema-graph.js'; +import type { SchemaGraphTableRow, SchemaGraphDictRow } from '../core/schema-graph.js'; import { - chUrl, parseExceptionText, findExceptionFrame, sqlString, ClickHouseError, - createClickHouseHttpClient, + sqlString, ClickHouseError, createClickHouseHttpClient, } from '@altinity/clickhouse-http'; import { authenticatedJson } from './authenticated-clickhouse-request.js'; import type { AuthenticatedRequestCtx } from './authenticated-clickhouse-request.js'; -export { chUrl, parseExceptionText, findExceptionFrame }; -export type { ChUrlOpts, StreamLine, StreamCallbacks } from '@altinity/clickhouse-http'; // ── Injected ctx seam ──────────────────────────────────────────────────────── diff --git a/tests/e2e/authenticated-clickhouse-request.html b/tests/e2e/authenticated-clickhouse-request.html new file mode 100644 index 00000000..1a32a7e3 --- /dev/null +++ b/tests/e2e/authenticated-clickhouse-request.html @@ -0,0 +1,282 @@ + + + + + SQL Browser — authenticated ClickHouse request harness + + + + + + + diff --git a/tests/e2e/authenticated-clickhouse-request.spec.js b/tests/e2e/authenticated-clickhouse-request.spec.js new file mode 100644 index 00000000..73fa911d --- /dev/null +++ b/tests/e2e/authenticated-clickhouse-request.spec.js @@ -0,0 +1,131 @@ +import { randomUUID } from 'node:crypto'; +import { test, expect } from '@playwright/test'; +import { startFaultServer, POST_HEADER_ABORT_HOLD_MS } from '../../packages/clickhouse-http/test/browser/fault-server.mjs'; + +// Issue #630 Phase 8 — split out of the former +// `tests/e2e/clickhouse-http-transport.spec.js` (issue #630 Phases 1/6/7): +// this spec keeps ONLY the SQL Browser authentication-policy variants of the +// post-header cancellation family — the package-native scenarios themselves +// moved to `packages/clickhouse-http/test/browser/regression.spec.js`, the +// package's own regression suite. This spec owns the fault server's +// Node-side lifecycle: the root Playwright config only starts +// build/e2e-serve.mjs (the static/raw-ESM host on :5599) — it knows nothing +// about this ephemeral fixture server. It imports the package-owned +// `fault-server.mjs` fixture directly (plan §15 — a legitimate root +// consumer while the workspace exists; #639's extraction handoff lists this +// import path as one to update when the workspace is removed). +// +// Firefox cannot launch locally (repo-wide constraint); Chromium and WebKit +// are this suite's real acceptance signal. + +test.describe('#630 Phase 8 — authenticated ClickHouse request cancellation semantics', () => { + test.skip( + ({ browserName }) => browserName === 'firefox', + 'authenticated-request acceptance is explicitly Chromium/WebKit', + ); + + /** @type {Awaited>} */ + let fault; + + test.beforeAll(async () => { + fault = await startFaultServer({ cors: true }); + }); + + test.afterAll(async () => { + // Playwright still runs a describe's afterAll even when every test in it + // was test.skip()-ed (Firefox here) — but in that case beforeAll never + // ran, so `fault` is still undefined. Guard rather than let a skipped + // Firefox run fail on an unrelated hook error. + await fault?.close(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto('/tests/e2e/authenticated-clickhouse-request.html'); + await page.waitForFunction(() => window.__ready === true); + }); + + // Unique per test/project so log-filtering assertions never depend on + // global log emptiness or ordering relative to any other test. + function qid(fixture, projectName) { + return `${fixture}__${projectName}-${randomUUID()}`; + } + + test('Scenario 5 — native post-header body lifetime through authenticatedRequest()', async ({ page }, testInfo) => { + const queryId = qid('post-header-abort-hold', testInfo.project.name); + const start = await page.evaluate( + ({ baseUrl, queryId }) => window.__scenario5AuthStart(baseUrl, queryId), + { baseUrl: fault.baseUrl, queryId }, + ); + expect(start.identity).toBe(true); + expect(start.bodyUsedBeforeRead).toBe(false); + expect(start.firstDone).toBe(false); + expect(start.firstText).toContain('first'); + expect(start.count).toBe(1); + expect(start.authorization).toBe('Bearer auth-e2e-test-token'); + + const after = await page.evaluate(() => window.__scenario5AuthAbortAndReadNext()); + expect(after.rejectedName).toBe('AbortError'); + // The already-settled authenticatedRequest() Response is untouched by + // the later abort — no already-errored synthetic stream, no status/ok + // mutation, and abort must never report offline. + expect(after.sendResponseStatus).toBe(200); + expect(after.sendResponseOk).toBe(true); + }); + + test('Scenario 6 — package streamLines() through authenticatedRequest() emits no callbacks after observable cancellation', async ({ page }, testInfo) => { + test.setTimeout(30_000); + const queryId = qid('post-header-abort-hold', testInfo.project.name); + const result = await page.evaluate( + ({ baseUrl, queryId, holdMs }) => window.__scenario6Auth(baseUrl, queryId, holdMs), + { baseUrl: fault.baseUrl, queryId, holdMs: POST_HEADER_ABORT_HOLD_MS }, + ); + expect(result.rejectedName).toBe('AbortError'); + expect(result.chunksAtRejection).toBeGreaterThanOrEqual(1); + expect(result.linesAfterWait).toBe(result.linesAtRejection); + expect(result.chunksAfterWait).toBe(result.chunksAtRejection); + expect(result.offlineCallsAfterAbort).toBe(0); + }); + + test('Scenario 7 — cancellation of one concurrent authenticated request cannot affect another sharing the same ctx', async ({ page }, testInfo) => { + test.setTimeout(30_000); + const queryIdA = qid('post-header-abort-hold', testInfo.project.name); + const queryIdB = qid('post-header-abort-hold', testInfo.project.name); + const result = await page.evaluate( + ({ baseUrl, queryIdA, queryIdB, holdMs }) => window.__scenario7Auth(baseUrl, queryIdA, queryIdB, holdMs), + { baseUrl: fault.baseUrl, queryIdA, queryIdB, holdMs: POST_HEADER_ABORT_HOLD_MS }, + ); + expect(result.aRejectedName).toBe('AbortError'); + expect(result.bFirstHeldDone).toBe(false); + expect(result.bFirstHeldText).toContain('after-hold'); + expect(result.bCompletedCleanly).toBe(true); + }); + + test('Scenario 8 — abort after full body completion through authenticatedRequest() has no effect', async ({ page }, testInfo) => { + const queryId = qid('ordinary-query', testInfo.project.name); + const result = await page.evaluate( + ({ baseUrl, queryId }) => window.__scenario8Auth(baseUrl, queryId), + { baseUrl: fault.baseUrl, queryId }, + ); + expect(result.linesBefore).toBeGreaterThan(0); + expect(result.abortThrew).toBe(false); + expect(result.linesAfter).toBe(result.linesBefore); + expect(result.chunksAfter).toBe(result.chunksBefore); + }); + + test('Scenario 9 — authenticatedProgress() emits no callbacks after observable cancellation, one Fetch', async ({ page }, testInfo) => { + test.setTimeout(30_000); + const queryId = qid('post-header-abort-hold', testInfo.project.name); + const result = await page.evaluate( + ({ baseUrl, queryId, holdMs }) => window.__scenario9Auth(baseUrl, queryId, holdMs), + { baseUrl: fault.baseUrl, queryId, holdMs: POST_HEADER_ABORT_HOLD_MS }, + ); + expect(result.rejectedName).toBe('AbortError'); + expect(result.chunksAtRejection).toBeGreaterThanOrEqual(1); + expect(result.countAtRejection).toBe(1); + // One real Fetch throughout — no retry, before or after the wait. + expect(result.countAfterWait).toBe(1); + expect(result.linesAfterWait).toBe(result.linesAtRejection); + expect(result.chunksAfterWait).toBe(result.chunksAtRejection); + expect(result.authorization).toBe('Bearer auth-e2e-test-token'); + }); +}); diff --git a/tests/e2e/clickhouse-http-transport.html b/tests/e2e/clickhouse-http-transport.html deleted file mode 100644 index fc2da6c8..00000000 --- a/tests/e2e/clickhouse-http-transport.html +++ /dev/null @@ -1,642 +0,0 @@ - - - - - #630 Phase 1 — native Fetch/Response/cancellation transport harness - - - - - - - diff --git a/tests/e2e/export-post-header-cancel.spec.js b/tests/e2e/export-post-header-cancel.spec.js index 0cb32580..cc6ba080 100644 --- a/tests/e2e/export-post-header-cancel.spec.js +++ b/tests/e2e/export-post-header-cancel.spec.js @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { startFaultServer } from '../spike/clickhouse-client/fault-server.mjs'; +import { startFaultServer } from '../../packages/clickhouse-http/test/browser/fault-server.mjs'; // #630 Phase 7 (pre-PR review Finding 1) — Plan §18/Checkpoint 3 and A15's // Definition of Done require a dedicated EXPORT-shaped real-browser fixture diff --git a/tests/spike/clickhouse-client/README.md b/tests/spike/clickhouse-client/README.md deleted file mode 100644 index 153d243b..00000000 --- a/tests/spike/clickhouse-client/README.md +++ /dev/null @@ -1,197 +0,0 @@ -# `@clickhouse/client-web` validation spike — issue #585 Phase 0 - -This directory is a **test-owned comparison harness** (plan §7 "Phase 0 -architecture"), not the Phase 1 production transport contract. It compares -the current custom ClickHouse transport (`src/net/ch-client.ts` and friends) -against the official `@clickhouse/client-web@1.23.1` package, running both -through the exact same scenario/request shape and diffing the normalized -result. Nothing under `src/` imports anything in this directory, and nothing -here changes production behavior — see `docs/ADR-0005-clickhouse-web-client.md` -for the evidence-based decision this harness feeds: **Rejected** (three -independently-verified failing hard gates — see the ADR for the exact root -causes; the current custom transport remains authoritative and no production -cutover occurred). - -`@clickhouse/client-web` is pinned as an exact, dev-only dependency -(`package.json`'s `devDependencies`, no range) for the lifetime of Phase 0. -It is never imported by the normal production graph (`src/main.ts` and -everything it imports) — the only file in this repository that imports it is -`official-adapter.ts`, plus the compile-time probe `format-type-probe.ts` and -the deterministic test suite `parity.test.ts`. -`tests/unit/client-web-spike-policy.test.js` (part of the normal, coverage- -gated `npm test` run) enforces all of this mechanically — see "Policy -enforcement" below. - -## Current status - -The harness is complete: every file this README describes is present, the -full evidence set under `docs/evidence/585/` has been generated and -validated, `docs/ADR-0005-clickhouse-web-client.md` records the -evidence-based decision (**Rejected**), and `.wiki/Decisions-and-Roadmap.md` -reflects the same status. - -* **Deterministic harness**: `types.ts`, `normalize.ts`, `expected-values.ts`, - `scenarios.ts`, `precision-corpus.ts`, `format-type-probe.ts`, - `current-adapter.ts`, `official-adapter.ts`, `progress-bridge.ts`, - `guarded-fetch.ts`, `auth-fixtures.ts`, `parity.test.ts`, `fault-server.mjs`, - `vitest.config.mjs`, `candidate-entry.ts`, `candidate-third-party-notices.md`. -* **Live-server harness**: `clickhouse-containers.mjs` (Docker orchestration), - `matrix.json` (the resolved, digest-pinned server matrix), `live-parity.test.ts`, - `live-precision.test.ts`, `live-sessions.test.ts`. -* **Evidence orchestration**: `run-matrix.mjs` (generates - `docs/evidence/585/`), `validate-evidence.mjs` (validates it). -* **Browser harness**: `spike-server.mjs`, `playwright.config.js`, - `browser-harness.html`, `browser-harness.ts`, `browser.spec.js`. - -See "Layout" below for what each file does. - -## Layout - -| File | Role | -| --- | --- | -| `types.ts` | The test-owned comparison interface (plan §7): `SpikeRequest`/`SpikeOutcome`/`ExpectedOutcome`/`ParityResult`. Nothing under `src/` may import it. | -| `normalize.ts` | Pure comparison/normalization helpers (`emptyOutcome`, `IncrementalSha256`, diffing). No fetch, no DOM. | -| `expected-values.ts` | Independently-authored precision literals (plan §17) — never derived from either adapter's output, so a match can't just mean "both share the same bug." | -| `scenarios.ts` | The deterministic subset of the plan §18 parity scenario matrix, each entry naming its `fault-server.mjs` fixture and the invariant-map row(s) it proves. Live-server-only rows (sessions, `SESSION_IS_LOCKED` against a real server, `KILL QUERY`, the full precision corpus) are `run-matrix.mjs`'s job, not this file's. | -| `precision-corpus.ts` | Runs every `expected-values.ts` case through both adapters against a **real** ClickHouse server (`ASB_SPIKE_CH_URL`). No fixture can safely stand in for a real server's exact numeric/date/decimal serialization. | -| `format-type-probe.ts` | The compile-time proof for plan §16: an uncast `JSONStringsEachRowWithProgress` call is rejected by installed 1.23.1's public types (`@ts-expect-error`), with a positive `JSONEachRowWithProgress` control. Type-checked automatically by `npm run check:types` via the `tsconfig.json` include below. | -| `current-adapter.ts` | Wraps the **real** production `runQuery`/`exportQuery`/`killQuery`/`createQueryExecutionService` — never a reimplemented replica (plan §7 "Current-side adapter"). | -| `official-adapter.ts` | The **only** module that imports `@clickhouse/client-web`. Constructs one client per connection config, injects fetch, supplies per-request auth via the vendor client's own `auth` field, and exposes only the test-owned `SpikeOutcome` — the vendor's own result/error types never escape this file. | -| `progress-bridge.ts` | The narrow `exec()`-based NDJSON progress bridge plan §16 allows (only because `query()` doesn't publicly support `JSONStringsEachRowWithProgress`) — incremental decode only, no normalization, no second general client. | -| `guarded-fetch.ts` | Plan §21's immediate pre-fetch epoch-fencing experiment: an injected-fetch checkpoint that rejects a stale-epoch request immediately before the real delegate fetch fires. | -| `auth-fixtures.ts` | Non-secret credential fixtures (Basic/Bearer/JWT-as-Basic/invalid) shared by the deterministic suite, the (future) local-Docker matrix, and the (future) browser harness. Inert by construction — safe to commit. | -| `fault-server.mjs` | A dependency-free Node `http` server exposing named, fully deterministic fixture routes (delayed headers, scheduled chunks, malformed/truncated lines, mid-stream resets, 401/403 sequences, tagged/legacy late exceptions, invalid UTF-8, request inspection). Never asserts — `scenarios.ts` and `parity.test.ts` do that. | -| `parity.test.ts` | The deterministic parity/precision/auth/epoch/retry suite, run under the dedicated Vitest config below. Every test proves at least one plan §11 invariant-map row. | -| `candidate-entry.ts` | The candidate build's esbuild entry point — imports the real production entry plus the official adapter, retains the latter through a non-executing global registration so esbuild's tree-shaker can't drop it. Never used by normal `npm run build`. | -| `candidate-third-party-notices.md` | The Apache-2.0 notice fragment appended **only** to the candidate artifact's embedded third-party notices (`build/build.mjs`'s `additionalNotices` option) — never to the normal `THIRD-PARTY-NOTICES.md` or the normal artifact. | -| `vitest.config.mjs` | The dedicated Vitest config the spike scripts use (`environment: 'node'`, `singleThread: true`, no coverage) — deliberately separate from `tests/vitest.config.ts` so `npm test` never discovers this suite. | -| `clickhouse-containers.mjs` | Dependency-free Docker orchestrator (plan §12/§13): pulls and boots one real ClickHouse server (OSS or Altinity Stable, resolved via `matrix.json`) for the live-server specs below, with every bind mount asserted under `$TMPDIR`/`$SPIKE_TMP` (never `/tmp`), unique run-labeled containers, and a `stop()`/orphan-sweep that removes only containers carrying this run's label. | -| `matrix.json` | The exact, digest-pinned ClickHouse server images the live matrix boots (proposed-oldest OSS/Altinity Stable, current-stable OSS, current Altinity Stable, plus a conditional Cloud row) — resolved by hand against the registry at implementation time, never an unqualified `latest` tag. | -| `live-parity.test.ts` | Live-server progressive timing, mid-stream exception, raw/export byte-hash, and `KILL QUERY`/`system.processes` cancellation proofs (plan §19/§20/§22/§24) against a **real** ClickHouse server (`ASB_SPIKE_CH_URL`, set by `clickhouse-containers.mjs`/`run-matrix.mjs`). Skips cleanly when the env var is unset. | -| `live-precision.test.ts` | Runs every `expected-values.ts` precision case (plan §17) through both adapters against a real server — no fixture can safely stand in for a real server's exact numeric/date/decimal serialization. Skips cleanly without `ASB_SPIKE_CH_URL`. | -| `live-sessions.test.ts` | Live-server logical-session, `SESSION_IS_LOCKED` retry, and connection-reset retry-safety proofs (plan §23) fed through the REAL, unmodified `QueryExecutionService`. Skips cleanly without `ASB_SPIKE_CH_URL`. | -| `run-matrix.mjs` | The evidence-generation orchestrator (plan §29/§34): runs the deterministic suite, the support-minimum derivation, the live server/browser matrix, the build/size-report measurements, and writes the complete `docs/evidence/585/` tree. `main()` runs unconditionally on import — only ever run as a script (`node run-matrix.mjs`), never imported. | -| `validate-evidence.mjs` | The evidence completeness/consistency validator (plan §29's exhaustive failure-rule list) — checked against the committed `docs/evidence/585/` by default. Exit 0 with no findings, exit 1 with an itemized list otherwise; never mutates anything, never prints a credential value. | -| `spike-server.mjs` | A dedicated Node HTTP server for the Playwright browser matrix (plan §14/§26): a streaming same-origin reverse proxy, the static browser-harness page, and a local esbuild-bundled ESM wrapper around the verified installed `@clickhouse/client-web` entry (no CDN). | -| `playwright.config.js` | A dedicated Playwright config scoped to `browser.spec.js` only (Chromium + WebKit — Firefox is explicitly excluded per plan §14, matching this sandbox's local e2e limitation) — the repository's root `playwright.config.js`/`npm run test:e2e` is untouched. | -| `browser-harness.html` / `browser-harness.ts` | The browser-facing static harness page and its module — the second (and only other) module in this repository that imports `@clickhouse/client-web`, this time from a real browser engine rather than Node, proving the same production decisions (exec()+bridge Table path, per-call `auth`, `query_id`, response headers) survive unmodified in Chromium/WebKit. | -| `browser.spec.js` | The actual Chromium/WebKit coverage (plan §25): client construction, ordinary query, progressive first row, request-local Basic auth, cancellation during streaming, response headers, query ID, raw bytes, and a network recorder proving no external runtime import, per required server/origin row. | - -## Type checking - -`tsconfig.json`'s root `include` already lists -`tests/spike/clickhouse-client/**/*.ts`, so every `.ts` file in this -directory — including `candidate-entry.ts` and `format-type-probe.ts` — is -checked automatically by: - -```sh -npm run check:types -``` - -No separate command is needed. A future upstream type-surface change (e.g. -`JSONStringsEachRowWithProgress` becoming publicly supported) will make this -fail until `format-type-probe.ts` and the ADR evidence are reconciled — that -is intentional (plan §8). - -## Running the harness - -```sh -# Deterministic fault-server-backed parity/auth/epoch/retry suite (no live -# ClickHouse, no Docker, no browser required). Uses its OWN Vitest config — -# npm test does not discover these tests. -npm run test:client-spike - -# Full evidence-generation run: the deterministic suite, the support-minimum -# derivation, the live Docker server matrix + live-*.test.ts, the Playwright -# browser matrix, and the build/size-report measurements — writes the -# complete docs/evidence/585/ tree. Every flag below narrows this for -# iteration/smoke-testing only; a narrowed invocation is never the "real" -# evidence run (see run-matrix.mjs's own header for the full flag list): -# --rows matrix.json rows to boot via Docker -# --browsers Playwright projects to run -# --skip-baseline-gate skip the baseline worktree's full local -# gate (the baseline size-report -# self-check still always runs) -npm run test:client-spike:matrix - -# Chromium/WebKit browser harness (same-origin + cross-origin CORS). -npm run test:client-spike:browser -- --project=chromium -npm run test:client-spike:browser -- --project=webkit - -# Evidence completeness/consistency validator (docs/evidence/585/). -npm run check:client-spike:evidence -``` - -`test:client-spike` sets `TZ=America/New_York` (matching `npm test`'s own -convention) and points explicitly at this directory's `vitest.config.mjs` — -a bare `npx vitest run` from the repo root would not reliably inherit either -config's `environment`/`include`, so every spike script names its config -explicitly rather than relying on Vitest's default discovery. - -## Measuring the candidate artifact - -The candidate build is a **measurement-only** artifact: it bundles the real -production entry (`src/main.ts`) plus `candidate-entry.ts`'s non-executing -registration of the official adapter, proving `@clickhouse/client-web` CAN be -included in one self-contained HTML file without becoming a permanent -runtime dependency. It is produced by calling the shared build helpers -(`build/build.mjs`) with a non-default `entryPoint` and `additionalNotices` — -there is no dedicated npm script for this because it is evidence-generation -infrastructure (`docs/evidence/585/candidate/`), not a repeatable developer -command: - -```js -import { buildArtifact } from '../../../build/build.mjs'; - -const { html, metafile } = await buildArtifact({ - entryPoint: 'tests/spike/clickhouse-client/candidate-entry.ts', - metafile: true, - additionalNotices: await readFile( - 'tests/spike/clickhouse-client/candidate-third-party-notices.md', 'utf8', - ), -}); -``` - -`tests/unit/client-web-spike-policy.test.js` runs exactly this shape (with -its output written under `$TMPDIR`, never into the repository's own `dist/`) -and asserts the resulting metafile includes -`node_modules/@clickhouse/client-web/...`, while a normal -`buildArtifact({ metafile: true })` call (the same one `npm run build`/ -`npm run size-report` use) excludes it entirely. - -## Policy enforcement - -`tests/unit/client-web-spike-policy.test.js` — part of the normal, -coverage-gated `npm test` run — mechanically enforces: - -* `@clickhouse/client-web` is pinned to the exact version `1.23.1` and lives - only in `devDependencies`, never `dependencies`; -* a normal production build's metafile never contains the package; -* a candidate build's metafile does contain it; -* no file under this directory imports a CDN/remote URL (an import - specifier, a dynamic `import()`, or an HTML ` - - - - - diff --git a/tests/spike/clickhouse-client/browser-harness.ts b/tests/spike/clickhouse-client/browser-harness.ts deleted file mode 100644 index bee1cd7c..00000000 --- a/tests/spike/clickhouse-client/browser-harness.ts +++ /dev/null @@ -1,358 +0,0 @@ -// Phase 0 / issue #585, plan §14 "Same-origin, CORS, and browser harness" and -// §25 "Browser and deployment matrix". This is the SECOND (and only other) -// module in this repository that imports `@clickhouse/client-web` — this -// time from a REAL browser engine (Chromium/WebKit via Playwright), not -// Node. `official-adapter.ts` already proves the vendor client's behavior -// under Node for the deterministic/live harness; this file proves the SAME -// production decisions (plan §16's exec()+bridge Table path, the vendor -// client's own per-call `auth` override, `query_id`, response headers) -// survive UNMODIFIED inside a real browser — it is deliberately narrower -// than the Node harness, not a second parity surface (plan §25's per-row -// browser coverage: client construction, ordinary query, progressive first -// row, request-local Basic auth, cancellation during streaming, response -// headers, query ID, raw bytes). -// -// Reuses, rather than reimplements, two already-proven pure pieces that work -// identically under Node and in a real browser (no DOM, no Node builtins): -// * progress-bridge.ts's bridgeNdjsonProgress — the EXACT narrow bridge -// official-adapter.ts's Table path uses; -// * normalize.ts's IncrementalSha256 — Web Crypto SHA-256. -// -// browser.spec.js drives this module's single exported entry point, -// `runScenario`, through `page.evaluate` — every argument and return value -// crosses the Playwright/browser boundary as plain JSON, so no live object -// (client, stream, AbortController) ever needs to survive that boundary. -// This file is served to the browser by `spike-server.mjs`, which type- -// strips it exactly like `build/e2e-serve.mjs` does for the normal e2e -// suite — the browser never executes TypeScript syntax. -// -// `@clickhouse/client-web` itself is resolved through `browser-harness.html`'s -// import map, which points at `spike-server.mjs`'s own esbuild-bundled ESM -// wrapper around the VERIFIED installed 1.23.1 entry (see that file's header -// for why a bare `import` of the installed CJS `dist/index.js` cannot work -// directly in a browser, and why the routing scheme below uses a header, -// never a URL path segment). - -import { createClient, ClickHouseError, isProgressRow, isRow, type ClickHouseClient } from '@clickhouse/client-web'; -import { bridgeNdjsonProgress } from './progress-bridge.js'; -import { IncrementalSha256 } from './normalize.js'; -// Issue #630 Phase 3 — `StreamLine` is package-owned now -// (`@altinity/clickhouse-http`); this is a type-only import (erased before -// this module is served to the browser by `spike-server.mjs`'s type-strip — -// see that file's header), so it needs no browser import-map entry, unlike -// the genuine runtime `@clickhouse/client-web` import above. -import type { StreamLine } from '@altinity/clickhouse-http'; - -export interface SpikeAuth { username: string; password: string } - -export type ScenarioName = - | 'construct' - | 'ordinaryQuery' - | 'progressiveFirstRow' - | 'basicAuth' - | 'cancelDuringStreaming' - | 'responseHeaders' - | 'queryId' - | 'rawBytes'; - -export interface ScenarioRequest { - scenario: ScenarioName; - url: string; - auth?: SpikeAuth; - authB?: SpikeAuth; - /** Same-origin mode only: the matrix-row key spike-server.mjs's proxy - * should route this client's requests to (see spike-server.mjs's header - * docstring for why this is a request header, never a URL path segment). - * Omitted entirely in cross-origin mode, where `url` already points - * directly at the row's own ClickHouse endpoint. MUST stay byte-identical - * to spike-server.mjs's own `ROW_HEADER` constant — cross-referenced by - * comment rather than a shared import, matching this repository's - * existing .mjs/.ts fixture cross-reference precedent - * (clickhouse-containers.mjs/auth-fixtures.ts). */ - rowHeader?: string; -} - -const ROW_HEADER_NAME = 'x-asb-spike-row'; - -export interface ScenarioResult { - ok: boolean; - error?: string; - [key: string]: unknown; -} - -/** One client per `url`, reused across every scenario call for that page - * (the plan's "one official client per connection config" invariant, held - * here too — `runScenario` never constructs a second client for a `url` it - * has already seen). The client-level default credential is deliberately - * invalid, matching `official-adapter.ts`'s `createOfficialConnection` — a - * request that omits `auth` must fail, proving the default never becomes - * authoritative. */ -const clients = new Map(); - -function clientFor(url: string, rowHeader?: string): ClickHouseClient { - const cacheKey = `${url}::${rowHeader ?? ''}`; - let client = clients.get(cacheKey); - if (!client) { - client = createClient({ - url, - username: 'asb-spike-default-invalid', - password: 'asb-spike-default-invalid', - ...(rowHeader ? { http_headers: { [ROW_HEADER_NAME]: rowHeader } } : {}), - }); - clients.set(cacheKey, client); - } - return client; -} - -function flattenHeaders(h: Record | undefined): Record { - const out: Record = {}; - if (!h) return out; - for (const [k, v] of Object.entries(h)) { - if (v === undefined) continue; - out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v; - } - return out; -} - -// DISCOVERED WHILE BUILDING THIS HARNESS (real cross-origin WebKit run -// against ClickHouse 26.6.2.160, verified independently with `curl`): this -// is a genuine CLICKHOUSE-SERVER-SIDE behavior, not a browser or vendor- -// client bug. `Accept-Encoding: gzip, deflate` (or `identity`) both cause -// ClickHouse's HTTP handler to flush each block as it completes — but -// `Accept-Encoding: ...br` (Brotli) makes ClickHouse withhold the ENTIRE -// response, including `X-ClickHouse-Summary`'s header value itself, until -// the query fully completes (`curl -D -` on the SAME query showed a -// PARTIAL summary — read_rows:1 — for gzip/deflate/identity, but the FULL -// final summary for `br` before a single body byte arrived). Real browsers -// differ in whether their default `fetch()` `Accept-Encoding` includes -// `br` (this is why the SAME query streamed progressively for Chromium's -// cross-origin request but not WebKit's, despite identical SQL/server, and -// why the SAME-ORIGIN proxy path never hits this at all — spike-server.mjs -// forces `identity` on its own upstream leg for exactly this class of -// reason, see that file's header docstring). `enable_http_compression` -// must disable it via the CLIENT'S `clickhouse_settings` (a URL query -// parameter the HTTP handler reads before parsing the query) — embedding -// it in the SQL's own `SETTINGS` clause does NOT work (verified: still -// buffered), because compression is an HTTP-handler-level decision made -// before the query settings clause is even parsed. Applied only to the two -// scenarios below that measure timing/incremental delivery — every other -// scenario is compression-encoding-agnostic by construction. -const NO_HTTP_COMPRESSION = { enable_http_compression: 0 } as const; - -async function drain(stream: ReadableStream): Promise { - const reader = stream.getReader(); - for (;;) { - const { done } = await reader.read(); - if (done) break; - } -} - -/** `.query({format:'JSONEachRowWithProgress'})` + `.stream()` — the one - * publicly-supported progress format (plan §16), matching - * `official-adapter.ts`'s KPI branch exactly (same `isRow`/`isProgressRow` - * imports, same wrapped-document shape). Used for every scenario here that - * only needs "does an ordinary query round-trip", not the narrow Table - * exec()+bridge path (that is `progressiveFirstRow`/`cancelDuringStreaming` - * below, on purpose — proving BOTH decided paths work in a real browser). */ -async function runJsonQuery( - client: ClickHouseClient, - sql: string, - opts: { auth?: SpikeAuth; queryId?: string } = {}, -): Promise<{ rows: unknown[][]; queryId: string; headers: Record }> { - const rs = await client.query({ - query: sql, - format: 'JSONEachRowWithProgress', - ...(opts.auth ? { auth: opts.auth } : {}), - ...(opts.queryId ? { query_id: opts.queryId } : {}), - }); - const headers = flattenHeaders(rs.response_headers); - const rows: unknown[][] = []; - const stream = rs.stream>(); - const reader = stream.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - for (const wrapped of value) { - const row = wrapped.json(); - if (isRow(row)) rows.push(Object.values(row.row as Record)); - else if (isProgressRow(row)) { /* observed, not needed by these scenarios */ } - } - } - return { rows, queryId: rs.query_id, headers }; -} - -async function construct(url: string, rowHeader?: string): Promise { - clientFor(url, rowHeader); - return { ok: true }; -} - -async function ordinaryQuery(client: ClickHouseClient, auth?: SpikeAuth): Promise { - const { rows } = await runJsonQuery(client, 'SELECT number FROM system.numbers LIMIT 5', { auth }); - return { ok: true, rows, rowCount: rows.length }; -} - -/** Plan §16's chosen Table path (exec() + the narrow NDJSON bridge) run - * against a query that flushes one row per block with a real delay between - * blocks — the same shape `live-parity.test.ts`'s real-server timing gate - * uses, proving first-row publication precedes completion by a real margin - * in an ACTUAL browser engine, not merely under Node. */ -async function progressiveFirstRow(client: ClickHouseClient, auth?: SpikeAuth): Promise { - const sql = 'SELECT sleepEachRow(0.2) FROM numbers(6) SETTINGS max_block_size = 1\nFORMAT JSONStringsEachRowWithProgress'; - const t0 = Date.now(); - const res = await client.exec({ query: sql, clickhouse_settings: NO_HTTP_COMPRESSION, ...(auth ? { auth } : {}) }); - let firstRowAtMs: number | null = null; - let rowCount = 0; - await bridgeNdjsonProgress(res.stream, (line: StreamLine) => { - if (line.row) { - rowCount += 1; - if (firstRowAtMs === null) firstRowAtMs = Date.now() - t0; - } - }); - const completedAtMs = Date.now() - t0; - return { - ok: true, - firstRowAtMs, - completedAtMs, - rowCount, - // The hard gate itself (plan §19): first row must precede completion by - // a real margin, never merely equal it (which would mean full buffering). - progressive: firstRowAtMs !== null && completedAtMs - firstRowAtMs >= 200, - }; -} - -/** Plan §21 "Per-request auth": one client, alternating per-request - * credentials, proving the client-level default (deliberately invalid) is - * never authoritative and each request is scoped to only its own override. */ -async function basicAuth(client: ClickHouseClient, authA?: SpikeAuth, authB?: SpikeAuth): Promise { - if (!authA || !authB) return { ok: false, error: 'basicAuth scenario requires both auth and authB' }; - const currentUser = async (auth?: SpikeAuth) => { - const { rows } = await runJsonQuery(client, 'SELECT currentUser() AS u', { auth }); - return rows[0]?.[0] ?? null; - }; - const userA = await currentUser(authA); - const userB = await currentUser(authB); - let defaultRejected = false; - try { - await currentUser(undefined); - } catch { - defaultRejected = true; - } - return { - ok: true, - userA, - userB, - defaultRejected, - matchesA: userA === authA.username, - matchesB: userB === authB.username, - }; -} - -/** Plan §22 "Cancellation": abort partway through a real streamed Table - * response and prove no row is published after the abort. */ -async function cancelDuringStreaming(client: ClickHouseClient, auth?: SpikeAuth): Promise { - const controller = new AbortController(); - const sql = 'SELECT sleepEachRow(0.15) FROM numbers(40) SETTINGS max_block_size = 1\nFORMAT JSONStringsEachRowWithProgress'; - const res = await client.exec({ query: sql, abort_signal: controller.signal, clickhouse_settings: NO_HTTP_COMPRESSION, ...(auth ? { auth } : {}) }); - let rowCount = 0; - let cancelled = false; - try { - await bridgeNdjsonProgress(res.stream, (line: StreamLine) => { - if (line.row) { - rowCount += 1; - if (rowCount === 3) controller.abort(); - } - }); - } catch (e) { - cancelled = (e instanceof Error && e.name === 'AbortError') || controller.signal.aborted; - } - const rowCountAtAbort = rowCount; - // Give any late/leaked chunk a real chance to arrive before asserting none did. - await new Promise((resolve) => setTimeout(resolve, 400)); - const rowCountAfterWait = rowCount; - return { - ok: true, - cancelled: cancelled || controller.signal.aborted, - rowCountAtAbort, - rowCountAfterWait, - noLaterRows: rowCountAfterWait === rowCountAtAbort, - }; -} - -/** Plan §18 "response headers" / "X-ClickHouse-Summary" — the exact headers - * `clickhouse-containers.mjs`'s CORS config explicitly exposes. */ -async function responseHeaders(client: ClickHouseClient, auth?: SpikeAuth): Promise { - const res = await client.exec({ query: 'SELECT 1', ...(auth ? { auth } : {}) }); - const headers = flattenHeaders(res.response_headers); - await drain(res.stream); - return { - ok: true, - headers, - hasSummary: 'x-clickhouse-summary' in headers, - hasQueryId: 'x-clickhouse-query-id' in headers, - }; -} - -/** Plan §18 "query ID" — caller allocates the ID before execution; it must - * be preserved verbatim, both on the vendor result and the response header. */ -async function queryIdScenario(client: ClickHouseClient, auth?: SpikeAuth): Promise { - const callerId = crypto.randomUUID(); - const res = await client.exec({ query: 'SELECT 1', query_id: callerId, ...(auth ? { auth } : {}) }); - const headers = flattenHeaders(res.response_headers); - await drain(res.stream); - return { - ok: true, - callerId, - queryId: res.query_id, - headerQueryId: headers['x-clickhouse-query-id'] ?? null, - matches: res.query_id === callerId, - }; -} - -/** Plan §24 "Raw and export byte proof" — a deliberately deterministic, - * server-version-independent literal (never `.text()`/`TextDecoder`, exactly - * `official-adapter.ts`'s raw path: `IncrementalSha256` over the raw - * `Uint8Array` chunks straight off `exec()`'s `.stream`). */ -async function rawBytes(client: ClickHouseClient, auth?: SpikeAuth): Promise { - // `toString()` of a small non-negative integer is its plain decimal - // representation on every ClickHouse version — deliberately avoiding any - // formatting function (e.g. hex()) whose exact digit-padding behavior - // this harness has not independently verified against a real server. - // browser.spec.js computes the SAME expected literal independently. - const sql = 'SELECT number, toString(number) FROM system.numbers LIMIT 3\nFORMAT TSV'; - const res = await client.exec({ query: sql, ...(auth ? { auth } : {}) }); - const hash = new IncrementalSha256(); - const reader = res.stream.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - hash.update(value); - } - return { ok: true, sha256: await hash.digestHex(), length: hash.totalBytes }; -} - -export async function runScenario(request: ScenarioRequest): Promise { - try { - if (request.scenario === 'construct') return await construct(request.url, request.rowHeader); - const client = clientFor(request.url, request.rowHeader); - switch (request.scenario) { - case 'ordinaryQuery': return await ordinaryQuery(client, request.auth); - case 'progressiveFirstRow': return await progressiveFirstRow(client, request.auth); - case 'basicAuth': return await basicAuth(client, request.auth, request.authB); - case 'cancelDuringStreaming': return await cancelDuringStreaming(client, request.auth); - case 'responseHeaders': return await responseHeaders(client, request.auth); - case 'queryId': return await queryIdScenario(client, request.auth); - case 'rawBytes': return await rawBytes(client, request.auth); - default: return { ok: false, error: `unknown scenario: ${String(request.scenario)}` }; - } - } catch (e) { - const message = e instanceof ClickHouseError ? `ClickHouseError: ${e.message}` : e instanceof Error ? e.message : String(e); - return { ok: false, error: message }; - } -} - -declare global { - interface Window { - __spikeRun?: typeof runScenario; - __spikeReady?: boolean; - } -} diff --git a/tests/spike/clickhouse-client/browser.spec.js b/tests/spike/clickhouse-client/browser.spec.js deleted file mode 100644 index 2e3de527..00000000 --- a/tests/spike/clickhouse-client/browser.spec.js +++ /dev/null @@ -1,426 +0,0 @@ -// Phase 0 / issue #585, plan §14 "Same-origin, CORS, and browser harness" -// and §25 "Browser and deployment matrix" — the actual Chromium/WebKit -// coverage plan §25's table requires per row/origin: client construction, -// ordinary query, progressive first row, request-local Basic auth, -// cancellation during streaming, response headers, query ID, raw bytes, and -// a network recorder proving no external runtime import. Driven through -// `spike-server.mjs` (started by `playwright.config.js`'s `webServer`), -// which owns the real Docker ClickHouse row(s) for this run. -// -// ASB_SPIKE_BROWSER_ROWS selects which matrix.json row(s) to cover — MUST -// stay in sync with spike-server.mjs's own read of the same variable (both -// default to "current-stable-oss" alone; this sub-task's doneWhen only -// requires that one row — set the variable to a comma-separated list -// matching plan §25's full table to cover the rest). -import { test, expect } from '@playwright/test'; -import { createHash, randomBytes } from 'node:crypto'; -import { createServer as createHttpServer } from 'node:http'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { mkdtemp, writeFile, chmod, rm, readFile as readFileAsync } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, extname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { buildArtifact } from '../../../build/build.mjs'; - -const execFileAsync = promisify(execFile); -const here = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(here, '../../..'); - -const ROW_KEYS = (process.env.ASB_SPIKE_BROWSER_ROWS || 'current-stable-oss') - .split(',').map((s) => s.trim()).filter(Boolean); - -// Independently-computed expected raw bytes for browser-harness.ts's -// `rawBytes` scenario's exact literal query — never derived from either -// adapter's own output (this repo's "independent expected outcome" -// convention, plan §15). -const RAW_BYTES_EXPECTED_TEXT = '0\t0\n1\t1\n2\t2\n'; -const RAW_BYTES_EXPECTED_SHA256 = createHash('sha256').update(RAW_BYTES_EXPECTED_TEXT, 'utf8').digest('hex'); -const RAW_BYTES_EXPECTED_LENGTH = Buffer.byteLength(RAW_BYTES_EXPECTED_TEXT, 'utf8'); - -/** @type {Record, role: string, serverVersion: string }>} */ -let rowsInfo; - -test.beforeAll(async ({ baseURL }) => { - const resp = await fetch(`${baseURL}/__rows.json`); - if (!resp.ok) throw new Error(`spike-server.mjs /__rows.json returned HTTP ${resp.status} — is it still booting? check /__health`); - rowsInfo = await resp.json(); - for (const rowKey of ROW_KEYS) { - if (!rowsInfo[rowKey]) { - throw new Error(`spike-server.mjs did not boot row "${rowKey}" (booted: ${Object.keys(rowsInfo).join(', ') || 'none'}) — set ASB_SPIKE_BROWSER_ROWS to match on both sides`); - } - } -}); - -// Plan §14 "records browser versions" — printed to the Playwright test log -// (docs/evidence/585/environment.json's own capture of this is a later -// sub-task's job, per this plan's execution order §34.G/H). -test('records the launched browser version', async ({ page, browserName }) => { - const version = page.context().browser()?.version() ?? 'unknown'; - // eslint-disable-next-line no-console - console.log(`asb585 browser matrix: ${browserName} ${version}`); - expect(version.length).toBeGreaterThan(0); -}); - -for (const rowKey of ROW_KEYS) { - for (const mode of ['same-origin', 'cross-origin']) { - test.describe(`row=${rowKey} origin=${mode}`, () => { - /** @type {string} */ - let targetUrl; - /** @type {string | undefined} */ - let rowHeader; - /** @type {Set} */ - let seenOrigins; - /** @type {string} */ - let pageOrigin; - - test.beforeEach(async ({ page, baseURL }) => { - if (mode === 'same-origin') { - // The proxy lives at spike-server.mjs's own origin root; row - // selection happens via the client-level http_headers default - // browser-harness.ts wires up from `rowHeader` — see - // spike-server.mjs's header docstring for why this is a header, - // never a URL path segment. - targetUrl = baseURL; - rowHeader = rowKey; - } else { - // Direct cross-origin mode: the row's OWN loopback URL, a - // different port from spike-server.mjs's own origin — genuinely - // cross-origin by the browser's own origin model, relying purely - // on clickhouse-containers.mjs's CORS configuration. - targetUrl = rowsInfo[rowKey].crossOriginUrl; - rowHeader = undefined; - } - seenOrigins = new Set(); - page.on('request', (req) => { - try { seenOrigins.add(new URL(req.url()).origin); } catch { /* non-HTTP scheme (e.g. about:) — ignore */ } - }); - await page.goto('/tests/spike/clickhouse-client/browser-harness.html'); - await page.waitForFunction(() => window.__spikeReady === true); - pageOrigin = new URL(page.url()).origin; - // Sanity: the two modes really do exercise different origin - // relationships — a same-origin test whose target isn't actually - // same-origin (or vice versa) would silently validate nothing. - const targetOrigin = new URL(targetUrl).origin; - if (mode === 'same-origin') expect(targetOrigin).toBe(pageOrigin); - else expect(targetOrigin).not.toBe(pageOrigin); - }); - - test('client construction', async ({ page }) => { - const result = await page.evaluate( - ({ url, header }) => window.__spikeRun({ scenario: 'construct', url, rowHeader: header }), - { url: targetUrl, header: rowHeader }, - ); - expect(result.ok, result.error).toBe(true); - }); - - test('ordinary query', async ({ page }) => { - const auth = rowsInfo[rowKey].fixtureUsers.basicA; - const result = await page.evaluate( - ({ url, header, auth: a }) => window.__spikeRun({ scenario: 'ordinaryQuery', url, rowHeader: header, auth: a }), - { url: targetUrl, header: rowHeader, auth }, - ); - expect(result.ok, result.error).toBe(true); - expect(result.rowCount).toBe(5); - }); - - test('progressive first row', async ({ page }) => { - const auth = rowsInfo[rowKey].fixtureUsers.basicA; - const result = await page.evaluate( - ({ url, header, auth: a }) => window.__spikeRun({ scenario: 'progressiveFirstRow', url, rowHeader: header, auth: a }), - { url: targetUrl, header: rowHeader, auth }, - ); - expect(result.ok, result.error).toBe(true); - expect(result.rowCount).toBe(6); - expect(result.progressive, JSON.stringify(result)).toBe(true); - }); - - test('request-local Basic auth', async ({ page }) => { - const { basicA, basicB } = rowsInfo[rowKey].fixtureUsers; - const result = await page.evaluate( - ({ url, header, auth, authB }) => window.__spikeRun({ scenario: 'basicAuth', url, rowHeader: header, auth, authB }), - { url: targetUrl, header: rowHeader, auth: basicA, authB: basicB }, - ); - expect(result.ok, result.error).toBe(true); - expect(result.matchesA).toBe(true); - expect(result.matchesB).toBe(true); - expect(result.defaultRejected, 'the client-level default (deliberately invalid) credential must never become authoritative').toBe(true); - }); - - test('cancellation during streaming', async ({ page }) => { - const auth = rowsInfo[rowKey].fixtureUsers.basicA; - const result = await page.evaluate( - ({ url, header, auth: a }) => window.__spikeRun({ scenario: 'cancelDuringStreaming', url, rowHeader: header, auth: a }), - { url: targetUrl, header: rowHeader, auth }, - ); - expect(result.ok, result.error).toBe(true); - expect(result.cancelled).toBe(true); - expect(result.rowCountAtAbort).toBeLessThan(40); - expect(result.noLaterRows, JSON.stringify(result)).toBe(true); - }); - - test('response headers', async ({ page }) => { - const auth = rowsInfo[rowKey].fixtureUsers.basicA; - const result = await page.evaluate( - ({ url, header, auth: a }) => window.__spikeRun({ scenario: 'responseHeaders', url, rowHeader: header, auth: a }), - { url: targetUrl, header: rowHeader, auth }, - ); - expect(result.ok, result.error).toBe(true); - expect(result.hasSummary, JSON.stringify(result.headers)).toBe(true); - expect(result.hasQueryId, JSON.stringify(result.headers)).toBe(true); - }); - - test('query ID', async ({ page }) => { - const auth = rowsInfo[rowKey].fixtureUsers.basicA; - const result = await page.evaluate( - ({ url, header, auth: a }) => window.__spikeRun({ scenario: 'queryId', url, rowHeader: header, auth: a }), - { url: targetUrl, header: rowHeader, auth }, - ); - expect(result.ok, result.error).toBe(true); - expect(result.matches, JSON.stringify(result)).toBe(true); - expect(result.headerQueryId).toBe(result.callerId); - }); - - test('raw bytes', async ({ page }) => { - const auth = rowsInfo[rowKey].fixtureUsers.basicA; - const result = await page.evaluate( - ({ url, header, auth: a }) => window.__spikeRun({ scenario: 'rawBytes', url, rowHeader: header, auth: a }), - { url: targetUrl, header: rowHeader, auth }, - ); - expect(result.ok, result.error).toBe(true); - expect(result.length).toBe(RAW_BYTES_EXPECTED_LENGTH); - expect(result.sha256).toBe(RAW_BYTES_EXPECTED_SHA256); - }); - - test('no external runtime import', async ({ page }) => { - // Drive one real request through the page so there is real traffic - // to inspect, not just the static page load. - const auth = rowsInfo[rowKey].fixtureUsers.basicA; - await page.evaluate( - ({ url, header, auth: a }) => window.__spikeRun({ scenario: 'ordinaryQuery', url, rowHeader: header, auth: a }), - { url: targetUrl, header: rowHeader, auth }, - ); - const targetOrigin = new URL(targetUrl).origin; - const allowed = new Set([pageOrigin, targetOrigin]); - for (const origin of seenOrigins) { - expect(allowed.has(origin), `unexpected network origin observed: ${origin} (allowed: ${[...allowed].join(', ')})`).toBe(true); - } - // The bundled client-web module itself is served from the page's - // own origin (spike-server.mjs's /__clickhouse-client-web.mjs), so a - // real request for it must appear in the recorder — proving the - // recorder is actually observing traffic, not vacuously empty. - expect(seenOrigins.has(pageOrigin)).toBe(true); - }); - }); - } -} - -// ── Plan §26 "CSP and self-contained artifact" ────────────────────────────── -// The candidate build (§9 "Candidate entry") already proves, at the metafile -// level, that `@clickhouse/client-web` CAN be bundled into one self-contained -// artifact (`tests/unit/client-web-spike-policy.test.js`, part of the -// normal coverage-gated `npm test` tree). What that unit test CANNOT prove — -// happy-dom enforces no CSP and makes no real network calls — is that the -// resulting artifact actually RUNS, in a real browser, under a real CSP -// header, with zero external traffic. That is this section's job, covering -// both plan §26 serving modes: "local static" (a bare directory server, no -// CSP header at all — the baseline self-containment proof) and the -// "existing... Caddy/container deployment shape" (the REAL, unmodified -// `deploy/caddy/Caddyfile` served by the cached `caddy:2.8.4-alpine` base -// image this repository's own Dockerfile also starts from — never a -// reimplemented policy, and never touching the repository's own `dist/` or -// the real deployment image). - -const CANDIDATE_ENTRY = 'tests/spike/clickhouse-client/candidate-entry.ts'; -const CANDIDATE_NOTICES_PATH = resolve(repoRoot, 'tests/spike/clickhouse-client/candidate-third-party-notices.md'); -const PACKAGE_INPUT_RE = /^node_modules\/@clickhouse\/client-web\//; -// Deliberately empty: this check has no real IdP/ClickHouse host to allow, -// so the CSP's connect-src stays 'self'-only — plan §26 "normal CSP, -// changing only required connect-src" (here, nothing is required). -const CANDIDATE_CONNECT_SRC = ''; -const CANDIDATE_CONFIG_JSON = JSON.stringify({ basic_login: true, idps: [] }); - -async function docker(args) { - const { stdout } = await execFileAsync('docker', args, { maxBuffer: 16 * 1024 * 1024 }); - return stdout; -} - -/** A bare, no-CSP-header, no-compression static directory server — plan - * §26's "local static mode". Deliberately minimal: this mode's only job is - * proving self-containment independent of any CSP enforcement. */ -function serveDirectoryStatic(dir) { - return createHttpServer(async (req, res) => { - const url = new URL(req.url, 'http://internal/'); - let pathname = decodeURIComponent(url.pathname); - if (pathname === '/') pathname = '/sql.html'; - const filePath = join(dir, pathname.replace(/^\/+/, '')); - if (resolve(filePath) !== resolve(dir) && !resolve(filePath).startsWith(resolve(dir) + '/')) { res.writeHead(403).end(); return; } - try { - const body = await readFileAsync(filePath); - const type = extname(filePath) === '.json' ? 'application/json; charset=utf-8' : 'text/html; charset=utf-8'; - res.writeHead(200, { 'content-type': type }).end(body); - } catch { - res.writeHead(404).end(); - } - }); -} - -function listenEphemeral(server) { - return new Promise((resolvePromise, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolvePromise(server.address().port)); - }); -} - -/** Boots the cached `caddy:2.8.4-alpine` base image (the exact base this - * repository's own Dockerfile builds from) against the REAL, unmodified - * `deploy/caddy/Caddyfile`, bind-mounting only the candidate artifact and a - * minimal external-reference-free `config.json` — never the repository's - * real `dist/`, never a rewritten Caddyfile. Every bind source lives under - * `$TMPDIR` (the caller's `candidateDir`), matching this repo's Docker - * sandbox rule everywhere else. Waits for `/healthz` before returning. */ -async function bootCandidateCaddy(candidateDir) { - const name = `asb585-candidate-caddy-${randomBytes(4).toString('hex')}`; - await docker([ - 'run', '-d', '--name', name, - '--label', 'com.altinity.sql-browser.spike585.candidate=1', - '-p', '127.0.0.1::8080', - '-v', `${join(candidateDir, 'sql.html')}:/app/sql.html:ro`, - '-v', `${resolve(repoRoot, 'deploy/caddy/Caddyfile')}:/etc/caddy/Caddyfile:ro`, - '-v', `${join(candidateDir, 'config.json')}:/config/config.json:ro`, - '-e', `CONNECT_SRC=${CANDIDATE_CONNECT_SRC}`, - 'caddy:2.8.4-alpine', - ]); - const stop = async () => { try { await docker(['rm', '-f', name]); } catch { /* best-effort */ } }; - try { - const portOut = await docker(['port', name, '8080/tcp']); - const port = Number(portOut.trim().split('\n')[0].split(':').pop()); - const deadline = Date.now() + 30_000; - for (;;) { - try { - const resp = await fetch(`http://127.0.0.1:${port}/healthz`); - if (resp.ok) break; - } catch { /* still starting */ } - if (Date.now() > deadline) throw new Error('asb585-candidate-caddy: /healthz never became ready'); - // eslint-disable-next-line no-await-in-loop -- polling readiness, not a hot loop. - await new Promise((r) => setTimeout(r, 300)); - } - return { name, port, stop }; - } catch (e) { - await stop(); - throw e; - } -} - -/** Every `securitypolicyviolation` DOM event, captured from the very first - * script tick — more reliable across Chromium/WebKit than scraping console - * text, and works whether or not a real CSP header is present (an - * unenforced page fires none, which is itself a meaningful assertion for - * the local-static mode below). */ -async function withViolationRecorder(page) { - await page.addInitScript(() => { - window.__cspViolations = []; - document.addEventListener('securitypolicyviolation', (e) => { - window.__cspViolations.push({ blockedURI: e.blockedURI, violatedDirective: e.violatedDirective }); - }); - }); -} - -async function readViolations(page) { - return page.evaluate(() => window.__cspViolations ?? []); -} - -test.describe('candidate artifact — CSP and self-containment (plan §26)', () => { - /** @type {string} */ - let candidateDir; - /** @type {import('../../../build/build.mjs').BuildArtifactResult} */ - let candidateBuild; - /** @type {{ metafile: import('esbuild').Metafile }} */ - let normalBuild; - - test.beforeAll(async () => { - const additionalNotices = await readFileAsync(CANDIDATE_NOTICES_PATH, 'utf8'); - [candidateBuild, normalBuild] = await Promise.all([ - buildArtifact({ entryPoint: CANDIDATE_ENTRY, metafile: true, additionalNotices }), - buildArtifact({ metafile: true }), - ]); - candidateDir = await mkdtemp(join(tmpdir(), 'asb585-candidate-')); - const htmlPath = join(candidateDir, 'sql.html'); - const configPath = join(candidateDir, 'config.json'); - await writeFile(htmlPath, candidateBuild.html); - await writeFile(configPath, CANDIDATE_CONFIG_JSON); - // Bind-mounted read-only into a container that may run as a non-root - // uid (this repo's own Dockerfile's uid 101) — world-readable, matching - // that Dockerfile's own `chmod 0644` step for the exact same reason. - await chmod(htmlPath, 0o644); - await chmod(configPath, 0o644); - }); - - test.afterAll(async () => { - if (candidateDir) await rm(candidateDir, { recursive: true, force: true }); - }); - - test('normal build excludes @clickhouse/client-web; candidate includes it; both are one self-contained HTML file', async () => { - const candidateInputs = Object.keys(candidateBuild.metafile.inputs); - const normalInputs = Object.keys(normalBuild.metafile.inputs); - expect(candidateInputs.some((p) => PACKAGE_INPUT_RE.test(p))).toBe(true); - expect(normalInputs.some((p) => PACKAGE_INPUT_RE.test(p))).toBe(false); - for (const html of [candidateBuild.html, normalBuild.html]) { - expect(html).not.toMatch(/]*\ssrc\s*=/i); - expect(html).not.toMatch(/]*\shref\s*=\s*["'](https?:)?\/\//i); - } - }); - - test('local-static mode: candidate loads with zero external network requests', async ({ browser }) => { - const server = serveDirectoryStatic(candidateDir); - const port = await listenEphemeral(server); - const page = await browser.newPage(); - try { - await withViolationRecorder(page); - const seenOrigins = new Set(); - page.on('request', (req) => { try { seenOrigins.add(new URL(req.url()).origin); } catch { /* ignore */ } }); - await page.goto(`http://127.0.0.1:${port}/`); - await page.waitForLoadState('networkidle'); - const pageOrigin = `http://127.0.0.1:${port}`; - for (const origin of seenOrigins) { - expect(origin, `unexpected network origin in local-static mode: ${origin}`).toBe(pageOrigin); - } - // No CSP header exists in this mode, so no violation can fire — this - // is the "the artifact itself makes no disallowed request" baseline, - // independent of CSP enforcement. - expect(await readViolations(page)).toEqual([]); - } finally { - await page.close(); - server.close(); - } - }); - - test('Caddy-shaped deployment mode: real CSP enforced, zero external requests, no unsafe-eval', async ({ browser }) => { - const caddy = await bootCandidateCaddy(candidateDir); - try { - const headResp = await fetch(`http://127.0.0.1:${caddy.port}/sql`); - const csp = headResp.headers.get('content-security-policy'); - expect(csp, 'deploy/caddy/Caddyfile must set a Content-Security-Policy header').not.toBeNull(); - expect(csp).not.toMatch(/unsafe-eval/); - expect(csp).toContain("connect-src 'self'"); - - const page = await browser.newPage(); - try { - await withViolationRecorder(page); - const seenOrigins = new Set(); - page.on('request', (req) => { try { seenOrigins.add(new URL(req.url()).origin); } catch { /* ignore */ } }); - await page.goto(`http://127.0.0.1:${caddy.port}/sql`); - await page.waitForLoadState('networkidle'); - const pageOrigin = `http://127.0.0.1:${caddy.port}`; - for (const origin of seenOrigins) { - expect(origin, `unexpected network origin under the real Caddy CSP: ${origin}`).toBe(pageOrigin); - } - expect(await readViolations(page), 'the deployed CSP must not be violated by the app\'s own normal load').toEqual([]); - } finally { - await page.close(); - } - } finally { - await caddy.stop(); - } - }); -}); diff --git a/tests/spike/clickhouse-client/candidate-entry.ts b/tests/spike/clickhouse-client/candidate-entry.ts deleted file mode 100644 index 47865a03..00000000 --- a/tests/spike/clickhouse-client/candidate-entry.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Phase 0 / issue #585 — the candidate build entry point (plan §9 "Candidate -// entry"; measured by `build/build.mjs`'s `entryPoint` option and asserted -// by `tests/unit/client-web-spike-policy.test.js`). -// -// This file is NEVER used by normal `npm run build`, which always points -// `entryPoint` at its default, `src/main.ts` (see build/build.mjs's -// `esbuildOptions()`/`buildArtifact()`) — nothing in the normal production -// graph imports this file or anything it imports, and no normal build ever -// names it as an entry point. It exists solely so a MEASUREMENT-ONLY -// candidate build — -// -// buildArtifact({ entryPoint: 'tests/spike/clickhouse-client/candidate-entry.ts', ... }) -// -// — can prove, through esbuild's own metafile, that `@clickhouse/client-web` -// and the spike's official-side adapter CAN be bundled into one self- -// contained, CSP-compatible artifact, without that artifact ever reaching a -// real user or the normal production graph. It contains no endpoint and no -// credential, and it never constructs a client, calls fetch, or otherwise -// executes anything at module-load time beyond the non-executing -// registration below. -// -// It: -// * imports the REAL production entry (`src/main.ts`) unmodified, so the -// candidate artifact stays a strict superset of the normal one, never a -// divergent replica (plan §9: "import the normal production entry"); -// * imports the official spike adapter — the ONLY module in this -// repository that imports `@clickhouse/client-web` (see -// official-adapter.ts's own header comment); -// * "retains" the adapter's exports through a spike-only, NON-EXECUTING -// global registration (plan §9: "retain the adapter through a spike- -// only, non-executing global registration so it cannot be tree- -// shaken"): assigning the imported function REFERENCES to a -// `globalThis` slot is enough to make esbuild's tree-shaker treat them -// as used — and therefore keep them, and the vendor package, in the -// bundle — without ever CALLING any of them. No client is constructed, -// no fetch runs, no network request is made anywhere in this file. -// -// This module executes ONLY when explicitly selected as the esbuild entry -// point for a measurement/CSP candidate build. It is not reachable from -// `src/main.ts`, and importing `src/main.ts` from here does not create an -// import cycle back into this file — the dependency direction is one-way -// (candidate entry -> production entry), never the reverse. -import '../../../src/main.js'; -import { - createOfficialConnection, - officialAuthFor, - runOfficial, - runOfficialRefreshThenRetry, - makeOfficialQueryExecutionAdapter, -} from './official-adapter.js'; - -declare global { - // eslint-disable-next-line no-var - var __ASB_SPIKE_CANDIDATE_CLIENT_WEB__: unknown; -} - -// Non-executing: stores function REFERENCES only, never invokes any of -// them. This assignment is the "spike-only, non-executing global -// registration" plan §9 requires — its only purpose is to keep esbuild's -// tree-shaker from discarding the official-adapter import (and therefore -// `@clickhouse/client-web` itself) as dead code, so the candidate metafile -// can prove the package was actually bundled. -globalThis.__ASB_SPIKE_CANDIDATE_CLIENT_WEB__ = { - createOfficialConnection, - officialAuthFor, - runOfficial, - runOfficialRefreshThenRetry, - makeOfficialQueryExecutionAdapter, -}; diff --git a/tests/spike/clickhouse-client/candidate-third-party-notices.md b/tests/spike/clickhouse-client/candidate-third-party-notices.md deleted file mode 100644 index 28e8ea5b..00000000 --- a/tests/spike/clickhouse-client/candidate-third-party-notices.md +++ /dev/null @@ -1,87 +0,0 @@ -Phase 0 / issue #585 (plan §9 "Candidate notices"). This fragment is -APPENDED ONLY to the isolated CANDIDATE artifact's embedded third-party -notices (via `build/build.mjs`'s `additionalNotices` option, e.g. from -`buildArtifact({ entryPoint: 'tests/spike/clickhouse-client/candidate-entry.ts', additionalNotices })`), -never to the repository's normal `THIRD-PARTY-NOTICES.md` or to the normal -production artifact (`dist/sql.html`, built from `src/main.ts`). -`@clickhouse/client-web` is a test-only `devDependency` (see `package.json`) -that is NOT shipped in any normal build — it reaches an assembled HTML -artifact only when a Phase 0 measurement/CSP candidate build explicitly -names `tests/spike/clickhouse-client/candidate-entry.ts` as its esbuild -entry point (see that file's own header). This fragment exists so THAT one -isolated artifact still carries the vendor package's required license -notice, exactly as every other bundled runtime dependency's notice is -reproduced in `THIRD-PARTY-NOTICES.md`. Same reason this file avoids an -HTML `` wrapper of its own: `buildArtifact()` combines the base -notices with this fragment and wraps the WHOLE result in one outer HTML -comment before embedding it, sanitizing any `--`/`-->` sequence in the -combined text — a second, nested comment marker here would just get -mangled by that sanitizer for no benefit. - ---- - -## @clickhouse/client-web — v1.23.1 (candidate build only — not in normal `dist/sql.html`) - -Apache License, Version 2.0 - -Copyright 2016-2024 ClickHouse, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - -Full license text (Apache License, Version 2.0, January 2004): - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - -"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. - -"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. - -"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - -"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. - -"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. - -"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. - -"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). - -"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - -"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." - -"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - -(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and - -(b) You must cause any modified files to carry prominent notices stating that You changed the files; and - -(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and - -(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. - -You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS diff --git a/tests/spike/clickhouse-client/clickhouse-containers.mjs b/tests/spike/clickhouse-client/clickhouse-containers.mjs deleted file mode 100644 index f237f46d..00000000 --- a/tests/spike/clickhouse-client/clickhouse-containers.mjs +++ /dev/null @@ -1,556 +0,0 @@ -// Phase 0 / issue #585, plan §12 "Temporary files and Docker orchestration" -// and §13 "Required matrix rows". A dependency-free Node orchestrator that -// boots one real ClickHouse server (OSS or Altinity Stable, resolved by -// `matrix.json`) in Docker for the live-server test specs -// (`live-parity.test.ts`/`live-precision.test.ts`/`live-sessions.test.ts`). -// -// Kept as plain `.mjs` (not `.ts`) per plan §8, matching `fault-server.mjs`'s -// precedent: Node orchestration files stay untyped. -// -// CRITICAL environment rule (this sandbox's own CLAUDE.md, and plan §12): -// Docker bind mounts from `/tmp` are BLOCKED here — every generated config -// file this module mounts into a container MUST live under `$TMPDIR`, never -// `/tmp` directly (they're different paths in this environment; `/tmp` is -// shared across sandbox users and rejected by the Docker daemon's own mount -// allowlist). `assertUnderSpikeTmp` enforces this before every `-v` flag this -// module constructs — never bypass it. -// -// DISCOVERED FOOTGUN — bind-mounting OVER /etc/clickhouse-server/config.d -// HIDES THE BASE IMAGE'S OWN docker_related_config.xml (verified empirically -// while building this module, with repeated live-container trials — record -// in memory if you hit it again): the official `clickhouse/clickhouse-server` -// image ships that one file inside `config.d/` specifically to override -// `` from the base config's loopback-only default to `::` / -// `0.0.0.0` ("Listen wildcard address to allow accepting connections from -// other containers and host network"). A bind mount TARGETING -// `config.d` itself (as opposed to a subdirectory or a different config -// path) REPLACES the whole directory, silently losing that file — the -// server then binds loopback-only INSIDE its own network namespace, so -// `docker exec ... wget http://127.0.0.1:8123/` still succeeds (loopback, -// from inside the same namespace) while the HOST's published port NAT- -// forwards a TCP connection that ClickHouse's own listener never accepts on -// that interface — curl sees "Connected... Empty reply from server" (exit -// 52) forever, indistinguishable from a slow cold start unless you check -// `docker exec` reachability too. This has NOTHING to do with -// `$DOCKER_NETWORK` attachment (a customs-network red herring this module's -// author chased first — both `--network`-attached and default-bridge-only -// containers reproduce it identically once `config.d` is shadowed, and -// neither reproduces it once the override below is restored). -// -// FIX: this module's own generated `config.d` always ships an EQUIVALENT -// `` override (`CORS_CONFIG_XML`, below) alongside its CORS -// settings, so mounting over `config.d` never loses that behavior. - -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs'; -import { join, sep, resolve as resolvePath } from 'node:path'; -import { randomBytes, randomUUID } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; - -const execFileAsync = promisify(execFile); -const here = dirname(fileURLToPath(import.meta.url)); -const DEFAULT_MATRIX_PATH = join(here, 'matrix.json'); - -/** The Docker label key every container this module creates carries. The - * value is a per-process run ID (see `RUN_ID` below) so a crashed/leaked run - * can still be identified and swept by `stopAllOrphans()` even from a fresh - * process — plan §12 "use unique names and labels" / "remove only containers - * carrying the run label". */ -export const RUN_LABEL_KEY = 'com.altinity.sql-browser.spike585'; - -/** One value per Node process invocation of this module — every container - * `startRow` creates in THIS process carries this exact label value, so - * `stopAll()` (no args) cleans up only what THIS run started, never a - * concurrent run's containers. */ -export const RUN_ID = `run-${Date.now()}-${randomBytes(4).toString('hex')}`; - -// ── Non-secret fixture credentials ────────────────────────────────────────── -// MUST stay byte-identical to `auth-fixtures.ts`'s BASIC_USER_A/B/DENIED_USER -// (cross-referenced there too) — this file is plain `.mjs` and therefore -// cannot import that `.ts` module directly under a bare `node -// clickhouse-containers.mjs` invocation (no vitest/esbuild transform present -// outside the test runner), so the two non-secret literal sets are kept in -// sync by comment cross-reference rather than a shared import. Every value -// here is a throwaway, container-local, non-secret fixture (plan §12 "create -// non-secret users"). -export const FIXTURE_USERS = { - basicA: { username: 'asb_spike_a', password: 'asb-spike-a-nonsecret' }, - basicB: { username: 'asb_spike_b', password: 'asb-spike-b-nonsecret' }, - denied: { username: 'asb_spike_denied', password: 'asb-spike-denied-nonsecret' }, -}; -export const FIXTURE_ROLE = 'asb_spike_role'; - -// ── Env preflight (plan §12: "require non-empty $DOCKER_NETWORK" / "$TMPDIR") ─ - -/** Throws unless both `$DOCKER_NETWORK` and `$TMPDIR` are non-empty. Never - * defaults either — a missing value is a hard stop, not a fallback to `/tmp` - * or the default bridge network. */ -export function requireEnv(env = process.env) { - const dockerNetwork = env.DOCKER_NETWORK; - const tmpdir = env.TMPDIR; - if (!dockerNetwork) throw new Error('clickhouse-containers: $DOCKER_NETWORK must be set and non-empty (never falls back to the default bridge)'); - if (!tmpdir) throw new Error('clickhouse-containers: $TMPDIR must be set and non-empty (never falls back to /tmp — Docker bind mounts from /tmp are blocked in this environment)'); - return { dockerNetwork, tmpdir }; -} - -/** `mktemp -d "$TMPDIR/asb-585.XXXXXX"` (plan §12's exact pattern), returning - * the created directory's absolute, symlink-resolved path. */ -export function createSpikeTmp(env = process.env) { - const { tmpdir } = requireEnv(env); - const dir = mkdtempSync(join(tmpdir, 'asb-585.')); - return realpathSync(dir); -} - -/** Throws unless `candidateAbs` resolves to a path strictly beneath - * `spikeTmpAbs` (both already-resolved, symlink-free absolute paths) — the - * preflight every bind-mount source this module constructs must pass (plan - * §12 "resolve and verify every bind source stays beneath $SPIKE_TMP" / - * §35 sabotage case 26 "bind-mount from /tmp"). */ -export function assertUnderSpikeTmp(candidateAbs, spikeTmpAbs) { - const candidate = resolvePath(candidateAbs); - const base = resolvePath(spikeTmpAbs); - if (candidate !== base && !candidate.startsWith(base + sep)) { - throw new Error(`clickhouse-containers: refusing to bind-mount "${candidate}" — it is not beneath SPIKE_TMP ("${base}")`); - } - return candidate; -} - -// ── matrix.json ────────────────────────────────────────────────────────────── - -export function loadMatrix(matrixPath = DEFAULT_MATRIX_PATH) { - return JSON.parse(readFileSync(matrixPath, 'utf8')); -} - -/** Resolve one matrix.json row by key, or pass a fully-formed row object - * straight through (tests occasionally want a row that isn't in matrix.json, - * e.g. a throwaway digest). Throws on an unknown key, a `cloud`/conditional - * row (no `pullRef` — nothing to boot), or a row missing `pullRef`. */ -export function resolveRow(rowKeyOrRow, matrixPath = DEFAULT_MATRIX_PATH) { - if (typeof rowKeyOrRow === 'object' && rowKeyOrRow !== null) { - if (!rowKeyOrRow.pullRef) throw new Error('clickhouse-containers: row object has no pullRef to boot'); - return rowKeyOrRow; - } - const matrix = loadMatrix(matrixPath); - const row = matrix.rows[rowKeyOrRow]; - if (!row) throw new Error(`clickhouse-containers: no matrix.json row named "${rowKeyOrRow}" (known rows: ${Object.keys(matrix.rows).join(', ')})`); - if (!row.pullRef) throw new Error(`clickhouse-containers: matrix.json row "${rowKeyOrRow}" has no pullRef — it is conditional/not resolved (${row.status || 'unknown reason'})`); - return row; -} - -// ── CORS + exposed-headers config (plan §12/§14) ──────────────────────────── - -const CORS_CONFIG_XML = ` - - :: - 0.0.0.0 - 1 - -
Access-Control-Allow-Origin*
-
Access-Control-Allow-HeadersAuthorization, Content-Type, X-ClickHouse-Format
-
Access-Control-Allow-MethodsPOST, GET, OPTIONS
-
Access-Control-Expose-HeadersX-ClickHouse-Summary, X-ClickHouse-Query-Id, X-ClickHouse-Exception-Tag, X-ClickHouse-Format, X-ClickHouse-Timezone
-
-
-`; - -/** Write the read-only CORS/exposed-headers config ClickHouse merges from - * `config.d/` under `${spikeTmp}//config.d/cors.xml`, verifying the - * result stays under `spikeTmp` before returning it. */ -function writeRowConfig(spikeTmp, rowLabel) { - const configDir = join(spikeTmp, rowLabel, 'config.d'); - assertUnderSpikeTmp(configDir, spikeTmp); - mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, 'cors.xml'), CORS_CONFIG_XML, 'utf8'); - return configDir; -} - -// ── docker CLI helpers ─────────────────────────────────────────────────────── - -async function docker(args, opts = {}) { - try { - const { stdout } = await execFileAsync('docker', args, { maxBuffer: 32 * 1024 * 1024, ...opts }); - return stdout; - } catch (e) { - const stderr = e && typeof e === 'object' && 'stderr' in e ? String(e.stderr) : ''; - throw new Error(`clickhouse-containers: docker ${args.join(' ')} failed: ${stderr || (e instanceof Error ? e.message : String(e))}`); - } -} - -async function httpPost(url, { username, password, body, timeoutMs = 5000 }) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const resp = await fetch(url, { - method: 'POST', - body, - headers: { Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` }, - signal: controller.signal, - }); - const text = await resp.text(); - return { ok: resp.ok, status: resp.status, text }; - } finally { - clearTimeout(timer); - } -} - -function sleep(ms) { - return new Promise((r) => setTimeout(r, ms)); -} - -/** Poll `SELECT 1` with the given admin credential until it succeeds, a hard - * failure is observed (container exited), or `maxWaitMs` elapses. Cold start - * under this sandbox's amd64-under-emulation Docker runtime has been - * observed to take up to ~2 minutes — the default budget is generous on - * purpose; a genuinely broken container fails fast via the `docker inspect` - * exited-state check rather than waiting out the whole budget. */ -async function waitForReady(containerName, url, admin, { maxWaitMs = 180_000, pollIntervalMs = 2000 } = {}) { - const deadline = Date.now() + maxWaitMs; - for (;;) { - try { - const { ok, text } = await httpPost(url, { ...admin, body: 'SELECT 1', timeoutMs: 3000 }); - if (ok && text.trim() === '1') return; - } catch { /* connection refused/reset while starting — keep polling */ } - let status = 'unknown'; - try { - status = (await docker(['inspect', '--format', '{{.State.Status}}', containerName])).trim(); - } catch { /* container may not be inspectable yet on the very first tick */ } - if (status === 'exited' || status === 'dead') { - let logs = ''; - try { logs = await docker(['logs', '--tail', '50', containerName]); } catch { /* best-effort */ } - throw new Error(`clickhouse-containers: container "${containerName}" ${status} before becoming ready. Last logs:\n${logs}`); - } - if (Date.now() > deadline) { - throw new Error(`clickhouse-containers: container "${containerName}" did not answer an authenticated SELECT 1 within ${maxWaitMs}ms`); - } - await sleep(pollIntervalMs); - } -} - -/** Run one bootstrap SQL statement as admin, throwing with the exact - * ClickHouse error text on failure (bootstrap DDL must never fail silently — - * a swallowed GRANT failure would surface as a much more confusing auth - * failure much later, in an unrelated test). */ -async function runAdminStatement(url, admin, sql) { - const { ok, status, text } = await httpPost(url, { ...admin, body: sql, timeoutMs: 15_000 }); - if (!ok) throw new Error(`clickhouse-containers: bootstrap statement failed (HTTP ${status}): ${sql}\n${text}`); -} - -/** Every bootstrap statement run against a fresh row after readiness (plan - * §12: "create non-secret users for Basic auth, roles, denial, and - * cancellation observation"). Grants are intentionally narrow (no superuser) - * — `system.*` SELECT for the deterministic-suite-compatible fixture shape, - * plus exactly what the live session/temp-table/cancellation specs need. */ -function bootstrapStatements() { - const { basicA, basicB, denied } = FIXTURE_USERS; - return [ - `CREATE USER IF NOT EXISTS ${basicA.username} IDENTIFIED WITH plaintext_password BY '${basicA.password}'`, - `CREATE USER IF NOT EXISTS ${basicB.username} IDENTIFIED WITH plaintext_password BY '${basicB.password}'`, - `CREATE USER IF NOT EXISTS ${denied.username} IDENTIFIED WITH plaintext_password BY '${denied.password}'`, - `GRANT SELECT ON system.* TO ${basicA.username}`, - `GRANT SELECT ON system.* TO ${basicB.username}`, - // Temporary-table + session SET tests (plan §23) need CREATE TEMPORARY - // TABLE plus ordinary read/write on a scratch namespace; cancellation - // observation (plan §22) needs KILL QUERY on the user's OWN queries. - `GRANT CREATE TEMPORARY TABLE, SELECT, CREATE TABLE, INSERT, DROP TABLE ON *.* TO ${basicA.username}`, - `GRANT CREATE TEMPORARY TABLE, SELECT, CREATE TABLE, INSERT, DROP TABLE ON *.* TO ${basicB.username}`, - `GRANT KILL QUERY ON *.* TO ${basicA.username}`, - `CREATE ROLE IF NOT EXISTS ${FIXTURE_ROLE}`, - `GRANT SELECT ON system.* TO ${FIXTURE_ROLE}`, - `GRANT ${FIXTURE_ROLE} TO ${basicA.username}`, - // asb_spike_denied deliberately receives NO grants beyond its own - // existence — the 403/denial fixture (plan §13 "roles, denial"). - ]; -} - -/** A single bounded liveness probe: does an unauthenticated `/ping` return - * "Ok." within `attempts * intervalMs`? Used only to health-check the - * opt-in `$DOCKER_NETWORK` attach below — NOT the main readiness gate - * (`waitForReady`, which additionally waits out ClickHouse's own cold-start - * and requires authenticated `SELECT 1`). */ -async function probePing(url, attempts, intervalMs) { - for (let i = 0; i < attempts; i++) { - try { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), Math.min(intervalMs, 2000)); - const resp = await fetch(`${url}ping`, { signal: controller.signal }); - clearTimeout(timer); - if (resp.ok && (await resp.text()).trim() === 'Ok.') return true; - } catch { /* keep trying */ } - await sleep(intervalMs); - } - return false; -} - -/** Attach `containerName` to `dockerNetwork`, then verify the already- - * working published port (`url`, confirmed reachable at container-boot time - * by the caller before this runs) is STILL reachable afterward. On any sign - * of the documented footgun (this module's header docstring), disconnects - * again and returns `false` — the container proceeds default-bridge-only - * rather than carrying a silently-broken port. Never throws: a failed - * attach/rollback here must never abort an otherwise-healthy container boot. */ -async function attachDockerNetworkWithRollback(containerName, url, dockerNetwork) { - try { - await docker(['network', 'connect', dockerNetwork, containerName]); - } catch (e) { - process.stderr?.write?.(`clickhouse-containers: warning — "docker network connect ${dockerNetwork} ${containerName}" failed, continuing default-bridge-only: ${e instanceof Error ? e.message : String(e)}\n`); - return false; - } - await sleep(1000); - const healthy = await probePing(url, 4, 1000); - if (healthy) return true; - process.stderr?.write?.(`clickhouse-containers: warning — attaching "${containerName}" to $DOCKER_NETWORK broke host->container HTTP delivery (the documented sandbox footgun); disconnecting and continuing default-bridge-only.\n`); - try { - await docker(['network', 'disconnect', dockerNetwork, containerName]); - } catch { /* best-effort rollback */ } - return false; -} - -// ── Port discovery (plan §12: "discover assigned ports programmatically") ── - -async function discoverPort(containerName, containerPort = '8123/tcp') { - const out = (await docker(['port', containerName, containerPort])).trim(); - // "127.0.0.1:32770" (possibly multiple lines if published on more than one - // interface — this module always publishes loopback-only, so the first - // line is authoritative). - const line = out.split('\n')[0]; - const m = line.match(/:(\d+)\s*$/); - if (!m) throw new Error(`clickhouse-containers: could not parse published port from "docker port ${containerName} ${containerPort}" output: "${out}"`); - return Number(m[1]); -} - -// ── Public API ─────────────────────────────────────────────────────────────── - -/** One booted row's handle. `stop()` is idempotent and always safe to call - * more than once (removes the container by name + rm -f semantics, which - * already no-ops on an already-removed container name at the docker CLI - * level via a clean error we swallow). */ - -/** - * Boot one ClickHouse server matching `rowKeyOrRow` (a matrix.json key or a - * fully-formed row object), bootstrap its non-secret fixture users/role, and - * wait for it to answer an authenticated `SELECT 1`. Returns a `Handle`: - * `{ rowKey, url, port, containerName, configDir, spikeTmp, ownsSpikeTmp, - * imageRef, digest, tag, serverVersion, admin, fixtureUsers, role, stop }`. - * - * `opts.spikeTmp` (optional): reuse a caller-provided `$SPIKE_TMP` (e.g. one - * shared across several rows in the same run) instead of minting a fresh one - * — when omitted, this call creates its own and `stop()` removes it too - * (`ownsSpikeTmp: true` on the returned handle marks that case). - */ -export async function startRow(rowKeyOrRow, opts = {}) { - const env = opts.env || process.env; - const { dockerNetwork } = requireEnv(env); - const matrixPath = opts.matrixPath || DEFAULT_MATRIX_PATH; - const row = resolveRow(rowKeyOrRow, matrixPath); - const rowKey = typeof rowKeyOrRow === 'string' ? rowKeyOrRow : (row.role || 'custom-row'); - const rowLabel = rowKey.replace(/[^a-zA-Z0-9._-]/g, '-'); - - const ownsSpikeTmp = !opts.spikeTmp; - const spikeTmp = opts.spikeTmp || createSpikeTmp(env); - const configDir = writeRowConfig(spikeTmp, rowLabel); - - const admin = { username: `asb_spike_admin_${randomBytes(3).toString('hex')}`, password: `asb-spike-admin-${randomUUID()}` }; - const containerName = `asb585-${rowLabel}-${randomBytes(4).toString('hex')}`; - - // `stop` is defined THIS early (before `docker pull`/`docker run` even run) - // and the try/catch below wraps EVERYTHING from here through readiness — - // not just the later readiness-wait — so a pull/run/discoverPort/attach - // failure cleans up exactly like a bootstrap/readiness failure already did. - // `docker rm -f` on a container that was never created (a pull or early - // run failure) is a harmless, already-caught no-op, so calling `stop()` - // unconditionally here is always safe. Discovered by review: the previous - // try/catch only wrapped `waitForReady`+bootstrap, so a pull or `docker - // run` failure returned/threw WITHOUT ever removing the scratch config - // directory this function creates unconditionally above. - const stop = async () => { - try { await docker(['rm', '-f', containerName]); } catch { /* already gone — fine */ } - if (ownsSpikeTmp) { - try { rmSync(spikeTmp, { recursive: true, force: true }); } catch { /* best-effort */ } - } - }; - - try { - // Explicit `docker pull` FIRST (plan §13: "If a required Altinity build - // cannot be resolved or run: do not substitute OSS silently; record the - // exact failure") — a pull failure here throws a clear, row-tagged error - // rather than an ambiguous `docker run` failure buried under container - // creation. - try { - await docker(['pull', row.pullRef]); - } catch (e) { - throw new Error(`clickhouse-containers: pull failed for row "${rowKey}" (${row.pullRef}) — recording exact failure, NOT substituting another image:\n${e instanceof Error ? e.message : String(e)}`); - } - - // Step 1: create on the DEFAULT bridge with the published port — see this - // module's header docstring for why `--network` must NOT be passed here. - await docker([ - 'run', '-d', - '--name', containerName, - '--label', `${RUN_LABEL_KEY}=${RUN_ID}`, - '--label', `asb585.row=${rowLabel}`, - '-p', '127.0.0.1::8123', - '-v', `${configDir}:/etc/clickhouse-server/config.d:ro`, - '-e', `CLICKHOUSE_USER=${admin.username}`, - '-e', `CLICKHOUSE_PASSWORD=${admin.password}`, - '-e', 'CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1', - row.pullRef, - ]); - - const port = await discoverPort(containerName); - const url = `http://127.0.0.1:${port}/`; - - // Step 2 (plan §12 "attach every container with --network $DOCKER_NETWORK"): - // attach the sandbox's allowlisted network, health-checking the result - // (belt-and-suspenders after the config.d footgun above) and rolling back - // rather than leaving a broken container if attaching ever does regress - // host->container delivery again. Pass `{ attachDockerNetwork: false }` to - // skip this entirely (e.g. a caller that wants the fastest possible boot - // and has no need for the row to be reachable from another container on - // that network). - let dockerNetworkAttached = false; - if (opts.attachDockerNetwork !== false) { - dockerNetworkAttached = await attachDockerNetworkWithRollback(containerName, url, dockerNetwork); - } - - await waitForReady(containerName, url, admin, opts.readiness); - for (const stmt of bootstrapStatements()) { - await runAdminStatement(url, admin, stmt); - } - const versionResp = await httpPost(url, { ...admin, body: 'SELECT version()' }); - const serverVersion = versionResp.text.trim(); - - return { - rowKey, - url, - port, - containerName, - configDir, - spikeTmp, - ownsSpikeTmp, - dockerNetworkAttached, - imageRef: row.pullRef, - digest: row.digest, - tag: row.tag, - serverVersion, - admin, - fixtureUsers: FIXTURE_USERS, - role: FIXTURE_ROLE, - stop, - }; - } catch (e) { - // Never leak a half-booted container OR the scratch config directory on - // ANY failure between directory creation and readiness (pull, run, - // discoverPort, network-attach, bootstrap, readiness). - await stop(); - throw e; - } -} - -/** Remove every container carrying THIS process's `RUN_ID` label value - * (never another concurrent run's containers). `stop()` on individual - * handles already does this per-container; call this as a final sweep (e.g. - * in a `finally` around a whole multi-row run) to guarantee nothing from - * this run is left even if an individual `stop()` was skipped. */ -export async function stopAll(env = process.env) { - await stopByLabel(`${RUN_LABEL_KEY}=${RUN_ID}`); -} - -/** Crash-recovery sweep: remove EVERY container carrying `RUN_LABEL_KEY` - * regardless of run value (a previous process that crashed before its own - * `stopAll()` ran). Never touches a container without this exact label. */ -export async function stopAllOrphans() { - await stopByLabel(RUN_LABEL_KEY); -} - -async function stopByLabel(labelFilter) { - const out = await docker(['ps', '-a', '--filter', `label=${labelFilter}`, '--format', '{{.Names}}']); - const names = out.split('\n').map((s) => s.trim()).filter(Boolean); - for (const name of names) { - try { await docker(['rm', '-f', name]); } catch { /* best-effort */ } - } - return names; -} - -/** List every container currently carrying `RUN_LABEL_KEY` (any run value) — - * used by the smoke-test/doneWhen check ("docker ps -a filtered by the run - * label is empty") without shelling out to `docker` directly. */ -export async function listLabeledContainers() { - const out = await docker(['ps', '-a', '--filter', `label=${RUN_LABEL_KEY}`, '--format', '{{.Names}}']); - return out.split('\n').map((s) => s.trim()).filter(Boolean); -} - -// ── CLI ────────────────────────────────────────────────────────────────────── -// `node clickhouse-containers.mjs up ` — boots one row, prints a -// single "READY " line to stdout once authenticated SELECT 1 succeeds -// and bootstrap DDL has run, then blocks (trapping SIGINT/SIGTERM/SIGHUP) — -// a caller (a shell wrapper, or a human) reads that line for the connection -// info, runs whatever it wants against `url`, then sends a signal to trigger -// cleanup. `node clickhouse-containers.mjs down` sweeps every orphaned -// labeled container from any previous run (crash recovery). - -function isMainModule() { - return import.meta.url === `file://${process.argv[1]}`; -} - -async function main() { - const [cmd, arg] = process.argv.slice(2); - if (cmd === 'up') { - if (!arg) { - process.stderr.write('usage: node clickhouse-containers.mjs up \n'); - process.exitCode = 2; - return; - } - const handle = await startRow(arg); - const summary = { - rowKey: handle.rowKey, - url: handle.url, - port: handle.port, - containerName: handle.containerName, - imageRef: handle.imageRef, - digest: handle.digest, - tag: handle.tag, - serverVersion: handle.serverVersion, - admin: handle.admin, - fixtureUsers: handle.fixtureUsers, - role: handle.role, - spikeTmp: handle.spikeTmp, - configDir: handle.configDir, - }; - process.stdout.write(`READY ${JSON.stringify(summary)}\n`); - let cleaningUp = false; - const cleanup = async (signal) => { - if (cleaningUp) return; - cleaningUp = true; - process.stderr.write(`clickhouse-containers: received ${signal}, cleaning up ${handle.containerName}...\n`); - await handle.stop(); - process.exit(0); - }; - process.on('SIGINT', () => cleanup('SIGINT')); - process.on('SIGTERM', () => cleanup('SIGTERM')); - process.on('SIGHUP', () => cleanup('SIGHUP')); - // Block forever (until a signal above fires) — a plain empty Promise - // rather than a busy-wait, so this process is idle while the caller - // drives tests against `url`. - await new Promise(() => {}); - } else if (cmd === 'down') { - const removed = await stopAllOrphans(); - process.stdout.write(`${JSON.stringify({ removed })}\n`); - } else { - process.stderr.write('usage: node clickhouse-containers.mjs |down>\n'); - process.exitCode = 2; - } -} - -if (isMainModule()) { - main().catch((e) => { - process.stderr.write(`clickhouse-containers: ${e instanceof Error ? e.stack || e.message : String(e)}\n`); - process.exitCode = 1; - }); -} diff --git a/tests/spike/clickhouse-client/current-adapter.ts b/tests/spike/clickhouse-client/current-adapter.ts deleted file mode 100644 index 709e3865..00000000 --- a/tests/spike/clickhouse-client/current-adapter.ts +++ /dev/null @@ -1,310 +0,0 @@ -// Phase 0 / issue #585 — the "current-side adapter" (plan §7): a thin -// SPIKE-OWNED wrapper around the REAL production functions from -// `src/net/authenticated-clickhouse-request.ts` (and `ch-client.ts`'s own -// zero-logic re-exports of package protocol helpers). It does not reimplement -// request construction, streaming, or error classification — it only -// translates the test-owned `SpikeRequest`/`SpikeOutcome` vocabulary at the -// boundary, exactly as the plan requires ("Do not reimplement current -// behavior in a test helper and compare that replica with the official -// client"). -// -// Issue #630 Phase 7 (plan §19/§2.4, Checkpoint 2C's spike portion) — this -// file no longer depends on `ch-client.ts`'s generic, now-retiring -// `runQuery`/`exportQuery`/mutable-context `killQuery` or its `ChCtx` type: -// it drives the SAME production request path those functions themselves now -// delegate to — `authenticated-clickhouse-request.ts`'s `authenticatedProgress` -// (Table/KPI streaming), `authenticatedText` (TSV/explicit-format whole-body -// reads), and `authenticatedResponse` (the raw/export path) — plus the -// package's own stateless `createClickHouseHttpClient(...).killQuery(...)` -// for best-effort cancellation. The Table/KPI/TSV/explicit-format mapping and -// the non-2xx/in-band-exception classification below are this file's OWN -// mirror of that mapping (the same one `QueryExecutionService` -// (`src/application/query-execution-service.ts`) now owns for production — -// see its module doc), not a reimplementation of the transport itself. -// `applyStreamLine`/`newResult` stay SQL-Browser-owned result policy, -// imported from `src/core/stream.js` unchanged. -import { - chUrl, parseExceptionText, -} from '../../../src/net/ch-client.js'; -import { - authenticatedResponse, authenticatedProgress, authenticatedText, -} from '../../../src/net/authenticated-clickhouse-request.js'; -import type { AuthenticatedRequestCtx } from '../../../src/net/authenticated-clickhouse-request.js'; -import { createClickHouseHttpClient } from '@altinity/clickhouse-http'; -import { applyStreamLine, newResult } from '../../../src/core/stream.js'; -import type { AdapterRunResult, SpikeCredential, SpikeRequest, SpikeOutcome } from './types.js'; -import { emptyOutcome, IncrementalSha256 } from './normalize.js'; - -/** Build the `Authorization` header for a `SpikeCredential` — the harness's - * own request-local credential concept, translated into exactly the header - * production's authenticated request path would send for that credential - * kind (`authenticated-clickhouse-request.ts`'s `authenticatedRequest`, - * since #630 Phase 6; formerly `ch-client.ts`'s `authedFetch`, unchanged in - * shape). */ -export function credentialAuthHeader(credential: SpikeCredential): string { - // `btoa` (standard Web API, global in Node >=18 and every target browser) - // rather than `Buffer` — see normalize.ts's `IncrementalSha256` docstring - // for why spike `.ts` files avoid Node-only globals. Every fixture - // username/password/JWT is ASCII (auth-fixtures.ts), so latin1 `btoa` is - // exact here — not a general credential encoder. - switch (credential.kind) { - case 'basic': - return 'Basic ' + btoa(`${credential.username}:${credential.password}`); - case 'bearer': - return 'Bearer ' + credential.token; - case 'jwt-as-basic': - // Matches the app's real JWT-as-Basic-password composition (username + - // the JWT used as the Basic password) — see authenticated-clickhouse- - // request.ts's `authHeader` seam. - return 'Basic ' + btoa(`${credential.username}:${credential.jwt}`); - case 'invalid': - default: - return 'Basic ' + btoa('invalid:invalid'); - } -} - -/** Optional hooks `makeCurrentCtx` wires onto the real production - * `AuthenticatedRequestCtx`'s own epoch/lifecycle seam (plan §21's "stale - * before request" / "stale during refresh" / "stale response" cases need - * REAL `authenticated-clickhouse-request.ts` epoch fencing exercised through - * its real production request path, `authenticatedRequest` — not a harness - * reimplementation of it). Every field - * is optional and defaults to the pre-existing no-op behavior, so no - * existing call site needs to change. */ -export interface CurrentCtxHooks { - currentEpoch?: () => number; - onSignedOut?: (detail?: string, expectedEpoch?: number) => void; - onTransportConnected?: () => void; - onTransportOffline?: (error?: unknown) => void; - /** Overrides the default no-op `refresh()` — a test drives this to return - * `true` after simulating a token refresh (plan's "refresh then retry"), - * optionally delaying/mutating shared state first (plan's "stale during - * refresh"). */ - refresh?: () => Promise; - /** Overrides the default constant-token `getToken()` — lets a test observe - * how many times a token was actually read (e.g. to prove a stale-epoch - * refresh's resolved token is never re-read for the delegate fetch). */ - getToken?: () => Promise; - /** Fires the instant a delegate fetch RESOLVES — before `runCurrent`'s own - * `lastResponse` capture and before production's own post-fetch epoch - * check runs (`authenticated-clickhouse-request.ts`'s `authenticatedRequest`) - * (plan §21 "stale response"). A test flips a shared epoch - * variable here to deterministically land the flip in that exact window, - * with no timing race. */ - onFetchResponse?: (resp: Response) => void; -} - -/** Build an `AuthenticatedRequestCtx` bound to one `SpikeRequest`'s - * credential and origin, using the real production `fetch` seam contract. - * `onFetch` is called once per underlying fetch invocation - * (constructor/fetch-count invariants); `onResponse` observes each settled - * `Response` (status/headers) — pure instrumentation at the already-injected - * fetch boundary, not a second request path: production's authenticated - * response consumers don't surface headers to their caller, so this is how - * the harness reads them without reimplementing that request/parsing logic. - * `hooks` (optional) wires the real epoch/lifecycle seam (`CurrentCtxHooks`, - * above) — omitted entirely preserves the exact previous behavior (no epoch - * hook, `refresh()` always resolves false, `onSignedOut` a no-op). */ -export function makeCurrentCtx( - request: SpikeRequest, - baseUrl: string, - realFetch: typeof fetch, - onFetch?: () => void, - onResponse?: (resp: Response) => void, - initialAuthConfirmed?: boolean, - hooks?: CurrentCtxHooks, -): AuthenticatedRequestCtx { - const authHeader = credentialAuthHeader(request.credential); - return { - origin: baseUrl, - fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { - onFetch?.(); - const resp = await realFetch(input, init); - onResponse?.(resp); - return resp; - }) as typeof fetch, - getToken: hooks?.getToken || (async () => authHeader.replace(/^(Bearer|Basic) /, '')), - refresh: hooks?.refresh || (async () => false), - onSignedOut: hooks?.onSignedOut || (() => {}), - authHeader: () => authHeader, - authConfirmed: initialAuthConfirmed, - currentEpoch: hooks?.currentEpoch, - onTransportConnected: hooks?.onTransportConnected, - onTransportOffline: hooks?.onTransportOffline, - }; -} - -/** Format one native-query-parameter VALUE exactly as installed 1.23.1's own - * `formatQueryParams` would (`dist/common/data_formatter/format_query_params.js`): - * a top-level scalar is stringified as-is (no quoting); an array wraps each - * element in single quotes and joins with `,` inside `[...]` (the vendor - * library's `isInArrayOrTuple: true, wrapStringInQuotes: true` branch). - * Restricted, on purpose, to exactly the shapes this spike's fixtures use - * (digit-string / number scalars and arrays of them — no escaping of - * tab/newline/quote/backslash, which the real vendor formatter also handles - * but no spike fixture exercises) — production's authenticated request path - * has no array-value concept at all, so the CURRENT adapter must pre-format - * an array-valued native parameter into the exact wire string itself before - * handing it to the request's plain `Record` params - * bag; the OFFICIAL adapter instead hands the array straight to - * `query_params` and lets the vendor library's own formatter do this. A - * match between the two proves this hand-written mirror is correct — see - * the "URL parameters" scenario in `parity.test.ts`. */ -export function formatNativeParamValue(value: string | number | (string | number)[]): string { - if (Array.isArray(value)) { - return `[${value.map((v) => `'${v}'`).join(',')}]`; - } - return String(value); -} - -/** Fold a `SpikeRequest`'s settings/native-params/role/session into the flat - * `Record` bag the production authenticated request - * path accepts — settings ride as bare keys (matching the official - * adapter's `clickhouse_settings`); native params are prefixed `param_` - * here (the CURRENT side's own responsibility — see `formatNativeParamValue`'s - * docstring for why the official side instead delegates this to the vendor - * library); `role`/`sessionId` become the same `role`/`session_id` bare keys - * the official client's own `toSearchParams` emits (array-valued `role` is - * deliberately unsupported here — the params bag cannot repeat a key, so - * every spike scenario exercising `role` uses a single string). */ -function nativeParamsForCurrent(request: SpikeRequest): Record { - const out: Record = { ...(request.settings || {}) }; - for (const [k, v] of Object.entries(request.params || {})) { - out[`param_${k}`] = formatNativeParamValue(v); - } - if (typeof request.role === 'string') out.role = request.role; - if (request.sessionId) out.session_id = request.sessionId; - return out; -} - -/** Run one `SpikeRequest` through the real production authenticated request - * functions — `authenticatedResponse` for the raw/export path, - * `authenticatedProgress`/`authenticatedText` for the rows path (mirroring - * the SAME Table/KPI/TSV/explicit-format mapping `QueryExecutionService` now - * owns in production, #630 Phase 7 §6.1-6.4) — folding the result into the - * normalized `SpikeOutcome` vocabulary. `hooks` (optional) wires the real - * epoch/lifecycle seam — see `CurrentCtxHooks`. */ -export async function runCurrent( - request: SpikeRequest, - baseUrl: string, - realFetch: typeof fetch, - initialAuthConfirmed?: boolean, - hooks?: CurrentCtxHooks, -): Promise { - let fetchCalls = 0; - let lastResponse: Response | null = null; - const ctx = makeCurrentCtx( - request, baseUrl, realFetch, - () => { fetchCalls += 1; }, - (resp) => { lastResponse = resp; hooks?.onFetchResponse?.(resp); }, - initialAuthConfirmed, - hooks, - ); - const outcome: SpikeOutcome = emptyOutcome(); - const t0 = Date.now(); - - if (request.consume === 'raw') { - try { - const resp = await authenticatedResponse(ctx, { - sql: request.sql, - defaultFormat: (request.format === 'Table' || request.format === 'KPI' ? undefined : request.format) || 'TabSeparatedWithNames', - params: { ...(request.queryId ? { query_id: request.queryId } : {}), ...nativeParamsForCurrent(request) }, - signal: request.signal, - }); - outcome.httpStatus = resp.status; - outcome.responseHeaders = Object.fromEntries(resp.headers.entries()); - const hash = new IncrementalSha256(); - const reader = resp.body!.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - hash.update(value); - } - outcome.rawByteCount = hash.totalBytes; - outcome.rawSha256 = await hash.digestHex(); - outcome.completedAtMs = Date.now() - t0; - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') outcome.cancelled = true; - else outcome.error = e instanceof Error ? e.message : String(e); - } - return { outcome, constructorCalls: 1, fetchCalls }; - } - - const result = newResult(request.format); - result.rowLimit = 0; - let firstRow = false; - // Same format/settings mapping production's `QueryExecutionService` owns - // (#630 Phase 7 §6.1-6.4): Table/KPI stream the progress-bearing JSON wire - // formats with no `wait_end_of_query`; TSV/explicit formats read the whole - // body as text with `wait_end_of_query=1`. This spike never exercises a - // positive row cap (`runCurrent` always passes an uncapped read), so no - // `max_result_rows`/`result_overflow_mode` is added here. - const fmt = request.format || 'Table'; - const isStreaming = fmt === 'Table' || fmt === 'KPI'; - const defaultFormat = isStreaming - ? (fmt === 'KPI' ? 'JSONEachRowWithProgress' : 'JSONStringsEachRowWithProgress') - : fmt === 'TSV' ? 'TabSeparatedWithNamesAndTypes' : fmt; - const settings: Record = { - ...(isStreaming ? {} : { wait_end_of_query: 1 }), - add_http_cors_header: 1, - }; - const params = { ...(request.queryId ? { query_id: request.queryId } : {}), ...nativeParamsForCurrent(request) }; - try { - if (isStreaming) { - await authenticatedProgress(ctx, { sql: request.sql, defaultFormat, settings, params, signal: request.signal }, { - onLine: (line) => { - applyStreamLine(line, result); - if (line.row && !firstRow) { firstRow = true; outcome.firstRowAtMs = Date.now() - t0; } - if (line.exception) outcome.chMessage = line.exception; - }, - }); - outcome.completedAtMs = Date.now() - t0; - } else { - const raw = await authenticatedText(ctx, { sql: request.sql, defaultFormat, settings, params, signal: request.signal }); - outcome.completedAtMs = Date.now() - t0; - outcome.rawByteCount = new TextEncoder().encode(raw).byteLength; - } - } catch (e) { - if (e instanceof Error && e.name === 'AbortError') outcome.cancelled = true; - else outcome.error = e instanceof Error ? e.message : String(e); - } - if (lastResponse) { - outcome.httpStatus = (lastResponse as Response).status; - outcome.responseHeaders = Object.fromEntries((lastResponse as Response).headers.entries()); - } - outcome.columns = result.columns.map((c) => ({ name: c.name, type: c.type })); - outcome.rows = result.rows; - outcome.partialRowCount = result.rows.length; - outcome.progress = result.progress.total_rows !== undefined - ? { rows: result.progress.rows, bytes: result.progress.bytes, totalRows: result.progress.total_rows } - : null; - if (result.error) { - outcome.error = result.error; - outcome.chMessage = parseExceptionText(result.error); - } - return { outcome, constructorCalls: 1, fetchCalls }; -} - -/** Best-effort server cancellation (plan §22 "Server cancellation") through - * the package's stateless `createClickHouseHttpClient(...).killQuery(...)` — - * #630 Phase 7 §19: no longer routes through `ch-client.ts`'s retiring - * mutable-context `killQuery`. Resolves the CURRENT Authorization from `ctx` - * itself (the same `getToken()`/`authHeader()` seam `makeCurrentCtx` wires - * up) and issues exactly one `KILL QUERY ... ASYNC`, swallowing every - * failure — matching the retired function's own best-effort contract. A - * missing token (never signed in) is a no-op, same as a missing `queryId`. */ -export async function currentKillQuery(ctx: AuthenticatedRequestCtx, queryId: string | null | undefined): Promise { - if (!queryId) return; - try { - const token = await ctx.getToken(); - if (!token) return; - const authHeader = ctx.authHeader || ((t: string) => 'Bearer ' + t); - const client = createClickHouseHttpClient({ fetch: () => ctx.fetch, origin: () => ctx.origin }); - await client.killQuery({ queryId, authorization: authHeader(token) }); - } catch { /* best-effort */ } -} - -/** Re-exported so scenario/harness code has one place to build the - * production-shaped URL for direct fetch comparisons (fault-server request - * inspection etc.) without importing `ch-client.ts` a second time. */ -export { chUrl }; diff --git a/tests/spike/clickhouse-client/expected-values.ts b/tests/spike/clickhouse-client/expected-values.ts deleted file mode 100644 index fa1ccd31..00000000 --- a/tests/spike/clickhouse-client/expected-values.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Phase 0 / issue #585, plan §17 "Precision corpus" — expected values are -// authored HERE, independently of both adapters (current production -// functions and the official client), so a match never just proves "both -// clients agree with each other" (plan's own objection: "Equal values can't -// prove correctness" — both could share the same bug). Every literal below -// is either a well-known numeric/string boundary (computed by hand / from -// the ClickHouse type documentation) or ClickHouse's own documented exact -// text-serialization rule for `JSONStringsEachRowWithProgress` -// (https://clickhouse.com/docs/interfaces/formats/JSONStringsEachRow). -// -// CORRECTED against a real server (live-precision.test.ts's first-ever run, -// 2026-08-05, against ClickHouse 26.6.2.160 — re-verified 2026-08-06 during -// issue #585 Phase 0's evidence review): the header comment above ORIGINALLY -// claimed "every leaf scalar renders as a JSON string, structure -// (arrays/tuples/maps) keeps normal JSON container syntax" — i.e. that a -// nested container's own JSON encoding independently re-stringifies every -// leaf. That is FALSE for `JSONStringsEachRowWithProgress`/ -// `JSONEachRowWithProgress`. What actually happens: each COLUMN's `row.` -// value is wrapped in exactly one JSON string (so the outer value IS a JS -// string), but for Array/Tuple/Map/top-level-Nullable/LowCardinality(Nullable) -// types the CONTENT of that string is ClickHouse's own plain/Pretty-style -// text syntax, not recursive JSON: unquoted numbers, single-quoted strings, -// parens for tuples (named tuples render IDENTICALLY to unnamed ones — the -// field names are NOT reflected in text output), the bare word `NULL` for a -// null value NESTED inside a container, and the small-caps glyph `ᴺᵁᴸᴸ` -// (U+1D3A U+1D41 U+1D38 U+1D38) for a null value at the TOP level of a -// Nullable/LowCardinality(Nullable) column. Verified live (2026-08-06) -// against ClickHouse 24.8.14.39 and 26.6.2.160 alike — same rendering on -// both, so this is a stable format property, not a version quirk. The exact -// digit/character content is preserved bit-for-bit either way (this is a -// SERIALIZATION-FORMAT correction, never a precision-loss finding — see -// `runPrecisionCase`'s corpus-wide pass once these literals are corrected). -// The one genuine partial exception is `json-object` (real ClickHouse `JSON` -// type): its string content IS real recursive JSON, but leaves are STILL not -// independently re-stringified (a leaf number stays a JSON number, e.g. -// `{"a":1}`, not `{"a":"1"}`). - -export interface PrecisionCase { - id: string; - category: string; - /** The SQL expression this case selects, aliased `v`. */ - select: string; - /** ClickHouse type name, for the ADR's compatibility notes. */ - chType: string; - /** The independently-authored expected STRING value as it must appear in - * a `JSONStringsEachRowWithProgress`/`JSONEachRowWithProgress` `row.v` - * field, or `null` for a capability-gated case with no fixed expectation - * (recorded, never silently skipped). */ - expected: string | null; - /** True when this case depends on a server capability that may be absent - * on an older/OSS/Altinity row (e.g. JSON type, IPv6) — the corpus runner - * records an explicit capability-gated omission rather than failing. */ - capabilityGated?: boolean; - because: string; -} - -export const PRECISION_CORPUS: PrecisionCase[] = [ - // ── Unsigned integers ────────────────────────────────────────────────── - { id: 'uint64-max', category: 'unsigned-integers', select: "CAST('18446744073709551615' AS UInt64) AS v", chType: 'UInt64', expected: '18446744073709551615', because: '2^64-1, the documented UInt64 maximum' }, - { id: 'uint64-above-safe-integer', category: 'unsigned-integers', select: "CAST('9007199254740993' AS UInt64) AS v", chType: 'UInt64', expected: '9007199254740993', because: 'Number.MAX_SAFE_INTEGER + 2 — Number() coercion loses this exact value' }, - { id: 'uint128-max', category: 'unsigned-integers', select: "CAST('340282366920938463463374607431768211455' AS UInt128) AS v", chType: 'UInt128', expected: '340282366920938463463374607431768211455', because: '2^128-1, the documented UInt128 maximum' }, - { id: 'uint256-max', category: 'unsigned-integers', select: "CAST('115792089237316195423570985008687907853269984665640564039457584007913129639935' AS UInt256) AS v", chType: 'UInt256', expected: '115792089237316195423570985008687907853269984665640564039457584007913129639935', because: '2^256-1, the documented UInt256 maximum' }, - // ── Signed integers ──────────────────────────────────────────────────── - { id: 'int64-min', category: 'signed-integers', select: "CAST('-9223372036854775808' AS Int64) AS v", chType: 'Int64', expected: '-9223372036854775808', because: '-2^63, the documented Int64 minimum' }, - { id: 'int64-max', category: 'signed-integers', select: "CAST('9223372036854775807' AS Int64) AS v", chType: 'Int64', expected: '9223372036854775807', because: '2^63-1, the documented Int64 maximum' }, - { id: 'int128-min', category: 'signed-integers', select: "CAST('-170141183460469231731687303715884105728' AS Int128) AS v", chType: 'Int128', expected: '-170141183460469231731687303715884105728', because: '-2^127, the documented Int128 minimum' }, - { id: 'int256-max', category: 'signed-integers', select: "CAST('57896044618658097711785492504343953926634992332820282019728792003956564819967' AS Int256) AS v", chType: 'Int256', expected: '57896044618658097711785492504343953926634992332820282019728792003956564819967', because: '2^255-1, the documented Int256 maximum' }, - // ── Decimals ─────────────────────────────────────────────────────────── - // CORRECTED against a real server by the live precision corpus - // (live-precision.test.ts, plan §17) — issue #585 Phase 0's - // support-minimum/live-matrix sub-task. The ORIGINAL literal expectation - // here ('1.2000') was authored from ClickHouse type documentation without - // ever running against a real server (exactly the gap plan §17 exists to - // close): a real server (verified on ClickHouse 26.6.2.160, and confirmed - // NOT format-specific — identical in TSV/JSON/Pretty/JSONStrings*) trims - // ALL trailing fractional zeros from a Decimal's text serialization - // regardless of its declared scale, for EVERY Decimal width (Decimal32 - // through Decimal256, CAST-from-string, a numeric literal, and a real - // table column all agree) — the declared scale governs internal storage - // and rounding, not trailing-zero padding in text output. This case still - // proves what it always meant to (decimal round-trip fidelity survives - // normalization) — the id and select are kept so it is still the corpus's - // one case deliberately targeting a value WITH trailing zeros; only the - // expectation now matches verified reality. - { id: 'decimal32-trailing-zeros', category: 'decimals', select: "CAST('1.2000' AS Decimal32(4)) AS v", chType: 'Decimal32(4)', expected: '1.2', because: 'ClickHouse trims trailing fractional zeros from Decimal text serialization regardless of declared scale (verified live against 26.6.2.160 in TSV/JSON/Pretty/JSONStrings* alike) — declared scale governs rounding/storage, not zero-padding on output' }, - { id: 'decimal64-negative', category: 'decimals', select: "CAST('-123456789.123456' AS Decimal64(6)) AS v", chType: 'Decimal64(6)', expected: '-123456789.123456', because: 'authored literal, sign + scale preserved' }, - { id: 'decimal128-large', category: 'decimals', select: "CAST('123456789012345678901234.123456789012' AS Decimal128(12)) AS v", chType: 'Decimal128(12)', expected: '123456789012345678901234.123456789012', because: 'authored literal exceeding float64 precision' }, - // CORRECTED against a real server (same live-precision.test.ts run as - // above): the ORIGINAL literal had 78 integer digits + 40 fractional - // digits = 118 total, far over Decimal256's documented 76-digit maximum - // total precision — a real server rejects it outright - // (Code: 69. ARGUMENT_OUT_OF_BOUND), which would otherwise abort the - // WHOLE corpus run before any other case's mismatch could even be - // observed (runPrecisionCase rethrows a non-capability-gated query - // error). Reduced to 28 integer + 40 fractional = 68 total digits — safely - // under the 76-digit ceiling — and deliberately ending in a non-zero - // fractional digit, so this case is unaffected by the trailing-zero - // trimming documented on decimal32-trailing-zeros above. - { id: 'decimal256-large', category: 'decimals', select: "CAST('1234567890123456789012345678.1234567890123456789012345678901234567891' AS Decimal256(40)) AS v", chType: 'Decimal256(40)', expected: '1234567890123456789012345678.1234567890123456789012345678901234567891', because: 'authored literal at Decimal256 scale, within its documented 76-digit total-precision ceiling (28 integer + 40 fractional = 68 digits) and verified round-trip-exact live against 26.6.2.160' }, - // ── Dates ────────────────────────────────────────────────────────────── - { id: 'date-ordinary', category: 'dates', select: "CAST('2024-02-29' AS Date) AS v", chType: 'Date', expected: '2024-02-29', because: 'leap-day literal, ISO date serialization' }, - { id: 'date32-pre-epoch', category: 'dates', select: "CAST('1950-06-15' AS Date32) AS v", chType: 'Date32', expected: '1950-06-15', because: 'Date32 supports pre-1970 dates (documented range from 1900)' }, - // ── Date/time ────────────────────────────────────────────────────────── - { id: 'datetime-tz', category: 'datetime', select: "toDateTime('2024-06-15 12:34:56', 'UTC') AS v", chType: "DateTime('UTC')", expected: '2024-06-15 12:34:56', because: 'authored wall-clock literal in a fixed named timezone' }, - { id: 'datetime64-fractional-tz', category: 'datetime', select: "toDateTime64('2024-06-15 12:34:56.123456', 6, 'UTC') AS v", chType: "DateTime64(6, 'UTC')", expected: '2024-06-15 12:34:56.123456', because: 'microsecond-precision literal, fixed named timezone' }, - // ── Identifiers/network ──────────────────────────────────────────────── - { id: 'uuid', category: 'identifiers-network', select: "CAST('61f0c404-5cb3-11e7-907b-a6006ad3dba0' AS UUID) AS v", chType: 'UUID', expected: '61f0c404-5cb3-11e7-907b-a6006ad3dba0', because: 'canonical UUID textual form is stable under round-trip' }, - { id: 'ipv4', category: 'identifiers-network', select: "CAST('192.168.1.100' AS IPv4) AS v", chType: 'IPv4', expected: '192.168.1.100', because: 'authored dotted-quad literal' }, - { id: 'ipv6', category: 'identifiers-network', select: "CAST('2001:db8::ff00:42:8329' AS IPv6) AS v", chType: 'IPv6', expected: '2001:db8::ff00:42:8329', because: 'authored compressed-form IPv6 literal, ClickHouse preserves compressed form on output' }, - // ── Enums ────────────────────────────────────────────────────────────── - { id: 'enum8', category: 'enums', select: "CAST('b' AS Enum8('a' = 1, 'b' = 2)) AS v", chType: "Enum8('a'=1,'b'=2)", expected: 'b', because: 'Enum text form round-trips as its label, not its ordinal' }, - { id: 'enum16', category: 'enums', select: "CAST('y' AS Enum16('x' = 1000, 'y' = 2000)) AS v", chType: "Enum16('x'=1000,'y'=2000)", expected: 'y', because: 'same as Enum8, wider ordinal range' }, - // ── Nullable ─────────────────────────────────────────────────────────── - { id: 'nullable-null', category: 'nullable', select: 'CAST(NULL AS Nullable(Int64)) AS v', chType: 'Nullable(Int64)', expected: 'ᴺᵁᴸᴸ', because: 'CORRECTED (verified live against 24.8.14.39/26.6.2.160): a top-level Nullable NULL is NOT JSON null — JSONStringsEachRowWithProgress renders it as the small-caps glyph "ᴺᵁᴸᴸ" (U+1D3A U+1D41 U+1D38 U+1D38), a plain JS string' }, - { id: 'nullable-nonnull', category: 'nullable', select: 'CAST(9223372036854775807 AS Nullable(Int64)) AS v', chType: 'Nullable(Int64)', expected: '9223372036854775807', because: 'a non-null Nullable(Int64) still stringifies like the base type' }, - // ── Arrays ───────────────────────────────────────────────────────────── - { id: 'array-large-integers', category: 'arrays', select: "[CAST('18446744073709551615' AS UInt64), CAST('0' AS UInt64)] AS v", chType: 'Array(UInt64)', expected: '[18446744073709551615,0]', because: 'CORRECTED (verified live): array member digits are NOT re-quoted — the array renders in plain/Pretty-style text (unquoted numbers) inside the one JSON string wrapping the whole column; digits are still preserved exactly, just unquoted' }, - { id: 'array-nullable', category: 'arrays', select: "[CAST(1 AS Nullable(Int32)), CAST(NULL AS Nullable(Int32))] AS v", chType: 'Array(Nullable(Int32))', expected: '[1,NULL]', because: 'CORRECTED (verified live): a null nested INSIDE a container renders as the bare word NULL (not JSON null, not the top-level "ᴺᵁᴸᴸ" glyph), and the non-null member is unquoted' }, - // ── Tuples ───────────────────────────────────────────────────────────── - { id: 'tuple-unnamed-precision', category: 'tuples', select: "(CAST('18446744073709551615' AS UInt64), CAST('-9223372036854775808' AS Int64)) AS v", chType: 'Tuple(UInt64, Int64)', expected: '(18446744073709551615,-9223372036854775808)', because: 'CORRECTED (verified live): an unnamed tuple renders as parens with unquoted/plain-text members, not a JSON array of strings' }, - { id: 'tuple-named-precision', category: 'tuples', select: "CAST((CAST('18446744073709551615' AS UInt64), 'x') AS Tuple(big UInt64, label String)) AS v", chType: 'Tuple(big UInt64, label String)', expected: "(18446744073709551615,'x')", because: "CORRECTED (verified live): a NAMED tuple renders IDENTICALLY to an unnamed one in text output — field names are not reflected at all, and the String member is single-quoted, not a JSON object keyed by field name" }, - // ── Maps ─────────────────────────────────────────────────────────────── - { id: 'map-string-large-integer', category: 'maps', select: "map('k', CAST('18446744073709551615' AS UInt64)) AS v", chType: 'Map(String, UInt64)', expected: "{'k':18446744073709551615}", because: "CORRECTED (verified live): a Map renders with single-quoted string keys and unquoted/plain-text values, not a JSON object with double-quoted string values" }, - { id: 'map-string-date', category: 'maps', select: "map('k', CAST('2024-06-15' AS Date)) AS v", chType: 'Map(String, Date)', expected: "{'k':'2024-06-15'}", because: "CORRECTED (verified live): Map keys AND Date values are both single-quoted plain text, not a JSON object with double-quoted values" }, - // ── LowCardinality ───────────────────────────────────────────────────── - { id: 'lowcardinality-string', category: 'lowcardinality', select: "CAST('hello' AS LowCardinality(String)) AS v", chType: 'LowCardinality(String)', expected: 'hello', because: 'LowCardinality is transparent to text serialization' }, - { id: 'lowcardinality-nullable-string', category: 'lowcardinality', select: 'CAST(NULL AS LowCardinality(Nullable(String))) AS v', chType: 'LowCardinality(Nullable(String))', expected: 'ᴺᵁᴸᴸ', because: 'CORRECTED (verified live): same top-level-Nullable rule as nullable-null above — LowCardinality is transparent, so this is still the "ᴺᵁᴸᴸ" glyph, not JSON null' }, - // ── JSON/Object (capability-gated) ───────────────────────────────────── - { id: 'json-object', category: 'json-object', select: "'{\"a\":1}'::JSON AS v", chType: 'JSON', expected: '{"a":1}', capabilityGated: true, because: 'CORRECTED (verified live): the real ClickHouse JSON type\'s string content IS recursive JSON syntax (unlike Array/Tuple/Map above), but leaf scalars are still NOT independently re-stringified — a leaf number stays a JSON number' }, - // ── Strings ──────────────────────────────────────────────────────────── - { id: 'string-newline', category: 'strings', select: "'a\\nb' AS v", chType: 'String', expected: 'a\nb', because: 'authored literal containing a real newline byte' }, - { id: 'string-nul', category: 'strings', select: "'a\\0b' AS v", chType: 'String', expected: 'ab', because: 'authored literal containing a real NUL byte — ClickHouse strings are byte strings, not C strings' }, - { id: 'string-non-bmp-unicode', category: 'strings', select: "'a\u{1F600}b' AS v", chType: 'String', expected: 'a\u{1F600}b', because: 'authored literal containing a non-BMP emoji (surrogate pair in UTF-16/JS)' }, - { id: 'string-backslash-quotes', category: 'strings', select: "'a\\\\b\"c' AS v", chType: 'String', expected: 'a\\b"c', because: 'authored literal containing a literal backslash and double quote' }, - { id: 'string-tab-cr', category: 'strings', select: "'a\\tb\\rc' AS v", chType: 'String', expected: 'a\tb\rc', because: 'authored literal containing real TAB and CR bytes' }, - { id: 'string-empty', category: 'strings', select: "'' AS v", chType: 'String', expected: '', because: 'the empty string is not the same outcome as JSON null' }, - // ── Nested structures ────────────────────────────────────────────────── - { id: 'nested-array-of-tuples', category: 'nested', select: "[(CAST('18446744073709551615' AS UInt64), CAST('2024-06-15' AS Date))] AS v", chType: 'Array(Tuple(UInt64, Date))', expected: "[(18446744073709551615,'2024-06-15')]", because: 'CORRECTED (verified live): array-of-tuples nests the same plain-text tuple syntax (parens, unquoted UInt64, single-quoted Date) inside brackets — no JSON re-encoding at any nesting level' }, - { id: 'nested-map-of-arrays', category: 'nested', select: "map('k', [CAST('18446744073709551615' AS UInt64), CAST('0' AS UInt64)]) AS v", chType: 'Map(String, Array(UInt64))', expected: "{'k':[18446744073709551615,0]}", because: 'CORRECTED (verified live): map-of-arrays nests the same plain-text array syntax (unquoted UInt64 members) inside the single-quoted-key map syntax' }, -]; diff --git a/tests/spike/clickhouse-client/format-type-probe.ts b/tests/spike/clickhouse-client/format-type-probe.ts deleted file mode 100644 index 371dc2ab..00000000 --- a/tests/spike/clickhouse-client/format-type-probe.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Phase 0 / issue #585, ADR-0005 §16 — compile-time proof of whether the -// installed `@clickhouse/client-web@1.23.1` publicly supports requesting -// `JSONStringsEachRowWithProgress` (the exact format `src/net/ch-client.ts` -// uses for Table streaming — see `chUrl`'s default and `runQuery`'s -// `fmtParam`). -// -// This file is included in the root `tsconfig.json` (`tests/spike/ -// clickhouse-client/**/*.ts`) so `npm run check:types` compiles it under the -// repository's normal strict settings. A future upstream type-surface change -// (the format becoming supported, or the `@ts-expect-error` becoming -// unnecessary) makes `check:types` FAIL until this file and the ADR evidence -// are reconciled — the file is the compile-time proof, not just a comment -// about one. -// -// Never imported by production or by the parity harness — it exists purely -// to be type-checked and to fail loudly on a `@ts-expect-error` mismatch. - -import { createClient } from '@clickhouse/client-web'; - -// A non-secret, unused-at-runtime client instance: this module is never -// executed (no test imports it, no build entry references it), only -// type-checked. `noUnusedLocals`/`noUnusedParameters` are not enabled in the -// repository's tsconfig, so an unused top-level const is fine here — but to -// stay unambiguous about intent, every probe below is inlined into a single -// generic function signature check instead of constructing a real client. - -declare const client: ReturnType; - -// ── Positive control ───────────────────────────────────────────────────── -// `JSONEachRowWithProgress` (the KPI-path format, unquoted numeric progress) -// IS part of the public `DataFormat` literal union -// (`SupportedJSONFormats`/`StreamableJSONFormats` in -// `dist/common/data_formatter/formatter.d.ts`). An uncast call must compile. -void client.query({ query: 'SELECT 1', format: 'JSONEachRowWithProgress' }); - -// ── Negative probe ─────────────────────────────────────────────────────── -// `JSONStringsEachRowWithProgress` (the Table-path format -// `chUrl`/`runQuery` default to — every numeric/precision-sensitive value -// arrives pre-stringified by the server, which is exactly what -// `core/stream.ts`'s `applyStreamLine` and the precision corpus rely on) is -// NOT a member of `SupportedJSONFormats`/`StreamableJSONFormats`/`DataFormat` -// in the installed 1.23.1 `dist/common/data_formatter/formatter.d.ts`. An -// uncast call is a compile error under the closed literal union — this line -// is expected to fail, and `@ts-expect-error` itself fails `check:types` if -// the line stops erroring (i.e. if upstream ever adds public support). -// -// Diagnostic captured in evidence (docs/evidence/585/critical-questions.md): -// TS2322-class "Argument of type '"JSONStringsEachRowWithProgress"' is not -// assignable to parameter of type 'DataFormat'" (exact literal not part of -// the `JSONDataFormat | RawDataFormat` union). -// -// @ts-expect-error — JSONStringsEachRowWithProgress is not a public DataFormat literal (see ADR-0005 §"JSONStringsEachRowWithProgress decision"). -void client.query({ query: 'SELECT 1', format: 'JSONStringsEachRowWithProgress' }); - -// A type cast does NOT count as public support (plan §16) — recorded here so -// the distinction is visible next to the probe, not exercised: casting past -// the union (`format: 'JSONStringsEachRowWithProgress' as any`) always -// "compiles" and would prove nothing about public support. `exec()` (used -// with the full literal SQL, `FORMAT JSONStringsEachRowWithProgress`) is the -// experimentally-chosen path — see `progress-bridge.ts`. diff --git a/tests/spike/clickhouse-client/guarded-fetch.ts b/tests/spike/clickhouse-client/guarded-fetch.ts deleted file mode 100644 index dc889221..00000000 --- a/tests/spike/clickhouse-client/guarded-fetch.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Phase 0 / issue #585, plan §21 "Immediate pre-fetch epoch fencing" — -// experiment infrastructure for whether a credential-epoch race (the -// replacement happening AFTER a request is prepared but BEFORE its real -// fetch fires) can still reach the network with a stale/replaced credential -// when the official client owns request construction internally. -// -// Two checkpoints, both required by the plan: -// 1. adapter-side: immediately before invoking the official API method -// (`register` below refuses to register — and the caller must not call -// the client at all — when the epoch has already turned). -// 2. injected-fetch boundary: `guardedFetch` re-checks the CURRENT epoch -// against the epoch that was current when the query_id was registered, -// immediately before delegating to the real fetch. This is the -// checkpoint that actually closes the race the plan describes, since -// the official client's internal work between "call the method" and -// "invoke injected fetch" is exactly the unguarded window. -// -// This is spike-only experiment code: if it becomes a second general request -// implementation (the plan's own failure condition), the ADR must fail -// auth/epoch parity rather than adopt it as-is. - -export interface EpochFence { - /** Adapter-side checkpoint #1. Call immediately before invoking the - * official client method. Returns false (and registers nothing) when the - * epoch has already turned — the caller must treat this exactly like a - * stale-epoch abort and never invoke the official API. */ - register(queryId: string, expectedEpoch: number): boolean; - /** Removes a query_id's registration once its call has settled. */ - unregister(queryId: string): void; - /** Injected-fetch checkpoint #2 — pass as `fetch` to `createClient`. */ - guardedFetch: typeof fetch; - /** How many times the real fetch was actually delegated to — the - * "no stale credential reaches fetch" invariant's proof. */ - readonly delegatedCalls: number; - /** How many times a request was rejected at the fetch boundary for being - * stale — the epoch-flip race's proof of effect. */ - readonly staleRejections: number; - /** How many times a delegated fetch's RESPONSE arrived after the epoch had - * already turned (plan §21 "stale response": "no replacement lifecycle or - * auth mutation") — this checkpoint doesn't reject the response (the - * original caller's data is still theirs to read), it only proves a - * post-response checkpoint EXISTS and fires, so a future Phase 1 adapter - * wiring a real lifecycle callback (`onTransportConnected`-equivalent) - * would have somewhere to gate it, exactly like production's own auth - * request path gates `ctx.authConfirmed`/`onTransportConnected` on the - * same check immediately after its `fetch` resolves — at the time this - * spike was written, that was `ch-client.ts`'s `authedFetch`; since #630 - * Phase 6 it is `authenticated-clickhouse-request.ts`'s - * `authenticatedRequest`, unchanged in shape. */ - readonly staleResponses: number; -} - -/** Extract `query_id` from a ClickHouse HTTP request URL's query string — - * the official client always sends it there (matching the production - * `ch-client.ts` URL shape), so this needs no test-only header. */ -function extractQueryId(input: RequestInfo | URL): string | null { - try { - const url = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL((input as Request).url); - return url.searchParams.get('query_id'); - } catch { - return null; - } -} - -class StaleEpochError extends Error { - constructor() { - super('request superseded by a newer authentication session (epoch fence)'); - this.name = 'AbortError'; - } -} - -/** Build one `EpochFence` bound to `getCurrentEpoch()` and a real delegate - * fetch. `registered` is removed in `finally` by the caller once its call - * settles (register/unregister is caller-driven, not fetch-driven, since a - * query_id may legitimately appear in more than one request — e.g. KILL - * QUERY reusing the same id is out of scope for this fence). */ -export function createEpochFence(getCurrentEpoch: () => number, realFetch: typeof fetch): EpochFence { - const registered = new Map(); - let delegatedCalls = 0; - let staleRejections = 0; - let staleResponses = 0; - - function register(queryId: string, expectedEpoch: number): boolean { - if (getCurrentEpoch() !== expectedEpoch) return false; - registered.set(queryId, expectedEpoch); - return true; - } - - const guardedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const queryId = extractQueryId(input); - const expectedEpoch = queryId != null ? registered.get(queryId) : undefined; - if (expectedEpoch !== undefined && getCurrentEpoch() !== expectedEpoch) { - staleRejections += 1; - throw new StaleEpochError(); - } - delegatedCalls += 1; - const resp = await realFetch(input, init); - // Post-response checkpoint (plan §21 "stale response"): the epoch may - // have turned WHILE this fetch was in flight. The response still belongs - // to its original caller (never discarded/rejected here — see the - // `staleResponses` docstring), but this proves the checkpoint fires. - if (expectedEpoch !== undefined && getCurrentEpoch() !== expectedEpoch) { - staleResponses += 1; - } - return resp; - }) as typeof fetch; - - return { - register, - unregister(queryId: string) { registered.delete(queryId); }, - guardedFetch, - get delegatedCalls() { return delegatedCalls; }, - get staleResponses() { return staleResponses; }, - get staleRejections() { return staleRejections; }, - }; -} diff --git a/tests/spike/clickhouse-client/live-parity.test.ts b/tests/spike/clickhouse-client/live-parity.test.ts deleted file mode 100644 index d1fb2aa2..00000000 --- a/tests/spike/clickhouse-client/live-parity.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -// Phase 0 / issue #585 — real-server progressive timing (§19), real -// mid-stream progress-format exceptions (§20), real server cancellation via -// `KILL QUERY`/`system.processes` (§22), and real raw/export byte-hash -// parity including a genuine late exception frame (§20/§24). Requires -// `ASB_SPIKE_CH_URL` (set externally); skips cleanly when unset — see -// `live-precision.test.ts`'s header for why. - -import { describe, it, expect } from 'vitest'; -import { runCurrent, currentKillQuery, makeCurrentCtx } from './current-adapter.js'; -import { createOfficialConnection, runOfficial } from './official-adapter.js'; -import { BASIC_USER_A } from './auth-fixtures.js'; -import type { SpikeRequest } from './types.js'; - -// See live-precision.test.ts's header comment for why this reads `process` -// through an untyped `globalThis` cast rather than an ambient `.d.ts`. -function envVar(name: string): string | undefined { - return (globalThis as unknown as { process?: { env?: Record } }).process?.env?.[name]; -} - -const CH_URL = envVar('ASB_SPIKE_CH_URL'); - -function baseReq(overrides: Partial = {}): SpikeRequest { - return { - sql: 'SELECT 1', - format: 'Table', - credential: BASIC_USER_A, - origin: 'same-origin', - consume: 'rows', - ...overrides, - }; -} - -function median(nums: number[]): number { - const sorted = [...nums].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; -} - -describe.skipIf(!CH_URL)('live progressive timing against a real ClickHouse server (plan §19)', () => { - // A query that flushes one row per block, each block delayed — real-server - // evidence that first-row publication precedes completion by a meaningful - // margin on BOTH adapters, not merely on the deterministic fault server. - // - // `enable_http_compression: 0` is REQUIRED here — discovered by THIS - // test's first-ever run: Node's built-in `fetch` (undici), when the - // server responds with `Content-Encoding: gzip` (ClickHouse's own default - // whenever `enable_http_compression=1`, which `chUrl`'s own default sends - // on every request), decompresses the ENTIRE gzip stream before handing - // ANY bytes to the `ReadableStream` reader — verified directly: 8 chunks - // arriving progressively over ~1.9s WITHOUT compression, vs. exactly 1 - // chunk arriving only at the very end WITH it, for the identical query. - // This is a NODE-RUNTIME-SPECIFIC limitation (a real browser's `fetch` - // streams a compressed body progressively, which is WHY ClickHouse's - // response compression is safe for production — the shipped app never - // runs under Node). It has nothing to do with either adapter: both - // `runCurrent` and `runOfficial` observed the identical non-progressive - // behavior under Node with compression on. Disabling it here is a - // narrow, test-harness-only accommodation for measuring real per-block - // timing under Node — never a claim about production/browser behavior. - const TIMING_SQL = 'SELECT sleepEachRow(0.3) FROM numbers(6) SETTINGS max_block_size = 1'; - const REPS = 5; - - it(`current adapter: first row precedes completion by >=1s across ${REPS} repetitions (median/range recorded)`, async () => { - const firstRows: number[] = []; - const completions: number[] = []; - for (let i = 0; i < REPS; i++) { - // eslint-disable-next-line no-await-in-loop -- sequential repetitions are the point: each is an independent real-server timing sample. - const { outcome } = await runCurrent(baseReq({ sql: TIMING_SQL, queryId: `live-timing-current-${Date.now()}-${i}`, settings: { enable_http_compression: 0 } }), CH_URL!, fetch); - expect(outcome.error).toBeNull(); - expect(outcome.firstRowAtMs).not.toBeNull(); - expect(outcome.completedAtMs).not.toBeNull(); - firstRows.push(outcome.firstRowAtMs!); - completions.push(outcome.completedAtMs!); - expect(outcome.completedAtMs! - outcome.firstRowAtMs!).toBeGreaterThanOrEqual(900); // >= ~1s of later rows, matching plan §19's "completion at least one second later" - } - // eslint-disable-next-line no-console - console.log('live timing (current adapter) firstRow ms:', firstRows, 'median', median(firstRows), 'completed ms:', completions, 'median', median(completions)); - }, 60_000); - - it(`official adapter: first row precedes completion by >=1s across ${REPS} repetitions, and never buffers the whole body (median/range recorded)`, async () => { - const conn = createOfficialConnection(CH_URL!, fetch); - const firstRows: number[] = []; - const completions: number[] = []; - for (let i = 0; i < REPS; i++) { - // eslint-disable-next-line no-await-in-loop - const { outcome } = await runOfficial(conn, baseReq({ sql: TIMING_SQL, queryId: `live-timing-official-${Date.now()}-${i}`, settings: { enable_http_compression: 0 } })); - expect(outcome.error).toBeNull(); - expect(outcome.firstRowAtMs).not.toBeNull(); - expect(outcome.completedAtMs).not.toBeNull(); - firstRows.push(outcome.firstRowAtMs!); - completions.push(outcome.completedAtMs!); - expect(outcome.completedAtMs! - outcome.firstRowAtMs!).toBeGreaterThanOrEqual(900); - } - // eslint-disable-next-line no-console - console.log('live timing (official adapter) firstRow ms:', firstRows, 'median', median(firstRows), 'completed ms:', completions, 'median', median(completions)); - expect(conn.constructorCalls).toBe(1); - }, 60_000); -}); - -describe.skipIf(!CH_URL)('live mid-stream progress-format exception against a real ClickHouse server (plan §20)', () => { - // throwIf(...) forces a genuine ClickHouse exception partway through a - // multi-block result — max_block_size=1 flushes each row as its own block - // so rows before the failing one are ALREADY sent when the exception hits. - const MIDSTREAM_SQL = "SELECT throwIf(number = 3, 'asb585 live mid-stream boom') AS v FROM numbers(6) SETTINGS max_block_size = 1"; - - // DISCOVERED BY THIS TEST'S FIRST-EVER RUN (2026-08-05, ClickHouse - // 26.6.2.160): the SQL exception text ("asb585 live mid-stream boom") - // does NOT reach either adapter's `outcome.error` for THIS scenario. Root - // cause, isolated directly (raw `fetch` against the same query, bypassing - // both adapters entirely): - // 1. By DEFAULT (no override), this server does NOT emit the clean - // in-band `{"exception": "..."}` JSON line `core/stream.ts`'s - // `applyStreamLine` (production) and this spike's `progress- - // bridge.ts` both parse — it emits the SAME raw - // `__exception__\r\n\r\n...` TEXT FRAME the raw/export - // path already handles (`findExceptionFrame`), REGARDLESS of format. - // Setting `http_write_exception_in_output_format=1` restores the - // clean JSON line; this server's default is effectively off. - // 2. After sending that raw frame, the connection is closed WITHOUT a - // clean chunked-encoding terminator — Node's `fetch` (undici) surfaces - // this as a "terminated"/`UND_ERR_SOCKET` read error, not a clean - // stream end, REGARDLESS of HTTP compression. - // Neither `applyStreamLine`/`progress-bridge.ts` parses raw exception- - // frame text (both only recognize the JSON `{"exception"}` shape) — so - // that text is silently skipped as malformed JSON lines, and the ONLY - // thing that ultimately surfaces as `outcome.error` is the low-level - // "terminated" transport error from step 2, propagating through each - // adapter's own try/catch. Plan §20's actual hard requirement — "never - // silently report success" — IS satisfied (both fail, neither returns a - // clean 6-row success), but the FRIENDLY exception text is lost on both - // paths under this server's current defaults. This is flagged prominently - // in the final report as a significant, ADR-relevant finding for whoever - // owns `core/stream.ts`/the eventual Phase 1 transport — fixing it is - // outside this sub-task's scope (no production file may be touched here). - it('current adapter: the query definitively fails, never silently succeeds (partial-row count is timing-dependent under this failure mode — see the official-adapter test below)', async () => { - const { outcome } = await runCurrent(baseReq({ sql: MIDSTREAM_SQL }), CH_URL!, fetch); - expect(outcome.error).not.toBeNull(); - expect(outcome.error).not.toBe(''); - expect(outcome.rows.length).toBeLessThan(6); // never the full, unfailed result - }); - - it('official adapter: the query definitively fails, never silently succeeds (partial-row count is timing-dependent under this failure mode, not asserted here — see below)', async () => { - // Unlike the current-adapter test above (which reliably observed rows - // 0-2 before the fatal read), THIS adapter's row count here was found to - // be genuinely RACY across repeated real runs (0 rows some runs, >0 - // others) — the fatal "terminated" read sometimes wins the race against - // the vendor client's own NDJSON line-buffering before even one row - // line is parsed. The one invariant that held on every repeated run is - // asserted: never the full 6-row result, and always a definite error. - const conn = createOfficialConnection(CH_URL!, fetch); - const { outcome } = await runOfficial(conn, baseReq({ sql: MIDSTREAM_SQL })); - expect(outcome.error).not.toBeNull(); - expect(outcome.error).not.toBe(''); - expect(outcome.rows.length).toBeLessThan(6); - }); -}); - -describe.skipIf(!CH_URL)('live raw/export byte-hash parity against a real ClickHouse server (plan §20/§24)', () => { - // NOTE: plain `JSON` is deliberately excluded from this byte-hash-equality - // list — discovered by this test's first-ever run: ClickHouse's `JSON` - // format body embeds a per-EXECUTION, non-deterministic - // `"statistics":{"elapsed": }` field, so two separate requests - // for the identical query NEVER hash identically even when every other - // byte matches exactly (verified: a byte-level diff of two captures showed - // the ENTIRE difference was that one floating-point timing field). `TSV`/ - // `CSV`/`RowBinary`/`TSVRaw` (below) carry no such per-execution field and - // are the correct vehicles for this exact-byte-hash proof. - const RAW_FORMATS: Array<{ id: string; format: string }> = [ - { id: 'tsv', format: 'TSV' }, - { id: 'csv', format: 'CSV' }, - ]; - - it.each(RAW_FORMATS)('$id: current and official raw export byte-hash identically for a NUL+Unicode string column', async ({ format }) => { - const sql = "SELECT number, 'a\\0b\u{1F600}c' AS s FROM numbers(5)"; - const current = await runCurrent(baseReq({ sql, format, consume: 'raw' }), CH_URL!, fetch); - const conn = createOfficialConnection(CH_URL!, fetch); - const official = await runOfficial(conn, baseReq({ sql, format, consume: 'raw' })); - expect(current.outcome.error).toBeNull(); - expect(official.outcome.error).toBeNull(); - expect(current.outcome.rawByteCount).not.toBeNull(); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - expect(current.outcome.rawByteCount).toBe(official.outcome.rawByteCount); - }); - - it('RowBinary (raw, binary-capable path): current and official raw export byte-hash identically', async () => { - const sql = 'SELECT number, toString(number) AS s FROM numbers(20)'; - const current = await runCurrent(baseReq({ sql, format: 'RowBinary', consume: 'raw' }), CH_URL!, fetch); - const conn = createOfficialConnection(CH_URL!, fetch); - const official = await runOfficial(conn, baseReq({ sql, format: 'RowBinary', consume: 'raw' })); - expect(current.outcome.error).toBeNull(); - expect(official.outcome.error).toBeNull(); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - expect(current.outcome.rawByteCount).toBeGreaterThan(0); - }); - - it('invalid UTF-8 raw bytes: current and official hash identically (no text-decoding on either raw path)', async () => { - // 0xFF is not valid UTF-8 anywhere — reinterpretCast/toFixedString forces - // ClickHouse to emit it verbatim in a raw TSV column. - const sql = "SELECT reinterpretAsFixedString(toUInt8(255)) AS v FROM numbers(3)"; - const current = await runCurrent(baseReq({ sql, format: 'TSVRaw', consume: 'raw' }), CH_URL!, fetch); - const conn = createOfficialConnection(CH_URL!, fetch); - const official = await runOfficial(conn, baseReq({ sql, format: 'TSVRaw', consume: 'raw' })); - expect(current.outcome.error).toBeNull(); - expect(official.outcome.error).toBeNull(); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - }); - - it('a genuine late export exception never completes as a clean, full success on either adapter, and both fail identically', async () => { - // Same throwIf trick as the mid-stream Table test, but through the RAW - // export path. DISCOVERED BY THIS TEST'S FIRST-EVER RUN (see the - // mid-stream Table describe block's header comment above for the full - // root-cause writeup, which applies identically here): this server - // closes the connection after the exception frame WITHOUT a clean - // chunked-encoding terminator, so BOTH adapters' raw byte-read loops - // throw ("terminated"/`UND_ERR_SOCKET`) rather than reaching their own - // `rawByteCount`/`rawSha256` assignment — `outcome.error` is what - // ultimately reports the failure on both sides, not a raw byte hash. - // The plan §20 hard requirement this still proves: raw export NEVER - // silently reports a clean 6-row success after a genuine mid-export - // exception, on EITHER adapter. - const sql = "SELECT throwIf(number = 3, 'asb585 live late export boom') AS v FROM numbers(6) SETTINGS max_block_size = 1"; - const current = await runCurrent(baseReq({ sql, format: 'TSV', consume: 'raw' }), CH_URL!, fetch); - const conn = createOfficialConnection(CH_URL!, fetch); - const official = await runOfficial(conn, baseReq({ sql, format: 'TSV', consume: 'raw' })); - // Neither side may report a clean success: EITHER an explicit error, OR - // (if a future ClickHouse/runtime combination someday delivers a clean - // stream end) a byte count that is NOT the full unfailed 6-row body. - const currentFailedOrPartial = current.outcome.error != null || (current.outcome.rawByteCount != null && current.outcome.rawByteCount < 30); - const officialFailedOrPartial = official.outcome.error != null || (official.outcome.rawByteCount != null && official.outcome.rawByteCount < 30); - expect(currentFailedOrPartial).toBe(true); - expect(officialFailedOrPartial).toBe(true); - // Both adapters must observe the SAME class of outcome (both errored, - // or both got a byte count) — a divergence here (one clean, one not) - // would be a real parity gap, unlike the shared "terminated" failure - // mode itself. - expect(current.outcome.error != null).toBe(official.outcome.error != null); - }); -}); - -describe.skipIf(!CH_URL)('live server cancellation via KILL QUERY / system.processes (plan §22)', () => { - it('a pre-allocated query_id is observable in system.processes; local abort + KILL QUERY makes it disappear', async () => { - const queryId = `asb585-live-cancel-${Date.now()}`; - const ctx = makeCurrentCtx(baseReq({ sql: '', queryId }), CH_URL!, fetch); - const controller = new AbortController(); - - // A long-running query, started but not awaited yet. `sleep(2.5)` (a - // fixed, predictable wall-clock delay), NOT a row-count query — - // discovered by this test's first-ever run: `SELECT count() FROM - // numbers(200000000)` completes in under 100ms on a real, unconstrained - // server (ClickHouse's `numbers()` source is heavily optimized), far too - // fast for the polling loop below to reliably observe it in - // `system.processes` before it finishes — a genuine, reproducible flake - // in the ORIGINAL query choice, not a cancellation-mechanism defect. - // 2.5s, not more: ALSO discovered live — ClickHouse's `sleep()` function - // has a hardcoded maximum of 3 seconds (`Code: 160. TOO_SLOW` above it), - // so `sleep(4)` fails INSTANTLY with a real exception rather than ever - // running long enough to observe or cancel at all. - const longRunning = runCurrent( - baseReq({ sql: 'SELECT sleep(2.5)', queryId, signal: controller.signal }), - CH_URL!, - fetch, - ); - - // 1/2. Poll system.processes for the query_id to appear (own-query - // visibility needs no special privilege) — bounded, real-server timing. - let seen = false; - for (let i = 0; i < 40 && !seen; i++) { - // eslint-disable-next-line no-await-in-loop - const probe = await runCurrent( - baseReq({ sql: `SELECT count() FROM system.processes WHERE query_id = '${queryId}'`, queryId: `${queryId}-probe-${i}` }), - CH_URL!, - fetch, - ); - if (probe.outcome.error == null && probe.outcome.rows[0]?.[0] === '1') seen = true; - else await new Promise((r) => setTimeout(r, 250)); - } - expect(seen).toBe(true); - - // 3. Local abort. - controller.abort(); - const result = await longRunning; - expect(result.outcome.cancelled).toBe(true); - - // 4. KILL QUERY against the REAL, running server — the REAL production - // `killQuery` (via current-adapter.ts's re-export), which resolves auth - // through `ctx`'s live authentication path. This is NOT - // `killQueryWithLease` (the frozen-credential cancellation path used by a - // closing authenticated execution scope, per src/net/ch-client.ts) — that - // invariant (a credential rotated after capture never reaches the - // request) is proven deterministically in parity.test.ts's "cancellation - // lease" block, which imports and calls `killQueryWithLease` directly. - // This test's job is the complementary, live-server-only proof: that - // issuing KILL QUERY for a real, still-running query_id actually makes it - // disappear from `system.processes`. - await currentKillQuery(ctx, queryId); - - // 5. Poll until it disappears from system.processes. - let gone = false; - for (let i = 0; i < 40 && !gone; i++) { - // eslint-disable-next-line no-await-in-loop - const probe = await runCurrent( - baseReq({ sql: `SELECT count() FROM system.processes WHERE query_id = '${queryId}'`, queryId: `${queryId}-probe-gone-${i}` }), - CH_URL!, - fetch, - ); - if (probe.outcome.error == null && probe.outcome.rows[0]?.[0] === '0') gone = true; - else await new Promise((r) => setTimeout(r, 250)); - } - expect(gone).toBe(true); - }, 40_000); - - it('the same server-cancellation proof holds for the official adapter (query_id observed pre-execution, aborted, KILLed, disappears)', async () => { - const queryId = `asb585-live-cancel-official-${Date.now()}`; - const conn = createOfficialConnection(CH_URL!, fetch); - const ctx = makeCurrentCtx(baseReq({ sql: '', queryId }), CH_URL!, fetch); // KILL QUERY reuses the real production killQuery — adapter-agnostic - const controller = new AbortController(); - - // See the current-adapter test above for why `sleep(2.5)`, not a row count. - const longRunning = runOfficial(conn, baseReq({ sql: 'SELECT sleep(2.5)', queryId, signal: controller.signal })); - - let seen = false; - for (let i = 0; i < 40 && !seen; i++) { - // eslint-disable-next-line no-await-in-loop - const probe = await runOfficial(conn, baseReq({ sql: `SELECT count() FROM system.processes WHERE query_id = '${queryId}'`, queryId: `${queryId}-probe-${i}` })); - if (probe.outcome.error == null && probe.outcome.rows[0]?.[0] === '1') seen = true; - else await new Promise((r) => setTimeout(r, 250)); - } - expect(seen).toBe(true); - - controller.abort(); - const result = await longRunning; - expect(result.outcome.cancelled).toBe(true); - - await currentKillQuery(ctx, queryId); - - let gone = false; - for (let i = 0; i < 40 && !gone; i++) { - // eslint-disable-next-line no-await-in-loop - const probe = await runOfficial(conn, baseReq({ sql: `SELECT count() FROM system.processes WHERE query_id = '${queryId}'`, queryId: `${queryId}-probe-gone-${i}` })); - if (probe.outcome.error == null && probe.outcome.rows[0]?.[0] === '0') gone = true; - else await new Promise((r) => setTimeout(r, 250)); - } - expect(gone).toBe(true); - }, 40_000); -}); diff --git a/tests/spike/clickhouse-client/live-precision.test.ts b/tests/spike/clickhouse-client/live-precision.test.ts deleted file mode 100644 index efc82061..00000000 --- a/tests/spike/clickhouse-client/live-precision.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Phase 0 / issue #585, plan §17 "Precision corpus" — runs the FULL -// `PRECISION_CORPUS` (expected-values.ts) through both adapters against a -// REAL ClickHouse server. Requires `ASB_SPIKE_CH_URL` (set by -// `clickhouse-containers.mjs`'s `up` command, or by a future -// `run-matrix.mjs`) to point at a reachable, already-bootstrapped server — -// this file never boots or tears down a container itself, and skips -// CLEANLY (an empty, green `describe.skip`) when the variable is unset, so -// `npm run test:client-spike` stays green with no server running (plan §8's -// hard requirement: the spike vitest `include` has no separate live-only -// glob, so an env-gated skip is the only way to keep the default run -// offline-safe). -// -// Credentials: the non-secret `BASIC_USER_A` fixture (auth-fixtures.ts) — -// the SAME literal username/password `clickhouse-containers.mjs` bootstraps -// into every row it boots (cross-referenced there). - -import { describe, it, expect, beforeAll } from 'vitest'; -import { PRECISION_CORPUS } from './expected-values.js'; -import { runPrecisionCase, type PrecisionCaseResult } from './precision-corpus.js'; -import { createOfficialConnection, type OfficialConnection } from './official-adapter.js'; -import { BASIC_USER_A } from './auth-fixtures.js'; - -// The repo carries no `@types/node` (ADR-0002: dev-time-only strict TS over -// browser-shipped source, CLAUDE.md hard rule 1/4) and this sub-task's file -// scope does not include `tests/types/**` (the repo's own precedent location -// for a Node ambient-global `.d.ts`, e.g. `node-crypto.d.ts`) — so `process` -// is read through an untyped `globalThis` cast rather than adding a new -// ambient declaration file outside that scope. -function envVar(name: string): string | undefined { - return (globalThis as unknown as { process?: { env?: Record } }).process?.env?.[name]; -} - -const CH_URL = envVar('ASB_SPIKE_CH_URL'); - -// NOTE on history: this file's first-ever real run (2026-08-05, against -// clickhouse/clickhouse-server 26.6.2.160) found that 11 of the corpus's -// container/JSON/NULL-typed cases disagreed with `expected-values.ts`'s -// literals even though `currentValue === officialValue` on every one of -// them (both adapters observing the identical wire bytes) — proving the -// literals themselves were authored from an incorrect assumption about -// `JSONStringsEachRowWithProgress`'s container serialization (documented in -// full in `expected-values.ts`'s header comment and each corrected case's -// own `because` field), not an adapter defect or a parity gap. Those 11 -// literals are now corrected there (issue #585 Phase 0 evidence review, -// 2026-08-06), so this file asserts a single, unqualified full-corpus match -// with no exclusion list. - -describe.skipIf(!CH_URL)('live precision corpus against a real ClickHouse server (plan §17)', () => { - let conn: OfficialConnection; - let results: PrecisionCaseResult[]; - - beforeAll(async () => { - // ONE official client for the whole corpus run (plan's "one official - // client per connection config" invariant) — `runPrecisionCase`'s - // optional `officialConn` parameter (added for this call site) reuses - // it across every one of the ~40 cases instead of constructing a fresh - // client per case. - conn = createOfficialConnection(CH_URL!, fetch); - results = []; - for (const kase of PRECISION_CORPUS) { - // eslint-disable-next-line no-await-in-loop -- sequential by design: precision cases must not race each other's query_id/session-less state, and this corpus is small enough that sequential execution is fast. - results.push(await runPrecisionCase(kase, CH_URL!, BASIC_USER_A, fetch, conn)); - } - }, 120_000); - - it('exercises every corpus case exactly once — none silently dropped (plan §17 point 8)', () => { - expect(results.map((r) => r.id).sort()).toEqual(PRECISION_CORPUS.map((k) => k.id).sort()); - }); - - it('constructs exactly one official client for the whole corpus run', () => { - expect(conn.constructorCalls).toBe(1); - }); - - it('every non-capability-gated case matches the independent expectation on BOTH adapters, and both adapters agree with each other', () => { - const hardFailures = results.filter((r) => !r.skippedReason - && (!r.currentMatchesExpected || !r.officialMatchesExpected || !r.currentMatchesOfficial)); - if (hardFailures.length) { - // Not silently swallowed into a boolean — the exact case id, expected - // literal, and each adapter's actual value land in the failure output. - // eslint-disable-next-line no-console - console.error('live precision corpus failures:', JSON.stringify(hardFailures, null, 2)); - } - expect(hardFailures).toEqual([]); - }); - - it('every capability-gated omission is recorded, never a case that was simply expected to match and silently didn\'t (plan §17 point 8)', () => { - const skipped = results.filter((r) => r.skippedReason); - for (const r of skipped) { - expect(r.capabilityGated, `case "${r.id}" skipped ("${r.skippedReason}") but is not marked capabilityGated in expected-values.ts`).toBe(true); - } - }); -}); diff --git a/tests/spike/clickhouse-client/live-sessions.test.ts b/tests/spike/clickhouse-client/live-sessions.test.ts deleted file mode 100644 index 2ca0105b..00000000 --- a/tests/spike/clickhouse-client/live-sessions.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// Phase 0 / issue #585, plan §23 "Sessions and retry safety" — temporary -// tables, session `SET` persistence, and a REAL `SESSION_IS_LOCKED` -// classification/retry against a real ClickHouse server. Requires -// `ASB_SPIKE_CH_URL` (set externally by `clickhouse-containers.mjs`/a future -// `run-matrix.mjs`); skips cleanly when unset — see `live-precision.test.ts`'s -// header for why this env-gate is mandatory, not optional. - -import { describe, it, expect } from 'vitest'; -import { runCurrent } from './current-adapter.js'; -import { createOfficialConnection, runOfficial, officialAuthFor, type OfficialConnection } from './official-adapter.js'; -import { bridgeNdjsonProgress } from './progress-bridge.js'; -import { createQueryExecutionService } from '../../../src/application/query-execution-service.js'; -import { BASIC_USER_A } from './auth-fixtures.js'; -import type { QueryExecutionRequest } from '../../../src/application/query-execution-service.js'; -import type { SpikeCredential, SpikeRequest } from './types.js'; - -// See live-precision.test.ts's header comment for why this reads `process` -// through an untyped `globalThis` cast rather than an ambient `.d.ts`. -function envVar(name: string): string | undefined { - return (globalThis as unknown as { process?: { env?: Record } }).process?.env?.[name]; -} - -const CH_URL = envVar('ASB_SPIKE_CH_URL'); - -function baseReq(overrides: Partial = {}): SpikeRequest { - return { - sql: 'SELECT 1', - format: 'Table', - credential: BASIC_USER_A, - origin: 'same-origin', - consume: 'rows', - ...overrides, - }; -} - -/** - * A SESSION-AWARE variant of `official-adapter.ts`'s own - * `makeOfficialQueryExecutionAdapter` — that adapter's `runText` has no - * `session_id` parameter at all (every deterministic scenario that needs one - * drives `runOfficial` directly instead — see its own docstring), so a - * session-carrying `QueryExecutionDeps['runText']` for the LIVE - * `SESSION_IS_LOCKED` proof below is written locally rather than expanding - * `official-adapter.ts`'s public surface outside this sub-task's declared - * file scope (#630 Phase 7, plan §19). Uses `exec()` + - * `FORMAT JSONStringsEachRowWithProgress` (the same Table-shaped bridge - * `official-adapter.ts`'s `runProgress` uses) rather than `command()` (unlike - * `runOfficialCommand` below) — this test's ONLY statement routed through it - * is `SELECT 1` (row-returning). Matching the new "package consumers throw" - * contract (#630 Phase 7 §6.5): a pre-header rejection (a `ClickHouseError` - * thrown by `exec()` itself), a mid-stream network failure, and an in-band - * `{"exception"}` line ALL propagate as a throw now, never a returned - * `{error}` — so `QueryExecutionService`'s real, unmodified - * `attemptStatement`/`SESSION_BUSY` retry logic runs unmodified against it, - * never a reimplementation of that policy, only of the session_id plumbing - * `makeOfficialQueryExecutionAdapter` doesn't carry. - */ -function makeSessionAwareRunText(conn: OfficialConnection, credential: SpikeCredential, sessionId: string): (request: QueryExecutionRequest) => Promise { - return async function sessionAwareRunText(request: QueryExecutionRequest): Promise { - const { query_id: queryId, ...nativeParams } = request.params || {}; - const auth = officialAuthFor(credential); - const fullSql = `${request.sql}\nFORMAT JSONStringsEachRowWithProgress`; - const res = await conn.client.exec({ - query: fullSql, - query_id: queryId != null ? String(queryId) : undefined, - session_id: sessionId, - abort_signal: request.signal, - auth, - query_params: nativeParams, - }); - let sawException: string | null = null; - await bridgeNdjsonProgress(res.stream, (line) => { - if (line.exception) sawException = line.exception; - }); - if (sawException) throw new Error(sawException); - return ''; - }; -} - -/** - * Run a statement that produces no output the caller cares about — `CREATE - * TEMPORARY TABLE`, `INSERT ... VALUES`, `SET` — through the official - * client's `command()`, per plan §7's own rule ("use `command()` only when - * discarding output is intentional"). `runOfficial`'s own `SpikeRequest` - * vocabulary has no such mode (`consume` is only `'rows' | 'raw'`, both of - * which route through `exec()`'s Table/raw branches, EVERY one of which - * appends a literal `FORMAT ...` clause to the SQL text — required by the - * installed `1.23.1` `ExecParams` type itself ("Statement to execute - * (including the FORMAT clause)"), but a hard ClickHouse SYNTAX_ERROR for - * `SET ...`/`INSERT ... VALUES (...)`, discovered by THIS test's first-ever - * real run — see the final report for the full write-up). This helper is - * therefore the correct, narrow way to drive these three statement kinds - * through the official client from a spike TEST file without expanding - * `official-adapter.ts`'s own exported surface for a single call site. - */ -async function runOfficialCommand(conn: OfficialConnection, credential: SpikeCredential, sessionId: string | undefined, sql: string): Promise { - await conn.client.command({ query: sql, session_id: sessionId, auth: officialAuthFor(credential) }); -} - -describe.skipIf(!CH_URL)('live sessions, temporary tables, and SESSION_IS_LOCKED against a real ClickHouse server (plan §23)', () => { - it('temporary table: persists only inside its explicit session, absent outside it — current adapter', async () => { - const table = `asb585_tmp_current_${Date.now()}`; - const sessionId = `asb585-live-session-current-${Date.now()}`; - - // 1. session-less control: no such table exists at all yet. - const control = await runCurrent(baseReq({ sql: `EXISTS TABLE ${table}` }), CH_URL!, fetch); - expect(control.outcome.error).toBeNull(); - expect(control.outcome.rows).toEqual([['0']]); - - // 2. create it inside the explicit session. - const create = await runCurrent(baseReq({ sql: `CREATE TEMPORARY TABLE ${table} (x Int32) ENGINE = Memory`, sessionId }), CH_URL!, fetch); - expect(create.outcome.error).toBeNull(); - - // 3. read it back in the SAME session. - const insert = await runCurrent(baseReq({ sql: `INSERT INTO ${table} VALUES (42)`, sessionId }), CH_URL!, fetch); - expect(insert.outcome.error).toBeNull(); - const readInside = await runCurrent(baseReq({ sql: `SELECT x FROM ${table}`, sessionId }), CH_URL!, fetch); - expect(readInside.outcome.error).toBeNull(); - expect(readInside.outcome.rows).toEqual([['42']]); - - // 4. absent outside the session — a session-less query for the SAME - // name must fail (temporary tables are never visible outside their - // owning session). - const readOutside = await runCurrent(baseReq({ sql: `SELECT x FROM ${table}` }), CH_URL!, fetch); - expect(readOutside.outcome.error).not.toBeNull(); - }); - - it('temporary table: persists only inside its explicit session, absent outside it — official adapter', async () => { - const table = `asb585_tmp_official_${Date.now()}`; - const sessionId = `asb585-live-session-official-${Date.now()}`; - const conn = createOfficialConnection(CH_URL!, fetch); - - const control = await runOfficial(conn, baseReq({ sql: `EXISTS TABLE ${table}` })); - expect(control.outcome.error).toBeNull(); - expect(control.outcome.rows).toEqual([['0']]); - - await runOfficialCommand(conn, BASIC_USER_A, sessionId, `CREATE TEMPORARY TABLE ${table} (x Int32) ENGINE = Memory`); - await runOfficialCommand(conn, BASIC_USER_A, sessionId, `INSERT INTO ${table} VALUES (43)`); - const readInside = await runOfficial(conn, baseReq({ sql: `SELECT x FROM ${table}`, sessionId })); - expect(readInside.outcome.error).toBeNull(); - expect(readInside.outcome.rows).toEqual([['43']]); - - const readOutside = await runOfficial(conn, baseReq({ sql: `SELECT x FROM ${table}` })); - expect(readOutside.outcome.error).not.toBeNull(); - - expect(conn.constructorCalls).toBe(1); - }); - - it('session SET persists inside the session and reverts to the default outside it — both adapters', async () => { - const sessionIdCurrent = `asb585-live-set-current-${Date.now()}`; - const sessionIdOfficial = `asb585-live-set-official-${Date.now()}`; - const conn = createOfficialConnection(CH_URL!, fetch); - - // Default max_result_rows is 0 (unlimited) on a fresh session/session-less - // connection — SET a distinctive non-default value (5) and prove it - // sticks inside the session, and that a session-LESS read still reports - // the ordinary default. - const setCurrent = await runCurrent(baseReq({ sql: 'SET max_result_rows = 5', sessionId: sessionIdCurrent }), CH_URL!, fetch); - expect(setCurrent.outcome.error).toBeNull(); - const insideCurrent = await runCurrent(baseReq({ sql: "SELECT value FROM system.settings WHERE name = 'max_result_rows'", sessionId: sessionIdCurrent }), CH_URL!, fetch); - expect(insideCurrent.outcome.rows).toEqual([['5']]); - const outsideCurrent = await runCurrent(baseReq({ sql: "SELECT value FROM system.settings WHERE name = 'max_result_rows'" }), CH_URL!, fetch); - expect(outsideCurrent.outcome.rows).toEqual([['0']]); - - await runOfficialCommand(conn, BASIC_USER_A, sessionIdOfficial, 'SET max_result_rows = 5'); - const insideOfficial = await runOfficial(conn, baseReq({ sql: "SELECT value FROM system.settings WHERE name = 'max_result_rows'", sessionId: sessionIdOfficial })); - expect(insideOfficial.outcome.rows).toEqual([['5']]); - const outsideOfficial = await runOfficial(conn, baseReq({ sql: "SELECT value FROM system.settings WHERE name = 'max_result_rows'" })); - expect(outsideOfficial.outcome.rows).toEqual([['0']]); - }); - - it('SESSION_IS_LOCKED: a genuinely overlapping request in one real session is classified and retried exactly once, through the REAL QueryExecutionService', async () => { - const sessionId = `asb585-live-lock-${Date.now()}`; - const conn = createOfficialConnection(CH_URL!, fetch); - - // Hold the session with a slow query — ClickHouse's own session - // machinery serializes requests sharing one session_id, rejecting a - // SECOND concurrent request with SESSION_IS_LOCKED (code 373) for as - // long as the first is still executing. - const holder = runOfficial(conn, baseReq({ sql: 'SELECT sleepEachRow(0.5) FROM numbers(4)', sessionId })); - await new Promise((r) => setTimeout(r, 300)); // let the holder's request land first - - const attempts: number[] = []; - const svc = createQueryExecutionService({ - // Never exercised — this test only calls `executeScript`. - runProgress: async () => { throw new Error('runProgress not exercised by this spike helper'); }, - runText: makeSessionAwareRunText(conn, BASIC_USER_A, sessionId), - cancel: async () => {}, - now: () => Date.now(), - uid: (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`, - retryMs: 3000, // >= the holder's own ~2s runtime, so the one retry lands after it releases the lock - sleep: (ms) => new Promise((r) => setTimeout(r, ms)), - }); - - const result = await svc.executeScript({ - statements: [{ sql: 'SELECT 1', execSql: 'SELECT 1', params: {} }], - onStatementStart: (_i, info) => attempts.push(info.attempt), - onStatementResult: () => {}, - }); - - await holder; // never leave the slow holder running past the test - - expect(attempts[0]).toBe(1); - expect(attempts.length).toBeLessThanOrEqual(2); // never a THIRD attempt - expect(result.entries).toHaveLength(1); - expect(result.entries[0].status).not.toBe('error'); // the one retry (if needed) must land after the lock clears - }, 20_000); -}); diff --git a/tests/spike/clickhouse-client/matrix.json b/tests/spike/clickhouse-client/matrix.json deleted file mode 100644 index 9b9f854e..00000000 --- a/tests/spike/clickhouse-client/matrix.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "$schema": "n/a — see this file's own comments in clickhouse-containers.mjs's header docstring for how rows are consumed", - "_purpose": "Phase 0 / issue #585, plan §13 \"Required matrix rows\". Exact, digest-pinned ClickHouse server images the live matrix (clickhouse-containers.mjs + run-matrix.mjs) boots. Every tag/digest below was resolved by hand at implementation time against the public Docker Hub registry API and cross-checked with `docker buildx imagetools inspect` (never `docker pull` of an unqualified `latest` tag — plan §12's \"never use unqualified latest in evidence\"). `proposedMinimum` is derived programmatically by support-minimum.mjs's deriveProposedMinimum(), not hand-picked; re-run `node tests/spike/clickhouse-client/support-minimum.mjs` to reproduce it.", - "resolvedAt": "2026-08-05T19:42:10Z", - "resolvedVia": "Docker Hub Registry HTTP API (hub.docker.com/v2/repositories//tags) for tag enumeration; `docker buildx imagetools inspect :` for the exact manifest-list digest (no image layers pulled to resolve — the digest is the registry's own content-addressed reference, platform-independent).", - "proposedMinimum": { - "value": "24.8", - "source": "pinned official-client guaranteed minimum", - "derivedBy": "tests/spike/clickhouse-client/support-minimum.mjs deriveProposedMinimum()", - "note": "The 'proposed oldest' rows below use the LATEST PATCH in the 24.8.x line, per plan §13: \"The initial oldest candidate may be the latest patch in the client's documented minimum line only after feature analysis shows no application dependency raises the floor\" — support-minimum.mjs's inventory confirms no inventoried server-sensitive dependency does." - }, - "rows": { - "proposed-oldest-oss": { - "role": "proposed oldest OSS ClickHouse", - "repository": "clickhouse/clickhouse-server", - "tag": "24.8.14.39", - "digest": "sha256:1ffa82edee000a42c09313bd9f1293d94c570aee74babc1b3ca9983a35fa597b", - "pullRef": "clickhouse/clickhouse-server@sha256:1ffa82edee000a42c09313bd9f1293d94c570aee74babc1b3ca9983a35fa597b", - "digestKind": "OCI image index (multi-platform manifest list)", - "platformsObserved": ["linux/amd64"], - "kind": "oss", - "derivation": "Latest patch release in the 24.8.x line (the pinned @clickhouse/client-web@1.23.1's own documented guaranteed floor, \"24.8+\") as of resolution time — confirmed via Docker Hub tag listing for clickhouse/clickhouse-server filtered to name=24.8 (58 tags, highest full version 24.8.14.39)." - }, - "proposed-oldest-altinity-stable": { - "role": "proposed oldest matching Altinity Stable build", - "repository": "altinity/clickhouse-server", - "tag": "24.8.14.10547.altinitystable", - "digest": "sha256:d0c456453ddc5220bc96e37c9b1f81eb210ca22fc0d6877dc9e71722ff43fa8f", - "pullRef": "altinity/clickhouse-server@sha256:d0c456453ddc5220bc96e37c9b1f81eb210ca22fc0d6877dc9e71722ff43fa8f", - "digestKind": "Docker manifest list", - "platformsObserved": ["linux/amd64"], - "kind": "altinity-stable", - "derivation": "Highest-patch Altinity Stable build tracking the SAME 24.8.x ClickHouse release line as proposed-oldest-oss (24.8.14) — confirmed via Docker Hub tag listing for altinity/clickhouse-server filtered to name=24.8 (highest 24.8.14 build: 24.8.14.10547.altinitystable)." - }, - "current-stable-oss": { - "role": "current stable OSS ClickHouse", - "repository": "clickhouse/clickhouse-server", - "tag": "26.6.2.160", - "digest": "sha256:a63a90ffdcb574683ebfe96e4c53e2dbe401864add7bb06dc244eb935d828e7f", - "pullRef": "clickhouse/clickhouse-server@sha256:a63a90ffdcb574683ebfe96e4c53e2dbe401864add7bb06dc244eb935d828e7f", - "digestKind": "OCI image index (multi-platform manifest list)", - "platformsObserved": ["linux/amd64"], - "kind": "oss", - "derivation": "Highest OSS release tagged on Docker Hub at resolution time, cross-checked against the ClickHouse/ClickHouse GitHub Releases API's most recent non-prerelease tag (v26.6.2.160-stable, published on the resolution date) — the two sources agree exactly on 26.6.2.160." - }, - "current-altinity-stable": { - "role": "current Altinity Stable build", - "repository": "altinity/clickhouse-server", - "tag": "26.3.16.10001.altinitystable", - "digest": "sha256:8526a7742e6ef707dee68107876b18d45570b0448f284d3c785eb2d2e4417a5e", - "pullRef": "altinity/clickhouse-server@sha256:8526a7742e6ef707dee68107876b18d45570b0448f284d3c785eb2d2e4417a5e", - "digestKind": "Docker manifest list", - "platformsObserved": ["linux/amd64"], - "kind": "altinity-stable", - "derivation": "Highest-major-line Altinity Stable build published on Docker Hub at resolution time (full paginated enumeration: 201 total altinity/clickhouse-server tags; highest \"*.altinitystable\" line is 26.3, latest patch 26.3.16.10001). Altinity Stable lags the OSS release train by design (it tracks LTS lines) — no 26.6-line Altinity Stable build existed at resolution time; this is the current row, not a substitute for it." - }, - "cloud": { - "role": "ClickHouse Cloud", - "status": "not evaluated — no ClickHouse Cloud credentials in this environment", - "conditional": true, - "note": "Per plan §5 \"Cloud credentials\": the runner may read only explicitly documented test environment variables and must never print or commit their values. This environment defines none of them (checked: no ASB_SPIKE_CLOUD_*/CLICKHOUSE_CLOUD_*/CH_CLOUD_* variables present). This conditional omission does not by itself fail the ADR." - } - } -} diff --git a/tests/spike/clickhouse-client/normalize.ts b/tests/spike/clickhouse-client/normalize.ts deleted file mode 100644 index e8714168..00000000 --- a/tests/spike/clickhouse-client/normalize.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Phase 0 / issue #585 — pure comparison/normalization helpers for the parity -// harness. No fetch, no DOM, no adapter-specific imports: this module only -// knows about the test-owned `SpikeOutcome`/`ExpectedOutcome` shapes -// (types.ts), matching the "pure logic has no side effects" discipline -// CLAUDE.md asks of `src/core/` — this is the spike's equivalent for -// `tests/spike/clickhouse-client/`. - -import type { ExpectedOutcome, ParityResult, SpikeOutcome } from './types.js'; - -/** Incremental-BY-INTERFACE SHA-256 over raw byte chunks, built on the - * standard Web Crypto `crypto.subtle` API (available globally in Node >=19 - * and every target browser) rather than `node:crypto` — deliberately, so - * this TypeScript file needs no `@types/node` (plan §8: "Node orchestration - * and configuration files remain `.mjs` or `.js`, avoiding an unrelated - * global Node-type decision" — the same reasoning extends to every spike - * `.ts` file, not just the `.mjs` orchestrators). `SubtleCrypto` has no - * streaming `update()`, so chunks are retained and concatenated once at - * `digestHex()` time; for the spike's fixture/export-sized payloads this is - * negligible and the *comparison* (current vs. official, both computed the - * same way) is unaffected either way. Used identically for the current - * export path and the official `exec()` raw path so a match proves - * byte-for-byte equality regardless of chunk boundaries (plan §24). */ -export class IncrementalSha256 { - private chunks: Uint8Array[] = []; - private bytes = 0; - update(chunk: Uint8Array): void { - this.chunks.push(chunk); - this.bytes += chunk.byteLength; - } - async digestHex(): Promise { - const whole = new Uint8Array(this.bytes); - let offset = 0; - for (const c of this.chunks) { whole.set(c, offset); offset += c.byteLength; } - const digest = await crypto.subtle.digest('SHA-256', whole); - return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); - } - get totalBytes(): number { - return this.bytes; - } -} - -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (a == null || b == null) return a === b; - if (typeof a !== typeof b) return false; - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; - return a.every((v, i) => deepEqual(v, b[i])); - } - if (typeof a === 'object') { - const ak = Object.keys(a as object).sort(); - const bk = Object.keys(b as object).sort(); - if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return false; - return ak.every((k) => deepEqual((a as Record)[k], (b as Record)[k])); - } - return false; -} - -/** Fields an `ExpectedOutcome` may declare — everything on `SpikeOutcome` - * except the raw `events` timeline (which is evidence, not an assertion - * target) and the `because` justification string itself. */ -const COMPARABLE_KEYS: (keyof Omit)[] = [ - 'columns', 'rows', 'partialRowCount', 'progress', 'error', 'cancelled', - 'chCode', 'chMessage', 'httpStatus', 'queryId', 'responseHeaders', 'summary', - 'rawByteCount', 'rawSha256', 'authEffects', 'firstRowAtMs', 'completedAtMs', -]; - -/** Compare one adapter's outcome against an independently-declared expected - * partial. Only keys present on `expected` are checked (plan §15: an - * `ExpectedOutcome` is deliberately partial). Returns the mismatch messages - * (empty = match). */ -export function diffAgainstExpected(actual: SpikeOutcome, expected: ExpectedOutcome, label: string): string[] { - const mismatches: string[] = []; - for (const key of COMPARABLE_KEYS) { - if (!(key in expected)) continue; - const expectedValue = expected[key]; - const actualValue = actual[key]; - if (!deepEqual(actualValue, expectedValue)) { - mismatches.push(`${label}.${key}: expected ${JSON.stringify(expectedValue)}, got ${JSON.stringify(actualValue)}`); - } - } - return mismatches; -} - -/** Compare the current-adapter outcome directly against the official-adapter - * outcome (pairwise) — plan §15: "Pairwise equality alone is insufficient", - * so this is ALWAYS run alongside (never instead of) `diffAgainstExpected` - * for both sides. */ -export function diffOutcomes(current: SpikeOutcome, official: SpikeOutcome): string[] { - const mismatches: string[] = []; - for (const key of COMPARABLE_KEYS) { - if (!deepEqual(current[key], official[key])) { - mismatches.push(`current.${key} vs official.${key}: ${JSON.stringify(current[key])} !== ${JSON.stringify(official[key])}`); - } - } - return mismatches; -} - -/** Assemble one scenario's full `ParityResult` from both adapters' raw - * outcomes plus the scenario's independent expectation. */ -export function buildParityResult( - scenarioId: string, - currentOutcome: SpikeOutcome, - officialOutcome: SpikeOutcome, - expected: ExpectedOutcome, -): ParityResult { - const currentMismatches = diffAgainstExpected(currentOutcome, expected, 'current'); - const officialMismatches = diffAgainstExpected(officialOutcome, expected, 'official'); - const pairwiseMismatches = diffOutcomes(currentOutcome, officialOutcome); - return { - scenarioId, - currentOutcome, - officialOutcome, - currentMatchesExpected: currentMismatches.length === 0, - officialMatchesExpected: officialMismatches.length === 0, - currentMatchesOfficial: pairwiseMismatches.length === 0, - mismatches: [...currentMismatches, ...officialMismatches, ...pairwiseMismatches], - }; -} - -/** A fresh, empty `SpikeOutcome` — the shape every adapter builds up via - * `applyStreamLine`-equivalent folding (current) or the progress bridge - * (official). Mirrors `core/stream.ts`'s `newResult()` shape but in the - * spike's own normalized vocabulary. */ -export function emptyOutcome(): SpikeOutcome { - return { - columns: [], - rows: [], - partialRowCount: 0, - progress: null, - error: null, - cancelled: false, - chCode: null, - chMessage: null, - httpStatus: null, - queryId: null, - responseHeaders: {}, - summary: null, - rawByteCount: null, - rawSha256: null, - authEffects: [], - events: [], - firstRowAtMs: null, - completedAtMs: null, - }; -} diff --git a/tests/spike/clickhouse-client/official-adapter.ts b/tests/spike/clickhouse-client/official-adapter.ts deleted file mode 100644 index 213f2bf8..00000000 --- a/tests/spike/clickhouse-client/official-adapter.ts +++ /dev/null @@ -1,493 +0,0 @@ -// Phase 0 / issue #585 — the "official-side spike adapter" (plan §7): the -// ONLY module in this repository that imports `@clickhouse/client-web`. -// Constructs one client per connection configuration (never per request or -// refresh — plan's "One official client per connection config" invariant), -// injects fetch, supplies complete per-request authentication via the -// vendor client's own per-call `auth` field (never a client-level default), -// and exposes only the test-owned normalized `SpikeOutcome` — the official -// result/error types (`ClickHouseError`, `ExecResult`, …) never escape this -// file. -// -// EMPIRICAL FINDING (recorded in docs/evidence/585/critical-questions.md): -// per-request `http_headers.Authorization` does NOT override the credential -// in installed 1.23.1. `WebConnection#defaultHeadersWithOverride` spreads -// `http_headers` first, then unconditionally sets `Authorization: -// authHeader` LAST — where `authHeader` is derived from `params.auth` (or -// the client-level default when `params.auth` is absent), clobbering -// whatever `Authorization` a caller supplied via `http_headers`. The correct -// per-request override is the `auth` field -// (`{username,password}`/`{access_token}`), which THIS file uses -// (`officialAuthFor`, below) — `http_headers` remains available for -// genuinely EXTRA headers, never for Authorization. -// -// Format decision (plan §16, proven in `format-type-probe.ts`): -// * KPI -> `query({ format: 'JSONEachRowWithProgress' })` — the -// one progress format installed 1.23.1 publicly -// supports; consumed via `.stream()`. -// * Table -> `exec()` with the full literal SQL + explicit -// `FORMAT JSONStringsEachRowWithProgress`, decoded by -// the narrow `progress-bridge.ts` (exec() exposes raw, -// undecoded response bytes, so this needs no text -// decoding beyond the bridge's own incremental UTF-8 -// JSON line parse). -// * raw/explicit -> `exec()` with the full literal SQL + its FORMAT -// clause, byte-hashed straight off `.stream`, exactly -// mirroring `exportQuery`'s raw path. - -import { createClient, ClickHouseError, type ClickHouseClient } from '@clickhouse/client-web'; -import { isProgressRow, isRow } from '@clickhouse/client-web'; -import type { AdapterRunResult, SpikeCredential, SpikeRequest, SpikeOutcome } from './types.js'; -import { emptyOutcome, IncrementalSha256 } from './normalize.js'; -import { bridgeNdjsonProgress } from './progress-bridge.js'; -import { withTrailingFormat } from '../../../src/core/format.js'; - -export interface OfficialConnection { - client: ClickHouseClient; - constructorCalls: number; - fetchCalls: number; -} - -/** Construct ONE official client for `baseUrl`, with `realFetch` injected and - * fetch-call counting wired in. The client-level `auth` is left at a - * non-secret, deliberately-invalid default (plan §21 "Per-request auth": - * "Construct one official client with a non-secret invalid default - * credential") — every real request supplies its own per-call `auth` - * override (see `officialAuthFor`), so the default is never authoritative. - * `requestTimeoutMs` - * (optional) sets the vendor client's own `request_timeout` (default 30s) — - * used by the "timeout" scenario to prove the official client's connection- - * level timeout produces a distinct `Error("Timeout error.")`, never an - * `AbortError`, unlike a caller-driven `abort_signal`. - * - * `constructorCalls` is a REAL count, not a literal: `.client` is exposed as - * a getter/setter pair backed by `constructorCallCount`, and the ONLY writer - * to that setter is the single `setClient()` call below, at construction. - * Nothing else in this file (or in `runOfficial`/`runOfficialRefreshThenRetry` - * /`makeOfficialRunQueryShim`) ever assigns `conn.client` again — they only - * read it — so this mechanically enforces the plan's "one official client per - * connection config, no reconstruction after refresh" invariant: if a FUTURE - * change (e.g. a refresh-retry path) were to reassign `conn.client` to a - * freshly `createClient()`-ed instance instead of reusing this one, the - * setter would increment the count past 1 and every `constructorCalls === 1` - * assertion (parity.test.ts / live-*.test.ts) would correctly fail. */ -export function createOfficialConnection(baseUrl: string, realFetch: typeof fetch, requestTimeoutMs?: number): OfficialConnection { - let fetchCalls = 0; - const countingFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - fetchCalls += 1; - return realFetch(input, init); - }) as typeof fetch; - let constructorCallCount = 0; - let currentClient!: ClickHouseClient; - function setClient(c: ClickHouseClient): void { - constructorCallCount += 1; - currentClient = c; - } - setClient(createClient({ - url: baseUrl, - username: 'asb-spike-default-invalid', - password: 'asb-spike-default-invalid', - fetch: countingFetch, - ...(requestTimeoutMs !== undefined ? { request_timeout: requestTimeoutMs } : {}), - })); - return { - get client() { return currentClient; }, - set client(c: ClickHouseClient) { setClient(c); }, - get constructorCalls() { return constructorCallCount; }, - get fetchCalls() { return fetchCalls; }, - }; -} - -/** The vendor client's own per-call credential-override shape (`BaseQueryParams.auth`) - * for one `SpikeCredential` — see this file's header comment for why this, - * and not `http_headers`, is the correct per-request override in installed - * 1.23.1. `'jwt-as-basic'` maps to `{username,password}` with the JWT AS the - * password (Basic scheme) — matching the app's own JWT-as-Basic-password - * pattern (`ch-client.ts`'s `authHeader` seam), never `access_token` (which - * is Bearer-scheme only). `'invalid'` maps to a non-secret, deliberately - * wrong credential distinct from the connection's own default, so a test can - * tell "the override was honored" apart from "the default leaked through". */ -export function officialAuthFor(credential: SpikeCredential): { username: string; password: string } | { access_token: string } { - switch (credential.kind) { - case 'basic': - return { username: credential.username, password: credential.password }; - case 'bearer': - return { access_token: credential.token }; - case 'jwt-as-basic': - return { username: credential.username, password: credential.jwt }; - case 'invalid': - default: - return { username: 'asb-spike-per-request-invalid', password: 'asb-spike-per-request-invalid' }; - } -} - -function classifyError(e: unknown, outcome: SpikeOutcome, signal?: AbortSignal): void { - if (e instanceof Error && e.name === 'AbortError') { outcome.cancelled = true; return; } - if (signal?.aborted) { outcome.cancelled = true; return; } - if (e instanceof ClickHouseError) { - outcome.chCode = Number(e.code) || null; - outcome.chMessage = e.message; - outcome.error = e.message; - return; - } - outcome.error = e instanceof Error ? e.message : String(e); -} - -/** Run one `SpikeRequest` through the official client, folding the result - * into the normalized `SpikeOutcome` vocabulary. `conn` is shared across many - * calls (the "one client per connection config" invariant) — this function - * itself performs zero client construction. */ -export async function runOfficial(conn: OfficialConnection, request: SpikeRequest): Promise { - const outcome: SpikeOutcome = emptyOutcome(); - // Pre-flight abort guard (plan §22 "pre-aborted before request: no fetch - // side effect") — EMPIRICALLY VERIFIED (see docs/evidence/585/ - // critical-questions.md): installed 1.23.1's exec()/query()/command() do - // NOT check `abort_signal.aborted` up front; `web_connection.js`'s - // `request()` only reacts to a FUTURE 'abort' EVENT via - // `params.abort_signal.onabort = ...`, so an ALREADY-aborted signal is - // silently ignored and the request reaches the network unchanged (proven: - // a pre-aborted `exec()` against a real server resolves normally and the - // server records the hit). This adapter-side guard is the narrow fix a - // real Phase 1 adapter would also need — the same shape as the epoch - // fence's own adapter-side checkpoint #1 in `guarded-fetch.ts`. - if (request.signal?.aborted) { - outcome.cancelled = true; - return { outcome, constructorCalls: conn.constructorCalls, fetchCalls: 0 }; - } - const t0 = Date.now(); - const auth = officialAuthFor(request.credential); - const fetchCallsBefore = conn.fetchCalls; - - try { - if (request.consume === 'raw' || request.format !== 'Table' && request.format !== 'KPI') { - // Raw/explicit-format path: exec() with the fully-authored SQL - // (including its own FORMAT clause), byte-hashed straight off .stream — - // never .text()/TextDecoder/JSON-parsed (plan §24's raw-decoding ban). - // Existing-FORMAT-clause detection reuses the SAME comment/string-aware - // scanner production's own export path uses (`prepareExportSql` calls - // this identical function — src/application/export-service.ts), rather - // than reimplementing a terminal regex here (plan §7 "do not - // reimplement current behavior"): a prior ad hoc - // `/\bFORMAT\s+\S+\s*;?\s*$/i` terminal regex missed an existing - // trailing FORMAT clause followed by a line/block comment (the regex - // requires the clause to be the literal string end), silently - // double-appending a second FORMAT clause on any such SQL — found by - // review during issue #585 Phase 0 (see docs/evidence/585 review - // notes). `withTrailingFormat` also correctly does NOT treat FORMAT- - // shaped text inside a string literal or comment as an existing - // clause, so the real format is still appended in that case. - const fullSql = withTrailingFormat(request.sql, request.format).sql; - const res = await conn.client.exec({ - query: fullSql, - query_id: request.queryId, - session_id: request.sessionId, - role: request.role, - abort_signal: request.signal, - auth, - clickhouse_settings: request.settings, - // NOTE (plan §18 "forced multipart"/"automatic multipart"): installed - // 1.23.1's `exec()`/`command()` never honor `use_multipart_params(_auto)` - // at all — only `query()` does (verified against - // `dist/connection/web_connection.js`'s `runExec` vs. `query`). These - // two fields are still threaded through here for parameter-shape - // parity with `query()` below, but they are a documented no-op on - // this branch — the multipart scenarios in `parity.test.ts` exercise - // `query()` directly for exactly this reason. - use_multipart_params: request.multipart, - use_multipart_params_auto: request.multipartAuto, - query_params: request.params, - }); - outcome.queryId = res.query_id; - outcome.responseHeaders = flattenHeaders(res.response_headers); - outcome.httpStatus = res.http_status_code ?? null; - outcome.summary = res.summary ?? null; - const hash = new IncrementalSha256(); - const reader = res.stream.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - hash.update(value); - } - outcome.rawByteCount = hash.totalBytes; - outcome.rawSha256 = await hash.digestHex(); - outcome.completedAtMs = Date.now() - t0; - } else if (request.format === 'KPI') { - // Publicly-supported progress path. - const rs = await conn.client.query({ - query: request.sql, - format: 'JSONEachRowWithProgress', - query_id: request.queryId, - session_id: request.sessionId, - role: request.role, - abort_signal: request.signal, - auth, - clickhouse_settings: request.settings, - // Only `query()` (this branch) honors multipart promotion in - // installed 1.23.1 — see the `exec()` branch's note above. - use_multipart_params: request.multipart, - use_multipart_params_auto: request.multipartAuto, - query_params: request.params, - }); - outcome.queryId = rs.query_id; - outcome.responseHeaders = flattenHeaders(rs.response_headers); - let firstRow = false; - const stream = rs.stream>(); - const reader = stream.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - for (const wrapped of value) { - const row = wrapped.json(); - if (isProgressRow(row)) { - outcome.progress = { - rows: Number(row.progress.read_rows) || 0, - bytes: Number(row.progress.read_bytes) || 0, - totalRows: Number(row.progress.total_rows_to_read) || undefined, - }; - } else if (isRow(row)) { - if (!firstRow) { firstRow = true; outcome.firstRowAtMs = Date.now() - t0; } - outcome.rows.push(Object.values(row.row as Record)); - outcome.partialRowCount += 1; - } else if (row && typeof row === 'object' && 'exception' in (row as object)) { - outcome.error = String((row as { exception: unknown }).exception); - outcome.chMessage = outcome.error; - } - } - } - outcome.completedAtMs = Date.now() - t0; - } else { - // Table -> narrow exec()-based bridge (plan §16's chosen path). - const fullSql = `${request.sql}\nFORMAT JSONStringsEachRowWithProgress`; - const res = await conn.client.exec({ - query: fullSql, - query_id: request.queryId, - session_id: request.sessionId, - role: request.role, - abort_signal: request.signal, - auth, - clickhouse_settings: request.settings, - // Documented no-op on exec() in installed 1.23.1 — see the raw - // branch's note above. - use_multipart_params: request.multipart, - use_multipart_params_auto: request.multipartAuto, - query_params: request.params, - }); - outcome.queryId = res.query_id; - outcome.responseHeaders = flattenHeaders(res.response_headers); - outcome.httpStatus = res.http_status_code ?? null; - outcome.summary = res.summary ?? null; - let firstRow = false; - await bridgeNdjsonProgress(res.stream, (line) => { - if (line.meta) { - outcome.columns = line.meta.map((m) => ({ name: m.name, type: m.type })); - } else if (line.row) { - if (!firstRow) { firstRow = true; outcome.firstRowAtMs = Date.now() - t0; } - outcome.rows.push(outcome.columns.map((c) => (line.row as Record)[c.name])); - outcome.partialRowCount += 1; - } else if (line.progress) { - outcome.progress = { - rows: Number(line.progress.read_rows) || 0, - bytes: Number(line.progress.read_bytes) || 0, - totalRows: Number(line.progress.total_rows_to_read) || undefined, - }; - } else if (line.exception) { - outcome.error = line.exception; - outcome.chMessage = line.exception; - } - }); - outcome.completedAtMs = Date.now() - t0; - } - } catch (e) { - classifyError(e, outcome, request.signal); - } - return { - outcome, - constructorCalls: conn.constructorCalls, - fetchCalls: conn.fetchCalls - fetchCallsBefore, - }; -} - -// ── Spike-only official-side refresh driver ───────────────────────────────── -// Plan §21 "refresh then retry" / "stale during refresh": the vendor client -// has NO refresh policy of its own (per-request `auth` is the whole -// auth surface — see `runOfficial` above), so a comparable "one refresh, one -// replay" policy has to be driven by the CALLER, exactly like production's -// own auth request path drives it around the injected `fetch` seam — at the -// time this spike was written, `ch-client.ts`'s `authedFetch`; since #630 -// Phase 6, `authenticated-clickhouse-request.ts`'s `authenticatedRequest`, -// unchanged in shape. This is SPIKE-ONLY experiment code (plan §21's "If -// [it] becomes a second general request implementation, fail auth/epoch -// parity" — this narrow, single-retry driver is not that: it is the Phase 1 -// candidate shape for this one policy, not a second transport). Never -// adopted as-is; a future Phase 1 adapter would fold an equivalent policy -// into its own request path. - -export interface RefreshDrivenResult { - outcome: SpikeOutcome; - /** How many times the official client's method was actually invoked. */ - attempts: number; - /** How many times `refresh()` was called. */ - refreshCalls: number; -} - -/** Run `request` once; on a classified authentication failure (the fault - * server's `AUTHENTICATION_FAILED`/code 516, matching `401-then-success`'s - * fixture body), call `refresh()` exactly once and — if it yields a - * replacement credential — replay the SAME request with that credential, - * exactly once, mirroring production's own `attempt === 0` bound (at the - * time this spike was written, `authedFetch`'s; since #630 Phase 6, - * `authenticated-clickhouse-request.ts`'s `authenticatedRequest`, - * unchanged in shape). If `isCurrentEpoch()` returns false immediately - * after `refresh()` resolves (the "stale during refresh" race), the - * replacement credential is NEVER read or replayed — this proves the same - * "no replacement credential or lifecycle mutation" invariant production's - * own `staleEpochAbort` guards, on the official adapter's own retry path. */ -export async function runOfficialRefreshThenRetry( - conn: OfficialConnection, - request: SpikeRequest, - refresh: () => Promise, - isCurrentEpoch: () => boolean, -): Promise { - let attempts = 0; - let refreshCalls = 0; - let credential = request.credential; - for (;;) { - attempts += 1; - const attemptResult = await runOfficial(conn, { ...request, credential }); - const authFailure = attemptResult.outcome.chCode === 516; - if (!authFailure || attempts > 1) { - return { outcome: attemptResult.outcome, attempts, refreshCalls }; - } - refreshCalls += 1; - const next = await refresh(); - if (!isCurrentEpoch()) { - const stale = emptyOutcome(); - stale.cancelled = true; - return { outcome: stale, attempts, refreshCalls }; - } - if (!next) return { outcome: attemptResult.outcome, attempts, refreshCalls }; - credential = next; - } -} - -// ── QueryExecutionService adapter (post-#630 Phase 7) ─────────────────────── -// Plan §19/§2.4 (Checkpoint 2C's spike portion) — replaces the retired -// `makeOfficialRunQueryShim`, which satisfied `typeof runQuery` from -// `src/net/ch-client.ts` (`RunQueryOptions`/`RunQueryResult` — both retiring, -// #630 Phase 7). This adapter instead satisfies `QueryExecutionService`'s OWN -// narrow `QueryExecutionDeps['runProgress' | 'runText']` shape -// (`src/application/query-execution-service.ts`) directly — never a -// reimplementation of that service's retry/classification policy, which -// still runs, real and unmodified, against whichever client (current or -// official) is injected (plan §23 "official outcomes feed existing execution -// policy"). -// -// `runProgress` mirrors the retired shim's 'Table'/'KPI' branches -// (exec()+bridgeNdjsonProgress / query()+stream reading respectively), -// dispatching on `request.defaultFormat` (QES's own wire-format names) -// instead of a SQL-Browser format string. An in-band `{"exception"}` line is -// delivered through `callbacks.onLine`, exactly like the real authenticated -// progress path (`core/stream.ts`'s `applyStreamLine` turns it into -// `result.error`) — never a thrown/returned `{error}` shape: only a -// pre-header rejection (a `ClickHouseError` thrown by `exec()`/`query()` -// itself) or a mid-stream network failure throws, matching the new "package -// consumers throw" contract `QueryExecutionDeps.runProgress`'s own doc -// requires (#630 Phase 7 §6.5). -// -// `runText` mirrors the retired shim's ELSE/raw branch exactly: EVERY -// `executeScript` statement this spike suite ever drives through it (row- -// returning or effect alike) used that branch — `serviceFor()`'s -// `QueryExecutionRequest.defaultFormat` is always 'JSONCompact' or -// 'TabSeparatedWithNamesAndTypes', neither of which is 'Table'/'KPI' — so -// `runText` keeps using `command()` verbatim on `request.sql` UNCHANGED (no -// FORMAT clause appended: installed 1.23.1 hard SYNTAX_ERRORs on `SET .../ -// INSERT ... VALUES (...)` with one appended — see `live-sessions.test.ts`'s -// own `runOfficialCommand` docstring for the same finding), per plan §7 "use -// command() only when discarding output is intentional", always resolving -// `''`. No spike test routed through `runText` has ever needed the real row/ -// effect body text back (only attempt-count/status/message classification) — -// this is a mechanical reshape of the retired shim's existing behavior, not a -// redesign of the vendor side (plan §19 "do not redesign the vendor side -// beyond compilation and existing test intent"). -import type { QueryExecutionRequest, QueryProgressCallbacks } from '../../../src/application/query-execution-service.js'; - -export interface OfficialQueryExecutionAdapter { - runProgress(request: QueryExecutionRequest, callbacks: QueryProgressCallbacks): Promise; - runText(request: QueryExecutionRequest): Promise; -} - -/** Build a `QueryExecutionDeps`-shaped `{runProgress, runText}` pair bound to - * one official-client connection and credential — the direct replacement for - * the retired `makeOfficialRunQueryShim`. `credentialFor` takes no `ChCtx` - * argument (that type is retiring too): every existing call site already - * ignored it (`() => BASIC_USER_A`), so dropping it is a mechanical signature - * narrowing, not a behavior change. */ -export function makeOfficialQueryExecutionAdapter( - conn: OfficialConnection, - credentialFor: () => SpikeCredential, -): OfficialQueryExecutionAdapter { - async function runProgress(request: QueryExecutionRequest, callbacks: QueryProgressCallbacks): Promise { - const { query_id: queryId, ...nativeParams } = request.params || {}; - const auth = officialAuthFor(credentialFor()); - const common = { - query_id: queryId != null ? String(queryId) : undefined, - abort_signal: request.signal, - auth, - clickhouse_settings: request.settings, - query_params: nativeParams, - }; - - if (request.defaultFormat === 'JSONEachRowWithProgress') { - const rs = await conn.client.query({ query: request.sql, format: 'JSONEachRowWithProgress', ...common }); - const stream = rs.stream>(); - const reader = stream.getReader(); - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - for (const wrapped of value) { - const row = wrapped.json() as unknown; - if (row && typeof row === 'object' && 'exception' in (row as object)) { - callbacks.onLine?.({ exception: String((row as { exception: unknown }).exception) }); - } else if (isRow>(row)) { - callbacks.onLine?.({ row: row.row }); - } else if (isProgressRow(row)) { - callbacks.onLine?.({ progress: { read_rows: row.progress.read_rows, read_bytes: row.progress.read_bytes, total_rows_to_read: row.progress.total_rows_to_read, elapsed_ns: row.progress.elapsed_ns } }); - } - callbacks.onChunk?.(); - } - } - return; - } - - // Table streaming (QES's `defaultFormat: 'JSONStringsEachRowWithProgress'`). - const fullSql = `${request.sql}\nFORMAT ${request.defaultFormat}`; - const res = await conn.client.exec({ query: fullSql, ...common }); - await bridgeNdjsonProgress(res.stream, (line) => { - callbacks.onLine?.(line); - callbacks.onChunk?.(); - }); - } - - async function runText(request: QueryExecutionRequest): Promise { - const { query_id: queryId, ...nativeParams } = request.params || {}; - await conn.client.command({ - query: request.sql, - query_id: queryId != null ? String(queryId) : undefined, - abort_signal: request.signal, - auth: officialAuthFor(credentialFor()), - clickhouse_settings: request.settings, - query_params: nativeParams, - }); - return ''; - } - - return { runProgress, runText }; -} - -function flattenHeaders(h: Record | undefined): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(h || {})) { - if (v === undefined) continue; - out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : v; - } - return out; -} diff --git a/tests/spike/clickhouse-client/parity.test.ts b/tests/spike/clickhouse-client/parity.test.ts deleted file mode 100644 index 37e0851f..00000000 --- a/tests/spike/clickhouse-client/parity.test.ts +++ /dev/null @@ -1,1125 +0,0 @@ -// Phase 0 / issue #585 — deterministic parity/precision/auth/epoch/retry -// suite, backed entirely by `fault-server.mjs` (no live ClickHouse needed; -// the live-server precision/session/cancellation matrix is -// `run-matrix.mjs`'s job — see docs/evidence/585/). Every scenario below -// proves at least one row of the plan §11 invariant map — the mapping is -// recorded in `scenarios.ts` and in the ADR/evidence, not repeated per test. -// -// This file is the ONLY consumer of `@clickhouse/client-web` besides -// `official-adapter.ts` itself (indirectly) — confirming the package never -// reaches the normal unit suite (`tests/unit/**`) or production. - -import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { startFaultServer, closedLoopbackUrl } from './fault-server.mjs'; -import { runCurrent } from './current-adapter.js'; -import { createOfficialConnection, runOfficial, makeOfficialQueryExecutionAdapter, runOfficialRefreshThenRetry, officialAuthFor } from './official-adapter.js'; -import { createEpochFence } from './guarded-fetch.js'; -import { BASIC_USER_A, BASIC_USER_B, DENIED_USER, BEARER_FIXTURE, JWT_AS_BASIC_FIXTURE } from './auth-fixtures.js'; -import { createQueryExecutionService } from '../../../src/application/query-execution-service.js'; -import { killQueryWithLease } from '../../../src/net/ch-client.js'; -import type { AuthenticatedCancellationLease } from '../../../src/net/ch-client.js'; -import type { ScriptEntry } from '../../../src/core/script-result.js'; -import type { SpikeCredential, SpikeRequest } from './types.js'; - -/** Narrow a `ScriptEntry` to its `status: 'error'` variant's message, or throw - * — a small local helper so the ambiguous-write tests below don't need an - * unsound cast to read `.error` off a union type. */ -function errorEntryMessage(entry: ScriptEntry): string { - if (entry.status !== 'error') throw new Error(`expected an error entry, got status=${entry.status}`); - return entry.error ?? ''; -} - -let fault: Awaited>; -let seq = 0; -function qid(fixture: string): string { - seq += 1; - return `${fixture}__${seq}`; -} - -beforeAll(async () => { - fault = await startFaultServer(); -}); -afterAll(async () => { - await fault.close(); -}); -afterEach(() => { - fault.resetAttemptCounts(); -}); - -function baseReq(fixture: string, overrides: Partial = {}): SpikeRequest { - return { - sql: 'SELECT 1', - format: 'Table', - credential: BASIC_USER_A, - origin: 'same-origin', - consume: 'rows', - queryId: qid(fixture), - ...overrides, - }; -} - -/** Wraps `realFetch` to capture the exact `Authorization` header value of the - * MOST RECENT call — for the "Bearer auth"/"JWT as Basic password" exact- - * header scenarios, which need to see the wire value itself (the fault - * server deliberately does NOT log full Authorization values — see - * `fault-server.mjs`'s own credential-hygiene docstring). Every fixture - * credential used with this helper is a committed, non-secret fixture value - * (`auth-fixtures.ts`), so capturing it in-process (never logged, never - * printed) stays within that same hygiene rule. */ -function capturingFetch(realFetch: typeof fetch): { fetch: typeof fetch; lastAuth: () => string | null } { - let last: string | null = null; - const wrapped = (async (input: RequestInfo | URL, init?: RequestInit) => { - const headers = init?.headers; - if (headers instanceof Headers) { - last = headers.get('authorization'); - } else if (Array.isArray(headers)) { - const found = headers.find(([k]) => k.toLowerCase() === 'authorization'); - last = found ? found[1] : null; - } else if (headers && typeof headers === 'object') { - const found = Object.entries(headers as Record).find(([k]) => k.toLowerCase() === 'authorization'); - last = found ? found[1] : null; - } - return realFetch(input, init); - }) as typeof fetch; - return { fetch: wrapped, lastAuth: () => last }; -} - -/** Same shape as the `service()` helper inside the "retry safety" describe - * block below, but with an `uid` that IGNORES its `prefix` argument and - * always mints a fresh id under `fixturePrefix` — `executeScript` always - * calls `deps.uid('q')` internally (a fixed literal, ignoring the actual - * fixture the test wants), so routing a full `executeScript` run to a - * SPECIFIC fault-server fixture requires this override. - * - * #630 Phase 7 (plan §19, Checkpoint 2C's spike portion) — `official.runText` - * below is the retired `makeOfficialRunQueryShim` + this file's own - * `runTextViaShim` compile-compat bridge, replaced by the real - * `QueryExecutionDeps` shape `makeOfficialQueryExecutionAdapter` now supplies - * directly: no intermediate `(ctx, sql, RunQueryOptions)` shim, no - * `{error}`-to-throw translation layer. */ -function serviceFor(conn: ReturnType, fixturePrefix: string) { - let n = 0; - const official = makeOfficialQueryExecutionAdapter(conn, () => BASIC_USER_A); - return createQueryExecutionService({ - // Never exercised by the `serviceFor()`-routed tests below — they only - // ever call `executeScript` (whole-body text mode). - runProgress: async () => { throw new Error('runProgress not exercised by this spike helper'); }, - runText: official.runText, - cancel: async () => {}, - now: () => Date.now(), - uid: () => { n += 1; return `${fixturePrefix}__${n}`; }, - retryMs: 1, - sleep: () => Promise.resolve(), - }); -} - -describe('deterministic parity — rows path', () => { - it('ordinary query: identical normalized columns/rows on both adapters', async () => { - const req = baseReq('ordinary-query'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.error).toBeNull(); - expect(official.outcome.error).toBeNull(); - expect(current.outcome.columns).toEqual([{ name: 'n', type: 'String' }]); - expect(official.outcome.columns).toEqual(current.outcome.columns); - expect(current.outcome.rows).toEqual([['1'], ['2']]); - expect(official.outcome.rows).toEqual(current.outcome.rows); - }); - - it('empty result: zero rows, clean completion, no error on either adapter', async () => { - const req = baseReq('empty-stream'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rows).toEqual([]); - expect(official.outcome.rows).toEqual([]); - expect(current.outcome.error).toBeNull(); - expect(official.outcome.error).toBeNull(); - }); - - it('KPI progress path (publicly supported JSONEachRowWithProgress): both adapters stream progressively', async () => { - const req = baseReq('kpi-progress', { format: 'KPI' }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rows.length).toBe(1); - expect(official.outcome.rows.length).toBe(1); - expect(current.outcome.progress?.rows).toBe(1); - expect(official.outcome.progress?.rows).toBe(1); - }); - - it('progressive first row: first row precedes completion on both adapters, no full-body buffering', async () => { - const req = baseReq('delayed-headers-scheduled-rows'); - const [current, official] = await Promise.all([ - runCurrent(req, fault.baseUrl, fetch), - (async () => runOfficial(createOfficialConnection(fault.baseUrl, fetch), req))(), - ]); - expect(current.outcome.firstRowAtMs).not.toBeNull(); - expect(official.outcome.firstRowAtMs).not.toBeNull(); - expect(current.outcome.firstRowAtMs!).toBeLessThan(current.outcome.completedAtMs!); - expect(official.outcome.firstRowAtMs!).toBeLessThan(official.outcome.completedAtMs!); - // First-row publication must not be materially behind current (plan §19: - // <=100ms budget for identical scheduled chunks). - expect(Math.abs(official.outcome.firstRowAtMs! - current.outcome.firstRowAtMs!)).toBeLessThan(150); - // Exact-precision proof riding along: UInt64 max survives as a string. - expect(current.outcome.rows[0][0]).toBe('18446744073709551615'); - expect(official.outcome.rows[0][0]).toBe('18446744073709551615'); - }, 10_000); - - it('malformed stream: a bad line is skipped, a later well-formed row still arrives, on both adapters', async () => { - const req = baseReq('malformed-line'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rows).toEqual([['after-malformed']]); - expect(official.outcome.rows).toEqual([['after-malformed']]); - expect(current.outcome.error).toBeNull(); - expect(official.outcome.error).toBeNull(); - }); - - it('truncated stream: an incomplete trailing line never completes, is silently dropped, no crash', async () => { - const req = baseReq('truncated-trailing-line'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rows).toEqual([['ok']]); - expect(official.outcome.rows).toEqual([['ok']]); - }); - - it('in-band mid-stream exception: partial rows preserved, ends in error, never success, on both adapters', async () => { - const req = baseReq('progress-format-mid-stream-exception'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rows).toEqual([['partial-before-exception']]); - expect(official.outcome.rows).toEqual([['partial-before-exception']]); - expect(current.outcome.error).toContain('Memory limit exceeded'); - expect(official.outcome.error).toContain('Memory limit exceeded'); - }); - - it('server error before headers: a query outcome on both adapters, not a network/offline classification', async () => { - const req = baseReq('pre-header-rejection'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - // Real finding: the current adapter's `parseExceptionText` keeps - // ClickHouse's raw "Code: N. DB::Exception: (CODE_NAME)" text - // verbatim, while the official client's `ClickHouseError` DECOMPOSES it - // into `.code`/`.type`/`.message` and the message itself drops the - // "Code: N. DB::Exception:" prefix and the "(CODE_NAME)" suffix — both - // sides retain the same code (60) and the same human-readable substring, - // just structured differently (recorded in - // docs/evidence/585/critical-questions.md, "Are code and message - // retained for current policy?"). - expect(current.outcome.error).toContain('UNKNOWN_TABLE'); - expect(current.outcome.error).toContain('Code: 60'); - expect(official.outcome.chCode).toBe(60); - expect(official.outcome.error).toContain('does not exist'); - expect(current.outcome.cancelled).toBe(false); - expect(official.outcome.cancelled).toBe(false); - }); - - it('repeated 401 (no prior successful connection): a query/auth outcome, not an infinite retry loop', async () => { - const req = baseReq('repeated-401'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.error).toBeTruthy(); - expect(official.outcome.error).toBeTruthy(); - }); - - it('post-confirmation 403: a query outcome on both adapters, not a sign-out/offline error', async () => { - // Production's authenticated request path (at the time this spike was - // written, `ch-client.ts`'s `authedFetch`; since #630 Phase 6, - // `authenticated-clickhouse-request.ts`'s `authenticatedRequest`, - // unchanged in shape) treats a FIRST-CONTACT 401/403 as a - // login-denial (sign-out) — by design (see its own docstring). The - // invariant this scenario actually proves ("post-confirmation 401/403 - // remain query outcomes") only applies once `ctx.authConfirmed` has - // already latched true from an earlier 2xx on this same connection — - // exactly the real workbench's session shape, never a connection's very - // first request. `initialAuthConfirmed=true` reproduces that. - const req = baseReq('forbidden-403', { credential: DENIED_USER }); - const current = await runCurrent(req, fault.baseUrl, fetch, true); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.error).toContain('ACCESS_DENIED'); - expect(official.outcome.chCode).toBe(497); - expect(official.outcome.error).toContain('Not enough privileges'); - }); - - it('response headers, query id, and X-ClickHouse-Summary are preserved verbatim by both adapters', async () => { - const id = qid('controlled-headers-and-summary'); - const req = baseReq('controlled-headers-and-summary', { queryId: id }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.responseHeaders['x-custom-exposed-header']).toBe('exposed-value'); - expect(official.outcome.responseHeaders['x-custom-exposed-header']).toBe('exposed-value'); - }); - - it('a mid-stream connection reset is a distinct, non-success failure on both adapters', async () => { - const req = baseReq('post-header-connection-reset'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.error).toBeTruthy(); - expect(official.outcome.error).toBeTruthy(); - expect(current.outcome.rows.length).toBeGreaterThanOrEqual(0); - expect(official.outcome.rows.length).toBeGreaterThanOrEqual(0); - }); -}); - -describe('deterministic parity — raw/export byte path', () => { - it('raw export: exact byte count and SHA-256 equality on a TSV row containing exception-shaped text', async () => { - const req = baseReq('raw-exception-like-text-then-more-data', { format: 'TabSeparatedWithNames', consume: 'raw' }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rawSha256).not.toBeNull(); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - expect(current.outcome.rawByteCount).toBe(official.outcome.rawByteCount); - }); - - it('raw export: invalid-UTF-8 bytes hash identically on both adapters (no text-decoding)', async () => { - const req = baseReq('invalid-utf8-raw', { format: 'TabSeparatedWithNames', consume: 'raw' }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rawByteCount).toBe(8); - expect(official.outcome.rawByteCount).toBe(8); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - }); - - it('raw export: a tagged late-exception trailer survives byte transport unmodified on both adapters', async () => { - const req = baseReq('raw-tagged-late-exception', { format: 'TabSeparatedWithNames', consume: 'raw' }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - expect(current.outcome.rawByteCount).toBe(official.outcome.rawByteCount); - }); - - it('raw export: a legacy untagged exception trailer survives byte transport unmodified on both adapters', async () => { - const req = baseReq('raw-legacy-untagged-exception', { format: 'TabSeparatedWithNames', consume: 'raw' }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - }); -}); - -describe('authentication — per-request credential, no client mutation/reconstruction', () => { - it('alternating Basic user A / user B / invalid / valid: each request uses only its own credential; one constructor call throughout', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - const seen: (string | null)[] = []; - for (const credential of [BASIC_USER_A, BASIC_USER_B, DENIED_USER, BASIC_USER_A] as SpikeCredential[]) { - fault.requestsLog.length = 0; - const req = baseReq('ordinary-query', { credential }); - await runOfficial(conn, req); - const last = fault.requestsLog.at(-1)!; - seen.push(last.headers.authorizationScheme); - } - expect(seen).toEqual(['Basic', 'Basic', 'Basic', 'Basic']); - expect(conn.constructorCalls).toBe(1); - }); -}); - -describe('credential-epoch fencing at the real fetch boundary', () => { - it('a stale epoch registered before preparation, flipped before the delegate fetch fires, never reaches the network', async () => { - let epoch = 1; - const fence = createEpochFence(() => epoch, fetch); - const client = createOfficialConnectionWithFetch(fault.baseUrl, fence.guardedFetch); - const id = qid('ordinary-query'); - const registered = fence.register(id, epoch); - expect(registered).toBe(true); - // Simulate the race deterministically: the epoch turns AFTER the request - // was PREPARED (registered under epoch 1) but BEFORE the official - // client's internal work reaches the injected fetch. Empirically, - // installed 1.23.1's `exec()` reaches the injected `fetch` with no - // microtask boundary a caller can reliably interleave with via - // `queueMicrotask` (verified: a `queueMicrotask`-scheduled flip run - // AFTER starting `exec()` consistently loses the race — the delegate - // fetch already fired). A synchronous flip immediately after - // registration and before invoking the client is therefore the - // deterministic reproduction of "replaced before the real fetch fires": - // program order guarantees the epoch has already turned by the time - // ANY internal step of the call — synchronous or not — reaches - // `guardedFetch`, which is exactly the boundary plan §21 asks this - // checkpoint to guard, regardless of how many (if any) microtask hops - // separate "prepared" from "fetched" inside a given client version. - epoch = 2; - const req = baseReq('ordinary-query', { queryId: id }); - const result = await runOfficial(client, req); - fence.unregister(id); - expect(result.outcome.cancelled).toBe(true); - expect(fence.staleRejections).toBe(1); - expect(fence.delegatedCalls).toBe(0); - }); - - it('a current (non-stale) epoch reaches the network exactly once', async () => { - let epoch = 5; - const fence = createEpochFence(() => epoch, fetch); - const client = createOfficialConnectionWithFetch(fault.baseUrl, fence.guardedFetch); - const id = qid('ordinary-query'); - fence.register(id, epoch); - const req = baseReq('ordinary-query', { queryId: id }); - const result = await runOfficial(client, req); - fence.unregister(id); - expect(result.outcome.error).toBeNull(); - expect(fence.delegatedCalls).toBe(1); - expect(fence.staleRejections).toBe(0); - }); -}); - -describe('cancellation lease — the REAL production killQueryWithLease uses the frozen request credential, never live auth state (plan §22/§28 "frozen cancellation lease")', () => { - it('a credential rotated AFTER the lease was captured never reaches the KILL QUERY request; the frozen one always does', async () => { - const { fetch: capturing, lastAuth } = capturingFetch(fetch); - // Mirrors src/application/connection-session.ts's captureCancellationLease(): - // `authorization` is a COMPLETE, already-resolved header value, frozen at - // capture time — never recomputed from mutable auth state later. - const frozenAuthorization = `Basic ${btoa('frozen-user:frozen-pass')}`; - const lease: AuthenticatedCancellationLease = { - epoch: 1, - origin: fault.baseUrl, - authorization: frozenAuthorization, - fetch: capturing, - }; - // Simulate a credential rotation (OAuth refresh / replacement sign-in) - // that happens AFTER the lease was captured — killQueryWithLease must - // have no way to observe this; it only ever reads `lease.authorization`, - // never a live ctx/auth-mode lookup (unlike plain `killQuery`, which - // does go through `queryJson`'s live auth path — `authenticatedRequest`, - // `authenticated-clickhouse-request.ts`, since #630 Phase 6; formerly - // `authedFetch`). - const rotatedAuthorization = `Basic ${btoa('rotated-user:rotated-pass')}`; - expect(rotatedAuthorization).not.toBe(frozenAuthorization); // sanity: the two really differ - - await killQueryWithLease(lease, qid('ordinary-query')); - - expect(lastAuth()).toBe(frozenAuthorization); - }); - - it('a null/undefined query_id is a no-op — no fetch at all (matches killQuery\'s own early-return contract)', async () => { - const { fetch: capturing, lastAuth } = capturingFetch(fetch); - const lease: AuthenticatedCancellationLease = { - epoch: 1, origin: fault.baseUrl, authorization: 'Basic irrelevant', fetch: capturing, - }; - await killQueryWithLease(lease, null); - await killQueryWithLease(lease, undefined); - expect(lastAuth()).toBeNull(); - }); -}); - -describe('retry safety — official outcomes fed through the REAL, unmodified QueryExecutionService', () => { - it('SESSION_IS_LOCKED gets exactly one delayed retry, then succeeds', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - // Routed through the REAL QueryExecutionService via `serviceFor()` — - // consistently with the sibling "ambiguous INSERT/DDL reset: no retry" - // tests below, which already prove `serviceFor()`'s overridden `uid` - // (minting `__` on every call, ignoring executeScript's - // own fixed `deps.uid('q')` prefix) routes a full `executeScript` run to - // a SPECIFIC fault-server fixture. The `scenarios.ts:63` comment claiming - // this can't be done ("executeScript always mints its own query_id, so it - // cannot itself be routed to a specific fixture") is contradicted by that - // sibling test's own successful use of exactly this mechanism. - const svc = serviceFor(conn, 'session-is-locked'); - const attempts: number[] = []; - const result = await svc.executeScript({ - statements: [{ sql: 'SELECT 1', execSql: 'SELECT 1', params: {} }], - onStatementStart: (_i, info) => attempts.push(info.attempt), - onStatementResult: () => {}, - }); - // fault-server.mjs's 'session-is-locked' fixture rejects attempt 1 with - // SESSION_IS_LOCKED (code 373) and succeeds on attempt 2 — exactly one - // retry, then success, never a third attempt. - expect(attempts).toEqual([1, 2]); - expect(result.entries).toHaveLength(1); - expect(result.entries[0].status).not.toBe('error'); - }); - - it('SESSION_IS_LOCKED: raw adapter retried once by hand-driving the same policy the service applies', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = makeOfficialQueryExecutionAdapter(conn, () => BASIC_USER_A); - const id = qid('session-is-locked'); - const request = { sql: 'SELECT 1', defaultFormat: 'JSONStringsEachRowWithProgress', params: { query_id: id } }; - // The retry policy's own `SESSION_BUSY` regex (query-execution-service.ts) - // matches "locked by a concurrent" case-insensitively — it does not - // depend on the "(SESSION_IS_LOCKED)" code-name suffix the official - // client's `ClickHouseError` strips from the message (see the - // pre-header-rejection scenario's comment above for the same finding). - // A pre-header rejection now THROWS (matching the new "package consumers - // throw" contract, #630 Phase 7 §6.5), never a returned `{error}`. - await expect(official.runProgress(request, {})).rejects.toThrow(/locked by a concurrent/); - await expect(official.runProgress(request, {})).resolves.toBeUndefined(); - }); - - it('a mid-stream connection reset on a read propagates as a throw (matching the new "package consumers throw" contract, not a swallowed {error})', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = makeOfficialQueryExecutionAdapter(conn, () => BASIC_USER_A); - const id = qid('post-header-connection-reset'); - await expect(official.runProgress({ sql: 'SELECT 1', defaultFormat: 'JSONStringsEachRowWithProgress', params: { query_id: id } }, {})).rejects.toBeTruthy(); - }); - - it('read-reset-retries-once: a read retries once after a mid-stream reset and then succeeds (hand-driven, same policy shape as the SESSION_IS_LOCKED case above)', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = makeOfficialQueryExecutionAdapter(conn, () => BASIC_USER_A); - const id = qid('read-reset-then-success'); - const request = { sql: 'SELECT 1', defaultFormat: 'JSONStringsEachRowWithProgress', params: { query_id: id } }; - await expect(official.runProgress(request, {})).rejects.toBeTruthy(); - await expect(official.runProgress(request, {})).resolves.toBeUndefined(); - }); - - it('ambiguous INSERT reset: no retry through the REAL QueryExecutionService; the ambiguous-write message is preserved', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - const svc = serviceFor(conn, 'post-header-connection-reset'); - const attempts: number[] = []; - const result = await svc.executeScript({ - statements: [{ sql: 'INSERT INTO t VALUES (1)', execSql: 'INSERT INTO t VALUES (1)', params: {} }], - onStatementStart: (_i, info) => attempts.push(info.attempt), - onStatementResult: () => {}, - }); - expect(attempts).toEqual([1]); // exactly one attempt — an ambiguous write must never retry - expect(result.entries).toHaveLength(1); - expect(result.entries[0].status).toBe('error'); - expect(errorEntryMessage(result.entries[0])).toContain('may have executed'); - }); - - it('ambiguous DDL reset: no retry through the REAL QueryExecutionService; the ambiguous-write message is preserved', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - const svc = serviceFor(conn, 'post-header-connection-reset'); - const attempts: number[] = []; - const result = await svc.executeScript({ - statements: [{ sql: 'CREATE TABLE t (x Int32) ENGINE = Memory', execSql: 'CREATE TABLE t (x Int32) ENGINE = Memory', params: {} }], - onStatementStart: (_i, info) => attempts.push(info.attempt), - onStatementResult: () => {}, - }); - expect(attempts).toEqual([1]); // exactly one attempt — DDL must never retry either - expect(result.entries).toHaveLength(1); - expect(result.entries[0].status).toBe('error'); - expect(errorEntryMessage(result.entries[0])).toContain('may have executed'); - }); -}); - -describe('Table streaming and totals/extremes (plan §18)', () => { - it('Table streaming: identical normalized meta/row/progress on both adapters', async () => { - const req = baseReq('ordinary-query'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.progress).toEqual({ rows: 2, bytes: 2, totalRows: 2 }); - expect(official.outcome.progress).toEqual(current.outcome.progress); - }); - - it('totals/extremes/rows_before_limit_at_least lines are a silent no-op on both adapters (documented current-behavior gap, not adopted new parsing)', async () => { - const req = baseReq('totals-extremes'); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(current.outcome.rows).toEqual([['1'], ['2']]); - expect(official.outcome.rows).toEqual([['1'], ['2']]); - expect(current.outcome.error).toBeNull(); - expect(official.outcome.error).toBeNull(); - }); -}); - -describe('cancellation — three deterministic points (plan §22)', () => { - it('cancel before request: a pre-aborted signal produces no fetch side effect on either adapter', async () => { - const controller = new AbortController(); - controller.abort(); - const req = baseReq('ordinary-query', { signal: controller.signal }); - const before = fault.requestsLog.length; - const current = await runCurrent(req, fault.baseUrl, fetch); - expect(current.outcome.cancelled).toBe(true); - - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(official.outcome.cancelled).toBe(true); - expect(official.fetchCalls).toBe(0); - expect(fault.requestsLog.length).toBe(before); - }); - - it('cancel awaiting headers: cancellation without an offline/auth mutation, on either adapter', async () => { - const controller1 = new AbortController(); - setTimeout(() => controller1.abort(), 50); - let offlineCalled = false; - const current = await runCurrent( - baseReq('slow-headers', { signal: controller1.signal }), - fault.baseUrl, - fetch, - undefined, - { onTransportOffline: () => { offlineCalled = true; } }, - ); - expect(current.outcome.cancelled).toBe(true); - expect(offlineCalled).toBe(false); - - const controller2 = new AbortController(); - setTimeout(() => controller2.abort(), 50); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, baseReq('slow-headers', { signal: controller2.signal })); - expect(official.outcome.cancelled).toBe(true); - }, 10_000); - - it('cancel during rows: no row is published after cancellation, on either adapter', async () => { - const controllerC = new AbortController(); - setTimeout(() => controllerC.abort(), 170); - const current = await runCurrent( - baseReq('delayed-headers-scheduled-rows', { signal: controllerC.signal }), - fault.baseUrl, - fetch, - ); - expect(current.outcome.cancelled).toBe(true); - expect(current.outcome.rows).toHaveLength(1); - - const controllerO = new AbortController(); - setTimeout(() => controllerO.abort(), 170); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, baseReq('delayed-headers-scheduled-rows', { signal: controllerO.signal })); - expect(official.outcome.cancelled).toBe(true); - expect(official.outcome.rows).toHaveLength(1); - }, 10_000); -}); - -describe('timeout vs. offline vs. HTTP error — distinct classifications (plan §19/§21)', () => { - it("timeout: the official client's own connection-level request_timeout produces a distinct Error('Timeout error.'); current has no built-in timeout, so a caller-driven timer is indistinguishable from a manual abort", async () => { - const controllerC = new AbortController(); - setTimeout(() => controllerC.abort(), 200); - const current = await runCurrent(baseReq('slow-headers', { signal: controllerC.signal }), fault.baseUrl, fetch); - expect(current.outcome.cancelled).toBe(true); - - const conn = createOfficialConnection(fault.baseUrl, fetch, 200); - const official = await runOfficial(conn, baseReq('slow-headers')); - expect(official.outcome.cancelled).toBe(false); - expect(official.outcome.error).toBe('Timeout error.'); - }, 10_000); - - it('offline rejection is classified distinctly from an HTTP ClickHouse query error, on either adapter', async () => { - const deadUrl = await closedLoopbackUrl(); - - const req = baseReq('ordinary-query'); - let offlineCalled = false; - const current = await runCurrent(req, deadUrl, fetch, undefined, { - onTransportOffline: () => { offlineCalled = true; }, - }); - expect(current.outcome.error).toBeTruthy(); - expect(offlineCalled).toBe(true); - - const conn = createOfficialConnection(deadUrl, fetch); - const official = await runOfficial(conn, req); - expect(official.outcome.chCode).toBeNull(); // network rejection, never a ClickHouseError - expect(official.outcome.error).toBeTruthy(); - - // Contrast: the pre-header-rejection HTTP-error scenario is a normal HTTP - // response, not a fetch rejection — onTransportOffline must NOT fire. - let offlineCalledForHttpError = false; - await runCurrent(baseReq('pre-header-rejection'), fault.baseUrl, fetch, undefined, { - onTransportOffline: () => { offlineCalledForHttpError = true; }, - }); - expect(offlineCalledForHttpError).toBe(false); - }); -}); - -describe('settings, role, session, query ID, and URL-parameter exact serialization (plan §18)', () => { - it('settings: exact server-observed bare-key values on both adapters', async () => { - const req = baseReq('ordinary-query', { settings: { max_threads: 4, readonly: '1' } }); - fault.requestsLog.length = 0; - await runCurrent(req, fault.baseUrl, fetch); - let logged = fault.requestsLog.at(-1)!; - expect(logged.params.max_threads).toBe('4'); - expect(logged.params.readonly).toBe('1'); - - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - await runOfficial(conn, req); - logged = fault.requestsLog.at(-1)!; - expect(logged.params.max_threads).toBe('4'); - expect(logged.params.readonly).toBe('1'); - }); - - it('role: exact server-observed value on both adapters', async () => { - const req = baseReq('ordinary-query', { role: 'analyst' }); - fault.requestsLog.length = 0; - await runCurrent(req, fault.baseUrl, fetch); - expect(fault.requestsLog.at(-1)!.params.role).toBe('analyst'); - - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - await runOfficial(conn, req); - expect(fault.requestsLog.at(-1)!.params.role).toBe('analyst'); - }); - - it('session_id: present with the exact value when requested, absent when session-less, on both adapters', async () => { - const withSession = baseReq('ordinary-query', { sessionId: 'asb-spike-session-1' }); - fault.requestsLog.length = 0; - await runCurrent(withSession, fault.baseUrl, fetch); - expect(fault.requestsLog.at(-1)!.params.session_id).toBe('asb-spike-session-1'); - - const conn = createOfficialConnection(fault.baseUrl, fetch); - fault.requestsLog.length = 0; - await runOfficial(conn, withSession); - expect(fault.requestsLog.at(-1)!.params.session_id).toBe('asb-spike-session-1'); - - const sessionLess = baseReq('ordinary-query'); - fault.requestsLog.length = 0; - await runCurrent(sessionLess, fault.baseUrl, fetch); - expect(fault.requestsLog.at(-1)!.params.session_id).toBeUndefined(); - - fault.requestsLog.length = 0; - await runOfficial(conn, sessionLess); - expect(fault.requestsLog.at(-1)!.params.session_id).toBeUndefined(); - }); - - it('query ID exists before execution: the caller-allocated id is on the wire (the server received it as part of the request), for both adapters', async () => { - const id = qid('controlled-headers-and-summary'); - const req = baseReq('controlled-headers-and-summary', { queryId: id }); - fault.requestsLog.length = 0; - await runCurrent(req, fault.baseUrl, fetch); - expect(fault.requestsLog.at(-1)!.params.query_id).toBe(id); - - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - await runOfficial(conn, req); - expect(fault.requestsLog.at(-1)!.params.query_id).toBe(id); - }); - - it('URL parameters: an array of large-integer strings and a scalar large-integer string serialize to the exact same independently-computed wire value on both adapters', async () => { - // Hand-computed, independent of both adapters' implementations: a - // top-level scalar native parameter is unquoted; an array wraps each - // element in single quotes inside `[...]` (ClickHouse's own array - // literal syntax) — see `formatNativeParamValue`'s docstring in - // `current-adapter.ts` for the algorithm both sides converge on. - const expectedArray = "['18446744073709551615','0']"; - const expectedScalar = '99999999999999999999'; - const req = baseReq('ordinary-query', { - params: { bignum: '99999999999999999999', arr: ['18446744073709551615', '0'] }, - }); - fault.requestsLog.length = 0; - await runCurrent(req, fault.baseUrl, fetch); - let logged = fault.requestsLog.at(-1)!; - expect(logged.params.param_bignum).toBe(expectedScalar); - expect(logged.params.param_arr).toBe(expectedArray); - - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - await runOfficial(conn, req); - logged = fault.requestsLog.at(-1)!; - expect(logged.params.param_bignum).toBe(expectedScalar); - expect(logged.params.param_arr).toBe(expectedArray); - }); -}); - -describe('multipart param promotion — official query() only (plan §18)', () => { - it('forced multipart: query() sends query_params as multipart/form-data with the correct field name/value, and omits them from the URL', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - fault.requestsLog.length = 0; - const rs = await conn.client.query({ - query: 'SELECT 1', - format: 'JSONEachRowWithProgress', - query_id: qid('kpi-progress'), - auth: officialAuthFor(BASIC_USER_A), - use_multipart_params: true, - query_params: { myparam: 'hello-multipart' }, - }); - const reader = rs.stream().getReader(); - for (;;) { const { done } = await reader.read(); if (done) break; } - - const logged = fault.requestsLog.at(-1)!; - expect(logged.headers['content-type']).toContain('multipart/form-data'); - expect(logged.body).toContain('name="param_myparam"'); - expect(logged.body).toContain('hello-multipart'); - expect(logged.params.param_myparam).toBeUndefined(); - }); - - it('automatic multipart: an oversized query_params payload is promoted to multipart automatically', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - const bigValue = 'x'.repeat(5000); - fault.requestsLog.length = 0; - const rs = await conn.client.query({ - query: 'SELECT 1', - format: 'JSONEachRowWithProgress', - query_id: qid('kpi-progress'), - auth: officialAuthFor(BASIC_USER_A), - use_multipart_params_auto: true, - query_params: { big: bigValue }, - }); - const reader = rs.stream().getReader(); - for (;;) { const { done } = await reader.read(); if (done) break; } - - const logged = fault.requestsLog.at(-1)!; - expect(logged.headers['content-type']).toContain('multipart/form-data'); - expect(logged.body).toContain('name="param_big"'); - expect(logged.params.param_big).toBeUndefined(); - }); -}); - -describe('explicit FORMAT and raw TSV/CSV/JSON exact output (plan §18/§24)', () => { - it('explicit FORMAT: a SQL text that already carries a trailing FORMAT clause is sent with exactly one FORMAT occurrence by the official adapter', async () => { - const req = baseReq('raw-tsv-fixed', { - sql: 'SELECT 1\nFORMAT TabSeparatedWithNames', - format: 'TabSeparatedWithNames', - consume: 'raw', - }); - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - await runOfficial(conn, req); - const body = fault.requestsLog.at(-1)!.body; - const occurrences = (body.match(/FORMAT/gi) || []).length; - expect(occurrences).toBe(1); - }); - - // Regression coverage for a P1 review finding (issue #585 Phase 0): a - // prior ad hoc terminal regex (`/\bFORMAT\s+\S+\s*;?\s*$/i`) recognized an - // existing trailing FORMAT clause only when it was the literal end of the - // string (optionally plus a trailing `;`) — so a FORMAT clause followed by - // a comment was invisible to it, and a second, duplicate FORMAT clause got - // appended. `withTrailingFormat` (src/core/format.ts, the same function - // production's own export path uses) is comment/string-aware via its - // shared span scanner, so each of these must still see exactly ONE - // existing FORMAT occurrence. - it.each([ - ['a trailing line comment (--)', 'SELECT 1\nFORMAT TabSeparatedWithNames -- trailing note'], - ['a trailing line comment (#)', 'SELECT 1\nFORMAT TabSeparatedWithNames # trailing note'], - ['a semicolon followed by a trailing comment', 'SELECT 1\nFORMAT TabSeparatedWithNames; -- trailing note'], - ['a trailing block comment', 'SELECT 1\nFORMAT TabSeparatedWithNames /* trailing note */'], - ['mixed-case FORMAT keyword', 'SELECT 1\nformat TabSeparatedWithNames'], - ['mixed-case FORMAT keyword plus a trailing comment', 'SELECT 1\nFoRmAt TabSeparatedWithNames -- note'], - ])('explicit FORMAT followed by %s: still exactly one FORMAT occurrence', async (_label, sql) => { - const req = baseReq('raw-tsv-fixed', { sql, format: 'TabSeparatedWithNames', consume: 'raw' }); - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - await runOfficial(conn, req); - const body = fault.requestsLog.at(-1)!.body; - const occurrences = (body.match(/FORMAT/gi) || []).length; - expect(occurrences).toBe(1); - }); - - it('FORMAT-shaped text inside a string literal is never mistaken for a trailing clause: the real FORMAT is still appended exactly once, after it', async () => { - const req = baseReq('raw-tsv-fixed', { - sql: "SELECT 'this text says FORMAT TabSeparatedWithNames but is just a string' AS note", - format: 'TabSeparatedWithNames', - consume: 'raw', - }); - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - await runOfficial(conn, req); - const body = fault.requestsLog.at(-1)!.body; - const occurrences = (body.match(/FORMAT/gi) || []).length; - // One inside the string literal (never a real clause) + one genuinely - // appended trailing clause = two occurrences, and the real clause must - // be the one actually trailing the query. - expect(occurrences).toBe(2); - expect(body.trim().endsWith('FORMAT TabSeparatedWithNames')).toBe(true); - }); - - it('FORMAT-shaped text inside a line comment is never mistaken for a trailing clause: the real FORMAT is still appended exactly once, after it', async () => { - const req = baseReq('raw-tsv-fixed', { - sql: 'SELECT 1 -- FORMAT TabSeparatedWithNames (not real, just a comment)', - format: 'TabSeparatedWithNames', - consume: 'raw', - }); - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - await runOfficial(conn, req); - const body = fault.requestsLog.at(-1)!.body; - const occurrences = (body.match(/FORMAT/gi) || []).length; - // The comment (and the FORMAT-shaped text inside it) is peeled by - // `withTrailingFormat` before the real clause is appended, so exactly one - // occurrence remains — the genuinely appended one. - expect(occurrences).toBe(1); - expect(body.trim().endsWith('FORMAT TabSeparatedWithNames')).toBe(true); - }); - - it('raw TSV: exact byte-for-byte output on both adapters', async () => { - const expected = 'a\tb\n1\tx\n2\ty\n'; - const req = baseReq('raw-tsv-fixed', { format: 'TabSeparatedWithNames', consume: 'raw' }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - const expectedBytes = new TextEncoder().encode(expected).byteLength; - expect(current.outcome.rawByteCount).toBe(expectedBytes); - expect(official.outcome.rawByteCount).toBe(expectedBytes); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - }); - - it('raw CSV: exact byte-for-byte output on both adapters', async () => { - const expected = '"a","b"\n"1","x"\n"2","y"\n'; - const req = baseReq('raw-csv-fixed', { format: 'CSVWithNames', consume: 'raw' }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - const expectedBytes = new TextEncoder().encode(expected).byteLength; - expect(current.outcome.rawByteCount).toBe(expectedBytes); - expect(official.outcome.rawByteCount).toBe(expectedBytes); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - }); - - it('raw JSON: exact byte-for-byte output on both adapters', async () => { - const expected = '{"meta":[{"name":"a","type":"String"}],"data":[{"a":"1"},{"a":"2"}]}\n'; - const req = baseReq('raw-json-fixed', { format: 'JSON', consume: 'raw' }); - const current = await runCurrent(req, fault.baseUrl, fetch); - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - const expectedBytes = new TextEncoder().encode(expected).byteLength; - expect(current.outcome.rawByteCount).toBe(expectedBytes); - expect(official.outcome.rawByteCount).toBe(expectedBytes); - expect(current.outcome.rawSha256).toBe(official.outcome.rawSha256); - }); -}); - -describe('no-output command (plan §18)', () => { - it('an INSERT/DDL-shaped empty-body response is drained/discarded without hanging, and issues exactly one request, on both the current raw path and the official command()', async () => { - const req = baseReq('no-output', { format: 'TSV', consume: 'raw' }); - fault.requestsLog.length = 0; - const current = await runCurrent(req, fault.baseUrl, fetch); - expect(current.outcome.error).toBeNull(); - expect(current.outcome.rawByteCount).toBe(0); - expect(fault.requestsLog.length).toBe(1); - - fault.requestsLog.length = 0; - const conn = createOfficialConnection(fault.baseUrl, fetch); - const result = await conn.client.command({ - query: 'INSERT INTO t VALUES (1)', - query_id: qid('no-output'), - auth: officialAuthFor(BASIC_USER_A), - }); - expect(result.http_status_code).toBe(200); - expect(fault.requestsLog.length).toBe(1); - }); -}); - -describe('Bearer / JWT-as-Basic exact header composition (plan §18/§21)', () => { - it('Bearer auth: exact request-local header on both adapters', async () => { - const expected = 'Bearer asb-spike-bearer-fixture-token'; - const req = baseReq('ordinary-query', { credential: BEARER_FIXTURE }); - - const cap1 = capturingFetch(fetch); - await runCurrent(req, fault.baseUrl, cap1.fetch); - expect(cap1.lastAuth()).toBe(expected); - - const cap2 = capturingFetch(fetch); - const conn = createOfficialConnection(fault.baseUrl, cap2.fetch); - await runOfficial(conn, req); - expect(cap2.lastAuth()).toBe(expected); - }); - - it('JWT as Basic password: exact independently-computed Basic composition on both adapters', async () => { - // Independently computed here (NOT via `credentialAuthHeader`, which is - // shared plumbing both adapters build their ctx/headers from) — the - // standard RFC 7617 Basic composition: base64("username:password"), - // where the "password" is the JWT. - const expected = 'Basic ' + btoa('asb_spike_jwt:asb.spike.jwt-fixture'); - const req = baseReq('ordinary-query', { credential: JWT_AS_BASIC_FIXTURE }); - - const cap1 = capturingFetch(fetch); - await runCurrent(req, fault.baseUrl, cap1.fetch); - expect(cap1.lastAuth()).toBe(expected); - - const cap2 = capturingFetch(fetch); - const conn = createOfficialConnection(fault.baseUrl, cap2.fetch); - await runOfficial(conn, req); - expect(cap2.lastAuth()).toBe(expected); - }); -}); - -describe('refresh then retry, and post-confirmation 401 (plan §18/§21)', () => { - it('refresh then retry: exactly one refresh and one replay to success, on both adapters', async () => { - const req = baseReq('401-then-success'); - let refreshCalls = 0; - const current = await runCurrent(req, fault.baseUrl, fetch, undefined, { - refresh: async () => { refreshCalls += 1; return true; }, - }); - expect(current.outcome.error).toBeNull(); - expect(current.outcome.rows).toEqual([['ok-after-refresh']]); - expect(refreshCalls).toBe(1); - - fault.resetAttemptCounts(); - - const conn = createOfficialConnection(fault.baseUrl, fetch); - let officialRefreshCalls = 0; - const officialResult = await runOfficialRefreshThenRetry( - conn, - baseReq('401-then-success'), - async () => { officialRefreshCalls += 1; return BASIC_USER_A; }, - () => true, - ); - expect(officialResult.refreshCalls).toBe(1); - expect(officialResult.attempts).toBe(2); - expect(officialResult.outcome.error).toBeNull(); - expect(officialRefreshCalls).toBe(1); - }); - - it('post-confirmation 401 remains a query outcome (no sign-out) on the current adapter; the official adapter classifies it as ClickHouseError code 516', async () => { - const req = baseReq('repeated-401'); - const current = await runCurrent(req, fault.baseUrl, fetch, true); // authConfirmed=true — see the forbidden-403 test's docstring above for why - expect(current.outcome.error).toBeTruthy(); - - const conn = createOfficialConnection(fault.baseUrl, fetch); - const official = await runOfficial(conn, req); - expect(official.outcome.chCode).toBe(516); - expect(official.outcome.error).toContain('Authentication failed'); - }); -}); - -describe('credential-epoch fencing — stale before request, during refresh, and in response (plan §21)', () => { - it('stale before request: an already-stale epoch prevents any fetch side effect, on either adapter', async () => { - let epoch = 1; - const req = baseReq('ordinary-query'); - const before = fault.requestsLog.length; - let getTokenCalls = 0; - const current = await runCurrent(req, fault.baseUrl, fetch, undefined, { - currentEpoch: () => epoch, - getToken: async () => { getTokenCalls += 1; epoch = 2; return 'irrelevant-token'; }, - }); - expect(current.outcome.cancelled).toBe(true); - expect(fault.requestsLog.length).toBe(before); - expect(getTokenCalls).toBe(1); - - let officialEpoch = 1; - const fence = createEpochFence(() => officialEpoch, fetch); - officialEpoch = 2; // already stale before register() is even called - const registered = fence.register(qid('ordinary-query'), 1); - expect(registered).toBe(false); - expect(fence.delegatedCalls).toBe(0); - }); - - it('stale during refresh: the epoch turning mid-refresh prevents any replacement credential read or replay, on either adapter', async () => { - let epoch = 1; - const req = baseReq('401-then-success'); - let getTokenCalls = 0; - const current = await runCurrent(req, fault.baseUrl, fetch, undefined, { - currentEpoch: () => epoch, - getToken: async () => { getTokenCalls += 1; return 'token-for-epoch-1'; }, - refresh: async () => { epoch = 2; return true; }, // epoch turns WHILE refresh is "in flight" - }); - expect(current.outcome.cancelled).toBe(true); - expect(getTokenCalls).toBe(1); // only the initial read — never re-read for a replacement - - fault.resetAttemptCounts(); - - let officialEpoch = 1; - const fence = createEpochFence(() => officialEpoch, fetch); - const id = qid('401-then-success'); - fence.register(id, 1); - const conn = createOfficialConnection(fault.baseUrl, fence.guardedFetch); - let officialRefreshCalls = 0; - const officialResult = await runOfficialRefreshThenRetry( - conn, - { ...baseReq('401-then-success'), queryId: id }, - async () => { officialRefreshCalls += 1; officialEpoch = 2; return BASIC_USER_A; }, - () => officialEpoch === 1, - ); - fence.unregister(id); - expect(officialRefreshCalls).toBe(1); - expect(officialResult.attempts).toBe(1); // no second (replay) attempt ever reached the client - expect(officialResult.outcome.cancelled).toBe(true); - }); - - it('stale response: no connected/lifecycle side effect fires for a response that arrives after the epoch has already turned, on either adapter (the caller\'s own data still comes through)', async () => { - let epoch = 1; - let connectedCalled = false; - const req = baseReq('ordinary-query'); - const current = await runCurrent(req, fault.baseUrl, fetch, undefined, { - currentEpoch: () => epoch, - onTransportConnected: () => { connectedCalled = true; }, - onFetchResponse: () => { epoch = 2; }, // flips AFTER the response, before authenticatedRequest's own post-check (formerly authedFetch's) - }); - expect(connectedCalled).toBe(false); - expect(current.outcome.rows).toEqual([['1'], ['2']]); - - let officialEpoch = 1; - const realFetchThatFlips = (async (input: RequestInfo | URL, init?: RequestInit) => { - const resp = await fetch(input, init); - officialEpoch = 2; // flips inside the delegate's OWN continuation, before guardedFetch's post-check runs - return resp; - }) as typeof fetch; - const fence = createEpochFence(() => officialEpoch, realFetchThatFlips); - const id = qid('ordinary-query'); - fence.register(id, 1); - const conn = createOfficialConnection(fault.baseUrl, fence.guardedFetch); - const official = await runOfficial(conn, { ...baseReq('ordinary-query'), queryId: id }); - fence.unregister(id); - expect(fence.staleResponses).toBe(1); - expect(official.outcome.rows).toEqual([['1'], ['2']]); - }); -}); - -describe('§16 runtime-surface experiment — literal cast-forced in isolation, NOT adopted', () => { - it('records stream()/json()/text() behavior when JSONStringsEachRowWithProgress is forced via an unsupported cast (empirically verified against installed 1.23.1)', async () => { - const conn = createOfficialConnection(fault.baseUrl, fetch); - - // .text() RESOLVES — it buffers the WHOLE body as a raw string. This is - // exactly the full-body-buffering behavior plan §19's hard gate forbids - // for the ADOPTED path, which is WHY the narrow bridge instead uses - // exec() (progress-bridge.ts) rather than casting this format into - // query().text(). - const rsText = await conn.client.query({ - query: 'SELECT 1', - format: 'JSONStringsEachRowWithProgress' as never, - query_id: qid('ordinary-query'), - auth: officialAuthFor(BASIC_USER_A), - }); - const text = await rsText.text(); - expect(typeof text).toBe('string'); - expect(text).toContain('"meta"'); - - // .json() THROWS — the vendor library's single-document JSON decoder - // cannot parse this newline-delimited multi-record format at all. - const rsJson = await conn.client.query({ - query: 'SELECT 1', - format: 'JSONStringsEachRowWithProgress' as never, - query_id: qid('ordinary-query'), - auth: officialAuthFor(BASIC_USER_A), - }); - let jsonError: string | null = null; - try { await rsJson.json(); } catch (e) { jsonError = e instanceof Error ? e.message : String(e); } - expect(jsonError).toContain('Cannot decode'); - - // .stream() THROWS synchronously — installed 1.23.1's own - // `StreamableJSONFormats` validation rejects this exact format, matching - // `format-type-probe.ts`'s compile-time finding that it's not in the - // public `DataFormat` union at all. - const rsStream = await conn.client.query({ - query: 'SELECT 1', - format: 'JSONStringsEachRowWithProgress' as never, - query_id: qid('ordinary-query'), - auth: officialAuthFor(BASIC_USER_A), - }); - let streamError: string | null = null; - try { rsStream.stream(); } catch (e) { streamError = e instanceof Error ? e.message : String(e); } - expect(streamError).toContain('not streamable'); - }); -}); - -// PR review fix (#630 Phase 1): `fault-server.mjs`'s own docstring says -// `opts.cors` defaults off and every pre-existing no-option caller (this -// file, `run-matrix.mjs`) keeps today's behavior — but the response -// error-suppression handler had been wired unconditionally, ahead of the -// `if (cors)` branch, silently changing that behavior for every caller here. -// Assert the scoping directly via the fault server's own -// `getLastErrorListenerCount()` introspection (kept inside `fault-server.mjs` -// so this `.ts` file needs no `node:http` import, per plan §8). -describe('#630 Phase 1 review fix — ServerResponse error-suppression is scoped to cors:true', () => { - it('registers no ServerResponse error listener for a legacy no-option (cors:false, default) request', async () => { - const req = baseReq('ordinary-query'); - const current = await runCurrent(req, fault.baseUrl, fetch); - expect(current.outcome.error).toBeNull(); - expect(fault.getLastErrorListenerCount()).toBe(0); - }); - - it('registers exactly one ServerResponse error listener for an opt-in cors:true request', async () => { - const corsFault = await startFaultServer({ cors: true }); - try { - const req = baseReq('ordinary-query'); - const current = await runCurrent(req, corsFault.baseUrl, fetch); - expect(current.outcome.error).toBeNull(); - expect(corsFault.getLastErrorListenerCount()).toBe(1); - } finally { - await corsFault.close(); - } - }); -}); - -function createOfficialConnectionWithFetch(baseUrl: string, fetchImpl: typeof fetch) { - return createOfficialConnection(baseUrl, fetchImpl); -} diff --git a/tests/spike/clickhouse-client/playwright.config.js b/tests/spike/clickhouse-client/playwright.config.js deleted file mode 100644 index 246b2f28..00000000 --- a/tests/spike/clickhouse-client/playwright.config.js +++ /dev/null @@ -1,94 +0,0 @@ -// Phase 0 / issue #585, plan §14 "Playwright configuration" and §25 -// "Browser and deployment matrix". A DEDICATED Playwright config scoped to -// this directory's `browser.spec.js` only — the normal repository e2e -// config (`playwright.config.js` at the repo root, `npm run test:e2e`) is -// left completely untouched; this file is reached only via -// `npm run test:client-spike:browser` (`playwright test --config -// tests/spike/clickhouse-client/playwright.config.js`). -// -// Chromium and WebKit only — Firefox is EXPLICITLY excluded (plan §14: -// "Firefox is not a local acceptance signal in this environment and must -// not be claimed" — this sandbox's `unshare(CLONE_NEWPID)` fails with EPERM -// for Playwright's Firefox launcher, a known, already-documented local -// limitation of the normal e2e suite too; never add a `firefox` project -// here as a "convenience" — an environment where it happens to launch would -// silently start claiming a signal this plan explicitly disclaims). -// -// `webServer` boots `spike-server.mjs`, which owns the real Docker -// ClickHouse row(s) for the run (`ASB_SPIKE_BROWSER_ROWS`, default -// "current-stable-oss") via `clickhouse-containers.mjs`'s `startRow` — see -// spike-server.mjs's own header for why row selection is a request header, -// not a URL path segment. `timeout` is generous because a cold Docker pull -// in this sandbox has been observed to take up to ~2 minutes -// (`clickhouse-containers.mjs`'s own `waitForReady` default budget) before -// spike-server.mjs's `/__health` route starts answering 200. -import { defineConfig } from '@playwright/test'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const here = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(here, '../../..'); - -// Single source of truth for the port: spike-server.mjs's own -// `DEFAULT_PORT` (cross-file comment, not a shared runtime import — a -// `.js` Playwright config cannot statically import spike-server.mjs's ESM -// export without spawning it, which would defeat the point of `webServer` -// spawning it as a separate process). -const PORT = Number(process.env.ASB_SPIKE_SERVER_PORT || 5680); -const BASE_URL = `http://127.0.0.1:${PORT}`; - -export default defineConfig({ - testDir: '.', - testMatch: '**/browser.spec.js', - timeout: 120_000, - webServer: { - command: 'node tests/spike/clickhouse-client/spike-server.mjs', - // Playwright's default webServer `cwd` is this config FILE's own - // directory, not the repository root — since this config lives under - // tests/spike/clickhouse-client/ (unlike the repo-root e2e config this - // one deliberately never touches), that default would double the - // repo-relative command path above. Set explicitly rather than relying - // on `npm run` always being invoked from repoRoot. - cwd: repoRoot, - url: `${BASE_URL}/__health`, - // Deliberately ALWAYS false, unlike the repo-root e2e config's - // `!process.env.CI` — this webServer owns a real, ephemeral Docker - // ClickHouse container (spike-server.mjs's own `bootRows`/`shutdown`). - // "Reusing" a previous run's server would mean reusing its container's - // now-stale fixture credentials (`/__rows.json`). - reuseExistingServer: false, - timeout: 240_000, - // WITHOUT this, Playwright's default webServer teardown is an - // UNCONDITIONAL `SIGKILL` to the whole process group the instant the - // run ends (verified by reading `playwright-core`'s own - // `launchProcess()`/`gracefullyClose()`: `attemptToGracefullyClose()` - // throws when `gracefulShutdown` is unset, and the catch falls straight - // to `process.kill(-pid, "SIGKILL")`) — SIGKILL cannot be caught by any - // handler, so spike-server.mjs's own `SIGTERM`/`SIGINT` cleanup (which - // stops the real Docker container) would never run at all, leaking a - // live container on every single run regardless of how that handler is - // written. `timeout` here is generous because `docker rm -f` on a - // running container has been observed to take a few real seconds under - // this sandbox's amd64-emulated Docker runtime. - gracefulShutdown: { signal: 'SIGTERM', timeout: 30_000 }, - }, - // Isolated Docker-contention flakes verified live in this sandbox (4 - // emulated ClickHouse containers booting simultaneously; whichever row - // boots last, under peak load) must be retried and recorded as flaky, not - // reported as a hard browser-matrix gate failure (issue #585 Phase 0 - // rejection root-cause fix) — `run-matrix.mjs`'s `collectBrowserFailureDetail`/ - // `classifyBrowserMatrixCell` still surface every retry as a distinct - // 'flaky' cell with full detail, so this is a corroborated pass, never a - // silently laundered one. `trace: 'retain-on-failure'` keeps a debuggable - // trace for whichever attempt(s) do fail, without paying trace overhead on - // a clean pass. - retries: 2, - use: { - baseURL: BASE_URL, - trace: 'retain-on-failure', - }, - projects: [ - { name: 'chromium', use: { browserName: 'chromium' } }, - { name: 'webkit', use: { browserName: 'webkit' } }, - ], -}); diff --git a/tests/spike/clickhouse-client/precision-corpus.ts b/tests/spike/clickhouse-client/precision-corpus.ts deleted file mode 100644 index a03296c5..00000000 --- a/tests/spike/clickhouse-client/precision-corpus.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Phase 0 / issue #585, plan §17 — runs each `PrecisionCase` (expected-values.ts) -// through BOTH adapters against a REAL ClickHouse server (no fixture can -// safely claim to reproduce a real server's exact numeric/decimal/date -// serialization — this corpus needs live infrastructure, unlike the -// fault-server-driven deterministic scenarios in `parity.test.ts`). Requires -// `ASB_SPIKE_CH_URL` to point at a reachable server (set by -// `clickhouse-containers.mjs` / `run-matrix.mjs`); callers must check for -// that env var themselves (this module has no side effect at import time). - -import { runCurrent } from './current-adapter.js'; -import { createOfficialConnection, runOfficial, type OfficialConnection } from './official-adapter.js'; -import type { SpikeCredential } from './types.js'; -import type { PrecisionCase } from './expected-values.js'; - -export interface PrecisionCaseResult { - id: string; - category: string; - chType: string; - expected: string | null; - currentValue: unknown; - officialValue: unknown; - currentMatchesExpected: boolean; - officialMatchesExpected: boolean; - currentMatchesOfficial: boolean; - capabilityGated: boolean; - skippedReason?: string; -} - -/** Run one precision case's `SELECT