Repository navigation
feat(qwp): align ACK waits, reconnect and startup with other QuestDB clients - #71
Conversation
QwpSenderSession and QwpSenderSessionFactory were exported from both package roots, which made the seam between QwpSender and its transports a public extension point. No other QuestDB client exposes a pluggable sender session: Java, Rust and Python keep that layer internal and hand out senders from their factories. Move both types to an internal module so neither package exports them, and mark the QwpSender constructor @internal. Applications keep obtaining senders from the runtime factories or a pooled client. The public type contracts now also assert that neither root re-exports the two names. Remove the QWP.md paragraph that documented custom session implementations, and regenerate the API reference.
Bring the QWP API closer to the Java, Rust and Python clients. ACK waiting: - Replace the awaitServerAck/awaitDurableAck sender options with flushAndWait(timeoutMs?), which resolves false when acknowledgements stop progressing. waitForAcknowledged() resolves a boolean too, and QwpIngressAckTimeoutError is removed. - Replace sendFrameWithPublication()/QwpIngressSendResult with publishFrame(). - Add QwpIngressSessionCloseTimeoutError. Reconnect: - Split QwpReconnectOptions into QwpIngressReconnectOptions (reconnect* names) and QwpEgressReconnectOptions (failover* names). A connected ingress session retries every outage until close(); reconnectMaxDurationMs bounds only a synchronous startup. Startup (initial_connect_retry, lazy_connect): - Accept lazy_connect=on|off|true|false in any case, and read initial_connect_retry case-insensitively, as the Java and Rust parsers do. - A standalone Sender ignores lazy_connect and warns, as the other clients' standalone senders do; it stays a pooled-client flag. - Make QwpIngressSessionOptions.initialConnectMode public and validate it. "async" now starts in the background in browsers as well, and the Node adapter honours the session spelling with store-and-forward and rejects two spellings that disagree. - Stop documenting initial_connect_retry as requiring sf_dir, and note that the Rust and Python pools do not promote startup to sync from reconnect_* keys.
9f6f1b2 to
f5b5316
Compare
QwpIngressSession was exported from both package roots, along with connectQwpNodeIngress() and connectQwpBrowserIngress(), which hand one out. That made the layer below QwpSender a second, parallel ingress API. The other QuestDB clients keep that layer internal: Java, Go and Python publish through their senders, and the batches Rust and C accept -- a row Buffer, or a column Chunk for zero-copy Arrow and Polars ingest -- are payloads handed to a sender, not a session. Stop exporting QwpIngressSession, the two factories, and QwpIngressSessionCloseTimeoutError, which only the session's own close() throws. The session options, metrics, notification and error types that the sender surfaces stay public. Both roots now re-export their runtime adapter by name instead of with `export *`, because the adapter module also exports the factory its senders use. The browser adapter therefore moves from index.ts to qwp.ts, verbatim apart from its header (git diff -C shows the move), matching the Node package's layout. The runtime contract asserts that the four names stay out of all three barrels, and the type contracts pin the same for the source and the built declarations. The browser bundle tests read the negotiated handshake from createQwpBrowserConnectionFactory() instead, which exercises the same ingress negotiation. QWP.md drops the low-level session guide and notes where the ordinary OK remains observable once durable ACK tracking moves the watermark.
Regenerated with pnpm run docs after the ingress session became internal. The pages for QwpIngressSession, QwpIngressSessionCloseTimeoutError, connectQwpNodeIngress and connectQwpBrowserIngress are removed, and source links now point at the browser adapter's new qwp.ts.
QwpNodeOrphanDrainer was exported from the Node root together with its options and session types and scanQwpNodeOrphanSlots(), so applications could construct and drive the drainer themselves. The other QuestDB clients configure orphan recovery instead -- drain_orphans and max_background_drainers in Java, Go, Rust and Python -- and expose at most a drainer listener and statistics. QwpNodeUdpSession was likewise a session-level UDP API beside connectQwpNodeUdpSender(), while Java publishes UDP rows through its Sender. Stop exporting QwpNodeOrphanDrainer, QwpNodeOrphanDrainerOptions, QwpNodeOrphanDrainSession, scanQwpNodeOrphanSlots, QwpNodeUdpSession and QwpNodeUdpMetrics, and remove connectQwpNodeUdp(), which only returned the session: createQwpNodeUdpSender() connects it directly. The drainer's notification surface -- onOrphanDrainEvent with its event, kind and metrics types -- stays public, as do retryQwpNodeOrphanSlot() and QWP_ORPHAN_FAILED_SENTINEL for re-enabling a quarantined slot. The UDP datagram counters were reachable only through the session; UDP senders keep their own metrics and onError. The runtime contract asserts that the four runtime names stay out of the Node root, and the type contracts pin all seven for the source and the built declarations. Tests reach the internals through their qwp-node/ modules, and config-docs no longer counts connectQwpNodeUdp() as a high-level entry point. QWP.md drops connectQwpNodeUdp() and scanQwpNodeOrphanSlots() and says how to configure and observe orphan recovery instead.
Regenerated with pnpm run docs after the orphan drainer and UDP session became internal. The pages for QwpNodeOrphanDrainer, QwpNodeOrphanDrainerOptions, QwpNodeOrphanDrainSession, scanQwpNodeOrphanSlots, QwpNodeUdpSession, QwpNodeUdpMetrics and connectQwpNodeUdp are removed, and the embedded QWP.md matches the updated guide.
The public option interfaces mirrored internal layers more than caller concerns, and several carried fields that belonged elsewhere. None of this API has shipped yet, so trim it before 5.0.0 does. The browser client accepted a split form with complete ingress and egress trees beside the cluster form. Both arrived in the same commit, so the split form was compatible with nothing published. Drop QwpBrowserSplitClientOptions and QwpBrowserUnifiedClientOptions: QwpBrowserClientOptions is now the cluster form, and a client without a cluster is rejected by name. Callers that must connect the two sides differently can give QwpClient their own factories. requestDurableAck sat on the WebSocket options both sides extend, so egress inherited it and two places had to strip it again: sent on /read/v1 it failed every query session. It now lives on the ingress types only -- QwpNodeIngressOptions and the new QwpBrowserIngressOptions, which also takes ingressNegotiationTimeoutMs -- and the Node egress path passes false to the endpoint connector instead of relying on a strip. The pooled client's typed override moves from webSocket.requestDurableAck to a new ingress section, and Sender.fromConfig() routes qwp.webSocket.requestDurableAck there. Without the ingress fields, QwpBrowserClusterOptions was identical to QwpBrowserWebSocketOptions and is removed. QwpEgressRoutingOptions becomes QwpRoutingOptions, since ingress honours target and zone too, and QwpSenderOptions takes gorilla and symbolDictionary directly instead of an encode object, which removes QwpSenderEncodeOptions. Five @internal fields on QwpIngressSessionOptions -- replayStore and the background and orphan store-and-forward handoffs -- were published in both packages' declarations, because nothing strips @internal. They move to QwpIngressSessionInternalOptions, which no root exports, together with the pre-session error delivery count, which had been attached under a symbol to avoid exactly that leak. Lazy startup no longer sets backgroundStoreAndForward on the public ingressSession options: the async initial connect mode already implies it. The type contracts pin the renamed and removed names for the source and the built declarations, and fail if an internal session field or an ingress-only field returns to a shared or egress type. New tests cover the Sender's requestDurableAck routing, an egress upgrade given requestDurableAck anyway, and a browser client without a cluster. QWP.md drops the split form and the encode object.
Regenerated with pnpm run docs after the option types were reduced. The pages for QwpBrowserClusterOptions, QwpBrowserSplitClientOptions, QwpBrowserUnifiedClientOptions, QwpEgressRoutingOptions and QwpSenderEncodeOptions are removed, QwpBrowserClientOptions is now an interface page rather than a type alias, and QwpBrowserIngressOptions and QwpRoutingOptions are added.
Each QWP entry point took its options in the shape of the layers behind it: a sender took connection, sender and session options as three arguments, an egress connection took connection and session options as two, and the pooled client took five sections split the same way. With the ingress session internal, two of those types named objects no caller can reach, and some settings lived in two places -- initialConnectMode on the session and on storeAndForward, maxBatchRows on the egress connection and on its session -- with code to reconcile them. The ingress and egress options of each runtime now absorb the sender's buffering options and the session's delivery options. connectQwpNodeSender(), connectQwpBrowserSender() and their create forms take one QwpNodeIngressOptions or QwpBrowserIngressOptions, the UDP senders one QwpNodeUdpOptions, and connectQwpNodeEgress() and connectQwpBrowserEgress() one egress options object plus an optional signal. QwpSenderOptions and QwpIngressSessionOptions become internal building blocks, which the published declarations still carry for the types that extend them; QwpEgressSessionOptions stays public for QwpEgressSession.connect(). storeAndForward loses its own initialConnectMode, so assertConsistentQwpInitialConnectMode() and the conflict it reported are gone, and one maxBatchRows is both the requested row cap and the decoder bound. The Node pooled client takes the cluster form the browser client already had -- cluster, ingress, egress, pool and lazyConnect -- with the endpoints and authorization owned by cluster and each side deriving its route from the cluster URL through a helper both runtimes now share. Overrides for a connect string take the same sections, all optional, so QwpNodeClientConfigOptions is no longer a vocabulary of its own, and lazyConnect gains a typed override. On the browser side QwpBrowserClientIngressOptions and QwpBrowserClientEgressOptions give way to the merged side options, minus what the cluster owns. Sender.fromConfig() keeps two qwp sections, webSocket and udp, each taking that sender's complete typed options; session and sender are folded into them. A section from the old shapes would otherwise be skipped with every setting inside it -- two tests here were passing qwp.session settings that no longer applied -- so the qwp sections, the pooled client's options and its overrides now reject an unknown section by name. Public option types fall from 19 to 17 on the Node root and from 18 to 14 on the browser root. The contracts pin the new signatures and shapes, and fail if a side accepts a cluster-owned field, if storeAndForward regains a startup mode, or if the merged ingress options regain an adapter-only session field. QWP.md, the READMEs and the example use the one-object forms.
Regenerated with pnpm run docs after each QWP entry point took one options object. The pages for QwpSenderOptions, QwpIngressSessionOptions, QwpBrowserClientIngressOptions and QwpBrowserClientEgressOptions are removed; the ingress and egress option pages now list the buffering, delivery and session members they include.
The connect-string overrides gained lazyConnect when they took the pooled client's own sections. Pin that a typed value wins over lazy_connect in both directions, and that enabling it applies the same async startup and cold query pool the key does.
Both packages exported a raw WebSocket connector and a stateful endpoint walker -- connectQwpNodeWebSocket() and createQwpNodeConnectionFactory(), connectQwpBrowserWebSocket() and createQwpBrowserConnectionFactory() -- each returning a bare QwpBinaryConnection with no session over it. With the ingress session internal, their only public consumer was QwpEgressSession.connect(), which connectQwpNodeEgress() and connectQwpBrowserEgress() already drive with factories of their own, and every sender, query session and pooled client opens and reconnects its own connections from the same endpoint options. Stop exporting all four. connectQwpNodeWebSocket() and connectQwpBrowserWebSocket() stay in their adapters as test seams, and createQwpBrowserConnectionFactory() as the factory behind the browser sender. The Node factory had no caller besides its own connector, so its public wrapper is removed and the internal walker takes its name. The same-origin and session-cookie guidance on the browser connector moves to QwpBrowserWebSocketOptions, so the API reference keeps it. The runtime contract asserts that the four names stay out of all three roots, and the type contracts pin them for the source and the built declarations. Three browser e2e tests drove the raw factory from the built bundle to read its handshake; they now connect a public sender and read its metrics, where effectiveAutoFlushBytes reflects the batch cap the SERVER_INFO frame advertised. QWP.md says the raw connectors are internal.
QwpWebSocketConnectOptions holds the endpoints and WebSocket deadlines both runtime adapters read, and both packages exported it. Nothing takes it on its own: QwpNodeWebSocketOptions and QwpBrowserWebSocketOptions extend it, and since the raw connection helpers became internal no public signature names it either. Exported, it was one more option type for a caller to tell apart from the two that are used. Move it from transport.ts, which the shared barrel re-exports whole, to _internal/websocket-connection.ts beside validateQwpWebSocketTimeouts(), which checks the same deadlines. The published declarations still carry it as the base of the two runtime options, as they do QwpSenderOptions, so its fields keep their documentation on those pages. The authTimeoutMs note now names connectTimeoutMs in prose rather than linking through the unexported base. The type contracts assert that neither root exports it, for the source and the built declarations, and fail if either runtime's WebSocket options lose one of its fields.
QwpNodeFileReplayStore was exported with its options and metrics, and both packages exported QwpIngressReplayStore with its record and reference types. The only way to hand a sender a replay store was the replayStore session option, which is internal, so applications could construct the journal but not give it to a sender: storeAndForward builds it, and QwpNodeStoreAndForwardOptions merely extended its options. Stop exporting QwpNodeFileReplayStore, QwpNodeFileReplayStoreOptions and QwpNodeFileReplayStoreMetrics, and move QwpIngressReplayStore, QwpIngressReplayRecord and QwpIngressReplayReference from transport.ts to a new _internal/replay-store.ts, which no root re-exports; the browser package carried the contract for a store it can never be given. QwpNodeStoreAndForwardOptions now declares the journal settings itself -- directory, maxBytes, maxSegmentBytes, durability, checkpointIntervalMs, backpressurePolicy, appendDeadlineMs and onRecoveryDataLoss -- and the store's internal options are a Pick of it, so the two cannot drift. The policies, the replay-store errors and the data-loss report stay public, since a sender surfaces them. The store's metrics were reachable only through a store a caller built; QWP.md now points at the backlog and watermarks a sender's metrics.ingress reports. Two dist tests built the store from the published bundle: the ESM worker check now opens a journal through an async sender, and the multi-process child drives its slot through a sender whose endpoint never answers, one journalled frame per flushed row, which keeps all five cross-process lock and recovery contracts. The runtime contract asserts that the store stays out of the Node root, the type contracts pin all six names for the source and the built declarations, and they fail if the store-and-forward options lose one of the journal settings. Unit tests import the store from its qwp-node/ module.
The typed storeAndForward options and the sf_* connect-string keys configure the same journal, yet they disagreed on four of its defaults: durability (append typed, memory in a connect string), the size cap (1 GiB against 10 GiB), a full journal (failing against waiting), and the journal's location (the directory itself against its `default` slot). Moving between Sender.fromConfig() and the typed options -- a change that looks like pure configuration style -- changed how durable and how bounded the journal was and pointed it at a different directory, stranding any backlog in the old one. The typed defaults were the journal's historical ones, the backpressure policy labelled backwards compatible, but no release exists to be compatible with; the connect string's follow the Java client. Take the connect string's everywhere. The journal's defaults come from one QWP_SF_DEFAULTS object, which the parser now resolves the sf_* keys from as well, so the two spellings cannot drift apart again. senderId defaults to `default`, as sender_id does, so storeAndForward.directory is always the slot root: a sender journals into <directory>/<senderId>, and pooled senders into <directory>/<senderId>-<slot>, `default-N` rather than `sender-N`. An adopted orphan still opens the slot the scanner found. With every journal a named slot, a standalone drainer always scans the configured directory, so the branch that ignored drainOrphans for an unnamed journal is gone. The warning that one construction style had left a journal where the other would not look becomes a warning that the slot root itself holds segments, which is what a directory naming a slot rather than its root leaves behind. Store tests that relied on the old defaults now ask for append durability or the error policy explicitly, and the transport tests use the default slot names. New tests pin that a typed storeAndForward object gets the parser's journal defaults, that a typed sender journals into its slot rather than the root, and the slot-root warning. QWP.md and the READMEs describe one set of defaults and one layout.
Regenerated with pnpm run docs after the four follow-ups. The pages for connectQwpNodeWebSocket, createQwpNodeConnectionFactory, connectQwpBrowserWebSocket, createQwpBrowserConnectionFactory, QwpWebSocketConnectOptions, QwpNodeFileReplayStore, QwpNodeFileReplayStoreOptions, QwpNodeFileReplayStoreMetrics, QwpIngressReplayStore, QwpIngressReplayRecord and QwpIngressReplayReference are removed. The WebSocket option pages still list the members of their unexported base, QwpNodeStoreAndForwardOptions lists the journal settings with the connect-string defaults, and the embedded QWP.md matches the updated guide.
QwpEgressSession is the query API, which connectQwpNodeEgress(), connectQwpBrowserEgress() and the pooled clients return, so the class has to stay public. Constructing one did not: the static connect() took a QwpConnectionFactory and the constructor a QwpBinaryConnection, and since the raw connection helpers became internal, nothing public produced either. A caller would have had to reimplement the QWP upgrade to use them; the documented ways to connect differently -- the webSocketFactory option and custom QwpClient factories -- go through the public egress functions instead. They were the egress counterpart of the ingress session made internal earlier, left public only because the class itself had to stay. The constructor now takes a token only this module holds, as QwpTableWriter's does, and the static connect() becomes connectQwpEgressSession(), with createQwpEgressSession() for one fixed connection. Neither root exports them, so the shared barrel lists the egress session's exports by name, as it already did for the ingress session and the sender. Opening a reconnecting connection used three of the session's private replay methods from inside the class; the constructor now fills in an internal hooks object with them instead, which the reconnecting connection calls through. With no public signature left taking them, QwpBinaryConnection, QwpConnectionFactory and the two transport-metrics types only that connection exposes move from transport.ts to a new _internal/binary-connection.ts. QwpEgressSessionOptions becomes a non-exported base of the runtime egress options, as QwpIngressSessionOptions did. QwpHandshakeMetadata stays public for the session's handshake getter. The runtime contract asserts that the factories stay internal, that the class has no static connect(), and that its constructor rejects a call without the token. The type contracts pin all five type names for the source and the built declarations, and fail if connect() returns, if the constructor stops leading with its token, or if the runtime egress options lose a session field. Tests construct sessions through the internal factories. QWP.md says how query sessions are opened.
QwpSender's constructor was documented as internal but callable: it took a QwpSenderSessionFactory, a type neither root exports since the sender session interface became internal, so the published declarations offered a constructor no caller could satisfy without reaching into unexported types. The runtime factories and the pooled clients' borrowSender() are how applications obtain a sender. The constructor now takes a token only the sender module holds, as QwpTableWriter's and now QwpEgressSession's do, and createQwpSender() builds senders for the runtime adapters. The shared barrel already lists the sender module's exports by name, so the factory stays internal. The runtime contract asserts that createQwpSender stays out of all three roots and that the constructor rejects a call without the token, and the type contracts fail, for the source and the built declarations, if the constructor stops leading with its token. Tests and the sender benchmark build senders through createQwpSender(). QWP.md says where a QwpSender comes from.
Regenerated with pnpm run docs after sender and query-session construction became internal. The pages for QwpBinaryConnection, QwpConnectionFactory, QwpEgressSessionOptions, QwpEgressTransportMetrics and QwpIngressTransportMetrics are removed from both packages. QwpEgressSession no longer lists a static connect(), its constructor and QwpSender's are marked internal, the egress option pages list the session members they include, and the embedded QWP.md matches the updated guide.
QwpSender, QwpEgressSession and QwpTableWriter are public classes whose constructors take a token only the package holds, so a caller can never use them. TypeDoc still rendered each one, token parameter and all: excludeInternal does not remove a constructor tagged @internal. Tag them @hidden as well, so the reference pages list only what a caller can use. The comments keep @internal and say where instances come from, for readers of the source and the published typings, which still declare the constructors.
Regenerated with pnpm run docs after the token-guarded constructors were hidden. The QwpSender, QwpEgressSession and QwpTableWriter pages in both packages no longer list a constructor; QwpClient's public constructor is still documented, and no page is added or removed.
…ested Without requestDurableAck, the Node client took durableAckKeepaliveMs (durable_ack_keepalive_interval_millis) as a request for durable ACKs and rejected it next to requestDurableAck=false, while the browser client rejected it outright. The Java and Rust clients accept the keepalive in both cases and ignore it: it only paces the durable-ACK poll once durable ACK has been requested. Promoting it also changed the upgrade Node sent, so a connect string those clients use against a server without durable ACK failed to connect from Node with QwpDurableAckUnavailableError. Both adapters now hand the session a keepalive only when durable ACK was requested -- the configured interval, or the 200ms default -- through resolveQwpDurableAckKeepaliveMs(), which the Node orphan drainer uses as well. The session reads a defined interval as the request to track durable progress, so dropping it is what keeps an ignored keepalive inert. The value is still validated when it is ignored. The connect-string parser no longer sets request_durable_ack from the keepalive or rejects it next to request_durable_ack=off. The Node and browser tests connect to a server that offers durable ACK unasked and check that the ordinary OK still advances the watermark, with no durable-ACK request and no polls. The Node test that polls with the keepalive now requests durable ACK explicitly. QWP.md documents the 200ms default, which config-docs pins to the code, and says the key is ignored without request_durable_ack=on.
Regenerated with pnpm run docs after the durable-ACK keepalive change. The QwpNodeIngressOptions and QwpBrowserIngressOptions pages say durableAckKeepaliveMs takes effect only alongside requestDurableAck and is otherwise ignored, the embedded README and QWP.md match the updated guides, and no page is added or removed.
QwpIngressSession.close() gave frames still in the in-memory replay queue up to 5 seconds to reach the socket, and rejected with QwpIngressSessionCloseTimeoutError when some could not. QwpSender never used it: its close() already flushes, waits up to closeFlushTimeoutMs for the committed-frame ACK watermark -- or, in a fast close, for those frames to reach the socket -- and rejects when that drain cannot finish, then closed the session through closeWithoutDrain() so the two budgets could not stack. Since the ingress session became internal, its only other caller is the orphan drainer, whose sessions journal to disk, where the drain did nothing. As with the Java client's close() and the Rust and Python clients' close_drain(), the drain belongs to the sender, which is what applications call. The session's close() now closes without a drain, as closeWithoutDrain() did, and closeWithoutDrain() goes from the session, the sender session interface and the sender. So do QwpIngressSessionCloseTimeoutError and the connection's stopPublishing() and unsentFrameCount, which only that drain used. The unsent-frame count stays behind waitForPendingSends(), which the sender's fast close still waits on. The three tests of the session drain go, the five-minute RAM replay test no longer closes into a timeout, and the public API contracts stop listing the removed error.
The QwpSender pages of both packages and the Node Sender page now say that flushAndWait() also waits for the frames a store-and-forward journal recovered; the index page carries the README's corrected browser durable-ACK paragraph, the embedded QWP.md matches the updated guide, and the search index follows. Every other page changes only its source links and line anchors, and no page is added or removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's browser credential authentication (#68) changed packages/browser-client/src/index.ts, which on this branch only re-exports; the implementation moved to qwp.ts, so the change is merged there instead. auth is cluster-owned like url, failoverUrls and sessionBootstrap, and main's credential tests use this branch's single-options-object API. docs/ keeps this branch's pages until the next commit regenerates them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser package gains pages for QwpBrowserAuthContext and QwpBrowserAuthProvider. QwpBrowserWebSocketOptions, and the ingress and egress option pages that inherit from it, document the new auth option; QwpBrowserClientOptions lists auth among the cluster-owned keys, and QwpBrowserSessionAuthentication says how sessionBootstrap and auth each use the credential. The browser module page carries the package README's third-party web application section and lists the two new types, the index page carries the README's auth example, the embedded QWP.md matches the updated guide, and the search, navigation and hierarchy indexes and the highlight stylesheet follow. Every other page changes only its source links and line anchors, and no page is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
QWP.md said the legacy onError callback reports timeouts. Since ACK waits were aligned with the other clients, a waitForAck() or flushAndWait() that times out returns false and a close() whose drain times out rejects, and none of them reaches onError. The guide now says so, as the onError TSDoc already did. It also said every backoff key requires a positive value, but failover_backoff_initial_ms and failover_backoff_max_ms accept zero, as in the Java and Rust clients, and failover_max_attempts still bounds the episode. The rule is now scoped to the reconnect_* keys, and a parser test pins both. And it said a failed prewarm does not close the client. That holds for connect() on a client from createQwpNodeClient() or createQwpBrowserClient(), but connectQwpNodeClient() and connectQwpBrowserClient() close the client they never hand back. The guide now separates the two. A Sender's qwp.webSocket section passes through the pooled client's resolver, so a url there was reported as an ingress.url that belongs under cluster.url, and an invalid initialConnectMode as ingress.initialConnectMode -- options a Sender does not have. Both are now checked first under their qwp.webSocket names. A programmatic Sender used to replace such a url silently; it now rejects it too. Two regression tests pin paths nothing covered: waking an ACK waiter on a recovered frame when retiring the recovered tail fails after the store accepted the OK, and reporting the timeout for a result whose batch arrives after the deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The embedded QWP.md page carries the corrected onError, failover backoff and prewarm statements; every other page changes only its source links and line anchors, and no page is added or removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g and leases A browser sender started with initialConnectMode "async" runs as a background store-and-forward sender, which retries every upgrade rejection once it has connected, so a credential can rotate on the server. That rule also caught the verdicts the browser client reaches on its own: an auth provider error carrying retryable: false, a returned credential that could never be sent, and a server that echoes the credential. The sender then asked the provider again, or re-sent the credential to the server that exposed it, for as long as it lived, without ever reaching onError -- contrary to the browser README, the auth provider TSDoc and QWP.md. Those three errors are now marked as final, and the reconnect classifier honours the mark before its store-and-forward rules, so they end the sender after one attempt in every mode. QWP.md names the exception. A durable flushAndWait() never confirmed frames a journal recovered. Their OK is not forwarded to the session, so the session tracked no durable targets for them and never polled, and QuestDB reports durable progress only in reply to inbound traffic: the wait returned false until new rows were published, including for this sender's own rows behind a recovered frame. The transport now reports acknowledged frames still awaiting durability and wakes the session when an unforwarded OK leaves one, and the session polls while either it or the transport has such a frame, as the Java client does. Closing a query lease while its query() waited to start -- for an abandoned query to drain, or for SERVER_INFO -- returned the session to the pool, and the waiting query then started under the next borrower, whose own query() failed because one was already active. Returning a lease now rejects every query() of it that has not started with QwpClientClosedError, including one called in the same turn as close(). QWP.md's lazy_connect row said query_pool_min defaults to 0 under it; an explicit positive value is rejected, and the row now says so. Each code fix has a regression test that fails without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QwpEgressSession pages of both packages show prepareForPoolRelease() taking the error that rejects a returned lease's unstarted queries, the embedded QWP.md matches the updated guide, and the search and hierarchy indexes follow. Every other page changes only its source links and line anchors, and no page is added or removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd durable polls A typed reconnect object replaced the connect string's whole reconnect policy. Adding only an onEvent observer to a string that tunes reconnect_* therefore discarded those budgets, and with them the sync startup the keys select: connect() made one fail-fast attempt where QWP.md promises a retrying one. A typed reconnect is now merged field by field with the string's keys, on ingress and egress alike, as storeAndForward already was, and startup is chosen from the merged policy. reconnect: false still disables reconnecting. A browser auth provider that threw, or returned a credential that could never be sent, was reported as auth-failed, which the event's documentation reserves for a server refusing the credentials. These failures are now marked as the client's own and reported as endpoint-attempt-failed. Like auth-failed they end the sweep, so no all-endpoints-unreachable follows. A 401 or 403 is still auth-failed. A browser sender with durable ACK queued one poll per keepalive tick while an acknowledged commit awaited its durable upload. No poll could be trimmed before that commit was durable, so the in-memory replay queue grew for as long as the upload stalled: about 18,000 frames an hour, each OK costing time in proportion to the backlog, all resent on reconnect. Publishing a poll now discards the earlier polls already on the wire. A late OK that names table transactions for a discarded poll hands them to the newest frame still queued before it, so the durable watermark cannot pass rows that are not durable. This applies only to durable-tracking sessions on the in-memory queue; Node pings instead. The fix for frames a journal recovered (811f52a) gains the regression test it lacked: a restarted durable sender that publishes nothing new must still poll and confirm its recovered frames. The cluster-endpoint helper's comment no longer claims relative URLs resolve. Each code fix has a regression test that fails without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QWP_RECONNECT_EVENT_KIND pages of both packages say a browser auth provider that cannot produce a credential is reported as endpoint-attempt-failed, and the QwpBrowserAuthProvider page and the browser package page, which embeds its README, say the same. The embedded QWP.md carries the field-by-field merge of typed reconnect overrides and the auth-provider event wording, and the search index follows. Every other page changes only its source links and line anchors, and no page is added or removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrect the auth retry docs An egress query whose timeout expired during a reconnect's reset was still replayed. The session counted its connection as restored as soon as the reset began, before it waited for a running queryViews callback and the transport waited for onFailoverReset. A timeout firing then queued a CANCEL behind the replay instead of dropping it, so the replacement received QUERY_REQUEST and then CANCEL: a statement could run again after its caller's deadline, and the caller waited for the server or the full cancelDrainTimeoutMs grace instead of being released at the deadline. QWP.md and the timeoutMs TSDoc promise the query is not replayed. The transport now tells the session when the replacement takes over -- just before the replay is sent, or when nothing is -- and until then a query that ends is withdrawn from the replay. The transport checks for a withdrawal after the reset and again after onFailoverReset. A replayed query's cancel bound now starts when the replay leaves, so an application cancel issued during onFailoverReset is still bounded. A query abandoned in that window is likewise dropped rather than replayed and cancelled. QWP.md contradicted itself on authentication rejections. One passage said a 401 or 403 is rethrown without retrying and exempted only a foreground store-and-forward sender and the browser; another said a sender started with "async" retries it indefinitely once connected, which is what the code does. The first passage now names async senders, including every pooled sender under lazy_connect=on. The two egress timeout tests fail without the fix. The cancel test guards the moved cancel bound, and a new test pins the async in-memory sender's retry that QWP.md now documents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QwpEgressQueryOptions pages of both packages say a query's timeout keeps running while its onFailoverReset callback does, and that a query whose timeout expires first is not replayed. The embedded QWP.md carries the same egress wording and the corrected account of which senders retry an authentication rejection, and the search and hierarchy indexes follow. Every other page changes only its source links and line anchors, and no page is added or removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a journal, flush() completes once its frames enter the in-memory replay queue, and a background drainer hands them to the socket. close() drained that queue -- to the socket in a fast close, to the ACK watermark otherwise -- only after its own final flush succeeded. When that flush failed, as when the staged batch held a row larger than the batch cap, close() closed the session at once: frames that earlier flushes had published but the drainer had not sent yet were discarded, and close() reported only the over-cap batch it discarded. On a connection whose sends complete a turn later, as with ws, two of three rows flushed just before close() were lost this way. close() now drains the published frames whatever became of its final flush, within the same deadlines. It still rejects with the flush's failure, and a drain that fails as well is logged rather than lost. Only an expired close deadline skips the drain: there is no time left to wait, and its timeout already reports the pending data. Two regression tests cover a fast close on a connection whose sends complete a turn later, and a default close during an outage. Both fail without the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a newer durable-ACK poll discards an earlier one whose OK has already arrived, discardSupersededDurableAckPolls() hands the table transactions that OK named to the frame it covered. Nothing tested that hand-off: the existing test delivers the poll's OK after the discard, which keepDiscardedPollTargets() handles, and the whole suite stayed green with the hand-off deleted. The new test delivers the OK before the next keepalive tick. Without the hand-off, the next table-less OK carries the durable watermark past a frame whose commit is not yet durable, and the test fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fzstd 0.1.1, which both packages inline to decompress Zstd RESULT_BATCH
bodies, built three pieces of decoder state by viewing the same memory
through typed arrays of different widths, so each took on the host's
byte order. The extra-bit tables for literals length and match length
codes were bytes of an Int32Array, and the initial repeat offsets 1, 4
and 8 were written as bytes and read back through one. On a big-endian
host such as s390x, a client that opted into Zstd egress compression
failed to decode results ("zstd output exceeds declared content size
409 [actual=1375]"). Frames whose first sequences reuse an initial
repeat offset fared worse once the tables were right: they decoded
without an error to wrong values, so fixing only the tables would have
traded the failure for silently wrong results -- a level-19 INT column
of 42s came back with some rows reading 0. Raw egress, the default, and
little-endian hosts were unaffected.
The patch now spells both tables out as bytes and sets the repeat
offsets through the Int32Array view, in the ESM and CommonJS builds.
The tables are byte-identical to what little-endian hosts read before,
so nothing changes there.
CI runs only on little-endian hosts, so the new tests evaluate the
installed fzstd builds with their multi-byte typed arrays storing
reversed bytes, which is how a big-endian host lays them out. They
decode a hand-built frame that uses every literals length and match
length code and each initial repeat offset, plus two libzstd frames,
and compare the bytes. They fail without the change, and with any one
of its three hunks reverted. Under QEMU s390x emulation, with timeouts
raised for its speed, all 75 egress tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
3929500 fixes Zstd egress decoding on big-endian hosts such as s390x. The vendored fzstd 0.1.1 built its literals length and match length extra-bit tables, and its initial repeat offsets 1, 4 and 8, through views that read back in the host's byte order. Results failed to decode, or with the tables alone fixed, decoded to wrong values without an error. The bug is still present on upstream master and is reported as 101arrowz/fzstd#26. Once a release fixes it, the byte-order hunks can come out of the patch; the size-check hunks are ours and stay. Until then, the "Zstd decoding on hosts of either byte order" tests in |
In-memory publication completes once a frame enters the replay queue, so a sender keeps accepting flushes during an outage until memoryReplayMaxBytes applies backpressure. That cap charged each frame its payload plus 64 bytes, but on Node a queued frame keeps about 800 bytes of heap beside its payload while it waits to be sent, and about 450 once it awaits its ACK. Flushing one row at a time through an outage, the default 128 MiB cap engaged only after 1,427,848 frames and about 1 GiB of heap, and a process with --max-old-space-size=512 ran out of memory with 58 MiB of the 128 counted. Before this branch a default sender's flush() blocked during the same outage instead of growing the queue. Each frame is now charged its payload plus 1 KiB (QWP_MEMORY_REPLAY_RECORD_OVERHEAD_BYTES), which covers what a queued frame keeps on Node 20, 22 and 24. The default cap now applies backpressure at about 127,000 one-row frames and 105 MiB of heap, inside a 512 MB heap. Frames of many rows are barely affected: a 20 KB frame is charged about 5% more than before. The memoryReplayMaxBytes TSDoc and QWP.md say what each frame is charged. The tests that hard-coded the old allowance compute their capacities from the constant, and a new test pins the allowance and the backpressure it gives an offline backlog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hooks The QwpEgressSession class comment ended up above the constructor token when the token, the query-start helpers and the replay hooks were added between it and the class. The emitted declarations and the API reference therefore documented a private constant and left the query session class without a description. The comment is back above the class. expire() and expireGrace() are the query timer's hooks into the session. They were untagged, so the API reference listed them beside query() and cancel(), and calling expireGrace() on a running query ended it with QwpEgressQueryTimeoutError. Both are now @internal and @hidden, as the token-guarded constructors are: the reference still lists an @internal member, with an Internal badge, and only @hidden leaves it out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The QwpEgressSession pages of both packages carry the class description again and no longer list expire() and expireGrace(). The QwpNodeIngressOptions and QwpBrowserIngressOptions pages say that each queued frame counts its payload plus 1 KiB toward memoryReplayMaxBytes, the embedded QWP.md matches the updated guide, and the search and hierarchy indexes follow. Every other page changes only its source links, line anchors and reflection IDs, and no page is added or removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A browser sender or query session given auth and failover endpoints stops its sweep at an endpoint that echoes the credential subprotocol, so the endpoints after it are never tried. reconnect.onEvent still reported all-endpoints-unreachable for that sweep, naming the endpoint that echoed, although the event means that every endpoint was tried and none accepted the connection. Only credential failures were spared it. Any QwpUpgradeError that tells the sweep not to try the next endpoint is now reported like a credential failure: endpoint-attempt-failed for the endpoint that returned it, once, and no all-endpoints-unreachable. The error the caller and onError receive is unchanged. Regression tests cover an ingress factory that walks endpoints and one that does not, an egress factory, and the browser credential echo on both sides. They fail without the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…room A browser sender with requestDurableAck asks for durable progress with table-less poll frames, which went through the publication queue like data. When the in-memory replay queue filled with frames awaiting their durable upload, the next publication waited for room that only durable progress could make, and QuestDB reports durable progress only in reply to inbound traffic. Every keepalive poll queued behind that publication, so none reached the server: the publication waited out its whole append deadline, 30 seconds by default, and failed with QwpMemoryReplayAppendTimeoutError against a server with progress to report. Only then did a poll go out and the queue drain. Before this branch the stalled poll also timed out and ended the session. While a publication waits for replay-queue capacity, the session now has the connection send the poll straight to the socket, as Node sends a WebSocket PING. The poll takes no frame or client sequence -- the waiting publication has already claimed the next frame sequence -- and is not replayed after a reconnect. It does take a wire sequence, and its OK is handled as a discarded poll's, whose durable targets pass to the last frame published before it. QuestDB rejecting such a poll blames no frame: a retriable status recycles the connection without a poison strike, and any other ends the session with an error naming the poll. A poll that finds frames still waiting for the socket is skipped as before, and nothing changes while no publication waits. Three regression tests cover the poll that clears the wait, a retriable rejection under a one-strike poison limit, and a terminal rejection. They fail without the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerated with pnpm run docs after the sweep-event and durable-ACK poll fixes. Every page changes only its source links and line anchors; no page's content changes, and no page is added or removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review: level 3 (base
|
With flow control on (initialCredit > 0), a query iterator's next() first returns the credit of the batch it consumed, and it awaited that CREDIT send. During an outage the send waits for the replacement connection, so when the query's timeout expired meanwhile, completion rejected with QwpEgressQueryTimeoutError at the deadline while the iterator stayed blocked until the reconnect settled: up to failoverMaxDurationMs, 30 seconds by default, later. QWP.md and the timeoutMs TSDoc say the timeout is reported at once when the connection is lost. The behaviour predates this branch. next() now races the grant against the query's end, so a query that ends while its CREDIT waits releases its consumer with the outcome at once. A grant for a query that is still running is awaited as before, and the CREDIT frame itself is unchanged. The regression test fails without the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review: level 3 (base
|
| Check | Result |
|---|---|
vitest (all files except the Docker-only test/sender.integration.test.ts) |
1,263 / 1,263 pass |
| eslint, format:check, typecheck, typecheck:test, typecheck:qwp-browser, typecheck:bench, lint:bench | pass |
| test:dist (39 tests), check:packages, typecheck:dist | pass |
| test:qwp-browser (10 real Chromium tests) | pass |
Regression test of d6cafe9a |
fails with the production change reverted, passes at head |
I also mutated these new safety checks one at a time, and an existing test failed for each: ACK-watermark publication, the close drain after a failed final flush, withdrawal of a query's replay after failover, the single CANCEL per query, the lost-connection terminal check, final upgrade verdicts, and admission of durable-ACK polls above the replay-queue cap.
Test coverage
The test gate passes, with no coverage gaps worth reporting.
Deliberate trade-offs (documented in the PR; not findings)
- The poison escalation window defaults to 5 seconds.
- Without a journal,
flush()completes once frames enter the in-memory replay queue. - Typed store-and-forward defaults are
memorydurability,waitbackpressure and 10 GiB. - Journals live in
<dir>/<senderId|default>. - Ingress never stops reconnecting after its first connection.
query()waits for a released query that is still draining instead of throwing.Sender.fromConfig()warns about and ignoreslazy_connect,targetandzone.
Submodules: none changed. Findings: 0 Critical, 0 Moderate, 0 Minor.
Generated by an automated review (Claude Code).
Review: level 3 (base
|
| Check | Result |
|---|---|
| eslint, format:check, lint:bench, typecheck, typecheck:test, typecheck:qwp-browser, typecheck:bench | pass |
vitest (all files except the Docker-only test/sender.integration.test.ts) |
1,263 / 1,263 pass |
| test:dist, check:packages, typecheck:dist | pass (39 dist tests) |
Not run: test:qwp-browser (real Chromium) and the Docker integration tests.
Deliberate trade-offs (documented in the PR; not findings)
- Poison window: the default dropped from 5 minutes to 5 seconds. A run of retriable rejections lasting about 6 s now ends the sender.
flush()without a journal: it now resolves when the frames enter the in-memory queue. Callclose()orflushAndWait()before the process exits.- Typed store-and-forward defaults: they now match the
sf_*keys:memorydurability,waitbackpressure, 10 GiB. A journal no longer survives power loss unlessdurability: "append"is set. - Journal location:
<dir>/<senderId|default>, anddefault-Nfor pooled senders. - Ingress reconnect never gives up after the first connection.
query()waits for a query that is still draining instead of throwing.Sender.fromConfig()warns about and ignoreslazy_connect,targetandzone.- Pooled sender teardown logs a warning on failure instead of rejecting.
Summary
- Findings: 0 Critical, 0 Moderate, 0 Minor (none in the diff, none in unchanged callers).
- Submodules: none changed.
- Not checked: crash and lock probes against a real journal directory (full journal, a lock held by a second process, a torn segment, a kill between append and checkpoint) were not run. This PR changes only the defaults on those paths, not the logic.
Generated by an automated review (Claude Code).
bluestreak01
left a comment
There was a problem hiding this comment.
Approving at d6cafe9a. Level-3 review: no Critical or Moderate issues.
Minor (optional): packages/browser-client/README.md:138-139 says "the default "off" makes a single attempt" without mentioning that setting reconnect.reconnectMaxDurationMs, reconnectInitialBackoffMs or reconnectMaxBackoffMs without an explicit initialConnectMode makes the default "sync". A probe through connectQwpBrowserSender confirmed this: 1 attempt with defaults, and 36 attempts then QwpReconnectExhaustedError with a 400 ms reconnect budget. QWP.md:397, README.md:256 and the TSDoc already state the rule.
Validated at d6cafe9a:
- vitest: 1,262 of 1,263 pass. The one failure is setup only (the
questdb-client-testinterop JSON is absent), and the Docker integration test was excluded. - eslint, format:check, typecheck, typecheck:test, typecheck:qwp-browser, typecheck:bench, lint:bench, test:dist, check:packages and typecheck:dist all pass.
- The #70 regression test and three sibling recovered-frame tests fail with the unforwarded-ACK wake-up stubbed out.
- Three more new conditions each fail their targeted test when mutated: the reaper keeping RAM senders that still hold frames, the close drain after a failed final flush, and the merge of a superseded poll's durable targets.
Summary
Aligns the QWP client with the Java, Rust and Python clients. ACK waits, reconnect, startup and store-and-forward defaults now behave the same way. Applications reach QWP only through senders, query sessions and pooled clients: the layers below them are internal, and each entry point takes one options object. Along the way, the PR fixes ACK-wait, in-memory replay, pool teardown and query-timeout bugs. Fixes #70.
QWP has not shipped in a release yet: npm
latestis 4.2.0, and@questdb/browser-clientis unpublished. The API changes below therefore break only code written againstmain. For the released ILP transports, the only change is the newSender.flushAndWait().Fixes
Replay ACK waiters (waitForAcknowledged() never resolves for frames replayed from the store-and-forward journal #70).
waitForAck()now wakes when a store-and-forward replay advances the ACK watermark without forwarding the replayed OK to the session. The session-visible watermark advances only after the response carrying it has been queued, so a woken waiter orclose()cannot overtake that response'sonResponseand progress notifications.In-memory replay delivery. Without a journal,
flush()completes once frames enter the bounded in-memory replay queue. A background drainer then sends them in order, so a sender keeps accepting flushes during an outage until the queue applies backpressure. The queue's cap (memoryReplayMaxBytes, 128 MiB by default) charges each frame its payload plus 1 KiB for what a queued frame keeps in memory, so it bounds a backlog of small frames too: at the 64 bytes it charged before, one-row flushes filled about 1 GiB of heap before the cap applied backpressure, and a process with a 512 MB heap ran out of memory. The default now holds about 127,000 one-row frames. To keep those frames from being dropped silently:closeFlushTimeoutMs <= 0) still waits up to 5 s for them to reach the socket, and otherwise rejects withQwpSenderCloseTimeoutError;requestDurableAck) are admitted above the queue's cap, and none is queued while frames still wait for the socket. Before this, a poll that met a full queue during an outage, or a queue full of frames awaiting durable upload, ended the session and lost every queued row.Pooled sender teardown is best effort, as in the other clients' pools. A failed close logs a warning, and the warning notes when frames remain in the store-and-forward journal. Neither
QwpClient.close()nor a lease'sclose()rejects.Query timeouts settle on the server's answer (commit
f8ad871c, markedBREAKING CHANGEagainstmain). When the timeout expires:cancelDrainTimeoutMs(query_close_timeout_ms) for the server's terminal response;CANCELLEDreply, or a result that lost a batch to the timeout, rejects withQwpEgressQueryTimeoutError.The timeout now runs from the
query()call, so waiting for a reconnect or for a previous query to drain counts against it.query()waits for a released query that is still draining instead of throwing "a QWP query is already active". The timeout is still enforced only by the client.Egress: resetting a query schema no longer throws on skipped batch slots.
QwpEgressQueryOptions.onFailoverResetadds a per-query replay callback, which pooled leases need because request IDs are not unique across pooled sessions.Durable-ACK keepalive.
durableAckKeepaliveMs(durable_ack_keepalive_interval_millis) takes effect only together withrequestDurableAck, as in the Java and Rust clients. Node no longer treats it as a request for durable ACK. The browser client no longer rejects it.API changes
ACK waiting
awaitServerAck,awaitDurableAckanddurableAckTimeoutMssender options are removed. Instead,flushAndWait(timeoutMs?)onQwpSenderand on the NodeSenderflushes and then:trueonce every published frame is acknowledged;falsewhen the ACK watermark makes no progress fortimeoutMs. That timeout defaults toackTimeoutMs(15 s) and restarts whenever the watermark advances.trueonce the data is sent.waitForAcknowledged()is renamedwaitForAck()and resolves a boolean with the same timeout semantics.QwpIngressAckTimeoutErroris removed, and a timed-out wait is reported only to its caller, not toonError.acknowledgedSequenceis renamedackedSequenceonQwpSender, the NodeSender,QwpIngressMetricsandQwpSenderCloseTimeoutError.QwpIngressMetricsdropspendingResponsesandpendingResponseBytes, which counted the per-frame ACK waiters thatwaitForAck()replaced.requestDurableAck: true. The watermark then follows durable progress, soflushAndWait(),waitForAck()and the close drain all wait for durability.QwpSender.commit()is removed.flush()is the commit point of a transactional sender, as in the Java client.One options object per entry point
connectQwpNodeSender()/createQwpNodeSender()take oneQwpNodeIngressOptions, which holds connection, buffering and delivery settings.connectQwpBrowserSender()/createQwpBrowserSender()take oneQwpBrowserIngressOptions.QwpNodeUdpOptions.connectQwpNodeEgress()/connectQwpBrowserEgress()take one egress options object plus an optional signal.QwpSenderOptions,QwpIngressSessionOptions,QwpEgressSessionOptionsandQwpWebSocketConnectOptionsare now non-exported bases of those types.gorillaandsymbolDictionaryreplace theencodeobject.requestDurableAckis an ingress option only; the egress upgrade never requests durable ACK.QwpEgressRoutingOptionsbecomesQwpRoutingOptions, and only query sessions take it. Node ingress no longer routes bytargetorzone: only the primary accepts writes, and the endpoint sweep reaches it through each replica's 421, so both keys route query sessions only. Node rejects typedtarget/zoneon a sender and under a pooled client'sclusteroringress; in a connect string they route egress, andSender.fromConfig()warns that it ignores them.{ cluster, ingress, egress, pool, lazyConnect }in Node and{ cluster, ingress, egress, pool }in browsers.clusterowns the endpoints and authorization, and each side derives its route from the cluster URL. The browser split form is removed.Sender.fromConfig()keeps twoqwpsections,webSocketandudp. Each takes that sender's complete typed options.Internal layers
No package root exports the following any more. Applications use senders,
connectQwp*Egress()and the pooled clients.QwpIngressSession,connectQwpNodeIngress()andconnectQwpBrowserIngress();QwpSenderSessionandQwpSenderSessionFactory;connectQwpNodeWebSocket(),createQwpNodeConnectionFactory(),connectQwpBrowserWebSocket()andcreateQwpBrowserConnectionFactory(), together withQwpBinaryConnection,QwpConnectionFactoryand the transport metrics types;QwpNodeFileReplayStoreand its options and metrics, plusQwpIngressReplayStore,QwpIngressReplayRecordandQwpIngressReplayReference;QwpNodeOrphanDrainer, its options and session types, andscanQwpNodeOrphanSlots(). Orphan recovery is configured throughstoreAndForwardand observed throughonOrphanDrainEvent.QwpNodeUdpSession,QwpNodeUdpMetricsandconnectQwpNodeUdp();QwpEgressSession.connect(). TheQwpSenderandQwpEgressSessionconstructors now require a token that only the package holds.Each package root therefore lists its runtime adapter's exports by name. The browser adapter moved from
src/index.tstosrc/qwp.ts, matching the Node package's layout.Reconnect
QwpReconnectOptionsis split in two:QwpIngressReconnectOptions(reconnect*names) andQwpEgressReconnectOptions(failover*names), mirroring the connect-string keys.close()or a terminal error.reconnectMaxDurationMsbounds only a synchronous startup, and0now allows one attempt without retries. Egress failover episodes remain bounded.reconnect*tuning settings promote an unset startup mode to"sync".poisonMinEscalationWindowMs(poison_min_escalation_window_millis) defaults to 5 seconds instead of 5 minutes, as in the Java, Rust and Python clients. A frame that keeps drawing retriable rejections (maxFrameRejections, 4 by default) is now treated as poison, and its sender goes terminal, after 5 seconds of connected dwell rather than 5 minutes.reconnect.onEventreports the connection events the Java, Rust, Go and Python clients share.disconnectedreplacesreconnecting.attempt-failedis replaced byendpoint-attempt-failed, raised as each endpoint fails, andall-endpoints-unreachable, raised once per failed sweep. A 401 or 403 is reported asauth-failedinstead.Query sessions
onReplayResetoption is renamedonFailoverReset, as the other clients name it (onFailoverReset()in Java,on_failover_reset()in Rust), and its event typeQwpEgressReplayResetEventis renamedQwpEgressFailoverResetEvent. A query's ownonFailoverResetoverrides the session's.QwpEgressReplayRequiredErroris removed. It was deprecated, and nothing threw it.Startup
initialConnectModeis an ingress option of every sender, with or without a journal."async"now also works in browsers.initial_connect_retryis case-insensitive.lazy_connectacceptson,off,trueandfalsein any case. Only the pooled client applies it:Sender.fromConfig()validates it, ignores it and logs a warning, as the other clients' standalone senders do.Store-and-forward defaults and layout
The typed
storeAndForwardoptions now default exactly as thesf_*keys do:durability: "memory"(was"append");maxBytes10 GiB (was 1 GiB);backpressurePolicy: "wait"(was"error");appendDeadlineMs30 s.Set
durability: "append"if every append must survive power loss.Each journal is a slot below the configured directory:
<directory>/<senderId>, wheresenderIddefaults todefault;<directory>/<senderId>-<slot>, i.e.default-Nwhere they usedsender-Nbefore.A typed sender built from
mainjournalled directly into<directory>. Move such a backlog into the slot directory before upgrading. A typed pool started withoutsenderIdadopts onlydefault-Nslots and warns when an earliersender-Nslot still holds journal segments; rename those todefault-N, or start the pool once withdrainOrphans: true. The client warns when the slot root itself holds journal segments.Connect-string keys
query_timeout_mssets the default query timeout;0disables it.Documentation
QWP.md,CLAUDE.md, the root, browser-client and nodejs-client READMEs and the benchmark README are updated to match. The regenerated TypeDoc reference underdocs/accounts for most of the changed files.Validation
Local runs on the head of this branch:
pnpm test: 1,266 tests pass.pnpm test:dist,pnpm check:packagesandpnpm test:qwp-browser(10 real-browser tests) pass.GitHub Actions, including the Enterprise durable-ACK E2E, passed on the previous head
dfaa95dand runs again on this one.