Skip to content

feat(qwp): add browser and Node.js QWP client - #62

Open
glasstiger wants to merge 365 commits into
mainfrom
ia_node_qwp
Open

feat(qwp): add browser and Node.js QWP client#62
glasstiger wants to merge 365 commits into
mainfrom
ia_node_qwp

Conversation

@glasstiger

@glasstiger glasstiger commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add a complete QWP client surface that works in both browsers and Node.js while leaving the existing ILP transports Node-only.

QWP support ships as a preview: QWP.md documents the compatibility baseline for the first QWP release, and imports from internal source paths are never supported.

Entry points

The repository now builds two published packages from a shared private core, each exposing its complete API from its package root.

Package Runtime Use it for
@questdb/nodejs-client Node.js Existing Sender (including QWP ingress selected with ws::, wss::, or udp::), QWP codecs, egress, TLS, and persistent store-and-forward
@questdb/browser-client Browser Browser-safe QWP ingress, egress, authentication bootstrap, sessions, and codecs

@questdb/nodejs-client keeps the existing Node.js transports and dependencies, so nothing about the current client changes for existing consumers. @questdb/browser-client has no Node.js imports, Node.js typings, Node engine requirement, undici, or ws, so supporting browsers does not require compromising the Node.js build.

Ingress

  • browser-safe QWP codecs and WebSocket sessions
  • high-level Sender integration with fluent rows, batching, byte/interval auto-flush, commits, transactions, and ACK watermarks
  • compiled object-row table writers (sender.writer(table, schema).row({...})) for repeated rows on one schema, with the full QWP column-type set
  • fire-and-forget QWP v1 over IPv4 UDP (udp::, Node-only) behind the same fluent row API
  • automatic symbol-dictionary deltas and server batch-cap splitting
  • durable ACK negotiation, backpressure, reconnect/failover, topology-aware routing, endpoint health, and replay semantics
  • Node-only store-and-forward using the cross-client SFA persistence format, segmented replay, orphan draining, durability policies, bounded memory, and background segment maintenance. Slot locking is pure JavaScript, so a Node.js and a Java client must not use one persistence directory concurrently (see Compatibility)
  • a rejected value discards the row in progress — both its columns and its table selection — so a half-built row can never reach QuestDB

Egress

  • typed SQL binds, cancellation, query deadlines, and automatic credit replenishment
  • zstd negotiation/decompression and bounded result buffering
  • reusable column-major and row-major result views
  • reconnect/failover with server-role validation and pooled concurrent query sessions

Observability

  • immutable metrics snapshots for polling, plus onProgress, onError, and the Java-parity onSenderError rejection stream for event-driven telemetry
  • callbacks run on bounded asynchronous inboxes and never inside ACK, reconnect, or orphan-recovery protocol stacks; overflow is counted in the metrics snapshot
  • unobserved retriable rejections, terminal rejections, and abandoned data are logged by default, so background store-and-forward failures are never silent

API and platform integration

  • unified browser and Node cluster configuration with conflict validation
  • pooled QuestDB facade combining ingress and egress
  • browser authentication bootstrap for qdb_session
  • dual CJS/ESM package exports, public API contract tests, examples, migration guidance, and QWP reference documentation
  • a diagnostic benchmark suite (benchmarks/) covering encoder floors, the high-level sender, egress views, store-and-forward persistence policies, and a live end-to-end lane
  • CI: a Playwright/Chromium job that drives the built browser bundle against a local mock server, and a dormant-by-default dispatch job that queues the Enterprise JavaScript-client E2E pipeline for the exact client commit. Tests requiring a live QuestDB are owned by the server repositories, which hold the topology and authentication fixtures; the browser negotiation and session-auth contracts are covered there by QwpBrowserSessionAuthTest, QwpIngressUpgradeProcessorOnHeadersReadyTest, and QwpEgressMaxBatchRowsTest, plus the Enterprise REST/OIDC login suites

Compatibility

  • Connection strings, options, and authentication behavior of the existing http/https/tcp/tcps senders are unchanged, with the two exceptions below.
  • ILP TCP authentication is fixed on Node.js 26. auth: {keyId, token} supplies only the private scalar, and the JWK was completed with a hardcoded public point unrelated to it. Node.js accepted that inconsistent pair without validating it up to v24 and rejects it from v26 with ERR_CRYPTO_INVALID_JWK, so TCP auth failed outright on that runtime. The point is now derived from the private key. Signing only ever used the private scalar, so signatures, credentials, and auth outcomes are unchanged on every Node.js version; callers passing a complete jwk object were never affected.
  • Nullish column values changed on those senders (Client should skip columns if value is null #28). null and undefined now omit the column, which QuestDB records as NULL; most column methods previously threw a type error. On protocol v2 this also changes the wire bytes for arrayColumn(name, null), which used to emit an explicit NULL-array marker — QuestDB rejects that encoding with ARRAY_INVALID_TYPE (verified against 9.4.3), so omitting it is itself a fix. A row whose every value is nullish now fails when the row is closed rather than at the column call. Code that relied on the throw as a data-quality guard should validate before calling the sender.
  • Browser mode does not provide disk persistence; store-and-forward is Node-only.
  • A Node.js client and a Java client must not use one store-and-forward directory at the same time. The Java client guards slots with flock/LockFileEx; the Node.js client uses a pure-JavaScript directory lock and cannot participate in those kernel locks, so neither sees the other. The persistence format stays cross-client for sequential handoff — a directory written by one runtime can be opened by the other once the first has closed it — and two Node.js processes still exclude each other. Depending on a native addon for kernel locks was the alternative, and it left store-and-forward broken on any platform or Node.js major without a prebuilt binary.
  • Browser-only capabilities are negotiated in protocol messages because browser WebSocket APIs cannot set custom upgrade headers.

Dependencies

  • new runtime dependency: ws (Node WebSocket transport). There is no native dependency; store-and-forward locking is pure JavaScript
  • fzstd is bundled into the build output for egress decompression; THIRD_PARTY_NOTICES.md records its license

Resolved issues

Validation

  • full test suite, including containerized integration coverage: 30 files / 756 tests passed
  • targeted reconnect and SFA interoperability suite: 156 tests passed
  • benchmark self-checks (pnpm vitest run benchmarks): 3 files / 14 tests passed
  • pnpm typecheck
  • pnpm typecheck:qwp-browser
  • pnpm typecheck:test
  • pnpm typecheck:bench
  • pnpm eslint
  • pnpm lint:bench
  • pnpm build
  • pnpm test:dist (loads both built packages through their exports maps): 3 files / 30 tests passed
  • pnpm typecheck:dist
  • pnpm check:packages

Dependencies and provenance

glasstiger and others added 29 commits August 21, 2026 11:28
transmit() awaited readFramePayload outside its try block, and enqueueDrain's
only handler is an unconditional failTerminal. Any store read failure therefore
ended the producer permanently: terminalError is never cleared and
QwpSender.getSession caches its session forever, so every later flush rejected,
the ingress session closed, no reconnect was attempted, and the journalled
frames stayed on disk unreachable in-process. The standalone orphan drainer
excludes the foreground's own slot, so only discarding the sender recovered.

The trigger is transient by the store's own design. A failed segment trim parks
maintenanceFailure, which assertReady raises from readPayload, and a retry
clears it about a second later -- a briefly full or read-only filesystem, or a
restarted maintenance worker. Commit a42fec5 made the store self-heal precisely
so one such failure could not "brick a running store-and-forward producer for
the rest of the process lifetime"; the connection layer still did.

replayInto makes the identical read and its failures reach connectLoop, where
isRetryableReconnectError treats QwpReplayStoreError as retriable and
background store-and-forward retries unbounded. Route the drain-path read the
same way: mark the frame transmitted so replayInto resends it, keep it off the
wire log because nothing reached the wire, and request a reconnect. This is the
technique the batch-cap branch directly above already uses. Deterministic
corruption -- QwpProtocolError, QwpReplayRejectedError -- still escapes and
stays terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…read

transmit() captured the connection, awaited the journal read, then pushed the
frame onto this.wireFrames and sent it on the captured connection. install()
replaces wireFrames wholesale and resets wireFramesBase, and replayInto() skips
a frame that is not yet transmitted, so a reconnect completing inside that
await left the frame written to the dead socket while occupying wire slot n of
the replacement -- the slot its next real frame would take.

translateResponse clamps an ACK to the wire log and indexes it with no
sequence-identity check, so the replacement's first cumulative ACK retired a
frame no server had received: acknowledgedFrameSequence advanced past it, its
journal record was deleted, and QwpSender had already released the staged rows.
No QwpSenderError, no reconnect event, no NACK -- totalFramesSent counted a
frame that never reached a server.

The window is real in the shipped sf_dir configuration: send() drops
frame.payload for a lazy store so every drain reads from disk, and that read
queues behind fsyncing appends on the store's shared FIFO -- measured at 5 to
162ms -- while the default reconnect delay is full jitter over [0,100)ms. The
producing event is an endpoint that accepts the upgrade and then dies with
nothing outstanding: a node restarting, a load balancer recycling sockets.

Re-check the connection and the install generation after the read, and retry
the frame against the current connection when either moved. The existing
coverage only exercised the opposite ordering, where the reconnect starts
before requireConnection() returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
flush(), flushAndGetSequence() and commit() all funnel into
enqueueFlushResult, which is not async and calls throwIfUnavailable() as its
first statement. A sender that is closed, or closing for the duration of
close()'s bounded publish and ACK drain, therefore threw synchronously out of
methods whose signature promises a Promise.

`sender.flush().catch(handler)` does not catch that, so the common shape --
a periodic flush racing shutdown from a timer or event handler -- turns into
an uncaught exception rather than a handled rejection. The sibling members
at(), atNow(), connect(), waitForAcknowledged() and close() are all async and
reject normally, so the surface was inconsistent with itself. The legacy
Sender facade wraps flush() in its own async method, which is why only the
qwp entry points were exposed.

Declare the three methods async. Their bodies contain no await before the
enqueue, so enqueueFlushResult still runs synchronously on the call and flush
ordering is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
connectingCandidate is assigned only after the connection factory resolves,
so close() issued while a connect was still in flight found nothing to cancel
and returned immediately. The socket -- already accepted by the peer but not
yet answered at the HTTP upgrade -- and its opening deadline stayed alive
until that deadline fired: measured at 15s on the defaults, with
getActiveResourcesInfo() still reporting a TCPSocketWrap and a Timeout eight
seconds after close() had resolved, and the process exiting exactly when
authTimeoutMs elapsed.

Give each connect attempt an AbortController, abort it from close(), and
have openQwpWebSocket tear the pending socket down on that signal. Both the
ingress and egress reconnect loops carry the same defect and are fixed
together. The listener is removed once the upgrade settles either way, so a
long-lived signal cannot accumulate listeners across reconnects.

QwpConnectionFactory gains an optional AbortSignal parameter. A factory that
declares no parameters stays assignable, so existing implementations and the
webSocketFactory test hooks are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QwpNodeUdpSession.close() short-circuited on `!this.bound`, and `bound` is set
only inside bind()'s success callback. A bind failure -- EMFILE under fd
exhaustion, EACCES in a restricted sandbox, EADDRNOTAVAIL -- therefore reached
connect()'s cleanup with `bound` still false and skipped socket.close()
entirely. node:dgram does not close the handle itself after a bind error
(verified: the handle is still present 300ms later), so every failed connect()
leaked one descriptor for the process lifetime, and a reconnect loop retrying
after EMFILE compounded the exhaustion it was retrying from.

Close unconditionally and treat an already-closed socket as done, which is what
the Java client's QwpUdpSender.close() does -- it calls channel.close() without
consulting bind state. The other constructor-time failure path was already
safe: setMulticastTTL/setMulticastInterface throw after `bound` is true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QWP.md's ingress table showed a dash for auto_flush_rows and
auto_flush_interval while giving concrete numbers for every sibling key, so a
reader had no way to learn that ws:: applies 1000 rows and 100 ms where http::
applies 75000 and 1000 ms. That is 75x smaller batches and 10x more frequent
time-triggered flushes for a workload migrated on the one-line change the
migration guide recommends, which the behavioral-differences checklist did not
mention either. The values themselves match the Java client, which keeps
separate DEFAULT_WS_AUTO_FLUSH_ROWS and DEFAULT_WS_AUTO_FLUSH_INTERVAL
constants for exactly this reason, so only the documentation was wrong.

Both numbers are now pinned by a test that reads them out of QWP.md and
asserts the sender flushes on those thresholds, so the table cannot drift from
the code again.

Separately, SenderOptions' TSDoc claimed auto_flush_bytes "Defaults to off",
but udp -- the only transport that accepts the key -- defaults it to
max_datagram_size so datagrams flush before outgrowing the limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RESOLVED_QWP_SENDER was a module-private Symbol() that nothing ever assigned:
its only three references are the declaration, the type that keys on it, and
the constructor branch that reads it. Because it is Symbol() rather than
Symbol.for() and is not exported, no caller inside or outside the package can
produce a SenderOptions carrying that key, so the branch was unreachable.

Remove the symbol, the ResolvedQwpSenderOptions type, and the branch. The
remaining ws/wss/udp path is unchanged and is the only way a Sender acquires a
QwpSender.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QwpWriterColumn carried its input type on a `unique symbol` key. bunchee emits
one self-contained bundle per entry point, so each emitted .d.ts re-declared
that symbol, and four nominally distinct keys resulted. The phantom property is
optional, so a column built by './qwp' still satisfied './qwp/node''s
QwpWriterColumn -- it simply never matched its key, leaving no inference site,
so QwpWriterColumnInput fell back to `unknown` and every row field silently
accepted anything.

That is invisible in this repository: importing from `src/` gives all four
entry points one module instance and one symbol, so the in-repo suites and the
public API contract typecheck correctly. Only a consumer resolving through
package.json `exports` sees the separate declaration files, which is every
consumer of the published package -- including the pattern README.md and QWP.md
teach. Wrong-typed rows still threw QwpWriterRowError at runtime, so this cost
compile-time checking rather than data integrity.

Symbol.for() does not help: `declare const x: unique symbol` is nominal per
declaration however the value is obtained. Carry the type on a shared property
name instead, which resolves structurally across bundles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test:dist loads the built bundles but asserts runtime behaviour only, with
`any` throughout its helpers, and no step ran tsc over consumer code at all.
The compiled writers promise per-column row typing that lives entirely in the
emitted .d.ts files, so nothing in CI could observe it: importing from `src/`
gives all four entry points one module instance, which is why `pnpm typecheck`
and the public API contract pass even when the published types are inert.

Add tsconfig.dist-types(.cjs).json, which resolve @questdb/nodejs-client and
its three subpaths to the emitted declarations for both the ESM and CJS emits,
over a consumer fixture that exercises a compiled writer built from every entry
point. Each check is a `@ts-expect-error`, so the gate fails in both
directions: a check that stops firing is reported as an unused directive, which
is exactly what a collapse of the row input type looks like.

Verified against the previous commit's parent: the four value checks report
TS2578 there and pass after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each entry point emits a self-contained bundle, so a class implemented in
src/qwp/** is declared once per bundle. 14 of the 16 classes duplicated between
the qwp and qwp/node bundles carry private members, which makes them nominal
and their declarations mutually incompatible. qwp/node.d.ts compounds it by
re-exporting index's QwpSender wholesale while createQwpNodeSender returns its
own local, unexported one, so the importable type and the returned type differ
whichever subpath a consumer imports from.

Inference is unaffected, which is why nothing caught this: every documented
example writes `const sender = await connectQwpNodeSender(...)`. Only explicit
annotation breaks -- class fields, parameter and return types -- and no
workaround exists, because the correctly-typed declaration is not exported.

Record the defect with @ts-expect-error rather than leaving it latent, and pin
the two shapes that do work so they cannot regress: inference, and the
structural classes with no private members. Collapsing src/qwp/** into one
shared chunk gives each class a single declaration, at which point these
annotations compile and tsc reports the directives as unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bunchee bundles each entry point self-contained, so the QWP implementation was
inlined into ./qwp, ./qwp/node and ./qwp/browser separately. Anything whose
identity depends on its declaration site therefore existed three times, and 14
of the 16 classes duplicated between the bundles carry private members, which
makes them nominal. qwp/node.d.ts compounded it: it re-exports index's
QwpSender wholesale while createQwpNodeSender returns its own local, unexported
one, so `const s: QwpSender = createQwpNodeSender(opts)` failed from every
subpath with no workaround, because the correctly-typed declaration was not
exported anywhere. The same duplication produced the writer-brand defect fixed
earlier; this removes the cause rather than another symptom.

Move the implementation under underscore-prefixed paths, which is bunchee's
shared-module convention, keeping the four entry files where the exports map
expects them. The prefix has to be applied at every level -- _core and
_internal as well as _qwp -- because a directory without it is inlined into
each shared module that imports it, which leaves the duplication in place.

Consumers get smaller graphs, since an entry no longer carries code only its
siblings need: ./qwp/browser 942 -> 510 kB, ./qwp/node 1112 -> 682 kB, the root
ILP entry 1256 -> 826 kB, ./qwp 452 -> 460 kB, and the published runtime total
1747 -> 845 kB.

Two things the layout newly requires. dist/_qwp is outside dist/es and
dist/cjs, so `files` must ship it -- npm pack omitted 132 files without that,
publishing a package whose every entry imports something missing. And the
publish artifact check now follows relative imports out of the exports targets,
since no exports entry names a chunk.

Browser purity is unchanged and verified through the whole chunk graph: 35
modules, none importing a node: builtin or ws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The C8 defect existed because the only failover coverage injected a factory
throw carrying tryNextEndpoint: true -- a shape a real browser WebSocket cannot
produce, since a browser never sees the HTTP response. The unit test added with
the fix drives a bare error event through a fake socket, which is closer but
still an approximation.

Drive real Chromium at a genuinely refused port instead. A probe confirms the
browser's own classification is `kind: "opaque"` with retryable and
tryNextEndpoint both absent, which is exactly the tri-state the sweep reads, so
the endpoint list is walked under the real conditions rather than a modelled
one. Verified load-bearing: reverting the failover guard fails this test.

The asset server is re-rooted at dist/ because the browser bundle now imports
shared chunks from dist/_qwp; serving only dist/es/qwp 403s every entry import,
which broke all eleven browser tests until this was fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GitHub disabled this workflow for repository inactivity, which also
stopped it firing on pull requests, so the PR gates were silently not
running. Manual dispatch is the recovery path that does not require
pushing a commit to a branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lock

fs-ext-extra-prebuilt is a NAN addon, so it needs a fresh binary for every
Node major and ships none past Node 25. Store-and-forward therefore stopped
working entirely on Node 26 rather than degrading.

Ownership now comes from a `.lock.owner` directory: mkdir is the only
exclusive-by-construction filesystem operation available on every supported
platform without a native addon. A kernel lock vanished when its holder died,
so a heartbeat replaces that: the holder refreshes the directory mtime every
5s and a contender reclaims a slot idle for 15s, or immediately when the
recorded PID is gone from the same host. Stale directories are renamed aside
before removal so two contenders cannot both win one slot.

This drops the guarantee that a Java and a Node client exclude each other on
one directory, because Java uses flock/LockFileEx and nothing pure-JS can
participate in those. The persistence format stays cross-client for sequential
handoff; only concurrent cross-runtime access is now unsupported, and QWP.md
states that explicitly. `.lock` and `.lock.pid` are still written so a slot
keeps the on-disk shape Java expects.

QwpReplayStoreUnavailableError is removed: with no optional native module,
nothing can be unavailable. The two tests asserting contention with a real
Java flock are removed rather than inverted, since simulating a flock holder
required the dependency being dropped; stale-reclaim tests replace them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`auth: {keyId, token}` supplies only the private scalar, so the JWK was
completed with a hardcoded x/y that bears no relation to it. Node accepted
that inconsistent pair without checking up to v24; v26 validates it and
raises ERR_CRYPTO_INVALID_JWK, which breaks ILP TCP authentication outright
for anyone on that runtime.

Derive the point with ECDH instead. Callers passing a complete `jwk` were
never affected, and a derived pair is byte-identical to a correct one, so
authentication behaviour is unchanged on every Node version.

The existing auth tests cannot catch this: they pass with the placeholder on
any Node below v26. The added test compares the point against the private key
directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three `against QuestDB` cases duplicated server-side coverage that
already exists, and each server-side version is broader:

  session cookie auth  -> QwpBrowserSessionAuthTest
                          .testSessionCookieAuthenticatesIngressAndEgress,
                          plus missing-session rejection, cookie rotation and
                          the service-account hook, plus the Enterprise
                          REST/OIDC login suites
  durable ACK opt-in   -> QwpIngressUpgradeProcessorOnHeadersReadyTest
                          .testOnHeadersReadyDoesNotSelectBrowserSubprotocol-
                          WhenRegistryDisabled and its three siblings
  version + batch cap  -> testBrowserHandshakeAppendsIngressServerInfo,
                          testOnHeadersReadyAdvertisesEffectiveBatchSize,
                          QwpEgressMaxBatchRowsTest

Tests needing a live database belong in the repositories that own the
topology and authentication fixtures. What is left here is the eight cases
that drive the built browser bundle in real Chromium against a local mock
server - the client-side half of the same negotiation paths, and the part no
Java suite can cover because it does not run JavaScript.

The job no longer pulls a container: it drops from ~16s to ~2s, stops
depending on questdb/questdb:nightly, and is renamed since it is no longer an
end-to-end suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pnpm typecheck` included only `src` and one contract file, so nothing
type-checked test/** at all. vitest strips types with esbuild rather than
checking them, so a test could reference a deleted export and still pass -
which is exactly how a dangling import of a removed error class survived
every gate in this branch.

`tsconfig.test.json` follows the tsconfig.bench.json pattern rather than
widening the base config, which bunchee also reads.

Turning it on surfaced 22 errors. Two were real:

  QwpNodeOrphanDrainSession was not exported from src/qwp/node, but the
  public QwpNodeOrphanDrainerOptions.createSession returns it, so nobody
  outside this package could implement that interface. Now exported and
  pinned in the type-position contract.

  session.test.ts passed `reconnect` in connectQwpBrowserIngress's first
  argument, where it is not a valid key. It was silently dropped and the test
  ran on the default backoff rather than the zero backoff it asked for.

The rest were test-local: helper parameters whose types were inferred as
narrow literals from their default values, header lookups typed `string`
where node returns `string | string[]`, a listener returning Array.push's
number, node's Blob requiring its sources argument, a duplicated named
import, a type argument on toMatchObject, and two `satisfies Partial<T>`
that did not account for toMatchObject matching nested objects partially.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test/dist-types imports the package by its published name, which resolves
only against a built dist/. tsconfig.test.json included all of test/, so
those files were type-checked before any build ran and failed with TS2307.

It passed locally only because a dist/ from an earlier build was still
present; verified now by removing dist/ first, which reproduces CI.

Those files already belong to tsconfig.dist-types*.json, which typecheck:dist
runs after pnpm build, so excluding them moves no coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `Check for build artifacts` step could never run. Its body was a
single-quoted `node -e '...'` containing a regex character class `['\"]`,
and a YAML block scalar performs no escape processing, so three literal
single quotes reached the shell. That closes the `node -e` argument at a
bare `(`: bash, sh, dash and zsh all fail to parse it, exit 2, and the
`Publish` step that follows never runs.

Behind that sat a second failure. The walk matched `from "./x"` anywhere
in the raw text of an emitted file, including inside a comment the
bundler preserved. src/_qwp/writer.ts explains the writer column brand
with the sentence "a schema built with the factories from './qwp' would
be rejected by the writer() of a sender imported from './qwp/node'",
which the pattern read as two imports and reported as four missing
artifacts, exiting 1 against a clean build.

Move the script to scripts/check-build-artifacts.mjs, where it needs no
shell quoting, and anchor the pattern to specifiers that name an emitted
file so prose cannot look like an import. Also run it in build.yml: the
gate existing only in the release workflow is why neither failure was
visible on a pull request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A multi-address Node client closed while it was reconnecting terminated
the host process with an uncaught exception and exit code 1.

close() aborts the shared connect AbortSignal. The endpoint being
negotiated rejects with QwpSendClosedError, which is not a
QwpUpgradeError, so the failover sweep in createQwpFailoverConnectionFactory
does not stop: it moves to the next endpoint carrying the same, now
aborted signal. openQwpWebSocket saw signal.aborted, closed the socket
and returned early -- but its open/message/error/close listeners are
attached at the very end of the executor, so that socket had none. `ws`
answers close() on a CONNECTING socket by emitting `error` on a later
tick, and an EventEmitter with no `error` listener rethrows into the
process. A catch around close() cannot stop it: the throw arrives after
close() has already resolved.

Record the pre-aborted signal instead and apply it after the listeners
are attached, so the close it triggers has a subscriber and the promise
rejects the way every other failure does. Also attach a throwaway error
listener before the timeout-validation teardown, which tears down a
CONNECTING socket that nothing has subscribed to yet for the same
reason.

Reproduced 10/10 before the fix and 0/25 after, driving the built ESM
bundle from a fresh child process and reading the raw exit code. The
regression test asserts the ordering directly rather than the crash,
because `ws` defers the emit and a synchronous throw would only be
swallowed by closeSocket()'s own try/catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The slot lock identified an acquisition by pathname and mtime, both of
which are reused the instant a lock changes hands. Two consequences,
each reproduced through QwpNodeFileReplayStore against real directories.

A release removed the owner directory by path. Because a failed release
is parked on a module-global list and retried before the next
acquisition of any lock in the process, a stalled holder that later
opened an unrelated journal deleted the owner directory of whichever
process held that first pathname by then. A fourth process could then
open a journal a third was already appending to. Observed: owner inode
1414684522 -> null, then "T opened slotA while R holds it".

Staleness fell back to the `.lock.pid` sidecar when the owner record
could not be read, and stamped it with the local hostname. The sidecar
deliberately outlives its holder for Java parity, so it always names a
process that has exited, and the fabricated hostname satisfied the
same-host guard that was supposed to make a foreign PID meaningless.
Every acquisition is briefly recordless, between its mkdir and its
record write, so a contender arriving in that window judged a directory
that had just been created stale and renamed it away from its live
owner. The comment on that fallback already said a sidecar "can only
expire by mtime"; the code did not.

Write a per-acquisition token into the owner record, verify it before
removing anything, and let a recordless directory expire by mtime alone
as intended. Deterministic before/after on the mid-acquisition state:
"ACQUIRED (stole it), ownerDir replaced" becomes "refused:
QwpReplayStoreLockedError, ownerDir intact", with an owner record naming
a live PID still refused in both.

This does not change the documented mtime reclaim of a genuinely stale
slot, which is covered separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A store-and-forward holder that stopped heartbeating kept appending to
its journal after another process had taken the slot over, destroying
frames the new owner had already durably appended.

Nothing on the write path consulted the lock. `compromised` was read in
exactly two places, both inside advisory-lock.ts, so the reclaim was
visible only at close() -- long after the damage. appendOnce() writes at
SEGMENT_HEADER_SIZE + logicalSize through an already-open handle, so the
resumed holder wrote at offsets the new owner had moved past, and a
frame's sequence is derived from its position in the segment: a
same-width overwrite leaves a journal that reopens with contiguous
sequences, valid CRCs, no torn tail, and no data-loss report.

Three changes make the loss impossible:

  - assertReady() -- the chokepoint every mutating path already routes
    through -- fails with the new QwpReplayStoreLockLostError once the
    slot lock can no longer be vouched for.
  - The heartbeat treats ENOENT as proof of loss. It previously waited
    for a drifted mtime, which a removed directory can never produce, so
    a lock whose directory was simply deleted was never noticed at all.
    It also compares the acquisition token, because an mtime cannot
    separate our directory from a replacement made inside the same clock
    tick, and some filesystems only report whole seconds.
  - Ownership expires on elapsed time, not only on the heartbeat firing.
    The heartbeat is a timer, so the very block that loses the lock also
    stops the timer that would notice; the first write after resuming
    landed before it could run. This is conservative by design: the
    holder gives up as soon as a contender could have taken the slot.

Two processes, real directories, a 20s main-thread block. Before: the
resumed holder's five appends all resolved, close() was clean, no
callback fired, and the byte census read A=1280 B=0 -- every one of the
new owner's durable frames gone. After: all five appends fail with
QwpReplayStoreLockLostError and the census reads A=640 B=640.

QWP.md documented the reclaim but not what the reclaimed holder then did
with its open handle, and claimed a second process cannot mutate journal
contents. Both paragraphs now describe the actual contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Omitting a nullish column took the rest of the call's validation with
it. The guard added for issue #28 is the first statement of all 13 ILP
setters, so a null or undefined value returned before the column name,
the row state, or the decimal scale had been looked at.

The result was a diagnosis that depended on the data rather than on the
code: the same call site raised on rows that carried a value and stayed
silent on rows that did not. A misspelled name, a name over
max_name_len, a symbol placed after a column, or an out-of-range decimal
scale could therefore first surface in production, on whichever row
happened to be populated. On a sender with max_name_len=5 this whole
sequence threw nothing and flushed "t i=1i\n":

    .decimalColumn(12345, null, 999)
    .arrayColumn("bad?name", null)
    .stringColumn("wayTooLongForMaxNameLen5", undefined)
    .symbol(123, null)
    .intColumn("i", 1)

Move the value-independent checks into validateColumnCall() and
validateSymbolCall(), run them ahead of the nullish guard, and drop them
from writeColumn(), whose callers are exactly these setters -- so the
name is still scanned once per cell, not twice.

The reported error is now the real defect rather than the incidental
one. Where this sequence previously reported "Column value must be of
type string, received undefined", it reports "Column name is too long,
max length is 5".

Nullish values still omit the column, and a value the negotiated
protocol version cannot represent is still skipped rather than
rejected, which is the behaviour the suite already pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README states that passing null or undefined omits the column "on both
the ILP and QWP senders, and to the compiled QWP writers". That held for
21 of the 22 QWP column methods. long256Column was the exception: its
four value parameters were plain bigint, so BigInt.asIntN() raised
"Cannot convert null to a BigInt", failRow() discarded the row, and a
plain-JavaScript caller mapping an optional field onto it got a raw
TypeError where every sibling method omits the column. TypeScript
callers were spared, which is why no suite noticed.

Accept nullish words. A LONG256 is one value spread over four
arguments, so "no value" means all four are absent; that omits the
column. A partial set is a caller mistake rather than a NULL and now
says so, instead of failing inside BigInt conversion.

Also correct the one other place the shared documentation overstated
the rule. Sender.decimalColumn's TSDoc said "An empty array represents
the NULL value" for both backends, but the ILP buffers write an explicit
NULL decimal field for an empty Int8Array while the QWP sender omits the
column, as it does for null. Both land as NULL for a column that already
exists -- verified against QuestDB 9.4.3 and 10.0.1-nightly -- but the
encodings differ and the TSDoc now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways a SYMBOL column could reach QuestDB as empty strings, with the
frame acknowledged OK.

encodeQwpIngressFrame() accepts a bare numeric dictionary ID for a
SYMBOL value: symbolId() has an explicit branch for it. Its non-delta
twin, symbolText(), had none. It read `.text` off the number, got
undefined, and TextEncoder encodes undefined as zero bytes -- so every
distinct symbol in the frame collapsed into a single empty-string inline
dictionary entry with all rows indexing it. The non-delta encoder builds
its dictionary out of the texts, so there is genuinely nothing to
resolve an ID against; say so rather than emitting a frame that looks
valid. The delta path, which is handed the dictionary that gives IDs
meaning, is unchanged.

QwpNodeUdpSession.sendTables() takes QwpIngressEncodeOptions but
encodeUdpDatagrams() dropped the argument and hardcoded
`{ gorilla: false }`. A caller who correctly supplied a delta dictionary
had it silently ignored and fell into exactly the case above. A datagram
has to decode on its own, so a connection-scoped dictionary cannot apply
to one: reject `dictionary` and `confirmedMaxSymbolId` instead of
accepting and discarding them, and pass `gorilla` through rather than
ignoring that too.

The high-level QwpSender coerces symbol values with String(), so it was
never affected; this is the low-level `./qwp` surface that QWP.md
documents for advanced integrations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The egress RESULT_BATCH decoder bounded the table- and column-name wire
fields with QWP_MAX_TABLE_NAME_LENGTH and QWP_MAX_COLUMN_NAME_LENGTH.
Those limits are UTF-16 code-unit counts -- the unit Java's TableUtils
measures in, which identifiers.ts mirrors on the ingress side -- but the
wire field they were compared against is a UTF-8 byte count.

So this client could encode identifiers it was then unable to read back.
No configuration was needed: at the default limit of 127, a name of 64
accented characters is 64 code units and 128 bytes. Ingress accepted it,
and a later query carrying that name failed with "column name length out
of range: 128". That QwpProtocolError is routed to recoverProtocolFailure,
which replays the same query on a replacement connection, so it
reproduced on every endpoint and ended in QwpReconnectExhaustedError with
the whole failover set deprioritized.

Bound the decode by QWP_MAX_IDENTIFIER_BYTES instead: the same limit
expressed as the widest UTF-8 encoding of a maximum-length identifier,
three bytes per code unit. The allocation stays bounded, which is what
the cap is for, and every identifier the encoder can legally produce now
decodes.

Also record the unit in the QWP.md `max_name_len` row, since a limit
whose unit is unstated is what allowed the two sides to drift apart.

Note that `max_name_len` still has no upper bound, for servers configured
with a larger cairo.max.file.name.length; setting it above the protocol
identifier limit remains the operator's business to match to their
server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways the three new subpaths were published without being usable.

typedoc.json listed only ./src/index.ts, which exports none of the QWP
surface, so the generated API reference -- the site GitHub Pages serves
from docs/ on main, and the package homepage -- documented 1 of the 324
symbols the QWP entry points export, and that one was a Sender.symbol
name collision. Typedoc had been reporting it all along as "referenced
by ... but not included in the documentation". Adding the three entry
points takes the output from 17 pages to 349, with a module page each
for qwp, qwp/browser and qwp/node.

TypeScript's node10 resolution ignores `exports`, and `module:
"commonjs"` implies node10 unless moduleResolution is set explicitly --
which is what `tsc --init` still emits. Such a consumer got TS2307 for
all three documented imports while the same imports worked at runtime.
Declaring them in `typesVersions` as well fixes it: verified against a
real `npm pack` install, three TS2307 errors before and none after,
across node10, node16, nodenext and bundler.

The build-artifact check now walks the typesVersions targets too, since
a missing one breaks compilation in a way no runtime suite can see. Note
that this proves the files exist, not that they resolve -- both
tsconfig.dist-types*.json use explicit `paths`, which bypasses module
resolution entirely, and that is why neither caught this. A real guard
needs a packed install.

docs/ itself is not regenerated here; it is refreshed at release, and is
already a version behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two halves of one lifecycle gap.

close() could not cancel a first connect. The reconnect loop owns an
AbortController, but the initial attempt bypasses it -- it is either
handed to QwpReconnectingIngressConnection as `initialConnection` or
awaited directly -- and both were built by calling the factory with no
signal. closeNow() could therefore only attach `.then(c => c.close())`
to the pending promise, so the socket and its opening deadline outlived
close() by the full connect/auth timeout. A CLI, serverless or test
process that closed a sender and expected to exit hung for up to that
long. Measured against a peer that accepts TCP and never answers the
upgrade: close() returned at ~305ms and the process exited at 20008ms.

QwpSenderSessionFactory now takes an optional AbortSignal, mirroring
QwpConnectionFactory (whose doc comment already notes that factories
ignoring the parameter stay assignable), and the sender aborts it when a
close finds a connect still in flight. Same probe: exit at 309ms.

close() also bounds its own flush with a deadline but cannot cancel it,
so an abandoned close flush stayed runnable. getSession() clears
sessionPromise when a connect fails, so that leftover flush reached the
cleared field, dialled the database again and wrote rows after close()
had already returned -- an application closing a sender to stop writing
kept writing. Through a proxy that swallows the first connection, the
server received an ingress frame 366ms after close() rejected with
QwpSenderCloseTimeoutError; it now receives none.

The new guard is keyed on `closed`, not `closing`: close() is documented
to publish completed rows, and doing that legitimately needs a session
even when none was opened yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The journal's exclusion, reclaim and release rules are entirely about
what separate processes observe of each other, and nothing tested them
that way. Every existing lock test runs two stores in one process, where
they share a module-global pending-release list, one event loop and
every advisory-lock object -- so the mechanisms could be observed in
isolation but never the contract. The closest existing test,
"arbitrates acquisition over stale Java lock metadata", also builds its
directory with mkdtemp, so the parent has no .slot-locks/ and no
.lock.pid from an earlier producer: the state a contender actually meets
in production is structurally unreachable there.

Adds a suite that forks real producers against the built package, since
that is what a deployed process runs. It covers exclusion under
contention from a used parent, a live heartbeating holder refusing a
contender, adoption of a SIGKILLed producer's slot with its frames
recovered, a reclaimed holder refusing to write, and a stalled holder's
release leaving a live lock alone.

Two of the five fail against the code before the lock fixes; the other
three passed already and are labelled in the file as contract tests
rather than regression tests. Staleness is produced by backdating the
owner directory's mtime rather than by waiting out the 15s window, which
is the same on-disk state a paused holder leaves and keeps the suite to
about 21 seconds.

It runs under `pnpm test:dist`, which build.yml already executes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
glasstiger and others added 16 commits September 8, 2026 12:59
authTimeoutMs inherits connectTimeoutMs when it is not set, deliberately, so
that narrowing only the connect deadline is not exceeded 75x by a default
nobody chose when a peer accepts TCP and never answers the upgrade. Three
places described the two budgets as independent instead:
QwpNodeWebSocketOptions.authTimeoutMs said "Defaults to 15s", and two QWP.md
passages said each deadline "independently" bounds its phase and that an
attempt can take up to their sum.

Measured, connectTimeoutMs=300 alone fails the upgrade at ~308ms, not at the
600ms those passages imply, so an operator narrowing connect_timeout to fail
fast on DNS was also narrowing the upgrade deadline without being told.

The behaviour is right and is unchanged; the connect_timeout table row and
the connection-lifecycle section already described it correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
Three files this branch added carried formatting Prettier rewrites:
ingress-session.ts's acknowledgementFailure signature, two statements in
core.test.ts, and a decimal-scale assertion in sender.buffer.test.ts.

`pnpm format` writes but nothing verified the result, which is how the drift
survived into files no other gate reads. Add `pnpm format:check` over the
same glob `pnpm format` writes, so the two cannot disagree, and run it in
build.yml beside eslint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
GitHub Pages serves main:/docs directly and no workflow regenerates it, so
the committed tree is the published site. It was last rebuilt before roughly
twenty commits that touched packages/*/src, leaving nine pages missing --
among them QwpIngressAckAbandonedError, QwpMemoryReplayBatchTooLargeError and
QwpReplayStoreLockUnprovableError, which test/qwp/public-api.test.ts names as
required public exports and which callers have to catch by name.

Regenerated after the fixes in this branch, so it also carries the corrected
authTimeoutMs and backpressurePolicy documentation. All 204 runtime exports
of @questdb/nodejs-client now have a page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
…verdicts

Three defects in the Node store-and-forward journal, all found by executing
against real directories rather than by reading.

Two worker threads sharing one `sf_dir` both took its advisory lock, and the
second reclaimed the first's live owner directory. `PROCESS_INSTANCE` is minted
per module registry, and every worker loads its own copy of the module, so a
live sibling presented the same shape a dead predecessor does: our PID, alive,
with an instance we did not mint. `isReusedPid` treated that as proof. It is
only a necessary condition now; the heartbeat is the proof, so a same-PID
successor waits out one staleness window instead of adopting the slot at once.
Measured before the fix: 60 successful flush() calls, then a successor's
recovery found 41 records with all 20 of the second writer's gone, two
producers sharing one frame-sequence space, 14 frames queued for duplicate
replay, and one symbol ID resolving to two different strings.

A load that failed unwound in a finally that could throw, so a fault in the
teardown -- EMFILE, EIO, an NFS ESTALE, the class
QwpReplayStoreLockUnprovableError exists for -- replaced the recovery verdict.
isQuarantinableReplayRecoveryError then said no, and the corrupt slot was never
moved aside, so the producer could not start on any later restart. The unwind
now runs for its side effects only, the way close() already handles its own.

Recovery stamped MANIFEST_REQUIRED_FLAG onto every surviving segment, including
the empty active one it had just proved carries no flag -- and that flag is the
entire basis for reading "empty" as "records were lost". The next restart read
back evidence recovery had forged and reported a data loss that never happened.
The stamp now belongs to the next append, one syscall before the first record,
as it already does after activateHotSpare.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
Two opposite misclassifications in the same loop, both reachable from a
documented connect string.

A permanently invalid option was retried. Option validation runs inside the
per-attempt connection callback, so the loop met a bad `connectTimeoutMs`, an
out-of-range `maxVersion`, endpoint userinfo or an Authorization conflict the
way it meets a refused connection: the classifier retries anything carrying no
structural `retryable` flag. Measured on the shipped ingress defaults: 125
attempts over 300 seconds, no log output for the whole window, and then a
generic QwpReconnectExhaustedError whose cause named the elapsed deadline
rather than the option -- the diagnosis was unrecoverable from the thrown
error, its causes and its stacks. Under `lazy_connect` it never ends at all:
connect() resolves, rows accumulate, nothing is ever sent. Retrying cannot fix
an option, so these now say so and surface in one attempt, as the equivalent
connect-string spelling already did.

A transient rejection latched a running producer terminal. The retry-forever
exemption for endpoint policy failures was gated on having connected once, so
the first attempt of a deferred-connect sender went terminal on any upgrade
rejection -- including a 404, which the connector itself marks
`tryNextEndpoint` because one node returns it mid-deploy while its peers are
healthy, and which the orphan drainer already treats as transient. connect()
had resolved and the first flush() had been accepted by then, so this ended a
running producer, and under the documented `lazy_connect` default of memory
replay it dropped the frames with it. Only a rejection the whole cluster would
repeat stays terminal now; 401 and 403 are unchanged, and their pinning test
still passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
QwpUpgradeError.url and QwpFailoverError.attempts[] strip userinfo from an
endpoint before a caller can log it, and test/qwp/session.test.ts pins that.
The reconnect events carrying the identical string did not: connected,
reconnecting, reconnected, failed-over, attempt-failed and the egress
onReplayReset all passed the factory's URL through verbatim, and the documented
way to use an event sink is to log the whole event. A credential therefore
reached the browser console and any telemetry behind it -- a channel with a
different retention and access profile from the application's own config.

The Node entry point rejects userinfo outright, so its own transports could not
produce this. The browser package has no such guard, and a caller-supplied
QwpConnectionFactory reaches it on either runtime -- which is the exact case
the redaction helper's own comment says it exists for. Verified in real
Chromium: a credentialed ws:// URL constructs, keeps its userinfo in .url, and
opens.

Both dispatchers now redact at emitEvent, so every event kind is covered by
construction rather than per call site. The helper moved to _internal/ because
qwp/index.ts re-exports transport.ts wholesale, and an internal fix should not
grow the published surface of either package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
…atic path

Two validations ran only when a QWP sender was built from a connection string,
so the same configuration written as a plain options object was accepted and
quietly ignored. `validateUdpSecurityOptions` was already applied on both
paths; these two were the ones left behind.

The six ILP-only keys -- init_buf_size, max_buf_size, request_timeout,
request_min_throughput, retry_timeout, stdlib_http -- are rejected by `udp::`
with a precise diagnostic, and by `ws::`/`wss::` because the QWP schema knows
no such key. `new Sender({protocol: 'udp', ..., max_buf_size})` took them and
dropped them: a caller capping memory got no cap and no warning, which is the
exact condition the connect-string check was written for. The check now covers
ws/wss as well as UDP and runs on both construction paths.

The programmatic `wss` path also accepted a root CA together with verification
disabled, leaving the CA inert and the connection unverified where the
documented connect string calls that combination an error, and it read the file
with a bare readFileSync -- so a path to something that is not a PEM bundle was
installed as a trust store and only failed later as an opaque TLS error at
connect time. Both now follow the connect string, and the PEM diagnostic names
whichever key the caller actually wrote. The ILP https/tcps transports are
untouched; this is the new wss surface only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
activateHotSpare() writes a segment's base sequence, fsyncs the manifest
that names it, and only then stamps the manifest-required flag. A crash or
a write fault in that window leaves a real segment on disk carrying a
non-zero base, no records and no flag, and recovery is required to retain
exactly that shape rather than forge the flag it did not find.

Retaining the base was the problem. Once the journal has drained -- the
steady state of a producer that is keeping up -- the ACK watermark is gone
and a reconnecting transport restarts its frame numbering at zero, so the
retained base belonged to a numbering nothing else remembered. The segment
contiguity check in appendOnce() then rejected every frame the producer
offered, non-retryably and identically after every restart, while load()
went on resolving successfully and reporting no data loss. Nothing healed
it: quarantine only fires on a corrupt load, and the orphan drainer skips
both live slots and flagless empty segments. The producer kept accepting
rows that could never be journalled.

An empty recovery now retires that segment with its manifest. Every
segment surviving the scan carries live records except the single empty
active one recovery may retain, so no recovered entries means the segment
is provably record-free -- that is what retaining it means -- and it holds
no manifest-required flag, so retiring it drops the stale origin without
discarding a frame or forging the evidence the flag stands for. Re-basing
it in place is not the alternative: the manifest pins each segment's base
and writeManifest() refuses to move a boundary backwards, so a rewritten
base would fail the next load as corruption.

Already-wedged directories recover on their next open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
QwpNotificationDispatcher waits for an `async` handler's promise before it
dispatches the next notification, so a slow observer applies backpressure
to its inbox and loses the oldest entries. Four of its call sites hand it
the user callback directly and get that. The two inside QwpIngressSession
queued thunks that called safelyInvoke(), which returned void, so the
dispatcher saw every notification finish immediately and re-entered the
observer once per drain turn.

An `async` onProgress, onError or onSenderError therefore accumulated
concurrent copies -- eight frames produced eight live observers in a test
that expects one -- while droppedProgressNotifications and
droppedErrorNotifications stayed at zero. QWP.md tells operators to read a
non-zero value there as "an observer is not keeping up", so the published
health signal read healthy precisely when the observer was furthest
behind. The same onSenderError callback was serialized when it reached the
orphan drainer's inbox and not when it reached this one, making the
behaviour depend on the transport rather than on the user's code.

safelyInvoke() now returns the contained promise. It is the already-handled
one, so it never rejects and the many callers that ignore it keep exactly
the containment they had. The error path builds up to two observers per
notification, so it waits for both rather than reporting one and leaving
the other to run underneath the next notification.

Left alone: the orphan-recovery wrapper in qwp.ts, which discards the
promise deliberately and says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
Two ways the shared ws::/wss:: vocabulary reached a Sender with the wrong
scope attached.

The failover keys. QWP.md scopes `failover`, `failover_max_attempts` and
the three failover backoff/duration keys to egress, and
parseEgressReconnect() is their only reader, so they resolve into
`egressSession` -- the section a Sender discards, and the very one
`initial_credit` lands in. They were nevertheless left out of the
ignored-key warning on the premise that ingress honours them. Ingress
honours the other keys named in that premise; it reads none of these. So a
Sender handed a cluster string that tuned failover applied none of it
without a word, while the pool key beside it warned, and `failover=off`
did not disable ingress endpoint sweeping either, because that sweeping is
unconditional. They now warn with the rest of the egress section.

The closing handshake. Opening runs under two deadlines and authTimeoutMs
already inherits an explicit connectTimeoutMs, so a caller who narrowed
the connect budget is not held for the 15s default when a peer accepts TCP
and never answers. Closing is a handshake too, and a peer that accepted
the upgrade and then stopped reading never answers the close frame, so
close() ran to the full closeTimeoutMs default. The pool's shutdown
deadline does not bound it -- the await that fires terminate() runs before
that deadline is consumed -- and closeTimeoutMs has no configuration-string
key, so acquire_timeout_ms, the only shutdown budget a connect string
offers, governed a borrowed slot and not an idle one. A caller asking for
200ms waited 15s. closeTimeoutMs now inherits an explicit connectTimeoutMs
the same way; set it to decouple them.

Neither default changes when nothing is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
`addr` is host[:port], so userinfo in it is always a rejected value -- but
rejecting it interpolated the whole entry into the thrown message, and a
connect string is parsed at startup, where that message is exactly what a
caller's configuration logging writes out. `ws::addr=admin:s3cr3t@host:9000`
produced "Invalid QWP cluster address: 'admin:s3cr3t@host:9000'", through
parseQwpNodeClientConfig() and Sender.fromConfig() alike. One of the seven
rejection sites even tests for userinfo before throwing, so it identified
the credential and printed it anyway.

The rest of the client already follows the opposite rule, three times over:
redactQwpEndpoint() for endpoints reaching failover errors and reconnect
events, redactedUrlText() for the browser bootstrap's own validation errors
on caller-supplied endpoints, and endpointUserinfo(), which reports "a
password" rather than the value. Even this file refuses a colon-bearing
username without echoing it.

Every address rejection now drops what precedes the last `@`, which cannot
appear in a valid entry, and keeps the host and port that make the error
actionable. The port rejection below them is reached only after a
digits-only test, so it can carry nothing secret and is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
…ds back

QWP.md documents `acknowledgement` as optional: await `publication` before
releasing retryable source rows, and `acknowledgement` only when server
acceptance is also required. The session rejects it anyway, from two paths a
caller who followed that sentence never asked about -- the ACK deadline in
startFrameWithPublication(), and rejectAll() in closeNow(). Under Node's
default unhandled-rejection mode an unobserved rejection terminates the
process, so the documented pattern killed the producer roughly ackTimeoutMs
into any outage, and again on close() with a frame still in flight. An
onError observer does not prevent it; the rejection is a different promise.

QwpSender never hit this because it already applies the containment to its own
use of these methods, and the durable-ACK poll does the same. The public API
was the one left holding an unobserved promise.

Attaching a handler settles Node's tracking without consuming anything: the
same promise is returned, so a caller who does await it still receives the
rejection, and both rejecting paths keep reporting through recordError() and
the onError observer. All three returned acknowledgements need it -- the
per-frame one, the split-batch aggregate, and the delta aggregate, the last of
which also carries the publication rejection. The two aggregates build fresh
promises with Promise.all(), so the per-frame containment does not reach them.

Reproduced before the fix against a server that accepts the upgrade and never
answers: `sendFrameWithPublication()` then `await result.publication` exits 1
at the ACK deadline, and again on `close()`. Both exit 0 now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
… zero

`queryFlags` is typed `number | bigint`, and the trailing flags varint is
appended only when it is non-zero. The test was `(request.queryFlags ?? 0) !== 0`,
which is true for `0n`, so the two spellings of "no flags" produced two
different frames: 0 and an omitted field appended nothing, 0n appended a
trailing 0x00. One declared value, two encodings.

encodeQwpQueryRequest() is a public export of both packages, reached through
`export * from "../_qwp/_core"`. Whether QuestDB tolerates the extra byte is
not the point -- it may well read it as zero -- but a caller accumulating
flags as bigints should not have to know which zero to write.

The session path never hit it: encodeQueryRequest() passes
QWP_QUERY_FLAG_RESET_DICTIONARY or undefined, both numbers.

Rejecting both spellings keeps writeQwpVarint() as the validator for
everything else, so a negative, non-integer or out-of-uint64 value still
raises the same RangeError it always did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
…e programmatic path

Three keys were taken and dropped when a QWP sender was built from an options
object rather than a connection string. A ws::/wss:: string is parsed by the
QWP schema, which rejects each of them with a relocation hint, and a udp::
string is rejected by parseProtocolVersion(); neither parser runs on the
programmatic path, and nothing downstream reads the keys.

  new Sender({protocol: 'ws', ..., max_datagram_size: 1400})  -- accepted, unread
  new Sender({protocol: 'ws', ..., multicast_ttl: 1})         -- accepted, unread
  new Sender({protocol: 'ws', ..., protocol_version: '2'})    -- accepted, unread
  new Sender({protocol: 'udp', ..., protocol_version: '2'})   -- accepted, unread

This is the gap bee2458 closed for the six ILP-only keys, in the same
validator, left open for the transport-scoped ones. The TSDoc for
max_datagram_size and multicast_ttl already claimed ws/wss reject them, so the
documentation described the connect string and not the API.

parseProtocolVersion() also stopped defaulting ws/wss to protocol version '1'.
It fell through to the ILP branch and stamped a value nothing then read; it now
rejects an explicit one and returns, the way it already did for udp.

The ws/wss half of bee2458's own check had no test -- removing the
validateQwpUnsupportedOptions() call from createConfiguredQwpSender() left the
suite green -- so the new case covers that too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
…verflows

writeColumn() sets hasColumnCall after its own checkCapacity(), then restores
it in the catch if the value encoder throws. Only the first half was tested.
"keeps the symbol section open when a column call overflows the buffer" stops
at the outer checkCapacity(), which rejects before the flag is set, and
"leaves no partial cell behind when a column value overflows the buffer" does
reach the catch but asserts only the position -- and its preceding intColumn()
has already set the flag, so restoring it changes nothing there.

Removing `this.hasColumnCall = hadColumnCall` therefore left all 74 buffer
tests green, while a caller who handled the overflow and fell back to a symbol
got a second, unrelated "Symbol can be added only after table name is set and
before any column added" on a row where nothing had been written -- a call base
accepted, since base never set hasColumns either.

The new case is the first column on the row, with a short name so the
separator and name fit and the value encoder is the thing that throws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
…pplies, and the v3 scale change

Three gaps between what the packages do and what a reader can find out.

The error table listed 25 of the 45 exported error classes, while QWP.md's own
public API policy says the compatibility contract covers "errors". Eight of the
missing ones appeared in neither the table nor public-api-contract.ts --
QwpSendClosedError among them, constructed on 39 paths including the browser
entry point, and QwpIngressSessionClosedError, which is what an abandoned
acknowledgement rejects with. A consumer writing catch policy from this
document had no entry for errors the client really throws, and renaming one
would have failed no gate. All 45 are now listed and contracted, and a test
keeps the table exact in both directions -- it found the 45th,
QwpBrowserSessionBootstrapError, which is exported from the browser package
only and which the manual sweep had missed.

Twelve keys showed "-" in the Default column while the code applied a concrete
value: initial_credit 0, buffer_pool_size 4, the four pool bounds, the four
pool timeouts, and the two inbox capacities. The three reconnect_* keys and the
four failover_* keys had no default documented either; the reconnect ones
differ by side, so they now carry both, ingress first. A test pins each
documented number to its source constant, reading the pool bounds back from a
default client's own metrics because those constants are module-private.
max_batch_rows, compression_level, client_id, the credentials and the rest keep
their "-": they genuinely have no client-side default.

The v3 decimalColumn() scale check tightened in this PR and was not in the
migration notes. `scale` is typed `number` and a non-integer one used to reach
Buffer.writeInt8, which coerces rather than rejects, so 2.5 was written as
scale 2 and NaN as scale 0 -- the row went out with a scale the caller never
asked for. Rejecting it is the fix, but it turns a previously accepted,
type-valid call into a throw, so it belongs beside the nullish note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
Comment thread test/sender.transport.test.ts
glasstiger and others added 13 commits September 8, 2026 23:48
The action scans a sliding 30-commit window of the pull request, so as
commits land the window moves. It has now reached the TCP auth test that
spells out a second P-256 scalar, which `generic-api-key` scores at 5.05
entropy; the previous push passed only because its window stopped short
of that commit.

Editing the test cannot fix it. The scan reads `git log` patches, so the
value stays in the diff of the commit that introduced it however the file
looks today. Add a config instead: extend the default rules, and allowlist
the exact sample values the tests, examples, and README have to spell out.
Every one is a placeholder or the published documentation keypair, handed
to a mock server inside the test process.

Allowlisting by value rather than by rule or path keeps the rest armed --
a genuinely new secret written in the same `{ keyId, token }` shape is
still reported.

Verified against gitleaks 8.24.3, the version the action pins: the window
that failed and all 351 commits of the branch now report no leaks, and a
planted secret in that same shape is still caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017dABiQabtxr6yL4Ufp7RaS
The browser client decided durable acknowledgements from socket.protocol
alone, and raised QwpDurableAckUnavailableError when the server had not
echoed questdb.qwp.durable-ack.v1. That check cannot run in a browser:
the WHATWG "establish a WebSocket connection" algorithm fails the
connection when the client offered a subprotocol and the response names
none, so the socket never opens and the callback that inspects
socket.protocol is never reached. The purpose-built error, and the
capability-gap settle budget in applyDurableAckMismatchPolicy behind it,
were unreachable through the one runtime they exist for.

The server now echoes the token whenever it was offered and reports the
capability on the ingress SERVER_INFO frame instead, so this reads the
verdict from there. decodeQwpIngressServerInfo returns
QwpIngressServerInfo rather than a bare batch cap, and requires exactly
six bytes: a five-byte frame comes from a server that predates the
capability byte, and reading it as "durable ACK off" would turn a
version skew into a wrong answer on the one field nothing else can
re-derive.

applyQwpBrowserIngressHandshake folds the bit into the handshake and
raises QwpDurableAckUnavailableError from an open socket, which lets the
existing mismatch policy run. The two connect paths shared a duplicated
handshake callback, so browserIngressHandshake now holds the single
remaining upgrade-time check: a missing echo means the server does not
speak the browser negotiation at all, which Node and an injected
webSocketFactory can still observe. connectQwpBrowserRawEndpoint
consumes the frame too, because offering the token is what makes the
server send it and an unconsumed frame would surface to the caller as a
data message.

Requesting durable ACK alongside ingressNegotiationTimeoutMs: 0 is now
an error rather than a guess, since that combination asks for a
capability whose verdict only arrives in the frame while asking not to
wait for the frame. connectQwpBrowserRawEndpoint validates that option
through the same helper as its sibling, which it did not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ak82JZCcANpEtK85fvnUgK
A maintenance batch checked directory ownership once on entry and then ran
up to TRIM_BATCH_SIZE trims, each with a handle close, an fsynced manifest
write and an unlink. `QwpNodeAdvisoryLock.lost` turns true on a timer -- a
heartbeat that cannot read the owner record deliberately lets `provenAtMs`
go stale, and an event-loop stall past the liveness window does the same --
so the lease could lapse part-way through the batch, with no second process
involved.

Past that point writeManifest() silently skipped its write while
trimSegment() unlinked the segment anyway, leaving sf-manifest.bin naming a
file that no longer existed. validateRecoveredManifest() rejects that pair
and runs before rewriteManifestForCurrentSegments(), so neither this process
nor a successor could repair it: the next load failed twice,
isQuarantinableReplayRecoveryError() sent the whole slot to quarantine, and
every frame the server had not acknowledged yet was abandoned -- over
segments that were fully acknowledged and whose deletion mattered to nobody.

writeManifest() now reports whether the on-disk manifest describes the
boundaries it was asked for, and trimSegment() deletes nothing when it does
not. runMaintenanceBatch() re-checks ownership per trim rather than only on
entry, which also covers the trimSegment() call activateHotSpare() makes
directly.
Reading the durable-ACK verdict from SERVER_INFO moved the answer out of
the subprotocol echo, which the server now sends whenever the token was
offered. The fake-socket suites were updated with it -- session.test.ts
passes ingressServerInfo(cap, true) and core.test.ts pins byte 0 to false
and byte 1 to true -- but the real-browser mock still sent the frame with
the capability bit clear while the test asserted durableAckEnabled: true.

The test was internally self-contradictory and the qwp-browser CI job
failed on it, so set the bit the assertion expects. Production is the
correct side of this: applyQwpBrowserIngressHandshake() has no other
source for the verdict a browser can read.
Two gaps in what the suite actually held, both proved by mutation: every
regression below left `pnpm test`, `test:dist`, `check:packages` and all
three `typecheck:dist` configs green.

The public-API contract only asserted that each documented name was
present, so it pinned nothing against additions. Both roots re-export the
shared barrel with `export *`, which means any new `export` under `_qwp/**`
became public API on two published packages, under semver, unreported --
package-boundaries.e2e.ts pins the `exports` subpaths in package.json
rather than the module's names, and public-api-contract.ts pins types. The
contract arrays are now the complete export inventory and are compared in
both directions, so widening the surface is a deliberate edit with a
reviewable diff. That inventory also covers the ILP names on the Node root,
including the newly exported SenderBufferV3, whose removal nothing caught.
It found one omission already: parseQwpNodeClientConfig was exported but
undocumented.

The QWP-shaped members on the root Sender -- writer(), flushAndGetSequence(),
publishedSequence, acknowledgedSequence and waitForAcknowledged() -- were
only ever driven through a `ws::` sender, leaving the branch every HTTP/TCP
user takes untested. Dropping the flush() from flushAndGetSequence() is
silent data loss: the call resolves, the caller believes the rows were sent,
and only close() mentions the unflushed buffer.
Four unrelated defects, each with a regression test that fails without its fix.

Store-and-forward recovery reads a manifest-required segment holding no records
as proof that records were written and lost. activateHotSpare stamped and
fsynced that flag, then ran a whole trimSegment of the previous segment -- a
manifest write, its fsync, a directory fsync and a cross-thread unlink -- before
appendOnce wrote the first record, so a kill anywhere in that stretch forged the
verdict and reported abandoned data to a producer whose first append had not
returned. The stamp moves to the append, immediately before the record write,
which is the ordering the flag has always been documented to have and the one
recovery already applies to a retained empty active segment.

A wss upgrade agent was accepted only when it was an instance of https.Agent.
That tests inheritance rather than whether the agent can serve the scheme, so it
refused every tunnelling agent -- the shape https-proxy-agent, socks-proxy-agent
and proxy-agent all build on -- while ws given the same agent completes the
upgrade. Node applies the real check inside https.request and reports a mismatch
as ERR_INVALID_PROTOCOL, so wss defers to it; ws keeps its own check. That error
is raised synchronously from the ws constructor and carries no retryable flag,
which the reconnect classifier treats as retryable, so it is now marked.

The orphan-recovery reconnect wrapper discarded what safelyInvoke returns, so
the notification inbox could not tell an async observer from a finished one. The
caller's own callback was serialized on a foreground session and re-entered on an
orphan-drained one, and the inbox bound never engaged because its queue was
drained on the same turn. The wrapper returns the promise, which safelyInvoke
documents as already contained and non-rejecting. It moves to qwp-node/ so a test
can reach it without widening the published surface.

Egress incremented a notification drop counter that nothing could read. attempt
resets on every success, so a delivered stream that lost events reads exactly
like a healthy one. QwpEgressSession.metrics reports it, as ingress does.

The public API contract pinned only runtime exports, so deleting the SenderBuffer,
SenderTransport and TimestampUnit re-export lines removed them from the published
declarations with every gate green. They are named in the contract now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ejquoL94xKsYAfpbRGkri
Five defects, each with a regression test that fails without its fix.

The reconnect loop's backoff wait sat behind `backoffMs > 0`, and that wait is
the loop's only macrotask. initialBackoffMs 0 is a value validateReconnectPolicy
accepts and this suite passes in 62 places, so a connection attempt that fails
without an I/O turn -- a caller-supplied webSocketFactory, or a browser
WebSocket constructor throwing SecurityError on mixed content -- left both the
ingress and the egress loop spinning in microtasks. Timers, I/O and close() all
stopped for as long as connecting kept failing, which under Node
store-and-forward is forever, since that policy reconnects unbounded. The wait
now runs on every retry and takes a zero delay when backoff is off; only a
non-zero backoff doubles. maxBackoffMs 0 reached the same spin by driving
backoff to zero after the first retry.

Deferring a wss upgrade agent to node fixed validateQwpWebSocketAgent but left
selectQwpSchemeAgent testing `instanceof https.Agent`, so the same object was
accepted through qwp.webSocket.agent and dropped, with one warning, through the
top-level `agent` option -- and a proxy-only deployment then connected direct.
That path admits any http.Agent on wss now, which is what a tunnelling agent is,
and keeps its own check on ws, where an https.Agent would attempt TLS on a
cleartext socket. An agent that genuinely cannot serve wss still fails by name,
because node raises ERR_INVALID_PROTOCOL and connectQwpNodeEndpoint marks it
non-retryable.

node:dgram discards the completion callbacks of sends still queued in the handle
when close() runs, and the UDP session resolved a send only from that callback,
so a sendTables() racing a close() returned a promise that never settled -- and
sendDatagrams() awaits each datagram, so one dropped callback stranded the whole
call. The session tracks what it hands the socket and settles it on close: a
datagram already queued resolves without advancing the watermark, the way a
datagram the kernel refused does, and the rest of an interrupted batch fails
with the closed error. The fake socket modelled the wrong half of this and now
drops the callbacks too.

The browser package's top-level `browser` field named the CommonJS build while
`module` named the ESM one, so a resolver that prefers `browser` and ignores
`exports` picked a build it cannot tree-shake, costing 231 KB of the 630 KB
bundle. Removing the field sends those resolvers to `module` and leaves a
CommonJS-only one on `main`; pointing it at the ESM build instead would hand an
.mjs to the jsdom-style resolvers that read it and emit require().

THIRD_PARTY_NOTICES described the bundled fzstd as stock 0.1.1. It is patched to
enforce the decompressed size a Zstandard frame header declares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ejquoL94xKsYAfpbRGkri
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

State-machine builder Client should skip columns if value is null

1 participant