From d6a6169a034c17e84baaf0decd54815fb4850c31 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 24 Sep 2026 18:50:32 +0100 Subject: [PATCH 01/10] perf(qwp): avoid repeated validation, row promises, and frame planning --- .../client-core/src/_qwp/_core/ingress.ts | 65 +++++++-- .../client-core/src/_qwp/ingress-session.ts | 7 +- packages/client-core/src/_qwp/sender.ts | 134 +++++++++++++----- test/qwp/core.test.ts | 53 +++++++ test/qwp/sender.test.ts | 107 ++++++++++++++ 5 files changed, 322 insertions(+), 44 deletions(-) diff --git a/packages/client-core/src/_qwp/_core/ingress.ts b/packages/client-core/src/_qwp/_core/ingress.ts index 5aa94690..cebef945 100644 --- a/packages/client-core/src/_qwp/_core/ingress.ts +++ b/packages/client-core/src/_qwp/_core/ingress.ts @@ -62,8 +62,9 @@ interface ColumnEncodeOptions { deltaSymbols: boolean; dictionary?: QwpSymbolDictionary; /** - * Scoped to a single encodeQwpIngressFrame() call, so a column mutated - * between calls can never be sized from a stale plan. + * Scoped to a single frame plan -- one encodeQwpIngressFrame() call, or one + * measureQwpIngressFrame() and its encode() -- so a column mutated between + * frames can never be sized from a stale plan. */ plans: Map; } @@ -922,27 +923,73 @@ function planQwpIngressFrameEncoding( }; } -/** @internal Measures without allocating the full frame output buffer. */ +/** @internal A sized ingress frame that can be encoded without replanning. */ +export interface QwpMeasuredIngressFrame { + /** Exact byte length encode() will produce. */ + readonly byteLength: number; + /** + * Encodes the frame from the plan that measured it. Call it at most once, + * before the tables or the dictionary change, in the same synchronous + * section as the measurement. + */ + encode(): Uint8Array; +} + +/** + * @internal Sizes a frame without allocating its output buffer. + * + * Measuring and then encoding used to plan the frame twice: the plan resolves + * every symbol against the dictionary and sizes every column, which made it + * the costliest step on the flush path. The measurement now holds on to its + * plan so encode() writes straight from it. + * + * On failure, either step truncates the dictionary to its size before + * measuring. A caller that discards a measured frame without encoding it must + * truncate the dictionary itself. + */ export function measureQwpIngressFrame( tables: readonly QwpTableBuffer[], options: QwpIngressEncodeOptions = {}, -): number { +): QwpMeasuredIngressFrame { const dictionarySize = options.dictionary?.size; - try { - const plan = planQwpIngressFrameEncoding(tables, options); - return QWP_HEADER_SIZE + plan.payloadLength; - } catch (error) { + const restoreDictionary = (): void => { if (dictionarySize !== undefined) options.dictionary!.truncate(dictionarySize); + }; + let plan: QwpIngressFrameEncodingPlan; + try { + plan = planQwpIngressFrameEncoding(tables, options); + } catch (error) { + restoreDictionary(); throw error; } + return { + byteLength: QWP_HEADER_SIZE + plan.payloadLength, + encode: () => { + try { + return writeQwpIngressFrame(tables, plan); + } catch (error) { + restoreDictionary(); + throw error; + } + }, + }; } function encodeQwpIngressFrameInternal( tables: readonly QwpTableBuffer[], options: QwpIngressEncodeOptions, ): Uint8Array { - const plan = planQwpIngressFrameEncoding(tables, options); + return writeQwpIngressFrame( + tables, + planQwpIngressFrameEncoding(tables, options), + ); +} + +function writeQwpIngressFrame( + tables: readonly QwpTableBuffer[], + plan: QwpIngressFrameEncodingPlan, +): Uint8Array { const writer = new QwpByteWriter(QWP_HEADER_SIZE + plan.payloadLength); writeQwpFrameHeader(writer, { flags: plan.flags, diff --git a/packages/client-core/src/_qwp/ingress-session.ts b/packages/client-core/src/_qwp/ingress-session.ts index cfc45f1c..76e597a6 100644 --- a/packages/client-core/src/_qwp/ingress-session.ts +++ b/packages/client-core/src/_qwp/ingress-session.ts @@ -142,12 +142,15 @@ function planIngressFrames( // oversized candidate before every bisection briefly allocated many // multiples of the negotiated cap and could exhaust the process before // splitting had a chance to help. - frameByteLength = measureQwpIngressFrame(candidate, candidateOptions); + // A candidate that fits is encoded from the plan that measured it, + // in this same synchronous section, rather than planned a second time. + const measured = measureQwpIngressFrame(candidate, candidateOptions); + frameByteLength = measured.byteLength; if (frameByteLength > maxBatchSizeBytes) { if (dictionarySize !== undefined) dictionary!.truncate(dictionarySize); } else { - const frame = encodeQwpIngressFrame(candidate, candidateOptions); + const frame = measured.encode(); frames.push(frame); if (dictionary) confirmedMaxSymbolId = dictionary.size - 1; return; diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index 9a976957..cb70065b 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -17,7 +17,11 @@ import { type QwpIngressSendResult, type QwpIngressMetrics, } from "./ingress-session"; -import { qwpColumnNameKey, validateQwpColumnName } from "./_core/identifiers"; +import { + qwpColumnNameKey, + validateQwpColumnName, + validateQwpTableName, +} from "./_core/identifiers"; import { exceedsQwpTimerCeiling, QWP_MAX_TIMER_DELAY_MS, @@ -1141,16 +1145,23 @@ function encodeQwpWriterValue( const QWP_TABLE_WRITER_CONSTRUCTOR = Symbol("QWP table writer constructor"); +/** Returned by at()/atNow() for a row that started no flush. */ +const SETTLED: Promise = Promise.resolve(); + +function ignoreResult(): void {} + /** A reusable table-bound writer compiled from a QWP schema. */ export class QwpTableWriter { /** @internal Construct table writers with QwpSender.writer(). */ constructor( token: typeof QWP_TABLE_WRITER_CONSTRUCTOR, readonly tableName: string, + // Returns undefined when the row staged without starting an auto-flush, + // so a stream of rows allocates no promise per row. May throw. private readonly appendRow: ( row: unknown, rowIndex?: number, - ) => Promise, + ) => Promise | undefined, ) { if (token !== QWP_TABLE_WRITER_CONSTRUCTOR) { throw new TypeError("QWP table writers must be created by QwpSender"); @@ -1158,8 +1169,9 @@ export class QwpTableWriter { } /** Validates and atomically appends one complete object row. */ - row(row: QwpWriterRow): Promise { - return this.appendRow(row); + async row(row: QwpWriterRow): Promise { + // `async` so a rejected row rejects rather than throws, as documented. + await this.appendRow(row); } /** Appends a synchronous or asynchronous stream of complete object rows. */ @@ -1183,7 +1195,10 @@ export class QwpTableWriter { let rowIndex = 0; for await (const row of rows) { - await this.appendRow(row, rowIndex++); + // Await only a flush the row actually started. Awaiting every row cost + // a promise per row, a large share of this loop's allocation churn. + const flushing = this.appendRow(row, rowIndex++); + if (flushing !== undefined) await flushing; } } } @@ -1356,10 +1371,13 @@ export class QwpSender { table(name: string): QwpSender { this.throwIfUnavailable(); if (this.current) throw new Error("Table name has already been set"); - // Validate eagerly rather than waiting for flush. - new QwpTableBuffer(name, this.maxNameLength); let table = this.tablesByName.get(name); if (!table) { + // Validate eagerly rather than waiting for flush. Only names reach + // tablesByName once validated, so a table already staged skips it: the + // fluent API names the same table on every row, and rescanning it each + // time was a measurable share of the builder's per-row cost. + validateQwpTableName(name, this.maxNameLength); table = { name, rows: [], @@ -1394,17 +1412,45 @@ export class QwpSender { if (value !== null && value !== undefined) return false; try { this.throwIfUnavailable(); - this.requireTable(); + const table = this.requireTable(); if (typeof name !== "string") { throw new TypeError("column name must be a string"); } - validateQwpColumnName(name, this.maxNameLength); + this.validateColumnName(table, name, qwpColumnNameKey(name)); } catch (error) { this.failRow(error); } return true; } + /** + * Validates a fluent column name unless the table already knows a column by + * exactly this spelling. + * + * The fluent API repeats the same names on every row, and rescanning each + * one per cell was a measurable share of the builder's per-row cost. Every + * name in a table's staged or published schema was validated before it got + * there, so an exact match proves this one is valid. The match has to be + * exact, not by key: two spellings can share a case-insensitive key and + * still differ in UTF-8 length. The designated timestamp's empty name is + * never valid for an ordinary column, so it is always checked. + * + * Unlike a global memo, this needs no bound of its own: it lives exactly as + * long as the schema it reads from. + */ + private validateColumnName( + table: StagedTable, + name: string, + nameKey: string, + ): void { + if (name.length !== 0) { + const known = + table.schema.get(nameKey) ?? table.publishedSchema.get(nameKey); + if (known !== undefined && known.name === name) return; + } + validateQwpColumnName(name, this.maxNameLength); + } + symbol(name: string, value: unknown): QwpSender { if (this.omitsNullish(name, value)) return this; // String() runs inside the guard, not in addColumn's argument list: the @@ -1914,32 +1960,48 @@ export class QwpSender { return this; } - async at( - value: number | bigint, - unit: QwpTimestampUnit = "us", - ): Promise { + // at() and atNow() are deliberately not `async`: an async method allocates + // a promise per row even when no flush starts, which is almost every row. + // They still never throw -- every failure is a rejected promise, as when + // they were async -- and a row that starts no flush gets a shared settled + // promise. + at(value: number | bigint, unit: QwpTimestampUnit = "us"): Promise { try { const timestamp = timestampValue(value, unit); this.addColumn("", timestamp.type, timestamp.value, {}, true); this.finishRow(); } catch (error) { - this.failRow(error); + this.discardRow(); + return Promise.reject(error); } - await this.tryFlush(); + return this.flushAfterRow(); } - async atNow(): Promise { - this.throwIfUnavailable(); - const table = this.requireTable(); - if ( - this.rejectZeroColumnRows && - this.currentRow.size === 0 && - table.knownColumnNames.size === 0 - ) { - return this.failRow(new Error("no columns were provided")); + atNow(): Promise { + try { + this.throwIfUnavailable(); + const table = this.requireTable(); + if ( + this.rejectZeroColumnRows && + this.currentRow.size === 0 && + table.knownColumnNames.size === 0 + ) { + this.failRow(new Error("no columns were provided")); + } + this.finishRow(); + } catch (error) { + return Promise.reject(error); + } + return this.flushAfterRow(); + } + + /** tryFlush() for the public row closers, which must return a Promise. */ + private flushAfterRow(): Promise { + try { + return this.tryFlush() ?? SETTLED; + } catch (error) { + return Promise.reject(error); } - this.finishRow(); - await this.tryFlush(); } /** @@ -2422,11 +2484,12 @@ export class QwpSender { return { columns, estimatedBytes: stagedRowBytes(columns) }; } - private async appendCompiledWriterRow( + /** Stages one writer row; returns only the auto-flush it started, if any. */ + private appendCompiledWriterRow( schema: CompiledQwpWriterSchema, input: unknown, rowIndex: number | undefined, - ): Promise { + ): Promise | undefined { this.throwIfUnavailable(); // Report the conflicting fluent row before validating this one: it is the // actionable error, and row contents cannot be staged either way. @@ -2532,7 +2595,7 @@ export class QwpSender { "debug", `Pending QWP rows: ${this.pendingRowCount}, estimated bytes: ${this.pendingByteCount}`, ); - await this.tryFlush(); + return this.tryFlush(); } private fixedDecimalColumn( @@ -2574,10 +2637,10 @@ export class QwpSender { if (typeof name !== "string") { throw new TypeError("column name must be a string"); } + const nameKey = qwpColumnNameKey(name); if (!designatedTimestamp) { - validateQwpColumnName(name, this.maxNameLength); + this.validateColumnName(table, name, nameKey); } - const nameKey = qwpColumnNameKey(name); // The fluent API is first-value-wins within a row. Apply that rule before // schema reconciliation: a duplicate decimal with a different scale is // ignored, not rescaled and allowed to discard the value already staged. @@ -2800,7 +2863,11 @@ export class QwpSender { return this.releaseStagedRows(snapshots, this.stagingGeneration, false); } - private async tryFlush(): Promise { + /** + * Starts an auto-flush when a threshold is crossed and returns it, or + * returns undefined -- the common case -- without allocating a promise. + */ + private tryFlush(): Promise | undefined { const byteThreshold = this.effectiveAutoFlushByteThreshold(); if ( this.autoFlush && @@ -2810,8 +2877,9 @@ export class QwpSender { (this.autoFlushIntervalMs > 0 && Date.now() - this.lastFlushTime >= this.autoFlushIntervalMs)) ) { - await this.enqueueFlush(this.transactional); + return this.enqueueFlush(this.transactional).then(ignoreResult); } + return undefined; } private async flushNow( diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index 88d9e9e5..bc1ca52a 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -51,6 +51,7 @@ import { encodeUtf8, utf8Length, } from "../../packages/client-core/src/_qwp/_core/bytes"; +import { measureQwpIngressFrame } from "../../packages/client-core/src/_qwp/_core/ingress"; function dataView(bytes: Uint8Array): DataView { return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -896,6 +897,58 @@ describe("QWP ingress codec", () => { ]); }); + it("encodes a measured frame from its plan exactly as a fresh encode", () => { + const table = (): QwpTableBuffer => { + const result = new QwpTableBuffer("trades"); + for (const [symbol, price] of [ + ["ETH-USD", 1.5], + ["BTC-USD", 2.5], + ["ETH-USD", 3.5], + ] as const) { + result + .getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)! + .values.push(symbol); + result + .getOrCreateColumn("price", QWP_COLUMN_TYPE.DOUBLE)! + .values.push(price); + result.nextRow(); + } + return result; + }; + for (const delta of [false, true]) { + const measuredDictionary = delta ? new QwpSymbolDictionary() : undefined; + const freshDictionary = delta ? new QwpSymbolDictionary() : undefined; + const measured = measureQwpIngressFrame([table()], { + dictionary: measuredDictionary, + confirmedMaxSymbolId: delta ? -1 : undefined, + }); + const encoded = measured.encode(); + const fresh = encodeQwpIngressFrame([table()], { + dictionary: freshDictionary, + confirmedMaxSymbolId: delta ? -1 : undefined, + }); + expect(encoded.byteLength).toBe(measured.byteLength); + expect(encoded).toEqual(fresh); + expect(measuredDictionary?.entriesFrom(0)).toEqual( + freshDictionary?.entriesFrom(0), + ); + } + }); + + it("restores the dictionary when a measured frame fails to plan", () => { + const dictionary = new QwpSymbolDictionary(); + dictionary.getOrAdd("ETH-USD"); + const table = new QwpTableBuffer("trades"); + table.getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)!.values.push("X"); + table.nextRow(); + // An out-of-range published ID fails the plan after nothing was added; + // the dictionary must come back at its pre-measure size either way. + expect(() => + measureQwpIngressFrame([table], { dictionary, confirmedMaxSymbolId: 5 }), + ).toThrow(/published symbol dictionary ID is out of range/); + expect(dictionary.size).toBe(1); + }); + it("encodes a full inline symbol dictionary with dense first-seen IDs", () => { // Without a connection dictionary the encoder emits a per-column dictionary // and one ID per row. Resolving each row used to be O(rows x distinct) via diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 0a240d7b..71b1868a 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -690,6 +690,113 @@ describe("QWP high-level sender", () => { await sender.close(); }); + it("keeps validating identifiers that only resemble ones already staged", async () => { + // Staged names skip revalidation, so each case below checks that the skip + // applies only to the exact spelling that was validated. + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + // An invalid name fails on every attempt, not only the first. + for (let attempt = 0; attempt < 2; attempt++) { + expect(() => sender.table("bad/table")).toThrow( + /table name contains illegal characters/, + ); + expect(() => sender.table("events").longColumn("bad-col", 1n)).toThrow( + /column name contains illegal characters/, + ); + } + + // U+212A (KELVIN SIGN) lower-cases to "k", so these two names share a + // column key, but only the ASCII one fits the UTF-8 byte limit. + const ascii = "k".repeat(100); + const kelvin = "\u212a".repeat(100); + const expectKelvinRejected = (): void => { + expect(() => sender.table("events").longColumn(kelvin, 2n)).toThrow( + /column name too long/, + ); + expect(() => sender.table("events").longColumn(kelvin, null)).toThrow( + /column name too long/, + ); + }; + + await sender.table("events").longColumn(ascii, 1n).at(1n); + // Staged, not yet published. + expectKelvinRejected(); + // The designated timestamp's empty name is in the schema too; it must not + // make an empty ordinary column name look validated. + expect(() => sender.table("events").longColumn("", 1n)).toThrow( + /column name cannot be empty/, + ); + expect(() => sender.table("events").longColumn("", null)).toThrow( + /column name cannot be empty/, + ); + await sender.flush(); + // Published: the staged frame schema is gone, the published one remains. + expectKelvinRejected(); + + // The exact spelling is still accepted without a hitch. + await sender.table("events").longColumn(ascii, 3n).at(2n); + await sender.flush(); + expect(column(session.sends.at(-1)!.tables[0], ascii).values).toEqual([3n]); + await sender.close(); + }); + + it("rejects rather than throws from at() and atNow()", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlush: false }); + + let pending: Promise | undefined; + expect(() => { + pending = sender.table("events").longColumn("value", 1n).at(1.5); + }).not.toThrow(); + await expect(pending).rejects.toThrow(); + // The failed row was discarded along with its table selection. + await sender.table("events").longColumn("value", 2n).at(1n); + + expect(() => { + pending = sender.atNow(); + }).not.toThrow(); + await expect(pending).rejects.toThrow(/table name must be set/); + + await sender.flush(); + expect(session.sends).toHaveLength(1); + expect(column(session.sends[0].tables[0], "value").values).toEqual([2n]); + + await sender.close(); + expect(() => { + pending = sender.at(1n); + }).not.toThrow(); + await expect(pending).rejects.toThrow(); + expect(() => { + pending = sender.atNow(); + }).not.toThrow(); + await expect(pending).rejects.toThrow(); + }); + + it("auto-flushes writer row streams and rejects bad writer rows", async () => { + const session = new RecordingSession(); + const sender = new QwpSender(async () => session, { autoFlushRows: 2 }); + const writer = sender.writer("events", { value: long() }); + + let pending: Promise | undefined; + expect(() => { + pending = writer.row({ value: "wrong" } as never); + }).not.toThrow(); + await expect(pending).rejects.toBeInstanceOf(QwpWriterRowError); + + await writer.rows([1n, 2n, 3n, 4n, 5n].map((value) => ({ value }))); + // Two rows per auto-flush; the fifth waits for an explicit flush. + expect(session.sends.map((send) => send.tables[0].rowCount)).toEqual([ + 2, 2, + ]); + expect(sender.metrics.pendingRows).toBe(1); + await sender.flush(); + expect( + session.sends.flatMap((send) => column(send.tables[0], "value").values), + ).toEqual([1n, 2n, 3n, 4n, 5n]); + await sender.close(); + }); + it("returns a publication sequence and waits for its ACK independently", async () => { const session = new WatermarkSession(); const sender = new QwpSender(async () => session, { From 6555ed16b9f3b6a6ab07fb7c69d5d4d70c549daf Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 24 Sep 2026 19:06:43 +0100 Subject: [PATCH 02/10] chore(skills): port review-pr skill to Pi --- .agents/skills/review-pr/SKILL.md | 902 ++++++++++++++++++++++++++++++ 1 file changed, 902 insertions(+) create mode 100644 .agents/skills/review-pr/SKILL.md diff --git a/.agents/skills/review-pr/SKILL.md b/.agents/skills/review-pr/SKILL.md new file mode 100644 index 00000000..94f1dd57 --- /dev/null +++ b/.agents/skills/review-pr/SKILL.md @@ -0,0 +1,902 @@ +--- +name: review-pr +description: Review a GitHub pull request or local Git range against the QuestDB JavaScript client TypeScript ILP/QWP coding standards. Use when asked to review a PR or a local diff. +--- + +# Review a Node.js client pull request + +**Usage:** `/skill:review-pr [PR number or URL | --range=..] [--level=0..3]` + +Review the PR or local range identified by the invocation arguments. Pi appends +arguments after `/skill:review-pr` as a user request; treat that request text as +the review arguments. When invoked by name in ordinary chat, parse the user's +request the same way. Parse exactly one review target: a PR number/URL, or +`--range=..`. The range head may be omitted (`--range=..`) to +review the working tree, including uncommitted changes. If both targets are supplied, +stop and ask which was intended. If neither is supplied, ask for one. + +Use Pi's `bash` for read-only `gh` and Git queries, repository searches (`rg`, +`rg --files`), and evidence-driven validation commands. Use `read` to inspect +files. Do not edit the primary working tree, push, post comments, or mutate the PR. +Step 3b may create isolated temporary worktrees for head/base execution or to +verify a regression test against reverted production hunks; remove them afterward. +Run validation that writes files only in a disposable worktree. Temporary review +artifacts must stay outside the repository. Do not use Pi's `edit` or `write` +tools on the project during a review. + +## Review mindset + +You are a senior QuestDB engineer performing a blocking code review. +`@questdb/nodejs-client` and `@questdb/browser-client` are mission-critical software, +built from a shared private `@questdb/client-core` workspace package: they serialize +rows into the QuestDB InfluxDB Line Protocol (ILP) over HTTP/HTTPS or TCP/TCPS, and +into the QuestDB Wire Protocol (QWP) over WebSocket or fire-and-forget UDP, with a +browser build, an egress query path, and a crash-safe Node store-and-forward journal. +A bug can silently +corrupt bytes, drop or duplicate rows, abandon persisted data, leak credentials, +exhaust resources, or break supported Node.js and browser consumers. + +**A review that blocks on everything blocks on nothing.** Every finding costs an +author and CI round-trip. Reserve blocking severity for defects with a real user +consequence, report other issues at the severity their evidence earns, and approve +when the gates pass. Zero findings is a successful outcome. + +- **Assume nothing is correct until verified.** Read surrounding source and tests; + do not review the diff in isolation. +- **Treat the diff as the entry point, not the boundary.** Contract changes often + break unchanged callers, overrides, transports, protocol versions, or generated + type consumers. +- **Discovery is not a finding.** Every concern, including agent output, is an + untrusted hypothesis until it passes Step 3b. Omit anything unproved. +- **Falsify before explaining.** Search for guards, validation, retries, alternate + callers, unsupported configurations, and identical base behavior before building + a failure narrative. Failure to disprove is not proof. +- **Keep the PR blast radius small.** The PR owns defects it introduces or exposes. + Pre-existing behavior that is unchanged from base does not block it; a fully proved + pre-existing bug may leave as an adjacent issue draft. +- **Do not praise the code.** Focus on defects, risks, and missing evidence. +- **Think adversarially.** Exercise `null`/`undefined`, empty strings and arrays, + `NaN`/`Infinity`, imprecise `number` integers, `bigint`, multi-byte UTF-8, all ILP + delimiters, maximum buffer sizes, retries after uncertain sends, connection drops, + TLS/auth failures, and every negotiated protocol version. For QWP also exercise + mid-frame socket loss, replay after a restart, a NACK of an already replayed frame, + a full or externally locked journal directory, a role-rejected or capability-gapped + endpoint, and a truncated or hostile server frame. +- **Store-and-forward promises no data loss.** Once rows enter the journal, only a + rejection that is deterministic under byte-identical replay may abandon them, and a + transient outage must never end the replay loop or surface to the producer. Treat a + breach of the store-and-forward checklist as Critical. +- **Demand efficient hot paths.** Per-row and per-cell work scales to millions of + rows. Avoid allocations, repeated scans, redundant conversions, extra buffer copies, + and suboptimal algorithms there. Bounded setup/configuration work is less severe. +- **Check what is missing.** Look for absent error handling, cleanup, tests, public + exports, TSDoc, README changes, deprecation wiring, and cross-transport parity. +- **Untested behavior is a coverage risk, not proof of a defect.** A missing test is + Critical only when a supported, reachable regression could cause material user harm + and existing safeguards do not contain it. +- **Verify every PR claim.** Reproduce fixes where practical, check performance claims + against the actual multiplier, and treat the PR description as a hypothesis. +- **Assess reachability before reporting.** Drop theoretical paths that callers, + validation, configuration, or buffer bounds make impossible. +- **Never review generated artifacts as source.** `packages/*/dist/**` and `docs/**` + are generated. Review their `packages/*/src/**/*.ts` or documentation source + instead. + +## Review level + +Parse the review arguments for `--level=N`, `-lN`, or a bare digit `0`-`3`. +Default to level 0. Strip the level token and any `--range=` token before passing +a PR target to `gh`. + +| Level | What runs | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **0 (default)** | Steps 1, 2, 2.4, 2.5f, 2.6, and 4. Discover issues inline without subagents. Build a compact coverage map and apply the Step 3b admission gate inline from a blank evidence form. | +| **1** | Add Steps 2.5a and 2.5e when tests change. Run Role 1 plus at most two applicable roles from Roles 2-7, 9-13, and 14-15. Independently falsify each surviving atomic candidate. | +| **2** | Run all of Step 2.5, restricting 2.5b to exported/public/protected symbols, transport interfaces, shared helpers, and configuration options. Run Role 1 plus at most four change-relevant roles. Independently falsify each surviving candidate. | +| **3** | Run the full workflow. Select at most six applicable discovery roles: Role 1 always; Role 8 when changed symbols have out-of-diff callers; Roles 2-7 and 14-15 when their domains are touched; Roles 9-13 for changed tests or a fix claim; Role 10 only when a distinct adversarial pass is warranted. Depth comes from evidence, not agent count. | + +State the selected level at the start of the review. If defaulted, mention that level +3 exists for a full mission-critical pass. Changes to +`packages/nodejs-client/src/buffer/**`, `packages/client-core/src/_qwp/**`, +`packages/nodejs-client/src/qwp-node/**`, transport/auth/TLS, protocol negotiation, +flush semantics, or either public entry point (`packages/nodejs-client/src/index.ts`, +`packages/browser-client/src/index.ts`) are high risk; recommend level 3, but honor +an explicit lower level and state the limitation. Replay-journal, ack-watermark, drainer, and failover +changes stay high risk regardless of how small the diff is. + +## Pi review delegation (levels 1-3) + +An explicit user request for level 1, 2, or 3 authorizes the corresponding review +fanout. A default level 0 review stays inline; complexity alone never authorizes +subagents. Before delegating, read the installed `pi-subagents` skill and its +review/execution guidance. Call `subagents_enable`, then use Pi's `subagent` tool +with fresh-context `reviewer` children and an explicit read-only task boundary. +Use `workflowScript` with `runs.all` for a bounded discovery wave; give each child +a stable key and short label. A single bounded falsifier can use a direct +`{ agent: "reviewer", task: "...", context: "fresh", async: true }` launch. +Prefer async runs and consume their results before admission; Pi notifies the +parent on completion. Never treat a reviewer response as proof or authorization +to modify the PR. Do not replace a failed subagent run with an unannounced CLI +agent or claim that the requested level completed; report the blocker. + +Steps 3 and 3b use fresh-context, read-only reviewer tasks. Give each child the +repository/cwd, revision identities, relevant artifacts, goal, scope, authority +boundary, evidence requirements, and concise output contract. Discovery tasks +receive the diff, Step 2.4 gitlink verdicts, the Step 2.5 surface map, the Step 2.6 +coverage map, the chosen role, and the candidate contract. Role 10 and Step 3b +falsifiers are deliberate reduced-context exceptions. Do not give a falsifier +the discovery map or parent review narrative. No child delegates further. + +Put parent-produced maps in temporary files outside the repository. For child +reports, use runtime-managed relative `output` paths instead of writing into +the repository or pasting large maps into every prompt. Never pass a discovery +narrative, proposed severity/fix, votes, or verification claims to a falsifier. The parent owns role selection, the private candidate ledger, +admission, severity, deduplication, and the final report. + +## Step 1: Gather review context + +Every mode must end with `$BASE` and `$HEAD` identified. Behavioral findings require +the same trigger at both revisions unless the surface is genuinely new. + +### GitHub PR + +```bash +PR='' +gh pr view "$PR" --json number,title,body,labels,state,baseRefOid,headRefOid +gh pr diff "$PR" +gh pr view "$PR" --comments +BASE=$(gh pr view "$PR" --json baseRefOid --jq .baseRefOid) +HEAD=$(gh pr view "$PR" --json headRefOid --jq .headRefOid) +``` + +Also inspect the commit subjects with a read-only query when available. Do not check +out the PR into the primary working tree merely to review it. + +### Local range (`--range`) + +```bash +BASE='' +HEAD='' +git diff "$BASE"${HEAD:+"...$HEAD"} --stat +git diff "$BASE"${HEAD:+"...$HEAD"} +git diff "$BASE"${HEAD:+"...$HEAD"} --name-only +git status --porcelain +``` + +With an empty head, include staged and unstaged tracked changes. `git diff` omits +untracked files, so read any untracked source/test files that belong to the change. +In range mode skip Step 2 because there is no PR metadata, state that fact, and run +all other selected steps normally. + +## Step 2: PR title and description + +Skip this step in range mode. + +Check the repository conventions in `CONTRIBUTING.md` and recent accepted PRs: + +- Title follows Conventional Commits: `type(scope): description`. +- Description explains end-user impact, not only implementation details. +- A bug fix links or closes its issue. +- Tone is analytical and avoids superlatives. +- Public API, option/default, export, or compatibility changes are explicit. +- README/TSDoc updates accompany user-visible behavior where needed. +- New or renamed options document their defaults and deprecation path through + `SenderOptions.resolveDeprecated`. +- New or renamed QWP keys are wired through + `packages/nodejs-client/src/qwp-node/client-config.ts`, validated against the + transports that support them, and documented in `QWP.md`. +- A changed public QWP surface updates `test/qwp/public-api-contract.ts`. + +## Step 2.4: Submodule boundaries (mandatory at every level) + +Treat submodule gitlink changes as opaque. Detect mode `160000` pointer moves, record +the path and old/new hashes, and classify each as exactly: + +```bash +git diff --raw "$BASE"${HEAD:+"...$HEAD"} | grep -E '^:160000 |^:[0-7]{6} 160000 ' +``` + +- **OPAQUE** — the superproject changes only the gitlink. Do not enter the submodule, + fetch its branches, expand the commit range, inspect its files, attribute upstream + behavior changes to this PR, or report findings from its contents. Assume the + referenced changes were already merged and reviewed upstream. + +Review submodule contents only when the user explicitly requests that as an independent +task. A genuine integration defect remains in scope only when code in the superproject +diff calls or configures the bumped submodule incorrectly; file the finding at that +superproject callsite and do not use an expanded submodule range as evidence. + +Repeat each `OPAQUE` verdict in Step 4 so the scope decision is auditable. If no +gitlinks changed, state `Submodules: none` in the summary. + +## Step 2.5: Map the change surface + +Use `rg` and `rg --files` via `bash` rather than reasoning about callers from +memory. The resulting map is input to every normal Step 3 reviewer. + +### 2.5a Semantic delta per changed symbol + +For every modified or added function, method, class, abstract/protected member, +exported type/constant, interface, and configuration option, record: + +- **Symbol:** fully qualified name. +- **Before:** signature, sync/async return shape, thrown errors and inputs, state + mutation (`hasTable`, `hasSymbols`, `hasColumns`, `position`, `endOfLastRow`), + allocation behavior, protocol versions, and exact wire bytes where applicable. +- **After:** the same fields. +- **Delta:** the concrete behavioral difference. Use `no behavioral change` only + after checking; words such as “refactored” or “simplified” are insufficient. + +### 2.5b Callsite inventory + +For every changed exported/public/protected symbol, base-class member, shared helper, +transport-interface method, or option name, search all source, tests, README/examples, +and exports. Group results by file and include overrides and implementations. + +At minimum check: + +- Both public entry points — `packages/nodejs-client/src/index.ts` and + `packages/browser-client/src/index.ts` — and emitted public type implications. + Each re-exports the shared `packages/client-core/src/qwp` barrel, so a change + there reaches both packages. +- `SenderBufferBase` plus `SenderBufferV1`/`V2`/`V3` overrides and `createBuffer`. +- `SenderTransport` plus Undici, stdlib HTTP, and TCP implementations. +- `SenderOptions.resolveAuto`, `resolveDeprecated`, config parsing, `fromConfig`, and + `fromEnv` for option changes, plus + `packages/nodejs-client/src/qwp-node/client-config.ts` for QWP keys. +- Changed `packages/client-core/src/_qwp/_core/**` constants and codecs against both + the ingress encoder and the egress decoder; one cap or type byte is normally read + by both sides. +- `QwpSender` and the writer helpers, `QwpIngressSession`, `QwpEgressSession`, + `QwpClient`, the reconnecting connections in + `packages/client-core/src/_qwp/_internal/**`, and the UDP sender. +- `QwpNodeFileReplayStore`, `QwpNodeOrphanDrainer`, the advisory lock, and the segment + maintenance worker for any store-and-forward change. +- Unit/integration tests and test helpers, including `test/qwp/**` and its fixtures. +- `test/qwp/public-api-contract.ts` for any exported QWP symbol, type, or option. +- `README.md`, `QWP.md`, and examples for public symbols/options. + +A changed shared symbol with no recorded `rg` command is a skill violation. Never +assert “only used here” without the search trace. + +### 2.5c Implicit contract list + +For each changed symbol, record before versus after for every applicable contract: + +- Inputs that throw synchronously and which callers catch or propagate. +- `null`/`undefined`: accept, reject, or omit. `strictNullChecks` is off, so validate + runtime behavior rather than trusting the signature. +- Buffer capacity: bytes reserved by `checkCapacity(data, base)` versus bytes emitted. +- Row state: reads/transitions of `hasTable`, `hasSymbols`, `hasColumns`, `position`, + and `endOfLastRow`, including empty-row closure. +- Sync/async shape and whether every caller awaits it. +- ILP bytes: separators, escaping, marker bytes, byte order, arrays, timestamp units, + decimal payloads, and protocol-version applicability. +- Transport lifecycle, retry/idempotency, auto-flush behavior, auth, TLS, and cleanup. +- Number precision: `number` versus `bigint`, especially LONG and nanosecond values. +- Configuration name/default/validation/deprecation behavior. +- Allocation and complexity on setup, per-row, and per-cell paths. + +### 2.5d Cross-context exposure list + +List places where the change is visible but the diff does not touch, grouped by: + +- Per-row/per-cell buffer-build hot path. +- Protocol-version fanout (ILP v1/v2/v3, and the QWP frame version with its negotiated + caps and capabilities). +- Transport fanout (Undici, stdlib HTTP, TCP/TCPS, QWP WebSocket ingress, QWP egress, + Node UDP). +- Runtime fanout (Node entry points versus the browser build, which must stay free of + Node built-ins, `ws`, and `qwp-node` imports). +- Flush, commit, retry, replay, and lazy auto-flush paths. +- Reconnect, failover, role/capability rejection, and poison-frame escalation. +- Store-and-forward journal, orphan drainer, advisory locking, and the maintenance + worker thread. +- Protocol negotiation, durable-ACK capability negotiation, and configuration parsing. +- Auth/TLS and resource lifecycle. +- Worker-thread use (one mutable `Sender` per worker) and multi-process use of a single + store-and-forward directory. +- Public ESM/CJS/type surface across all four entry points. +- Tests, helpers, README, `QWP.md`, and examples. + +Every listed context must be checked in Step 3. + +### 2.5e Test surface and helper inventory + +Run when tests are added or changed. Use repository searches to record: + +- Existing setup/teardown, fixtures, mock HTTP/proxy helpers, buffer hex helpers, + custom matchers, and parameterized-test patterns the change could reuse. +- Callers of any changed shared test helper or fixture. +- The production symbols each changed test actually exercises. +- Whether the assertion observes public behavior, exact wire bytes, transport calls, + resource cleanup, or only implementation details. + +### 2.5f Build and runtime profile (mandatory at every level) + +Record current facts with file/line citations; do not rely on this list becoming stale: + +- TypeScript flags from `tsconfig.json`, especially `strictNullChecks`, + `noImplicitAny`, and `noUncheckedIndexedAccess`. +- Node.js version floor and `@types/node` version. +- Runtime dependencies and what each covers: `undici` (HTTP) and `ws` (Node QWP + WebSocket). There is no native dependency: store-and-forward locking is pure + JavaScript in `advisory-lock.ts`, using a `.lock.owner` directory as the mutex with + an mtime heartbeat for stale recovery. Reintroducing a native addon would break + every consumer on a platform or Node major it has no binary for, so treat a new + `optionalDependencies` entry or a compiled binary in the bundle as a finding. + `fzstd` is a dependency of the private `packages/client-core` package, which no + published package declares, so the bundler must inline it; making it an external + import would break every install. +- Dual ESM/CJS build. Each published package declares exactly one `package.json` + exports subpath, `.`, so those two specifiers are the whole public surface; check + which sources each is allowed to import. +- ILP protocol default/negotiation and TCP's explicit-version requirement. +- QWP `QWP_VERSION`, the `/write/v4` ingress and `/read/v1` egress routes, the caps in + `packages/client-core/src/_qwp/_core/constants.ts`, and the capabilities negotiated + per connection. +- `worker_threads` use by the segment maintenance worker, and the `Date.now()` / + `Math.random()` dependencies in backoff, episode, and timeout accounting that + deterministic tests must be able to control. +- `Buffer.write` versus `writeInt*` boundary semantics. A short `Buffer.write` can + silently truncate, while numeric writes throw out of bounds; `writeInt8` requires + `-128..127` and marker bytes above 127 must be sign-folded. + +Put the relevant facts at the top of normal Step 3 prompts. Role 10 receives only +the reduced context defined below. + +## Step 2.6: Test coverage map (mandatory at every level) + +For every production behavioral change, including each new branch/error/NULL/boundary +path, build an internal row containing: + +- **Change:** symbol and exact behavior/path. +- **Test:** exact test file and name found through recorded `rg`/`rg --files` searches. +- **Failure link:** assertion and why it fails if the behavior regresses. +- **Reachability/population:** supported API/configuration/event and affected users. +- **Credible consequence:** concrete recurrence and observed harm. +- **Change risk:** complexity, caller breadth, state/resource sensitivity, safeguards. +- **Stable test design:** least invasive meaningful unit/integration/fault-injection + assertion and observation seam. +- **Effort/fragility evidence:** concrete setup, nondeterminism, platform, or production + seam costs; “hard to test” alone is not evidence. +- **Dimensions:** applicable protocol, transport, runtime (Node/browser), happy/error, + NULL, boundary, concurrency, retry, reconnect/replay, crash-recovery, and + resource-cleanup dimensions. +- **Disposition:** `COVERED`, `CRITICAL GAP`, `MODERATE GAP`, `ACCEPTED GAP`, or `EXEMPT`. + +Mark rows with no effective assertion `UNTESTED` before classification. Missing tests +alone never establish Critical severity: + +- **Critical gap:** a supported reachable regression can cause data loss/corruption, + a security failure, outage/hang, compatibility break, unbounded resource loss, or + similarly material harm; safeguards do not contain it; Step 3b admits it. +- **Moderate gap:** meaningful but bounded exposure, including most bug fixes without + an effective regression test. +- **Accepted gap:** localized low-risk behavior where a stable test is demonstrably + disproportionate or more fragile than the code and existing safeguards are strong. +- **Exempt:** verified non-behavioral source, documentation, generated-output, or CI + changes. + +Publish only admitted gaps. Keep covered, accepted, exempt, and omitted rows private +unless the user asks for the complete map. + +## Step 3: Change-specific candidate discovery + +Use fresh-context, read-only Pi `reviewer` tasks for the selected roles. Select only +roles materially touched by the change and obey the review-level cap. Reviewer +count is never evidence. + +Every normal discovery task receives the diff, change-surface map, coverage map, and +these candidate rules: + +- Generate atomic, falsifiable hypotheses; do not assign severity, propose fixes, + write persuasive titles, or claim verification. +- Cite the exact changed hunk or unchanged callsite contract allegedly broken. +- Name the supported-state producer: exact public API call, option, protocol, server + response, runtime, or event that creates every trigger. Use `producer: unknown` + rather than inventing one. +- Give reachability, head observation, same-trigger base observation, user symptom, + evidence commands/artifacts, and strongest counterevidence. Mark unchecked fields + `unknown`. +- Universal claims such as “never”, “only”, “no retry”, or “all transports” require + an exhaustive caller/event-source inventory. +- Do not split supporting mechanisms into findings without independent consequences. +- Pre-existing unchanged behavior is not a PR finding. Fully proved pre-existing bugs + may be proposed as adjacent issues only after Step 3b. +- Returning no candidate is valid and preferred to speculation. + +### Reviewer roles + +**Role 1 — Correctness and ILP semantics:** Check nullish omission, separators, +escaping, input validation, integer precision, timestamp conversion, float edge cases, +array shape/type/emptiness, decimal encoding, error paths, and exact v1/v2/v3 wire +behavior. Check every changed symbol against its callers and overrides. + +**Role 2 — Buffer and byte-encoding safety:** Reconstruct bytes and capacity math. +Check every write against `checkCapacity`, UTF-8/escaping expansion, signed marker +bytes, little-endian numeric/dimension encoding, `position`, overlapping compaction, +resize/max-size behavior, `toBufferView` aliasing, `toBufferNew` mutation, and decimal +two's-complement bounds. + +**Role 3 — Transport, negotiation, auth, and TLS:** Check serializer negotiation, +TCP explicit versions, retry classification/idempotency, Undici/stdlib parity, Basic/ +Bearer/JWK credentials, secret exposure, TLS verification/custom roots, timeouts, and +connect/send/close behavior. + +**Role 4 — Async, concurrency, and flush semantics:** Check every Promise/`await`, +ordering across `at`/`atNow`/`tryFlush`/`flush`, row loss after `toBufferNew` compaction, +uncertain-send duplication, lazy interval/row-count auto-flush, and unsafe sharing or +interleaving of mutable Sender state. + +**Role 5 — Resource management and lifecycle:** Trace sockets, Undici pools/agents, +user-supplied versus owned agents, timers, abort controllers, listeners, and buffer +views on success, failure, and early return. Verify failed connect/send/TLS paths close +or preserve ownership correctly. + +**Role 6 — Performance and algorithmic optimality:** For each loop, scan, allocation, +copy, conversion, and data structure, state complexity and the best feasible approach. +Focus on per-row/per-cell `toString`, string concatenation, repeated `Buffer.byteLength`, +per-character writes, resize copying, large arrays, and avoidable buffer copies. Every +candidate must state its multiplier or fixed bound and whether users wait on the path. + +**Role 7 — Public API, compatibility, and code quality:** Check both package roots, +ESM/CJS exports, `.d.ts` implications, TSDoc, option defaults/deprecations, supported +Node APIs, README/examples, unsound casts, dead code/imports, ESLint, Prettier, +naming, and member ordering. Separate compatibility defects from cosmetics. + +**Role 8 — Cross-context caller impact:** Walk every 2.5b callsite with callers up to +two levels. For each, return `SAFE`, `CANDIDATE`, or `INSUFFICIENT_EVIDENCE` and state +whether the new contract breaks valid inputs, row state, bytes, sync/async shape, +protocol subclasses, transports, config readers, error/retry paths, or worker contexts. + +**Role 9 — Test coverage:** Recheck every Step 2.6 test and failure link, add missed +behavior rows, and mutation-spot-check the most dangerous changed conditions. Check +the matrix of protocols, transports, auth/TLS, auto-flush, resize, escaping, nullish +values, arrays, precision, timestamps, retry/error, and resource cleanup. + +**Role 10 — Fresh-context adversarial:** Receive only the diff and changed filenames. +Instruction: “Generate a small set of falsifiable ways this code could be wrong and +try to disprove each before returning it.” It may inspect the repository but receives +no surface map, checklists, prior candidates, severities, or fixes. + +**Role 11 — Test efficacy and correctness:** Trace each changed test from production +symbol to assertion. Find vacuous assertions, tests that do not reach the changed path, +wrong/stale expected wire bytes, happy-path-only coverage, swallowed asynchronous +assertion failures, timing-dependent synchronization, and cleanup failures. + +**Role 12 — Test-code quality:** Search the 2.5e inventory before flagging duplicated +setup or helpers. Check parameterization opportunities, misleading names, copy/paste +residue, debug output, commented code, unjustified skipped tests, brittle implementation +assertions, and unnecessary casts. Name a real reusable alternative for each complaint. + +**Role 13 — Regression-test efficacy:** For a bug-fix claim, identify which production +hunk each test depends on. A candidate survives only if the test passes at head and +fails when the production fix is reverted in an isolated scratch worktree. + +**Role 14 — QWP wire format and protocol sessions:** Reconstruct frame headers, +LEB128 varints, column encodings, Gorilla bit packing, zstd framing, symbol-dictionary +IDs with their delta/reset flags, decimal scale, geohash bits, array shape, and NULL +bitmaps against the caps in `packages/client-core/src/_qwp/_core/constants.ts`. Check +the ingress encoder and the egress decoder together because both read the same +constants. Check status-byte to +category to policy mapping, per-table transaction grouping, durable-ACK negotiation, +ingress cap splitting, and that a truncated, oversized, or hostile server frame is +rejected before it is allocated, copied, or trusted. + +**Role 15 — Store-and-forward, replay, and failover:** Verify the durability contract +in the checklist below. Trace the cumulative ack watermark, replay from +`ackedFsn + 1`, segment format and checkpoints, append backpressure and deadlines, +cross-process advisory locking, orphan-slot quarantine, poison-frame strike accounting, +capability-gap episodes, reconnect budgets, and endpoint health/zone ranking. Any path +that abandons accepted rows, advances the watermark past an unacknowledged frame, or +ends the steady-state replay loop on a transient failure is a data-loss candidate. + +Combine outputs into a private candidate ledger. Split compound narratives into atomic +propositions, deduplicate by proposition plus evidence, and record dependencies. Do not +draft severity, fixes, or report prose yet. + +## Step 3b: Independently falsify, prove, and admit candidates + +Use this state machine without shortcuts: + +`HYPOTHESIS → FALSIFYING → PROVEN → ADMITTED` + +Missing proof, unresolved contradiction, failed reproduction, unsupported producer, +or dependency on an omitted premise ends at `OMITTED`. “Could not disprove” is not +`PROVEN`, and there is no public downgraded/false-positive section. + +At levels 1-3, launch one fresh-context Pi `reviewer` falsifier per atomic candidate. +Give it only: + +1. The neutral proposition. +2. Repository plus base/head identities (or captured working-tree diff hash) and + relevant filenames. +3. Raw evidence/artifact paths. + +Do not send the discovery narrative, severity, fix, author identity, votes, or claims +that anyone verified it. At level 0, apply the same protocol inline from a blank form. + +The falsifier first constructs the strongest disproof: missing producer, unsupported +configuration, impossible version pairing, omitted caller, retry, guard, validation, +cleanup, downstream containment, or identical/better base behavior. Only a surviving +candidate receives affirmative proof. + +Admit a behavioral candidate only when every applicable field has cited evidence: + +- **Attribution:** changed hunk, or unchanged callsite plus changed contract. +- **Supported-state producer:** exact supported API/config/protocol/runtime/event. +- **Reachability:** complete producer-to-symptom path, including guards, retries, + dispatch, ownership, and cleanup. +- **Head observation:** executed trigger and observed result at the reviewed revision. +- **Base observation:** identical trigger/result at `$BASE`, or `N/A — genuinely new +surface` with proof. +- **User symptom:** independently observable consequence. +- **Counterevidence search:** strongest disproof and why it does not apply. +- **Artifact:** command/test, output, environment/configuration, and revision identity. + +Runtime-shape, race, ordering, retry, restart, resource-lifetime, compatibility, and +wire-format claims require executed artifacts; static reading alone cannot admit them. +For fully static compile errors or standards violations, mark runtime-only fields +`N/A — static` and cite the complete source proof. Coverage searches prove absence of +a test, not the reachability or impact needed for a Critical gap. + +Apply these special burdens: + +- Universal negatives require an exhaustive inventory and executed probe. +- Concurrency/order candidates must force or observe the interleaving. +- Regression-test candidates must run green at head and red with the production fix + reverted in a scratch worktree, never the primary working tree. +- If execution is impossible, record the limitation privately and omit the behavioral + candidate rather than replacing evidence with confident prose. +- If a parent premise is omitted, omit every dependent candidate. + +Then independently verify Node-client specifics: + +1. Read exact source lines in `packages/*/src/**/*.ts`, not generated output, and + trace callers, interfaces, factories, and v1/v2/v3 overrides. +2. Count every emitted byte against capacity, including escaped multi-byte UTF-8, + separators, suffixes, marker bytes, dimension headers, and decimal payloads. +3. Reconstruct expected wire bytes and compare them with both production output and + byte-level test expectations. +4. Validate nullish behavior at runtime because TypeScript nullability may be disabled. +5. Trace `toBufferNew`/compaction relative to awaited sends for loss/duplication claims. +6. Trace retry classes and whether the server could have durably accepted an uncertain + send before replay. +7. Trace every socket, agent, timer, abort controller, listener, and buffer view through + success/error/early return; never destroy a user-supplied agent. +8. For performance, prove complexity, hot/cold placement, call frequency, multiplier + or fixed bound, and a materially better feasible implementation. +9. For public API/config claims, check every export, parser, default, deprecation path, + README example, ESM/CJS output implication, and supported Node version. +10. For test efficacy, prove the assertion reaches the change and would fail under the + claimed regression. Recompute expected hex/bytes rather than trusting fixtures. +11. For QWP wire claims, reconstruct the frame bytes for encode and decode, and check + every length, cap, and flag against + `packages/client-core/src/_qwp/_core/constants.ts` rather than against an assumed + peer behavior. +12. For replay, ack, reconnect, or failover claims, trace the cumulative ack watermark + and prove which frames a restart, NACK, or non-orderly close resends or drops. + Classify the failure through `qwpDefaultSenderErrorPolicy` before calling anything + terminal. +13. For store-and-forward claims, execute against a real directory: fill it, hold its + lock from a second process, truncate or corrupt a segment, and kill the process + between append and checkpoint. Durability and crash-recovery claims need journal + artifacts, never source reading alone. +14. Derive a fix only after admission, then verify it compiles and closes all admitted + paths without creating a compatibility, ownership, or retry defect. + +### Net user impact and ledger classification + +Before assigning severity, answer in order: + +- **Population:** named supported API/config/protocol/runtime population. +- **Delta vs base:** observed difference for the identical trigger. +- **Magnitude/frequency:** per cell, row, flush, request, Sender lifetime, or once. +- **Offsets:** validation, retry, server rejection, type/build gate, operational process, + or other containment before the user sees harm. +- **Net:** `net-negative`, `net-neutral`, or `net-positive`. Only net-negative behavioral + candidates may be findings. + +Classify ledger entries as: + +- **ADMITTED in-diff** — proved defect inside the diff. +- **ADMITTED out-of-diff-breakage** — proved unchanged caller broken by this PR's + changed contract. +- **OMITTED pre-existing/not-attributed** — same or worse behavior exists at base and + this PR does not expose a new path. +- **OMITTED false** — counterevidence disproves it. +- **OMITTED unverified** — required producer, path, observation, artifact, or dependency + is missing. + +Keep omitted candidates and disproofs private. A fully proved pre-existing bug may +become an adjacent issue draft; false or unverified candidates never do. Verify every +enumerated instance independently rather than sampling and generalizing. + +## Review checklists + +### Correctness and wire format + +- Nullish omission must not emit a separator or leave invalid row state. +- `number` LONG values beyond `2^53` lose precision; nanosecond timestamps require + `bigint`; v1 timestamps use microseconds while v2+ preserve nanoseconds. +- Reject or intentionally encode `NaN`, `Infinity`, invalid units, invalid types, and + unsupported protocol features. +- Verify table/symbol/column escaping for space, comma, equals, newline, carriage + return, quote, backslash, and multi-byte UTF-8. +- Validate irregular/non-homogeneous/empty arrays and v2 dimension/type bytes. +- Verify v3 decimal sign, scale, length, two's complement, and big-endian payload. + +### Buffer and byte safety + +- Every write has capacity for actual escaped UTF-8 bytes and suffix/marker bytes. +- `writeInt8` values stay in `-128..127`; sign-fold unsigned marker bytes. +- Doubles, int32 values, and dimensions use correct little-endian width/order. +- Do not retain `toBufferView` across mutation; account for `toBufferNew` compaction. +- Verify overlapping compact copies, growth termination, `max_buf_size`, and exact + `position` advancement. + +### Transport, protocol, auth, and TLS + +- Negotiated serializer matches the server; TCP requires an explicit version. +- Retriable classification, backoff, and time budgets are correct; uncertain replay + cannot silently duplicate accepted rows. +- Undici and stdlib HTTP agree on auth, TLS, timeout, retry, and response handling. +- Basic/Bearer/JWK credentials are correct and never logged or included in errors. +- Verification is disabled only explicitly; custom CA/roots are applied. +- QWP endpoint selection honors the health and zone ranking; a background drainer + publishes health observations but never resets foreground classifications. +- Upgrade failures are classified into a `QwpUpgradeError` kind, and a browser's opaque + upgrade error is never reported as a specific cause. +- WebSocket close codes carry no policy meaning; classify by status byte and upgrade + kind instead. + +### QWP wire format and sessions + +- Frame header magic, version, flags, table count, and payload length agree between + encoder and decoder, and every cap in + `packages/client-core/src/_qwp/_core/constants.ts` is enforced on both sides. +- Varints stay inside uint64; row, column, name-length, array-element, and dictionary + limits are checked on encode and on decode. +- Symbol dictionary IDs stay dense and connection-scoped; delta and reset flags match + what the peer reconstructs, and a `DICTIONARY_GAP` rejection triggers catch-up rather + than a terminal failure. +- Gorilla, zstd, and raw encodings round-trip; decompression respects + `QWP_MAX_ZSTD_DECOMPRESSED_SIZE`, and every server-supplied length is validated before + it is allocated or copied. +- Decimal scale, geohash bits, long256 words, UUID, IPv4, binary, and array shape + validation match the documented bounds for each column and bind type. +- Server-supplied text decodes as fatal UTF-8 into a `QwpProtocolError`, never into a + silently mangled value. +- Transactions are atomic per table, not across a flush; closing publishes staged rows + without committing them. +- Durable ACK is requested through the Node upgrade header or the browser subprotocol, + and an unconfirmed capability fails with `QwpDurableAckUnavailableError`. +- Ingress splitting respects the negotiated cap, and a single row above the cap fails + with `QwpBatchTooLargeError` instead of being dropped. +- The browser entry point stays free of Node built-ins, `ws`, and `qwp-node` symbols. + +### Store-and-forward and durability + +A breach here is Critical: the contract is that a running producer neither loses data +nor hard-fails on a transient outage. + +- The steady-state replay loop does not surface transport or server errors to the + producer. Journal exhaustion and its append deadline are the errors a caller may see. +- Node foreground replay is unbounded after startup. Attempt and duration budgets apply + to `"sync"` startup and to the browser/memory policy only; a budget that latches a + running sender terminal during a long outage is a data-loss defect. +- Connectivity errors are the caller's problem only during initialization, and + `lazy_connect` decides who sees them. Left off, the default, the initial connect must + surface DNS, connection-refused, TLS, authentication and upgrade-timeout failures to + the caller. Set on, `connectQwpNodeClient()` must resolve with no server present: the + sender buffers immediately, ingress uses `initial_connect_retry=async`, and egress + defers to the first query on `query_pool_min=0`. An explicit + `initial_connect_retry=off|sync`, or a positive `query_pool_min`, conflicts with it + and is rejected before the client is created. Past initialization both modes revert + to the steady-state contract above, whichever one started the client. +- Backoff is exponential with full jitter and a capped per-attempt delay, while the + store-and-forward retry loop itself stays uncapped. +- NACK policy follows `qwpDefaultSenderErrorPolicy`: `WRITE_ERROR`, `INTERNAL_ERROR`, + `DICTIONARY_GAP`, and an unknown status retry from `ackedFsn + 1`; `NOT_WRITABLE` + retries elsewhere; only rejections that are deterministic under byte-identical replay + go terminal. An unrecognized status byte fails open to retry, never closed. +- The ack watermark never advances past a NACKed or unacknowledged frame, and abandoned + bytes are quarantined and reported through `QwpSenderError` rather than dropped. +- Repeated rejection escalates through the poison-frame detector, honoring + `maxFrameRejections` and `poisonMinEscalationWindowMs`. Normal and going-away closes, + `NOT_WRITABLE`, and dictionary catch-up must not consume strikes, and a transient + class must not consume a capability-gap episode budget. +- Orphan-drainer terminals are the ones that are terminal by design — authentication, + protocol, poison frame, and an exhausted capability-gap episode — and they quarantine + the slot behind its `.failed` sentinel for an operator. Any other terminal is a + finding. +- Segment magic, format version, and checkpoint invariants hold; a torn, truncated, or + foreign-version segment is quarantined instead of replayed. +- Advisory locking is fail-closed: a directory owned by another process yields + `QwpReplayStoreLockedError`, a release that cannot be proved stays on the retry list, + and the maintenance worker is stopped on every exit path. +- UDP ingress is fire-and-forget by contract — no acknowledgement, no replay, no + durability claim. Review it for datagram sizing and socket cleanup, not against the + guarantees above. + +### Async, concurrency, and resources + +- Await every Promise; preserve send order and error propagation. +- Understand that compaction precedes the awaited send and auto-flush is lazy. +- Do not invite concurrent mutation or share a Sender across workers. +- Close owned sockets/pools/agents/timers/listeners on every path; preserve user-owned + agents; do not retain stale buffer views. +- Close QWP sockets, keepalive and ACK timers, reconnect timers, the maintenance worker + thread, and advisory locks on every path, including a failed upgrade, an aborted + replay, and a quarantined slot. + +### Performance + +- Avoid per-row/per-cell allocations, repeated `toString`/`Buffer.byteLength`, string + concatenation, and avoidable conversions/scans. +- Avoid per-character writes where safe bulk copying exists, resize thrashing, needless + buffer copies, and O(n²) work over rows/cells/array elements. +- State the data multiplier for hot-path findings; bounded setup costs are Moderate at + most unless they create an outage or compatibility failure. + +### Public API and code quality + +- Export new public symbols; treat removals/renames/signature/default changes as + compatibility changes. +- Only the two package roots are public. Paths containing `internal`, `qwp-node`, + `client-core`, or `src` are implementation details even when a bundler resolves + them. +- A changed exported QWP symbol, option, constant, or error updates + `test/qwp/public-api-contract.ts` and `QWP.md`. +- Keep TSDoc/types accurate and avoid casts that hide runtime null/type problems. +- Wire renamed options through parsing, validation, `resolveDeprecated`, `resolveAuto`, + `fromConfig`, and `fromEnv` as applicable, and through the QWP config parser for QWP + keys. +- Update README/examples for user-visible behavior. +- Keep ESLint/Prettier clean; remove dead code/imports; follow local naming/order. + +### Tests + +- Cover each changed protocol/transport/auth/TLS/configuration path that behaves + differently, plus error, nullish, boundary, resize, retry, and cleanup paths. +- Use byte-level assertions for serializer changes and transport-level assertions for + network/auth changes. +- Recompute expected hex/bytes and ensure assertions can fail and reach production code. +- QWP changes need frame-level assertions, and behavior that depends on it needs a + reconnect, replay, restart, or lock-contention test. Reuse the fake sockets, fixtures, + and interop helpers already in `test/qwp/`. +- A bug fix needs a regression test that fails without the fix unless the Step 2.6 + proportionality analysis admits a non-Critical gap. +- Prefer existing helpers and deterministic synchronization; avoid brittle timing, + debug residue, misleading names, and implementation-only assertions. + +### TODOs and commit messages + +- Scan added/changed lines for `TODO`, `FIXME`, `HACK`, `XXX`, and `WORKAROUND`. + Distinguish moved comments from newly deferred work and verify referenced issues. +- Check Conventional Commit subjects against `CONTRIBUTING.md`; descriptions should + state user impact where relevant. + +## Step 4: Output + +Present only **ADMITTED** findings. Omitted hypotheses, disproofs, retractions, agent +counts, candidate counts, and the private ledger never appear. Do not publish a concern +and retract it later. Keep the report actionable; if a normal PR produces more than +about seven findings, rerun admission and remove dependent, duplicate, not-attributed, +or low-value items. + +Every Critical and Moderate finding begins with three lines written from the completed +admission form: + +- **Problem:** what is wrong, at most 12 words. +- **Net impact:** supported population and magnitude, at most 12 words. +- **Evidence:** decisive artifact/static proof and reviewed revision identity. + +Then provide only the minimal producer → path → symptom trace, base comparison, exact +file/line, in-diff versus out-of-diff-breakage classification, and suggested fix. + +### Severity classification + +Severity is determined by reachable user consequence, not checklist category. + +**Critical** requires a supported trigger and one of: + +- Wrong/missing/duplicated/corrupted data or ILP/QWP wire bytes. +- Abandoned, silently dropped, or unreplayable store-and-forward data, or an ack + watermark advanced past an unacknowledged frame. +- Crash, hang, outage, unbounded loop, OOM, or unbounded socket/timer/listener leak. +- A steady-state replay loop that ends, or surfaces a transient transport failure to the + producer, instead of retrying. +- Credential exposure, auth/TLS bypass, or another security failure. +- Silent/misleading failure that makes ingestion appear successful or undiagnosable. +- Public API, config, runtime, module-system, protocol, or rolling-version compatibility + break affecting existing supported consumers. +- User-observable throughput/latency/network regression multiplied per row/cell/request. +- An admitted Critical coverage gap meeting Step 2.6's full reachability/impact burden. + +Every behavioral Critical must complete: “user does X → sees Y,” with an executed +same-trigger base comparison. A performance Critical states the multiplier. A theory +without a supported trigger is omitted, not preserved as Moderate. + +**Moderate** covers admitted attributable issues with bounded/developer-facing impact: +proved weak tests, missing internal-path coverage, documentation defects, concrete +standards violations, or bounded setup/configuration costs. Dynamic speculation and +unchanged hardening opportunities are omitted. + +**Minor** covers concrete cosmetics on changed lines: naming, ordering, formatting, +or comment wording. + +Exclude merge mechanics, tautologies true of every similar PR, deliberate project +decisions without evidence they are wrong, generated artifacts as source, and all +contents behind `OPAQUE` submodule gitlink bumps. + +### Critical + +List blocking admitted issues in descending user impact. Include the three summary +lines, population/base delta/magnitude/offsets/net-negative determination, exact file +and lines, supported trigger and symptom, executed artifacts, classification, contract +and caller for out-of-diff breakage, and a fix scoped to this PR. + +### Moderate + +List non-blocking admitted issues with the three summary lines and decisive evidence. + +### Minor + +List optional, concrete cosmetics. Omit the section when empty. + +### Adjacent findings (not blocking — file as GitHub issues) + +Include only fully proved pre-existing bugs encountered in changed files or mapped +callers that this PR does not introduce, expose, or worsen. They never affect the +verdict and are never proposed as changes to this PR. For each provide: + +- **Problem:** issue-title-length summary. +- **Net impact:** population and magnitude. +- **Location:** exact file and lines. +- **Symptom/reachability:** observed path, or named guard if latent. +- **Suggested fix:** one or two lines. +- **Standalone severity:** Critical, Moderate, or Minor. + +Offer to file them; never file without permission. + +### Coverage map + +State the test-gate result and number of admitted coverage gaps. Render admitted gap +rows with their recorded search and failure link. Do not expose covered/accepted/exempt +rows or omitted-candidate counts unless asked. + +### Summary + +Choose exactly one verdict: + +- **approve** — no open Critical findings and the test gate passes. +- **approve with comments** — both gates pass, but named Moderate items remain. +- **request changes** — at least one Critical finding is open or the test gate fails. +- **needs discussion** — product, architecture, or compatibility decision is required. + +Apply these hard gates: + +- **Correctness gate:** any admitted Critical requires `request changes`. Omitted + hypotheses never affect the verdict. +- **Test gate:** fails only for admitted Critical coverage gaps. Zero test changes or + missing regression coverage alone does not fail it. +- Before finalizing, re-audit each rendered behavioral finding for strongest disproof, + supported producer, independent falsifier context, dynamic head/base evidence, + dependency survival, net-negative user impact, and post-admission severity. +- If both gates pass, approve plainly; Moderate/Minor items do not justify withholding + approval. + +Also state: + +- Test-gate result and admitted gap count. +- Regressions or tradeoffs. +- Submodule verdicts (`path: OPAQUE — contents excluded`) or `Submodules: none`. +- Admitted split: in-diff / out-of-diff-breakage. +- Severity distribution. +- At levels 0-1, the callsite-analysis limitation rather than implying exhaustive + out-of-diff coverage. + +Do not state agent counts, candidate counts, rejected/false-positive counts, or +retraction history. From 426111a99d679e25925b87745c431dfe2553ea43 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 24 Sep 2026 23:00:44 +0100 Subject: [PATCH 03/10] test(qwp): cover measured-frame dictionary rollback --- test/qwp/core.test.ts | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/test/qwp/core.test.ts b/test/qwp/core.test.ts index bc1ca52a..fcd01cd1 100644 --- a/test/qwp/core.test.ts +++ b/test/qwp/core.test.ts @@ -935,18 +935,52 @@ describe("QWP ingress codec", () => { } }); - it("restores the dictionary when a measured frame fails to plan", () => { + it("restores newly added symbols when a measured frame fails to plan", () => { const dictionary = new QwpSymbolDictionary(); dictionary.getOrAdd("ETH-USD"); const table = new QwpTableBuffer("trades"); table.getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)!.values.push("X"); + table + .getOrCreateColumn("payload", QWP_COLUMN_TYPE.BINARY)! + .values.push("not binary"); table.nextRow(); - // An out-of-range published ID fails the plan after nothing was added; - // the dictionary must come back at its pre-measure size either way. + + // The valid published ID lets planning add "X" before sizing BINARY fails. + expect(() => + measureQwpIngressFrame([table], { dictionary, confirmedMaxSymbolId: 0 }), + ).toThrow(/Uint8Array/); + expect(dictionary.entriesFrom(0)).toEqual(["ETH-USD"]); + }); + + it("rejects published symbol IDs outside the dictionary", () => { + const dictionary = new QwpSymbolDictionary(); + dictionary.getOrAdd("ETH-USD"); + const table = new QwpTableBuffer("trades"); + table.getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)!.values.push("X"); + table.nextRow(); + expect(() => measureQwpIngressFrame([table], { dictionary, confirmedMaxSymbolId: 5 }), ).toThrow(/published symbol dictionary ID is out of range/); - expect(dictionary.size).toBe(1); + expect(dictionary.entriesFrom(0)).toEqual(["ETH-USD"]); + }); + + it("restores newly added symbols when a measured frame fails to encode", () => { + const dictionary = new QwpSymbolDictionary(); + dictionary.getOrAdd("ETH-USD"); + const table = new QwpTableBuffer("trades"); + table.getOrCreateColumn("symbol", QWP_COLUMN_TYPE.SYMBOL)!.values.push("X"); + table.getOrCreateColumn("price", QWP_COLUMN_TYPE.BYTE)!.values.push(300); + table.nextRow(); + + // Fixed-width sizing succeeds; only the write rejects the invalid BYTE. + const measured = measureQwpIngressFrame([table], { + dictionary, + confirmedMaxSymbolId: 0, + }); + expect(dictionary.entriesFrom(0)).toEqual(["ETH-USD", "X"]); + expect(() => measured.encode()).toThrow(/between -128 and 127/); + expect(dictionary.entriesFrom(0)).toEqual(["ETH-USD"]); }); it("encodes a full inline symbol dictionary with dense first-seen IDs", () => { From c6d95c04373d31b28a6d19b1f801e7d8ab48b27a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Thu, 24 Sep 2026 23:43:49 +0100 Subject: [PATCH 04/10] perf(qwp): avoid redundant scans for nullish column names --- benchmarks/sender.bench.ts | 22 +++++++++++++++++++++- packages/client-core/src/_qwp/sender.ts | 7 ++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/benchmarks/sender.bench.ts b/benchmarks/sender.bench.ts index 531343aa..a2fda152 100644 --- a/benchmarks/sender.bench.ts +++ b/benchmarks/sender.bench.ts @@ -7,10 +7,17 @@ import { type QwpIngressResponse, type QwpTableBuffer, } from "../packages/client-core/src/_qwp/_core"; -import { QwpSender, type QwpSenderSession } from "../packages/client-core/src/_qwp/sender"; +import { + QwpSender, + type QwpSenderSession, +} from "../packages/client-core/src/_qwp/sender"; import { BENCHMARK_WORKLOADS, type BenchmarkRow } from "./workloads"; const ROWS = 10_000; +const NULLISH_COLUMN_NAMES = Array.from( + { length: 20 }, + (_, index) => `optional_${index}_${"x".repeat(82)}`, +); let sink = 0; class EncodingSession implements QwpSenderSession { @@ -159,4 +166,17 @@ describe("high-level symbol dictionary modes", () => { }); }); +describe("high-level QwpSender with never-populated optional columns", () => { + bench("validate nullish names and publish", async () => { + const sender = senderFor(new EncodingSession(), "full"); + await sender.table("bench_nullish").longColumn("present", 1n).atNow(); + for (let row = 0; row < ROWS; row++) { + sender.table("bench_nullish"); + for (const name of NULLISH_COLUMN_NAMES) sender.longColumn(name, null); + await sender.atNow(); + } + await sender.flush(); + }); +}); + export const senderBenchmarkSink = (): number => sink; diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index cb70065b..7d56b9a0 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -1416,7 +1416,12 @@ export class QwpSender { if (typeof name !== "string") { throw new TypeError("column name must be a string"); } - this.validateColumnName(table, name, qwpColumnNameKey(name)); + // An omitted name never enters the schema. Look up exact keys without + // case-folding first, so an unknown nullish name is validated only once. + const known = table.schema.get(name) ?? table.publishedSchema.get(name); + if (name.length === 0 || known?.name !== name) { + validateQwpColumnName(name, this.maxNameLength); + } } catch (error) { this.failRow(error); } From 58d2ffe29421359c92b2d4879cfddaa57fb77fbe Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 25 Sep 2026 00:16:44 +0100 Subject: [PATCH 05/10] test(qwp): pin exact-spelling check for nullish column names A nullish call spelled like another column's case-folded key finds that entry by key. Cover a staged name whose key grows past the UTF-8 limit, so weakening the exact-spelling check in omitsNullish() fails the test. --- test/qwp/sender.test.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 71b1868a..d39f7270 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -710,18 +710,34 @@ describe("QWP high-level sender", () => { // column key, but only the ASCII one fits the UTF-8 byte limit. const ascii = "k".repeat(100); const kelvin = "\u212a".repeat(100); - const expectKelvinRejected = (): void => { + // The reverse: U+023A lower-cases to U+2C65, which takes one more UTF-8 + // byte, so this staged name fits the limit but its own key does not. + const grows = "\u023a".repeat(63); + const grownKey = grows.toLowerCase(); + const expectLookalikesRejected = (): void => { expect(() => sender.table("events").longColumn(kelvin, 2n)).toThrow( /column name too long/, ); expect(() => sender.table("events").longColumn(kelvin, null)).toThrow( /column name too long/, ); + // A nullish call spelled like the key finds the staged entry by that + // key, and must still be validated against its own spelling. + expect(() => sender.table("events").longColumn(grownKey, 2n)).toThrow( + /column name too long/, + ); + expect(() => sender.table("events").longColumn(grownKey, null)).toThrow( + /column name too long/, + ); }; - await sender.table("events").longColumn(ascii, 1n).at(1n); + await sender + .table("events") + .longColumn(ascii, 1n) + .longColumn(grows, 1n) + .at(1n); // Staged, not yet published. - expectKelvinRejected(); + expectLookalikesRejected(); // The designated timestamp's empty name is in the schema too; it must not // make an empty ordinary column name look validated. expect(() => sender.table("events").longColumn("", 1n)).toThrow( @@ -732,7 +748,7 @@ describe("QWP high-level sender", () => { ); await sender.flush(); // Published: the staged frame schema is gone, the published one remains. - expectKelvinRejected(); + expectLookalikesRejected(); // The exact spelling is still accepted without a hitch. await sender.table("events").longColumn(ascii, 3n).at(2n); From e9eaf767ab05e96778b4d018340da78c8d98842c Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 25 Sep 2026 00:38:50 +0100 Subject: [PATCH 06/10] test(qwp): cover the published-schema name lookup A flush keeps non-decimal columns in the staged schema, so the existing post-flush lookalike checks never reached the published-schema fallback. Add a decimal case, whose scale lock a flush drops, and correct the comments that described the lookups. --- packages/client-core/src/_qwp/sender.ts | 5 +++-- test/qwp/sender.test.ts | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index 7d56b9a0..29be3324 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -1416,8 +1416,9 @@ export class QwpSender { if (typeof name !== "string") { throw new TypeError("column name must be a string"); } - // An omitted name never enters the schema. Look up exact keys without - // case-folding first, so an unknown nullish name is validated only once. + // An omitted name never enters the schema, so skip case-folding and + // look it up by its exact spelling. An exact hit was validated when it + // was staged; any other name is validated here. const known = table.schema.get(name) ?? table.publishedSchema.get(name); if (name.length === 0 || known?.name !== name) { validateQwpColumnName(name, this.maxNameLength); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index d39f7270..81ed576d 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -747,9 +747,22 @@ describe("QWP high-level sender", () => { /column name cannot be empty/, ); await sender.flush(); - // Published: the staged frame schema is gone, the published one remains. + // Published. A flush keeps non-decimal columns in the staged schema, so + // these lookups still resolve there. expectLookalikesRejected(); + // A flush drops a decimal column's frame-local scale lock, leaving the + // column only in the published schema. A lookalike must not be accepted + // through that entry either. + await sender.table("prices").decimalColumn(ascii, 1n, 2).at(1n); + await sender.flush(); + expect(() => sender.table("prices").decimalColumn(kelvin, 2n, 2)).toThrow( + /column name too long/, + ); + await sender.table("prices").decimalColumn(ascii, 3n, 2).at(2n); + await sender.flush(); + expect(column(session.sends.at(-1)!.tables[0], ascii).values).toEqual([3n]); + // The exact spelling is still accepted without a hitch. await sender.table("events").longColumn(ascii, 3n).at(2n); await sender.flush(); From 717638266e93103d536ba26f78ad2e0c36fea978 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 25 Sep 2026 12:07:09 +0100 Subject: [PATCH 07/10] test(qwp): pin auto-flush interval in writer stream test The writer auto-flush test relied on the 100 ms interval default never firing, so a stall on a slow runner could add a time-based flush and change the asserted batch sizes. Disable the interval as the other auto-flush tests do. --- test/qwp/sender.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 81ed576d..2891ccd3 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -804,7 +804,11 @@ describe("QWP high-level sender", () => { it("auto-flushes writer row streams and rejects bad writer rows", async () => { const session = new RecordingSession(); - const sender = new QwpSender(async () => session, { autoFlushRows: 2 }); + // Pin the interval off so a slow runner cannot add a time-based flush. + const sender = new QwpSender(async () => session, { + autoFlushRows: 2, + autoFlushIntervalMs: 0, + }); const writer = sender.writer("events", { value: long() }); let pending: Promise | undefined; From b41ea033ad1d80a9e235021cc96716db207b2dda Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 25 Sep 2026 12:59:44 +0100 Subject: [PATCH 08/10] fix(qwp): reject over-long column names before case-folding addColumn() built the case-folded column key before validating the name, so rejecting a multi-megabyte invalid name cost heap in proportion to its length (about 300 MB for a 10M-character name). Names longer than maxNameLength are now rejected first, with the same error as before. Also covers writer.row() rejecting with the auto-flush it starts, which no test exercised. --- packages/client-core/src/_qwp/sender.ts | 8 ++++ test/qwp/sender.test.ts | 59 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/client-core/src/_qwp/sender.ts b/packages/client-core/src/_qwp/sender.ts index 29be3324..5145355d 100644 --- a/packages/client-core/src/_qwp/sender.ts +++ b/packages/client-core/src/_qwp/sender.ts @@ -2643,6 +2643,14 @@ export class QwpSender { if (typeof name !== "string") { throw new TypeError("column name must be a string"); } + // Refuse an over-long name before case-folding it: the key is rebuilt + // over the whole name, so an invalid multi-megabyte name would otherwise + // cost heap in proportion to its length just to be rejected. UTF-16 + // length never exceeds UTF-8 byte length, so validation rejects every + // name this catches, with the same error. + if (name.length > this.maxNameLength) { + validateQwpColumnName(name, this.maxNameLength); + } const nameKey = qwpColumnNameKey(name); if (!designatedTimestamp) { this.validateColumnName(table, name, nameKey); diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index 2891ccd3..edf10113 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -830,6 +830,65 @@ describe("QWP high-level sender", () => { await sender.close(); }); + it("rejects writer.row() with the auto-flush it starts", async () => { + class FailingSession extends RecordingSession { + fail = true; + // flush() publishes locally by default, so fail the publication. + override async publishTables( + tables: readonly QwpTableBuffer[], + options?: QwpIngressEncodeOptions, + ): Promise { + if (this.fail) throw new Error("auto-flush send failed"); + return super.publishTables(tables, options); + } + } + const session = new FailingSession(); + const sender = new QwpSender(async () => session, { + autoFlushRows: 1, + autoFlushIntervalMs: 0, + }); + const writer = sender.writer("events", { value: long() }); + + // row() must not settle before the flush it started, nor hide its failure. + await expect(writer.row({ value: 1n })).rejects.toThrow( + /auto-flush send failed/, + ); + session.fail = false; + await sender.flush(); + expect( + session.sends.flatMap((send) => column(send.tables[0], "value").values), + ).toEqual([1n]); + await sender.close(); + }); + + it("rejects an over-long column name without case-folding it", async () => { + const sender = new QwpSender(async () => new RecordingSession(), { + autoFlush: false, + }); + // An upper-case first letter sends qwpColumnNameKey() down its per-code-unit + // toLowerCase() path, which would copy the whole name before validation. + const name = "A".repeat(200); + let rejection: unknown; + let caseFolds = -1; + // Count only the sender call: expect() matchers may lower-case strings, + // and mockRestore() clears the recorded calls. + const toLowerCase = vi.spyOn(String.prototype, "toLowerCase"); + try { + sender.table("events").longColumn(name, 1n); + } catch (error) { + rejection = error; + } finally { + caseFolds = toLowerCase.mock.calls.length; + toLowerCase.mockRestore(); + } + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toMatch( + /column name too long.*maxLength=127/, + ); + expect(caseFolds).toBe(0); + await sender.close(); + }); + it("returns a publication sequence and waits for its ACK independently", async () => { const session = new WatermarkSession(); const sender = new QwpSender(async () => session, { From 5040c4e0ff0ed2236d501cf28dabd3b1cb142d8a Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 25 Sep 2026 13:02:07 +0100 Subject: [PATCH 09/10] docs(agents): do not watch CI after pushing unless asked --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 252d5c06..0a20f3a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,3 +102,7 @@ Integration tests use TestContainers to spin up QuestDB instances for realistic - Each worker thread needs its own Sender instance (buffers cannot be shared) - Protocol version 2 or higher is recommended for new implementations; v2 adds array columns and v3 adds DECIMAL - Run `pnpm test:dist` after package-boundary changes; it checks both npm tarballs, ESM/CJS loading, browser bundling, and the absence of Node modules from the browser artifact. + +## Workflow + +- Do not watch or poll CI after pushing unless explicitly asked. From 94f53bf6a48a4da7bf2103c3f23d59a02ef10ab9 Mon Sep 17 00:00:00 2001 From: glasstiger Date: Fri, 25 Sep 2026 13:33:16 +0100 Subject: [PATCH 10/10] test(qwp): assert rejection messages for at and atNow --- test/qwp/sender.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/qwp/sender.test.ts b/test/qwp/sender.test.ts index edf10113..034ff190 100644 --- a/test/qwp/sender.test.ts +++ b/test/qwp/sender.test.ts @@ -778,7 +778,7 @@ describe("QWP high-level sender", () => { expect(() => { pending = sender.table("events").longColumn("value", 1n).at(1.5); }).not.toThrow(); - await expect(pending).rejects.toThrow(); + await expect(pending).rejects.toThrow(/safe integer/); // The failed row was discarded along with its table selection. await sender.table("events").longColumn("value", 2n).at(1n); @@ -795,11 +795,11 @@ describe("QWP high-level sender", () => { expect(() => { pending = sender.at(1n); }).not.toThrow(); - await expect(pending).rejects.toThrow(); + await expect(pending).rejects.toThrow(/closed/); expect(() => { pending = sender.atNow(); }).not.toThrow(); - await expect(pending).rejects.toThrow(); + await expect(pending).rejects.toThrow(/closed/); }); it("auto-flushes writer row streams and rejects bad writer rows", async () => {